Files
mesh-configs/skills/devops/jit-ssh-access-portal/SKILL.md
T

5.1 KiB
Raw Blame History

name, description, version, platforms
name description version platforms
jit-ssh-access-portal Web login + Telegram 2FA + time-boxed UFW SSH opening. 1.0.0
linux

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)

  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 <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.
  • 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 <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:
    sqlite3 /opt/sshportal/portal.db \
      "UPDATE users SET pw_hash='<bcrypt of first-pw>', 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:

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 status shows 22/tcp ALLOW <ip> # 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.