Guide · ~6 min · pfSense

Run HLE on pfSense
without opening a port

Your firewall is the one box that's always on and has a route to everything. It's also the last box you want to punch a hole in. Two commands, and you can reach it from anywhere — with nothing forwarded and SSO in front.

The chicken-and-egg of remote firewall access

Remote access to pfSense is usually a VPN. Which is fine — until the VPN is the thing that's broken, or you're on a network that blocks it, or you just want to check one rule from your phone. The tool you'd use to fix the firewall lives behind the firewall.

The alternative is worse: forwarding 443 to the pfSense web UI puts a login page for your entire network on the public internet, to be found by every scanner on the planet within a day.

HLE takes a third path. The agent runs on pfSense and dials out to the relay. No inbound port, no dynamic DNS, no certificate to renew — and the login page sits behind SSO, so unauthenticated traffic never reaches pfSense at all.

The firewall that nearly needed a Rust compiler

Until recently this guide couldn't exist, and the reason is a good illustration of how one dependency can decide which platforms you support.

pfSense already has Python — unbound, Suricata and pfBlockerNG all depend on it, so it arrives with the system. The obstacle was pydantic. Its core is written in Rust, and it publishes no FreeBSD wheel — so pip would fall back to building from source. On a real pfSense box that looked like this:

Collecting pydantic-core==2.46.4
  Downloading pydantic_core-2.46.4.tar.gz (471 kB)
      Unsupported platform: 311
      Rust not found, installing into a temporary directory

It was about to download a Rust toolchain onto a firewall. Meanwhile the other four dependencies — click, rich, httpx, websockets — installed in seconds, because they're pure Python.

One library was the entire reason the agent couldn't run there. And it turned out the protocol never needed it: outside a single model these were plain data containers with no validators, using pydantic only to turn objects into JSON and back. So in 2608.2 they became stdlib dataclasses, with the wire format asserted byte-for-byte identical against a baseline captured beforehand.

The agent now installs anywhere Python runs. pfSense was just the first place that noticed.

Install the agent

SSH in as root, or use Diagnostics → Command Prompt:

fetch -o - https://get.hle.world | sh -s -- --agent

fetch rather than curl — that's the FreeBSD equivalent, and it's already there. The installer prompts for an agent token, enrolls the firewall, and sets up an rc.d service so it starts at boot and restarts if it exits.

Get the token from Connections → New → Agent in the dashboard. It's shown only once. To skip the prompt entirely — handy from Ansible:

fetch -o - https://get.hle.world | sh -s -- --agent --token hlea_xxxxx

Check it came up:

service hle_agent status

The agent should show as online in the dashboard within a few seconds. That's the last command you'll run on the firewall.

Publish the web UI

One command:

fetch -o /tmp/hle-pfsense.sh https://hle.world/scripts/pfsense.sh
sh /tmp/hle-pfsense.sh expose

It asks for an API key, publishes the firewall's web UI behind SSO, and prints the URL. That's it — you're done.

Two jobs are hiding in there, and the second is the one that trips people up. Creating the tunnel is obvious. But pfSense also validates the Host header and the Referer against its own hostname, so the very first page load fails with “An HTTP_REFERER was detected…” unless you tell it the new name is legitimate. The script does both, so you never meet that error.

It writes the hostname exactly the way the GUI does, recording a config revision — so it's revertable from Diagnostics → Backup & Restore → Config History like any other change.

Don't set auth to none here. This is a login page for your entire network. With SSO, HLE's gate sits in front and only the email addresses you allow get as far as pfSense. Without it, you've published your firewall's front door.

The self-signed certificate is a non-issue — HLE terminates TLS at the relay and doesn't verify upstream unless you ask it to. Use https:// and move on.

Everything else behind the firewall works identically. Point a tunnel at http://192.168.1.50:8096 and your Jellyfin is reachable, with no NAT rule and no route. That's the advantage of running the agent here: it already reaches everything.

What an upgrade can break

The agent installs as a Python virtual environment under /root, not as a package, so an upgrade doesn't delete it. Two things can still go wrong, and both end with no remote access to your firewall at exactly the wrong moment.

The venv links whichever interpreter it was built against — Python 3.11 on pfSense 2.7, 3.13 on OPNsense. pfSense 2.8.0 moves to a FreeBSD 15 base, and if the Python minor version changes that link breaks — leaving an hle that still exists, is still executable, and can no longer run. Separately, the service that starts it lives in /usr/local/etc/rc.d, which has none of /root's protection.

One command makes both self-healing:

fetch -o /tmp/hle-pfsense.sh https://hle.world/scripts/pfsense.sh
sh /tmp/hle-pfsense.sh persist

/conf is the one directory pfSense preserves across upgrades, so that's where this puts your agent token and a small bootstrap script, then registers two hooks in config.xml — also preserved: one at boot, one every ten minutes.

Two, because losing access to your firewall isn't worth being clever about. The boot hook is the fast path, but it runs early — before the network is necessarily up. The ten-minute job doesn't care: it notices whenever the agent stops working and repairs it. The worst case after an upgrade is a ten-minute wait, not a locked door. persist then verifies both hooks landed, and fails loudly if either didn't.

What it checks is that the agent actually runs, not that its file is present. The broken-link case above leaves an hle that looks perfectly healthy to anything testing for a file, which is how you end up with a firewall you can't reach and a script cheerfully reporting success.

The token comes back too, so the agent reconnects as the same agent with your tunnels intact. Nothing to fix in the dashboard, no new token.

Routine pkg upgrade within the same firmware version doesn't disturb anything.

Prefer to keep the firewall untouched? Run the agent on any other LAN machine and point an tunnel at https://192.168.1.1. It reaches the firewall just as your browser does. Both setups are supported.

Two things that catch people

“An HTTP_REFERER was detected other than what is defined in System > Advanced” — or a redirect loop, or a complaint about the hostname. All the same cause: you skipped the hostname step above, or added a second tunnel that needs its own entry.

fetch -o /tmp/hle-pfsense.sh https://hle.world/scripts/pfsense.sh
sh /tmp/hle-pfsense.sh allow-hostname <the-hostname-in-the-error>

Don't reach for the “disable referer check” toggle instead — that's genuine CSRF protection for your firewall's admin UI, and allowlisting the hostname is exactly what the field is for.

A command hangs on a ? prompt. You're in tcsh, pfSense's default root shell, waiting on input that will never come. Press Ctrl-C and run sh first — every command here assumes it.

A 502. The agent can't reach the service. Test it from the firewall:

curl -sk https://127.0.0.1 -o /dev/null -w '%{http_code}\n'

A 200 or 302 means the path is fine and the problem is elsewhere — check /var/log/hle_agent.log.

Reach your firewall from anywhere

Free tier, no card. The agent runs on pfSense, OPNsense, or any FreeBSD box.