How to configure GUI login (NetID allowlist + one shared team password) and hub device tokens on the production cloud service, how to verify it, and how to rotate or revoke access later.
Written for humans and agents. Values in <ANGLE_BRACKETS> are placeholders you must replace.
Every step ends with an Expected result; stop and troubleshoot if it does not match.
- Never paste the team password, the JWT secret, or device tokens into chat, commits, PR text, issues, or logs. The password hash is less sensitive but still keep it private.
- Secrets live only in the backend env file on the server (and each Pi's
.envfor its own token). Both are gitignored; nevergit addthem. - Configure the server before the new cloud-services code is deployed. With
ENVIRONMENT=productionthe service refuses to start if any value below is missing or still a development default, and deploys happen automatically when the hyperloop-gui PR merges. - The old per-user passwords (
users.json) and the old credential generator are retired. The Gmail app password that was committed with the generator must be revoked in that Google account (Google Account > Security > App passwords) if that has not been done.
- A person signs in with their NetID and the team password. The NetID must be on the allowlist. The JWT carries the NetID, so actions stay attributable to a person.
- "View Only Mode" needs no credentials and cannot send any command (write, restart, close, flash).
- Failed logins are limited per NetID (5 per 5 minutes) and per client IP (20 per 5 minutes).
- Removing a NetID from the allowlist ends that person's existing sessions on their next request.
- Development (no production settings): NetID
dev, passwordhyperloop-dev; hubs connect withdev-token-rpi-bridge-01/dev-token-rpi-bridge-02.
On the EC2 host (SSH in as the deploy user):
systemctl cat backendLook at WorkingDirectory= and for ENV_FILE= in Environment=/ExecStart=, or an
EnvironmentFile=. cloud-services reads the file named by ENV_FILE, relative to the working
directory, and falls back to .env.
Expected result: you know the path of the env file, referred to below as <ENV_FILE>
(typically /home/ubuntu/cloud-services/.env.production). If you cannot find one, create
/home/ubuntu/cloud-services/.env.production and make sure the unit sets
Environment=ENV_FILE=.env.production.
Pick a long passphrase (for example four or more random words; at most 72 bytes). Hash it on the server with the backend's virtualenv. The password is typed at a hidden prompt, so it never appears in shell history:
cd /home/ubuntu/cloud-services
venv/bin/python -c "import bcrypt, getpass; p = getpass.getpass('Team password: ').encode(); print(bcrypt.hashpw(p, bcrypt.gensalt(12)).decode())"Expected result: one line starting with $2b$12$. This is <TEAM_PASSWORD_HASH>. Give the
passphrase itself to the team in person or through a private channel.
venv/bin/python -c "import secrets; print(secrets.token_urlsafe(48))"Expected result: a random string of about 64 characters, <JWT_SECRET>.
nano /home/ubuntu/allowed_netids.txt
chmod 600 /home/ubuntu/allowed_netids.txtOne NetID per line, lowercase, without @cornell.edu. # starts a comment:
# Electrical subteam
abc123
xyz789 # team lead
Expected result: the file lists every member who should be able to send commands.
viewer, admin and root are ignored if listed. (Alternative: put a comma-separated list in
ALLOWED_NETIDS= instead of using a file.)
Decide the hub IDs first. They must match HUB_ID in each Pi's .env
(see .claude/rpi-hub-setup.md). Example IDs: rpi-lab-01, rpi-lab-02, rpi-lab-03,
rpi-cellular-01. Existing Pis can keep their current IDs (rpi-bridge-01, rpi-bridge-02).
Run once per hub:
venv/bin/python -c "import secrets; print(secrets.token_urlsafe(32))"Build the value as comma-separated hub-id:token pairs:
DEVICE_TOKENS=rpi-lab-01:<TOKEN_1>,rpi-lab-02:<TOKEN_2>,rpi-lab-03:<TOKEN_3>,rpi-cellular-01:<TOKEN_4>
Expected result: every hub has its own token. Record which token belongs to which Pi somewhere
private; each Pi needs its token as DEVICE_TOKEN. Every hub listed here appears on the GUI
dashboard (offline until it connects).
Edit <ENV_FILE> and set (keep any other existing lines such as CORS_ORIGINS):
ENVIRONMENT=production
JWT_SECRET_KEY=<JWT_SECRET>
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=480
# Single quotes keep the $ characters of the hash literal
TEAM_PASSWORD_HASH='<TEAM_PASSWORD_HASH>'
ALLOWED_NETIDS_FILE=/home/ubuntu/allowed_netids.txt
DEVICE_TOKENS=<VALUE_FROM_STEP_5>
# Only if port 8080 is not reachable from the internet (all traffic comes through Caddy/Cloudflare):
TRUST_PROXY_HEADERS=trueThen:
chmod 600 <ENV_FILE>Remove USERS_FILE_PATH if present (no longer used). Old DEVICE_TOKEN_RPI_BRIDGE_01/02 lines
still work, but move those hubs into DEVICE_TOKENS and delete the old lines.
Expected result: the file contains every key above with real values; no dev-token
values remain.
sudo systemctl restart backend
sleep 3
systemctl is-active backend
journalctl -u backend -n 50 --no-pagerExpected result: active, and no Invalid production configuration in the log. If that
error appears, it lists exactly which setting is missing or invalid.
Set the API base (use http://127.0.0.1:8080 when testing on the server itself):
API=https://api.cornellhyperloop.com-
Login works (enter your NetID and the team password when prompted):
read -p "NetID: " NETID; read -s -p "Team password: " PW; echo curl -s -o /dev/null -w "%{http_code}\n" -X POST "$API/auth/login" \ -H 'content-type: application/json' -d "{\"username\":\"$NETID\",\"password\":\"$PW\"}"
Expected result:
200 -
A wrong password is rejected:
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$API/auth/login" \ -H 'content-type: application/json' -d "{\"username\":\"$NETID\",\"password\":\"wrong\"}"
Expected result:
401. After 5 failures in 5 minutes the same NetID gets429, even with the right password, until the window passes. Test that with a throwaway NetID rather than your own. -
View-only sessions cannot send commands:
VIEWER=$(curl -s -X POST "$API/auth/login-viewer" | python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])") curl -s -o /dev/null -w "%{http_code}\n" -X POST "$API/api/hubs/<HUB_ID>/commands/restart" \ -H "Authorization: Bearer $VIEWER" -H 'content-type: application/json' -d '{"portId":"x"}'
Expected result:
403 -
Configured hubs are listed:
curl -s "$API/api/hubs" -H "Authorization: Bearer $VIEWER"
Expected result: one entry per hub in
DEVICE_TOKENS,"connected": falseuntil that Pi is set up. -
In the GUI, sign in with your NetID and the team password.
Clean up the shell: unset PW NETID VIEWER.
Once login works, delete the old users file if it exists: rm -f /home/ubuntu/users.json.
| Task | Steps | Takes effect |
|---|---|---|
| Add or remove a member | Edit allowed_netids.txt |
Next request (no restart); removed members are logged out |
| Rotate the team password | Step 2, replace TEAM_PASSWORD_HASH, restart backend |
New logins; existing sessions last until they expire (8 h) |
| Log everyone out now | Replace JWT_SECRET_KEY (step 3), restart backend |
Immediately |
| Add a hub | Generate a token (step 5), append to DEVICE_TOKENS, restart backend, put the token in the Pi's .env |
After restart |
| Revoke a hub (lost/stolen Pi) | Remove its pair from DEVICE_TOKENS, restart backend |
Immediately; the hub is rejected (ws_rejected in its log) |
| Symptom | Cause / fix |
|---|---|
backend fails to start with Invalid production configuration |
The message names the missing or invalid setting; fix it in <ENV_FILE> |
Everyone gets 401 with the right password |
Hash copied incorrectly (must start with $2b$ and be in single quotes), or the service reads a different env file (step 1) |
One person gets 401 |
Their NetID is not in the allowlist, or typo in the file |
Everyone gets 429 |
Without TRUST_PROXY_HEADERS=true behind a proxy, all users share the proxy's IP (20 failures per 5 minutes locks everyone out). Set it only if port 8080 is not publicly reachable |
| A hub never shows online | Its HUB_ID/DEVICE_TOKEN pair does not match DEVICE_TOKENS; see .claude/rpi-hub-setup.md |