09 July 2026

`apt` on a no-subscription Proxmox host: the 401, and installing around it

A quick one, because it bites everyone who runs Proxmox without a subscription and it's not obvious the first time. You go to install something, and apt falls over before it does anything useful:

E: Failed to fetch https://enterprise.proxmox.com/debian/pve/dists/bookworm/InRelease  401  Unauthorized
E: The repository 'https://enterprise.proxmox.com/debian/pve bookworm InRelease' is not signed.

That's the enterprise repository, which needs a paid subscription key. Without one it returns 401, and because apt-get update exits non-zero, anything that runs update first — including a lot of vendor install scripts (curl ... | sh) — aborts before installing your actual package. Maddening, because the package you wanted may have nothing to do with Proxmox at all.

The quick way through (one package)

apt-get install uses the package lists already on disk; it doesn't insist on a fresh update. So if the lists for the repo you need were fetched at all, just install directly and let the enterprise 401 be someone else's problem:

DEBIAN_FRONTEND=noninteractive apt-get install -y <package>

That got a third-party tool onto a box for me even with the enterprise repo still throwing 401s in the background.

The proper fix (do this once)

If you're genuinely running no-subscription, switch to the no-subscription repo — that's the supported free channel, and crucially it's where pve-headers live (which you'll want the moment you build a DKMS module):

echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
  > /etc/apt/sources.list.d/pve-no-subscription.list
# and disable the enterprise list so update stops erroring:
# comment out the line in /etc/apt/sources.list.d/pve-enterprise.list
apt-get update
apt-get install -y pve-headers-$(uname -r)

(There's also a ceph enterprise list that 401s the same way if you use Ceph — same treatment.)

Notably, none of this requires turning off the enterprise repo to install around it in a pinch — but for a box you'll keep, sort the sources out properly so update is clean and DKMS rebuilds don't surprise you after a kernel bump.

I hope this saves you the head-scratching. Enjoy!

Clicking "I accept" on a captive portal — for a headless server

Captive portals are a fact of life on guest wifi: connect, get redirected to a page, tick a box or log in, and you're online. Fine for a laptop. A proper pain for a headless server that has no browser and no one sitting in front of it. Here's the trick I used to get a headless box past a per-MAC portal, and it generalises nicely.

Why you can't just do it from your laptop

The portal authorises per device — it remembers the MAC (and/or IP) that completed the login and lifts the walled garden for that client. So logging in from your laptop's browser authorises your laptop, not the server. The server's own connection is still walled. You have to make the login traffic originate from the server itself.

The trick: proxy a browser through the server

If you can SSH to the box (over a wired path, a second interface, whatever), turn it into a SOCKS proxy:

ssh -D 1080 -N user@server

Now anything you send into localhost:1080 is originated by the server and exits its network connection. Point a browser at that proxy and, from the portal's point of view, it's the server knocking — correct MAC, correct session. Chromium does remote DNS over SOCKS5, so name resolution goes out the server's link too.

I drove it headlessly with Playwright through the proxy:

chromium --proxy-server="socks5://localhost:1080"
# or, in Playwright, launch with proxy={"server":"socks5://localhost:1080"}

Navigate to anything over plain HTTP (http://neverssl.com is the classic), let the portal redirect fire, accept the terms / sign in, and the gateway authorises the server's MAC. Once that's done you can tear the proxy and browser down entirely — the authorisation lives at the gateway, keyed to the MAC, not in the browser session.

Making it stick

Two wrinkles for unattended use:

  • Sessions expire. Wrap the same headless flow in a small script and have a watchdog run it whenever a connectivity check (curl -s -o /dev/null -w '%{http_code}' http://connectivitycheck.gstatic.com/generate_204, want 204) comes back non-204. A persistent browser profile keeps any "remember me" cookie between runs.
  • Registration vs. login. Some portals (Sky's "The Cloud", BT Wi-fi and friends) want an account. A throwaway mailbox with an API — I used mail.tm — lets you register and, if needed, script the catching of a verification email.

It feels a bit like sawing through the bars from the inside, but it's entirely above board — you're authenticating the device that's actually using the connection. The SOCKS-proxy-through-the-box idea is the reusable part; keep it for the next headless thing stuck behind a portal.

I hope this resolves any difficulties you may be experiencing. Enjoy!

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!

Is your firewall man-in-the-middling you? Check the certificate issuer.

A VPN I was setting up simply refused to connect — no error I could act on, just a sulk. The thing that explained it took one command, and it's a trick worth having in your pocket whenever a service mysteriously won't talk on a network you don't control.

The one command

echo | openssl s_client -connect controlplane.example-vpn.com:443 -servername controlplane.example-vpn.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject

What came back was not the certificate I expected:

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

That O=Fortinet issuer is the smoking gun. A FortiGate firewall was doing deep SSL inspection — terminating the TLS, presenting its own certificate signed by the appliance's CA, and re-encrypting onwards. My client validates that endpoint's certificate against the public trust store, the Fortinet CA isn't in it, the handshake fails, and the whole thing quietly gives up. (The VPN's own logs, to its credit, even said the cert "looks like Fortinet equipment" — they ship a detector for exactly this.)

The genuinely useful bit: map what's tampered with

Interception is rarely applied to everything — it's expensive, and it breaks things. So check a handful of endpoints and compare issuers; the pattern tells you your firewall's policy:

Endpoint Issuer seen Verdict
controlplane.example-vpn.com Fortinet intercepted
api.cloudflare.com Google Trust Services clean
github.com Sectigo clean

In my case the interception was targeted — the VPN's control plane specifically, everything else untouched. That immediately reframed the problem: stop fighting the blocked thing, and pick a transport the firewall leaves alone (a Cloudflare tunnel egressed perfectly happily, real cert and all).

Why this matters beyond "my VPN broke"

If a corporate box is inspecting your TLS, it can read everything that crosses it that it has decrypted — which is the point of the appliance, but worth being clear-eyed about. The cert issuer is the quickest way to find out whether and where it's happening. A real public CA (Let's Encrypt, Google Trust Services, Sectigo, DigiCert) on the cert means end-to-end; your employer's name, or "Fortinet"/"Palo Alto"/"Zscaler", means the conversation is being opened in transit.

Go and openssl s_client a few of your own endpoints — you may be surprised who's signing them.

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