5.1 KiB
name, description, version, platforms
| name | description | version | platforms | |
|---|---|---|---|---|
| jit-ssh-access-portal | Web login + Telegram 2FA + time-boxed UFW SSH opening. | 1.0.0 |
|
JIT SSH Access Portal (Telegram 2FA + Time-boxed Firewall)
Opens a web endpoint (ssh.<domain>.de) with password login, then a 6-digit Telegram 2FA
code. On success, the backend opens Port 22 in UFW only for the requesting IP for a bounded
time (default 1h) and auto-closes it. SSH stays closed-for-the-world otherwise.
Why (security model)
- SSH is closed by default — no "22 Anywhere" rule.
- 2FA over Telegram is out-of-band (a channel separate from the web login).
- Firewall opening is time-boxed (
expiry+ background auto-close) → no permanent hole. - Passwords stored hashed (werkzeug
generate_password_hash); first-use password forces change.
Components
nginx vhost ssh.<domain>.de (SSL) → reverse proxy → Flask app on 127.0.0.1:8091
Flask app:
- SQLite users table (username, pw_hash, pw_changed, failed, lockout_until)
- POST / login (Erstpasswort → redirect zum PW-Wechsel; danach → /2fa)
- POST /change-pw one-time password change (min length, confirm)
- POST /2fa verify 6-digit code, `ufw allow from <ip> to any port 22` (1h)
- background thread `_close_expired()` → `ufw delete allow ...` after expiry
- `/srv` session carries `2fa_code`, `2fa_ts`, `2fa_attempts`
Full reference implementation: references/app.py (drop-in /opt/sshportal/app.py).
Deploy as a systemd service (survives reboot, overrides the VM-side "can't background" limit).
Security anchors (non-negotiable)
- First password is one-time: set
pw_changed=0on first user; force change before 2FA. - 2FA TTL + attempt cap: code valid ~120s, max 3 attempts, then lock (log + Telegram alert).
- Client IP source: read
X-Real-IP/X-Forwarded-For(set by nginxproxy_set_header), NOTremote_addrdirectly (would be the proxy). - Brute-force: track
failed; lockout window after N failures. ACHTUNG (2026-08-04):users-Tabelle hatfailed/lockout_until-Spalten, und dielogin()-Route muss sie WIRKLICH nutzen — eine frühere Version hatte sie angelegt, aber nie abgefragt (Login prüfte nur Passwort). Zusätzlich IP-basiertes In-Memory-Lockout (_IP_FAIL), da Angriffe mit leerem/fremden Benutzernamen am User-Lockout vorbeigehen. Nach Nutzung vonlogin(): echten User auffailed=0, lockout_until=0zurücksetzen, da Tests den Zähler hochziehen. - Rate-limit / TLS only: only serve behind https (certbot
--redirect). - Logging: every login / pw-change / 2FA / open / close → local log + mesh event log.
Pitfalls
render_template_stringescapes HTML by default → wrap dynamic HTML content inMarkup(...)or pass plain text messages separately, or the form renders as escaped text (no<h1>).- Launching the Flask app with
nohup ... &via the agent shell is blocked ("can't background inside gateway") → use a systemd unit, not a shell background process. ufwinteractive confirm: when deleting by numberufw delete Nprompts → pipeyes |or delete by full rule string.- UFW rule dedupe:
ufw_allowshould skip ifufw statusalready showsfrom <ip>. - After the requesting IP is external (e.g. the browser came over the VPS public IP), the opened rule targets that external IP — if the real SSH client is on ZeroTier, the browser IP won't match. Decide whether the portal serves ZT-internal clients (requesting IP = ZT IP) or external.
- End-to-end test mutates the DB. Running the full login→pw-change flow during testing
overwrites the stored hash and sets
pw_changed=1. If you don't reset afterward, the user's real first login FAILS with "falsches Passwort". After any E2E test, reset to first-use state:(or re-run the app'ssqlite3 /opt/sshportal/portal.db \ "UPDATE users SET pw_hash='<bcrypt of first-pw>', pw_changed=0 WHERE username='mehlzoerwer';"ensure_first_userlogic). Essentially: a "password changed" side effect from testing must be rolled back before declaring the portal ready.
Firewall pre-condition
Before enabling a JIT portal, tighten SSH so it is NOT "Anywhere". Restrict to the trusted ZeroTier mesh IPs, and close leftover WG/NFS ports:
ufw delete allow 22/tcp
for ip in <trusted-ips>; do ufw allow from $ip to any port 22 proto tcp; done
systemctl stop rpcbind rpcbind.socket && systemctl disable rpcbind rpcbind.socket
Public reachable surface should be only 80 + 443.
Verification
curl https://ssh.<domain>.de/→ 200 login page.- First login (first PW) →
302 /change-pw; set new PW → "Passwort geändert" log. - Second login →
302 /2fa; backend logs "2FA-Code gesendet" and Telegram delivers a 6-digit code. - Submit correct code →
ufw statusshows22/tcp ALLOW <ip> # SSH JIT Portal; wrong code ×3 → lock. - After expiry → rule removed (test
ufw_allow/ufw_denyon a throwaway IP end-to-end).
Full safe E2E test recipe for the login/lockout logic (throwaway user, IP-reset-before-DB-branch,
cleanup): references/safe-e2e-login-testing.md.