VDI console access via Kerbside signed tokens¶
Prompt¶
Before responding to questions or discussion points in this
document, explore the shakenfist codebase thoroughly. Read
relevant source files, understand existing patterns (the REST
API decorator stack in shakenfist/external_api/base.py, JWT
issuance in shakenfist/util/access_tokens.py, the
cluster_config table and load_cluster_config() in
shakenfist/config.py and shakenfist/mariadb.py, the
eventlog audit pattern, and the deployer PKI roles under
deploy/ansible/roles/pki_internal_ca/), and ground your
answers in what the code actually does today. Do not
speculate about the codebase when you could read it instead.
This is a cross-repository plan. The affected repositories and their working branches are:
| Repository | Branch | Role |
|---|---|---|
| shakenfist/shakenfist | vdi-console-tokens |
Token minting, signing key, pubkey publication |
| shakenfist/client-python | vdi-console-tokens-client |
New API client methods, CLI surface, viewer launch |
| shakenfist/kerbside | sf-vdi-tokens |
Token exchange endpoint, scrape changes |
| shakenfist/ryll | vdi-console-tokens |
pip-installable viewer so the CLI can launch a session |
The master plan (this document) lives in shakenfist's
docs/plans/. Phase plan files live in the repository whose
code they change, so that each plan travels with the PR that
implements it. Kerbside's docs/plans/index.md carries a
cross-reference entry pointing back here.
For the kerbside side, consult kerbside's .claude/CLAUDE.md
and docs/proxy-architecture.md. Key kerbside references:
kerbside/api.py (the NovaToken resource is the pattern the
new exchange endpoint mirrors), kerbside/consoletoken.py,
kerbside/sources/shakenfist.py, and kerbside/main.py (the
maintenance-loop reapers).
When we get to detailed planning, I prefer a separate plan
file per detailed phase, named with -phase-NN-descriptive
appended, in the repository the phase changes.
I prefer one commit per logical change, and at minimum one commit per phase. Each commit should be self-contained: it should build, pass tests, and have a clear commit message explaining what changed and why.
Situation¶
Kerbside is a SPICE VDI protocol proxy that provides remote console access to VMs across Shaken Fist, OpenStack, and oVirt clouds. Ironically, Shaken Fist itself now has the worst Kerbside integration of the three:
- OpenStack (Nova 2025.1+ with spice-direct consoles) has
a per-instance authorisation story: when a user requests a
console, Nova returns a URL pointing at Kerbside with an
authentication token. Kerbside validates that token against
Nova's
/os-console-auth-tokens/API and only then issues its own session credential. Authorisation is enforced by the cloud that knows who owns the instance. - Shaken Fist is merely scraped: kerbside's
ShakenFistSourcepolls one configured namespace once a minute (kerbside/sources/shakenfist.py) and lists the consoles in kerbside's admin interface. There is no per-instance access control — anyone in kerbside's Keystone access group can fetch a.vvfor any scraped console. - Shaken Fist does already have the authorisation primitive:
InstanceVDIConsoleHelperEndpoint(shakenfist/external_api/instance.py:1442) hands out a.vvfile gated by@requires_instance_ownership(external_api/base.py:328) — but that.vvpoints the client directly at the hypervisor, bypassing Kerbside (and therefore bypassing TLS termination at the proxy, the SPICE protocol firewall, session auditing, and session termination).
Because we control both codebases, Shaken Fist can do
something Nova cannot: sign the console grant asymmetrically
so that Kerbside can validate it offline, with no
callback to the Shaken Fist API on console open. Nova's model
requires Kerbside to consult Nova at .vv-fetch time; a
signed-token model has no such availability coupling.
Relevant existing machinery, confirmed by code inspection:
- SF's REST API JWTs are HS256 via
flask_jwt_extendedwith a single cluster-wideAUTH_SECRET_SEED(external_api/app.py:59,config.py:148). The proxy token needs a different, asymmetric signing path (Ed25519 via raw PyJWT) so that kerbside holds only a public key. - SF already has PyJWT 2.13.0 as a direct dependency
(
pyproject.toml:116), but notcryptography, which PyJWT requires for EdDSA. Kerbside already depends on bothcryptographyand PyJWT (viaflask-jwt-extended). - The post-etcd cluster-wide key/value store is the
cluster_configtable (mariadb.py:1530), read intoSHAKENFIST_*environment variables at process start byload_cluster_config()(config.py:27).AUTH_SECRET_SEEDis already shared across API nodes exactly this way — the signing keypair follows the same pattern. - The pattern for publishing cluster material to kerbside
exists:
GET /admin/cacert(external_api/admin.py:47, any authenticated namespace) returns the SPICE CA cert, advertised via thecluster-cacertcapability string in the root page HTML (external_api/app.py:201), and consumed by kerbside'sShakenFistSource.__init__at source-init time. @redirect_instance_request(external_api/base.py:273) proxies vdiconsole requests to the instance's hypervisor because the direct.vvneeds node-local state (the SPICE CA file and the node's ports). A proxy-token endpoint needs none of that, so it can answer from any API node and must not carry that decorator.- The Python client (
shakenfist_client, repo shakenfist/client-python) follows HTTP redirects silently (apiclient.py:208-236), so the new endpoint must return the kerbside URL in a JSON body, not as a 3xx redirect — otherwise clients transparently fetch the.vvand the URL is never surfaced to tooling that wants it. - Kerbside's exchange machinery exists for Nova: the
NovaTokenresource (kerbside/api.py:516, route/nova-console.vv) validates an external token, callsdb.add_console(...), mints a kerbsideconsoletoken(a 48-char random string sized for the SPICE password field,kerbside/consoletoken.py:19) and returns a.vv. The SF exchange endpoint mirrors this shape, minus the callback. - The CLI's viewer launch already exists, but is virt-viewer
shaped:
sf-client instance vdiconsolewrites the.vvto a temp file and runsremote-viewervia subprocess (shakenfist_client/commandline/instance.py:636-663). Ryll — our own SPICE client, which understands kerbside's.vvextensions includinghost-subject— accepts either--file <path>or--url <URL to fetch a .vv from>(ryll README, "Connect using a .vv configuration file"). The--urlform means the CLI can hand ryll the kerbside exchange URL directly: ryll performs the one-shot token exchange itself and the JWT never touches disk. Ryll is not currently pip-installable; the precedent for shipping a Rust binary via pip is kerbside-proxy's maturin bin wheel (kerbsiderust/kerbside-proxy/, built in Docker). - Hypervisor SPICE TLS certs are provisioned by the deployer:
one cluster CA, per-node server certs with
cn = hostname(deploy/ansible/roles/pki_internal_ca/tasks/host_certificate.yml). The cert subject is not currently recorded anywhere the API exposes. Kerbside's scrape yieldshost_subject: Nonefor SF consoles today, so the proxy's host-subject enforcement (which kerbside ships as of v0.4.0) protects only static/oVirt paths, not SF.
Mission and problem statement¶
Give Shaken Fist the best Kerbside integration of the three supported clouds:
- Per-instance authorisation, enforced by Shaken Fist. A
user may open a proxied console for an instance if and only
if they hold a namespace credential that
requires_instance_ownershipaccepts for that instance. - No connect-time coupling. Kerbside must be able to authorise a console open without calling the Shaken Fist API: token validation is an offline signature check plus a local database lookup.
- Defense in depth. Kerbside takes backend connection details (hypervisor IP, ports, host subject) only from its own scraped database, never from token claims — a forged or replayed token cannot point the proxy at an arbitrary backend.
- Seamless client UX.
sf-client instance vdiconsolelands the user in a running viewer session with all of this happening under the hood: mint the token, hand ryll the exchange URL, session opens. The token plumbing — and ideally the viewer installation itself, via a pip-installable ryll — is invisible. - The existing direct-to-hypervisor
.vvpath keeps working for clusters with no Kerbside deployed.
Out of scope for this plan (deferred to a later deployment
phase, see Future work): ansible-collection deployer support,
including a dedicated kerbside infrastructure group modelled
on the database tier.
Token flow (the design in one place)¶
sf-client Shaken Fist API Kerbside
| | |
|-- GET vdiconsoleproxy --->| |
| (namespace JWT) | ownership check |
| | mint Ed25519 JWT |
|<-- {url, expires_at} -----| (aud=kerbside, sub= |
| | instance, jti, exp) |
| | |
|-- GET /sf-console.vv?token=<jwt> ------------------->|
| | verify sig (cached |
| | pubkey), check aud/ |
| | exp, jti unused, |
| | console in scrape DB |
|<-- .vv (password = kerbside consoletoken) -----------|
| | |
|== SPICE connect to kerbside, existing machinery ====>|
The JWT is a bearer capability for one HTTP exchange, not
a session credential. It never appears in the .vv (the SPICE
password field cannot hold a ~250-char JWT; kerbside's
existing 48-char consoletoken fills that role). Kerbside
persists only the token's jti for replay rejection until
exp; Shaken Fist stores nothing per-token and mints-and-
forgets, recording only an audit event.
Token claims (settled in phase 0, strawman here):
iss: the SF cluster'sconfig.ZONE(matches existing JWT practice,util/access_tokens.py).aud: the kerbside deployment's public base URL (from the newKERBSIDE_URLcluster config). Kerbside rejects tokens whoseaudis not itself.sub: the instance UUID.sf:namespace: the owning namespace (audit/display only — kerbside must not make authorisation decisions from it).iat,exp: TTL default of a few minutes (KERBSIDE_TOKEN_DURATION, phase 0 decides the default).jti: UUID4, kerbside's replay-rejection handle.- Header
kid: which cluster signing key signed this, so rotation is possible without a flag day.
Alternatives considered¶
Nova-parity callback validation¶
Kerbside receives an opaque token and calls back to a new SF "validate console token" admin API, exactly as it does for Nova. Rejected: it reintroduces the availability coupling (SF API down means no new consoles), requires SF to persist issued tokens, and buys nothing — we control both sides, so offline signature verification is available to us in a way it was not for Nova.
Push model¶
SF calls a kerbside API to register each issued token, so kerbside validates locally. Rejected: inverts the dependency (SF must hold kerbside credentials and reach its API), requires new kerbside API surface, and fails when kerbside's control plane is briefly unreachable even though its data plane is fine. Signed tokens achieve the same local validation with no push channel.
SF builds the proxy .vv itself¶
The vdiconsoleproxy endpoint could skip the kerbside HTTP
exchange and emit a .vv pointing at kerbside directly, with
the JWT... somewhere. Rejected on two grounds: the SPICE
password field cannot carry a JWT (48-char kerbside tokens
exist precisely because the field is tiny), and SF would need
to know kerbside's public CA cert, ports, and proxy
host-subject to author the .vv — smearing kerbside's TLS
identity into SF's config. Kerbside stays the author of its
own .vv, as it is for Nova.
Kerbside-side ACLs on scraped consoles¶
Keep pure scraping and grow a per-console ACL model inside kerbside, mapping kerbside (Keystone) users to consoles. Rejected: duplicates SF's identity model in a second database that must be kept in sync, and kerbside's identity layer is a single coarse Keystone group with no relationship to SF namespaces. Authorisation belongs to the cloud that owns the instance.
Open questions¶
These are settled in phase 0 before implementation starts. Recommendations are recorded inline.
- Signing key custody and bootstrap. Recommendation:
store the keypair in
cluster_config(the same custody asAUTH_SECRET_SEED, which gates strictly more power), as a JSON value holding{kid, private_pem, public_pem, created}. Generate lazily on first use with an atomicINSERTon thekey_nameprimary key (loser of the race re-reads), plus ansf-ctlsubcommand for explicit pre-generation and rotation. Rotation model: publish current + previous public keys, sign with current only. - Token TTL default. Recommendation: 300 seconds. Long enough for a human to click through a saved URL, short enough that the replay-cache table stays trivial. The deeper revocation question is bounded by scrape freshness anyway (a deleted instance vanishes from kerbside's console table within a minute, and live sessions are governed by kerbside's existing termination machinery, not the JWT).
- Scrape scope. Today
ShakenFistSourcescrapes exactly one namespace (sources/shakenfist.py:62), which would make the token flow work only for instances in that namespace. Recommendation: when the source's credential is thesystemnamespace, scrape cluster-wide viaget_instances(all=True)(already supported by client and server). Phase 0 should decide whether per-namespace scraping survives as a filter option. - How kerbside learns which SF source a token belongs to.
Recommendation: mirror
NovaToken— iterate configuredshakenfistsources and attempt verification against each source's cached public keys;audmust equal kerbside's ownPUBLIC_FQDN-derived URL. On an unknownkid, refetch the source's published keys once before rejecting (this is also the rotation path). - Fate of the direct-to-hypervisor
.vvendpoint. It is ownership-gated and remains correct for proxyless clusters. Recommendation: keep it unconditionally for now; add a cluster config knob to disable it only if operators ask. Decide in phase 0 whether the client should prefer the proxy path automatically when the capability is advertised. - Kerbside admin-UI exposure of SF consoles. Anyone in
kerbside's Keystone access group can currently mint a
session for any scraped console via
/console/proxy/<source>/<uuid>/console.vv. With cluster-wide scraping this surface grows to every SF instance. Recommendation: keep the admin path (it is the operator break-glass and is audited), but record the decision explicitly; a follow-on could restrict.vvissuance for SF-sourced consoles to an admin subset of the access group. - host_subject provenance. The deployer provisions
per-node SPICE certs with
cn = hostname, but nothing exposes the subject via the API. Options: (a) kerbside synthesisesCN=<node hostname>per a per-source config flag; (b) each SF node records its actualserver-cert.pemsubject into node attributes at daemon startup, and the API exposes it for the scrape. Recommendation: (b) — it is accurate for custom PKI too and keeps kerbside free of deployment assumptions; (a) is acceptable as an interim if (b) grows too large, but note host-subject verification is exact-match on the full subject, so synthesis is brittle against deployer changes. - Endpoint and capability naming. Strawman used below:
route
GET /instances/<ref>/vdiconsoleproxy, capability stringvdi-console-proxy, pubkey routeGET /admin/vditokenpubkey, kerbside route/sf-console.vv. The kerbside route name is decided (operator preference, 2026-07-19):sf-consolerather than the fullshakenfist-console, accepting the asymmetry with the existing/nova-console.vvfor brevity. Phase 0 confirms or amends the rest. - Ryll distribution mechanism. A maturin bin wheel (the
kerbside-proxy pattern) is the strawman, published as a
standalone
shakenfist-ryllpackage that client users opt into via an extra (pip install shakenfist-client[vdi]). The risk is manylinux compliance: unlike kerbside-proxy (a headless network daemon), ryll's GUI build links winit's X11/Wayland/xkb surface, cpal's ALSA, and rfd's dialog backend — some of which are outside the manylinux allowed-library set. Phase 3 must prototype the wheel build early and, if auditwheel cannot be satisfied, fall back to an installer that fetches a ryll release binary (GitHub releases) into a user-local path, keeping thesf-clientUX identical. Decide in phase 0 only the shape (standalone package + extra, plus a PATH/remote-viewer fallback chain); let the phase-3 prototype pick the mechanism. - Viewer selection order in the CLI. Recommendation:
packaged ryll if importable/installed, else
ryllon$PATH, elseremote-viewer(which cannot follow the exchange URL, so the CLI fetches the.vvto a temp file for it exactly as today), with a--vieweroverride.remote-viewermust remain a working path — the.vvwe emit is standard virt-viewer format.
Execution¶
| Phase | Repo | Plan | Status |
|---|---|---|---|
| 0. Decisions and token format | shakenfist | Folded into this document (no separate plan file) | Complete |
| 1. Cluster signing key + pubkey publication | shakenfist | PLAN-kerbside-vdi-tokens-phase-01-signing-key.md | Complete |
| 2. vdiconsoleproxy endpoint | shakenfist | PLAN-kerbside-vdi-tokens-phase-02-proxy-endpoint.md | Complete |
| 3. Pip-installable ryll | ryll | PLAN-pip-distribution.md (in ryll's docs/plans/) | Complete |
| 4. Client, CLI, and viewer launch | client-python | PLAN-vdi-console-tokens.md (in that repo, branch vdi-console-tokens-client) |
Complete |
| 5. Kerbside exchange endpoint | kerbside | PLAN-kerbside-vdi-tokens-phase-05-exchange.md (in kerbside, branch sf-vdi-tokens) |
Complete |
| 6. Cluster-wide scrape and host_subject | kerbside + shakenfist | PLAN-kerbside-vdi-tokens-phase-06-scrape.md (in kerbside) | Complete |
| 7. Functional test: SF mint path | shakenfist | PLAN-kerbside-vdi-tokens-phase-07-ci.md (in kerbside) | Complete |
| 8. Documentation | all | PLAN-kerbside-vdi-tokens-phase-08-docs.md | Complete |
| 9. Full cross-repo end-to-end + kerbside exchange lane (post-merge, real SF) | all | PLAN-kerbside-vdi-tokens-phase-09-e2e.md (in kerbside) | Complete |
| 10. Push audit | all | PLAN-kerbside-vdi-tokens-phase-10-push-audit.md | Complete |
| 11. Close out the post-completion defects (#4003, #4009) | shakenfist | See Post-completion defects below | Complete |
The table above names plan files rather than pull requests, which
meant phase 10 had to reconstruct the merge history from git log in
four working copies before it could audit anything. That
reconstruction is recorded once, in
phase 10's decision 1,
and is the authoritative list of what this plan actually merged:
| Repo | Phases | PR | Merge |
|---|---|---|---|
| shakenfist | 1, 2, 6 (SF half), 7 (SF half), 8 (SF half) | #3491 | 9d41a1716 |
| shakenfist | 9 closeout (docs) | #3580 | 07d7081b7 |
| shakenfist | plan update (docs) | #4011 | c3e76ff8a |
| shakenfist | 11 (#4009) | #4016 | 5ef83c065 |
| shakenfist | 11 (#4003) | #4018 | 913411586 |
| shakenfist | 11 (#4004) | #4024 | f2df423d8 |
| shakenfist | 10 (audit, its three medium fixes, the .vv functional test) |
#4099 | 2d585cd1a |
| shakenfist | 10 closeout (docs) | #4137 | ccc1b5ff2 |
| client-python | 4 | #350 | b426e1f |
| kerbside | 5, 6, 7, 8 (kerbside half) | #167 | f50ea59 |
| kerbside | 9 (SF end-to-end lane) | #194 | 115416c |
| kerbside | post-phase-9 scrape fix | #201 | 7803368 |
| kerbside | 10 (F-B1 part 1: a key fetch no longer errors the source) | #412 | e2a493ea6 |
| kerbside | 10 (F-B1 part 2: cleanup narrowed to enumerated sources) | #413 | 29323fb85 |
| ryll | 3 | #190 | fa7ee21 |
| ryll | 10 (F8: SPICE TLS trust anchors) | #358 | aac25cf3c |
| ryll | 10 follow-up (redundant app.js test assertion removed) |
#366 | 0113f0ff7 |
Rows are grouped by repository and ordered by merge within each, which is why phase 10 trails phase 11 in the Shaken Fist block: the post-completion defects were fixed before the audit ran. Phase 10 appears six times because the audit did not merge as one change. Its findings were fixed in whichever repository owned them, and the blocking one took two attempts. The fourteen advisory findings are deliberately not rows here -- they were filed as issues in their own repositories and land on their own schedule, so a list would go stale. Follow those from the disposition table in phase 10's plan.
Phase 0: Decisions and token format¶
Settle the Open questions above and write the token format down as a short normative spec (claims, algorithm, key storage, rotation, error responses) inside the phase plan. Everything downstream cites that spec. Plan at high effort — this is where cross-repo consistency is cheapest to buy.
What actually happened. No separate phase 0 plan file was ever written: the decisions, the token format and the Open questions were settled inline in this document, and every downstream phase cited them from here. That is a fine outcome and the phase is marked complete on that basis.
The one obligation that was genuinely dropped is the issue tracker scan (see Bugs found during this work below, which sat empty from the plan's creation until 2026-09-01). Do not read the empty section as "no related bugs existed"; read it as "nobody looked".
Phase 1: Cluster signing key and pubkey publication (SF)¶
- Add
cryptographyas a direct dependency (PyJWT needs it for EdDSA; today SF has PyJWT but not cryptography). - Key generation and storage per phase 0:
cluster_configrow(s), atomic lazy creation,sf-ctlmanagement subcommand. GET /admin/vditokenpubkey: JSON list of{kid, alg, public_pem}. Auth mirrors/admin/cacert(@verify_token+@log_token_use, any authenticated namespace — the material is public-key only).- Capability string in the root-page HTML so clients and kerbside can probe for support.
- Unit tests: generation atomicity (two racing creators converge), endpoint shape, no private material in the response or in any log/event.
Phase 2: vdiconsoleproxy endpoint (SF)¶
- New
KERBSIDE_URLconfig field (empty default = feature off), populated cluster-wide viacluster_configlikeDNS_SERVERis today. - New
InstanceVDIProxyConsoleHelperEndpoint: decorator stack@verify_token/@arg_is_instance_ref/@requires_instance_ownership/@log_token_use— deliberately without@redirect_instance_request, since minting needs no node-local state and must work from any API node. - Guards: 404-equivalent when
KERBSIDE_URLis unset; the same instance-state andvideo.vdichecks the scrape applies (state == created, vdi type starts withspice). - Mint the JWT per the phase-0 spec; respond
{url: '<KERBSIDE_URL>/sf-console.vv?token=<jwt>', expires_at: ...}as JSON (no redirect — the client follows redirects silently and the URL must be surfaceable). - Audit event on the instance (
EVENT_TYPE_AUDIT) recordingjtiandkidbut never the token itself. - Unit tests: ownership enforcement (namespace A cannot mint for namespace B's instance), disabled-feature path, claim correctness, TTL.
Phase 3: Pip-installable ryll (ryll)¶
The goal: pip install shakenfist-ryll (typically via the
shakenfist-client[vdi] extra) puts a working ryll binary
on the venv's PATH, the way kerbside's pip install ships
kerbside-proxy.
- Prototype a maturin bin wheel early, mirroring
kerbside-proxy's Docker-wrapped build (ryll builds in
Docker already; no native toolchain on dev hosts). The
open risk is manylinux compliance for a GUI binary — see
open question 9. If auditwheel cannot be satisfied, pivot
to the fallback there (a thin
shakenfist-ryllpackage that fetches the matching GitHub release binary at install or first run) without changing the client-facing contract: either way the package exposes aryllexecutable. - CI: build the wheel in ryll's existing rust.yml lane; release automation alongside ryll's crate/binary releases.
- This phase deliberately contains all the packaging
weirdness so that phase 4 sees only "a
ryllbinary is installed or it is not".
Phase 4: Client, CLI, and viewer launch (client-python)¶
get_vdi_console_proxy(instance_ref)returning the parsed JSON, and a convenience that fetches the kerbside URL and returns.vvtext (plainrequests.get— kerbside's endpoint takes no SF auth).get_vdi_token_public_keys()for kerbside's source driver.- CLI:
sf-client instance vdiconsolebecomes the seamless path — when thevdi-console-proxycapability is advertised, mint the token and launch the viewer; with ryll, pass the exchange URL viaryll --url(no temp file, token never on disk); withremote-viewer, fetch the.vvto a temp file as today. Viewer selection per open question 10 (packaged ryll → PATH ryll → remote-viewer,--viewer/--directoverrides).vdiconsolefilekeeps working for both paths. - Packaging:
vdiextra depending onshakenfist-ryll(from phase 3). - Release note: kerbside's phase 6 consumes
get_vdi_token_public_keys(), so a client release precedes it (kerbside pinsshakenfist-client>=).
Phase 5: Kerbside exchange endpoint (kerbside)¶
- New
SfTokenresource at/sf-console.vv, mirroringNovaToken(api.py:516) with validation replaced by: signature verification against cached per-source public keys (EdDSA; refetch-once on unknownkid),aud/expchecks, and single-usejtienforcement. - Alembic migration: a
sf_token_jtisreplay table (jtiprimary key,expiry); reject exchanges whosejtiis present; reaper in the maintenance loop mirroring_reap_expired_console_tokens(main.py:185). - Console lookup strictly from the scraped
consolestable by(source, sub)— claims are never used for backend addressing. 404 when the console is not (yet) scraped. - Issue the kerbside
consoletokenand.vvexactly as the Nova path does; audit events for accepted and rejected exchanges. - Unit tests: replay rejection, expired token, wrong
aud, unknownkid(with and without successful refetch), forged signature, unscraped console.
Phase 6: Cluster-wide scrape and host_subject (kerbside + SF)¶
- Scrape scope per phase 0:
get_instances(all=True)under a system credential; public-key fetch added toShakenFistSource.__init__besideget_cluster_cacert(), with periodic refresh. - host_subject per phase 0's decision: SF-side node attribute
publication and/or kerbside-side synthesis, then populate
host_subjectin the scrape yield (sources/shakenfist.py:85) so the proxy's existing enforcement covers SF backends. - The SF half of this phase (node cert-subject publication) lands on the SF branch with its own phase plan section.
Phase 7: Functional test — SF mint path¶
Narrowed to the SF-side mint test; the kerbside functional lane moved to phase 9 (see below and the phase-7 plan in kerbside for the grounding).
- SF: a
cluster_ci_testsfunctional test (deploy/shakenfist_ci/cluster_ci_tests/test_vdi_tokens.py) asserting the minting path (ownership gate, JSON shape, verifiable signature against the published pubkey) without needing a kerbside deployment. Calls the REST endpoints via_request_urlso it is independent of the client PR's merge order, and skips cleanly whenKERBSIDE_URLis unprovisioned (it is process-cached at SF start, so it is set for real by phase 9's deployment; the test then activates).
Phase 8: Documentation¶
Done. The detailed plan of record for this phase is
PLAN-kerbside-vdi-tokens-phase-08-docs.md (authored in the kerbside repo
alongside phases 5–7). The four repos' doc PRs are independent.
- SF: user guide (how to open a proxied console), operator
guide (enabling
KERBSIDE_URL, key rotation runbook),ARCHITECTURE.md/AGENTS.mdtouch-ups. - Kerbside: rewrite the Shaken Fist section of
docs/console-sources.md(it currently documents only the scrape model), updatedocs/proxy-architecture.mdand the plan index. - client-python: README/CLI help, including the viewer
selection chain and the
[vdi]extra. - ryll: README install section (pip alongside the existing build-from-source instructions).
Phase 9: Full cross-repo end-to-end (post-merge, real SF)¶
Runs once the four PRs are on develop, so kerbside CI can
install SF and the client at HEAD. Absorbs the kerbside
exchange/proxy lane that phase 7 originally intended: a
direct-qemu-style exchange against a static console is
infeasible because kerbside only trusts type: shakenfist
sources for token verification and the maintenance loop reaps
any console a live scrape did not yield — so a real SF (not a
static or hand-seeded console) is required.
- Deploy a single-node SF in CI (as realized: the
build-smoke-clusteraction, not getsf), provisionKERBSIDE_URL(deploy-time, since it is process-cached) and a signing key, point a kerbside at it, and drive the full flow: mint token via client, exchange for.vv, connect through the proxy, assert session audit/teardown. - Adversarial coverage against the live app: replayed token, expired token, wrong audience, unknown kid, cross-namespace mint attempt.
- The SF mint path (phase 7's
test_vdi_tokens.py) activates here too, onceKERBSIDE_URLis provisioned.
Phase 10: Push audit¶
Runs PUSH-AUDIT.md over the accumulated diff of every phase
in this plan against each repository's default branch, not the
last phase's diff alone. Findings land as their own pull
request, and the plan is not complete until each is resolved
or declined in writing here. If the audit finds nothing, that
is recorded in one sentence.
What it found. Not nothing. One blocking defect, in
kerbside: a failed VDI signing-key fetch errored the Shaken
Fist console source, and an errored source is skipped before
the scrape loop that would have kept its consoles alive -- so
the unconditional cleanup afterwards deleted the source's
entire console inventory, breaking the direct and proxy
console routes that have nothing to do with tokens. It
triggers against any cluster that has not run sf-ctl
ensure-kerbside-signing-key, which makes it the third
defect from the one configuration no phase ever tested (after
4003 and #4009), and the first that destroys data. Also one¶
high-severity finding in ryll's SPICE TLS verifier, which
trusted the public WebPKI root set even when a .vv supplied
a private cluster CA -- the mechanism this plan's whole
host_subject story depends on. The kerbside fix took two
pull requests: kerbside#412 (e2a493ea6) stopped the key
fetch erroring the source but left the cleanup itself
untouched, so every other early-exit path still deleted the
inventory, and kerbside#413 (29323fb85) narrowed that
cleanup to the sources a pass actually enumerated. Both ship
in Kerbside v0.6.0; Shaken Fist's own operator guide and
release notes carry the provisioning order which avoids the
hazard on earlier releases. ryll#358 merged as aac25cf3c.
Alongside those came three medium security findings and the audit's own discovery
that the .vv type collapse, though covered by a unit test,
had no functional coverage -- in a project that prefers
functional coverage where it can only have one.
Fourteen advisory findings were filed as issues across the
four repositories. Full detail, with what each heading
examined and the management session's spot-checks and
mutation tests, is in the phase plan's Findings section.
No blocking or high-severity finding remains open, so this
phase is Complete.
Phase 11: Close out the post-completion defects¶
Phases 1-9 all closed, and the feature works on a cluster that runs Kerbside. But two defects found on 2026-09-01 against sfcbr (a cluster that does not run Kerbside) mean the plan's own success criteria are not yet met — see Post-completion defects below. This phase exists so the plan is not filed as finished while they stand:
- shakenfist#4003 — gate the
vdi-console-proxycapability onconfig.KERBSIDE_URL. Blocks two success criteria and mission goal 5. - shakenfist#4009 — fix the direct
.vvgenerator (hostis a node UUID,typeis an SF-internal enum, nohost-subject) and add the CI coverage whose absence let it rot.
Both are shakenfist-only and independent of each other. Neither needs a Kerbside deployment to reproduce or to verify, which is precisely the property that was never tested.
Complete as at 2026-09-02. #4009 was fixed by PR #4016, #4003 by
PR #4018, and the related gap #4004 by PR #4024; all three issues are
closed and all three fixes are on develop. The direct .vv now
resolves the placement node to its IP, collapses the internal spice*
enum to spice, and emits host-subject
(shakenfist/external_api/instance.py:1688-1727); vdi-console-proxy
is a conditional capability token gated on config.KERBSIDE_URL
(shakenfist/external_api/app.py:355-360); and the coverage whose
absence let all three rot now exists as
shakenfist/deploy/shakenfist_ci/cluster_ci_tests/test_vdi_console_file.py,
which parses a real .vv on a cluster with no Kerbside deployed.
Phase 10 audits those fixes along with everything else rather than
taking them on trust.
Dependencies on other plans¶
None hard. This plan is independent of the BYO-MariaDB /
remove-primary thread — it uses cluster_config as it exists
today. Sequencing within this plan: phases 1→2 are ordered on
the SF side; phase 3 (ryll packaging) is independent and can
start immediately — it is also the highest-uncertainty item,
so starting its prototype early buys information cheaply;
phase 4 needs phases 2 and 3 (and degrades gracefully if 3
slips, via the remote-viewer fallback); phase 5 needs only
the phase-0 spec plus phase 1 (a pubkey endpoint to fetch in
tests can be faked until then); phase 6 needs a released
client containing phase 4's get_vdi_token_public_keys(). A
kerbside release (v0.5.0) ships phases 5–6; the
lockstep-release discipline from the rust-proxy transition
applies if any proxy-contract change sneaks in (none is
expected — this plan is entirely control-plane).
Agent guidance¶
Execution model¶
All implementation work is done by sub-agents, never in the management session. The management session is reserved for planning, review, and decision-making. The workflow is:
- Plan at high effort in the management session.
- Spawn a sub-agent for each implementation step with the brief from the phase plan, at the recommended effort level and model.
- Review the sub-agent's output in the management session. Check the actual files — the sub-agent's summary describes what it intended, not necessarily what it did.
- Fix or retry if the output is wrong. Diagnose whether the brief was insufficient (improve it) or the model was too light (upgrade it), then re-run.
- Commit once the management session is satisfied.
Cross-repo caution: a sub-agent works in exactly one repository per step. Steps that "need" to touch two repos are a sign the phase plan split a seam wrongly — go back to the plan.
Planning effort¶
The master plan and phase 0 are high-effort work. Phases 1, 2 and 5 involve key custody, authorisation gates, and replay semantics — plan those at high effort too. Phase 4 (client methods mirroring existing ones, launcher chain) and phase 8 (docs) can be planned at medium effort. Two items carry the plan's schedule risk and deserve design-document treatment rather than checklists: phase 3's manylinux question (spike it first) and phase 7's SF-lane-in-kerbside-CI step.
Step-level guidance¶
Each phase plan should include the step table from
PLAN-TEMPLATE.md (| Step | Effort | Model | Isolation |
Brief for sub-agent |). Front-load research into briefs: cite
file and line for the pattern each step mirrors (e.g. "mirror
AdminClusterCaCertificateEndpoint at
external_api/admin.py:47", "mirror the NovaToken resource
at kerbside/api.py:516"). Security-sensitive steps (key
generation, signature verification, replay cache) should
recommend opus and include the adversarial test list in the
brief.
Management session review checklist¶
Per PLAN-TEMPLATE.md, plus specific to this plan:
- No private key material in API responses, logs, or
events (grep the diff for
privatenear logging calls). - Kerbside never reads backend addresses from JWT claims.
- Every new endpoint carries the intended decorator stack
— in particular
requires_instance_ownershipon the minting endpoint and noredirect_instance_request. -
pre-commit run --all-filespasses in whichever repo changed (SF's runs flake8, stestr, mypy; kerbside's runs its tox envs).
Administration and logistics¶
Success criteria¶
We will know this plan is complete when:
- A user holding only a namespace key can run
sf-client instance vdiconsole <instance>against a Kerbside-enabled SF cluster and land in a running ryll session for their own instance with no other manual steps (givenpip install shakenfist-client[vdi]) — and cannot get a console for an instance in another namespace. - The same command still works with only
remote-viewerinstalled, and still works direct-to-hypervisor on clusters with noKERBSIDE_URLconfigured. Met as at 2026-09-02, having been broken from the feature's first release until then: shakenfist#4003 advertised the capability unconditionally so the client never reached the direct path, and shakenfist#4009 left the direct.vvmalformed. Both are fixed and both now have coverage on a Kerbside-less cluster. - Kerbside opens that console with zero Shaken Fist API calls on the exchange path (signature check + local DB only).
- A replayed exchange URL is rejected; an expired one is rejected; both rejections are audited on the kerbside side, and every mint is audited on the SF side.
- SF-sourced consoles carry a
host_subjectand the proxy enforces it, closing the gap where enforcement covered only static/oVirt sources. - CI proves the end-to-end flow (kerbside SF lane) and the
minting authorisation gate (SF cluster_ci), and the
pre-push checks pass in all four repos. Met, with a
caveat worth carrying forward: the two lanes exist, the
Kerbside-less gap that let #4003 and #4009 ship is covered
by
cluster_ci_tests/test_vdi_console_file.py, and the pre-push audit ran in phase 10 and passed wave 1 in all four repositories. The caveat is that phase 10's seam lens found the mint path's own functional coverage never executes in Shaken Fist's CI (#4093), and that several values crossing repository boundaries are asserted only by hand-written fixtures on both sides (#4097). CI proves the flow; it does not yet prove the contract. - Documentation in all four repos reflects the feature, and the plan index status tables are current.
Future work¶
- Deployer support (explicitly deferred phase). Teach the
ansible collection to deploy Kerbside alongside SF: a
dedicated
kerbsideinfrastructure group (modelled on the database tier from the BYO-MariaDB work, not "every node"), renderingsources.yamlwith a system credential, writingKERBSIDE_URLintocluster_config, and wiring kerbside's own TLS material. This lands as its own plan once the code support here has proven itself. Now tracked as shakenfist#4004, which also carries the much smaller first increment: akerbside_urlvariable rendered intoroles/node/templates/config, so an operator can point a cluster at a Kerbside deployed by other means without the full infrastructure-group work. - Kerbside admin-auth pluggability: kerbside's own login is
Keystone-only (
kerbside/api.py:200), which is an odd requirement for an SF-only deployment. Possibly converges with SF's OIDC plan (PLAN-oidc-authentication.md) and the auth-federation branch. - Restricting kerbside's operator break-glass
.vvissuance for SF-sourced consoles to an admin subset (open question 6 records the current decision). - Signing-key rotation automation (scheduled rotation rather
than operator-initiated
sf-ctl).
Bugs found during this work¶
The phase 0 tracker scan never happened, so this section sat empty through phases 1-9. It was opened on 2026-09-01, after the defects below were found by hand.
Post-completion defects¶
Found on 2026-09-01 by running sf-client instance
vdiconsolefile against sfcbr, a cluster with no Kerbside
deployed. That configuration — the feature off — is the one
combination no phase ever tested, and both defects live in it.
- shakenfist#4003 —
vdi-console-proxyis a static entry inAPI_CAPABILITIES(external_api/app.py), so it is advertised even whenconfig.KERBSIDE_URLis empty. The client'scheck_capability()is a substring match against the root page and is the only feature-detection channel it has, so it takes the proxy path and dies on the endpoint's own404 kerbside integration is not configured, instead of falling back to the direct.vv.
This contradicts phase 2's decision 5, which reasoned that
"a client probes the vdi-console-proxy capability first
anyway". The probe cannot work: the advertisement is not
conditional on the config value the guard reads. The fix is
more than a one-liner because render_capabilities() walks
the dict unconditionally and tests/external_api/test_root.py
pins it exhaustively — both need a notion of a conditional
token.
- shakenfist#4009 — the direct
.vvfrom/vdiconsolehelperis malformed.host=is substituted frominstance.placement['node'], which now holds a node UUID, so it names a host that resolves nowhere;type=is substituted frominstance.video['vdi'], so aspiceconcurrentinstance emits a type that virt-viewer rejects with "Unsupported graphic type"; and nohost-subjectis emitted, leaving ryll's subject pinning inert on this path (any cert from the cluster CA passes). The module's owninstance_vv_file_exampledocstring still shows the correcttype=spice/host=sf-3, so the code has disagreed with its own example since placement moved to UUIDs. No test in any repo parses a.vv, which is how all three survived.
Both are pure-shakenfist and reproduce without a Kerbside deployment. Phase 11 tracks closing them.
Related gap (not a defect)¶
- shakenfist#4004 — the ansible collection has no
KERBSIDE_URLknob, so no collection-deployed cluster can turn the feature on. This is the deployer phase this plan deliberately deferred (see Future work), not something that regressed; it is filed so the gap lives in the tracker rather than only in a plan bullet.
Documentation index maintenance¶
On creation of this plan: add a Master plans row to
docs/plans/index.md and an entry to docs/plans/order.yml
in the shakenfist repo, and a cross-reference entry to
kerbside's docs/plans/index.md. As phases complete, update
the Execution table above and both indexes.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.