Zoraxy Reverse Proxy Setup
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