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.
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.
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.
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.
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.
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.
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.
“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.
Free tier, no card. The agent runs on pfSense, OPNsense, or any FreeBSD box.