#!/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 < [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 Add virtual host vhost remove Remove virtual host vhost sync Sync vhosts to config Backends: backend list List all backends backend add Add backend backend remove Remove backend Servers: server list List servers in backend server add Add server to backend server remove Remove server Certificates: cert list List certificates cert add Request ACME certificate cert import Import certificate cert renew [domain] Renew certificate(s) cert remove 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" -- "$@" } # =========================================== # Configuration Generation # =========================================== generate_config() { load_config local cfg_file="$CONFIG_PATH/haproxy.cfg" log_info "Generating HAProxy configuration..." # 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" if [ -d "$CERTS_PATH" ] && ls "$CERTS_PATH"/*.pem >/dev/null 2>&1; then 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 # 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 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" 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 " 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 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} [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