secubox-openwrt/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl
CyberMind-FR ab34719f9f feat(secubox-core): Add secubox-landing page generator
- Add secubox-landing script to generate landing pages from HAProxy vhosts
- Integrate landing command into secubox CLI
- Add boot hook to regenerate landing pages on startup
- Fix HAProxy multi-cert SNI using crt-list instead of directory mode
- Fix backend IPs from 127.0.0.1 to 192.168.255.1 for LXC compatibility
- Auto-convert localhost IPs in RPCD handler and CLI tools

Landing page features:
- Groups all services by zone with stats header
- Shows SSL certificate status per domain
- Categorizes by type: Streamlit, Blog, Admin, Media, Dev, etc.
- Regenerates at boot (30s after startup)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 06:15:37 +01:00

1804 lines
51 KiB
Bash

#!/bin/sh
# SecuBox HAProxy Controller
# Copyright (C) 2025 CyberMind.fr
# Source OpenWrt functions for UCI iteration
. /lib/functions.sh
CONFIG="haproxy"
LXC_NAME="haproxy"
# Paths
LXC_PATH="/srv/lxc"
LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs"
LXC_CONFIG="$LXC_PATH/$LXC_NAME/config"
DATA_PATH="/srv/haproxy"
SHARE_PATH="/usr/share/haproxy"
CERTS_PATH="$DATA_PATH/certs"
CONFIG_PATH="$DATA_PATH/config"
# Logging
log_info() { echo "[INFO] $*"; logger -t haproxy "$*"; }
log_warn() { echo "[WARN] $*" >&2; logger -t haproxy -p warning "$*"; }
log_error() { echo "[ERROR] $*" >&2; logger -t haproxy -p err "$*"; }
log_debug() { [ "$DEBUG" = "1" ] && echo "[DEBUG] $*"; }
# Helpers
require_root() {
[ "$(id -u)" -eq 0 ] || { log_error "Root required"; exit 1; }
}
has_lxc() { command -v lxc-start >/dev/null 2>&1; }
ensure_dir() { [ -d "$1" ] || mkdir -p "$1"; }
uci_get() { uci -q get ${CONFIG}.$1; }
uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; }
# Firewall management - auto-open ports when publishing
firewall_ensure_haproxy_rules() {
local http_port="${1:-80}"
local https_port="${2:-443}"
local changed=0
# Check if HTTP rule exists
local http_rule_exists=0
local i=0
while uci -q get firewall.@rule[$i] >/dev/null 2>&1; do
local name=$(uci -q get firewall.@rule[$i].name)
if [ "$name" = "HAProxy-HTTP" ]; then
http_rule_exists=1
break
fi
i=$((i + 1))
done
# Create HTTP rule if missing
if [ "$http_rule_exists" = "0" ]; then
log_info "Creating firewall rule for HTTP (port $http_port)..."
uci add firewall rule
uci set firewall.@rule[-1].name='HAProxy-HTTP'
uci set firewall.@rule[-1].src='wan'
uci set firewall.@rule[-1].dest_port="$http_port"
uci set firewall.@rule[-1].proto='tcp'
uci set firewall.@rule[-1].target='ACCEPT'
uci set firewall.@rule[-1].enabled='1'
changed=1
fi
# Check if HTTPS rule exists
local https_rule_exists=0
i=0
while uci -q get firewall.@rule[$i] >/dev/null 2>&1; do
local name=$(uci -q get firewall.@rule[$i].name)
if [ "$name" = "HAProxy-HTTPS" ]; then
https_rule_exists=1
break
fi
i=$((i + 1))
done
# Create HTTPS rule if missing
if [ "$https_rule_exists" = "0" ]; then
log_info "Creating firewall rule for HTTPS (port $https_port)..."
uci add firewall rule
uci set firewall.@rule[-1].name='HAProxy-HTTPS'
uci set firewall.@rule[-1].src='wan'
uci set firewall.@rule[-1].dest_port="$https_port"
uci set firewall.@rule[-1].proto='tcp'
uci set firewall.@rule[-1].target='ACCEPT'
uci set firewall.@rule[-1].enabled='1'
changed=1
fi
# Apply changes
if [ "$changed" = "1" ]; then
uci commit firewall
/etc/init.d/firewall reload 2>/dev/null || true
log_info "Firewall rules updated - ports $http_port and $https_port open on WAN"
fi
}
firewall_check_haproxy_rules() {
local http_ok=0 https_ok=0
local i=0
while uci -q get firewall.@rule[$i] >/dev/null 2>&1; do
local name=$(uci -q get firewall.@rule[$i].name)
local enabled=$(uci -q get firewall.@rule[$i].enabled)
[ "$name" = "HAProxy-HTTP" ] && [ "$enabled" != "0" ] && http_ok=1
[ "$name" = "HAProxy-HTTPS" ] && [ "$enabled" != "0" ] && https_ok=1
i=$((i + 1))
done
[ "$http_ok" = "1" ] && [ "$https_ok" = "1" ]
}
# Load configuration
load_config() {
http_port="$(uci_get main.http_port)" || http_port="80"
https_port="$(uci_get main.https_port)" || https_port="443"
stats_port="$(uci_get main.stats_port)" || stats_port="8404"
stats_enabled="$(uci_get main.stats_enabled)" || stats_enabled="1"
stats_user="$(uci_get main.stats_user)" || stats_user="admin"
stats_password="$(uci_get main.stats_password)" || stats_password="secubox"
data_path="$(uci_get main.data_path)" || data_path="$DATA_PATH"
memory_limit="$(uci_get main.memory_limit)" || memory_limit="256M"
maxconn="$(uci_get main.maxconn)" || maxconn="4096"
log_level="$(uci_get main.log_level)" || log_level="warning"
default_backend="$(uci_get main.default_backend)" || default_backend="default_luci"
CERTS_PATH="$data_path/certs"
CONFIG_PATH="$data_path/config"
ensure_dir "$data_path"
ensure_dir "$CERTS_PATH"
ensure_dir "$CONFIG_PATH"
}
# Usage
usage() {
cat <<EOF
SecuBox HAProxy Controller
Usage: $(basename $0) <command> [options]
Container Commands:
install Setup HAProxy LXC container
uninstall Remove container (keeps config)
update Update HAProxy in container
status Show service status
Configuration:
generate Generate haproxy.cfg from UCI
validate Validate configuration
reload Reload HAProxy config (no downtime)
Virtual Hosts:
vhost list List all virtual hosts
vhost add <domain> Add virtual host
vhost remove <domain> Remove virtual host
vhost sync Sync vhosts to config
Backends:
backend list List all backends
backend add <name> Add backend
backend remove <name> Remove backend
Servers:
server list <backend> List servers in backend
server add <backend> <addr:port> Add server to backend
server remove <backend> <name> Remove server
Certificates:
cert list List certificates
cert add <domain> Request ACME certificate
cert import <domain> <cert> <key> Import certificate
cert renew [domain] Renew certificate(s)
cert remove <domain> Remove certificate
Service Commands:
service-run Run in foreground (for init)
service-stop Stop service
Stats:
stats Show HAProxy stats
connections Show active connections
EOF
}
# ===========================================
# LXC Container Management
# ===========================================
lxc_running() {
lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"
}
lxc_exists() {
[ -f "$LXC_CONFIG" ] && [ -d "$LXC_ROOTFS" ]
}
lxc_stop() {
if lxc_running; then
log_info "Stopping HAProxy container..."
lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true
sleep 2
fi
}
lxc_create_rootfs() {
log_info "Creating Alpine rootfs for HAProxy..."
ensure_dir "$LXC_PATH/$LXC_NAME"
local arch="x86_64"
case "$(uname -m)" in
aarch64) arch="aarch64" ;;
armv7l) arch="armv7" ;;
esac
local alpine_url="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/$arch/alpine-minirootfs-3.21.2-$arch.tar.gz"
local rootfs_tar="/tmp/alpine-haproxy.tar.gz"
log_info "Downloading Alpine rootfs..."
wget -q -O "$rootfs_tar" "$alpine_url" || {
log_error "Failed to download Alpine rootfs"
return 1
}
log_info "Extracting rootfs..."
ensure_dir "$LXC_ROOTFS"
tar -xzf "$rootfs_tar" -C "$LXC_ROOTFS" || {
log_error "Failed to extract rootfs"
return 1
}
rm -f "$rootfs_tar"
# Configure Alpine
cat > "$LXC_ROOTFS/etc/resolv.conf" << 'EOF'
nameserver 1.1.1.1
nameserver 8.8.8.8
EOF
cat > "$LXC_ROOTFS/etc/apk/repositories" << 'EOF'
https://dl-cdn.alpinelinux.org/alpine/v3.21/main
https://dl-cdn.alpinelinux.org/alpine/v3.21/community
EOF
# Install HAProxy
log_info "Installing HAProxy..."
chroot "$LXC_ROOTFS" /bin/sh -c "
apk update
apk add --no-cache haproxy openssl curl socat lua5.4 lua5.4-socket
" || {
log_error "Failed to install HAProxy"
return 1
}
log_info "Rootfs created successfully"
}
lxc_create_config() {
load_config
local arch="x86_64"
case "$(uname -m)" in
aarch64) arch="aarch64" ;;
armv7l) arch="armhf" ;;
esac
local mem_bytes=$(echo "$memory_limit" | sed 's/M/000000/;s/G/000000000/')
cat > "$LXC_CONFIG" << EOF
# HAProxy LXC Configuration
lxc.uts.name = $LXC_NAME
lxc.rootfs.path = dir:$LXC_ROOTFS
lxc.arch = $arch
# Network: use host network for binding ports
lxc.net.0.type = none
# Mount points - avoid cgroup:mixed which causes failures on some systems
lxc.mount.entry = $data_path opt/haproxy none bind,create=dir 0 0
# Disable seccomp for compatibility
lxc.seccomp.profile =
# TTY/PTY settings
lxc.tty.max = 0
lxc.pty.max = 256
# Auto device nodes
lxc.autodev = 1
# Environment
lxc.environment = HTTP_PORT=$http_port
lxc.environment = HTTPS_PORT=$https_port
lxc.environment = STATS_PORT=$stats_port
# Init command
lxc.init.cmd = /opt/start-haproxy.sh
EOF
log_info "LXC config created"
}
lxc_run() {
load_config
lxc_stop
if ! lxc_exists; then
log_error "Container not installed. Run: haproxyctl install"
return 1
fi
lxc_create_config
# Ensure start script exists
local start_script="$LXC_ROOTFS/opt/start-haproxy.sh"
cat > "$start_script" << 'STARTEOF'
#!/bin/sh
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CONFIG_FILE="/opt/haproxy/config/haproxy.cfg"
LOG_FILE="/opt/haproxy/startup.log"
# Log all output
exec >>"$LOG_FILE" 2>&1
echo "=== HAProxy startup: $(date) ==="
echo "Config: $CONFIG_FILE"
ls -la /opt/haproxy/
ls -la /opt/haproxy/certs/ 2>/dev/null || echo "No certs dir"
# Clean up legacy certificate files - only .pem files should exist
# HAProxy loads all files from certs directory, and extra files cause errors
if [ -d "/opt/haproxy/certs" ]; then
for pem in /opt/haproxy/certs/*.pem; do
[ -f "$pem" ] || continue
base="${pem%.pem}"
# Remove any associated .crt, .key, .fullchain.pem, .crt.key files
rm -f "${base}.crt" "${base}.key" "${base}.crt.key" "${base}.fullchain.pem" 2>/dev/null
done
fi
# Wait for config
if [ ! -f "$CONFIG_FILE" ]; then
echo "[haproxy] Config not found, generating default..."
mkdir -p /opt/haproxy/config
cat > "$CONFIG_FILE" << 'CFGEOF'
global
log stdout format raw local0
maxconn 4096
stats socket /var/run/haproxy.sock mode 660 level admin expose-fd listeners
stats timeout 30s
defaults
mode http
log global
option httplog
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
frontend stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
stats admin if TRUE
frontend http-in
bind *:80
mode http
default_backend default_luci
backend default_luci
mode http
balance roundrobin
server luci 192.168.255.1:8081 check
CFGEOF
fi
# Validate config first
echo "[haproxy] Validating config..."
haproxy -c -f "$CONFIG_FILE"
RC=$?
echo "[haproxy] Validation exit code: $RC"
if [ $RC -ne 0 ]; then
echo "[haproxy] Config validation failed!"
exit 1
fi
echo "[haproxy] Starting HAProxy..."
exec haproxy -f "$CONFIG_FILE" -W -db
STARTEOF
chmod +x "$start_script"
# Generate config before starting
generate_config
log_info "Starting HAProxy container..."
exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG"
}
lxc_exec() {
if ! lxc_running; then
log_error "Container not running"
return 1
fi
lxc-attach -n "$LXC_NAME" -- "$@"
}
# ===========================================
# Certificate List Generation (for multi-cert SNI)
# ===========================================
# Generate crt-list file for multi-certificate SNI support
# This is more reliable than directory mode for multi-domain setups
generate_certs_list() {
local certs_list="$CERTS_PATH/certs.list"
local container_certs_path="/opt/haproxy/certs"
log_info "Generating certificate list file..."
# Start fresh
> "$certs_list"
# Process each .pem file
for cert_file in "$CERTS_PATH"/*.pem; do
[ -f "$cert_file" ] || continue
local filename=$(basename "$cert_file")
local container_cert_path="$container_certs_path/$filename"
# Extract domain names from certificate (CN and SANs)
local domains=$(openssl x509 -in "$cert_file" -noout -text 2>/dev/null | \
grep -A1 "Subject Alternative Name" | \
tail -n1 | \
sed 's/DNS://g' | \
tr ',' '\n' | \
tr -d ' ')
# If no SANs, try to get CN
if [ -z "$domains" ]; then
domains=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | \
sed -n 's/.*CN *= *\([^,/]*\).*/\1/p')
fi
if [ -n "$domains" ]; then
# For each domain, add an entry with SNI filter
# Format: /path/to/cert.pem [sni_filter]
echo "$domains" | while read -r domain; do
[ -n "$domain" ] || continue
# Handle wildcard certificates - the SNI filter should match the pattern
echo "$container_cert_path $domain" >> "$certs_list"
done
else
# Fallback: add cert without SNI filter (will be default)
log_warn "No domain found in certificate: $filename"
echo "$container_cert_path" >> "$certs_list"
fi
done
# Deduplicate entries (same cert may have multiple SANs)
if [ -f "$certs_list" ]; then
sort -u "$certs_list" > "$certs_list.tmp"
mv "$certs_list.tmp" "$certs_list"
local count=$(wc -l < "$certs_list")
log_info "Generated certs.list with $count entries"
else
log_warn "No certificates found in $CERTS_PATH"
fi
}
# ===========================================
# Configuration Generation
# ===========================================
generate_config() {
load_config
local cfg_file="$CONFIG_PATH/haproxy.cfg"
log_info "Generating HAProxy configuration..."
# Generate certs.list for multi-certificate SNI support
generate_certs_list
# Global section
cat > "$cfg_file" << EOF
# HAProxy Configuration - Generated by SecuBox
# DO NOT EDIT - Use UCI configuration
global
log stdout format raw local0 $log_level
maxconn $maxconn
stats socket /var/run/haproxy.sock mode 660 level admin expose-fd listeners
stats timeout 30s
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
tune.ssl.default-dh-param 2048
EOF
# Defaults section
local mode=$(uci_get defaults.mode) || mode="http"
local timeout_connect=$(uci_get defaults.timeout_connect) || timeout_connect="5s"
local timeout_client=$(uci_get defaults.timeout_client) || timeout_client="30s"
local timeout_server=$(uci_get defaults.timeout_server) || timeout_server="30s"
cat >> "$cfg_file" << EOF
defaults
mode $mode
log global
option httplog
option dontlognull
option forwardfor
timeout connect $timeout_connect
timeout client $timeout_client
timeout server $timeout_server
timeout http-request 10s
timeout http-keep-alive 10s
retries 3
EOF
# Stats frontend
if [ "$stats_enabled" = "1" ]; then
cat >> "$cfg_file" << EOF
frontend stats
bind *:$stats_port
mode http
stats enable
stats uri /stats
stats refresh 10s
stats auth $stats_user:$stats_password
stats admin if TRUE
EOF
fi
# Generate frontends from UCI
_generate_frontends >> "$cfg_file"
# Generate backends from UCI
_generate_backends >> "$cfg_file"
log_info "Configuration generated: $cfg_file"
}
_generate_frontends() {
# HTTP Frontend
cat << EOF
frontend http-in
bind *:$http_port,[::]:$http_port
mode http
# ACME challenge routing (no HAProxy restart needed for cert issuance)
acl is_acme_challenge path_beg /.well-known/acme-challenge/
use_backend acme_challenge if is_acme_challenge
EOF
# Add HTTPS redirect rules for vhosts with ssl_redirect
config_load haproxy
config_foreach _add_ssl_redirect vhost
# Add vhost ACLs for HTTP
config_foreach _add_vhost_acl vhost "http"
echo " default_backend $default_backend"
echo ""
# HTTPS Frontend (if certificates exist)
# Use container path /opt/haproxy/certs/ (not host path)
local CONTAINER_CERTS_PATH="/opt/haproxy/certs"
local CERTS_LIST_FILE="$CERTS_PATH/certs.list"
if [ -d "$CERTS_PATH" ] && ls "$CERTS_PATH"/*.pem >/dev/null 2>&1; then
# Use crt-list for proper multi-certificate SNI matching
# Falls back to directory mode if certs.list doesn't exist
if [ -f "$CERTS_LIST_FILE" ] && [ -s "$CERTS_LIST_FILE" ]; then
cat << EOF
frontend https-in
bind *:$https_port,[::]:$https_port ssl crt-list $CONTAINER_CERTS_PATH/certs.list alpn h2,http/1.1
mode http
http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
EOF
else
# Fallback to directory mode if no certs.list
cat << EOF
frontend https-in
bind *:$https_port,[::]:$https_port ssl crt $CONTAINER_CERTS_PATH/ alpn h2,http/1.1
mode http
http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
EOF
fi
# Add vhost ACLs for HTTPS
config_foreach _add_vhost_acl vhost "https"
echo " default_backend $default_backend"
echo ""
fi
}
_add_ssl_redirect() {
local section="$1"
local enabled domain ssl_redirect
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
config_get domain "$section" domain
config_get ssl_redirect "$section" ssl_redirect "0"
[ -n "$domain" ] || return
[ "$ssl_redirect" = "1" ] || return
local acl_name=$(echo "$domain" | tr '.' '_' | tr '-' '_')
echo " acl host_${acl_name} hdr(host) -i $domain"
echo " http-request redirect scheme https code 301 if host_${acl_name} !{ ssl_fc } !is_acme_challenge"
}
_add_vhost_acl() {
local section="$1"
local proto="$2"
local enabled domain backend ssl
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
config_get domain "$section" domain
config_get backend "$section" backend
# Validate backend is not IP:port (common misconfiguration)
case "$backend" in
*:*) log_warn "Vhost $section has IP:port backend , should be backend name"; return ;;
esac
config_get ssl "$section" ssl "0"
[ -n "$domain" ] || return
[ -n "$backend" ] || return
# For HTTP frontend, skip SSL-only vhosts
[ "$proto" = "http" ] && [ "$ssl" = "1" ] && return
local acl_name=$(echo "$domain" | tr '.' '_' | tr '-' '_')
echo " acl host_${acl_name} hdr(host) -i $domain"
echo " use_backend $backend if host_${acl_name}"
}
_generate_backends() {
config_load haproxy
# ACME challenge backend (for certificate issuance without HAProxy restart)
# Serves /.well-known/acme-challenge/ from /var/www/acme-challenge/
cat << EOF
backend acme_challenge
mode http
server acme_webroot 192.168.255.1:8402 check
EOF
# Track which backends are generated
_generated_backends=""
# Generate each backend from UCI
config_foreach _generate_backend backend
# Collect all backends referenced by vhosts
_referenced_backends=""
_collect_vhost_backend() {
local section="$1"
local enabled backend
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
config_get backend "$section" backend
[ -n "$backend" ] && _referenced_backends="$_referenced_backends $backend"
}
config_foreach _collect_vhost_backend vhost
# Add default_backend to referenced list
_referenced_backends="$_referenced_backends $default_backend"
# Generate fallback backends for any referenced but not generated
# These common backends route to uhttpd on the host
for backend_name in default_luci luci apps; do
# Check if this backend is referenced
echo "$_referenced_backends" | grep -qw "$backend_name" || continue
# Check if already generated
echo "$_generated_backends" | grep -qw "$backend_name" && continue
# Generate fallback
cat << EOF
backend $backend_name
mode http
balance roundrobin
server $backend_name 192.168.255.1:8081 check
EOF
done
}
_generate_backend() {
local section="$1"
local enabled name mode balance health_check health_check_uri http_request
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
config_get name "$section" name "$section"
config_get mode "$section" mode "http"
config_get balance "$section" balance "roundrobin"
config_get health_check "$section" health_check ""
config_get health_check_uri "$section" health_check_uri ""
# Track generated backend
_generated_backends="$_generated_backends $name"
echo ""
echo "backend $name"
echo " mode $mode"
# Check for http-request directives
# Support both single value and list
local http_request_val=""
config_get http_request_val "$section" http_request ""
if [ -n "$http_request_val" ]; then
# Single http-request option set
echo " http-request $http_request_val"
# If it's a "return" directive, this is a static backend - skip servers
case "$http_request_val" in
return*) return ;;
esac
fi
# Also check for list values (http_request as list)
local has_http_request_return=0
_emit_and_check_http_request() {
local val="$1"
echo " http-request $val"
case "$val" in
return*) has_http_request_return=1 ;;
esac
}
config_list_foreach "$section" http_request _emit_and_check_http_request
# If any http-request was a "return" directive, skip servers
[ "$has_http_request_return" = "1" ] && return
echo " balance $balance"
# Health check configuration
if [ -n "$health_check" ]; then
echo " option $health_check"
# If health_check_uri specified, use HTTP check with GET method
if [ -n "$health_check_uri" ]; then
echo " http-check send meth GET uri $health_check_uri"
echo " http-check expect status 200"
fi
fi
# Check if there are separate server sections for this backend
local has_server_sections=0
_check_server_sections() {
local srv_section="$1"
local srv_backend
config_get srv_backend "$srv_section" backend
config_get srv_enabled "$srv_section" enabled "0"
if [ "$srv_backend" = "$name" ] && [ "$srv_enabled" = "1" ]; then
has_server_sections=1
fi
}
config_foreach _check_server_sections server
# Add inline server ONLY if no separate server sections exist
# This prevents duplicate server names
if [ "$has_server_sections" = "0" ]; then
local server_line
config_get server_line "$section" server ""
[ -n "$server_line" ] && echo " server $server_line"
fi
# Add servers from separate server UCI sections
config_foreach _add_server_to_backend server "$name"
}
_add_server_to_backend() {
local section="$1"
local target_backend="$2"
local backend server_name address port weight check enabled
config_get backend "$section" backend
[ "$backend" = "$target_backend" ] || return
config_get enabled "$section" enabled "0"
[ "$enabled" = "1" ] || return
config_get server_name "$section" name "$section"
config_get address "$section" address
config_get port "$section" port "80"
config_get weight "$section" weight "100"
config_get check "$section" check "1"
[ -n "$address" ] || return
# Validate address - if it's a hostname (not IP), try to resolve it
if ! echo "$address" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
# It's a hostname, try to resolve it
local resolved_ip=""
resolved_ip=$(nslookup "$address" 2>/dev/null | awk '/^Address: / { print $2; exit }')
if [ -z "$resolved_ip" ]; then
# Try getent as fallback
resolved_ip=$(getent hosts "$address" 2>/dev/null | awk '{print $1; exit}')
fi
if [ -z "$resolved_ip" ]; then
log_warn "Cannot resolve hostname '$address' for server $server_name in backend $target_backend - skipping"
return
fi
# Use the resolved IP instead
log_debug "Resolved $address to $resolved_ip"
address="$resolved_ip"
fi
local check_opt=""
[ "$check" = "1" ] && check_opt="check"
echo " server $server_name $address:$port weight $weight $check_opt"
}
# ===========================================
# Certificate Management
# ===========================================
# Check if certificate is from Let's Encrypt Production (not Staging)
cert_is_production() {
local cert_file="$1"
[ -f "$cert_file" ] || return 1
# Check the issuer - staging certs have "(STAGING)" in the issuer
local issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null)
if echo "$issuer" | grep -qi "staging\|test\|fake"; then
return 1 # Staging certificate
fi
# Check for Let's Encrypt production issuers
if echo "$issuer" | grep -qiE "Let's Encrypt|R3|R10|R11|E1|E2|ISRG"; then
return 0 # Production certificate
fi
# Check if it's a self-signed or other CA
return 0 # Assume production for other CAs
}
# Validate certificate publicly using external service
cert_validate_public() {
local domain="$1"
local timeout=10
# Try to connect and verify the certificate
if command -v curl >/dev/null 2>&1; then
if curl -sS --max-time "$timeout" -o /dev/null "https://$domain" 2>/dev/null; then
return 0
fi
fi
# Fallback: use openssl s_client
if command -v openssl >/dev/null 2>&1; then
local result=$(echo | timeout "$timeout" openssl s_client -connect "$domain:443" -servername "$domain" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null)
if [ -n "$result" ]; then
return 0
fi
fi
return 1
}
# Get certificate info
cert_info() {
local cert_file="$1"
[ -f "$cert_file" ] || return 1
local subject=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | sed 's/subject=//')
local issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/issuer=//')
local not_after=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2)
local not_before=$(openssl x509 -in "$cert_file" -noout -startdate 2>/dev/null | cut -d= -f2)
echo "Subject: $subject"
echo "Issuer: $issuer"
echo "Valid From: $not_before"
echo "Valid Until: $not_after"
if cert_is_production "$cert_file"; then
echo "Type: PRODUCTION (publicly trusted)"
else
echo "Type: STAGING/TEST (NOT publicly trusted!)"
fi
}
# Verify and report certificate status
cmd_cert_verify() {
load_config
local domain="$1"
if [ -z "$domain" ]; then
echo "Usage: haproxyctl cert verify <domain>"
return 1
fi
local cert_file="$CERTS_PATH/$domain.pem"
if [ ! -f "$cert_file" ]; then
log_error "Certificate not found: $cert_file"
return 1
fi
echo "Certificate Information for $domain:"
echo "======================================"
cert_info "$cert_file"
echo ""
# Check if it's production
if ! cert_is_production "$cert_file"; then
log_warn "This is a STAGING certificate - NOT trusted by browsers!"
log_warn "To get a production certificate, ensure staging='0' in config and re-issue"
return 1
fi
# Try public validation
echo "Public Validation:"
if cert_validate_public "$domain"; then
log_info "Certificate is publicly valid and accessible"
return 0
else
log_warn "Could not verify certificate publicly"
log_warn "Ensure DNS points to this server and port 443 is accessible"
return 1
fi
}
cmd_cert_list() {
load_config
echo "Certificates in $CERTS_PATH:"
echo "----------------------------"
if [ -d "$CERTS_PATH" ]; then
for cert in "$CERTS_PATH"/*.pem; do
[ -f "$cert" ] || continue
local name=$(basename "$cert" .pem)
local expiry=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | cut -d= -f2)
local type_icon="✅"
if ! cert_is_production "$cert"; then
type_icon="⚠️ STAGING"
fi
echo " $name - Expires: ${expiry:-Unknown} $type_icon"
done
else
echo " No certificates found"
fi
# Show current mode
local staging=$(uci -q get haproxy.acme.staging)
echo ""
if [ "$staging" = "1" ]; then
echo "⚠️ ACME Mode: STAGING (certificates will NOT be trusted by browsers)"
echo " To use production: uci set haproxy.acme.staging='0' && uci commit haproxy"
else
echo "✅ ACME Mode: PRODUCTION (certificates will be publicly trusted)"
fi
}
# Check if we have local BIND for a zone
has_local_bind_zone() {
local domain="$1"
local zone_file="/etc/bind/zones/${domain}.zone"
[ -f "$zone_file" ] && command -v rndc >/dev/null 2>&1
}
# Add TXT record to local BIND zone
bind_add_txt() {
local subdomain="$1"
local zone="$2"
local value="$3"
local zone_file="/etc/bind/zones/${zone}.zone"
[ ! -f "$zone_file" ] && return 1
# Update serial (format: YYYYMMDDNN)
local current_serial=$(grep -oP '\d{10}(?=\s*;\s*Serial)' "$zone_file" 2>/dev/null)
local today=$(date +%Y%m%d)
local new_serial
if [ -n "$current_serial" ]; then
local serial_date="${current_serial:0:8}"
local serial_num="${current_serial:8:2}"
if [ "$serial_date" = "$today" ]; then
serial_num=$((10#$serial_num + 1))
new_serial=$(printf "%s%02d" "$today" "$serial_num")
else
new_serial="${today}01"
fi
sed -i "s/$current_serial/$new_serial/" "$zone_file"
fi
# Add TXT record (before the ; IPv6 comment or at end)
local record="$subdomain IN TXT \"$value\""
if grep -q "; IPv6" "$zone_file"; then
sed -i "/; IPv6/i $record" "$zone_file"
else
echo "$record" >> "$zone_file"
fi
# Reload BIND
rndc reload "$zone" 2>/dev/null || rndc reload 2>/dev/null
log_info "Added TXT record: $subdomain.$zone -> $value"
}
# Remove TXT record from local BIND zone
bind_remove_txt() {
local subdomain="$1"
local zone="$2"
local value="$3"
local zone_file="/etc/bind/zones/${zone}.zone"
[ ! -f "$zone_file" ] && return 1
# Remove the specific TXT record
sed -i "/$subdomain IN TXT \"$value\"/d" "$zone_file"
# Update serial
local current_serial=$(grep -oP '\d{10}(?=\s*;\s*Serial)' "$zone_file" 2>/dev/null)
if [ -n "$current_serial" ]; then
local today=$(date +%Y%m%d)
local serial_date="${current_serial:0:8}"
local serial_num="${current_serial:8:2}"
if [ "$serial_date" = "$today" ]; then
serial_num=$((10#$serial_num + 1))
else
serial_num=1
fi
local new_serial=$(printf "%s%02d" "$today" "$serial_num")
sed -i "s/$current_serial/$new_serial/" "$zone_file"
fi
rndc reload "$zone" 2>/dev/null || rndc reload 2>/dev/null
log_info "Removed TXT record: $subdomain.$zone"
}
# Get DNS provider configuration for DNS-01 challenge
get_dns_provider_env() {
local provider=$(uci -q get dns-provider.main.provider)
[ -z "$provider" ] && return 1
case "$provider" in
local|bind)
# Using local BIND - acme.sh will use our custom hook
echo "dns_local_bind"
;;
gandi)
local api_key=$(uci -q get dns-provider.gandi.api_key)
[ -z "$api_key" ] && return 1
export GANDI_LIVEDNS_KEY="$api_key"
echo "dns_gandi_livedns"
;;
ovh)
local app_key=$(uci -q get dns-provider.ovh.app_key)
local app_secret=$(uci -q get dns-provider.ovh.app_secret)
local consumer_key=$(uci -q get dns-provider.ovh.consumer_key)
local endpoint=$(uci -q get dns-provider.ovh.endpoint)
[ -z "$app_key" ] || [ -z "$app_secret" ] || [ -z "$consumer_key" ] && return 1
export OVH_AK="$app_key"
export OVH_AS="$app_secret"
export OVH_CK="$consumer_key"
export OVH_END="${endpoint:-ovh-eu}"
echo "dns_ovh"
;;
cloudflare)
local api_token=$(uci -q get dns-provider.cloudflare.api_token)
[ -z "$api_token" ] && return 1
export CF_Token="$api_token"
echo "dns_cloudflare"
;;
*)
return 1
;;
esac
return 0
}
# Create acme.sh DNS hook for local BIND
create_local_dns_hook() {
local hook_dir="/etc/acme/dnsapi"
ensure_dir "$hook_dir"
cat > "$hook_dir/dns_local_bind.sh" << 'HOOK'
#!/bin/sh
# acme.sh DNS hook for local BIND zones
dns_local_bind_add() {
local fulldomain="$1"
local txtvalue="$2"
# Extract zone from domain (e.g., _acme-challenge.gk2.secubox.in -> secubox.in)
local zone=$(echo "$fulldomain" | awk -F. '{print $(NF-1)"."$NF}')
local subdomain="${fulldomain%.$zone}"
local zone_file="/etc/bind/zones/${zone}.zone"
[ ! -f "$zone_file" ] && { echo "Zone file not found: $zone_file"; return 1; }
# Update serial
local current_serial=$(grep -oP '\d{10}(?=\s*;\s*Serial)' "$zone_file" 2>/dev/null)
if [ -n "$current_serial" ]; then
local today=$(date +%Y%m%d)
local serial_date="${current_serial:0:8}"
local serial_num="${current_serial:8:2}"
if [ "$serial_date" = "$today" ]; then
serial_num=$((10#$serial_num + 1))
else
serial_num=1
fi
local new_serial=$(printf "%s%02d" "$today" "$serial_num")
sed -i "s/$current_serial/$new_serial/" "$zone_file"
fi
# Add TXT record
local record="$subdomain IN TXT \"$txtvalue\""
if grep -q "; IPv6" "$zone_file"; then
sed -i "/; IPv6/i $record" "$zone_file"
else
echo "$record" >> "$zone_file"
fi
# Reload BIND (use kill -HUP since rndc may not be configured)
local bind_pid=$(pidof named 2>/dev/null)
if [ -n "$bind_pid" ]; then
kill -HUP "$bind_pid" 2>/dev/null
fi
rndc reload "$zone" 2>/dev/null || rndc reload 2>/dev/null || true
echo "Added: $fulldomain TXT $txtvalue"
# Wait for DNS propagation
sleep 5
return 0
}
dns_local_bind_rm() {
local fulldomain="$1"
local txtvalue="$2"
local zone=$(echo "$fulldomain" | awk -F. '{print $(NF-1)"."$NF}')
local subdomain="${fulldomain%.$zone}"
local zone_file="/etc/bind/zones/${zone}.zone"
[ ! -f "$zone_file" ] && return 0
# Remove the TXT record
sed -i "/$subdomain IN TXT \"$txtvalue\"/d" "$zone_file"
# Update serial
local current_serial=$(grep -oP '\d{10}(?=\s*;\s*Serial)' "$zone_file" 2>/dev/null)
if [ -n "$current_serial" ]; then
local today=$(date +%Y%m%d)
local serial_date="${current_serial:0:8}"
local serial_num="${current_serial:8:2}"
if [ "$serial_date" = "$today" ]; then
serial_num=$((10#$serial_num + 1))
else
serial_num=1
fi
local new_serial=$(printf "%s%02d" "$today" "$serial_num")
sed -i "s/$current_serial/$new_serial/" "$zone_file"
fi
# Reload BIND
local bind_pid=$(pidof named 2>/dev/null)
if [ -n "$bind_pid" ]; then
kill -HUP "$bind_pid" 2>/dev/null
fi
rndc reload "$zone" 2>/dev/null || rndc reload 2>/dev/null || true
echo "Removed: $fulldomain TXT $txtvalue"
return 0
}
HOOK
chmod +x "$hook_dir/dns_local_bind.sh"
}
cmd_cert_add() {
require_root
load_config
local domain="$1"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
# Detect if this is a wildcard certificate request
local is_wildcard=0
local base_domain="$domain"
case "$domain" in
\*.*)
is_wildcard=1
base_domain="${domain#\*.}"
log_info "Wildcard certificate requested for $domain"
;;
esac
# Ensure HAProxy config has ACME backend (for webroot mode)
log_info "Ensuring HAProxy config is up to date..."
generate_config
# Ensure firewall allows HTTP/HTTPS from WAN (required for ACME)
log_info "Checking firewall rules..."
firewall_ensure_haproxy_rules "$http_port" "$https_port"
local email=$(uci_get acme.email)
local staging=$(uci_get acme.staging)
local key_type_raw=$(uci_get acme.key_type) || key_type_raw="ec-256"
# Convert key type for acme.sh (rsa-4096 → 4096, ec-256 stays ec-256)
local key_type="$key_type_raw"
case "$key_type_raw" in
rsa-*) key_type="${key_type_raw#rsa-}" ;; # rsa-4096 → 4096
RSA-*) key_type="${key_type_raw#RSA-}" ;;
esac
[ -z "$email" ] && { log_error "ACME email not configured. Set in LuCI > Services > HAProxy > Settings"; return 1; }
# For wildcard certs, DNS-01 challenge is REQUIRED
local dns_plugin=""
local use_local_bind=0
if [ "$is_wildcard" = "1" ]; then
# First check if we have local BIND for this zone
# Extract parent zone from domain (e.g., *.gk2.secubox.in -> secubox.in)
# Use awk instead of rev which isn't available on OpenWrt
local parent_zone=$(echo "$base_domain" | awk -F. '{print $(NF-1)"."$NF}')
if has_local_bind_zone "$parent_zone"; then
log_info "Local BIND zone found for $parent_zone"
use_local_bind=1
dns_plugin="dns_local_bind"
create_local_dns_hook
else
# Try external DNS provider
dns_plugin=$(get_dns_provider_env)
fi
if [ -z "$dns_plugin" ]; then
log_error "DNS provider not configured. Wildcard certificates require DNS-01 challenge."
log_error "Options:"
log_error " 1. Use local BIND: ensure /etc/bind/zones/<zone>.zone exists"
log_error " 2. Configure external DNS: LuCI > Services > DNS Provider"
log_error " uci set dns-provider.main.provider='gandi'"
log_error " uci set dns-provider.gandi.api_key='YOUR_API_KEY'"
log_error " uci commit dns-provider"
return 1
fi
log_info "Using DNS-01 challenge with $dns_plugin"
fi
# Warn about staging mode
if [ "$staging" = "1" ]; then
log_warn "=========================================="
log_warn "STAGING MODE ENABLED!"
log_warn "Certificate will NOT be trusted by browsers"
log_warn "To use production: uci set haproxy.acme.staging='0' && uci commit haproxy"
log_warn "=========================================="
sleep 2
else
log_info "Using Let's Encrypt PRODUCTION (certificates will be publicly trusted)"
fi
log_info "Requesting certificate for $domain..."
local staging_flag=""
[ "$staging" = "1" ] && staging_flag="--staging"
# Find acme.sh - check OpenWrt location first, then PATH
local ACME_SH=""
if [ -x "/usr/lib/acme/client/acme.sh" ]; then
ACME_SH="/usr/lib/acme/client/acme.sh"
elif command -v acme.sh >/dev/null 2>&1; then
ACME_SH="acme.sh"
fi
if [ -n "$ACME_SH" ]; then
# Set acme.sh home directory
export LE_WORKING_DIR="/etc/acme"
export LE_CONFIG_HOME="/etc/acme"
ensure_dir "$LE_WORKING_DIR"
# Register account if needed
if [ ! -f "$LE_WORKING_DIR/account.conf" ]; then
log_info "Registering ACME account..."
"$ACME_SH" --register-account -m "$email" --server letsencrypt $staging_flag --home "$LE_WORKING_DIR" || true
fi
local acme_result=0
if [ "$is_wildcard" = "1" ]; then
# DNS-01 challenge for wildcard certificates
log_info "Issuing wildcard certificate via DNS-01 challenge..."
log_info "This will create a TXT record at _acme-challenge.$base_domain"
"$ACME_SH" --issue -d "$domain" -d "$base_domain" \
--server letsencrypt \
--dns "$dns_plugin" \
--keylength "$key_type" \
$staging_flag \
--home "$LE_WORKING_DIR" || acme_result=$?
else
# HTTP-01 challenge for regular domains (webroot mode)
# Setup webroot for ACME challenges (HAProxy routes /.well-known/acme-challenge/ here)
local ACME_WEBROOT="/var/www/acme-challenge"
ensure_dir "$ACME_WEBROOT/.well-known/acme-challenge"
chmod 755 "$ACME_WEBROOT" "$ACME_WEBROOT/.well-known" "$ACME_WEBROOT/.well-known/acme-challenge"
# Start simple webserver for ACME challenges (if not already running)
local ACME_PORT=8402
if ! netstat -tln 2>/dev/null | grep -q ":$ACME_PORT "; then
log_info "Starting ACME challenge webserver on port $ACME_PORT..."
# Use busybox httpd (available on OpenWrt)
start-stop-daemon -S -b -x /usr/sbin/httpd -- -p $ACME_PORT -h "$ACME_WEBROOT" -f 2>/dev/null || \
busybox httpd -p $ACME_PORT -h "$ACME_WEBROOT" &
sleep 1
fi
# Ensure HAProxy is running with ACME backend
if ! lxc_running; then
log_info "Starting HAProxy..."
/etc/init.d/haproxy start 2>/dev/null || true
sleep 2
fi
# Issue certificate using webroot mode (NO HAProxy restart needed!)
log_info "Issuing certificate (webroot mode - HAProxy stays running)..."
"$ACME_SH" --issue -d "$domain" \
--server letsencrypt \
--webroot "$ACME_WEBROOT" \
--keylength "$key_type" \
$staging_flag \
--home "$LE_WORKING_DIR" || acme_result=$?
fi
# acme.sh returns 0 on success, 2 on "skip/already valid" - both are OK
# Install the certificate to our certs path
# For wildcard, use sanitized filename (replace * with _wildcard_)
local cert_filename="$domain"
case "$domain" in
\*.*) cert_filename="_wildcard_.${domain#\*.}" ;;
esac
if [ "$acme_result" -eq 0 ] || [ "$acme_result" -eq 2 ]; then
log_info "Installing certificate..."
"$ACME_SH" --install-cert -d "$domain" \
--home "$LE_WORKING_DIR" \
--cert-file "$CERTS_PATH/$cert_filename.crt" \
--key-file "$CERTS_PATH/$cert_filename.key" \
--fullchain-file "$CERTS_PATH/$cert_filename.fullchain.pem" \
--reloadcmd "/etc/init.d/haproxy reload" 2>/dev/null || true
# HAProxy needs combined file: fullchain + private key
log_info "Creating combined PEM for HAProxy..."
cat "$CERTS_PATH/$cert_filename.fullchain.pem" "$CERTS_PATH/$cert_filename.key" > "$CERTS_PATH/$cert_filename.pem"
chmod 600 "$CERTS_PATH/$cert_filename.pem"
# Clean up intermediate files - HAProxy only needs the .pem file
rm -f "$CERTS_PATH/$cert_filename.crt" "$CERTS_PATH/$cert_filename.key" "$CERTS_PATH/$cert_filename.fullchain.pem" "$CERTS_PATH/$cert_filename.crt.key" 2>/dev/null
# Reload HAProxy to pick up new cert
log_info "Reloading HAProxy to use new certificate..."
/etc/init.d/haproxy reload 2>/dev/null || true
fi
# Check if certificate was created
if [ ! -f "$CERTS_PATH/$cert_filename.pem" ]; then
if [ "$is_wildcard" = "1" ]; then
log_error "Wildcard certificate issuance failed. Check:"
log_error " 1. DNS provider API credentials are correct"
log_error " 2. You have permission to modify DNS for $base_domain"
log_error " 3. DNS propagation may take time (try again in a few minutes)"
else
log_error "Certificate issuance failed. Check:"
log_error " 1. Domain $domain points to this server's public IP"
log_error " 2. Port 80 is accessible from internet"
log_error " 3. HAProxy is running with ACME backend (haproxyctl generate)"
fi
return 1
fi
log_info "Certificate ready: $CERTS_PATH/$cert_filename.pem"
# Update domain variable for UCI storage
domain="$cert_filename"
elif command -v certbot >/dev/null 2>&1; then
certbot certonly --standalone -d "$domain" \
--email "$email" --agree-tos -n \
--http-01-port "$http_port" $staging_flag || {
log_error "Certbot failed"
return 1
}
# Copy to HAProxy certs dir
local le_path="/etc/letsencrypt/live/$domain"
cat "$le_path/fullchain.pem" "$le_path/privkey.pem" > "$CERTS_PATH/$domain.pem"
else
log_error "No ACME client found. Install: opkg install acme acme-acmesh"
return 1
fi
chmod 600 "$CERTS_PATH/$domain.pem"
# Verify certificate type (production vs staging)
echo ""
if cert_is_production "$CERTS_PATH/$domain.pem"; then
log_info "✅ Certificate is from PRODUCTION CA (publicly trusted)"
else
log_warn "⚠️ Certificate is from STAGING CA (NOT publicly trusted!)"
log_warn " Browsers will show security warnings for this certificate"
log_warn " To get a production certificate:"
log_warn " 1. uci set haproxy.acme.staging='0'"
log_warn " 2. uci commit haproxy"
log_warn " 3. haproxyctl cert remove $domain"
log_warn " 4. haproxyctl cert add $domain"
fi
# Show certificate info
echo ""
cert_info "$CERTS_PATH/$domain.pem"
# Add to UCI
local section="cert_$(echo "$domain" | tr '.-' '__')"
uci set haproxy.$section=certificate
uci set haproxy.$section.domain="$domain"
uci set haproxy.$section.type="acme"
uci set haproxy.$section.enabled="1"
uci commit haproxy
log_info "Certificate installed for $domain"
# Offer to verify publicly if production
if cert_is_production "$CERTS_PATH/$domain.pem"; then
echo ""
log_info "To verify the certificate is working publicly, run:"
log_info " haproxyctl cert verify $domain"
fi
}
cmd_cert_import() {
require_root
load_config
local domain="$1"
local cert_file="$2"
local key_file="$3"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
[ -z "$cert_file" ] && { log_error "Certificate file required"; return 1; }
[ -z "$key_file" ] && { log_error "Key file required"; return 1; }
[ -f "$cert_file" ] || { log_error "Certificate file not found"; return 1; }
[ -f "$key_file" ] || { log_error "Key file not found"; return 1; }
# Combine cert and key for HAProxy
cat "$cert_file" "$key_file" > "$CERTS_PATH/$domain.pem"
chmod 600 "$CERTS_PATH/$domain.pem"
# Add to UCI
uci set haproxy.cert_${domain//[.-]/_}=certificate
uci set haproxy.cert_${domain//[.-]/_}.domain="$domain"
uci set haproxy.cert_${domain//[.-]/_}.type="manual"
uci set haproxy.cert_${domain//[.-]/_}.enabled="1"
uci commit haproxy
log_info "Certificate imported for $domain"
}
# ===========================================
# Virtual Host Management
# ===========================================
cmd_vhost_list() {
load_config
echo "Virtual Hosts:"
echo "--------------"
config_load haproxy
config_foreach _print_vhost vhost
}
_print_vhost() {
local section="$1"
local enabled domain backend ssl ssl_redirect acme
config_get domain "$section" domain
config_get backend "$section" backend
config_get enabled "$section" enabled "0"
config_get ssl "$section" ssl "0"
config_get ssl_redirect "$section" ssl_redirect "0"
config_get acme "$section" acme "0"
local status="disabled"
[ "$enabled" = "1" ] && status="enabled"
local flags=""
[ "$ssl" = "1" ] && flags="${flags}SSL "
[ "$ssl_redirect" = "1" ] && flags="${flags}REDIRECT "
[ "$acme" = "1" ] && flags="${flags}ACME "
printf " %-30s -> %-20s [%s] %s\n" "$domain" "$backend" "$status" "$flags"
}
cmd_vhost_add() {
require_root
load_config
local domain="$1"
local backend="$2"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
[ -z "$backend" ] && backend="fallback"
local section="vhost_${domain//[.-]/_}"
uci set haproxy.$section=vhost
uci set haproxy.$section.domain="$domain"
uci set haproxy.$section.backend="$backend"
uci set haproxy.$section.ssl="1"
uci set haproxy.$section.ssl_redirect="1"
uci set haproxy.$section.acme="1"
uci set haproxy.$section.enabled="1"
uci commit haproxy
log_info "Virtual host added: $domain -> $backend"
}
cmd_vhost_remove() {
require_root
local domain="$1"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
local section="vhost_${domain//[.-]/_}"
uci delete haproxy.$section 2>/dev/null
uci commit haproxy
log_info "Virtual host removed: $domain"
}
# ===========================================
# Backend Management
# ===========================================
cmd_backend_list() {
load_config
echo "Backends:"
echo "---------"
config_load haproxy
config_foreach _print_backend backend
}
_print_backend() {
local section="$1"
local enabled name mode balance
config_get name "$section" name "$section"
config_get enabled "$section" enabled "0"
config_get mode "$section" mode "http"
config_get balance "$section" balance "roundrobin"
local status="disabled"
[ "$enabled" = "1" ] && status="enabled"
printf " %-20s mode=%-6s balance=%-12s [%s]\n" "$name" "$mode" "$balance" "$status"
}
cmd_backend_add() {
require_root
local name="$1"
[ -z "$name" ] && { log_error "Backend name required"; return 1; }
local section="backend_${name//[.-]/_}"
uci set haproxy.$section=backend
uci set haproxy.$section.name="$name"
uci set haproxy.$section.mode="http"
uci set haproxy.$section.balance="roundrobin"
uci set haproxy.$section.enabled="1"
uci commit haproxy
log_info "Backend added: $name"
}
cmd_server_add() {
require_root
local backend="$1"
local addr_port="$2"
local server_name="$3"
[ -z "$backend" ] && { log_error "Backend name required"; return 1; }
[ -z "$addr_port" ] && { log_error "Address:port required"; return 1; }
local address=$(echo "$addr_port" | cut -d: -f1)
local port=$(echo "$addr_port" | cut -d: -f2)
[ -z "$port" ] && port="80"
[ -z "$server_name" ] && server_name="srv_$(echo $address | tr '.' '_')_$port"
local section="server_${server_name//[.-]/_}"
uci set haproxy.$section=server
uci set haproxy.$section.backend="$backend"
uci set haproxy.$section.name="$server_name"
uci set haproxy.$section.address="$address"
uci set haproxy.$section.port="$port"
uci set haproxy.$section.weight="100"
uci set haproxy.$section.check="1"
uci set haproxy.$section.enabled="1"
uci commit haproxy
log_info "Server added: $server_name ($address:$port) to backend $backend"
}
# ===========================================
# Commands
# ===========================================
cmd_install() {
require_root
load_config
log_info "Installing HAProxy..."
has_lxc || { log_error "LXC not installed"; exit 1; }
if ! lxc_exists; then
lxc_create_rootfs || exit 1
fi
lxc_create_config || exit 1
log_info "Installation complete!"
log_info ""
log_info "Next steps:"
log_info " 1. Enable: uci set haproxy.main.enabled=1 && uci commit haproxy"
log_info " 2. Add vhost: haproxyctl vhost add example.com backend_name"
log_info " 3. Start: /etc/init.d/haproxy start"
}
cmd_status() {
load_config
local enabled=$(uci_get main.enabled)
local running="no"
lxc_running && running="yes"
cat << EOF
HAProxy Status
==============
Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no")
Running: $running
HTTP Port: $http_port
HTTPS Port: $https_port
Stats Port: $stats_port
Stats URL: http://localhost:$stats_port/stats
Container: $LXC_NAME
Rootfs: $LXC_ROOTFS
Config: $CONFIG_PATH/haproxy.cfg
Certs: $CERTS_PATH
EOF
}
cmd_reload() {
require_root
if ! lxc_running; then
log_error "Container not running"
return 1
fi
generate_config
log_info "Reloading HAProxy configuration..."
# HAProxy in master-worker mode (-W) reloads gracefully on SIGUSR2
# Fallback to SIGHUP if USR2 fails
lxc_exec killall -USR2 haproxy 2>/dev/null || \
lxc_exec killall -HUP haproxy 2>/dev/null || \
log_error "Could not signal HAProxy for reload"
log_info "Reload complete"
}
cmd_validate() {
load_config
generate_config
log_info "Validating configuration..."
if lxc_running; then
lxc_exec haproxy -c -f /opt/haproxy/config/haproxy.cfg
else
# Validate locally if possible
if [ -f "$CONFIG_PATH/haproxy.cfg" ]; then
log_info "Config file: $CONFIG_PATH/haproxy.cfg"
head -50 "$CONFIG_PATH/haproxy.cfg"
fi
fi
}
cmd_stats() {
if ! lxc_running; then
log_error "Container not running"
return 1
fi
lxc_exec sh -c "echo 'show stat' | socat stdio /var/run/haproxy.sock" 2>/dev/null || \
curl -s "http://localhost:$stats_port/stats;csv"
}
cmd_service_run() {
require_root
load_config
has_lxc || { log_error "LXC not installed"; exit 1; }
lxc_run
}
cmd_service_stop() {
require_root
lxc_stop
}
# ===========================================
# Main
# ===========================================
case "${1:-}" in
install) shift; cmd_install "$@" ;;
uninstall) shift; lxc_stop; log_info "Uninstall: rm -rf $LXC_PATH/$LXC_NAME" ;;
update) shift; lxc_exec apk update && lxc_exec apk upgrade haproxy ;;
status) shift; cmd_status "$@" ;;
generate) shift; generate_config "$@" ;;
validate) shift; cmd_validate "$@" ;;
reload) shift; cmd_reload "$@" ;;
vhost)
shift
case "${1:-}" in
list) shift; cmd_vhost_list "$@" ;;
add) shift; cmd_vhost_add "$@" ;;
remove) shift; cmd_vhost_remove "$@" ;;
sync) shift; generate_config && cmd_reload ;;
*) echo "Usage: haproxyctl vhost {list|add|remove|sync}" ;;
esac
;;
backend)
shift
case "${1:-}" in
list) shift; cmd_backend_list "$@" ;;
add) shift; cmd_backend_add "$@" ;;
remove) shift; uci delete haproxy.backend_${2//[.-]/_} 2>/dev/null; uci commit haproxy ;;
*) echo "Usage: haproxyctl backend {list|add|remove}" ;;
esac
;;
server)
shift
case "${1:-}" in
list) shift; config_load haproxy; config_foreach _print_server server "$1" ;;
add) shift; cmd_server_add "$@" ;;
remove) shift; uci delete haproxy.server_${3//[.-]/_} 2>/dev/null; uci commit haproxy ;;
*) echo "Usage: haproxyctl server {list|add|remove} <backend> [addr:port]" ;;
esac
;;
cert)
shift
case "${1:-}" in
list) shift; cmd_cert_list "$@" ;;
add) shift; cmd_cert_add "$@" ;;
import) shift; cmd_cert_import "$@" ;;
renew) shift; cmd_cert_add "$@" ;;
verify) shift; cmd_cert_verify "$@" ;;
remove) shift; rm -f "$CERTS_PATH/$1.pem"; uci delete haproxy.cert_${1//[.-]/_} 2>/dev/null ;;
*) echo "Usage: haproxyctl cert {list|add|import|renew|verify|remove}" ;;
esac
;;
stats) shift; cmd_stats "$@" ;;
connections) shift; lxc_exec sh -c "echo 'show sess' | socat stdio /var/run/haproxy.sock" ;;
service-run) shift; cmd_service_run "$@" ;;
service-stop) shift; cmd_service_stop "$@" ;;
shell) shift; lxc_exec /bin/sh ;;
exec) shift; lxc_exec "$@" ;;
*) usage ;;
esac