Zoraxy - Reverse Proxy

Zoraxy - Reverse Proxy
Zoraxy - Reverse Proxy

This is one of my pet projects

Due to my other projects I have not paid this the attention it deserves and I am now finding I have made a serious mistake

  1. All external access was removed in early March.
  2. It was restored a week ago.
  3. A number of problems have been discovered as of 30 minutes ago.
  4. All previous posts on this subject have been pulled.
  5. So here we go.

If you've spent any time self-hosting services at home, you've probably hit the same wall I did — one public IP address, a dozen services you want to reach from the internet, and the immediate question of how to route everything cleanly. That's the problem a reverse proxy solves, and Zoraxy is the one I've settled on for the braedach.com infrastructure.

What Is Zoraxy?

Zoraxy is an open-source, general-purpose HTTP reverse proxy written in Go by Toby Chui. Unlike the more established options in the self-hosting space — Nginx Proxy Manager, Caddy, Traefik — Zoraxy was built from the ground up to be approachable. The tagline on the GitHub repo describes it as being designed for "networking noobs," which is both accurate and slightly undersells it. What it actually is, is a fully-featured reverse proxy with a clean web UI that doesn't require you to touch a config file to get started.

At its core, Zoraxy does what every reverse proxy does: it sits in front of your services and forwards inbound traffic to the right backend based on the hostname of the request. You hit immich.braedach.com and Zoraxy knows to forward that to the Immich container on port 2283. You hit n8n.braedach.com and it goes somewhere else entirely. One port 443 on one public IP, routing cleanly to dozens of services.

But Zoraxy adds a layer on top of the basics that makes it genuinely useful as a homelab tool:

TLS/SSL with ACME automation. Zoraxy handles Let's Encrypt certificate generation and renewal natively, including DNS-01 challenge support for wildcard certificates. My setup uses Cloudflare as the DNS provider, which means I can issue *.braedach.com and *.baden.braedach.com wildcard certs without exposing any port 80 for HTTP challenge. The certificates auto-renew and are automatically applied to matching proxy rules.

WebSocket proxy — automatic, no configuration. This is one of the things that catches people out with other reverse proxies. WebSocket connections require specific proxy headers (Upgrade, Connection) to be forwarded correctly, and forgetting them breaks real-time features in apps like Home Assistant, n8n, and Jellyfin. Zoraxy handles this transparently.

Access control at the proxy layer. Zoraxy supports IP-based blacklists and whitelists (single IPs, CIDR ranges, and wildcards), country-level blocking, and basic authentication per proxy host. This lets you gate internal tools without touching the underlying service's own auth.

Built-in exploit blocking. Zoraxy includes a detection layer for common web attack patterns — path traversal attempts, known bad user agents, and probe strings. When it catches something, it logs it with a [router:exploit-blocked] tag and returns a 403. This is what populates the exploit section of the forensic reports I run weekly.

Static web server. There's a built-in static file server that can serve HTML, text files, and other content directly from a directory on the Zoraxy host — useful for things like custom error pages or a minimal robots.txt.

Uptime monitoring. Zoraxy includes a basic uptime monitor that tracks the availability of your proxied backends and alerts you when something goes down.

Stream proxy. Beyond HTTP, Zoraxy can proxy raw TCP and UDP streams — useful if you need to expose a non-HTTP service through the same host.

Plugin system. Community-contributed plugins add features like Forward Auth integration (Authelia, Authentik), OAuth2 support, and reCAPTCHA protection, extending Zoraxy well beyond its out-of-the-box feature set.

Pros

Dead simple to get started. If you've wrestled with Nginx location blocks or Traefik label syntax, Zoraxy's web UI is genuinely refreshing. Add a proxy host, set the target, click save. Done. No YAML files, no label archaeology, no container restarts.

Wildcard certificate support that actually works. DNS-01 challenge with Cloudflare integration is solid once you get the token configuration right. The certificates apply automatically to any proxy rule matching the wildcard domain — you don't have to manually assign certs to each host.

WebSocket handling is seamless. It just works. This sounds minor until you've spent an afternoon debugging why your Home Assistant frontend keeps dropping its connection.

Unified security layer. Having one place where all inbound traffic passes through means you can apply access controls, rate limiting, and exploit detection in one spot without configuring it per-service.

Lightweight and fast. Being written in Go means the binary is small, startup is near-instant, and resource usage is minimal. On alex (the LXC container running Zoraxy), it sits comfortably on a handful of CPU percentage points and well under a gigabyte of RAM even under load.

Self-contained. No external databases, no complex dependencies. Zoraxy stores its configuration in a single SQLite database file and its certificates in a directory. Backing it up is a single tar.

Cons

