--- name: jit-ssh-access-portal description: "Web login + Telegram 2FA + time-boxed UFW SSH opening." version: 1.0.0 platforms: [linux] --- # JIT SSH Access Portal (Telegram 2FA + Time-boxed Firewall) Opens a web endpoint (`ssh..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..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 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) 1. **First password is one-time**: set `pw_changed=0` on first user; force change before 2FA. 2. **2FA TTL + attempt cap**: code valid ~120s, max 3 attempts, then lock (log + Telegram alert). 3. **Client IP source**: read `X-Real-IP`/`X-Forwarded-For` (set by nginx `proxy_set_header`), NOT `remote_addr` directly (would be the proxy). 4. **Brute-force**: track `failed`; lockout window after N failures. **ACHTUNG (2026-08-04):** `users`-Tabelle hat `failed`/`lockout_until`-Spalten, und die `login()`-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 von `login()`: echten User auf `failed=0, lockout_until=0` zurücksetzen, da Tests den Zähler hochziehen. 5. **Rate-limit / TLS only**: only serve behind https (certbot `--redirect`). 6. **Logging**: every login / pw-change / 2FA / open / close → local log + mesh event log. ## Pitfalls - `render_template_string` escapes HTML by default → wrap dynamic HTML content in `Markup(...)` or pass plain text messages separately, or the form renders as escaped text (no `

`). - 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. - `ufw` interactive confirm: when deleting by number `ufw delete N` prompts → pipe `yes |` or delete by full rule string. - UFW rule dedupe: `ufw_allow` should skip if `ufw status` already shows `from `. - 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: ```bash sqlite3 /opt/sshportal/portal.db \ "UPDATE users SET pw_hash='', pw_changed=0 WHERE username='mehlzoerwer';" ``` (or re-run the app's `ensure_first_user` logic). 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: ```bash ufw delete allow 22/tcp for ip in ; 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..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 status` shows `22/tcp ALLOW # SSH JIT Portal`; wrong code ×3 → lock. - After expiry → rule removed (test `ufw_allow`/`ufw_deny` on 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`.