Skip to content

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)

  1. Self-service claim portal (option B), not printed handouts.
  2. 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.
  3. Both claim modes, chosen per workshop: roster (personal links) and open (workshop code + email).
  4. Design doc first, then phased implementation.
  5. 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 HostProvider protocol with a reserved host id portal and an extra tag/label rodeo-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.yaml under a top-level portal: block (not hosts[]), so fleet deploy, status, diagnose and retry never fan out to it.
  • fleet deprovision terminates it together with the labs; --keep-portal opts out (e.g. to keep the roster while re-provisioning labs).
  • Bare metal / BYO: portal.host: user@ip skips provisioning; portal up just installs onto that machine.
  • Security group: its own managed SG, tagged rodeo-resource: portal-security-group so the lab SG lookup can never match it. 443 and 80 (ACME HTTP-01 only) open to the world, 22 operator /32 only, 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 in authorized_keys, so the operator can still SSH in.

4.2 Software on the portal

  • rodeo itself, installed with the existing install.sh at the fleet's lab.ref, so the portal runs the same version as the CLI that talks to it.
  • rodeo portal serve: new command, stdlib http.server.ThreadingHTTPServer + jinja2 (already a dependency) + sqlite3. Binds 127.0.0.1:8080. Runs as a dedicated rodeo-portal system user under a systemd unit with the usual hardening (ProtectSystem=strict, NoNewPrivileges, PrivateTmp, state in StateDirectory=).
  • 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 Rancher letsEncrypt). 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

  1. workshop.yaml points to portal.roster: students.csv with columns name,email[,host_id]. A host_id pins a student to a lab; otherwise labs are assigned in order at invite time.
  2. rodeo fleet portal invite creates one unguessable token per student (secrets.token_urlsafe(24)), stores only its SHA-256 on the portal, and writes portal-invites.csv locally (name,email,host_id,url) for mail-merge. The file is 0600, and the command reminds the operator it contains credentials-equivalent links.
  3. The student opens https://<portal>/l/<token> and sees their lab card.

5.2 Open (workshop code)

  1. rodeo fleet portal info shows the portal URL and an event code (e.g. WOLF-4821, 8+ chars from an unambiguous alphabet), meant for a slide.
  2. The student enters the code once, then name + email + a 6-digit PIN they choose (obvious PINs refused).
  3. 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.
  4. 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.)
  5. 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::

provider:
  student_access: open     # operator (default) | open
  • operator (default, today's behaviour): exactly the operator /32 on every managed port. Dev and test fleets stay as they are.
  • open: allows the lab UI ports (8443, 30002) to be opened to 0.0.0.0/0, but provision never opens them. The operator runs rodeo fleet open-access once every lab is up (decision, Andrés, 2026-09-29). 22 always stays operator-only. With portal.student_ssh: true (F5.4) students get 2222 instead, served by a separate sshd that only lets the student user 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 deploy only starts the runs and returns, so it cannot be the trigger either.
  • rodeo fleet open-access refuses 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_ingress desired state becomes operator /32 plus 0.0.0.0/0 on the component ports.
  • rodeo fleet open-access --close, or any later fleet provision run, reconciles back to operator-only (idempotent, tested with the mocked EC2 pattern).
  • Provision prints a note when open is set, pointing at open-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

  1. 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.
  2. Do the workshop exercises need SSH on the KVM host at all? If suse-virt/suse-edge exercises are UI-only, student_ssh stays false and F5.4 can be skipped.
  3. Should the instructor also get a read-only web view (who claimed what) for the room screen? Currently excluded (CLI only).
  4. Lab expiry shown to students ("this lab is destroyed at 17:00"): easy to add if workshop.yaml gains an ends_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 up installs 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: true implements 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): the virt-workshop-aws profiles 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 of sudo.
  • 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-link issues 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 /privacy page. 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 up copies only rodeo/portal/ (standard library only, enforced by a test) and runs it with the system python3. Same version as the CLI talking to it, and no Ansible/libvirt dependencies on a machine that does not need them. jinja2 is not used: pages are built with html.escape on every value (tests cover escaping).
  • Portal VM provisioning: AwsHostProvider.provision_portal() / deprovision_portal() rather than a role field threaded through the lab provision(). Lab provisioning is untouched, which removes regression risk R3/R5 by construction. Lab deprovision skips instances tagged rodeo-role: portal and ignores them when deciding whether the lab SG can go (R1).
  • Not implemented yet: portal.close_after, the student column in fleet 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:

  • :22 is never opened to students. set_student_access refuses it outright, and open-access takes away a 0.0.0.0/0 rule on :22 left by an older release.
  • Publish sets up rodeo-student-sshd, a second sshd on :2222 with 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 with sshd -t before install, the service restarts only when it changed (KillMode=process keeps 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-access adds 0.0.0.0/0 there and any later provision or --close removes 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_letters is 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_ALL in rodeo/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 up after upgrading rotates the code to the new length, as any code_letters change does.