Known WebSocket deadline bug. There's a hardcoded 5-minute WebSocket timeout in the core proxy code (dpcore.go) that forcibly closes all WebSocket connections after 5 minutes regardless of activity. This is GitHub issue #1159, filed April 2026, and at the time of writing it has no confirmed fix in the current release. In practice this manifests as Jellyfin's web UI going "stale" — the transcode finishes fine but the frontend loses its connection. It's a known upstream issue, not a misconfiguration, but it's annoying.

No cluster or multi-node support. Zoraxy is a single-admin, single-instance tool. If you want to run multiple Zoraxy instances in a distributed setup, there's no built-in synchronisation. For a homelab this is rarely a problem, but worth knowing if you're planning something more complex.

Limited configuration from the CLI or API. Most configuration happens through the web UI. There's no official API for bulk-configuring proxy rules or ACME credentials programmatically. This bit me when I needed to update wildcard cert credentials across multiple domains — each one requires a manual UI operation.

Static file server is basic. The built-in static file server works for simple use cases but lacks features you'd expect from a proper web server — directory listings, MIME type overrides, fine-grained caching headers. For anything beyond serving a robots.txt, you're better off with a dedicated nginx container.

Exploit detection produces false positives. The exploit blocking pattern matching catches URL-encoded characters (%2F, %2E) that appear legitimately in Ghost admin API calls and application-level auth tokens. This isn't dangerous, but it fills the exploit log section with noise that has to be filtered out in reporting.

Single point of failure. All external traffic funnels through alex. If that LXC container goes down, everything goes with it. Mitigation requires either redundant Zoraxy instances (not currently supported natively) or careful Proxmox HA configuration.

How Zoraxy Fits Into the Network

The infrastructure runs on a Proxmox hypervisor (pxe) with a UDM SE handling network segmentation across several VLANs. Zoraxy lives in a dedicated Debian LXC container called alex (192.168.1.15), which sits on the main LAN management VLAN alongside the hypervisor itself.

All external traffic flows through Cloudflare first. Cloudflare provides the first layer of defence — WAF rules blocking known scanner subnets, AI crawlers, and automated probing agents before a single packet reaches the network. A Cloudflare tunnel on alex means the public-facing port 443 never touches the router's WAN interface directly; Cloudflare terminates the TLS connection, applies its security rules, and forwards clean traffic to Zoraxy over an authenticated tunnel.

Zoraxy receives that traffic and applies its own layer of rules — IP blacklists, country blocks, rate limiting, and the exploit detection layer — before forwarding to the appropriate backend container. Backends are a mix of Podman stacks on baden (the main app server) and Docker stacks on claude (the dev container). Zoraxy handles the TLS termination for internal traffic too, using wildcard certificates for *.braedach.com, *.baden.braedach.com, and other subdomains to keep all internal service traffic encrypted even on the LAN.

The result is a clean two-layer security model: Cloudflare filters the internet, Zoraxy filters what Cloudflare passes through. The forensic reporting script I run weekly against the Zoraxy access logs gives visibility into what's hitting the proxy, what's being blocked, and where new threats are emerging — which is exactly what caught the credential harvesting attack against vert.braedach.com in September 2026 before it could be missed.

Zoraxy isn't the most powerful reverse proxy available, and it has rough edges. But for a homelab running dozens of self-hosted services on a single public IP, it's hard to argue with the combination of simplicity, built-in TLS automation, and a security layer that actually tells you what it's doing.


So where is it at.

  1. Zoraxy is still deployed.
  2. Zoraxy has a forensic script built against its logs.
  3. Scripts are shared below.
  4. However a breach was discovered on the 4th September 2026 and is included along with the result of the forensic script report.
  5. This is why I do stuff like this. To learn.
  6. All external access to vert.braedach.com has been pulled.

I am currently investigating. However to the best of my knowledge.

  1. All global accessible services are deployed with protections in Zoraxy.
  2. The dates given are roughly when I re-enabled Zoraxy.
  3. Zoraxy defenses were supplmented with third party plugins enabled at defenses.
  4. This is exactly why I do projects like this. To learn.
  5. To be advised.

Zoraxy Deployment script

The following scipt is the deployment script

# Insert deployment script here

Zoraxy Forensic script

#!/bin/bash

