Initial: Mesh-Skills, Configs, Scripts

This commit is contained in:
root
2026-09-02 23:49:07 +02:00
commit 26c7980750
783 changed files with 145393 additions and 0 deletions
@@ -0,0 +1,89 @@
---
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`.