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, tailnet 100.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, ssh actions). Identity provider is Pocket-ID at id.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.db under the stack dir.
  • OIDC client “Mainframe fallback”, client_id fbd09167-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)

  1. bind 192.168.1.123 is REQUIRED in the admin.levandor.io block. Without it Caddy tries to grab :443 on all interfaces and collides with Tailscale, which owns 100.115.209.87:443. Binding the LAN IP sidesteps the collision.
  2. TLS must be the multi-line formtls { dns cloudflare {env.CF_API_TOKEN}}. The single-line tls { dns cloudflare {env.CF_API_TOKEN} } fails to parse.
  3. After any cert/env change, systemctl restart caddy — a reload is 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, mode 600), 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):

VariableValueWhy
OAUTH2_PROXY_UPSTREAMShttp://olivetin:1337Compose service name, not 127.0.0.1:1337. Loopback = oauth2-proxy’s OWN container → 502 (see gotcha).
OAUTH2_PROXY_PROMPTloginEmpty approval_prompt falls back to force → Pocket-ID returns invalid_request.
OAUTH2_PROXY_CODE_CHALLENGE_METHODS256The Pocket-ID OIDC client requires PKCE.
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAILtrueAccept 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 trusts X-Forwarded-* from any IP. Low risk because only Caddy can reach the container (nothing else is on that docker network path). A --trusted-proxy-ip restriction was offered but not yet applied.

OliveTin (container, user: root)

  • Config at /home/levander/admin-portal/olivetin/config/config.yaml.
  • Actions are now in HungarianpageTitle: "Telep vezérlőpult". The English version is backed up at config.yaml.bak-en.
  • Each action sshes into a host to run a command. Targets:
    • mainframe192.168.1.123
    • routerroot@100.69.112.32 (tailscale IP)
    • pi / kraken192.168.1.200
  • The OliveTin ssh identity/config/known_hosts live under olivetin/ssh/ in the stack dir; the public key is olivetin/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-selftest on telep-mainframe (192.168.1.123). It prints clean colored ✓ / ✗ / ⚠ output grouped into sections.
  • Sections: dns, airplay, camwall, services, cameras, health, alerts, and all.
  • 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)” (runs alerts).
  • Script source authored this session; the OliveTin container already runs as user: root with the writable /root/.ssh mount (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:

  • frigate and frigate-notify containers are up.
  • api.telegram.org is reachable FROM INSIDE the frigate containerdocker 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 resolve api.telegram.org when the host resolver is Tailscale MagicDNS).
  • The last “Alert sent” timestamp from the frigate-notify logs.
  • Counts misbehaving DNS errors in the last 30 minutes — i.e. it directly watches for the server misbehaving signature of the Tailscale-MagicDNS-breaks-container-DNS regression.

Gotcha 3 — OliveTin runs every shell: string through Go text/template BEFORE executing

OliveTin templates each action’s shell: command through Go text/template first. 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.actionTemplateContext

This is exactly what made the “Szolgáltatások” action fail. Avoid {{ }} in OliveTin shell: 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-selftest runs ON the mainframe, so its router DNS check must ssh to the router via its LAN IP 192.168.1.1, NOT its Tailscale IP 100.69.112.32. The mainframe cannot reach the router’s tailnet IP (ping 100.69.112.32 from the mainframe times out / fails) but 192.168.1.1 works 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 python3

The action “Kamerák újraindítása (ONVIF)” (and any raw ~/tapo-ctl/reboot-cams-onvif.py call) originally ran under system python3, which lacks the onvif module — that module lives only in ~/tapo-ctl/venv. So the action failed with ModuleNotFoundError: onvif. Fixed in /home/levander/admin-portal/olivetin/config/config.yaml to 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=true in oauth2-proxy.env and 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 failed and/or Permission denied (publickey). It looks like a stale/changed host key — a misleading symptom that sends you chasing known_hosts.

Root cause: the jamesread/olivetin:latest image runs as USER 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 olivetin service in docker-compose.yml:

  1. Add user: root (so HOME=/root and the /root/.ssh mount is where ssh actually looks).
  2. Drop the :ro flag on the ssh volume mount, so StrictHostKeyChecking=accept-new can 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. Append olivetin/ssh/id_ed25519.pub to the Pi’s authorized_keys when it returns.
  • oauth2-proxy --trusted-proxy-ip — restrict X-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 :443 collision, so admin.levandor.io is not reachable off the LAN / over Tailscale yet. A tailnet path (per tailnet-service-exposure-convention) is deferred.