################################################################################
# Zoraxy Forensic Report Generator
# Version: 2.5.1
# Created: 2026-04-22
# Revised: 2026-09-05
#
# Description:
#   Generates a forensic security report from the current month's Zoraxy Proxy
#   log, filtering out internal traffic and noise. Saves the report to a file
#   and emails it to the security team via postfix as an attachment. The
#   attachment filename matches the timestamped report filename on disk.
#
# Setup / Installation:
#   1. Copy this script to /usr/local/bin/:
#        cp forensic-report.sh /usr/local/bin/forensic-report.sh
#
#   2. Set correct permissions:
#        chmod 700 /usr/local/bin/forensic-report.sh
#        chown root:root /usr/local/bin/forensic-report.sh
#
#   3. Create the reports output directory (first run only):
#        mkdir -p /srv/zoraxy/reports
#        chown root:root /srv/zoraxy/reports
#        chmod 700 /srv/zoraxy/reports
#
#   4. Schedule via cron (runs every Sunday at 2300hrs):
#        crontab -e
#        Add the following line:
#        0 23 * * 0 /usr/local/bin/forensic-report.sh
#
# Key Changes in v2.5.1:
#   - Fixed: LANIP placeholder "[IP_ADDRESS]" was being interpreted as a regex
#     character class by grep, silently wiping most log lines and producing a
#     blank report. LANIP filter is now skipped when the placeholder value is
#     unchanged, making the script safe to run before configuration.
#   - Fixed: LANIP is now validated as a non-empty, non-placeholder value
#     before being applied as a grep filter; a warning is printed when skipped.
#
# Key Changes in v2.5.0:
#   - Fixed: email attachment filename now matches the timestamped report
#     filename on disk (e.g. forensic-report-2026-09-05_16-32-55.txt)
#     rather than the static "forensic-report.txt"
#   - Added: "Created" date to header (original script creation date)
#   - Added: "Revised" date to header (tracks most recent update)
#
# Key Changes in v2.4.0:
#   - Fixed: script no longer continues and emails an empty report when the
#     current month's log file is missing; now exits with an error instead
#   - Fixed: HTTP error section now correctly extracts the status code field
#     from Zoraxy log lines rather than using $NF (last field)
#   - Fixed: temp file protected by trap to ensure cleanup on unexpected exit
#   - Fixed: install path corrected to /usr/local/bin throughout
#   - Fixed: cron expression corrected to "0 23 * * 0" (every Sunday at 2300)
#   - Improved: exploit detection pattern expanded to catch encoded path
#     traversal and injection probe strings
#
# Key Changes in v2.3.0:
#   - Added timestamp to report file name and email subject
#
# Requirements:
#   - Postfix configured and running
#   - Access to Zoraxy logs directory (/srv/zoraxy/log)
#   - sendmail available at /usr/sbin/sendmail
#
# Usage:
#   /usr/local/bin/forensic-report.sh
#
################################################################################

LOGDIR="/srv/zoraxy/log"
LANIP="[IP_ADDRESS]"           # Replace with your LAN gateway/proxy IP to filter internal traffic
REPORT_TIMESTAMP="$(date '+%Y-%m-%d_%H-%M-%S')"
REPORT_FILE="/srv/zoraxy/reports/forensic-report-${REPORT_TIMESTAMP}.txt"
REPORT_FILENAME="$(basename "$REPORT_FILE")"
EMAIL_TO="[EMAIL_ADDRESS]"
EMAIL_FROM="zoraxy-forensics@$(hostname -f)"
EMAIL_SUBJECT="Zoraxy Forensic Report - $(date '+%Y-%m-%d %H:%M:%S')"

# Determine current month's log file (Zoraxy naming format: zr_YYYY-M.log)
CURRENT_LOG="$LOGDIR/zr_$(date '+%Y-%-m').log"

