Zoraxy Reverse Proxy Setup

Zoraxy Reverse Proxy Setup
Zoraxy Reverse Proxy

This script has been created to automate the process of creating the proxy

Learnings:

  • Debian 13 uses nftables by default so I made a mess of it using iptables
  • Backup the LXC
  • Undo all the incorrect work and rebuild it using nftables
  • Test the code again
  • Put a reworking note in my Todo list to change all code to use nftables
  • Port scan from mobile device passed

Here is the code for your learning. Hopefully it's a little better than previous work

#!/bin/bash
# ====================================================================
# Proxmox LXC Zoraxy Proxy Setup (nftables-native)
# ====================================================================
# Purpose:
#    - Configure a Debian 13 LXC as a Zoraxy reverse proxy
#    - Use nftables as the ONLY firewall mechanism (no iptables)
#    - Enforce inbound policy with:
#         * Drop-by-default on INPUT
#         * SSH restricted to single admin IP (192.168.1.10) IPv4 ONLY
#         * IPv6 SSH connections completely blocked
#         * Brute-force protection on allowed SSH connections
#         * Basic HTTP(S)/admin DoS protection
#         * Centralised firewall logging via rsyslog + logrotate
#
# Notes:
#   - Assumes this LXC is a reverse proxy, not a router
#   - No Podman/LXC container chaining on this host (pure proxy role)
#   - Ensure to configure Zoraxy after installation
#   - Ensure to configure Cloudflared tunnel after installation
#
# Version: 1.3.1
# Created: 26-12-2025
# Updated: 10-02-2026
# ====================================================================

set -euo pipefail

# -----------------------------------------------------------------------
# 1. Utility functions
# -----------------------------------------------------------------------

info()  { echo -e "\033[0;32m[INFO]\033[0m $*"; }
warn()  { echo -e "\033[0;33m[WARN]\033[0m $*" >&2; }
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }

# Check to ensure the system is set to nftables only
set_alt() {
    local name="$1"
    local target="$2"

    if sudo update-alternatives --set "$name" "$target"; then
        info "Set $name → $target"
    else
        warn "Failed to set $name to $target — investigate alternatives state"
    fi
}

# -----------------------------------------------------------------------
# 2. Base packages
# -----------------------------------------------------------------------
info "Installing base packages..."
apt-get update -qq || warn "apt-get update failed, check DNS/network"
apt-get install -y -qq \
    bind9-dnsutils rsyslog sudo curl gpg net-tools apt-transport-https cron mtr \
    openssh-server openssh-client smartmontools nftables

info "Removing undesired packages..."
for pkg in ufw inetutils-telnet iptables iptables-persistent netfilter-persistent; do
    if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed"; then
        apt-get purge --auto-remove -y -qq "$pkg"
    fi
done

# Ensure nftables service is enabled
info "Enabling nftables service..."
systemctl enable --now nftables.service || warn "Failed to enable/start nftables.service"

# -----------------------------------------------------------------------
# 3. Miscellaneous configurations
# -----------------------------------------------------------------------
info "Configuring automatic updates via cron..."

# Ensure the cron jobs exist
( crontab -l 2>/dev/null || true; \
  echo "0 1 * * * apt-get update -qq && apt-get -y -qq upgrade && apt-get -y -qq autoremove && apt-get -y -qq autoclean >> /var/log/apt-cron.log 2>&1"; \
  echo "0 */4 * * * /usr/bin/systemctl reset-failed >> /var/log/your-command.log 2>&1" \
) | sort -u | crontab -

# Restart cron service depending on distro
if systemctl restart cron.service 2>/dev/null; then
  info "Cron restarted (cron.service)"
elif systemctl restart crond.service 2>/dev/null; then
  info "Cron restarted (crond.service)"
else
  error "Cron restart failed"
  exit 1
fi

# Timezone configuration
info "Set the timezone manually to ensure consistency..."
timedatectl set-timezone Australia/Perth

