Internals

Architecture

One sentence: browser → React UI → FastAPI → Proxmox API → pct exec / qm guest exec → guest's authorized_keys.

ProxKey architecture: browser hits FastAPI; backend talks to Proxmox API; Proxmox executes inside the guest via pct exec or qm guest exec; the inject script writes to authorized_keys
The browser drives the UI. The backend orchestrates Proxmox. Proxmox does the privileged work inside the guest.

In the browser

A single-page React 19 app served by nginx from the frontend container. Nginx proxies /api to the backend so the SPA only needs one origin. Four top-level tabs:

Stack: React 19, TypeScript, Vite, Tailwind CSS v4, TanStack Query for data fetching and cache, Zustand for cross-component selection state.

In the backend

FastAPI + SQLModel + proxmoxer. Lifespan creates the DB tables, wires the Proxmox client from the loaded settings, and registers four routers:

State lives in a single SQLite database (/data/proxkey.db inside the container, mounted as a volume). Three tables: the key library, per-host deployment records, and the audit log.

Host discovery

GET /api/hosts walks the Proxmox API in two passes:

  1. /nodes/{node}/lxc returns every container with VMID, name, and status.
  2. /nodes/{node}/qemu returns every VM with the same fields.

For each running host, ProxKey then fetches its IP address:

Link-local (fe80::) and loopback addresses are filtered out before the host card surfaces them. The discovery result is sent down in one JSON payload — no SSE here, the UI just refetches via TanStack Query when it needs fresh state.

Injection path

The interesting part. ProxKey can run on the Proxmox node directly or anywhere with API access. Either way, the wire ends at pct exec / qm guest exec inside the guest.

Dual execution paths

Mode is implicit: PROXMOX_SSH_PASSWORD set ⇒ remote mode. The injector doesn't care which side it's on — both code paths converge in services/injector.py.

Base64 transport

Injection scripts cross two or three shell boundaries: the local subprocess, optionally SSH, then pct exec's own bash -c. To stop quotes and dollars from getting mangled, the script is base64-encoded and the wire just carries the encoded blob:

SCRIPT_B64="$(base64 -w0 <<'EOF'
mkdir -p /root/.ssh && chmod 700 /root/.ssh
touch /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys
grep -qF "$KEY" /root/.ssh/authorized_keys || echo "$KEY" >> /root/.ssh/authorized_keys
EOF
)"
pct exec "$VMID" -- bash -c "echo '$SCRIPT_B64' | base64 -d | bash"

The actual script is short, defensive, and idempotent. The grep -qF check makes re-deploys safe; the chmod calls fix permissions on every run rather than assuming the directory already exists.

Revocation

Same execution model. The revoke script deletes only the matching line using sed with a fixed-string match, so it can't damage unrelated entries. Revoking a key that isn't there is a no-op — the audit log still records the attempt.

Auto-inject on start

A Proxmox hookscript on the node can POST to /api/hooks/lxc-started in the post-start phase. ProxKey looks up every key marked as global and deploys them into the just-started container. Because injection is idempotent, the hookscript can fire on every start without consequence.

Data model

Three SQLModel tables; everything else is derived.

TablePurposeNotable fields
SSHKeyThe key librarylabel, public_key, fingerprint, is_global
KeyDeploymentPer-host deploy statevmid, key_id, deployed_at, status
AuditLogEvery deploy/revoke/CRUDaction, vmid, key_id, outcome, detail, at

SQLModel auto-creates the tables on first startup. The file lives at /data/proxkey.db inside the backend container and is mounted as a Docker named volume so it survives restarts.

Deployment

Two containers behind a single host port:

docker-compose.yml wires them together with a shared named volume for the SQLite file. Configuration lives entirely in .env — the images are credential-free.

CI & releases

.github/workflows/build.yml builds the backend and frontend images for linux/amd64 and linux/arm64, pushes them to GHCR on every push to main, and on tagged pushes (v*) stitches per-arch tags into multi-arch manifests and creates a GitHub Release with the docker-compose.yml attached.

.github/workflows/pages.yml publishes this static site from docs/ to GitHub Pages on every push to main.

Known limits