# Function to generate report
generate_report() {
    echo
    echo "==============================================="
    echo "        ZORAXY OFFLINE FORENSIC SUMMARY"
    echo "==============================================="
    echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
    echo "Hostname: $(hostname)"
    echo "Log file: $CURRENT_LOG"
    echo

    # Abort if the current month's log does not exist
    if [ ! -f "$CURRENT_LOG" ]; then
        echo "ERROR: No log found for current month: $CURRENT_LOG"
        echo "Aborting report generation."
        return 1
    fi

    echo "Log found:"
    echo "$CURRENT_LOG"
    echo

    # Create temp file and ensure it is cleaned up on any exit
    FILTERED=$(mktemp)
    trap "rm -f '$FILTERED'" EXIT

    # Apply LANIP filter only when the placeholder has been replaced with a real value;
    # if still set to the default "[IP_ADDRESS]", skip it with a warning to avoid regex
    # character-class mangling that silently wipes most log lines.
    if [ "$LANIP" = "[IP_ADDRESS]" ] || [ -z "$LANIP" ]; then
        echo "WARNING: LANIP not configured — internal IP filter skipped." >&2
        grep -v "netstatgraph" "$CURRENT_LOG" \
        | grep -v "summary?fast" \
        | grep -v "snippet" \
        | grep -v "script/" \
        | grep -v "darktheme" \
        > "$FILTERED"
    else
        grep -v "$LANIP" "$CURRENT_LOG" \
        | grep -v "netstatgraph" \
        | grep -v "summary?fast" \
        | grep -v "snippet" \
        | grep -v "script/" \
        | grep -v "darktheme" \
        > "$FILTERED"
    fi

    echo "-----------------------------------------------"
    echo " External Requests by IP"
    echo "-----------------------------------------------"
    grep -Eo "client: [^]]+" "$FILTERED" \
    | awk '{print $2}' \
    | sort | uniq -c | sort -nr
    echo

    echo "-----------------------------------------------"
    echo " Suspicious User Agents"
    echo "-----------------------------------------------"
    grep -v "Mozilla/5.0" "$FILTERED" \
    | grep -Eo "useragent: [^]]+" \
    | awk -F'useragent: ' '{print $2}' \
    | sort | uniq -c | sort -nr
    echo

    echo "-----------------------------------------------"
    echo " HTTP Errors (4xx / 5xx)"
    echo "-----------------------------------------------"
    # Extract the HTTP status code field (field 9 in standard combined log format). Adjust the field number below if
    # your Zoraxy log format differs.
    grep -E " [45][0-9]{2} " "$FILTERED" \
    | awk '{print $9}' \
    | grep -E "^[45][0-9]{2}$" \
    | sort | uniq -c | sort -nr
    echo

    echo "-----------------------------------------------"
    echo " Exploit / Probe Attempts"
    echo "-----------------------------------------------"
    # Matches common probe and injection patterns: path traversal, encoded sequences, shell injection, script injection,
    # and eval-based payloads.
    grep -iE "(exploit|\.\./|%2e%2e|%2f|cmd=|exec=|eval\(|<script|union.*select|/etc/passwd|/bin/sh|wget |curl )" "$FILTERED" \
    || echo "None detected"
    echo

    echo "-----------------------------------------------"
    echo " Suspicious URLs (non-dashboard, top 20)"
    echo "-----------------------------------------------"
    grep -E "GET|POST" "$FILTERED" \
    | awk '{print $NF}' \
    | sort | uniq -c | sort -nr \
    | head -20
    echo

    echo "-----------------------------------------------"
    echo " Summary Complete"
    echo "-----------------------------------------------"
    echo
}

# Generate report and save to file; abort cleanly if generation fails
generate_report > "$REPORT_FILE" 2>&1
if [ $? -ne 0 ]; then
    echo "Error: Report generation failed (check log path or permissions)."
    echo "Partial report (if any) saved to: $REPORT_FILE"
    exit 1
fi

# Send email with report as MIME attachment; filename matches the on-disk report filename
(
    echo "To: $EMAIL_TO"
    echo "From: $EMAIL_FROM"
    echo "Subject: $EMAIL_SUBJECT"
    echo "MIME-Version: 1.0"
    echo "Content-Type: multipart/mixed; boundary=\"BOUNDARY\""
    echo ""
    echo "--BOUNDARY"
    echo "Content-Type: text/plain; charset=UTF-8"
    echo ""
    echo "Zoraxy forensic report attached."
    echo "Generated on $(hostname) at $(date '+%Y-%m-%d %H:%M:%S')"
    echo ""
    echo "--BOUNDARY"
    echo "Content-Type: text/plain; charset=UTF-8"
    echo "Content-Disposition: attachment; filename=\"$REPORT_FILENAME\""
    echo ""
    cat "$REPORT_FILE"
    echo ""
    echo "--BOUNDARY--"
) | /usr/sbin/sendmail -t

# Report on email dispatch result
if [ $? -eq 0 ]; then
    echo "Forensic report generated and emailed to $EMAIL_TO"
    echo "Report saved to: $REPORT_FILE"
else
    echo "Error: Failed to send email via sendmail."
    echo "Report saved to: $REPORT_FILE"
    exit 1
fi

# End of script

Conclusions

Write conclusions here
Posted prematurely because this is seriously interesting.

  1. Was there a breach - no
  2. This confirms it. vert is running nginx internally (nginx -g daemon o...). When nginx receives a request for /media../.env and has no matching location block, it returned a 200 with whatever its default root serves — almost certainly the vertd web UI's index.html. The attacker got an HTML page, not credentials. This is a nginx behaviour quirk, not a data exposure.
  3. Everything else is clean. 👍

All good - however

Incident status: Closed. No compromise.

Your remaining actions are:

  1. Confirm the Zoraxy proxy rule for vert.braedach.com is removed
  2. Add 93.123.109.0/24 and 195.178.110.0/24 to the Cloudflare WAF
  3. Report 34.139.107.15 to GCP abuse if you feel inclined

Shitheads.

#enoughsaid