A reusable Flask lab portal for deploying, managing, and destroying Proxmox virtual machines. Students use a web dashboard while operators control access, lab visibility, and the VM fleet.
- My Labs tab -- unified view of active VMs with extend and destroy controls in one place
- One-click deployment -- select a lab, click Create, and the portal provisions all VMs in parallel with automatic retries
- Lifecycle management -- extend VM lifetime when less than 60 minutes remain, or destroy a deployment group when finished
- Entry-point IP display -- only the entry-point VM's IP is shown; internal topology stays hidden
- Admin Dashboard -- student management, VM overview, bulk operations, and alert testing from a single panel
- Lab assignment restrictions -- control which deployment groups each student can see (unrestricted by default; restrict by assigning specific students)
- Student management -- enable/disable accounts, toggle admin role, view per-student VM counts
- Bulk operations -- extend or destroy many VMs at once; bulk-assign students to labs
- CSV import -- import student whitelist from Canvas CSV exports; VLAN tags and Headscale keys are pre-assigned
- Alert system -- email, webhook (Slack/Discord), database, and file alerts for failed VM deletions and config corruption
- Competitive lab arena -- one shared deployment of a
templates.yamlgroup, multipleStudentGroups play as teams, single-active-game enforced at the DB - Live scoreboard + network map -- Cytoscape.js topology view, scoreboard with rank/score, event log, all updated via Server-Sent Events
- Token-authenticated box claims -- students
curl /koth/claim -d token=... -d box=$(hostname)from inside rooted boxes; per-team submission tokens are rotatable - Service-uptime scoring -- in-process port scanner ticks every 12s, awards points for each open service the team owns; exponential late-game multiplier
- Admin-defined flags -- per-game flags entered in the admin UI, submitted via
/koth/flag, deduplicated per (flag, team) - Pause + hide controls -- pause non-admin deployments while the game is in progress; hide scoreboard or map for end-of-game reveal
- Deploy / redeploy -- redeploy preserves scores/teams/history but re-clones VMs (new IPs, ownership clears)
- See docs/KING_OF_THE_HILL.md for the full guide. Operators enable KOTH on deployment groups in their own
templates.yaml.
- Weighted load balancing -- distributes VMs across Proxmox nodes by CPU (30%), memory (30%), and free memory (40%)
- Cross-node template sync --
sync_template.shreplicates templates between nodes using vzdump/qmrestore (no NFS) - Automatic cleanup -- a background task destroys expired VMs every minute; a record whose guest was deleted by hand in Proxmox is retired, with an admin alert, once fresh inventory has confirmed the absence for five minutes; a time-boxed orphan reaper removes expired guests that have no record
- Per-student VLAN isolation -- entry VLAN (100-200) and internal VLAN (200-300) per student
- Startup config validation -- templates.yaml is validated at boot; mismatches are caught before any VM is created
| Layer | Technology |
|---|---|
| Backend | Flask 2.3, Python 3.12 |
| Database | MySQL 8.0 via SQLAlchemy, SQLite for tests |
| Proxmox API | Proxmoxer 2.0 |
| Frontend | HTML5, Bootstrap 5, Vanilla JS (5 modules) |
| Fonts | DM Sans (UI), JetBrains Mono (code/data) |
| Configuration | .env + templates.yaml |
| Deployment | Docker, Docker Compose, Gunicorn |
| CI/CD | GitHub Actions (unit + integration + Docker Hub push) |
| Testing | pytest, Playwright |
Ragnar-Lab-Playground/
├── app/ # Flask application package
│ ├── __init__.py # App factory
│ ├── auth.py # Authentication routes & decorators
│ ├── config.py # Configuration from env vars
│ ├── models.py # SQLAlchemy models
│ ├── proxmox_service.py # Proxmox API wrapper
│ ├── tasks.py # Background tasks (cleanup, alerts)
│ ├── template_manager.py # templates.yaml loader & validator
│ ├── utils.py # Shared utilities
│ └── routes/ # Modular route blueprints
│ ├── __init__.py # Blueprint aggregation
│ ├── admin.py # Admin endpoints (students, assignments, bulk ops)
│ ├── node_resources.py # Cluster node monitoring
│ ├── pages.py # HTML page rendering
│ ├── templates.py # Deployment group listing with restriction filter
│ ├── utils.py # Route helper functions
│ ├── vm_creation.py # Parallel VM creation
│ ├── vm_lifecycle.py # Extend & destroy
│ ├── vm_listing.py # VM list with orphan cleanup
│ └── vm_status.py # Time-remaining queries
├── static/
│ ├── css/style.css # Dark SOC-style theme
│ └── js/
│ ├── app.js # Core application logic
│ ├── admin.js # Admin dashboard UI
│ ├── auth.js # Login/register flows
│ ├── utils.js # Shared JS helpers
│ └── vm-operations.js # VM create/extend/destroy UI
├── templates/ # Jinja2 HTML templates
│ ├── index.html # Main dashboard
│ ├── login.html # Login page
│ ├── register.html # Registration page
│ └── reset_password.html # Password reset page
├── tests/
│ ├── conftest.py # Shared test fixtures
│ ├── test_admin_dashboard.py # Admin endpoint tests
│ ├── test_api_routes.py # API route tests
│ ├── test_auth_routes.py # Auth flow tests
│ ├── test_config.py # Config validation tests
│ ├── test_lab_assignments.py # Assignment restriction tests
│ ├── test_models.py # Model unit tests
│ ├── test_tasks.py # Background task tests
│ ├── test_template_manager.py # Template loading tests
│ ├── test_vm_creation_parallel.py # Parallel creation tests
│ └── integration/
│ ├── conftest.py # Integration test fixtures
│ └── test_flows.py # Playwright browser tests
├── scripts/
│ ├── sync_template.sh # Cross-node template replication
│ ├── import_students.py # Canvas CSV student import
│ ├── init_db.py # Database initialization
│ ├── jumpbox_provision.sh # Create the jumpbox VM on a Proxmox node (docs/JUMPBOX.md)
│ ├── jumpbox_configure.sh # Jumpbox router: VLAN gateways, DHCP, Tailscale, optional Zeek/RITA
│ ├── jumpbox_netgen.py # netplan/dnsmasq generator used by jumpbox_configure.sh
│ ├── jumpbox_accounts.sh # Optional jumpbox SSH accounts: create, lock-stale, unlock
│ ├── backup-database.sh # MySQL backup utility
│ ├── rotate_headscale_keys.sh # Rotate students' Headscale pre-auth keys (dry run by default)
│ ├── update_headscale_keys.py # Store rotated keys in the portal database (run in the app container)
│ └── migrate_*.py # Database migration scripts
├── .github/workflows/ci.yml # CI pipeline definition
├── .github/workflows/release.yml # Tag-driven release: GHCR image + GitHub Release
├── docker-compose.yml # Development environment
├── docker-compose.prod.yml # Production environment
├── docker-compose.release.yml # Override: run a released GHCR image instead of building
├── CHANGELOG.md # Release history with upgrade notes
├── docker-compose.hub.yml # Deploy from Docker Hub (no source needed)
├── docker-compose.test.yml # Integration test environment
├── Dockerfile # Development image
├── Dockerfile.prod # Production image (Gunicorn)
├── templates.example.yaml # Example lab/deployment group definitions
├── requirements.txt # Python dependencies
├── run.py # Dev entry point
├── run_prod.py # Production entry point
└── gunicorn.conf.py # Gunicorn configuration
The fastest path is Docker Compose:
# 1. Clone the repository
git clone git@github.com:Ragnar-Security/Ragnar-Lab-Playground.git
cd Ragnar-Lab-Playground
# 2. Create operator-owned configuration
cp .env.example .env
cp templates.example.yaml templates.yaml
# Edit both files for your Proxmox cluster and site
# 3. Start everything
docker compose up -d --build
# 4. Open http://localhost:5001
# Set ADMIN_LOGIN_ID and ADMIN_PASSWORD explicitly before sharing the instanceEach vX.Y.Z release publishes a container image,
ghcr.io/ragnar-security/lab-playground:X.Y.Z, for linux/amd64 and
linux/arm64, with build provenance and an SBOM. The highest release also
moves latest, and the highest of each X.Y line moves X.Y. The
GitHub Release
carries the upgrade notes and a deploy bundle: the production Compose stack,
the docker-compose.release.yml override, the Caddy configuration, example
configuration files, the backup script and every migration. No git checkout
is needed.
# Verify the download and the image (GitHub CLI)
sha256sum -c SHA256SUMS
gh attestation verify lab-playground-1.0.0-deploy.tar.gz \
-R Ragnar-Security/Ragnar-Lab-Playground \
--signer-workflow Ragnar-Security/Ragnar-Lab-Playground/.github/workflows/release.yml \
--source-ref refs/tags/v1.0.0
gh attestation verify oci://ghcr.io/ragnar-security/lab-playground:1.0.0 \
-R Ragnar-Security/Ragnar-Lab-Playground \
--signer-workflow Ragnar-Security/Ragnar-Lab-Playground/.github/workflows/release.yml \
--source-ref refs/tags/v1.0.0
tar -xzf lab-playground-1.0.0-deploy.tar.gz
cd lab-playground-1.0.0
cp .env.example .env # then edit, and add:
# COMPOSE_FILE=docker-compose.prod.yml:docker-compose.release.yml
# LAB_PLAYGROUND_VERSION=1.0.0
cp templates.example.yaml templates.yaml
python3 scripts/preflight_env.py .env
docker compose up -dDEPLOY-FROM-RELEASE.md in the bundle has the full steps, including upgrades
and rollback. Before upgrading, read the ### Upgrade notes of every release
in CHANGELOG.md since your version. /auth/health reports the
running version and, for release images, the git revision. Maintainers:
docs/RELEASING.md.
Deploy without cloning the repository. This stack serves plain HTTP and binds
the app to 127.0.0.1:5001 only, so it is not a complete production
deployment by itself: put a TLS reverse proxy in front of it (see Caddyfile
and docker-compose.prod.yml for the hardened setup) before exposing it:
-
Create a directory with your config files:
mkdir lab-playground && cd lab-playground
-
Create your
.envfile with required settings (see.env.examplein the repository) -
Copy
templates.example.yamltotemplates.yamland configure your labs -
Download the compose file:
curl -o docker-compose.yml https://raw.githubusercontent.com/Ragnar-Security/Ragnar-Lab-Playground/main/docker-compose.hub.yml
-
Deploy:
docker compose up -d
The image is automatically built and pushed on every merge to main.
For production, use the hardened compose file:
./setup-production.sh # interactive setup wizard
docker compose -f docker-compose.prod.yml up -d --build
curl http://localhost:5000/auth/healthReal hostnames, IP addresses, usernames, credentials, node names, storage pools, and VMIDs belong only in operator-owned untracked configuration. Start from the committed examples; never put a deployment's values in a tracked file.
| File | Create from / purpose |
|---|---|
.env |
Copy .env.example; application, database, Proxmox, proxy, and image settings |
templates.yaml |
Copy templates.example.yaml; deployment groups, node/storage mappings, and template VMIDs |
scripts/site.env |
Copy scripts/site.env.example; workstation operations and remote-host settings |
config/headscale-acl.json |
Copy config/headscale-acl.example.json; deployment-specific Headscale users and hosts |
data/students_merged.csv |
Generated roster with student records and live VPN credentials; never commit it |
When upgrading a deployment from a revision that tracked templates.yaml,
copy every operator file (templates.yaml, data/students_merged.csv, and
config/headscale-acl.json) into a 0700 directory outside the checkout
before pulling the cutover, then restore them. A pull that removes a tracked
file is not a configuration backup. The container runs as uid 1000, so
templates.yaml must be readable (chmod 644). Add the new .env keys
before pulling a compose file with new ${VAR:?} requirements, run
python3 scripts/preflight_env.py .env, and keep the checkout directory name
(or set COMPOSE_PROJECT_NAME) so Compose reuses the database volume. The
full procedure is "Migrating an existing checkout to this repository" in
docs/PRODUCTION_DEPLOYMENT.md; it requires the Docker Compose v2 plugin.
These values normally live in .env:
| Variable | Purpose / default |
|---|---|
TEMPLATES_FILE |
Deployment-group YAML path; defaults to templates.yaml relative to the app root |
SITE_NAME |
Displayed site title; defaults to Lab Playground |
SITE_BRAND_TAG |
Small label above the title; defaults to empty |
SITE_ORG |
Footer organization; defaults to empty |
VPN_LOGIN_SERVER |
Optional Tailscale/Headscale login-server URL, including its scheme (https://vpn.example.edu), shown in student instructions; when unset, the UI omits the --login-server flag |
SITE_DOMAIN |
Public hostname consumed by Caddy |
ACME_EMAIL |
ACME contact address consumed by Caddy |
ADMIN_PASSWORD |
Bootstrap admin password; production refuses one that is missing, a known default, or shorter than 16 characters when the account is about to be seeded (see SEED_ADMIN_ACCOUNT) |
SEED_ADMIN_ACCOUNT |
Whether init_db creates the generic ADMIN_LOGIN_ID account when it does not exist; defaults to true. Set false when staff use personal accounts listed in ADMIN_USERS; ADMIN_PASSWORD is then not required, in production or by scripts/preflight_env.py. It never deletes or changes an account that already exists. To retire an existing generic account, sign in as another admin and PUT /admin/students/<id> with {"disabled": true} (the same toggle as the Admin Dashboard's student list). Anything other than a recognised boolean fails startup |
PROXMOX_HOST |
Proxmox API hostname or IP |
PROXMOX_USER / PROXMOX_PASSWORD |
Password authentication (development) |
PROXMOX_TOKEN_ID / PROXMOX_TOKEN_SECRET |
API token authentication (production) |
PROXMOX_VERIFY_SSL |
Verify the Proxmox API TLS certificate; defaults to true. Set false only for a self-signed Proxmox reached over a private tunnel (startup logs a warning); prefer PROXMOX_CA_BUNDLE |
PROXMOX_CA_BUNDLE |
Path to a PEM CA certificate, inside the container, used to verify Proxmox; startup fails if the file is missing |
SECRET_KEY |
Flask session signing key. Production refuses to start when it is missing, empty, a value published in the repo's examples or docs, or shorter than 32 characters |
SELF_SERVICE_PASSWORD_RESET |
Student self-service password reset (roster SIS ID check, rate limited); defaults to false, which returns 404 and tells students to ask an administrator for a reset link. true lets anyone who knows a student's login and SIS ID reset that password; enable only if that risk is acceptable. Admin-issued reset links (see "Password recovery") work either way |
PASSWORD_RESET_TOKEN_TTL_MINUTES |
Lifetime of an admin-issued password reset link, in minutes; defaults to 60. Values below 1 fall back to the default |
TRUSTED_PROXY_HOPS |
Number of reverse proxies whose X-Forwarded-For is trusted; 1 behind the bundled Caddy (set by docker-compose.prod.yml), 0 (default) when exposed directly |
SESSION_COOKIE_SECURE |
Secure flag on the session cookie; defaults to on in production, off otherwise. docker-compose.prod.yml deliberately does not pass it (forced on in production behind Caddy) |
SESSION_COOKIE_HTTPONLY |
HttpOnly flag; defaults to true. Not passed by docker-compose.prod.yml; forced on in production |
SESSION_COOKIE_SAMESITE |
Lax (default), Strict or None; an invalid value fails at startup |
PERMANENT_SESSION_LIFETIME |
Session lifetime in seconds; defaults to 7200 |
DATABASE_URL |
SQLAlchemy connection string; production refuses an empty or published database password (also checked for MYSQL_PASSWORD) |
ADMIN_LOGIN_ID |
Bootstrap admin login ID |
ADMIN_USERS |
Comma-separated admin login IDs |
ENABLE_LOAD_BALANCING |
Global load-balancing toggle |
FIXED_NODE |
Default node when load balancing is off |
CPU_THRESHOLD / MEMORY_THRESHOLD |
Global resource thresholds |
DISTRIBUTED_VM_LOCKS |
Serialize each VMID's stop/delete/retire across gunicorn workers with MySQL GET_LOCK; defaults to false, which uses an in-process lock only, enough for the single production worker. Set true before running more than one worker; each lock holder then uses a second database connection |
SMTP_* / ALERT_WEBHOOK_* |
Alert notification settings |
DOCKERHUB_USERNAME |
Docker Hub namespace |
IMAGE_NAME |
Image repository name; defaults to lab-playground |
CI reads vars.DOCKERHUB_USERNAME and vars.IMAGE_NAME, plus
secrets.DOCKERHUB_TOKEN. Publishing remains disabled unless
vars.PUBLISH_DOCKERHUB == 'true'.
The file is optional; operations scripts source it when present, while exported environment variables take precedence. Scripts fail fast and name any required value that is missing.
| Variable | Purpose |
|---|---|
PORTAL_HOST |
SSH destination for the portal host |
PORTAL_DIR |
Portal checkout directory |
PORTAL_COMPOSE |
Portal Compose filename |
PORTAL_APP_CONTAINER |
Portal application service/container |
HEADSCALE_HOST |
SSH destination for the Headscale host |
HEADSCALE_DIR |
Headscale deployment directory |
HEADSCALE_KEEP_USER |
Infrastructure user preserved at term changeover and never rotated |
HEADSCALE_STAFF |
Comma-separated staff users preserved at term changeover and never rotated |
EMAIL_DOMAIN |
Student email domain used while building the roster |
REMOTE_BACKUP_DIR |
Remote directory for changeover backups |
KEY_DAYS |
Lifetime of new student pre-auth keys (for example 150d), used by changeover and key rotation |
HEADSCALE_URL |
Login-server URL the jumpbox joins |
JUMPBOX_NODE |
Proxmox node on which to create the jumpbox |
JUMPBOX_STORAGE |
Proxmox storage for the jumpbox |
JUMPBOX_VMID |
VMID of the jumpbox; outside the portal's VMID range |
JUMPBOX_SSH_PUBKEY |
Public-key file on the node, installed by cloud-init for JUMPBOX_ADMIN_USER (default ubuntu) |
JUMPBOX_SSH_STAFF_IPS |
Comma-separated staff tailnet IPv4/IPv6 addresses allowed to SSH to the jumpbox with jumpbox_configure.sh --ssh-staff-only (management interface and loopback are always allowed; see docs/JUMPBOX.md) |
JUMPBOX_* (others) |
Optional VM shape, NICs, VLAN bands, DHCP, Tailscale and monitoring settings, with defaults, listed in scripts/site.env.example and docs/JUMPBOX.md |
JUMPBOX_CREDENTIALS_FILE |
Output file (mode 0600, gitignored) for random per-user passwords generated by jumpbox_accounts.sh create; defaults to ./jumpbox-credentials.csv. Users must change the password at first login |
All VM timing lives here (not in .env). Each deployment group defines:
deployment_groups:
- display_name: "Homework 2 - Active Recon"
description: "Active reconnaissance lab."
enabled: true
uptime_minutes: 120 # initial VM lifetime
extend_time_minutes: 60 # added on each extend
load_balancing:
enabled: true
cpu_threshold: 80.0
memory_threshold: 85.0
templates:
- name: "hw2-active-recon"
type: "qemu"
entry_point: true # gets entry VLAN NIC; IP shown to student
memory: 2048
cores: 1
template_locations:
node1: 116 # VMID on node1
node2: 117 # VMID on node2The startup validator cross-checks templates.yaml against Proxmox to catch missing templates or misconfigured VMIDs before students deploy anything.
python scripts/import_students.py data/students.csvThe CSV must include SIS Login ID and Student (full name) columns. The import script pre-assigns entry VLAN tags (100-200) and internal VLAN tags (200-300), plus optional Headscale auth keys.
- Admin imports student CSV into the whitelist table.
- Student navigates to
/registerand enters their Canvas login ID, password, and optional email. - The system verifies the login ID exists in the whitelist before allowing registration.
- On first login the student sees all enabled, unrestricted labs.
Admins can disable a student account via PUT /admin/students/<id> with {"disabled": true}. Disabled students cannot log in or manage VMs until re-enabled.
At the end of a semester, or after a suspected leak, replace every student's reusable Headscale pre-auth key without changing the roster:
scripts/rotate_headscale_keys.sh # dry run: prints the plan, changes nothing
scripts/rotate_headscale_keys.sh --apply --only <login> # one account first
scripts/rotate_headscale_keys.sh --apply --yes # everyone except HEADSCALE_STAFF/KEEP_USERIt creates the new keys, replaces data/students_merged.csv locally and on the
portal (mode 0640, group 1000: the app container's gid), updates both key
columns in the database, and only then expires the old keys, so a failure
part-way leaves every student with a working key. Registered devices stay
connected. --apply holds a lock on both hosts for the whole run, so a second
rotation refuses to start. A roster login missing from the portal database
keeps its old key and makes the run exit non-zero unless --allow-partial is
given. The portal container must include scripts/update_headscale_keys.py.
See docs/TERM_CHANGEOVER.md.
The recommended path is an admin-issued reset link; it needs no mail server. In the admin dashboard's Students tab, the key button on a student's row creates a link and shows it once. Hand it to the student out of band (in person, the course messaging system). The link:
- works once, and expires after
PASSWORD_RESET_TOKEN_TTL_MINUTES(default 60); - stops working when a newer link is created for the same student;
- signs the student out everywhere when used, like logout;
- carries its token in the URL fragment (
/reset-password#token=...), which browsers never send to the server, so it stays out of gunicorn and Caddy access logs. The database stores only its SHA-256.
Failed link attempts count against the same per-address limit as
self-service resets. Run scripts/migrate_add_password_reset_tokens.py once
on an existing database before using the feature.
The older self-service reset (login ID plus roster SIS ID) stays available
behind SELF_SERVICE_PASSWORD_RESET; it is weaker because a SIS ID is not a
secret.
By default every enabled deployment group is visible to all students. When an admin assigns even one student to a group, that group becomes restricted -- only assigned students can see and deploy it. Unassigned groups remain visible to everyone.
This means admins can progressively lock down labs as the semester progresses:
- No assignments for a group -- all students see it.
- At least one assignment exists -- only assigned students see it.
- Bulk assign -- use
POST /admin/assignments/bulkwith lists of user IDs and group names. - Clear restrictions -- use
POST /admin/assignments/clearto remove all assignments for a group, making it visible to all again.
The filtering happens in GET /templates at query time; the database stores only (user_id, deployment_group_name) pairs.
The admin dashboard (visible when logged in as an admin) provides:
- Student Management -- list all users, view active VM counts, toggle admin role, disable/enable accounts, create one-time password reset links
- VM Overview -- list all VMs across all students with filters by student, group, node, and status
- Bulk Destroy -- select multiple VMs and destroy them in one operation
- Bulk Extend -- select multiple VMs and add time to their expiration
- Lab Assignments -- assign or unassign students to deployment groups, bulk assign, clear all assignments for a group
- Alert Testing -- send test alerts through email, webhook, database, and file channels to verify configuration
Each Proxmox node must have a local copy of every template it will clone from. The sync_template.sh script automates replication:
./scripts/sync_template.sh <source_node> <source_vmid> <target_node> [target_storage]
# Example: copy template 116 from node1 to node2
./scripts/sync_template.sh node1 116 node2 local-zfsThe script performs a vzdump on the source node, transfers the backup via SCP, restores it on the target with the next available VMID, and converts it to a template. Update templates.yaml with the new VMID after syncing.
Prerequisites: SSH key-based access to both nodes, vzdump/qmrestore installed, sufficient disk space in /var/lib/vz/dump.
python -m pytest tests/ -v --ignore=tests/integrationUnit tests cover models, config validation, template loading, auth routes, API routes, admin dashboard, lab assignments, background tasks, and parallel VM creation. They use an in-memory SQLite database and mock the Proxmox API.
# Start the test environment
docker compose -f docker-compose.test.yml up -d --build
# Wait for health check
curl -s http://localhost:5099/auth/health
# Run Playwright browser tests
python -m pytest tests/integration/test_flows.py --base-url http://localhost:5099 -v
# Tear down
docker compose -f docker-compose.test.yml downIntegration tests use Playwright to exercise real browser flows (login, registration, VM creation, admin operations) against a containerized instance with a SQLite database and mocked Proxmox host.
GitHub Actions (.github/workflows/ci.yml) runs on every push to main or features/* and on pull requests to main:
- Unit Tests job -- installs dependencies, runs the pure-Python test subset.
- Integration Tests job -- builds the Docker test environment, installs Playwright + Chromium, runs browser tests, then tears down.
- Docker Push job -- (main branch only) builds the production image and pushes to Docker Hub with
latestand SHA tags. RequiresDOCKERHUB_USERNAMEandDOCKERHUB_TOKENrepository secrets. - Release (
.github/workflows/release.yml) -- on a pushedvX.Y.Z(orvX.Y.Z-rc.N) tag: checks the tag against the version and the CHANGELOG, reruns this CI on the tag, and, after a maintainer approves thereleaseenvironment, publishes and attests the GHCR image and the deploy bundle and creates the GitHub Release. A manual run is a dry run that publishes nothing. See docs/RELEASING.md.
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/register |
Register a new student account (whitelist required) |
| POST | /auth/login |
Log in with credentials |
| POST | /auth/logout |
Log out current session |
| POST | /auth/reset-password |
Self-service reset with login ID and SIS ID (only with SELF_SERVICE_PASSWORD_RESET=true) |
| POST | /auth/reset-password/link |
Set a new password with an admin-issued link ({"token", "password"}) |
| GET | /auth/me |
Get current user info |
| GET | /auth/check |
Check authentication status |
| GET | /auth/health |
Health check for monitoring |
| Method | Endpoint | Description |
|---|---|---|
| GET | / |
Main dashboard (redirects to login if unauthenticated) |
| GET | /register |
Registration page |
| GET | /reset-password |
Password reset page |
| Method | Endpoint | Description |
|---|---|---|
| GET | /templates |
List enabled deployment groups (filtered by assignment restrictions) |
| POST | /create |
Create VMs for a deployment group |
| GET | /list |
List VMs grouped by student and deployment group |
| POST | /extend |
Extend one deployment's lifetime (deployment_scope=individual|group and group_id pick between same-named deployments; an ambiguous request is refused) |
| POST | /destroy |
Destroy one deployment (same deployment_scope / group_id selection as /extend) |
| GET | /time-remaining |
Check VM expiration time |
| GET | /api/vms |
List VMs (flat, filterable by active status) |
| GET | /api/vms/<vmid> |
Get detailed info for a single VM |
| GET | /nodes/resources |
Cluster node resource usage (admin only). Memory fields: memory_available_gb (Linux MemAvailable; total - used if the node omits it), memory_linux_free_gb (Linux MemFree), and memory_free_gb, a deprecated alias that equals memory_available_gb, not MemFree. See docs/LOAD_BALANCING.md |
| Method | Endpoint | Description |
|---|---|---|
| GET | /admin/students |
List students (id, login_id, name, admin flag) |
| GET | /admin/students/full |
List students with VM counts and full details |
| PUT | /admin/students/<id> |
Update student (toggle admin, disable/enable) |
| POST | /admin/students/<id>/reset-link |
Create a one-time password reset link (JSON body required; returned once) |
| GET | /admin/assignments |
List lab assignments (filter by user_id or group) |
| POST | /admin/assignments |
Assign a student to a deployment group |
| DELETE | /admin/assignments/<id> |
Remove a specific assignment |
| POST | /admin/assignments/bulk |
Bulk assign students to groups |
| POST | /admin/assignments/clear |
Clear all assignments for a group |
| GET | /admin/vms |
List all VMs with optional filters |
| POST | /admin/bulk-destroy |
Destroy multiple VMs by record ID |
| POST | /admin/bulk-extend |
Extend expiration for multiple VMs |
| POST | /admin/test-alerts |
Send test alert notifications |
Last Updated: October 2026
Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.