The now-working iPhone-only, passkey-gated self-hosted admin panel at admin.levandor.io. A tap-to-run control surface for the homelab: passkey login (Pocket-ID) → OIDC-authenticated proxy → OliveTin runs YAML “actions” that ssh into the hosts. This note captures the full request chain, the exact env/config that makes it work, and the two non-obvious gotchas that cost the most debugging time.
For Agents
Everything lives on telep-mainframe (
192.168.1.123, tailnet100.115.209.87) in a docker-compose stack at/home/levander/admin-portal/. Chain: Caddy (host, TLS) → oauth2-proxy (OIDC, PROXY mode, container) → OliveTin (container,sshactions). Identity provider is Pocket-ID atid.levandor.io(also on this box). Two failure modes are documented as callouts below — read them before touching login or the ssh actions.
Architecture
Request chain
iPhone (passkey)
│ https://admin.levandor.io
▼
Caddy (host /etc/caddy/Caddyfile, TLS via Cloudflare DNS-01, bind 192.168.1.123)
│ reverse_proxy → oauth2-proxy
▼
oauth2-proxy (container, PROXY mode, OIDC against Pocket-ID)
│ OAUTH2_PROXY_UPSTREAMS = http://olivetin:1337 ← compose service name, NOT loopback
▼
OliveTin (container jamesread/olivetin, user: root)
│ YAML actions
▼
ssh → mainframe 192.168.1.123 · router root@100.69.112.32 · pi/kraken 192.168.1.200
Identity provider — Pocket-ID (id.levandor.io)
- Pocket-ID v2.13, a passkey (WebAuthn) OIDC provider. SQLite DB at
pocket-id/data/pocket-id.dbunder the stack dir. - OIDC client “Mainframe fallback”,
client_idfbd09167-4f21-4277-80e7-b635f35c2861. - This is the single-admin login: one self-registered passkey account, no external verifier.
Caddy (host /etc/caddy/Caddyfile)
The admin.levandor.io site block, with the load-bearing details:
Caddy config footguns (three of them)
bind 192.168.1.123is REQUIRED in theadmin.levandor.ioblock. Without it Caddy tries to grab:443on all interfaces and collides with Tailscale, which owns100.115.209.87:443. Binding the LAN IP sidesteps the collision.- TLS must be the multi-line form —
tls {⏎dns cloudflare {env.CF_API_TOKEN}⏎}. The single-linetls { dns cloudflare {env.CF_API_TOKEN} }fails to parse.- After any cert/env change,
systemctl restart caddy— areloadis NOT enough to pick up the new Cloudflare token / cert.
- TLS is issued via the Cloudflare DNS-01 challenge (no public :80/:443 exposure needed).
- The Cloudflare API token lives in
/etc/caddy/cloudflare.env(root, mode600), loaded into the caddy process via a systemd drop-in (EnvironmentFile=), surfaced to the Caddyfile as{env.CF_API_TOKEN}.
oauth2-proxy (PROXY mode, container)
Runs as an authenticating reverse proxy in front of OliveTin. The env that makes it work (oauth2-proxy.env):
| Variable | Value | Why |
|---|---|---|
OAUTH2_PROXY_UPSTREAMS | http://olivetin:1337 | Compose service name, not 127.0.0.1:1337. Loopback = oauth2-proxy’s OWN container → 502 (see gotcha). |
OAUTH2_PROXY_PROMPT | login | Empty approval_prompt falls back to force → Pocket-ID returns invalid_request. |
OAUTH2_PROXY_CODE_CHALLENGE_METHOD | S256 | The Pocket-ID OIDC client requires PKCE. |
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL | true | Accept the self-hosted account whose email is unverified (see gotcha 1). |
Outstanding warning (accepted, low risk)
oauth2-proxy logs
WARNING: no --trusted-proxy-ip set— it currently trustsX-Forwarded-*from any IP. Low risk because only Caddy can reach the container (nothing else is on that docker network path). A--trusted-proxy-iprestriction was offered but not yet applied.
OliveTin (container, user: root)
- Config at
/home/levander/admin-portal/olivetin/config/config.yaml. - Actions are now in Hungarian —
pageTitle: "Telep vezérlőpult". The English version is backed up atconfig.yaml.bak-en. - Each action
sshes into a host to run a command. Targets:- mainframe —
192.168.1.123 - router —
root@100.69.112.32(tailscale IP) - pi / kraken —
192.168.1.200
- mainframe —
- The OliveTin ssh identity/config/known_hosts live under
olivetin/ssh/in the stack dir; the public key isolivetin/ssh/id_ed25519.pub.
OliveTin self-test (telep-selftest) — 2026-08-15 refactor
The status/health OliveTin actions were redesigned to keep all logic OUT of the YAML and in a single shell script on the mainframe. This fixes the “Szolgáltatások” action failing and makes the whole status surface DRY.
The redesign
- One self-test script deployed at
/usr/local/bin/telep-selfteston telep-mainframe (192.168.1.123). It prints clean colored✓ / ✗ / ⚠output grouped into sections. - Sections:
dns,airplay,camwall,services,cameras,health,alerts, andall. - Usage:
telep-selftest [section|all]. - The OliveTin status actions now just call
ssh 192.168.1.123 'telep-selftest <section>'— no per-action shell logic, no quoting/template traps in the YAML. - Two new actions added this session: “Teljes önteszt” (runs
all) and “Riasztások (Telegram)” (runsalerts). - Script source authored this session; the OliveTin container already runs as
user: rootwith the writable/root/.sshmount (see the OliveTin section above and Gotcha 2).
The alerts section — a canary for the MagicDNS→container-DNS outage
The alerts section is a purpose-built canary for the exact failure documented in 2026-08-15-camera-alerts-dead-tailscale-magicdns-docker-dns. It checks:
frigateandfrigate-notifycontainers are up.api.telegram.orgis reachable FROM INSIDE the frigate container —docker exec frigate curl https://api.telegram.org. This is the precise probe that would have caught the 17-day silent alert outage (a container that can’t resolveapi.telegram.orgwhen the host resolver is Tailscale MagicDNS).- The last “Alert sent” timestamp from the frigate-notify logs.
- Counts
misbehavingDNS errors in the last 30 minutes — i.e. it directly watches for theserver misbehavingsignature of the Tailscale-MagicDNS-breaks-container-DNS regression.
Gotcha 3 — OliveTin runs every
shell:string through Gotext/templateBEFORE executingOliveTin templates each action’s
shell:command through Gotext/templatefirst. Any literal{{ }}in the command — e.g.docker ps --format "{{.Names}} {{.Status}}"— is interpreted as an OliveTin template variable and errors:Error executing template ... can't evaluate field Names in type *tpl.actionTemplateContextThis is exactly what made the “Szolgáltatások” action fail. Avoid
{{ }}in OliveTinshell:commands (or escape them as Go-template literals), OR — the approach taken here — keep all logic out of the YAML entirely and call a script (telep-selftest) over ssh so no{{ }}ever reaches OliveTin’s template engine.
Gotcha 4 — from the mainframe, the router is reachable ONLY on its LAN IP, not its Tailscale IP
telep-selftestruns ON the mainframe, so its router DNS check must ssh to the router via its LAN IP192.168.1.1, NOT its Tailscale IP100.69.112.32. The mainframe cannot reach the router’s tailnet IP (ping 100.69.112.32from the mainframe times out / fails) but192.168.1.1works fine.This is asymmetric with OliveTin’s own container: the OliveTin container can reach the router on
100.69.112.32(that’s the address its fix/reboot actions use — see the Architecture request chain above). So the correct router address depends on WHERE the command runs: mainframe →192.168.1.1; OliveTin container →100.69.112.32.
Gotcha 5 — OliveTin ONVIF camera-reboot action must call the venv python, not system
python3The action “Kamerák újraindítása (ONVIF)” (and any raw
~/tapo-ctl/reboot-cams-onvif.pycall) originally ran under systempython3, which lacks theonvifmodule — that module lives only in~/tapo-ctl/venv. So the action failed withModuleNotFoundError: onvif. Fixed in/home/levander/admin-portal/olivetin/config/config.yamlto call~/tapo-ctl/venv/bin/python ~/tapo-ctl/reboot-cams-onvif.py. General rule: every~/tapo-ctl/script runs via~/tapo-ctl/venv/bin/python. Discovered during 2026-08-15-camwall-dead-br-cams-bridge-down-after-wifi-reload.
Gotchas (the two that cost the most time)
Gotcha 1 — login fails with HTTP 500 "internal server error"
Symptom: passkey completes, then the callback dies with a 500 page. oauth2-proxy logs:
Error redeeming code during OAuth2 callback: email in id_token (...) isn't verified.Root cause: a self-hosted Pocket-ID account has
email_verified = 0— nothing ever verifies your own address on a single-admin instance. oauth2-proxy rejects unverified emails by default.Fix: set
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=trueinoauth2-proxy.envand recreate the container (docker compose up -d --force-recreate oauth2-proxy). Perfectly fine for a single-admin self-hosted tool.
Gotcha 2 — OliveTin ssh actions fail: "Host key verification failed" and/or "Permission denied"
Symptom: every ssh action fails with
Host key verification failedand/orPermission denied (publickey). It looks like a stale/changed host key — a misleading symptom that sends you chasingknown_hosts.Root cause: the
jamesread/olivetin:latestimage runs asUSER olivetin(HOME=/home/olivetin), but the ssh key + config + known_hosts were mounted at/root/.ssh. So ssh looked in/home/olivetin/.ssh, found no identity key (→Permission denied) and no known_hosts (→Host key verification failed). The volume was mounted, just at the wrong HOME.Fix: two changes to the
olivetinservice indocker-compose.yml:
- Add
user: root(so HOME=/rootand the/root/.sshmount is where ssh actually looks).- Drop the
:roflag on the ssh volume mount, soStrictHostKeyChecking=accept-newcan persist newly-accepted host keys.After that, ssh to mainframe + router works — the OliveTin pubkey (
olivetin/ssh/id_ed25519.pub) is already authorized on both.
Open items
- Authorize the OliveTin pubkey on the Pi (
192.168.1.200) once the Kraken rig is back online. The Pi is currently offline (No route to host) — see 2026-08-12-krakensdr-doa-rig. Appendolivetin/ssh/id_ed25519.pubto the Pi’sauthorized_keyswhen it returns. - oauth2-proxy
--trusted-proxy-ip— restrictX-Forwarded-*trust to Caddy’s IP (offered, not yet applied). Low risk. - Remote / off-LAN access deferred — Caddy binds the LAN IP only (
192.168.1.123) to dodge the Tailscale:443collision, soadmin.levandor.iois not reachable off the LAN / over Tailscale yet. A tailnet path (per tailnet-service-exposure-convention) is deferred.
Related
- telep-mainframe · telep-router — the hosts the actions ssh into
- 2026-08-15-camera-alerts-dead-tailscale-magicdns-docker-dns — the outage the
telep-selftest alertssection is a canary for (container DNS /server misbehaving) - 2026-08-15-telep-lan-split-horizon-caddy — the OTHER Caddy on this box (
:80,*.telep.lan,auto_https off); do not confuse the two Caddy configs - 2026-08-15-dhcp-outage-duplicate-reservation-postmortem — same-day network context
- 2026-08-12-krakensdr-doa-rig — the Pi (
192.168.1.200) whose pubkey authorization is pending - 2026-08-12-airplay-mdns-fix-printer-migration — the router (
root@100.69.112.32) mDNS/AirPlay work - tailnet-service-exposure-convention — the pattern for a future remote-access node
- telep-mainframe-handover · admin-portal-login-broken (runbook)