13 July 2026

I thought Cloudflare Access was guarding my Proxmox box. It wasn't.

I'd just put a Proxmox server behind a Cloudflare Tunnel — two public hostnames, one for SSH and one for the web UI — and I was quietly pleased with myself. I already run a Zero Trust setup on the domain, a wildcard application that demands an email one-time-code before it lets anyone near anything. So the new box was protected too. Obviously.

It was not protected. And the way I found out is worth writing down, because the failure is silent and the test for it is genuinely easy once you know what you're looking at.

The niggle

I was testing the new web UI hostname and ran a plain curl at it — no browser, no cookies, nothing. It cheerfully handed me back the Proxmox login page. HTTP 200.

That shouldn't happen. If Cloudflare Access were sitting in front, an unauthenticated request should never reach the origin — it should be bounced to the login screen. Getting the actual Proxmox page from a credential-free curl is the sound of a stable door swinging in the wind.

My first instinct was to explain it away — "this machine's already authenticated, it's passing through." Plausible! And wrong, as it turned out, which is the useful bit.

The insight: where Access actually sits

Cloudflare Access runs at the edge, before your request is proxied down the tunnel to your origin. So for a hostname an Access policy actually covers, an unauthenticated request gets a 302 redirect to your-team.cloudflareaccess.com and never touches the tunnel. You can test it without even involving the origin:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -i https://your-host.example.com
  • 302 → *.cloudflareaccess.com — Access is enforcing. Good.
  • 200 (origin), or 530 (tunnel down) — your request reached the tunnel layer, which means Access did not stop it. Not protected.

That last point is the neat one: even with my origin offline later (tunnel down, returning 530), the test still worked — because a 530 only happens after a request has cleared the edge. If Access were guarding the hostname I'd have got the login redirect regardless of whether the origin was up. A 530 to an unauthenticated caller is itself a finding.

The confounder: don't test from your own machine

Here's the trap I nearly fell into. If you test from a device that's enrolled in WARP, or that's already completed the email-code dance in a browser, your requests carry a valid Access session — so they sail straight through and everything looks protected. False negative.

Two things to check on your testing machine first:

curl -s https://www.cloudflare.com/cdn-cgi/trace | grep -E 'warp=|gateway='   # want warp=off gateway=off
ls ~/.cloudflared/                                                            # cached *-token files = you hold sessions

In my case the laptop wasn't on WARP — but ~/.cloudflared/ held an org-level Access token and a token for a different host entirely. So my "I'm already authenticated, it's fine" hunch was half right: I held a session, but for other applications. The new box had no application at all. Test from somewhere with an empty ~/.cloudflared, no WARP, and no browser cookies.

The actual cause

This is the part worth tattooing somewhere. A wildcard Access application does not automatically protect every subdomain you later create. And creating a tunnel route — cloudflared tunnel route dns <tunnel> <hostname> — only writes the CNAME. It does not create an Access application. So my two new hostnames had a working tunnel, a working DNS record, and precisely nothing standing between the public internet and a Proxmox login (and, for the SSH hostname, only the root password). The wildcard app I was relying on simply didn't match them in the way I'd assumed.

The fix

