Skip to content

Dashy Board Sync

Two tools for keeping the Dashy board honest against what Proxmox and Traefik actually hold.

The board is built from Traefik’s live router table, not from guest names or compose labels, and that table drifts in two directions that look nothing alike:

  • a guest is destroyed and its tile keeps pointing at a route that answers 502
  • a guest is created — or recreated, which is the one that catches people — and comes back with the stock community-script description, so its label block is gone and it is running, healthy, and reachable by nobody

Neither is reported anywhere. The first shows up as a red tile eventually; the second shows up as a tile that used to work.

Runs on the workstation. Reads the Proxmox API, Traefik’s router table and community-scripts-lxc/dashy/user-data/conf.yml, and prints the three-way diff.

Terminal window
python3 utils/dashy-board-sync/board_diff.py
python3 utils/dashy-board-sync/board_diff.py --probe # HTTP-check everything too

Credentials come from .claude/creds.txt (gitignored) or from PROXMOX_URL / PROXMOX_TOKEN_SECRET in the environment. Stdlib only — no pip install.

Three sections come out:

SectionMeans
tiles with no routeSplit by probe: not answering is a real problem, answering is served by the prod-talos cluster or the tunnel and is expected
routed, no tileCandidates for the board. .fr twins of tiled .lan hosts are filtered out — the board deliberately links the .lan name
running guests with no routeMostly deliberate: plenty are reached by IP or consumed by another service

That last section compares backend IPs, not names. The proxmox provider builds each service URL from the guest’s own address, so the IP is exact; an earlier version matched on names and reported all 65 LXCs as unrouted, because remmina-kasm serves remmina, networking-tools serves networking-toolbox and step-ca serves labca.

Use it to find guests running a web UI that nothing routes — they are invisible to Dashy and Raindrop too. Check what each listens on with pct exec <vmid> -- ss -lntH, ignoring the agent ports that sit on most guests (3553 arcane, 7007 dozzle, 2375 docker-proxy, 25 postfix), then probe what is left: an API answers 200 with no <title>, a real UI has one or redirects to a login.

pve_label_guest.py — give a guest a route

Section titled “pve_label_guest.py — give a guest a route”

Runs on the Proxmox node; it shells out to pct / qm. Copy it over first.

Terminal window
python3 pve_label_guest.py 236 komga 25600 # stage and show the block
python3 pve_label_guest.py 236 komga 25600 --apply # write it
python3 pve_label_guest.py --restore 236 # put the old value back

It appends the standard LAN-only block — internal Step CA cert, secure-chain-no-auth@file, no Authelia — mirroring what radarr, upsnap and whois already carry. Staging and applying are separate steps because applying writes to a live guest.

Three things about the description field it handles for you:

  • It is one API field, so every edit rewrites the whole thing. The original is saved URL-encoded to /root/pct-backups/<vmid>-desc.enc before anything is written.
  • It is capped at 8192 characters, and an oversize value is rejected atomically — nothing is written, the old value survives. VM 217 hit that ceiling, which is why its routes live in the file provider now. The script refuses rather than letting pct set fail.
  • pct config returns it URL-encoded. Appending without decoding first buries the block in an escaped blob and the plugin never sees it.

Re-running on an already-labelled guest is a no-op, so it is safe in a loop.

The plugin polls every 30 seconds. No Traefik restart — this is provider state, not static config.

Guest create/destroy history, which is how you find out that a tile’s guest is gone rather than merely unrouted. The API token in .claude/creds.txt cannot read /nodes/{node}/tasks, so this goes over SSH to the node:

Terminal window
pvesh get /nodes/pve-dell/tasks --limit 4000 --output-format json \
| python3 -c "import sys,json,time;[print(time.strftime('%Y-%m-%d %H:%M',time.localtime(t['starttime'])),t['type'],t.get('id',''),t.get('status','')) for t in json.load(sys.stdin) if t['type'] in ('vzcreate','vzdestroy','qmcreate','qmdestroy')]"

vmids get reused. CT 208 was prowlarr and is now bookorbit; CT 230 was openwebui and is now prowlarr. A vzcreate for an id you recognise is not necessarily the guest you remember, so check the name.