90 lines
5.1 KiB
Markdown
90 lines
5.1 KiB
Markdown
---
|
||
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.<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:
|
||
```bash
|
||
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:
|
||
```bash
|
||
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`.
|