Add a real Access application for each hostname (Zero Trust → Access → Applications → Add → Self-hosted):

  • One application per hostname (or a wildcard you've verified matches), Allow policy, with an identity requirement — email OTP at minimum, ideally device posture or a group.
  • Keep the session short.
  • Re-run the curl test from a clean machine afterwards and confirm you get the 302 to *.cloudflareaccess.com.

For SSH hostnames specifically, the Access app is the front door but it isn't the only lock — make sure the origin itself isn't accepting password root logins behind it. Belt and braces.

How to audit the whole lot

If you've made this mistake once, you've probably made it more than once, so audit everything rather than spot-fixing. Briefly:

  1. List your DNS records and flag every CNAME pointing at *.cfargotunnel.com — those are tunnel-backed hostnames.
  2. List your tunnels and their ingress rules (note: locally-managed tunnels keep their ingress in /etc/cloudflared/config.yml on the origin, not in the dashboard API — you have to read the box).
  3. List your Access applications and their policies — note any bypass decisions, which are effectively "no protection" (sometimes that's deliberate; document why).
  4. Cross-reference: for every tunnel-backed hostname, is there an Access app, what's the policy, and does it require MFA? Anything fronting SSH, RDP or a hypervisor UI without an Allow+MFA policy is exposed.
  5. Verify each one empirically with the clean-machine curl test. Config and reality don't always agree.

The Cloudflare API (read-only token) makes steps 1–3 scriptable; I'll spare you the JSON here.

The lesson

The uncomfortable bit isn't that I'd misconfigured something — it's that I'd assumed a protection existed and never tested the assumption, because the dashboard looked tidy and the tunnel worked. A tunnel coming up is not the same as a door being locked. Test the lock, from outside the building, with no keys in your pocket.

Ta ta for now, and do go and curl your own hostnames — you might get a surprise.

A Realtek RTL8821AU on kernel 6.8, and the `new_id` trap that hung my reboot

I plugged a USB wifi dongle into a headless Linux box and got... nothing. No wl interface, no driver bound. What followed was a proper little saga involving an out-of-tree driver, the wrong driver grabbing the device, and a reboot that wouldn't reboot. Here's the whole thing so you can skip the bits I didn't.

Identifying the chipset (and the usual gripe)

lsusb
... ID 2357:0120 TP-Link 802.11ac WLAN Adapter

USB 2357:0120 is a TP-Link Archer T2U Plus, chipset Realtek RTL8821AU. The product string just says "802.11ac WLAN Adapter", which is no help at all — and TP-Link cheerfully reuse that string and shuffle USB IDs across hardware revisions. (When will manufacturers learn not to make us reverse-engineer which silicon we actually bought?)

On kernel 6.8 there's no in-tree driver that binds it: rtl8xxxu lists a clutch of 2357:01xx IDs but not 0120, and rtw88 covers newer chips. So it's the out-of-tree DKMS driver.

Building the driver

morrownr's maintained fork does the job. On a Debian/Proxmox box you'll need the matching kernel headers and a toolchain first:

apt-get install -y dkms git build-essential
# headers matching `uname -r` (on a no-subscription Proxmox host, from the no-subscription repo)
git clone https://github.com/morrownr/8821au-20210708.git
cd 8821au-20210708 && ./install-driver.sh NoPrompt

DKMS reported rtl8821au/5.12.5.2 ... installed and the module loaded — but still no interface. That's where it got interesting.

The trap: the wrong driver had already grabbed it

While poking about earlier I'd modprobe'd one of the rtw88 USB drivers and added the device with a runtime new_id override. That driver had claimed the dongle — so the correct 8821au driver couldn't bind it, even though it was loaded. Worse, when I tried to unbind it:

echo -n "1-3:1.0" > /sys/bus/usb/drivers/rtw_8822bu/unbind   # <-- hung

The write blocked in uninterruptible sleep (D state). You cannot kill a D-state process; it's wedged in the kernel waiting on the driver's disconnect path. And here's the sting in the tail: that stuck process then hung the rebootsystemd-shutdown waited 90 seconds for it, failed to remount the root filesystem read-only ("Device or resource busy"), forced the reboot anyway, and I finished it with a hard power-cycle. (No harm done — ext4 journalled-recovered on the way back up.)

The fix that stuck

Blacklist the drivers that shouldn't touch it, so on boot only the right one matches:

# /etc/modprobe.d/wifi-dongle.conf
blacklist rtw88_8822bu
blacklist rtw88_8821cu
blacklist rtl8xxxu

Reboot. With the imposters out of the way, 8821au claimed the device and wlx<mac> appeared, type managed, ready for wpa_supplicant.

The lesson I'll carry: new_id overrides are runtime-only and brilliant for experimenting, but if one goes wrong, don't fight the live binding — a reboot clears the runtime state and a blacklist makes the result permanent. Trying to unbind a wedged USB driver by hand is how you end up power-cycling a server.

Happy prototyping!

12 July 2026

Building a Triggerable Home Lab Health Check with Python and systemd

The Claude Code sessions on my laptop dropped out mid-conversation. The internet felt fine — pages loaded, DNS resolved — but I couldn't be certain whether something had shifted on the LAN. What I wanted was a single command that would sweep through everything: public internet reachability, DNS resolution, gateway, every Incus container with a .lan address, and a handful of remote hosts. One invocation, one exit code.

What I discovered while building it made the effort worthwhile almost immediately.

The DHCP drift problem

My home lab includes a TP-Link Kasa HS110 smart plug (running on python-kasa) that controls mains power to a Proxmox node with no IPMI — it's the only remote kill switch available. That plug's IP was documented everywhere as 10.140.1.235. The health check tried it and got silence.

A quick nmap sweep by MAC address found it immediately at 10.140.1.236:

sudo nmap -sn 10.140.0.0/22 | grep -B2 D8:47:32:A3:FF:CA
Nmap scan report for HS110.lan (10.140.1.236)
Host is up (0.075s latency).
MAC Address: D8:47:32:A3:FF:CA (TP-Link Technologies)

The DHCP lease had quietly shifted by one. The plug was on, PVE2 was drawing 126 W and happily running — I just wouldn't have been able to reach it from any documented procedure. The lesson is obvious in hindsight: for anything critical, find it by MAC, not by IP. The health check script now tries the known IP first and falls back to an nmap sweep if it doesn't respond, reporting the new address so you can update your docs.

Adding missing .lan entries

While auditing the LAN, I found two services without .lan entries: Gitea (running in an LXC container on a second Proxmox node at 10.140.3.116:3000) and the Xenon Proxmox node itself (at 10.140.3.82:8006). Both get Caddy reverse proxy entries — Xenon needs a tls_insecure_skip_verify because Proxmox uses a self-signed certificate:

# /etc/caddy/sites-enabled/gitea.caddy
gitea.lan {
    tls internal
    reverse_proxy http://10.140.3.116:3000
}
# /etc/caddy/sites-enabled/xenon.caddy
xenon.lan {
    tls internal
    reverse_proxy https://10.140.3.82:8006 {
        transport http {
            tls_insecure_skip_verify
        }
    }
}
sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy

The DNS priority conflict

Here's where it got interesting. After reloading Caddy, curl https://gitea.lan/ returned a connection refused — but not because of Caddy. Running resolvectl query gitea.lan showed it resolving to 10.140.3.116 directly rather than 127.0.0.1 (which is what the dnsmasq wildcard should return).

The setup uses dnsmasq at 127.0.0.2 with address=/.lan/127.0.0.1 to catch all .lan names and send them to Caddy. systemd-resolved is configured in /etc/systemd/resolved.conf.d/lan.conf to route .lan to that dnsmasq instance:

[Resolve]
DNS=127.0.0.2
Domains=~lan

The problem: the wired ethernet DHCP lease (from the router at 10.140.2.6) also pushes domain=lan, which means systemd-resolved registers the router as a per-link routing DNS for .lan as well. Since the router has a genuine DHCP record for gitea.lan (it knows about the Gitea container's lease), it answers first — and systemd-resolved never falls back to dnsmasq.

Setting dns-priority=100 on the NetworkManager connection should tell systemd-resolved to prefer the global server, but the change didn't propagate cleanly in this session. The reliable workaround is an /etc/hosts entry:

printf '127.0.0.1 gitea.lan\n127.0.0.1 xenon.lan\n' | sudo tee -a /etc/hosts

Inelegant, but unambiguous. getent hosts gitea.lan immediately returned 127.0.0.1 and Caddy took over correctly. The underlying dns-priority issue is worth a proper investigation another day — the nmcli setting is:

sudo nmcli con mod "Wired connection 3" ipv4.dns-priority 100
sudo nmcli con mod "MiHA-2G 2" ipv4.dns-priority 100

The health check script

The script lives at agents/lan_health.py and covers four categories: internet (ICMP to 1.1.1.1 and 8.8.8.8, pass if either responds), DNS resolution, LAN host pings, and HTTPS checks against every .lan service. The HS110 section is the most useful part:

HS110_MAC = "D8:47:32:A3:FF:CA"
HS110_IP  = "10.140.1.236"   # DHCP — may drift

def find_hs110():
    r = subprocess.run(
        ["sudo", "nmap", "-sn", "--open", "10.140.1.0/24"],
        capture_output=True, text=True, timeout=30
    )
    lines = r.stdout.splitlines()
    for i, line in enumerate(lines):
        if HS110_MAC.upper() in line.upper():
            for j in range(max(0, i-3), i):
                if "report for" in lines[j]:
                    return lines[j].split()[-1].strip("()")
    return None

If the known IP doesn't respond, find_hs110() runs the sweep and returns the new IP (or None if the plug genuinely isn't on the network). Output is colour-coded; exit code is non-zero on any failure, which makes it composable with scripts or monitoring.

The systemd service

The whole point is to trigger this on demand rather than poll continuously. A oneshot unit is the right shape:

# /etc/systemd/system/lan-health.service
[Unit]
Description=Home LAN health check
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/home/user/claude/.venv/bin/python3 /home/user/claude/agents/lan_health.py
User=user
StandardOutput=journal+console
StandardError=journal+console
sudo systemctl daemon-reload
systemctl start lan-health.service
journalctl -u lan-health.service -n 50 --no-pager

No persistent daemon, no polling — it runs when asked, logs to the journal, and exits. StandardOutput=journal+console means you see the output immediately if you're watching, and journalctl -u lan-health.service gives you the last run any time you want it.

First invocation caught the HS110 drift, three services returning 502 (backends stopped, proxies still up), and confirmed everything else was healthy. Not a bad return for an afternoon's work.

References

  • nmap — network discovery; -sn ping scan, MAC address lookup in sweep output
  • Caddy v2 — automatic HTTPS reverse proxy; tls internal for self-signed .lan certs, tls_insecure_skip_verify for upstream self-signed
  • dnsmasq — lightweight DNS/DHCP; address=/.lan/127.0.0.1 wildcard used here
  • systemd-resolved — stub resolver; Domains=~lan routing domain config
  • resolvectl(1)resolvectl query for diagnosing which DNS server answered
  • NetworkManager / nmcliipv4.dns-priority for per-link DNS priority
  • systemd.service(5)Type=oneshot for run-and-exit services
  • python-kasa — TP-Link Kasa smart device control (used separately to read HS110 wattage)
  • Python stdlib: subprocess, socket, urllib.request, ssl — all used in the health check script

Ta ta for now, Matt

Getting a Proxmox box online — and remotely reachable — through a corporate network that really didn't want me to

I had a Proxmox server sitting on a desk at work (let's call it NERIC), no IPMI, no remote anything, and a narrow window in which I was physically next to it. The goal was simple to state and, as it turned out, a mite tricky to achieve: get NERIC permanently reachable from home, over a university network that is locked down by a corporate firewall and has no obvious route in or out.

This is the story of how that went — including the several dead ends, because the dead ends are where the actual learning lives.

A note on the clock: I was at work with maybe an hour, and my laptop — the only thing on the same wired segment as NERIC — was going home with me at the end of it. So a recurring theme below is "make the right decision given that I'm about to lose my only local access." That pressure shaped a lot of the choices.


Step 0: I couldn't even reach it

First problem: NERIC (192.168.100.2) wouldn't answer a ping or SSH from my laptop, despite both being plugged into the same physical network. ARP resolved fine, though — which is the tell.

arp -n | grep 192.168.100.2
192.168.100.2  ether  04:d4:c4:xx:xx:xx  C  enx1ce192b3ec51

ARP works at layer 2, so the wire is fine. ICMP and TCP timing out while ARP succeeds is the classic signature of asymmetric routing: my packets reach NERIC, but NERIC can't get its replies back. A quick tcpdump confirmed it — NERIC received each ping and immediately ARPed for its gateway (192.168.100.1) trying to route the reply, got no answer, and gave up. My laptop was sourcing from 192.168.0.143 (a /16), which isn't on NERIC's local /24, so NERIC needed a gateway that was dead.

The fix is local and reversible — give my laptop a second IP on NERIC's subnet so the replies come straight back over ARP, no gateway required:

sudo ip addr add 192.168.100.50/24 dev enx1ce192b3ec51
ssh -b 192.168.100.50 root@192.168.100.2

And I was in. (Interestingly, ICMP stayed firewalled even after this — but TCP worked, which is all I needed. Don't let a failed ping convince you the path is dead.)


Step 1: a rabbit hole — "why does NERIC have no internet?"

Inside, the picture was bleak: vmbr0 statically set to 192.168.100.2/24, gateway 192.168.100.1, and that gateway stone dead. No internet, DNS broken, nothing.

I spotted that the live router on the wire was actually 192.168.0.1 (my laptop's own gateway, healthy at ~2ms), on a different subnet to NERIC entirely. So I did the clever thing — an onlink default route so NERIC would ARP for an off-subnet gateway directly:

ip route replace default via 192.168.0.1 dev vmbr0 onlink

NERIC could now reach 192.168.0.1... and traceroute died at exactly one hop. The router took the packets and refused to forward them. I burned a little time giving NERIC a temporary in-subnet IP to make it NAT-eligible — still nothing.

Then the penny dropped, and it's embarrassing in hindsight: my laptop's own internet was coming over wifi, not that wired segment at all (the wired default route had a much higher metric). I'd never actually established that 192.168.0.1 had a path to the internet — and it didn't. The whole wired segment was an island. I'd been trying to route through a door that opened onto a wall.

Lesson re-learned: verify your assumptions about the upstream before building anything on top of them. curl to a real host beats a ping to the gateway every time.

At this point the owner of the network kindly reconfigured the router to actually serve 192.168.100.0/24 with a working gateway — but testing from both NERIC and the laptop showed the gateway answered on the LAN yet still wouldn't pass DNS or TCP to the outside. The segment simply had no usable WAN. Dead end, cleanly confirmed.


Step 2: Tailscale — and why it was never going to work here

The plan had been Tailscale. I installed it, ran tailscale up... and it sat there refusing to produce a login URL. The daemon log told me exactly why, and it's worth quoting because Tailscale ships a detector for precisely this:

tlsdial: server cert seen while dialing "controlplane.tailscale.com"
         looks like "Fortinet" equipment (could be blocking Tailscale)
Received error: fetch control key: ... x509: certificate signed by unknown authority

A quick look at the certificate confirmed it:

issuer=C=US, O=Fortinet, OU=Certificate Authority, CN=<appliance-serial-redacted>
subject=CN=controlplane.tailscale.com

There's a FortiGate doing SSL deep-inspection, and it's specifically intercepting Tailscale's coordination server with its own (untrusted) certificate. login.tailscale.com and tailscale.com passed through untouched — this is targeted. Tailscale validates that control-plane cert against the system trust store, the Fortinet CA isn't in it, the handshake fails, and you never get a login URL. There is no client-side fix; only the network team can exempt it.

So Tailscale was out. Not misconfigured — blocked. Good to know definitively rather than fiddle with it for an hour.

The useful question then became: what isn't blocked? I checked the certificates on a few endpoints:

Endpoint Cert issuer Verdict
controlplane.tailscale.com Fortinet intercepted
api.cloudflare.com Google Trust Services clean
update.argotunnel.com Google Trust Services clean
github.com Sectigo clean

And region1.v2.argotunnel.com:7844 (Cloudflare's tunnel port) connected fine. Cloudflare tunnels sail straight through this firewall. I already run cloudflared for another Proxmox box at home, so the decision made itself: cloudflared, not Tailscale.


Step 3: but first, NERIC needs its own internet — enter the wifi dongle

A tunnel is no use if the box can't get online once I've left with the laptop. The owner plugged in a USB wifi dongle, and this turned into its own little saga.

It enumerated as a TP-Link "802.11ac WLAN Adapter", USB ID 2357:0120 — which a quick search identifies as a TP-Link Archer T2U Plus, chipset Realtek RTL8821AU. No driver bound, no wl* interface, and (naturally) no in-kernel support on the PVE 6.8 kernel. The mainline rtl8xxxu lists 0107/0108/...0126 but not 0120; rtw88 has the 8821cu/8822bu families but, again, not this ID.

So it's the out-of-tree 8821au DKMS driver (morrownr's maintained fork). That needs kernel headers, which on a no-subscription PVE host means adding the no-subscription repo to get pve-headers-6.8.12-4-pve. Build tools and the driver went in fine:

git clone https://github.com/morrownr/8821au-20210708.git
cd 8821au-20210708 && ./install-driver.sh NoPrompt

DKMS reported rtl8821au/5.12.5.2 ... installed and the module loaded — but still no interface. The reason was entirely self-inflicted: while experimenting earlier I'd modprobe'd rtw88_8822bu and it had grabbed the device via a runtime new_id override, so the correct driver couldn't claim it. Unbinding it hung — the unbind write blocked in uninterruptible (D) state, which is about as stuck as Linux gets.

A reboot was the clean way out. Except the reboot also hung — on the very same wedged process. The console showed systemd-shutdown waiting 90 seconds for it, failing to remount root read-only ("Device or resource busy"), and finally forcing the reboot. A hard power-cycle finished the job. (No data loss — ext4 journalled-recovered cleanly on the way back up.)

On the next boot, with the conflicting drivers blacklisted:

# /etc/modprobe.d/neric-wifi-dongle.conf
blacklist rtw88_8822bu
blacklist rtw88_8821cu
blacklist rtl8xxxu

...the right driver won, and wlxXXXXXXXXXXXX appeared. Lesson: when you've been poking at new_id overrides, a reboot clears the runtime cruft and a blacklist makes the result stick.


Step 4: the captive portal — authenticating a headless server

The dongle associated to the open WiFi-Guest SSID and got a DHCP lease immediately. But any HTTP request bounced to a familiar wall:

Location: https://wifi.sky.com/login?client_mac=XXXXXXXXXXXX&reason=Un-Auth-Captive...

It's Sky WiFi / "The Cloud" — a per-MAC captive portal, which is precisely the wrong thing to put in front of a headless server. There's no browser to click "Continue", and the portal authorises the MAC that hits it — so I couldn't just log in from my laptop's browser, because my laptop was on a different network (eduroam, as it happens) with a different MAC.

The trick that solved it is the bit I'm most pleased with. I turned NERIC into a SOCKS proxy over the SSH connection I already had:

ssh -D 1080 -N -b 192.168.100.50 root@192.168.100.2

The SSH control channel rides the stable wired path; anything sent into that proxy is originated by NERIC and exits its wifi connection. So I pointed a headless Playwright browser at the proxy, and from the portal's point of view it was NERIC knocking — correct MAC, correct session. I accepted the terms, registered a Cloud account against a disposable mailbox, signed in, and:

(The throwaway address, in case it's handy: neric1780499778@wshu.net, from the rather useful mail.tm. It has an API, which is what makes it scriptable for catching a verification email — though The Cloud didn't end up demanding one. It'll lapse before long, so do generate your own rather than relying on mine.)

generate_204 -> 204      (no longer walled)
github.com   -> 200
api.cloudflare.com -> 200

We were online. (The authorisation lives at the gateway keyed to NERIC's MAC, so once done I could tear the browser and proxy down — the box stays connected on its own.)


Step 5: making it survive a reboot (and me leaving)

A working setup that evaporates on reboot is not a setup. Two services make this persistent.

A wifi watchdog (neric-wifi.service) that re-associates, re-DHCPs, and — crucially — forces the default route over wifi, overriding the dead wired gateway that /etc/network/interfaces still installs at boot. I deliberately did not edit interfaces (no appetite for breaking PVE's bridge config remotely); the watchdog just out-prioritises the dead route at runtime, every boot, and re-checks every 30 seconds.

cloudflared, installed as a service, publishing two hostnames off my existing tunnel zone:

ingress:
  - hostname: neric-pve.example.com      # SSH
    service: ssh://localhost:22
  - hostname: neric-pve-ui.example.com   # Proxmox web UI
    service: https://localhost:8006
    originRequest:
      noTLSVerify: true
      disableChunkedEncoding: true
  - service: http_status:404

(noTLSVerify and disableChunkedEncoding because Proxmox uses a self-signed cert internally and doesn't play nicely with chunked encoding through a tunnel — learned that on the previous box.)

And because The Cloud's session can eventually expire, the watchdog calls a headless Playwright re-auth script on NERIC when it detects a walled state — replaying the same "accept terms, sign in" flow that worked manually.


Does it actually work?

Tested from the public internet, with the laptop's scaffolding (NAT, SOCKS, the IP alias) all torn down — so this is genuinely NERIC standing on its own:

Test Result
Internet on NERIC (generate_204) 204
Default route via 10.223.88.1 dev wlxXXXXXXXXXXXX (wifi)
neric-wifi.service active, enabled
cloudflared.service active, enabled (4 QUIC conns to LHR edge)
SSH via cloudflared access ssh --hostname neric-pve.example.com returns hostname pve
PVE UI https://neric-pve-ui.example.com HTTP 200, "pve - Proxmox Virtual Environment" ✅
Re-auth script dry run already online, exit 0 ✅

So: a Proxmox box, online via a USB wifi dongle, behind a captive portal, past a FortiGate that blocks Tailscale — and reachable from anywhere over SSH and the web UI. Et voilĂ .


A word on decisions under time pressure

A few calls were made specifically because the clock was running:

  • Pre-stage over perfect. I got cloudflared up and tested before the laptop left, because cloudflared is my only way back in afterwards. Everything else can be refined remotely.
  • Watchdog over editing interfaces. Forcing the route at runtime is uglier than a clean config edit, but it can't brick the host's networking — and I wouldn't be there to fix it if it did.
  • Re-auth on the host for now. The neater home for the headless browser is an LXC (keep the hypervisor clean), but downloading a template and wiring its networking through the host's wifi was too much to finish in the window. Host now, container later — over the tunnel.

Not the choices I'd make with unlimited time, but the right ones for the time I had.


Next steps

  • Migrate the portal re-auth browser into an LXC and strip Playwright/Chromium off the hypervisor.
  • Tidy the now-unused DNS record left over from a rename.
  • The dormant Tailscale install stays put — it costs nothing, and if the network team ever exempts *.tailscale.com from inspection, it'll just connect.

If you're ever trying to do something similar — a headless box on a hostile corporate network — the two ideas worth stealing are the SOCKS-proxy-through-the-server trick for satisfying a per-MAC captive portal, and checking certificate issuers to find out what your firewall is and isn't tampering with before you commit to a tunnelling strategy.

Ta ta for now, and I hope you found this helpful.