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

90 lines
5.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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`.