Fleet claim portal¶
1. Problem¶
rodeo fleet provision + rodeo fleet deploy give the instructor N working labs,
but students cannot get into them today:
| Gap | Where | Effect |
|---|---|---|
| Ingress is operator-only | _reconcile_managed_sg_ingress in rodeo/providers/aws.py, MANAGED_SG_PORTS = (22, 8443, 30002) |
The managed SG allows exactly the operator's /32 and revokes anything else. Students time out. |
| Credentials never leave the host | rodeo/fleet/access.py (URLs only), ~/.rodeo/secrets.yaml per host |
Nothing hands a student their password. |
| No student identity | workshop.yaml hosts[] |
No record of who has which lab; the instructor cannot find "Maria's lab" when she asks for help. |
| Only one SSH key exists, and it is fleet-wide | plant_rodeo_ssh_key copies ~/.rodeo/ssh/id_ed25519 to /root/.ssh/ on every host |
Anyone with root on one lab host can read a key that is root on all of them. Students must never get root on the KVM host. |
2. Decisions already made (Andrés, 2026-09-25)¶
- Self-service claim portal (option B), not printed handouts.
- Runs on a dedicated small VM, provisioned and destroyed with the fleet. Not on a lab host, not on the laptop, not provider-specific serverless.
- Both claim modes, chosen per workshop: roster (personal links) and open (workshop code + email).
- Design doc first, then phased implementation.
- Students need zero network knowledge. Lab UI ports are opened to the internet
(
provider.student_access: open) with safeguards, instead of IP allowlists (section 7).
The portal's security constraints (section 6) keep one rule fixed: no cloud credentials and no rodeo SSH key ever leave the instructor's machine.
3. Goals and non-goals¶
Goals
- A student opens one URL, identifies themselves, and gets their lab: UI URLs, usernames, passwords and, optionally, SSH access. Nothing about anyone else's lab.
- The instructor sees who has which lab and can release or reassign one.
- Works for any fleet: AWS today, GCP/Vultr/Hetzner once F4b-d ship, bare metal via a bring-your-own portal host.
- Idempotent like the rest of rodeo: every command safe to re-run.
- No new Python dependencies in the core package.
Non-goals (v1)
- Sending email. The CLI outputs a CSV of personal links; the instructor mail-merges them.
- A web admin UI. Instructor control is CLI-only (smaller attack surface, no admin password to manage).
- SSO / OAuth login.
- Proxying lab UIs through the portal. Students connect to their lab host directly.
4. Architecture¶
Instructor laptop Portal VM (per workshop) Lab hosts
----------------- ------------------------ ---------
rodeo fleet provision ──creates──► t3.small, tag rodeo-role=portal
──creates──────────────────────────────────────► host-01..N
rodeo fleet deploy ──SSH──────────────────────────────────────────► rodeo up
rodeo fleet portal publish
│ 1. SSH to each ready host, read secrets.yaml, ensure student user/key
│ 2. SSH to portal, upsert lab records ──SSH──► sqlite (0600)
▼
rodeo fleet portal status/close/release ──SSH──► rodeo portal admin (local CLI)
Students ──HTTPS :443──► Caddy ──► rodeo portal serve (127.0.0.1)
Students ──HTTPS :8443/:30002 (+ SSH :2222 opt.)──────────────────────► their host
Key property: the portal is passive. It never connects to lab hosts or cloud APIs, holds no rodeo SSH key and no cloud credentials. The laptop pushes data to it over SSH. If the portal VM is compromised, the blast radius is the student-level credentials of that one workshop's labs, which are ephemeral anyway.
4.1 Portal VM¶
- Provisioned through the existing
HostProviderprotocol with a reserved host idportaland an extra tag/labelrodeo-role: portal. Default size per provider (AWS:t3.small, same Leap 16 AMI as the lab hosts, so no second AMI lookup). Roughly 2 US cents/hour on AWS. - Written to
workshop.yamlunder a top-levelportal:block (nothosts[]), sofleet deploy,status,diagnoseandretrynever fan out to it. fleet deprovisionterminates it together with the labs;--keep-portalopts out (e.g. to keep the roster while re-provisioning labs).- Bare metal / BYO:
portal.host: user@ipskips provisioning;portal upjust installs onto that machine. - Security group: its own managed SG, tagged
rodeo-resource: portal-security-groupso the lab SG lookup can never match it.443and80(ACME HTTP-01 only) open to the world,22operator/32only, same reconcile pattern as the lab SG. - No rodeo private key on the portal. The provider's provision loop plants the
fleet-wide key on every host it creates; the portal is provisioned with
role: portal, which skips that step. Only the public key is inauthorized_keys, so the operator can still SSH in.
4.2 Software on the portal¶
rodeoitself, installed with the existinginstall.shat the fleet'slab.ref, so the portal runs the same version as the CLI that talks to it.rodeo portal serve: new command, stdlibhttp.server.ThreadingHTTPServer+jinja2(already a dependency) +sqlite3. Binds127.0.0.1:8080. Runs as a dedicatedrodeo-portalsystem user under a systemd unit with the usual hardening (ProtectSystem=strict,NoNewPrivileges,PrivateTmp, state inStateDirectory=).- Caddy in front for TLS with automatic Let's Encrypt on
portal-<ip-dashed>.sslip.io(the same sslip.io trick rodeo already uses for RancherletsEncrypt).portal.hostname:overrides it for a real DNS name. rodeo portal admin ...: local CLI on the portal that the laptop invokes over SSH (publish,status,release, ...). It is the only write path into the database besides the claim flow.
5. Claim flows¶
portal.mode: roster | open | both (default both).
5.1 Roster¶
workshop.yamlpoints toportal.roster: students.csvwith columnsname,email[,host_id]. Ahost_idpins a student to a lab; otherwise labs are assigned in order at invite time.rodeo fleet portal invitecreates one unguessable token per student (secrets.token_urlsafe(24)), stores only its SHA-256 on the portal, and writesportal-invites.csvlocally (name,email,host_id,url) for mail-merge. The file is 0600, and the command reminds the operator it contains credentials-equivalent links.- The student opens
https://<portal>/l/<token>and sees their lab card.
5.2 Open (workshop code)¶
rodeo fleet portal infoshows the portal URL and an event code (e.g.WOLF-4821, 8+ chars from an unambiguous alphabet), meant for a slide.- The student enters the code once, then name + email + a 6-digit PIN they choose (obvious PINs refused).
- In one sqlite transaction the portal assigns the next free, ready lab and returns
a personal link (
/l/<token>) plus a cookie. The page tells them to bookmark it. - Lost link: the same browser remembers the lab (cookie, 3 days, "forget this device" available); on another device a separate "get it back" form takes email + PIN only and returns the same lab under a new link. The PIN stops someone who only knows a classmate's email from opening their lab. (Decision 2026-10-01: the name is not used for recovery: it is public on the board and typed inconsistently.)
- No labs left: "No free labs, ask your instructor." Claiming can be closed
(
portal close) or auto-closed (portal.close_after: 2h).
5.3 Both¶
The roster is invited first and those labs are reserved. The remaining labs are open to the event code. This covers "registered attendees plus walk-ins".
5.4 What a student sees¶
One card: lab id, status (ready / still building, refresh in a few minutes),
Harvester URL + admin + password, Rancher URL + admin + password (only for the
components the lab has, reusing lab.components logic from fleet/access.py), and,
if enabled, SSH: a download link for their private key plus a copy-paste
ssh -i lab-07.key student@<ip> line. Also a self-signed-certificate note for the lab
UIs. Pages send Cache-Control: no-store and Referrer-Policy: no-referrer.
6. Security model¶
| Threat | Mitigation |
|---|---|
| Portal compromise | Portal stores only student-level creds for this workshop; no cloud creds, no rodeo key, no admin web UI. Short-lived: destroyed at deprovision. Encrypted root volume where the provider supports it. |
| Guessing the event code | 8+ char code, per-IP rate limit and lockout on the claim endpoint, portal close to stop claiming, portal rotate-code. |
| Leaked personal link | Tokens stored hashed; portal release <lab> or portal revoke <email> invalidates the token. |
| Student escalates to other labs | Student user has no sudo and cannot read /root. This is mandatory while the fleet-wide rodeo key is planted in /root/.ssh/ (section 1). :22 is never opened to the internet; with student_ssh: true students get :2222, a separate sshd that accepts only student, by key (section 14b). |
Lab UIs reachable from the internet (student_access: open) |
Enforced strong per-host passwords, only the component ports, short lifetime. See section 7. |
| Credentials in transit | Laptop to hosts and to portal: SSH only. Students to portal: HTTPS only (HTTP redirects, HSTS). |
| Secrets in logs | Server logs method, path template and status only; never tokens, PINs, emails or passwords. |
| Personal data (emails, GDPR) | Emails exist only in the portal DB and the local invites CSV. Deleted with the portal VM; portal export for the instructor's attendance list, then gone. |
Follow-up, out of scope here but worth its own ROADMAP line: replace the fleet-wide key
planted in /root/.ssh/id_ed25519 with a per-host key for the nested hops. Once that
lands, giving students sudo on their own host becomes safe to consider.
7. Student network access (prerequisite, useful even without the portal)¶
Decision (Andrés, 2026-09-25): students must reach their lab without knowing anything about their network or IP. So there is no per-student or per-venue allowlist. The lab ports are opened to the internet for workshops that ask for it. Alternatives rejected:
- Auto-allowlist the student's IP on claim: breaks silently when the student's IP changes (mobile, VPN, another Wi-Fi), and needs the portal to hold a cloud permission.
- Proxy all lab traffic through the portal: VM image uploads and consoles through one small VM, and Rancher/Harvester misbehave behind a different hostname.
New workshop.yaml field under provider::
operator(default, today's behaviour): exactly the operator/32on every managed port. Dev and test fleets stay as they are.open: allows the lab UI ports (8443,30002) to be opened to0.0.0.0/0, but provision never opens them. The operator runsrodeo fleet open-accessonce every lab is up (decision, Andrés, 2026-09-29).22always stays operator-only. Withportal.student_ssh: true(F5.4) students get2222instead, served by a separate sshd that only lets thestudentuser in (section 14b). IPv4 only; the hosts have no public IPv6.- Why a separate step: the tester lab on 2026-09-25 showed the ports must open only
after the deploy has set the Harvester/Rancher admin passwords, otherwise the
first-login screen is on the internet for the whole deploy.
fleet deployonly starts the runs and returns, so it cannot be the trigger either. rodeo fleet open-accessrefuses unless every host reports all phases complete and its UI admin passwords are strong. The managed SG is shared by the whole workshop, so one unready host blocks the lot. On success,_reconcile_managed_sg_ingressdesired state becomes operator/32plus0.0.0.0/0on the component ports.rodeo fleet open-access --close, or any laterfleet provisionrun, reconciles back to operator-only (idempotent, tested with the mocked EC2 pattern).- Provision prints a note when
openis set, pointing atopen-access.
Safeguards that come with open:
| Safeguard | Detail |
|---|---|
| Strong passwords enforced | Before fleet open-access opens the ports (and before the portal publishes a lab), rodeo checks the host's secrets.yaml on the host: every UI admin password must be at least 16 chars with upper, lower and digit (what secretgen.random_password produces, about 95 bits). The check prints only strong / weak / missing per key; the password never leaves the host. A weaker, hand-set password (e.g. via rodeo set-password) blocks opening with a clear error instead of exposing it. |
| SSH key-only | The EC2 userdata already sets ssh_pwauth: false and PermitRootLogin prohibit-password; the student user only has a key. |
| Minimal ports | Only the ports the lab's components use (lab.components), never the full MANAGED_SG_PORTS blindly. |
| Short lifetime | Labs live for the workshop; the portal shows the teardown time and fleet deprovision closes everything. |
| BYO security groups untouched | With provider.security_group_ids set, rodeo does not change rules; open-access refuses and says the operator must open the ports. |
Accepted residual risk: the Harvester and Rancher login pages are internet-visible for the workshop's duration and will be found by scanners. With random 95-bit passwords the realistic exposure is an unpatched vulnerability in those products, limited by current versions and hours-long lifetimes. This matches how hosted lab platforms operate.
Bare metal fleets have no managed SG; student_access is ignored there and
fleet doctor reports whether each host's UI ports are reachable from outside.
8. workshop.yaml additions¶
portal:
enabled: true
mode: both # roster | open | both
roster: students.csv # roster / both
student_ssh: false # per-lab 'student' user + key, on its own sshd at :2222
close_after: 2h # optional: stop accepting new claims
# instance_type: t3.small # provider-specific override
# hostname: labs.example.com # instead of portal-<ip>.sslip.io
# host: root@203.0.113.50 # BYO / bare metal: skip provisioning
# written by provision, like hosts[]:
# ssh: ec2-user@203.0.113.50
# public_ip: 203.0.113.50
# labels: {provider_id: i-0abc...}
load_inventory validates this block and fails closed on unknown mode, a missing
roster file, or portal.enabled: true on a provider fleet whose
provider.student_access is not open (students could not reach their labs).
9. CLI surface¶
| Command | Runs where | What |
|---|---|---|
rodeo fleet provision |
laptop | Also provisions the portal VM when portal.enabled (--no-portal to skip). |
rodeo fleet open-access [--close] |
laptop | Opens the lab UI ports to 0.0.0.0/0 once every lab is complete with strong passwords (F5.0). |
rodeo fleet portal up |
laptop to portal | Install rodeo + Caddy, write systemd units, start. Idempotent. |
rodeo fleet portal publish |
laptop to hosts, then portal | For each host: check readiness (rodeo status --output json), read secrets.yaml, ensure student user/key if enabled, upsert the lab record. Labs not ready are published as building. Safe to run repeatedly; run it again after fleet retry. |
rodeo fleet portal invite |
laptop | Roster tokens, writes portal-invites.csv. Re-running keeps existing tokens unless --rotate. |
rodeo fleet portal info |
laptop | Portal URL, workshop code, open/closed. |
rodeo fleet portal status [--output json] |
laptop to portal | Lab, state, student, claimed-at. Also shown as a student column in fleet status. |
rodeo fleet portal open / close / rotate-code |
laptop to portal | Claim window control. |
rodeo fleet portal release <lab> / reassign <email> <lab> / revoke <email> |
laptop to portal | Fix mistakes during a workshop. |
rodeo fleet portal export |
laptop to portal | Attendance CSV (name, email, lab). |
rodeo fleet deprovision |
laptop | Also terminates the portal (--keep-portal to keep it). |
rodeo fleet deprovision --unclaimed N\|all / --host <lab> ... |
laptop to portal | Scale down: pauses claiming, terminates N unclaimed labs (or the named ones; claimed labs refused unless --force), removes them from the portal, reopens claiming. Portal VM stays. |
All of them support --output json so scripts can call them without parsing Rich
tables.
10. Code layout¶
New modules only; existing working code is touched only where noted.
| Path | Kind |
|---|---|
rodeo/portal/server.py, db.py, claims.py, templates/*.html |
new: the service on the portal VM |
rodeo/portal/admin.py |
new: rodeo portal admin local CLI |
rodeo/fleet/portal.py |
new: laptop-side orchestration (provision, up, publish, ...) |
rodeo/commands/portal_cmd.py |
new: rodeo portal serve/admin and rodeo fleet portal ... group |
rodeo/data/portal/ |
new: systemd unit, Caddyfile template |
rodeo/fleet/inventory.py |
edit: parse/validate portal: and provider.student_access |
rodeo/providers/aws.py |
edit: student_access: open in SG reconcile; role tag for the portal instance |
rodeo/providers/base.py |
edit: optional role on ProvisionSpec |
rodeo/commands/fleet_cmd.py |
edit: wire provision/deprovision/status hooks |
docs/fleet.md, mkdocs.yml, ROADMAP.md |
edit: new phase F5 |
11. Phases¶
The step-by-step plan, the no-regression contract, the risks found in the current code and the test and security gates are in claim-portal-plan.md. Summary:
| Phase | Deliverable |
|---|---|
| F5.0 | Baseline tests pinning today's behaviour, then provider.student_access: open + password strength gate |
| F5.1 | Portal service (new code only): db, claim flows, rodeo portal serve/admin |
| F5.2 | Portal VM lifecycle: provision/up/deprovision, own SG, no key planting, Caddy + TLS |
| F5.3 | publish, invite and instructor commands, fleet status student column |
| F5.4 | Optional student SSH (off by default, no sudo) |
| F5.5 | Docs, ROADMAP, CHANGELOG |
12. Open questions¶
- Caddy on Leap 16. Needs checking whether it is in the Leap 16 repos. Fallback: the official static binary pinned by version and SHA-256.
- Do the workshop exercises need SSH on the KVM host at all? If
suse-virt/suse-edge exercises are UI-only,
student_sshstaysfalseand F5.4 can be skipped. - Should the instructor also get a read-only web view (who claimed what) for the room screen? Currently excluded (CLI only).
- Lab expiry shown to students ("this lab is destroyed at 17:00"): easy to add if
workshop.yamlgains anends_at.
13. Decisions during implementation (2026-09-30)¶
Answers to section 12 and deliberate deviations, all from Andrés unless noted.
- Open question 1 (Caddy): not in the SLES 16 repos.
portal upinstalls the official static binary pinned by version and SHA-512 (rodeo/fleet/portal.py). - Open question 2 (student SSH): yes, students need SSH to their lab host.
portal.student_ssh: trueimplements F5.4 as designed (no sudo, no privileged groups, cannot read/root, checked on every publish). The suse-virt workshop's terminal steps work from that account (2026-10-16): thevirt-workshop-awsprofiles install a student kit at/opt/suse-virt-workshop/(self-checks and a shared lab VM key the pre-created VMs trust), and the guide takes kubectl's kubeconfig from the Harvester UI instead ofsudo. - Open question 3 (instructor web view): yes, in addition to the CLI. A read-only
page at
/admin/<token>with every claim and every lab's credentials and SSH key;rodeo fleet portal admin-linkissues a new token and revokes the previous one. No write actions on the web, so no admin password. - Public claim board: the front page lists every lab with its state and the claimant's name (never email or credentials), so students see who has which lab.
- Privacy notice (2026-10-01): footer line on every page + note under the claim
form + a public
/privacypage. Only essential cookies (csrf,access,lab), so no consent banner. Wording states what is kept (name, email, hashed PIN, claim times; deleted with the portal VM; the instructor can export it) rather than "no personal data", which would be false. Suggested: privacy-team review of the text. - No email sending (considered and dropped 2026-09-30): the lab is shown on the student's personal page right after claiming.
- Portal software: instead of installing all of rodeo on the portal VM,
portal upcopies onlyrodeo/portal/(standard library only, enforced by a test) and runs it with the systempython3. Same version as the CLI talking to it, and no Ansible/libvirt dependencies on a machine that does not need them.jinja2is not used: pages are built withhtml.escapeon every value (tests cover escaping). - Portal VM provisioning:
AwsHostProvider.provision_portal()/deprovision_portal()rather than arolefield threaded through the labprovision(). Lab provisioning is untouched, which removes regression risk R3/R5 by construction. Labdeprovisionskips instances taggedrodeo-role: portaland ignores them when deciding whether the lab SG can go (R1). - Not implemented yet:
portal.close_after, thestudentcolumn infleet status, GCP/Vultr/Hetzner portal VMs.
14. Security review (2026-09-30)¶
Review of origin/main..docs/claim-portal. All findings fixed on the branch:
| # | Finding | Fix |
|---|---|---|
| 1 | Every claim counted against 10/min per IP: a classroom behind one NAT address would be locked out. | Only failed attempts count (wrong code or PIN, unknown links, bad admin token): 30 per 10 minutes per address. |
| 2 | The public board showed attendee names to anyone; the sslip.io hostname is public via Certificate Transparency. |
Workshop code gate (option a): the front page shows only a code field; a cookie bound to the current code then shows board and claim form. Code format RODEO-XXXX-YYYYMMDD (Andrés's choice), 4 random letters by default, portal.code_letters up to 8. |
| 3 | Fleet SSH never verified host keys while it now moves lab passwords and student keys. | accept-new + per-workshop known_hosts; provisioning forgets re-created addresses. Host-to-VM hops inside a lab unchanged. |
| 4 | Host ids reached file paths and HTTP headers; portal.hostname reached the Caddyfile unvalidated. |
Validated at inventory load (and lab ids again on import); workshop names sanitised in local paths. |
| 5 | Portal install script used fixed /tmp names as root. |
mktemp -d work directory. |
| 6 | Inline style= attributes were blocked by the portal's own CSP (empty progress bars). |
CSS classes and native <progress>; a test asserts no page uses style=. |
Accepted as designed: lab logins internet-visible behind strong passwords; anyone with
the code can lock out an email (5 wrong PINs, portal unlock) or take labs under fake
emails (release, close); a portal compromise exposes student-level credentials only;
R7 (fleet-wide key in /root/.ssh on lab hosts). To verify live: sshd -T shows
passwordauthentication no, and as student,
find / -xdev -name 'id_ed25519*' -readable finds only the student's own key.
14b. Student SSH off port 22 (2026-10-05)¶
The Cursor security review of PR #53 found that student_ssh: true made
open-access open :22 to 0.0.0.0/0. That sshd also accepts the fleet-wide rodeo
key for root and ssh_user (ec2-user, NOPASSWD sudo). So whoever got the operator's
key could log in as root from anywhere, and root on one lab could use the key in
/root/.ssh/id_ed25519 (R7) to reach every other lab over their public IPs. It
shipped in v0.18.0 and v0.19.0.
Fix:
:22is never opened to students.set_student_accessrefuses it outright, andopen-accesstakes away a0.0.0.0/0rule on:22left by an older release.- Publish sets up
rodeo-student-sshd, a second sshd on:2222with its own config (/etc/ssh/rodeo-student-sshd_config):AllowUsers student, no root, no passwords. The main sshd is not touched, so the operator can't be locked out. The config is validated withsshd -tbefore install, the service restarts only when it changed (KillMode=processkeeps open sessions), and publish proves the effective settings and the listener before reporting the lab ready. - The managed SG has no operator rule on
:2222;open-accessadds0.0.0.0/0there and any laterprovisionor--closeremoves it. - The portal shows
ssh -p 2222 -i <lab>.key student@<ip>.
Root on a lab can still read the planted key (R7), but no other lab accepts it from the internet any more. The per-host nested key that removes R7 is still to do.
14c. Workshop code against distributed guessing (2026-10-05)¶
The same Cursor review (MEDIUM) found that the default code was 4 letters from 24, about 331k codes or 18 bits (the date isn't secret), and failed attempts were only limited per address (30 per 10 minutes). Many addresses together could walk the code space during a workshop and claim labs without the spoken code.
Fix:
- Default
portal.code_lettersis now 6 (about 191M codes). 4 to 8 still work. - Wrong workshop codes also count against 200 per 10 minutes for the whole portal
(
CODE_FAIL_LIMIT_ALLinrodeo/portal/web.py). - Past the cap, only addresses that already sent 3 or more wrong codes in the window
wait (
CODE_FAILS_WHILE_CAPPED). Fresh addresses, so real students, still get in with the right code, and everyone who already entered keeps their access cookie (24 h); claims, recovery and lab links don't use the cap. So flooding wrong codes can't close the gate for the class (Cursor review on #77). The price: past the cap, each new attacking address still gets 2 guesses per window. With 1,000 addresses over 8 hours that's about 105k guesses, about 0.06% odds against 6 letters (0.75% without the portal-wide cap). - Existing portals: the next
portal upafter upgrading rotates the code to the new length, as anycode_letterschange does.