# Skip downloading extra APT languages
info "Configuring APT to skip downloading extra language files..."
echo 'Acquire::Languages "none";' | tee /etc/apt/apt.conf.d/99-disable-languages

# Force APT to use IPv4 to avoid potential issues
info "Configuring APT to use IPv4..."
echo 'Acquire::ForceIPv4 "true";' | tee /etc/apt/apt.conf.d/99force-ipv4

# Harden bash records - survive across sessions
cat >/etc/profile.d/hardened-history.sh <<'EOF'
# Hardened Bash History
export HISTSIZE=10000
export HISTFILESIZE=100000
shopt -s histappend
export HISTTIMEFORMAT='%F %T '
PROMPT_COMMAND='history -a'
history -r
EOF

# Alter the motd to show the new system information format
info "Configuring MOTD system information display..."

rm -f /etc/update-motd.d/*.*
cat <<'EOF' > /etc/update-motd.d/80-sysinfo
#!/bin/bash
# /etc/update-motd.d/80-sysinfo

echo "=== System Information ==="
echo ""
echo "  🖥️ Operating System : $( lsb_release -ds 2>/dev/null || grep -oP '(?<=PRETTY_NAME=\").*(?=\")' /etc/os-release )" 
echo "  🌐 IPv4 Address : $(hostname -I | awk '{print $1}')" 
echo "  🔗 IPv6 Global : $(ip -6 addr show scope global | grep inet6 | awk '{print $2}' | cut -d'/' -f1 | head -n1)" 
echo "  🪢 IPv6 Link-local : $(ip -6 addr show scope link | grep inet6 | awk '{print $2}' | cut -d'/' -f1 | head -n1)" 
echo "  ⏱️ System Uptime : $(uptime -p)"
EOF

chmod +x /etc/update-motd.d/80-sysinfo


# Simply overwrite the PAM config - no interactive tools needed
cat > /etc/pam.d/common-session << 'EOF'
# Minimal PAM session config for LXC containers
session [default=1]                     pam_permit.so
session requisite                       pam_deny.so
session required                        pam_permit.so
session optional                        pam_umask.so
session required        pam_unix.so
EOF

# Add a comment to prevent pam-auth-update from managing this file
echo "# This file is NOT managed by pam-auth-update" >> /etc/pam.d/common-session


# -----------------------------------------------------------------------
# 4. Firewall configuration (nftables-native)
# -----------------------------------------------------------------------

info "Configuring nftables firewall..."

info "Forcing the system to nftables - everything else to nft shim...."
set_alt iptables   /usr/sbin/iptables-nft
set_alt ip6tables  /usr/sbin/ip6tables-nft
set_alt arptables  /usr/sbin/arptables-nft
set_alt ebtables   /usr/sbin/ebtables-nft

cat <<'EOF' > /etc/nftables.conf
#!/usr/sbin/nft -f

# ============================================================
#  nftables Reverse Proxy Firewall (Merged Configuration)
#  Includes:
#    - Stateful filtering
#    - ICMPv4/v6
#    - DNS, mDNS, DHCP + DHCPv6
#    - IPv6 RA, RS, ND, MLD
#    - SSH with brute‑force protection (IPv4 from 192.168.1.10 ONLY)
#    - ALL IPv6 SSH connections BLOCKED
#    - Web DoS protection
#    - Port‑scan detection (SYN, FIN, NULL, XMAS)
#    - Anti‑fragmentation protection
#    - Invalid TCP flag protection
#    - Global per‑IP new‑connection rate‑limit
#    - Full logging of all dropped / malicious packets
#
#    - Version: 1.1.0
# ============================================================

flush ruleset

table inet filter {

    # ================================
    #  Port Scan Detection Sets
    # ================================
    set portscan_blacklist_v4 {
        type ipv4_addr
        timeout 10m
        flags timeout
    }

    set portscan_blacklist_v6 {
        type ipv6_addr
        timeout 10m
        flags timeout
    }

    # ================================
    #  SSH Brute Force Protection Set
    # ================================
    set ssh_ratelimit {
        type ipv4_addr
        timeout 1m
        flags timeout
    }

    # ================================
    #  INPUT CHAIN
    # ================================
    chain input {
        type filter hook input priority filter; policy drop;

        # Stateful inspection (MUST be first)
        ct state established,related accept
        ct state invalid log prefix "INVALID-STATE: " drop

        # Loopback (use iifname for LXC compatibility)
        iifname "lo" accept

        # Check port scan blacklists early
        ip saddr @portscan_blacklist_v4 log prefix "PORTSCAN-BLOCKED-v4: " drop
        ip6 saddr @portscan_blacklist_v6 log prefix "PORTSCAN-BLOCKED-v6: " drop

        # Anti-fragmentation (IPv4)
        ip frag-off & 0x1fff != 0 log prefix "FRAG-DROP: " drop

        # Invalid TCP flag combinations
        tcp flags & (fin|syn) == (fin|syn) log prefix "BADFLAGS-FIN-SYN: " drop
        tcp flags & (syn|rst) == (syn|rst) log prefix "BADFLAGS-SYN-RST: " drop

        # ================================
        #  Port Scan Detection (NEW connections only)
        # ================================
        # NULL scan (IPv4)
        meta l4proto tcp tcp flags == 0x0 ip saddr != 0.0.0.0 \
            add @portscan_blacklist_v4 { ip saddr } \
            log prefix "PORTSCAN-NULL-v4: " drop

        # NULL scan (IPv6)
        meta l4proto tcp tcp flags == 0x0 meta nfproto ipv6 \
            add @portscan_blacklist_v6 { ip6 saddr } \
            log prefix "PORTSCAN-NULL-v6: " drop

        # FIN scan (FIN without ACK) - IPv4
        meta l4proto tcp tcp flags & (fin|ack) == fin ip saddr != 0.0.0.0 \
            add @portscan_blacklist_v4 { ip saddr } \
            log prefix "PORTSCAN-FIN-v4: " drop

        # FIN scan (FIN without ACK) - IPv6
        meta l4proto tcp tcp flags & (fin|ack) == fin meta nfproto ipv6 \
            add @portscan_blacklist_v6 { ip6 saddr } \
            log prefix "PORTSCAN-FIN-v6: " drop

        # XMAS scan - IPv4
        meta l4proto tcp tcp flags & (fin|psh|urg) == fin|psh|urg ip saddr != 0.0.0.0 \
            add @portscan_blacklist_v4 { ip saddr } \
            log prefix "PORTSCAN-XMAS-v4: " drop

        # XMAS scan - IPv6
        meta l4proto tcp tcp flags & (fin|psh|urg) == fin|psh|urg meta nfproto ipv6 \
            add @portscan_blacklist_v6 { ip6 saddr } \
            log prefix "PORTSCAN-XMAS-v6: " drop

        # ================================
        #  IPv4 ICMP
        # ================================
        ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } \
            limit rate 10/second accept

        # ================================
        #  IPv6 Control Plane
        # ================================
        meta l4proto ipv6-icmp icmpv6 type { 
            destination-unreachable, packet-too-big, time-exceeded, 
            parameter-problem, echo-request, echo-reply,
            nd-router-solicit, nd-router-advert,
            nd-neighbor-solicit, nd-neighbor-advert,
            mld-listener-query, mld-listener-report, mld-listener-done
        } accept

        # DHCPv6 client
        meta l4proto udp udp sport 547 udp dport 546 accept

        # IPv6 multicast (solicited-node, all-nodes, all-routers)
        ip6 daddr { ff02::1:ff00:0/104, ff02::1, ff02::2 } accept

        # ================================
        #  DNS + mDNS
        # ================================
        meta l4proto udp udp dport 53 accept
        meta l4proto tcp tcp dport 53 accept
        meta l4proto udp udp dport 5353 accept

        # ================================
        #  DHCPv4 client
        # ================================
        meta l4proto udp udp sport 67 udp dport 68 accept

        # ================================
        #  SSH (IPv4 from 192.168.1.10 ONLY)
        #  ALL IPv6 SSH connections BLOCKED
        # ================================
        
        # BLOCK all IPv6 SSH attempts first
        meta nfproto ipv6 tcp dport 22 log prefix "SSH-DENY-IPv6: " drop
        
        # Allow IPv4 SSH only from 192.168.1.10 with rate limiting
        tcp dport 22 ip saddr 192.168.1.10 ct state new \
            add @ssh_ratelimit { ip saddr } \
            limit rate 3/minute burst 5 packets accept

        # Brute force protection for allowed IP
        tcp dport 22 ip saddr 192.168.1.10 ct state new \
            log prefix "SSH-BRUTE-ALLOWED-IP: " drop

        # Deny all other IPv4 SSH attempts
        tcp dport 22 log prefix "SSH-DENY-IPv4: " drop

        # ================================
        #  Global per-IP new-connection rate-limit
        # ================================
        ct state new limit rate over 200/second burst 400 packets \
            log prefix "GLOBAL-DOS: " drop

        # ================================
        #  Web traffic (DoS protection)
        # ================================
        tcp dport { 80, 81, 443, 8890 } ct state new \
            limit rate over 50/second burst 200 packets \
            log prefix "WEB-DOS: " drop

        tcp dport { 80, 81, 443, 8890 } accept

        # ================================
        #  Final catch-all logging
        # ================================
        limit rate 5/minute burst 20 packets \
            log prefix "DEFAULT-DROP: "
    }

    # ================================
    #  FORWARD CHAIN
    # ================================
    chain forward {
        type filter hook forward priority filter; policy drop;
    }

    # ================================
    #  OUTPUT CHAIN
    # ================================
    chain output {
        type filter hook output priority filter; policy accept;
    }
}
EOF

info "Validating nftables configuration..."
if nft -c -f /etc/nftables.conf; then
    info "nftables.conf syntax OK"
else
    error "nftables.conf test failed — investigate configuration"
    exit 1
fi

info "Applying nftables configuration..."
if nft -f /etc/nftables.conf; then
    info "nftables configuration applied successfully"
fi


# Configure nftables logging
touch /var/log/nftables.log
cat <<'EOF' > /etc/rsyslog.d/30-nftables.conf
:msg, contains, "DROP" /var/log/nftables.log
:msg, contains, "PORTSCAN" /var/log/nftables.log
:msg, contains, "BADFLAGS" /var/log/nftables.log
:msg, contains, "FRAG" /var/log/nftables.log
:msg, contains, "SSH" /var/log/nftables.log
:msg, contains, "DOS" /var/log/nftables.log
:msg, contains, "GLOBAL" /var/log/nftables.log
:msg, contains, "DEFAULT" /var/log/nftables.log
& stop
EOF

info "nftables logging configured at /var/log/nftables.log"

# Log rotation for nftables logs
cat <<'EOF' >/etc/logrotate.d/nftables
/var/log/nftables.log {
    daily
    missingok
    rotate 7
    compress
    delaycompress
    notifempty
    create 0640 root adm
    postrotate
        systemctl reload rsyslog >/dev/null 2>&1 || true
    endscript
}
EOF

# Restart rsyslog to apply logging configuration
systemctl restart rsyslog

# You now have a working nftables firewall with logging and rotation
# Implement fail2ban or similar on various states as needed

# -----------------------------------------------------------------------
# 6. Sysctl configuration
# -----------------------------------------------------------------------

info "Configuring sysctl settings..."

# Disable IPv4 forwarding (this is a proxy, not a router)
info "Applying IPv4 sysctl tuning..."
tee /etc/sysctl.d/99-ipv4-lxc.conf >/dev/null <<EOF
net.ipv4.ip_forward=0
EOF

# Disable IPv6 forwarding and enable RA acceptance on eth0
info "Applying IPv6 sysctl tuning..."
tee /etc/sysctl.d/99-ipv6-lxc.conf >/dev/null <<EOF
net.ipv6.conf.all.forwarding=0
net.ipv6.conf.eth0.accept_ra=2
EOF

sysctl --system || warn "Failed to apply IPv4/IPv6 sysctl settings"

warn "Some warnings are normal due to the fact it's a LXC...."

# -----------------------------------------------------------------------
# 7. SSH configuration hardening - to survive system updates
# -----------------------------------------------------------------------

info "Hardening SSH configuration..."

BACKUP_TS=$(date +"%Y%m%d-%H%M%S")
SSHD_DIR="/etc/ssh"
SSHD_CFG="${SSHD_DIR}/sshd_config"
SSHD_CUSTOM_DIR="${SSHD_DIR}/sshd_config.d"
SSHD_CUSTOM_CFG="${SSHD_CUSTOM_DIR}/99-hardened-custom.conf"
BACKUP_DIR="${SSHD_DIR}/backup-${BACKUP_TS}"

mkdir -p "${BACKUP_DIR}"
mkdir -p "${SSHD_CUSTOM_DIR}"

info "Backing up sshd_config and host keys..."
cp -a "${SSHD_CFG}" "${BACKUP_DIR}/sshd_config.bak" || true
for key in ssh_host_ed25519_key ssh_host_rsa_key ssh_host_ecdsa_key; do
    [[ -f "${SSHD_DIR}/${key}" ]] && mv "${SSHD_DIR}/${key}" "${BACKUP_DIR}/" || true
    [[ -f "${SSHD_DIR}/${key}.pub" ]] && mv "${SSHD_DIR}/${key}.pub" "${BACKUP_DIR}/" || true
done

info "Deleting old SSH host keys..."
rm -f /etc/ssh/ssh_host_*

info "Generating new ED25519 host key..."
ssh-keygen -t ed25519 -f "${SSHD_DIR}/ssh_host_ed25519_key" -N "" -o -a 100

info "Generating new RSA host key..."
ssh-keygen -t rsa -b 4096 -f "${SSHD_DIR}/ssh_host_rsa_key" -N "" -o

chmod 600 "${SSHD_DIR}/ssh_host_ed25519_key" "${SSHD_DIR}/ssh_host_rsa_key"
chmod 644 "${SSHD_DIR}/ssh_host_ed25519_key.pub" "${SSHD_DIR}/ssh_host_rsa_key.pub"

info "Ensuring main sshd_config includes custom configs..."

# Check if Include directive exists and add if missing
if ! grep -q "^Include ${SSHD_CUSTOM_DIR}/\*.conf" "${SSHD_CFG}"; then
    # Add Include directive at the top of the config file
    sed -i "1i Include ${SSHD_CUSTOM_DIR}/*.conf" "${SSHD_CFG}"
    info "Added Include directive to ${SSHD_CFG}"
fi

info "Writing hardened SSH configuration to ${SSHD_CUSTOM_CFG}..."

tee "${SSHD_CUSTOM_CFG}" >/dev/null <<EOF
# Hardened SSH Configuration
# Managed by proxmox-lxc-zoraxy-setup script
# This file is in sshd_config.d/ and will survive system updates

# Protocol and authentication
Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
HostbasedAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes

# Connection limits and timeouts
LoginGraceTime 30
MaxAuthTries 4
MaxSessions 10
ClientAliveInterval 300
ClientAliveCountMax 2

# Host keys
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key

# Disabled features
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
UseDNS no
PrintMotd no
PrintLastLog yes

# Environment
AcceptEnv LANG LC_*

# IPv4-only SSH access - deny IPv6
AddressFamily inet

# Allow root login with password only from trusted admin IP
Match Address 192.168.1.10
  PermitRootLogin yes
  PasswordAuthentication yes
EOF

# Ensure the custom config has appropriate permissions
chmod 644 "${SSHD_CUSTOM_CFG}"

info "Validating sshd_config..."
if sshd -t; then
    info "sshd_config syntax OK, restarting ssh..."
    if systemctl restart ssh || systemctl restart sshd; then
        info "sshd restarted successfully."
    else
        error "systemctl restart ssh failed - check service status."
    fi
else
    error "sshd_config test failed - not restarting ssh."
    error "Backup available at: ${BACKUP_DIR}/sshd_config.bak"
fi


# -----------------------------------------------------------------------
# 8. Install the Zoraxy proxy
# -----------------------------------------------------------------------

INSTALL_DIR="/srv/zoraxy"
BIN_PATH="$INSTALL_DIR/zoraxy"
SERVICE_FILE="/etc/systemd/system/zoraxy.service"
ZORAXY_URL="https://github.com/tobychui/zoraxy/releases/latest/download/zoraxy_linux_amd64"

info "Creating install directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"

info "Downloading Zoraxy binary..."
curl -L "$ZORAXY_URL" -o "$BIN_PATH"
chmod +x "$BIN_PATH"

info "Creating Zoraxy start script..."
cat <<EOF > "$INSTALL_DIR/start.sh"
#!/bin/bash
"$BIN_PATH" -port=:81
EOF

chmod +x "$INSTALL_DIR/start.sh"

info "Creating Zoraxy systemd service..."
cat <<EOF > "$SERVICE_FILE"
[Unit]
Description=Zoraxy Reverse Proxy
After=network.target

[Service]
Type=simple
User=root
Group=root
ExecStart=$INSTALL_DIR/start.sh
WorkingDirectory=$INSTALL_DIR
Restart=on-failure
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zoraxy

[Install]
WantedBy=multi-user.target
EOF

info "Enabling and starting Zoraxy service..."
systemctl daemon-reload  || error "systemd failed to reload"
systemctl enable zoraxy || warn "Zoraxy service failed to enable"
systemctl start zoraxy  || warn "Zoraxy service failed to start"

info "Zoraxy installation complete."
info "Manage the service with: systemctl [status|start|stop|restart] zoraxy"


# -----------------------------------------------------------------------
# 9. Install Cloudflared Tunnel
# -----------------------------------------------------------------------

info "Installing Cloudflared Tunnel..."

mkdir -p /usr/local/bin
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
  -o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared || warn "Failed to install Cloudflared tunnel..."

cloudflared --version

warn "You need to run the initialisation command...."


# -----------------------------------------------------------------------
# 10. Final notes
# -----------------------------------------------------------------------
info "======================================================================="
info "Zoraxy proxy deployment complete!"
info "======================================================================="
info ""
info "SECURITY STATUS:"
info "  ✓ SSH access: IPv4 only from 192.168.1.10"
info "  ✓ IPv6 SSH: BLOCKED"
info "  ✓ nftables firewall: Active with comprehensive logging"
info "  ✓ SSH configuration: Hardened and update-resistant"
info ""
info "VERIFICATION COMMANDS:"
info "  - View firewall rules: nft list ruleset"
info "  - Monitor firewall logs: tail -f /var/log/nftables.log"
info "  - Test SSH config: sshd -t"
info "  - Check Zoraxy status: systemctl status zoraxy"
info ""
info "NEXT STEPS:"
info "  1. Configure Zoraxy web interface (port 81) - http://<LXC_IP>:81 and set up your reverse proxy rules"
info "  2. Initialize Cloudflared tunnel - sudo cloudflared service install [token] and then sudo systemctl start cloudflared"
info "  3. Test SSH access from 192.168.1.10 (should work)"
info "  4. Test SSH access from other IPs (should be blocked)"
info "  5. Monitor /var/log/nftables.log for security events"
info "  6. Run the following scripts - postfix, fail2ban, create the forensics script..."
info "  7. Run the apt update/upgrade cron job manually to verify it works - check /var/log/apt-cron.log for output"
info "  8. Run the beszel agent binary installation switching the hub URL to https and ensure it starts correctly"
info "======================================================================="

Hope this helps someone

Probably going to pull other posts and start a cleanup on my systems.

Live and learn

#enoughsaid