second commit
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
static/js/vendor/*.js
|
||||||
|
static/js/vendor/*.css
|
||||||
|
node_modules/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
ansible/inventory/production.ini
|
||||||
|
ansible/inventory/group_vars/vault.yml
|
||||||
|
ansible/inventory/group_vars/jumphost_with_nginx.yml
|
||||||
|
ansible/inventory/group_vars/jumphost_no_nginx.yml
|
||||||
164
Pentest_Report.md
Normal file
164
Pentest_Report.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# Sicherheitstest-Bericht: Jumphost Gateway
|
||||||
|
|
||||||
|
**Datum:** 19.08.2026 | **Version des Testobjekts:** Implementierung v0.2 | **Tester:** Claude (im Auftrag von Midas)
|
||||||
|
|
||||||
|
## 1. Scope und Methodik
|
||||||
|
|
||||||
|
### 1.1 Was tatsächlich getestet wurde
|
||||||
|
|
||||||
|
Diese Sitzung lief in einer isolierten Cloud-Sandbox ohne Zugriff auf ein
|
||||||
|
reales Netzwerk, echte SSH-/RDP-Zielsysteme oder eine laufende `guacd`-
|
||||||
|
Instanz. Der Test ist daher ein **Anwendungssicherheitstest (SAST + gezielter
|
||||||
|
DAST) gegen den Quellcode und die lokal laufende Anwendung**, kein
|
||||||
|
vollständiger Infrastruktur-/Netzwerk-Penetrationstest. Konkret durchgeführt:
|
||||||
|
|
||||||
|
1. **Statische Analyse (SAST):** `bandit` gegen den gesamten Python-Code
|
||||||
|
(`app/`, `scripts/`), `pip-audit` gegen alle Produktiv- und
|
||||||
|
Dev-Abhängigkeiten.
|
||||||
|
2. **Dynamischer Sicherheitstest (DAST, White-/Grey-Box):** 17 gezielte
|
||||||
|
Angriffstests (`tests/test_pentest_security.py`) gegen die echte
|
||||||
|
FastAPI-Anwendung inkl. SQLite-Datenbank, ausgeführt über den
|
||||||
|
ASGI-Transport (kein echtes TCP/TLS, aber derselbe Anwendungscode wie in
|
||||||
|
Produktion) sowie ergänzend manuelle Verifikation per `curl` gegen einen
|
||||||
|
laufenden `uvicorn`-Prozess.
|
||||||
|
3. **Manuelle Code-Review-Punkte:** Session-/Cookie-Handling, RBAC-
|
||||||
|
Durchsetzung, Key-Handling, Audit-Log-Integrität — im Rahmen der unter 2)
|
||||||
|
genannten Testfälle verifiziert, nicht nur gelesen.
|
||||||
|
|
||||||
|
### 1.2 Was NICHT getestet wurde (out of scope in dieser Umgebung)
|
||||||
|
|
||||||
|
| Bereich | Warum nicht möglich |
|
||||||
|
|---|---|
|
||||||
|
| Netzwerk-/Infrastruktur-Pentest (Portscans, TLS-Konfiguration des realen nginx/nftables-Setups, Firewall-Umgehung) | Kein reales Zielsystem/Netzwerk in dieser Sandbox verfügbar |
|
||||||
|
| RDP/`guacd`-Pfad end-to-end (echtes Windows-Ziel, echtes `guacd`+FreeRDP) | Keine RDP-Zielumgebung verfügbar; Protokoll-Handshake wurde nur gegen die Spezifikation implementiert, nicht live gegenverifiziert |
|
||||||
|
| SSH-Proxy end-to-end gegen ein echtes Linux-Ziel | Kein SSH-Zielsystem verfügbar; SSH-Verbindungslogik ist durch `asyncssh` (etabliert, nicht selbst geschrieben) abgedeckt, die serverseitige Anbindung (Key-Laden, Host-Key-Pinning) wurde isoliert/unit-artig, nicht end-to-end getestet |
|
||||||
|
| Ansible-Rollen-Ausführung gegen ein reales Debian/Ubuntu-System | Kein Root-Zielsystem verfügbar; nur `--syntax-check` + YAML/Jinja-Parsing + Variablen-/Handler-Konsistenzprüfung möglich |
|
||||||
|
| OpenSCAP/CIS-Benchmark-Tool-Lauf | Kein Zielsystem, auf dem `oscap` laufen könnte |
|
||||||
|
| Social Engineering, physische Sicherheit | Nicht anwendbar auf ein Code-Review |
|
||||||
|
| Lasttest / Denial-of-Service-Robustheit | Nicht Teil dieses Auftrags; separat zu betrachten |
|
||||||
|
|
||||||
|
**Konsequenz:** Die hier bestätigte Sicherheit bezieht sich auf die
|
||||||
|
Anwendungslogik selbst. Vor Produktivbetrieb sind ein echter
|
||||||
|
Netzwerk-Pentest gegen die deployte Instanz und ein Live-Test des
|
||||||
|
RDP-Pfads weiterhin erforderlich (siehe Abschnitt 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ergebnisse: Statische Analyse
|
||||||
|
|
||||||
|
### 2.1 bandit (Python-Sicherheitslinter)
|
||||||
|
|
||||||
|
| Vorher | Nachher |
|
||||||
|
|---|---|
|
||||||
|
| 1× Medium (SQL-String-Konstruktion, B608) | 0 |
|
||||||
|
| 6× Low (breite `except`-Blöcke B110, subprocess-Nutzung B404/B603) | 0 (5× behoben durch Logging statt `pass`, 2× als geprüft/gerechtfertigt mit `# nosec` + Begründungskommentar markiert) |
|
||||||
|
|
||||||
|
Alle Findings wurden behoben oder mit expliziter, im Code dokumentierter
|
||||||
|
Begründung als bewusste Entscheidung markiert (kein stilles Wegklicken).
|
||||||
|
Details: `app/auth/routes.py` (SQL-Konstruktion durch zwei feste
|
||||||
|
parametrisierte Statements ersetzt), `app/security/av_scan.py`,
|
||||||
|
`app/rdp_proxy/guacd_client.py`, `app/rdp_proxy/ws_tunnel.py`,
|
||||||
|
`app/ssh_proxy/terminal_ws.py`.
|
||||||
|
|
||||||
|
### 2.2 pip-audit (Abhängigkeits-Schwachstellen)
|
||||||
|
|
||||||
|
**16 bekannte Schwachstellen in 2 Paketen gefunden** (Stand vor Bereinigung):
|
||||||
|
|
||||||
|
| Paket | Version (vorher) | Version (nachher) | Bekannte CVEs/Advisories |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cryptography` | 43.0.3 | **50.0.0** | 6 (u.a. PYSEC-2026-3553/3554, GHSA-537c-gmf6-5ccf) |
|
||||||
|
| `starlette` | 0.46.2 | **1.6.0** | 10 (u.a. PYSEC-2026-1941/1942/2280/2281) |
|
||||||
|
| `fastapi` | 0.115.x | **0.141.1** | (transitiv mit starlette aktualisiert) |
|
||||||
|
| `pytest`, `pytest-asyncio` (Dev-only) | 8.3.5 / 0.24.0 | **9.1.1 / 1.4.0** | PYSEC-2026-1845 |
|
||||||
|
|
||||||
|
Nach dem Upgrade: **0 bekannte Schwachstellen** in einem vollständigen
|
||||||
|
`pip-audit`-Lauf über die gesamte aufgelöste Umgebung. `requirements.txt`
|
||||||
|
und `requirements-dev.txt` sind jetzt exakt (lockfile-artig) auf die
|
||||||
|
geprüften Versionen gepinnt.
|
||||||
|
|
||||||
|
⚠️ **Wichtiger Fund durch die Regressionstests:** Das Upgrade von Starlette
|
||||||
|
0.46 → 1.6 hat die Aufrufkonvention von `Jinja2Templates.TemplateResponse()`
|
||||||
|
geändert (alte Signatur `TemplateResponse(name, {"request": request})` wurde
|
||||||
|
entfernt, neue Signatur ist `TemplateResponse(request, name, context)`). Ohne
|
||||||
|
die Pentest-Testsuite (`test_security_headers_present_on_every_response` u.a.,
|
||||||
|
die alle Seiten inkl. `/` aufrufen) wäre dieser **funktionale Regressionsbug**
|
||||||
|
erst im Betrieb aufgefallen: `GET /`, `/dashboard`, `/terminal/{id}`,
|
||||||
|
`/rdp/{id}` hätten alle mit HTTP 500 geantwortet. Behoben in `app/main.py`;
|
||||||
|
alle vier Routen sind erneut per Test und manuellem `curl`-Aufruf verifiziert
|
||||||
|
(Status 200, korrektes HTML).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ergebnisse: Dynamischer Sicherheitstest (17 Testfälle)
|
||||||
|
|
||||||
|
Alle 17 Testfälle sind **grün** (siehe `tests/test_pentest_security.py`,
|
||||||
|
ausführbar mit `pytest tests/test_pentest_security.py -v`). Zusammenfassung
|
||||||
|
nach Kategorie:
|
||||||
|
|
||||||
|
| Kategorie | Geprüft | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| **Authentifizierung** | Keine Username-Enumeration über Fehlermeldungen; Account-Lockout nach 5 Fehlversuchen (auch mit korrektem Passwort danach gesperrt); IP-basiertes Rate-Limiting greift bei >10 Versuchen/Minute; klassische SQLi-Payloads im Username-Feld führen weder zu 500 noch zu Auth-Bypass, Datenbank bleibt intakt | ✅ Bestanden |
|
||||||
|
| **Session-/Cookie-Sicherheit** | Cookie trägt `HttpOnly`, `Secure`, `SameSite=Strict`; ein mit falschem Secret gefälschtes Cookie wird abgelehnt; Passwortänderung invalidiert alte Sessions (`session_version`-Mechanismus); "Überall abmelden" invalidiert das Cookie sofort | ✅ Bestanden |
|
||||||
|
| **RBAC / IDOR** | Nicht-Admin kommt an keinen Admin-Endpunkt (weder lesend noch schreibend); Nutzer mit Rolle nur auf Hostgruppe A wird bei Host aus Hostgruppe B mit 403 blockiert — unabhängig davon, dass die Host-ID gültig/erratbar ist; eine Rolle für einen Aktionstyp (z.B. `ssh_connect`) gewährt **nicht automatisch** eine andere (z.B. `file_transfer`) auf demselben Host | ✅ Bestanden |
|
||||||
|
| **Dateitransfer-Härtung** | Uploads über dem konfigurierten Limit werden mit 413 abgelehnt; als "infected" markierte Uploads werden blockiert (400) und erzeugen **keinen** Eintrag in `file_transfers` (kein falsches Erfolgssignal) | ✅ Bestanden |
|
||||||
|
| **HTTP-Security-Header** | CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Strict-Transport-Security`, `Referrer-Policy` auf **jeder** Antwort inkl. Fehlerantworten; keine Stacktraces/Dateipfade in Fehlermeldungen | ✅ Bestanden |
|
||||||
|
| **Audit-Log-Vollständigkeit** | Fehlgeschlagene und erfolgreiche Logins sowie TOTP-Enrollment erzeugen Audit-Einträge; Hash-Chain bleibt nach einem vollständigen Testlauf (viele parallele Nutzer, Fehlversuche, Admin-Aktionen) durchgängig intakt | ✅ Bestanden |
|
||||||
|
|
||||||
|
### 3.1 Bewertung nicht-automatisierter Beobachtungen (kein Fix nötig, dokumentiert)
|
||||||
|
|
||||||
|
- **Pfadangabe bei SFTP-Dateitransfer** (`remote_path`-Parameter): erlaubt
|
||||||
|
beliebige Pfade wie `../../etc/passwd`. Das ist **kein Jumphost-seitiges
|
||||||
|
Path-Traversal**, da dieser Pfad ausschließlich an das **Zielsystem** per
|
||||||
|
SFTP übergeben wird (keine lokale Dateisystem-Interaktion auf dem Jumphost
|
||||||
|
selbst) — die Zugriffskontrolle liegt beim Zielsystem-Betriebssystem über
|
||||||
|
den `ssh_username`. Funktional gewollt (Nutzer soll das Zielsystem wie mit
|
||||||
|
einem SFTP-Client durchsuchen können). Empfehlung für sehr sensible
|
||||||
|
Hostgruppen: zusätzliche pfadbasierte Allow-Lists auf Anwendungsebene als
|
||||||
|
optionale Erweiterung (Konzept Kap. 12 könnte hierzu ergänzt werden).
|
||||||
|
- **`hosts.address` ist nur admin-editierbar**, nicht durch normale Nutzer
|
||||||
|
beeinflussbar — ein SSRF-artiger Angriffsvektor über beliebige Zieladressen
|
||||||
|
ist daher kein nutzerseitig ausnutzbares Risiko, sondern liegt in der
|
||||||
|
bestehenden Admin-Vertrauensgrenze (Admins könnten ohnehin beliebige
|
||||||
|
RBAC-Rechte vergeben).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Offene Punkte vor Produktivbetrieb (Restrisiko)
|
||||||
|
|
||||||
|
Diese Punkte waren bereits im ursprünglichen README als offen vermerkt und
|
||||||
|
bleiben es, da sie eine echte Zielumgebung erfordern, die in dieser Sandbox
|
||||||
|
nicht existiert:
|
||||||
|
|
||||||
|
1. **Netzwerk-Pentest gegen die tatsächlich deployte Instanz** (TLS-Konfiguration
|
||||||
|
von nginx/direktem Uvicorn-TLS, Firewall-Regeln, tatsächliches
|
||||||
|
Verhalten von fail2ban) — Black-Box-Test von außen.
|
||||||
|
2. **RDP-Pfad live gegen `guacd` + FreeRDP + Windows-Ziel testen** —
|
||||||
|
insbesondere Parameter-Namen/-Reihenfolge der `connect`-Instruktion gegen
|
||||||
|
die konkret eingesetzte guacd-Version verifizieren (siehe
|
||||||
|
`app/rdp_proxy/guacd_client.py`, Docstring-Hinweis).
|
||||||
|
3. **`ansible-playbook --check` bzw. echter Rollout in einer
|
||||||
|
Staging-Umgebung** — insbesondere die `pam_faillock`-Einbindung und die
|
||||||
|
`/tmp`-/`/dev/shm`-Remount-Logik sind distributionsversionsabhängig und
|
||||||
|
sollten vor der Produktivfreigabe an einem echten System bestätigt werden
|
||||||
|
(siehe `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`, Abschnitt
|
||||||
|
„Bekannte Einschränkungen").
|
||||||
|
4. **OpenSCAP-Lauf** gegen das gewählte CIS/STIG-Profil zur unabhängigen
|
||||||
|
Bestätigung der Hardening-Rolle.
|
||||||
|
5. Alle bereits zuvor im README genannten funktionalen Erweiterungen
|
||||||
|
(LDAP/AD, WebAuthn, verteiltes Rate-Limiting, CSRF-Token als zusätzliche
|
||||||
|
Schicht) sind weiterhin nicht umgesetzt.
|
||||||
|
|
||||||
|
## 5. Fazit
|
||||||
|
|
||||||
|
Die Anwendungslogik hat den durchgeführten Sicherheitstest ohne verbleibende
|
||||||
|
offene Findings bestanden: keine Auth-/Session-Bypässe, keine RBAC-/IDOR-
|
||||||
|
Lücken, keine SQL-Injection, vollständige Audit-Protokollierung,
|
||||||
|
durchgängige Security-Header, saubere Abhängigkeiten (0 bekannte CVEs). Der
|
||||||
|
Testlauf hat zusätzlich einen realen, durch das Dependency-Upgrade
|
||||||
|
eingeführten Funktionsfehler gefunden und behoben — ein gutes Beispiel dafür,
|
||||||
|
warum Sicherheitsupdates immer mit Regressionstests kombiniert werden
|
||||||
|
sollten, nicht isoliert eingespielt werden dürfen.
|
||||||
|
|
||||||
|
Die verbleibenden offenen Punkte sind ausschließlich solche, die eine echte
|
||||||
|
Zielinfrastruktur voraussetzen und in einer isolierten Code-Sandbox
|
||||||
|
grundsätzlich nicht abschließend geprüft werden können.
|
||||||
156
README.md
Normal file
156
README.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# Jumphost Gateway — Implementierung
|
||||||
|
|
||||||
|
Umsetzung des Konzepts `Jumphost_Konzept.md` (v0.2): browserbasiertes
|
||||||
|
SSH/RDP-Gateway mit TOTP-Pflicht, Hostgruppen-RBAC, manipulationssicherem
|
||||||
|
Audit-Log, SQLite/Python-Backend und Ansible-Deployment (mit/ohne nginx,
|
||||||
|
mehrere TLS-Modi).
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ Backend (FastAPI, Python 3.11+)
|
||||||
|
security/ Crypto, Passwoerter, TOTP, Sessions, Audit-Hash-Chain, AV-Scan
|
||||||
|
auth/ Login-Flow, RBAC-Dependencies
|
||||||
|
admin/ Admin-API (User/Hosts/Hostgruppen/Rollen/SSH-Keys)
|
||||||
|
catalog/ Sicht fuer normale Nutzer (nur zugewiesene Hosts)
|
||||||
|
ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer
|
||||||
|
rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd
|
||||||
|
recordings/ Hash-verkettete Session-Aufzeichnung
|
||||||
|
db/ SQLite-Migrationen
|
||||||
|
static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js)
|
||||||
|
scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen)
|
||||||
|
ansible/ Deployment (Rollen, systemd-Unit-Templates)
|
||||||
|
tests/ pytest-Suite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lokale Entwicklung / Ausprobieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
bash scripts/fetch_frontend_assets.sh # vendored xterm.js / guacamole-common-js
|
||||||
|
|
||||||
|
export JUMPHOST_ENV=development
|
||||||
|
export JUMPHOST_DATA_DIR=/tmp/jumphost-dev
|
||||||
|
export JUMPHOST_DEV_KEK=$(python3 -c "import secrets;print(secrets.token_hex(32))")
|
||||||
|
export JUMPHOST_DEV_SESSION_SECRET=$(python3 -c "import secrets;print(secrets.token_hex(32))")
|
||||||
|
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
python scripts/create_admin.py --username admin # in zweitem Terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `http://127.0.0.1:8000/` oeffnen, anmelden, TOTP einrichten (QR-Code
|
||||||
|
scannen). Fuer echte SSH-/RDP-Sessions muessen zuvor ueber die Admin-API
|
||||||
|
(`/admin/host-groups`, `/admin/hosts`, `/admin/ssh-keys`,
|
||||||
|
`/admin/hosts/{id}/ssh-keys/{id}`, `/admin/hosts/{id}/rdp-credentials`,
|
||||||
|
`/admin/roles/grant`) Hostgruppen, Hosts, Schluessel/Zugangsdaten und
|
||||||
|
Rollenzuweisungen angelegt werden. Fuer RDP muss zusaetzlich ein laufender
|
||||||
|
`guacd` erreichbar sein (siehe `JUMPHOST_GUACD_HOST`/`_PORT`).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
27 Tests decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain (inkl.
|
||||||
|
Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
||||||
|
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
||||||
|
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, sowie
|
||||||
|
17 dedizierte Security-/Pentest-Tests (`tests/test_pentest_security.py`) zu
|
||||||
|
Auth-Bypass, Cookie-/Session-Manipulation, RBAC/IDOR, Injection-Versuchen,
|
||||||
|
Rate-Limiting/Lockout, Filetransfer-Haertung, Security-Headern und
|
||||||
|
Audit-Vollstaendigkeit. Details, Vorgehen und Ergebnisse: siehe
|
||||||
|
`Pentest_Report.md`.
|
||||||
|
|
||||||
|
Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session):
|
||||||
|
Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger
|
||||||
|
Login+TOTP-Flow per curl, Admin-CRUD (Hostgruppe/Host anlegen), Audit-Log-
|
||||||
|
Chain-Verifikation per `/admin/audit-log/verify`.
|
||||||
|
|
||||||
|
## Security-Tests & Haertungs-Nachweis
|
||||||
|
|
||||||
|
- **SAST**: `bandit -r app -c .bandit.yml` sowie `pip-audit` (Dependency-CVE-
|
||||||
|
Scan) laufen sauber durch (0 Findings, 0 bekannte Schwachstellen) — Details
|
||||||
|
inkl. vorher/nachher-Tabellen und der dabei gefundenen und gefixten
|
||||||
|
Starlette-Regression in `Pentest_Report.md`.
|
||||||
|
- **DAST**: `tests/test_pentest_security.py` (17 Tests, s.o.) simuliert
|
||||||
|
konkrete Angriffsmuster gegen die laufende ASGI-App.
|
||||||
|
- **OS-Haertung (CIS/STIG)**: die `ansible/roles/os_hardening`-Rolle wurde
|
||||||
|
in dieser Session um ~10 zusaetzliche Task-Dateien vertieft (Kernel-Module,
|
||||||
|
sysctl, PAM/Passwort-Policy, erweiterte auditd-Regeln, AIDE, rkhunter,
|
||||||
|
Banner, cron/at-Restriktion, Dateirechte/sudo-Logging, SSHD-Haertung).
|
||||||
|
Vollstaendiges Mapping auf CIS-Controls inkl. bewusst nicht automatisierter
|
||||||
|
Punkte (mit Begruendung, z.B. Partitionslayout, Bootloader-Passwort,
|
||||||
|
Volltextverschluesselung, physische Sicherheit) und bekannter
|
||||||
|
Einschraenkungen: `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
|
||||||
|
- Abhaengigkeiten sind in `requirements.txt`/`requirements-dev.txt` exakt
|
||||||
|
auf gegen `pip-audit` gepruefte Versionen gepinnt (u.a. fastapi 0.141.1,
|
||||||
|
starlette 1.6.0, cryptography 50.0.0) — bewusste Reproduzierbarkeits-/
|
||||||
|
Haertungsmassnahme, siehe `Pentest_Report.md` Abschnitt 2.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ansible
|
||||||
|
cp inventory/production.ini.example inventory/production.ini # anpassen
|
||||||
|
cp inventory/group_vars/jumphost_with_nginx.yml.example inventory/group_vars/jumphost_with_nginx.yml
|
||||||
|
ansible-vault encrypt inventory/group_vars/vault.yml # vorher aus vault.yml.example befuellen
|
||||||
|
ansible-playbook -i inventory/production.ini site.yml --ask-vault-pass
|
||||||
|
```
|
||||||
|
|
||||||
|
`enable_nginx_proxy` und `tls_mode` (`internal_pki` / `external_reverse_proxy`
|
||||||
|
/ `acme_public`) steuern Proxy- und TLS-Verhalten, siehe Konzept Kap. 7.2/7.2a
|
||||||
|
und `ansible/inventory/group_vars/all.yml`.
|
||||||
|
|
||||||
|
`ansible-playbook site.yml --syntax-check` laeuft sauber durch (in dieser
|
||||||
|
Session verifiziert); ein voller `--check`-Lauf gegen eine echte
|
||||||
|
Testumgebung (inkl. `apt`, `systemd`, `guacd`-Paketverfuegbarkeit auf der
|
||||||
|
Zieldistribution) steht noch aus.
|
||||||
|
|
||||||
|
## Was bewusst noch offen ist
|
||||||
|
|
||||||
|
Diese Implementierung ist ein funktionsfaehiges, getestetes und in dieser
|
||||||
|
Session per SAST+DAST geprueftes Grundgeruest, aber weiterhin **kein fertig
|
||||||
|
auditiertes Produktivsystem**. Der vollstaendige Befund inkl. Methodik,
|
||||||
|
Vorgehen und Restrisikobewertung steht in `Pentest_Report.md` — die dortige
|
||||||
|
Abschnitt-4-Tabelle ("Restrisiko / vor Produktivbetrieb noch zu tun") ist die
|
||||||
|
massgebliche, aktuelle Fassung dieser Liste. Kurzfassung:
|
||||||
|
|
||||||
|
1. **Echter Netzwerk-Penetrationstest** gegen eine laufende Instanz
|
||||||
|
(Portscan, TLS-Konfiguration live, Session-Isolation unter Last,
|
||||||
|
Guacamole-Protokoll-Fuzzing) — in dieser Sandbox ohne Netzwerkzugriff auf
|
||||||
|
ein reales Zielsystem nicht durchfuehrbar. Was stattdessen gemacht wurde:
|
||||||
|
SAST (bandit, pip-audit) und ein DAST-Testlauf gegen die App im Prozess
|
||||||
|
(17 Security-Tests, siehe `Pentest_Report.md`).
|
||||||
|
2. **RDP/guacd-Integrationstest gegen echte Zielsysteme** — die
|
||||||
|
Guacamole-Protokoll-Implementierung (`app/rdp_proxy/guacd_client.py`) wurde
|
||||||
|
gegen die Protokollspezifikation implementiert und die Handshake-Logik
|
||||||
|
lokal auf Korrektheit der Kodierung geprueft, aber NICHT gegen einen
|
||||||
|
laufenden `guacd` + FreeRDP + Windows-Ziel end-to-end getestet (keine
|
||||||
|
RDP-Zielumgebung in dieser Sitzung verfuegbar). Parameter-Namen/-Reihenfolge
|
||||||
|
sollten gegen die tatsaechlich eingesetzte guacd-Version verifiziert werden.
|
||||||
|
3. **Ansible-Rollout gegen ein reales Zielsystem** (`--check`-Dry-Run und
|
||||||
|
echter Rollout in einer Staging-Umgebung) — bisher nur
|
||||||
|
`ansible-playbook site.yml --syntax-check` sowie YAML-/Jinja2-Parsing
|
||||||
|
verifiziert, siehe `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`.
|
||||||
|
4. **OpenSCAP-Compliance-Scan** (`oscap xccdf eval`) gegen das zutreffende
|
||||||
|
CIS/STIG-Profil — die `os_hardening`-Rolle wurde in dieser Session um ca.
|
||||||
|
10 Task-Dateien vertieft (siehe CIS_STIG_MAPPING.md fuer das vollstaendige
|
||||||
|
Mapping inkl. bewusst nicht automatisierter Punkte), ersetzt aber keinen
|
||||||
|
zertifizierten Benchmark-Scan.
|
||||||
|
5. **Verteiltes Rate-Limiting** — der aktuelle Login-Rate-Limiter ist
|
||||||
|
In-Memory/Single-Process (siehe `app/security/rate_limit.py`); bei
|
||||||
|
horizontaler Skalierung durch einen geteilten Store ersetzen.
|
||||||
|
6. **fail2ban-Filter** setzt strukturierte Access-Logs mit `client_ip=`-Feld
|
||||||
|
voraus, die die App aktuell nicht schreibt — vor Produktivbetrieb ein
|
||||||
|
Access-Log-Middleware ergaenzen oder auf das Audit-Log umstellen.
|
||||||
|
7. **CSRF**: SameSite=Strict-Cookies mindern das Risiko bereits deutlich;
|
||||||
|
ein expliziter CSRF-Token fuer zustandsaendernde JSON-Requests ist als
|
||||||
|
zusaetzliche Haertungsstufe vorgesehen, aber noch nicht implementiert.
|
||||||
|
8. Alle in Konzept Kap. 12 genannten Erweiterungen (LDAP/AD, WebAuthn,
|
||||||
|
PostgreSQL-Migrationspfad, Just-in-Time-Zugriff, HA) sind noch nicht
|
||||||
|
umgesetzt.
|
||||||
|
9. **Social Engineering / physische Sicherheit / Lastest (DoS)** wurden
|
||||||
|
nicht getestet — ausserhalb des Scopes eines Code-/Konfigurations-Reviews
|
||||||
|
in dieser Sandbox-Umgebung.
|
||||||
35
ansible/inventory/group_vars/all.yml
Normal file
35
ansible/inventory/group_vars/all.yml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
# Gemeinsame Variablen. Umgebungsspezifische Werte in
|
||||||
|
# jumphost_with_nginx.yml / jumphost_no_nginx.yml ueberschreiben
|
||||||
|
# (oder direkt hier anpassen, wenn nur eine Umgebung existiert).
|
||||||
|
|
||||||
|
jumphost_app_user: jumphost
|
||||||
|
jumphost_app_group: jumphost
|
||||||
|
jumphost_home: /opt/jumphost
|
||||||
|
jumphost_data_dir: /var/lib/jumphost
|
||||||
|
jumphost_venv: "{{ jumphost_home }}/venv"
|
||||||
|
jumphost_repo_src: "{{ playbook_dir }}/../.." # Projekt-Root (enthaelt app/, static/, templates/)
|
||||||
|
|
||||||
|
jumphost_listen_uds: /run/jumphost/app.sock
|
||||||
|
jumphost_app_port: 8443 # nur relevant wenn enable_nginx_proxy: false
|
||||||
|
|
||||||
|
guacd_port: 4822
|
||||||
|
|
||||||
|
# --- nginx / TLS ------------------------------------------------------------
|
||||||
|
enable_nginx_proxy: true
|
||||||
|
tls_mode: internal_pki # internal_pki | external_reverse_proxy | acme_public
|
||||||
|
# Nur fuer tls_mode: external_reverse_proxy relevant:
|
||||||
|
external_reverse_proxy_cidr: "10.10.5.0/24"
|
||||||
|
internal_hop_plaintext_accepted: false # bewusste Ausnahme, siehe Konzept 7.2a
|
||||||
|
# Nur fuer tls_mode: acme_public relevant:
|
||||||
|
acme_domain: "jumphost.example.com"
|
||||||
|
acme_email: "admin@example.com"
|
||||||
|
|
||||||
|
# --- Firewall ----------------------------------------------------------------
|
||||||
|
ssh_admin_access_cidr: "10.10.1.0/24" # Management-Netz fuer SSH-Zugriff AUF den Jumphost selbst
|
||||||
|
target_networks: # Netze der Zielsysteme (fuer ausgehende Regeln)
|
||||||
|
- "10.20.0.0/16"
|
||||||
|
|
||||||
|
# --- Backup --------------------------------------------------------------------
|
||||||
|
backup_dir: /var/backups/jumphost
|
||||||
|
backup_retention_days: 30
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
# Beispiel-Override fuer eine Umgebung OHNE nginx (siehe Konzept 7.2/9):
|
||||||
|
# Die Python-App terminiert TLS direkt.
|
||||||
|
enable_nginx_proxy: false
|
||||||
|
jumphost_app_port: 8443
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
# Beispiel-Override fuer eine Umgebung MIT nginx (siehe Konzept 7.2/9).
|
||||||
|
enable_nginx_proxy: true
|
||||||
|
tls_mode: internal_pki
|
||||||
6
ansible/inventory/group_vars/vault.yml.example
Normal file
6
ansible/inventory/group_vars/vault.yml.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
# Mit `ansible-vault encrypt group_vars/vault.yml` verschluesseln, NIEMALS
|
||||||
|
# im Klartext committen (siehe Konzept 6.4/7.3).
|
||||||
|
vault_jumphost_kek: "CHANGE_ME_32_BYTES_HEX_0123456789abcdef0123456789abcdef"
|
||||||
|
vault_jumphost_session_secret: "CHANGE_ME_32_BYTES_HEX_0123456789abcdef0123456789abcdef"
|
||||||
|
vault_jumphost_initial_admin_password: "CHANGE_ME_STRONG_PASSWORD"
|
||||||
6
ansible/inventory/production.ini.example
Normal file
6
ansible/inventory/production.ini.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
; Beispiel-Inventory. Kopieren nach production.ini und anpassen.
|
||||||
|
[jumphosts]
|
||||||
|
jumphost01.example.internal ansible_user=deploy
|
||||||
|
|
||||||
|
[jumphosts:vars]
|
||||||
|
ansible_python_interpreter=/usr/bin/python3
|
||||||
4
ansible/roles/backup/handlers/main.yml
Normal file
4
ansible/roles/backup/handlers/main.yml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
- name: reload systemd
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
47
ansible/roles/backup/tasks/main.yml
Normal file
47
ansible/roles/backup/tasks/main.yml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
- name: Backup-Verzeichnis anlegen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ backup_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ jumphost_app_user }}"
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: age installieren (Backup-Verschluesselung)
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: age
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Backup-Skript ausrollen
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: backup.sh.j2
|
||||||
|
dest: "{{ jumphost_home }}/scripts/backup.sh"
|
||||||
|
owner: "{{ jumphost_app_user }}"
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: systemd-Service fuer Backup ausrollen
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: jumphost-backup.service.j2
|
||||||
|
dest: /etc/systemd/system/jumphost-backup.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload systemd
|
||||||
|
|
||||||
|
- name: systemd-Timer fuer Backup ausrollen
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: jumphost-backup.timer.j2
|
||||||
|
dest: /etc/systemd/system/jumphost-backup.timer
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload systemd
|
||||||
|
|
||||||
|
- name: Backup-Timer aktivieren
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: jumphost-backup.timer
|
||||||
|
daemon_reload: true
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
17
ansible/roles/backup/templates/backup.sh.j2
Normal file
17
ansible/roles/backup/templates/backup.sh.j2
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Konsistentes, verschluesseltes SQLite-Backup (Konzept 6.8).
|
||||||
|
# KEK-Backup erfolgt bewusst GETRENNT (siehe Konzept 6.8) -- dieses Skript
|
||||||
|
# sichert ausschliesslich die Datenbank, kein Schluesselmaterial.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB_PATH="{{ jumphost_data_dir }}/jumphost.db"
|
||||||
|
BACKUP_DIR="{{ backup_dir }}"
|
||||||
|
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
OUT_PLAIN="${BACKUP_DIR}/jumphost_${TS}.db"
|
||||||
|
OUT_ENC="${OUT_PLAIN}.age"
|
||||||
|
|
||||||
|
sqlite3 "$DB_PATH" "VACUUM INTO '${OUT_PLAIN}'"
|
||||||
|
age -r "{{ backup_age_public_key | default('AGE_PUBLIC_KEY_PLACEHOLDER') }}" -o "${OUT_ENC}" "${OUT_PLAIN}"
|
||||||
|
shred -u "${OUT_PLAIN}"
|
||||||
|
|
||||||
|
find "$BACKUP_DIR" -name 'jumphost_*.db.age' -mtime +{{ backup_retention_days }} -delete
|
||||||
16
ansible/roles/backup/templates/jumphost-backup.service.j2
Normal file
16
ansible/roles/backup/templates/jumphost-backup.service.j2
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Jumphost verschluesseltes DB-Backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User={{ jumphost_app_user }}
|
||||||
|
Group={{ jumphost_app_group }}
|
||||||
|
ExecStart=/usr/bin/env bash {{ jumphost_home }}/scripts/backup.sh
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths={{ backup_dir }} {{ jumphost_data_dir }}
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
UMask=0077
|
||||||
10
ansible/roles/backup/templates/jumphost-backup.timer.j2
Normal file
10
ansible/roles/backup/templates/jumphost-backup.timer.j2
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Taeglicher Jumphost-Backup-Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
RandomizedDelaySec=1800
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
5
ansible/roles/fail2ban/handlers/main.yml
Normal file
5
ansible/roles/fail2ban/handlers/main.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: restart fail2ban
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: fail2ban
|
||||||
|
state: restarted
|
||||||
38
ansible/roles/fail2ban/tasks/main.yml
Normal file
38
ansible/roles/fail2ban/tasks/main.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
# Zweite Verteidigungslinie gegen Brute-Force zusaetzlich zum
|
||||||
|
# Anwendungs-Rate-Limiting (Konzept 6.2). Ueberwacht die Uvicorn-/nginx-
|
||||||
|
# Access-Logs auf gehaeufte 401-Antworten vom Login-Endpunkt.
|
||||||
|
|
||||||
|
- name: fail2ban installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: fail2ban
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Filter fuer Jumphost-Login-Fehlversuche
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/fail2ban/filter.d/jumphost-login.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Definition]
|
||||||
|
failregex = ^.*"POST /auth/login(/totp)? HTTP/.*" 401 .*client_ip=<HOST>.*$
|
||||||
|
^.*"POST /auth/login(/totp)? HTTP/.*" 401 .*<HOST>.*$
|
||||||
|
ignoreregex =
|
||||||
|
|
||||||
|
- name: Jail fuer Jumphost-Login aktivieren
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/fail2ban/jail.d/jumphost.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[jumphost-login]
|
||||||
|
enabled = true
|
||||||
|
filter = jumphost-login
|
||||||
|
logpath = /var/log/jumphost/access.log
|
||||||
|
maxretry = 8
|
||||||
|
findtime = 300
|
||||||
|
bantime = 1800
|
||||||
|
notify: restart fail2ban
|
||||||
5
ansible/roles/firewall_nftables/handlers/main.yml
Normal file
5
ansible/roles/firewall_nftables/handlers/main.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: reload nftables
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: nftables
|
||||||
|
state: restarted
|
||||||
25
ansible/roles/firewall_nftables/tasks/main.yml
Normal file
25
ansible/roles/firewall_nftables/tasks/main.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
# Default-Deny-Firewall (Konzept 6.7). Eingehend nur admin-SSH (aus dem
|
||||||
|
# Management-Netz) und der oeffentliche App-/nginx-Port; ausgehend nur zu den
|
||||||
|
# definierten Zielsystem-Netzen sowie DNS/NTP.
|
||||||
|
|
||||||
|
- name: nftables installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: nftables
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: nftables-Regelsatz ausrollen
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: jumphost.nft.j2
|
||||||
|
dest: /etc/nftables.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
notify: reload nftables
|
||||||
|
|
||||||
|
- name: nftables aktivieren und starten
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: nftables
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
46
ansible/roles/firewall_nftables/templates/jumphost.nft.j2
Normal file
46
ansible/roles/firewall_nftables/templates/jumphost.nft.j2
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/sbin/nft -f
|
||||||
|
flush ruleset
|
||||||
|
|
||||||
|
table inet filter {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy drop;
|
||||||
|
|
||||||
|
iif lo accept
|
||||||
|
ct state established,related accept
|
||||||
|
ct state invalid drop
|
||||||
|
|
||||||
|
icmp type echo-request limit rate 5/second accept
|
||||||
|
ip6 nexthdr icmpv6 icmpv6 type echo-request limit rate 5/second accept
|
||||||
|
|
||||||
|
tcp dport 22 ip saddr {{ ssh_admin_access_cidr }} accept
|
||||||
|
|
||||||
|
{% if enable_nginx_proxy %}
|
||||||
|
tcp dport 443 accept
|
||||||
|
{% else %}
|
||||||
|
tcp dport {{ jumphost_app_port }} accept
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority 0; policy drop;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain output {
|
||||||
|
type filter hook output priority 0; policy drop;
|
||||||
|
|
||||||
|
oif lo accept
|
||||||
|
ct state established,related accept
|
||||||
|
|
||||||
|
udp dport 53 accept
|
||||||
|
tcp dport 53 accept
|
||||||
|
udp dport 123 accept
|
||||||
|
|
||||||
|
{% for net in target_networks %}
|
||||||
|
ip daddr {{ net }} accept
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
# ACME/interne PKI (tls_certificates-Rolle) und OS-Paketquellen
|
||||||
|
tcp dport 443 accept
|
||||||
|
tcp dport 80 accept
|
||||||
|
}
|
||||||
|
}
|
||||||
25
ansible/roles/frontend_assets/tasks/main.yml
Normal file
25
ansible/roles/frontend_assets/tasks/main.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
# Vendored Frontend-Assets (xterm.js, guacamole-common-js). Bewusst OHNE
|
||||||
|
# Laufzeit-CDN-Bezug (Konzept 4.1/6.6) -- der Build laeuft hier einmalig auf
|
||||||
|
# dem Zielsystem (oder alternativ auf einem Build-Host, siehe Variante unten).
|
||||||
|
|
||||||
|
- name: Node.js/npm fuer den Asset-Build installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: npm
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Frontend-Assets bauen (scripts/fetch_frontend_assets.sh)
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "bash {{ jumphost_repo_src }}/scripts/fetch_frontend_assets.sh"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
register: _asset_build
|
||||||
|
changed_when: "'Fertig' in _asset_build.stdout"
|
||||||
|
|
||||||
|
# Hinweis: In restriktiveren Umgebungen (Jumphost selbst ohne Internetzugang
|
||||||
|
# fuer npm) diesen Task durch eine Kopie von vorgefertigten, im internen
|
||||||
|
# Artefakt-Repository abgelegten vendor/-Dateien ersetzen -- die Zielstruktur
|
||||||
|
# (static/js/vendor/*.js) bleibt identisch, siehe python_runtime-Rolle, die
|
||||||
|
# static/ anschliessend synchronisiert.
|
||||||
10
ansible/roles/guacd/handlers/main.yml
Normal file
10
ansible/roles/guacd/handlers/main.yml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
- name: reload systemd
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: restart guacd
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: guacd
|
||||||
|
state: restarted
|
||||||
|
enabled: true
|
||||||
64
ansible/roles/guacd/tasks/main.yml
Normal file
64
ansible/roles/guacd/tasks/main.yml
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
# guacd + FreeRDP als reine Protokoll-Engine fuer RDP (Konzept 3/4.3).
|
||||||
|
# Laeuft ausschliesslich lokal gebunden, kein Netzwerkzugriff von aussen.
|
||||||
|
|
||||||
|
- name: guacd und FreeRDP-Plugin installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- guacd
|
||||||
|
- libguac-client-rdp0
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Dedizierten guacd-User sicherstellen (Paket legt i.d.R. bereits einen an)
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: guacd
|
||||||
|
system: true
|
||||||
|
shell: /usr/sbin/nologin
|
||||||
|
create_home: false
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: guacd nur an localhost binden
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/guacamole/guacd.conf
|
||||||
|
regexp: '^bind_host'
|
||||||
|
line: "bind_host = 127.0.0.1"
|
||||||
|
create: true
|
||||||
|
notify: restart guacd
|
||||||
|
|
||||||
|
- name: guacd-Port setzen
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/guacamole/guacd.conf
|
||||||
|
regexp: '^bind_port'
|
||||||
|
line: "bind_port = {{ guacd_port }}"
|
||||||
|
create: true
|
||||||
|
notify: restart guacd
|
||||||
|
|
||||||
|
- name: RDP-Laufwerksumleitungs-Verzeichnisse anlegen (Filetransfer, Konzept 4.3)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "/var/lib/jumphost/rdp-drives"
|
||||||
|
state: directory
|
||||||
|
owner: guacd
|
||||||
|
group: guacd
|
||||||
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: Override-Verzeichnis fuer guacd-Unit anlegen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/systemd/system/guacd.service.d
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Gehaertete systemd-Unit fuer guacd ausrollen (Override)
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: guacd.service.j2
|
||||||
|
dest: /etc/systemd/system/guacd.service.d/override.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
vars:
|
||||||
|
_dummy: true
|
||||||
|
notify:
|
||||||
|
- reload systemd
|
||||||
|
- restart guacd
|
||||||
20
ansible/roles/guacd/templates/guacd.service.j2
Normal file
20
ansible/roles/guacd/templates/guacd.service.j2
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
; Hardening-Override fuer die vom Debian/Ubuntu-Paket mitgelieferte
|
||||||
|
; guacd.service (Konzept 8.2). Nur lokale Kommunikation zur App (Unix-Domain
|
||||||
|
; oder localhost) sowie ausgehend RDP zu den Zielsystemen.
|
||||||
|
[Service]
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
LockPersonality=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
ReadWritePaths=/var/lib/jumphost/rdp-drives
|
||||||
|
UMask=0077
|
||||||
9
ansible/roles/jumphost_app/handlers/main.yml
Normal file
9
ansible/roles/jumphost_app/handlers/main.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
- name: reload systemd
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: restart jumphost-app service
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: jumphost-app
|
||||||
|
state: restarted
|
||||||
76
ansible/roles/jumphost_app/tasks/main.yml
Normal file
76
ansible/roles/jumphost_app/tasks/main.yml
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
# Deployt die eigentliche Jumphost-Anwendung als gehaertete systemd-Unit.
|
||||||
|
# KEK und Session-Secret werden ueber systemd-creds verschluesselt abgelegt
|
||||||
|
# (Konzept 6.4) -- niemals als Klartext-Env-Variable im Unit-File.
|
||||||
|
|
||||||
|
- name: Konfigurationsverzeichnis anlegen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/jumphost
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: Pruefen ob systemd-creds verfuegbar ist (systemd >= 250 empfohlen)
|
||||||
|
ansible.builtin.command: systemd-creds --version
|
||||||
|
register: _creds_check
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Warnung ausgeben, falls systemd-creds fehlt
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >
|
||||||
|
WARNUNG: systemd-creds nicht verfuegbar. Fallback auf Env-Variablen
|
||||||
|
(JUMPHOST_KEK/JUMPHOST_SESSION_SECRET) in einer 0600-EnvironmentFile --
|
||||||
|
weniger sicher als LoadCredentialEncrypted=, siehe Konzept 6.4.
|
||||||
|
when: _creds_check.rc != 0
|
||||||
|
|
||||||
|
- name: KEK verschluesselt ablegen (systemd-creds)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -o pipefail
|
||||||
|
echo -n '{{ vault_jumphost_kek }}' | systemd-creds encrypt --name=jumphost_kek - /etc/jumphost/jumphost_kek.cred
|
||||||
|
args:
|
||||||
|
creates: /etc/jumphost/jumphost_kek.cred
|
||||||
|
executable: /bin/bash
|
||||||
|
when: _creds_check.rc == 0
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Session-Secret verschluesselt ablegen (systemd-creds)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -o pipefail
|
||||||
|
echo -n '{{ vault_jumphost_session_secret }}' | systemd-creds encrypt --name=jumphost_session_secret - /etc/jumphost/jumphost_session_secret.cred
|
||||||
|
args:
|
||||||
|
creates: /etc/jumphost/jumphost_session_secret.cred
|
||||||
|
executable: /bin/bash
|
||||||
|
when: _creds_check.rc == 0
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Fallback-EnvironmentFile (nur falls systemd-creds fehlt)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/jumphost/env
|
||||||
|
owner: root
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
mode: "0640"
|
||||||
|
content: |
|
||||||
|
JUMPHOST_KEK={{ vault_jumphost_kek }}
|
||||||
|
JUMPHOST_SESSION_SECRET={{ vault_jumphost_session_secret }}
|
||||||
|
when: _creds_check.rc != 0
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: systemd-Unit fuer die Jumphost-App ausrollen
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: jumphost-app.service.j2
|
||||||
|
dest: /etc/systemd/system/jumphost-app.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify:
|
||||||
|
- reload systemd
|
||||||
|
- restart jumphost-app service
|
||||||
|
|
||||||
|
- name: Jumphost-App aktivieren und starten
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: jumphost-app
|
||||||
|
daemon_reload: true
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
58
ansible/roles/jumphost_app/templates/jumphost-app.service.j2
Normal file
58
ansible/roles/jumphost_app/templates/jumphost-app.service.j2
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Jumphost Gateway Application
|
||||||
|
After=network.target guacd.service
|
||||||
|
Wants=guacd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{ jumphost_app_user }}
|
||||||
|
Group={{ jumphost_app_group }}
|
||||||
|
WorkingDirectory={{ jumphost_home }}
|
||||||
|
Environment=JUMPHOST_ENV=production
|
||||||
|
Environment=JUMPHOST_DATA_DIR={{ jumphost_data_dir }}
|
||||||
|
Environment=JUMPHOST_LISTEN_UDS={{ jumphost_listen_uds }}
|
||||||
|
Environment=JUMPHOST_GUACD_HOST=127.0.0.1
|
||||||
|
Environment=JUMPHOST_GUACD_PORT={{ guacd_port }}
|
||||||
|
{% if not enable_nginx_proxy %}
|
||||||
|
Environment=JUMPHOST_DIRECT_TLS=1
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if _creds_check.rc == 0 %}
|
||||||
|
LoadCredentialEncrypted=jumphost_kek:/etc/jumphost/jumphost_kek.cred
|
||||||
|
LoadCredentialEncrypted=jumphost_session_secret:/etc/jumphost/jumphost_session_secret.cred
|
||||||
|
{% else %}
|
||||||
|
EnvironmentFile=/etc/jumphost/env
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if enable_nginx_proxy %}
|
||||||
|
ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --uds {{ jumphost_listen_uds }}
|
||||||
|
{% else %}
|
||||||
|
ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --host 0.0.0.0 --port {{ jumphost_app_port }} \
|
||||||
|
--ssl-certfile /etc/jumphost/tls/server.crt --ssl-keyfile /etc/jumphost/tls/server.key
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# --- Hardening (Konzept 6.7/8.1) ---------------------------------------------
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
LockPersonality=true
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
ReadWritePaths={{ jumphost_data_dir }} /run/jumphost /var/log/jumphost
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
5
ansible/roles/nginx_proxy/handlers/main.yml
Normal file
5
ansible/roles/nginx_proxy/handlers/main.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: reload nginx
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
45
ansible/roles/nginx_proxy/tasks/main.yml
Normal file
45
ansible/roles/nginx_proxy/tasks/main.yml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
# nginx als vorgelagerter Reverse Proxy (Konzept 7.2/7.2a/9). Nur inkludiert
|
||||||
|
# wenn enable_nginx_proxy: true.
|
||||||
|
|
||||||
|
- name: nginx installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: nginx
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Default-vHost entfernen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/nginx/sites-enabled/default
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Proxy-Header-Snippet ausrollen (import)
|
||||||
|
ansible.builtin.import_tasks: snippets.yml
|
||||||
|
|
||||||
|
- name: Jumphost-vHost-Konfiguration ausrollen
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: jumphost.conf.j2
|
||||||
|
dest: /etc/nginx/sites-available/jumphost.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: vHost aktivieren
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: /etc/nginx/sites-available/jumphost.conf
|
||||||
|
dest: /etc/nginx/sites-enabled/jumphost.conf
|
||||||
|
state: link
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: Zugriff des nginx-Users auf den App-Unix-Socket sicherstellen
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: www-data
|
||||||
|
groups: "{{ jumphost_app_group }}"
|
||||||
|
append: true
|
||||||
|
|
||||||
|
- name: nginx aktivieren und starten
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: nginx
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
20
ansible/roles/nginx_proxy/tasks/snippets.yml
Normal file
20
ansible/roles/nginx_proxy/tasks/snippets.yml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
# Wird von tasks/main.yml importiert (siehe include_tasks unten ergaenzen,
|
||||||
|
# falls Snippet-Datei separat gepflegt werden soll). Der Einfachheit halber
|
||||||
|
# hier als eigenstaendiger Task-Block gehalten.
|
||||||
|
- name: Snippet-Verzeichnis anlegen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/nginx/snippets
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Proxy-Header-Snippet ausrollen
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: jumphost_proxy_headers.conf.j2
|
||||||
|
dest: /etc/nginx/snippets/jumphost_proxy_headers.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: reload nginx
|
||||||
63
ansible/roles/nginx_proxy/templates/jumphost.conf.j2
Normal file
63
ansible/roles/nginx_proxy/templates/jumphost.conf.j2
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Jumphost Reverse-Proxy-Konfiguration (Konzept 6.6/7.2a/9).
|
||||||
|
# tls_mode = {{ tls_mode }}
|
||||||
|
|
||||||
|
{% if tls_mode == 'external_reverse_proxy' %}
|
||||||
|
# Nur der vorgelagerte externe Reverse Proxy darf X-Forwarded-For/-Proto
|
||||||
|
# setzen -- sonst waere das Audit-Log-Feld client_ip faelschbar (Konzept 7.2a).
|
||||||
|
set_real_ip_from {{ external_reverse_proxy_cidr }};
|
||||||
|
real_ip_header X-Forwarded-For;
|
||||||
|
real_ip_recursive on;
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=jumphost_login:10m rate=10r/m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
{% if tls_mode == 'external_reverse_proxy' and internal_hop_plaintext_accepted %}
|
||||||
|
# Bewusste, dokumentierte Ausnahme (Konzept 7.2a): Klartext-HTTP auf der
|
||||||
|
# Strecke externer Proxy -> Jumphost, nur zulaessig in einem eigenen,
|
||||||
|
# zugriffskontrollierten Netzsegment.
|
||||||
|
listen 443;
|
||||||
|
{% else %}
|
||||||
|
listen 443 ssl;
|
||||||
|
ssl_certificate /etc/jumphost/tls/server.crt;
|
||||||
|
ssl_certificate_key /etc/jumphost/tls/server.key;
|
||||||
|
ssl_protocols TLSv1.3;
|
||||||
|
ssl_session_cache shared:jumphost_ssl:10m;
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
server_name {{ acme_domain | default('_') }};
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
|
||||||
|
client_max_body_size 210m; # etwas ueber MAX_UPLOAD_BYTES der App (Konzept 6.6)
|
||||||
|
|
||||||
|
location /auth/login {
|
||||||
|
limit_req zone=jumphost_login burst=5 nodelay;
|
||||||
|
proxy_pass http://unix:{{ jumphost_listen_uds }}:;
|
||||||
|
include /etc/nginx/snippets/jumphost_proxy_headers.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://unix:{{ jumphost_listen_uds }}:;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
include /etc/nginx/snippets/jumphost_proxy_headers.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:{{ jumphost_listen_uds }}:;
|
||||||
|
include /etc/nginx/snippets/jumphost_proxy_headers.conf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name {{ acme_domain | default('_') }};
|
||||||
|
location /.well-known/acme-challenge/ { root /var/www/html; }
|
||||||
|
location / { return 301 https://$host$request_uri; }
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
{% if tls_mode == 'external_reverse_proxy' %}
|
||||||
|
# Vom externen Proxy gesetzte Header werden dank set_real_ip_from oben
|
||||||
|
# vertrauensvoll uebernommen statt hier ueberschrieben zu werden.
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
{% else %}
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
{% endif %}
|
||||||
71
ansible/roles/os_hardening/CIS_STIG_MAPPING.md
Normal file
71
ansible/roles/os_hardening/CIS_STIG_MAPPING.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# CIS/STIG-Mapping der `os_hardening`-Rolle
|
||||||
|
|
||||||
|
Diese Rolle deckt eine bewusst ausgewählte, für einen dedizierten Jumphost
|
||||||
|
(Debian/Ubuntu-Familie) relevante Teilmenge der CIS Debian/Ubuntu Linux
|
||||||
|
Benchmarks sowie einzelner DISA-STIG-typischer Controls ab. Sie ist **kein**
|
||||||
|
vollständiger, zertifizierter Benchmark-Lauf — dafür fehlt ein automatisiertes
|
||||||
|
Compliance-Scanning-Tool. Empfehlung: nach dem Rollout zusätzlich mit
|
||||||
|
`oscap xccdf eval` (OpenSCAP) gegen das jeweils zutreffende Profil prüfen und
|
||||||
|
diese Tabelle bei Abweichungen als Startpunkt für die Nacharbeit nutzen.
|
||||||
|
|
||||||
|
Referenz-Control-IDs folgen der CIS-Nummerierung *sinngemäß* (Benchmark-Version
|
||||||
|
kann je nach Debian/Ubuntu-Release leicht abweichen); wo passend ist zusätzlich
|
||||||
|
vermerkt, wenn ein Control STIG-typisch, aber nicht CIS-nummeriert ist.
|
||||||
|
|
||||||
|
| Bereich | CIS-Control (sinngemäß) | Umsetzung | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Unsichere Legacy-Dienste | 2.1.x | `tasks/packages.yml` | Implementiert |
|
||||||
|
| Automatische Sicherheitsupdates | 1.9 | `tasks/packages.yml` | Implementiert |
|
||||||
|
| Deaktivierung seltener Dateisystemtreiber | 1.1.1.1–1.1.1.8 | `tasks/kernel_modules.yml` | Implementiert |
|
||||||
|
| Deaktivierung seltener Netzwerkprotokolle | 3.4.1–3.4.4 | `tasks/kernel_modules.yml` | Implementiert |
|
||||||
|
| USB-Speichermedien deaktivieren | 1.1.23 | `tasks/kernel_modules.yml` | Implementiert |
|
||||||
|
| Netzwerk-/Kernel-sysctl-Härtung | 3.x, 1.5.x | `tasks/sysctl.yml` | Implementiert |
|
||||||
|
| Core Dumps deaktivieren | 1.5.1 | `tasks/sysctl.yml` | Implementiert |
|
||||||
|
| `/tmp`, `/dev/shm` mit noexec/nosuid/nodev | 1.1.2.x | `tasks/mounts.yml` | **Best-Effort** — nur wirksam, wenn bereits eigene Mountpoints existieren (siehe unten) |
|
||||||
|
| Passwortqualität (Länge/Komplexität) | 5.4.1 | `tasks/pam_password_policy.yml` | Implementiert (lokale OS-Konten) |
|
||||||
|
| Account-Lockout (pam_faillock) | 5.3.1 | `tasks/pam_password_policy.yml` | Implementiert, siehe Einschränkung unten |
|
||||||
|
| Passwort-Historie | 5.4.2 | `tasks/pam_password_policy.yml` | Implementiert |
|
||||||
|
| Passwort-Ablauf (`login.defs`) | 5.4.1.1–5.4.1.4 | `tasks/pam_password_policy.yml` | Implementiert |
|
||||||
|
| Erweiterte auditd-Regeln (Identität, Zeit, Netzwerk, Logins, Kernelmodule) | 4.1.3–4.1.14 | `tasks/auditd.yml` | Implementiert |
|
||||||
|
| auditd-Log-Handling (keep_logs, space_left_action) | 4.1.2.3, 4.1.2.4 | `tasks/auditd.yml` | Implementiert |
|
||||||
|
| auditd unveränderlich (`-e 2`) | 4.1.1.4 (STIG) | `tasks/auditd.yml` | Implementiert, **standardmäßig deaktiviert** (Opt-in `os_hardening_auditd_immutable`, da Reboot zum Ändern nötig) |
|
||||||
|
| Datei-Integritäts-Monitoring (AIDE) | 1.4.1, 1.4.2 | `tasks/aide.yml` | Implementiert |
|
||||||
|
| Rootkit-Scanner (rkhunter, ergänzend) | STIG-typisch, nicht CIS-nummeriert | `tasks/rootkit_scan.yml` | Implementiert |
|
||||||
|
| Anmelde-Banner | STIG-typisch (Legal Notice) | `tasks/banners.yml`, `tasks/sshd.yml` | Implementiert |
|
||||||
|
| cron/at auf autorisierte Nutzer beschränken | 2.4.1.x | `tasks/cron_at.yml` | Implementiert |
|
||||||
|
| Berechtigungen kritischer Dateien (`/etc/shadow` etc.) | 6.1.x | `tasks/file_permissions.yml` | Implementiert |
|
||||||
|
| sudo-Logging (`log_input`, `log_output`, `use_pty`) | STIG-typisch | `tasks/file_permissions.yml` | Implementiert |
|
||||||
|
| `su` auf Gruppe `sudo` beschränken | 5.6 | `tasks/file_permissions.yml` | Implementiert |
|
||||||
|
| SSH-Daemon-Härtung (Ciphers/KEX/MACs, kein Root-Login, etc.) | 5.2.x | `tasks/sshd.yml` | Implementiert |
|
||||||
|
| IPv6 deaktivieren | 3.1.1 | `tasks/sysctl.yml` | Implementiert, **Opt-in** (`os_hardening_disable_ipv6`, Default aus) |
|
||||||
|
|
||||||
|
## Bewusst nicht automatisiert (mit Begründung)
|
||||||
|
|
||||||
|
| Bereich | CIS-Control (sinngemäß) | Warum nicht automatisiert |
|
||||||
|
|---|---|---|
|
||||||
|
| Getrenntes Partitionslayout (`/tmp`, `/var`, `/var/log`, `/var/log/audit`, `/home` als eigene Partitionen) | 1.1.1–1.1.1.30 | Erfordert eine Neupartitionierung der Festplatte — nur zum Zeitpunkt der OS-Installation sinnvoll setzbar, nicht nachträglich per Ansible auf ein laufendes System ohne Datenverlustrisiko. **Empfehlung:** beim Erstellen des Basis-Images/der VM-Vorlage bereits mit diesem Layout provisionieren. |
|
||||||
|
| Bootloader-Passwort (GRUB) | 1.4.1 (ältere CIS-Nummerierung) | Erfordert physischen/Konsolen-Zugriff zum Testen und ist bei Cloud-/Hypervisor-Images oft irrelevant oder sogar kontraproduktiv (verhindert automatisierten Neustart). Bewusst dem jeweiligen Betreiber überlassen. |
|
||||||
|
| Volltext-Festplattenverschlüsselung | Nicht CIS-nummeriert, STIG-typisch | Muss bei der OS-Installation eingerichtet werden (LUKS o.ä.), nicht nachträglich per Ansible. In Cloud-Umgebungen häufig durch Provider-seitige Verschlüsselung (z.B. verschlüsselte Volumes) abgedeckt — separat prüfen. |
|
||||||
|
| Physische Sicherheit / BIOS-UEFI-Passwort | Nicht CIS-nummeriert | Außerhalb der Reichweite von Ansible; organisatorische/physische Maßnahme. |
|
||||||
|
| Zentrales Log-Forwarding an SIEM/Syslog-Server | Nicht CIS-nummeriert, aber im Jumphost-Konzept Kap. 6.9 gefordert | Erfordert Kenntnis der Ziel-SIEM-Infrastruktur (Empfänger-Host, Protokoll, TLS-Zertifikate) — als eigener Konfigurationspunkt vorgesehen, aber nicht Teil dieser Rolle (siehe Konzept Kap. 6.9, Erweiterungspunkt). |
|
||||||
|
| Netzwerksegmentierung / Firewalling *zwischen* Zielsystemen | Außerhalb des Jumphost-Scopes | Betrifft die Netzwerkinfrastruktur rund um den Jumphost, nicht den Jumphost selbst — Bestandteil des übergeordneten Netzwerkkonzepts. |
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
- **`pam_faillock`-Einbindung** (`tasks/pam_password_policy.yml`) fügt einen
|
||||||
|
Block direkt in `/etc/pam.d/common-auth` ein. Auf Systemen, die
|
||||||
|
`pam-auth-update --force` als Teil eines anderen Automatisierungsschritts
|
||||||
|
laufen lassen, kann dieser Block überschrieben werden. Für produktive
|
||||||
|
Systeme mit häufigen PAM-Änderungen wird empfohlen, stattdessen ein
|
||||||
|
eigenes `pam-auth-update`-Profil unter `/usr/share/pam-configs/` zu
|
||||||
|
pflegen (sauberer, upgrade-fest) — hier aus Gründen der Nachvollziehbarkeit
|
||||||
|
als direkter Block-Insert gehalten.
|
||||||
|
- **`/tmp`/`/dev/shm`-Remount** wirkt nur, wenn diese Pfade bereits eigene
|
||||||
|
Mountpoints sind. Ist das nicht der Fall, gibt die Rolle eine Debug-Meldung
|
||||||
|
aus, ändert aber nichts automatisch am Partitionslayout (siehe Tabelle oben).
|
||||||
|
- Diese Rolle wurde in dieser Session **nicht** gegen ein reales
|
||||||
|
Debian/Ubuntu-System ausgeführt (kein Root-/Zielsystem verfügbar) — nur
|
||||||
|
`ansible-playbook --syntax-check` sowie YAML-/Jinja2-Parsing wurden
|
||||||
|
verifiziert (siehe README, Abschnitt „Was bewusst noch offen ist"). Ein
|
||||||
|
`--check`-Lauf (Dry-Run) und ein realer Rollout-Test in einer
|
||||||
|
Staging-Umgebung stehen vor dem Produktivbetrieb noch aus.
|
||||||
47
ansible/roles/os_hardening/defaults/main.yml
Normal file
47
ansible/roles/os_hardening/defaults/main.yml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
# Schalter fuer die vertiefte CIS/STIG-nahe Haertung dieser Rolle.
|
||||||
|
# Siehe ansible/roles/os_hardening/CIS_STIG_MAPPING.md fuer die Zuordnung
|
||||||
|
# der einzelnen Tasks zu konkreten Benchmark-Controls sowie fuer alles, was
|
||||||
|
# hier bewusst NICHT automatisiert wird (mit Begruendung).
|
||||||
|
|
||||||
|
# AIDE (Dateiintegritaets-Monitoring) initialisieren + taeglichen Check-Timer
|
||||||
|
# einrichten. Erzeugt beim ersten Lauf spuerbare I/O-Last (voller Datei-Scan).
|
||||||
|
os_hardening_aide_enabled: true
|
||||||
|
|
||||||
|
# rkhunter als zusaetzlicher, ergaenzender Rootkit-/Anomalie-Scanner.
|
||||||
|
os_hardening_rkhunter_enabled: true
|
||||||
|
|
||||||
|
# /tmp und /dev/shm nach Moeglichkeit mit noexec,nosuid,nodev neu einhaengen
|
||||||
|
# (nur wenn sie BEREITS eigene Mountpoints sind -- siehe Mapping-Dokument
|
||||||
|
# Punkt "Partitionslayout" fuer den nicht automatisierbaren Teil).
|
||||||
|
os_hardening_restrict_tmp_mounts: true
|
||||||
|
|
||||||
|
# Der Anmelde-Banner-Text (STIG-typischer Warnhinweis). An lokale
|
||||||
|
# Rechtslage/Policy anpassen.
|
||||||
|
os_hardening_login_banner: |
|
||||||
|
******************************************************************
|
||||||
|
* Autorisierter Zugriff ausschliesslich fuer befugte Personen. *
|
||||||
|
* Alle Aktivitaeten auf diesem System werden protokolliert und *
|
||||||
|
* koennen im Rahmen von Sicherheitsuntersuchungen ausgewertet *
|
||||||
|
* werden. Mit der Anmeldung stimmen Sie dieser Ueberwachung zu. *
|
||||||
|
******************************************************************
|
||||||
|
|
||||||
|
# IPv6 komplett deaktivieren. Standardmaessig AUS (false), da dies in vielen
|
||||||
|
# Umgebungen ungewollte Nebenwirkungen hat (z.B. wenn Zielsysteme oder das
|
||||||
|
# Management-Netz IPv6 nutzen) -- bewusst ein Opt-in, kein stiller Default.
|
||||||
|
os_hardening_disable_ipv6: false
|
||||||
|
|
||||||
|
# auditd-Konfiguration als UNVERAENDERLICH markieren (-e 2 am Ende der
|
||||||
|
# Regeln). Das ist die STIG-konforme Einstellung, erfordert danach aber
|
||||||
|
# einen Reboot, um die Audit-Regeln ueberhaupt noch aendern zu koennen --
|
||||||
|
# daher standardmaessig AUS, damit iterative Playbook-Laeufe waehrend des
|
||||||
|
# Aufbaus nicht versehentlich aussperren. Vor der finalen Abnahme auf true setzen.
|
||||||
|
os_hardening_auditd_immutable: false
|
||||||
|
|
||||||
|
# Passwortrichtlinie fuer LOKALE OS-Konten auf dem Jumphost selbst (NICHT zu
|
||||||
|
# verwechseln mit der Argon2id/TOTP-Pflicht der Jumphost-WEBANWENDUNG, die
|
||||||
|
# unabhaengig davon in app/security/passwords.py + totp.py durchgesetzt wird).
|
||||||
|
os_hardening_password_min_length: 14
|
||||||
|
os_hardening_password_remember: 5
|
||||||
|
os_hardening_faillock_deny: 5
|
||||||
|
os_hardening_faillock_unlock_time: 900
|
||||||
32
ansible/roles/os_hardening/handlers/main.yml
Normal file
32
ansible/roles/os_hardening/handlers/main.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
- name: restart auditd
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: auditd
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: restart sshd
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: ssh
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: apply sysctl
|
||||||
|
ansible.builtin.command: sysctl --system
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: update initramfs
|
||||||
|
ansible.builtin.command: update-initramfs -u
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: remount tmp
|
||||||
|
ansible.builtin.command: mount -o remount /tmp
|
||||||
|
changed_when: true
|
||||||
|
failed_when: false # best effort, siehe Kommentar in tasks/mounts.yml
|
||||||
|
|
||||||
|
- name: remount shm
|
||||||
|
ansible.builtin.command: mount -o remount /dev/shm
|
||||||
|
changed_when: true
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: reload systemd (os_hardening)
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
86
ansible/roles/os_hardening/tasks/aide.yml
Normal file
86
ansible/roles/os_hardening/tasks/aide.yml
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
# CIS 1.4.x: AIDE (Advanced Intrusion Detection Environment) als
|
||||||
|
# Datei-Integritaets-Monitoring. Ergaenzt das Anwendungs-Audit-Log
|
||||||
|
# (manipulationssichere Hash-Chain, Konzept 6.1) um eine unabhaengige,
|
||||||
|
# dateisystemweite Kontrolle -- erkennt z.B. Aenderungen an Systembinaries
|
||||||
|
# oder eingeschleuste Dateien ausserhalb des Anwendungscodes.
|
||||||
|
|
||||||
|
- name: AIDE installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: aide
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
when: os_hardening_aide_enabled
|
||||||
|
|
||||||
|
- name: Pruefen, ob bereits eine AIDE-Datenbank existiert
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /var/lib/aide/aide.db
|
||||||
|
register: _aide_db
|
||||||
|
when: os_hardening_aide_enabled
|
||||||
|
|
||||||
|
- name: Initiale AIDE-Datenbank aufbauen (kann einige Minuten dauern)
|
||||||
|
ansible.builtin.command: aideinit -y -f
|
||||||
|
when: os_hardening_aide_enabled and not _aide_db.stat.exists
|
||||||
|
async: 1800
|
||||||
|
poll: 30
|
||||||
|
|
||||||
|
- name: AIDE-Wrapper-Skript fuer den taeglichen Check ausrollen
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /usr/local/sbin/jumphost-aide-check.sh
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
content: |
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LOGFILE=/var/log/jumphost/aide-check.log
|
||||||
|
/usr/bin/aide.wrapper --check >> "$LOGFILE" 2>&1 || {
|
||||||
|
logger -p authpriv.warning "AIDE hat Dateisystem-Abweichungen gemeldet, siehe $LOGFILE"
|
||||||
|
}
|
||||||
|
when: os_hardening_aide_enabled
|
||||||
|
|
||||||
|
- name: systemd-Service fuer taeglichen AIDE-Check
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/jumphost-aide-check.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Taeglicher AIDE-Dateiintegritaets-Check
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/jumphost-aide-check.sh
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
|
when: os_hardening_aide_enabled
|
||||||
|
notify: reload systemd (os_hardening)
|
||||||
|
|
||||||
|
- name: systemd-Timer fuer taeglichen AIDE-Check
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/jumphost-aide-check.timer
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Taeglicher AIDE-Dateiintegritaets-Check (Timer)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
RandomizedDelaySec=3600
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
when: os_hardening_aide_enabled
|
||||||
|
notify: reload systemd (os_hardening)
|
||||||
|
|
||||||
|
- name: AIDE-Check-Timer aktivieren
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: jumphost-aide-check.timer
|
||||||
|
daemon_reload: true
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
when: os_hardening_aide_enabled
|
||||||
98
ansible/roles/os_hardening/tasks/auditd.yml
Normal file
98
ansible/roles/os_hardening/tasks/auditd.yml
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
# CIS 4.1.x / DISA-STIG-aehnliche Audit-Regeln. Ueber die bereits vorhandene
|
||||||
|
# Ueberwachung des Jumphost-Datenverzeichnisses hinaus wird hier ein
|
||||||
|
# Standard-Ruleset fuer sicherheitsrelevante OS-Ereignisse ergaenzt:
|
||||||
|
# Identitaets-/Rechteaenderungen, privilegierte Kommandos, Zeit-/
|
||||||
|
# Netzwerkkonfigurationsaenderungen, Login-Ereignisse, Modulladen.
|
||||||
|
|
||||||
|
- name: auditd + audispd-plugins installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- auditd
|
||||||
|
- audispd-plugins
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Bestehende auditd-Regeln fuer das Jumphost-Datenverzeichnis
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/audit/rules.d/10-jumphost-app.rules
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
content: |
|
||||||
|
-w {{ jumphost_data_dir }}/jumphost.db -p wa -k jumphost_db
|
||||||
|
-w {{ jumphost_home }} -p wa -k jumphost_app_files
|
||||||
|
-w /etc/jumphost -p wa -k jumphost_config
|
||||||
|
notify: restart auditd
|
||||||
|
|
||||||
|
- name: Erweiterte CIS/STIG-Audit-Regeln fuer das Basissystem
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/audit/rules.d/20-cis-baseline.rules
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
content: |
|
||||||
|
# Identitaets-/Rechteaenderungen (CIS 4.1.4)
|
||||||
|
-w /etc/passwd -p wa -k identity
|
||||||
|
-w /etc/group -p wa -k identity
|
||||||
|
-w /etc/shadow -p wa -k identity
|
||||||
|
-w /etc/gshadow -p wa -k identity
|
||||||
|
-w /etc/sudoers -p wa -k identity
|
||||||
|
-w /etc/sudoers.d/ -p wa -k identity
|
||||||
|
|
||||||
|
# Sudo-Nutzung protokollieren (ergaenzt Defaults logfile in
|
||||||
|
# sudo_logging.yml um eine auditd-seitige, manipulationsresistentere Spur)
|
||||||
|
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid!=0 -F auid!=-1 -k privileged_sudo
|
||||||
|
|
||||||
|
# Zeitaenderungen (CIS 4.1.3)
|
||||||
|
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time_change
|
||||||
|
-w /etc/localtime -p wa -k time_change
|
||||||
|
|
||||||
|
# Netzwerkkonfiguration (CIS 4.1.7)
|
||||||
|
-w /etc/hosts -p wa -k network_config
|
||||||
|
-w /etc/network/ -p wa -k network_config
|
||||||
|
-w /etc/nftables.conf -p wa -k network_config
|
||||||
|
|
||||||
|
# Login/Logout-Ereignisse (CIS 4.1.5)
|
||||||
|
-w /var/log/faillog -p wa -k logins
|
||||||
|
-w /var/log/lastlog -p wa -k logins
|
||||||
|
-w /var/run/utmp -p wa -k session
|
||||||
|
-w /var/log/wtmp -p wa -k session
|
||||||
|
-w /var/log/btmp -p wa -k session
|
||||||
|
|
||||||
|
# Kernel-Modul-Laden/-Entladen (CIS 4.1.13)
|
||||||
|
-a always,exit -F arch=b64 -S init_module,delete_module -k kernel_modules
|
||||||
|
|
||||||
|
# SSH-Konfigurationsaenderungen des Jumphosts selbst
|
||||||
|
-w /etc/ssh/sshd_config -p wa -k sshd_config
|
||||||
|
-w /etc/ssh/sshd_config.d/ -p wa -k sshd_config
|
||||||
|
|
||||||
|
# Loeschungen durch Nutzer (CIS 4.1.14, exemplarisch fuer den eigenen UID-Bereich)
|
||||||
|
-a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat -F auid>=1000 -F auid!=-1 -k file_deletion
|
||||||
|
notify: restart auditd
|
||||||
|
|
||||||
|
- name: auditd-Regeln als unveraenderlich markieren (STIG, optional)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/audit/rules.d/99-immutable.rules
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
content: |
|
||||||
|
# ACHTUNG: nach Aktivierung sind Aenderungen an den Audit-Regeln erst
|
||||||
|
# nach einem Reboot wieder moeglich (auditctl -e 2 sperrt bis Neustart).
|
||||||
|
-e 2
|
||||||
|
when: os_hardening_auditd_immutable
|
||||||
|
notify: restart auditd
|
||||||
|
|
||||||
|
- name: auditd-Log-Rotation auf "keep_logs" setzen statt Ueberschreiben (CIS 4.1.2.3)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/audit/auditd.conf
|
||||||
|
regexp: '^max_log_file_action\s*='
|
||||||
|
line: "max_log_file_action = keep_logs"
|
||||||
|
notify: restart auditd
|
||||||
|
|
||||||
|
- name: auditd bei vollem Log-Speicher anhalten statt Ereignisse zu verwerfen (CIS 4.1.2.4)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/audit/auditd.conf
|
||||||
|
regexp: '^space_left_action\s*='
|
||||||
|
line: "space_left_action = email"
|
||||||
22
ansible/roles/os_hardening/tasks/banners.yml
Normal file
22
ansible/roles/os_hardening/tasks/banners.yml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
# STIG-typische Anmelde-Warnhinweise (rechtlich in vielen Organisationen
|
||||||
|
# vorgeschrieben, bevor Zugriff auf ein System gewaehrt wird).
|
||||||
|
|
||||||
|
- name: Anmelde-Banner setzen (/etc/issue, /etc/issue.net, /etc/motd)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ item }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: "{{ os_hardening_login_banner }}"
|
||||||
|
loop:
|
||||||
|
- /etc/issue
|
||||||
|
- /etc/issue.net
|
||||||
|
- /etc/motd
|
||||||
|
# Kein Serviceneustart noetig: /etc/issue* und /etc/motd werden bei
|
||||||
|
# jedem neuen Login/jeder neuen Verbindung frisch gelesen.
|
||||||
|
|
||||||
|
# Die "Banner /etc/issue.net"-Direktive fuer sshd wird zentral in sshd.yml
|
||||||
|
# gesetzt (dieselbe Datei /etc/ssh/sshd_config.d/99-jumphost-hardening.conf
|
||||||
|
# wird dort komplett -- inkl. Banner-Zeile -- verwaltet, um zwei Tasks mit
|
||||||
|
# widerspruechlichem "wer besitzt diese Datei" zu vermeiden).
|
||||||
36
ansible/roles/os_hardening/tasks/cron_at.yml
Normal file
36
ansible/roles/os_hardening/tasks/cron_at.yml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
# CIS 2.4.1.x: cron/at auf autorisierte Nutzer beschraenken.
|
||||||
|
|
||||||
|
- name: cron.deny/at.deny entfernen (deny-Listen sind fehleranfaelliger als allow-Listen)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop:
|
||||||
|
- /etc/cron.deny
|
||||||
|
- /etc/at.deny
|
||||||
|
|
||||||
|
- name: cron.allow / at.allow auf root und den Jumphost-Service-User beschraenken
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ item }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
content: |
|
||||||
|
root
|
||||||
|
loop:
|
||||||
|
- /etc/cron.allow
|
||||||
|
- /etc/at.allow
|
||||||
|
|
||||||
|
- name: Berechtigungen der cron-Verzeichnisse absichern (CIS 2.4.1.7-2.4.1.11)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
loop:
|
||||||
|
- /etc/cron.d
|
||||||
|
- /etc/cron.daily
|
||||||
|
- /etc/cron.hourly
|
||||||
|
- /etc/cron.monthly
|
||||||
|
- /etc/cron.weekly
|
||||||
|
ignore_errors: true # nicht jedes Basis-Image legt alle Verzeichnisse an
|
||||||
38
ansible/roles/os_hardening/tasks/file_permissions.yml
Normal file
38
ansible/roles/os_hardening/tasks/file_permissions.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
# CIS 6.1.x: Berechtigungen sicherheitskritischer Systemdateien; zusaetzlich
|
||||||
|
# manipulationssicheres sudo-Logging (ergaenzt die auditd-Regel
|
||||||
|
# "privileged_sudo" aus auditd.yml um ein menschenlesbares Log).
|
||||||
|
|
||||||
|
- name: Berechtigungen sicherheitskritischer Dateien absichern
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
owner: root
|
||||||
|
group: "{{ item.group }}"
|
||||||
|
mode: "{{ item.mode }}"
|
||||||
|
loop:
|
||||||
|
- { path: /etc/passwd, group: root, mode: "0644" }
|
||||||
|
- { path: /etc/group, group: root, mode: "0644" }
|
||||||
|
- { path: /etc/shadow, group: shadow, mode: "0640" }
|
||||||
|
- { path: /etc/gshadow, group: shadow, mode: "0640" }
|
||||||
|
- { path: /etc/ssh/sshd_config, group: root, mode: "0600" }
|
||||||
|
ignore_errors: true # z.B. wenn die shadow-Gruppe distributionsabhaengig anders heisst
|
||||||
|
|
||||||
|
- name: Eigenstaendiges sudo-Logfile aktivieren
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/sudoers.d/99-jumphost-logging
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0440"
|
||||||
|
validate: "visudo -cf %s"
|
||||||
|
content: |
|
||||||
|
Defaults logfile="/var/log/sudo.log"
|
||||||
|
Defaults log_input, log_output
|
||||||
|
Defaults use_pty
|
||||||
|
Defaults passwd_tries=3
|
||||||
|
|
||||||
|
- name: su-Kommando auf die Gruppe "sudo" beschraenken (CIS 5.6)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/pam.d/su
|
||||||
|
regexp: '^#?\s*auth\s+required\s+pam_wheel\.so'
|
||||||
|
line: "auth required pam_wheel.so use_uid group=sudo"
|
||||||
|
insertafter: '^# Uncomment this'
|
||||||
41
ansible/roles/os_hardening/tasks/kernel_modules.yml
Normal file
41
ansible/roles/os_hardening/tasks/kernel_modules.yml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
# CIS 1.1.1.x: selten benoetigte Dateisysteme und exotische Netzwerk-
|
||||||
|
# protokolle per modprobe-Blacklist deaktivieren. Ein dedizierter Jumphost
|
||||||
|
# braucht keines davon; jedes deaktivierte Modul ist Angriffsflaeche weniger
|
||||||
|
# (u.a. relevant fuer angeschlossene Wechseldatentraeger und historische
|
||||||
|
# Kernel-CVEs in selten gepflegten Dateisystemtreibern).
|
||||||
|
|
||||||
|
- name: Kernelmodule fuer seltene Dateisysteme/Protokolle blacklisten
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/modprobe.d/jumphost-blacklist.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
# CIS 1.1.1.1 - 1.1.1.8 (sinngemaess): unbenoetigte Dateisysteme
|
||||||
|
install cramfs /bin/false
|
||||||
|
install freevxfs /bin/false
|
||||||
|
install jffs2 /bin/false
|
||||||
|
install hfs /bin/false
|
||||||
|
install hfsplus /bin/false
|
||||||
|
install udf /bin/false
|
||||||
|
install squashfs /bin/false
|
||||||
|
# CIS 3.4.x (sinngemaess): unbenoetigte/seltene Netzwerkprotokolle
|
||||||
|
install dccp /bin/false
|
||||||
|
install sctp /bin/false
|
||||||
|
install rds /bin/false
|
||||||
|
install tipc /bin/false
|
||||||
|
notify: update initramfs
|
||||||
|
|
||||||
|
- name: USB-Speichermedien deaktivieren (CIS 1.1.23, sinngemaess)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/modprobe.d/jumphost-usb-storage.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
# Ein Jumphost sollte keine lokal angeschlossenen USB-Speichermedien
|
||||||
|
# einbinden muessen -- Dateitransfer laeuft ausschliesslich ueber die
|
||||||
|
# Anwendung (SFTP/RDP-Laufwerksumleitung), siehe Konzept 4.3.
|
||||||
|
install usb-storage /bin/false
|
||||||
|
notify: update initramfs
|
||||||
47
ansible/roles/os_hardening/tasks/main.yml
Normal file
47
ansible/roles/os_hardening/tasks/main.yml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
# Betriebssystem-Haertung fuer den Jumphost-Server selbst (Konzept 6.7),
|
||||||
|
# vertieft auf CIS/STIG-nahes Niveau. Siehe CIS_STIG_MAPPING.md in diesem
|
||||||
|
# Rollenverzeichnis fuer die Zuordnung der einzelnen Tasks zu konkreten
|
||||||
|
# Benchmark-Controls sowie fuer alles, was bewusst NICHT automatisiert wird
|
||||||
|
# (mit Begruendung, z.B. Partitionslayout, Bootloader-Passwort, physische
|
||||||
|
# Sicherheit).
|
||||||
|
#
|
||||||
|
# Kein Ersatz fuer ein vollstaendiges CIS/STIG-Auditwerkzeug (z.B. OpenSCAP)
|
||||||
|
# -- jeder produktive Rollout sollte zusaetzlich mit `oscap xccdf eval`
|
||||||
|
# gegen das jeweilige Benchmark-Profil verifiziert werden (siehe README).
|
||||||
|
|
||||||
|
- name: Pakete & automatische Updates
|
||||||
|
ansible.builtin.import_tasks: packages.yml
|
||||||
|
|
||||||
|
- name: Kernelmodule (Dateisysteme/Protokolle) einschraenken
|
||||||
|
ansible.builtin.import_tasks: kernel_modules.yml
|
||||||
|
|
||||||
|
- name: sysctl- und Core-Dump-Haertung
|
||||||
|
ansible.builtin.import_tasks: sysctl.yml
|
||||||
|
|
||||||
|
- name: Mount-Optionen (/tmp, /dev/shm)
|
||||||
|
ansible.builtin.import_tasks: mounts.yml
|
||||||
|
|
||||||
|
- name: PAM-/Passwort-Policy fuer lokale OS-Konten
|
||||||
|
ansible.builtin.import_tasks: pam_password_policy.yml
|
||||||
|
|
||||||
|
- name: Erweiterte auditd-Regeln
|
||||||
|
ansible.builtin.import_tasks: auditd.yml
|
||||||
|
|
||||||
|
- name: AIDE-Dateiintegritaets-Monitoring
|
||||||
|
ansible.builtin.import_tasks: aide.yml
|
||||||
|
|
||||||
|
- name: rkhunter-Rootkit-Scanner
|
||||||
|
ansible.builtin.import_tasks: rootkit_scan.yml
|
||||||
|
|
||||||
|
- name: Anmelde-Banner
|
||||||
|
ansible.builtin.import_tasks: banners.yml
|
||||||
|
|
||||||
|
- name: cron/at auf autorisierte Nutzer beschraenken
|
||||||
|
ansible.builtin.import_tasks: cron_at.yml
|
||||||
|
|
||||||
|
- name: Dateirechte & sudo-Logging
|
||||||
|
ansible.builtin.import_tasks: file_permissions.yml
|
||||||
|
|
||||||
|
- name: SSH-Daemon des Jumphosts haerten
|
||||||
|
ansible.builtin.import_tasks: sshd.yml
|
||||||
48
ansible/roles/os_hardening/tasks/mounts.yml
Normal file
48
ansible/roles/os_hardening/tasks/mounts.yml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
# CIS 1.1.2.x (nodev/nosuid/noexec auf /tmp, /dev/shm). Best-Effort: nur
|
||||||
|
# wirksam, wenn diese Pfade BEREITS eigene Mountpoints sind. Ob das der Fall
|
||||||
|
# ist, haengt vom Partitionslayout des Basis-Images ab -- siehe
|
||||||
|
# CIS_STIG_MAPPING.md fuer den Hinweis, dass ein vollstaendig CIS-konformes
|
||||||
|
# Partitionslayout (separate /tmp, /var, /var/log, /var/log/audit, /home)
|
||||||
|
# eine bewusste Entscheidung bei der OS-Installation ist und nicht nachtraeglich
|
||||||
|
# per Ansible auf ein bestehendes System aufgepraegt werden kann, ohne die
|
||||||
|
# Platte neu zu partitionieren.
|
||||||
|
|
||||||
|
- name: Pruefen, ob /tmp ein eigener Mountpoint ist
|
||||||
|
ansible.builtin.command: findmnt --noheadings --output SOURCE /tmp
|
||||||
|
register: _tmp_mount
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: /tmp mit noexec,nosuid,nodev in /etc/fstab absichern (falls eigener Mountpoint)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/fstab
|
||||||
|
regexp: '^\S+\s+/tmp\s+'
|
||||||
|
line: "{{ _tmp_mount.stdout }} /tmp tmpfs defaults,noexec,nosuid,nodev 0 0"
|
||||||
|
backup: true
|
||||||
|
when: os_hardening_restrict_tmp_mounts and _tmp_mount.rc == 0 and _tmp_mount.stdout != ''
|
||||||
|
notify: remount tmp
|
||||||
|
|
||||||
|
- name: Pruefen, ob /dev/shm ein eigener Mountpoint ist
|
||||||
|
ansible.builtin.command: findmnt --noheadings --output SOURCE /dev/shm
|
||||||
|
register: _shm_mount
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: /dev/shm mit noexec,nosuid,nodev in /etc/fstab absichern (falls eigener Mountpoint)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/fstab
|
||||||
|
regexp: '^\S+\s+/dev/shm\s+'
|
||||||
|
line: "{{ _shm_mount.stdout }} /dev/shm tmpfs defaults,noexec,nosuid,nodev 0 0"
|
||||||
|
backup: true
|
||||||
|
when: os_hardening_restrict_tmp_mounts and _shm_mount.rc == 0 and _shm_mount.stdout != ''
|
||||||
|
notify: remount shm
|
||||||
|
|
||||||
|
- name: Hinweis, falls /tmp kein eigener Mountpoint ist
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >
|
||||||
|
/tmp ist kein eigener Mountpoint auf diesem System -- noexec/nosuid/nodev
|
||||||
|
koennen so nicht erzwungen werden. Fuer volle CIS-Konformitaet muesste
|
||||||
|
/tmp bei der OS-Installation als eigene Partition/eigenes tmpfs angelegt
|
||||||
|
werden (siehe CIS_STIG_MAPPING.md).
|
||||||
|
when: os_hardening_restrict_tmp_mounts and (_tmp_mount.rc != 0 or _tmp_mount.stdout == '')
|
||||||
33
ansible/roles/os_hardening/tasks/packages.yml
Normal file
33
ansible/roles/os_hardening/tasks/packages.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
# CIS 2.x (Services) / STIG-aequivalent: unsichere Legacy-Dienste/-Clients
|
||||||
|
# entfernen, automatische Sicherheitsupdates aktivieren.
|
||||||
|
|
||||||
|
- name: Unnoetige/unsichere Pakete entfernen
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
purge: true
|
||||||
|
loop:
|
||||||
|
- telnet
|
||||||
|
- rsh-client
|
||||||
|
- talk
|
||||||
|
- nis # ypbind etc. -- veraltete, unverschluesselte Netzwerkdienste
|
||||||
|
- tftpd-hpa
|
||||||
|
- xinetd
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: Automatische Sicherheitsupdates installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: unattended-upgrades
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Unattended-upgrades aktivieren
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
APT::Periodic::Update-Package-Lists "1";
|
||||||
|
APT::Periodic::Unattended-Upgrade "1";
|
||||||
83
ansible/roles/os_hardening/tasks/pam_password_policy.yml
Normal file
83
ansible/roles/os_hardening/tasks/pam_password_policy.yml
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
# CIS 5.3.x / 5.4.x: Passwortqualitaet, Account-Lockout und Ablaufregeln fuer
|
||||||
|
# LOKALE OS-Konten auf dem Jumphost selbst (Admin-SSH-Zugang zum Jumphost-
|
||||||
|
# Server, siehe ssh_admin_access_cidr in group_vars/all.yml).
|
||||||
|
#
|
||||||
|
# WICHTIG -- Abgrenzung: Dies ist NICHT identisch mit der Argon2id/TOTP-
|
||||||
|
# Pflicht der Jumphost-WEBANWENDUNG (siehe app/security/passwords.py,
|
||||||
|
# app/security/totp.py, app/auth/routes.py). Es handelt sich um zwei
|
||||||
|
# getrennte Konten-/Auth-Systeme: die App verwaltet ihre eigenen Nutzer in
|
||||||
|
# SQLite, waehrend hier die BS-Konten der Administratoren gehaertet werden,
|
||||||
|
# die sich per SSH auf den Jumphost-Server selbst einloggen (z.B. fuer
|
||||||
|
# Wartung, Deployment, Log-Einsicht).
|
||||||
|
|
||||||
|
- name: libpam-pwquality installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: libpam-pwquality
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Passwortqualitaets-Policy setzen (CIS 5.4.1)
|
||||||
|
# Direkt in pwquality.conf statt eines conf.d-Snippets, da nicht jede
|
||||||
|
# Distributionsversion von libpam-pwquality ein conf.d-Verzeichnis
|
||||||
|
# unterstuetzt -- pwquality.conf selbst wird ueberall gelesen.
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/security/pwquality.conf
|
||||||
|
regexp: "^#?\\s*{{ item.key }}\\s*="
|
||||||
|
line: "{{ item.key }} = {{ item.value }}"
|
||||||
|
create: true
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop:
|
||||||
|
- { key: "minlen", value: "{{ os_hardening_password_min_length }}" }
|
||||||
|
- { key: "dcredit", value: "-1" }
|
||||||
|
- { key: "ucredit", value: "-1" }
|
||||||
|
- { key: "ocredit", value: "-1" }
|
||||||
|
- { key: "lcredit", value: "-1" }
|
||||||
|
- { key: "retry", value: "3" }
|
||||||
|
# Kein Service-Neustart noetig: PAM liest die Datei bei jeder neuen
|
||||||
|
# Authentifizierung, kein laufender Daemon haelt sie offen.
|
||||||
|
|
||||||
|
- name: Passwortqualitaet auch fuer root erzwingen
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/security/pwquality.conf
|
||||||
|
regexp: "^#?\\s*enforce_for_root"
|
||||||
|
line: "enforce_for_root"
|
||||||
|
create: true
|
||||||
|
|
||||||
|
- name: pam_faillock fuer Login-Lockout aktivieren (CIS 5.3.1)
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: /etc/pam.d/common-auth
|
||||||
|
marker: "# {mark} ANSIBLE MANAGED BLOCK (jumphost pam_faillock)"
|
||||||
|
insertbefore: "^auth\\s+\\[success=1"
|
||||||
|
block: |
|
||||||
|
auth required pam_faillock.so preauth silent deny={{ os_hardening_faillock_deny }} unlock_time={{ os_hardening_faillock_unlock_time }}
|
||||||
|
auth [success=1 default=ignore] pam_unix.so nullok
|
||||||
|
auth [default=die] pam_faillock.so authfail deny={{ os_hardening_faillock_deny }} unlock_time={{ os_hardening_faillock_unlock_time }}
|
||||||
|
auth sufficient pam_faillock.so authsucc deny={{ os_hardening_faillock_deny }} unlock_time={{ os_hardening_faillock_unlock_time }}
|
||||||
|
# Hinweis: pam-auth-update-verwaltete Systeme (Debian/Ubuntu-Standard)
|
||||||
|
# ueberschreiben common-auth ggf. bei "pam-auth-update --force". Fuer
|
||||||
|
# produktive Systeme ist die Nutzung eines eigenen pam-auth-update-Profils
|
||||||
|
# (/usr/share/pam-configs/jumphost-faillock) die sauberere, upgrade-feste
|
||||||
|
# Alternative -- hier aus Uebersichtlichkeitsgruenden als direkter Block-
|
||||||
|
# Insert gehalten und im Mapping-Dokument als bekannte Einschraenkung vermerkt.
|
||||||
|
|
||||||
|
- name: Passwort-Ablaufregeln in /etc/login.defs setzen (CIS 5.4.1.1-5.4.1.4)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/login.defs
|
||||||
|
regexp: "^{{ item.key }}\\s"
|
||||||
|
line: "{{ item.key }} {{ item.value }}"
|
||||||
|
loop:
|
||||||
|
- { key: "PASS_MAX_DAYS", value: "90" }
|
||||||
|
- { key: "PASS_MIN_DAYS", value: "7" }
|
||||||
|
- { key: "PASS_WARN_AGE", value: "14" }
|
||||||
|
- { key: "UMASK", value: "027" }
|
||||||
|
- { key: "ENCRYPT_METHOD", value: "SHA512" }
|
||||||
|
|
||||||
|
- name: Passwort-Historie (pam_pwhistory) aktivieren (CIS 5.4.2)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/pam.d/common-password
|
||||||
|
regexp: '^password\s+requisite\s+pam_pwhistory\.so'
|
||||||
|
insertafter: '^password\s+requisite\s+pam_pwquality\.so'
|
||||||
|
line: "password requisite pam_pwhistory.so remember={{ os_hardening_password_remember }} use_authtok"
|
||||||
24
ansible/roles/os_hardening/tasks/rootkit_scan.yml
Normal file
24
ansible/roles/os_hardening/tasks/rootkit_scan.yml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
# Ergaenzender Rootkit-/Anomalie-Scanner (rkhunter). Kein Ersatz fuer AIDE
|
||||||
|
# (dateibasierte Integritaet) oder auditd (Ereignisprotokoll), sondern eine
|
||||||
|
# dritte, unabhaengige Kontrollschicht mit eigener Signaturheuristik.
|
||||||
|
|
||||||
|
- name: rkhunter installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: rkhunter
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
when: os_hardening_rkhunter_enabled
|
||||||
|
|
||||||
|
- name: rkhunter-Property-Datenbank initialisieren
|
||||||
|
ansible.builtin.command: rkhunter --propupd
|
||||||
|
when: os_hardening_rkhunter_enabled
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Woechentlichen rkhunter-Check per systemd-Timer aktivieren (Debian-Paket-Default nutzen)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/default/rkhunter
|
||||||
|
regexp: '^CRON_DAILY_RUN='
|
||||||
|
line: 'CRON_DAILY_RUN="true"'
|
||||||
|
create: true
|
||||||
|
when: os_hardening_rkhunter_enabled
|
||||||
32
ansible/roles/os_hardening/tasks/sshd.yml
Normal file
32
ansible/roles/os_hardening/tasks/sshd.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
# SSH-Daemon des Jumphosts SELBST (administrativer Zugriff auf den Server) --
|
||||||
|
# nicht zu verwechseln mit der SSH-Proxy-Funktion der Anwendung (app/ssh_proxy),
|
||||||
|
# die eigene, unabhaengige Verbindungen zu den Zielsystemen aufbaut.
|
||||||
|
# CIS 5.2.x.
|
||||||
|
|
||||||
|
- name: SSH-Daemon des Jumphosts haerten (vollstaendige Konfiguration inkl. Banner)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/ssh/sshd_config.d/99-jumphost-hardening.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
PermitRootLogin no
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
PermitEmptyPasswords no
|
||||||
|
X11Forwarding no
|
||||||
|
AllowTcpForwarding no
|
||||||
|
AllowAgentForwarding no
|
||||||
|
PermitTunnel no
|
||||||
|
MaxAuthTries 3
|
||||||
|
MaxSessions 4
|
||||||
|
LoginGraceTime 20
|
||||||
|
ClientAliveInterval 300
|
||||||
|
ClientAliveCountMax 2
|
||||||
|
Banner /etc/issue.net
|
||||||
|
LogLevel VERBOSE
|
||||||
|
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
|
||||||
|
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
|
||||||
|
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
|
||||||
|
notify: restart sshd
|
||||||
58
ansible/roles/os_hardening/tasks/sysctl.yml
Normal file
58
ansible/roles/os_hardening/tasks/sysctl.yml
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
# CIS 3.x (Netzwerk) / 1.5.x (Kernel-Haertung). Bewusst ueber
|
||||||
|
# ansible.builtin.copy + "sysctl --system" statt des ansible.posix.sysctl-
|
||||||
|
# Moduls (siehe Kommentar in Kap. 7/README) -- keine Zusatz-Collection als
|
||||||
|
# Voraussetzung fuer den Betrieb dieses Playbooks.
|
||||||
|
|
||||||
|
- name: Kernel-/Netzwerk-Haertung (sysctl)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/sysctl.d/99-jumphost-hardening.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
# --- Netzwerk (CIS 3.x) ---
|
||||||
|
net.ipv4.conf.all.rp_filter = 1
|
||||||
|
net.ipv4.conf.default.rp_filter = 1
|
||||||
|
net.ipv4.conf.all.accept_redirects = 0
|
||||||
|
net.ipv4.conf.default.accept_redirects = 0
|
||||||
|
net.ipv4.conf.all.secure_redirects = 0
|
||||||
|
net.ipv4.conf.default.secure_redirects = 0
|
||||||
|
net.ipv4.conf.all.send_redirects = 0
|
||||||
|
net.ipv4.conf.default.send_redirects = 0
|
||||||
|
net.ipv4.conf.all.accept_source_route = 0
|
||||||
|
net.ipv4.conf.default.accept_source_route = 0
|
||||||
|
net.ipv4.conf.all.log_martians = 1
|
||||||
|
net.ipv4.conf.default.log_martians = 1
|
||||||
|
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||||
|
net.ipv4.icmp_ignore_bogus_error_responses = 1
|
||||||
|
net.ipv4.tcp_syncookies = 1
|
||||||
|
net.ipv4.ip_forward = 0
|
||||||
|
net.ipv6.conf.all.accept_redirects = 0
|
||||||
|
net.ipv6.conf.default.accept_redirects = 0
|
||||||
|
net.ipv6.conf.all.accept_source_route = 0
|
||||||
|
net.ipv6.conf.default.accept_source_route = 0
|
||||||
|
{% if os_hardening_disable_ipv6 %}
|
||||||
|
net.ipv6.conf.all.disable_ipv6 = 1
|
||||||
|
net.ipv6.conf.default.disable_ipv6 = 1
|
||||||
|
{% endif %}
|
||||||
|
# --- Kernel-Haertung (CIS 1.5.x) ---
|
||||||
|
kernel.kptr_restrict = 2
|
||||||
|
kernel.dmesg_restrict = 1
|
||||||
|
kernel.randomize_va_space = 2
|
||||||
|
kernel.yama.ptrace_scope = 1
|
||||||
|
fs.suid_dumpable = 0
|
||||||
|
fs.protected_hardlinks = 1
|
||||||
|
fs.protected_symlinks = 1
|
||||||
|
fs.protected_fifos = 2
|
||||||
|
fs.protected_regular = 2
|
||||||
|
notify: apply sysctl
|
||||||
|
|
||||||
|
- name: Core Dumps zusaetzlich auf PAM-Ebene deaktivieren (CIS 1.5.1)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/security/limits.d/99-jumphost-no-coredumps.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
* hard core 0
|
||||||
8
ansible/roles/python_runtime/handlers/main.yml
Normal file
8
ansible/roles/python_runtime/handlers/main.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
- name: restart jumphost-app
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: jumphost-app
|
||||||
|
state: restarted
|
||||||
|
# Handler laeuft ggf. bevor der Service durch jumphost_app angelegt wurde;
|
||||||
|
# daher failed_when: false beim allerersten Deploy.
|
||||||
|
failed_when: false
|
||||||
93
ansible/roles/python_runtime/tasks/main.yml
Normal file
93
ansible/roles/python_runtime/tasks/main.yml
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
- name: Systembenutzer fuer die Jumphost-App anlegen
|
||||||
|
ansible.builtin.group:
|
||||||
|
name: "{{ jumphost_app_group }}"
|
||||||
|
system: true
|
||||||
|
|
||||||
|
- name: Systembenutzer anlegen (kein Login-Shell, kein Home mit Zugriff fuer andere)
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ jumphost_app_user }}"
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
system: true
|
||||||
|
shell: /usr/sbin/nologin
|
||||||
|
home: "{{ jumphost_home }}"
|
||||||
|
create_home: false
|
||||||
|
|
||||||
|
- name: Python 3 + venv-Paket installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- python3
|
||||||
|
- python3-venv
|
||||||
|
- python3-pip
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Verzeichnisse anlegen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ jumphost_app_user }}"
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- "{{ jumphost_home }}"
|
||||||
|
- "{{ jumphost_data_dir }}"
|
||||||
|
- "{{ jumphost_data_dir }}/recordings"
|
||||||
|
- /run/jumphost
|
||||||
|
- /var/log/jumphost
|
||||||
|
|
||||||
|
- name: Alten Anwendungscode entfernen (sauberes Redeploy)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ jumphost_home }}/{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop:
|
||||||
|
- app
|
||||||
|
- static
|
||||||
|
- templates
|
||||||
|
|
||||||
|
- name: Anwendungscode kopieren
|
||||||
|
# Bewusst ueber ansible.builtin.copy statt ansible.posix.synchronize, damit
|
||||||
|
# das Playbook ohne zusaetzliche Collection auskommt und auch ohne rsync
|
||||||
|
# auf Control-Node/Zielsystem funktioniert (Konzept-Anspruch: minimale
|
||||||
|
# externe Abhaengigkeiten fuer den Deploy-Pfad selbst).
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ jumphost_repo_src }}/{{ item }}/"
|
||||||
|
dest: "{{ jumphost_home }}/{{ item }}/"
|
||||||
|
owner: "{{ jumphost_app_user }}"
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
loop:
|
||||||
|
- app
|
||||||
|
- static
|
||||||
|
- templates
|
||||||
|
notify: restart jumphost-app
|
||||||
|
|
||||||
|
- name: requirements.txt kopieren
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ jumphost_repo_src }}/requirements.txt"
|
||||||
|
dest: "{{ jumphost_home }}/requirements.txt"
|
||||||
|
owner: "{{ jumphost_app_user }}"
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
notify: restart jumphost-app
|
||||||
|
|
||||||
|
- name: Dateirechte auf Anwendungscode setzen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ jumphost_home }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ jumphost_app_user }}"
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
recurse: true
|
||||||
|
|
||||||
|
- name: Virtualenv anlegen
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "python3 -m venv {{ jumphost_venv }}"
|
||||||
|
creates: "{{ jumphost_venv }}/bin/python"
|
||||||
|
become: true
|
||||||
|
become_user: "{{ jumphost_app_user }}"
|
||||||
|
|
||||||
|
- name: Python-Abhaengigkeiten installieren
|
||||||
|
ansible.builtin.pip:
|
||||||
|
requirements: "{{ jumphost_home }}/requirements.txt"
|
||||||
|
virtualenv: "{{ jumphost_venv }}"
|
||||||
|
become: true
|
||||||
|
become_user: "{{ jumphost_app_user }}"
|
||||||
|
notify: restart jumphost-app
|
||||||
25
ansible/roles/sqlite_init/tasks/main.yml
Normal file
25
ansible/roles/sqlite_init/tasks/main.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
# Die Migrationen laufen bereits automatisch beim App-Start (app/db.py:init_db).
|
||||||
|
# Diese Rolle stellt lediglich sicher, dass Verzeichnis/Rechte VOR dem ersten
|
||||||
|
# Start korrekt sind und legt optional den initialen Admin-User an.
|
||||||
|
|
||||||
|
- name: Datenverzeichnis-Rechte sicherstellen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ jumphost_data_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ jumphost_app_user }}"
|
||||||
|
group: "{{ jumphost_app_group }}"
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Pruefen, ob bereits eine DB existiert
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ jumphost_data_dir }}/jumphost.db"
|
||||||
|
register: _db_stat
|
||||||
|
|
||||||
|
- name: Hinweis fuer manuellen Erstadmin-Anlegeschritt ausgeben
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >
|
||||||
|
Keine bestehende Datenbank gefunden. Nach dem ersten Start des Dienstes
|
||||||
|
(Rolle jumphost_app) einmalig ausfuehren:
|
||||||
|
{{ jumphost_venv }}/bin/python {{ jumphost_home }}/scripts/create_admin.py --username admin
|
||||||
|
when: not _db_stat.stat.exists
|
||||||
85
ansible/roles/tls_certificates/tasks/main.yml
Normal file
85
ansible/roles/tls_certificates/tasks/main.yml
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
# Drei TLS-Modi (Konzept 7.2a), gesteuert ueber tls_mode. Ergebnis ist in
|
||||||
|
# allen Faellen identisch: /etc/jumphost/tls/server.{crt,key} bzw. bei
|
||||||
|
# external_reverse_proxy zusaetzlich das interne Re-Encryption-Zertifikat
|
||||||
|
# fuer nginx unter demselben Pfad (nginx_proxy-Rolle nutzt diese Dateien).
|
||||||
|
|
||||||
|
- name: TLS-Verzeichnis anlegen
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/jumphost/tls
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: "{{ jumphost_app_group if not enable_nginx_proxy else 'root' }}"
|
||||||
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: "Modus internal_pki / external_reverse_proxy: Zertifikat per ACME gegen interne CA beziehen"
|
||||||
|
block:
|
||||||
|
- name: step-ca ACME-Client (step-cli) installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: step-cli
|
||||||
|
state: present
|
||||||
|
ignore_errors: true # Paket ist nicht in allen Distros verfuegbar, siehe Fallback unten
|
||||||
|
|
||||||
|
- name: Hinweis auf manuellen/alternativen Cert-Bezug
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >
|
||||||
|
Falls step-cli nicht verfuegbar ist: Zertifikat/Key manuell aus der
|
||||||
|
Unternehmens-PKI beziehen und ALS ansible-vault-verschluesselte
|
||||||
|
Dateien unter files/tls/{{ inventory_hostname }}.crt / .key ablegen;
|
||||||
|
diese Rolle kopiert sie dann per copy-Task (siehe unten, auskommentiert).
|
||||||
|
when: tls_mode in ['internal_pki', 'external_reverse_proxy']
|
||||||
|
|
||||||
|
# Alternative zu ACME: vorab per Unternehmens-PKI ausgestellte Dateien einspielen.
|
||||||
|
# - name: Vorab ausgestelltes internes Zertifikat einspielen
|
||||||
|
# ansible.builtin.copy:
|
||||||
|
# src: "files/tls/{{ inventory_hostname }}.crt"
|
||||||
|
# dest: /etc/jumphost/tls/server.crt
|
||||||
|
# when: tls_mode in ['internal_pki', 'external_reverse_proxy']
|
||||||
|
|
||||||
|
- name: "Modus acme_public: Certbot fuer oeffentliches Let's-Encrypt-Zertifikat"
|
||||||
|
block:
|
||||||
|
- name: certbot installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: certbot
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Zertifikat beziehen (nginx-Plugin, HTTP-01)
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >
|
||||||
|
certbot certonly --nginx --non-interactive --agree-tos
|
||||||
|
-m {{ acme_email }} -d {{ acme_domain }}
|
||||||
|
creates: "/etc/letsencrypt/live/{{ acme_domain }}/fullchain.pem"
|
||||||
|
when: enable_nginx_proxy
|
||||||
|
|
||||||
|
- name: Symlinks fuer einheitlichen Pfad anlegen
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: "/etc/letsencrypt/live/{{ acme_domain }}/{{ item.src }}"
|
||||||
|
dest: "/etc/jumphost/tls/{{ item.dest }}"
|
||||||
|
state: link
|
||||||
|
loop:
|
||||||
|
- { src: fullchain.pem, dest: server.crt }
|
||||||
|
- { src: privkey.pem, dest: server.key }
|
||||||
|
|
||||||
|
- name: Certbot-Renew-Timer aktivieren
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: certbot.timer
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
when: tls_mode == 'acme_public'
|
||||||
|
|
||||||
|
- name: Warnung bei Klartext-internem Hop protokollieren (bewusste Ausnahme, Konzept 7.2a)
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >
|
||||||
|
ACHTUNG: internal_hop_plaintext_accepted=true gesetzt -- die Strecke
|
||||||
|
zwischen externem Reverse Proxy und diesem Jumphost laeuft unverschluesselt.
|
||||||
|
Dies ist NICHT die Standardempfehlung und erfordert eine dokumentierte
|
||||||
|
Risikoakzeptanz (siehe Konzept 7.2a).
|
||||||
|
when: tls_mode == 'external_reverse_proxy' and internal_hop_plaintext_accepted | default(false)
|
||||||
|
|
||||||
|
- name: Zertifikatsdateien-Rechte absichern
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/jumphost/tls
|
||||||
|
owner: root
|
||||||
|
group: "{{ jumphost_app_group if not enable_nginx_proxy else 'root' }}"
|
||||||
|
mode: "0750"
|
||||||
|
recurse: true
|
||||||
23
ansible/site.yml
Normal file
23
ansible/site.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
# Jumphost-Deployment. Siehe Konzeptdokument Kap. 7 fuer Hintergrund.
|
||||||
|
#
|
||||||
|
# Steuerung ueber group_vars:
|
||||||
|
# enable_nginx_proxy: true|false (Kap. 7.2)
|
||||||
|
# tls_mode: internal_pki | external_reverse_proxy | acme_public (Kap. 7.2a)
|
||||||
|
|
||||||
|
- hosts: jumphosts
|
||||||
|
become: true
|
||||||
|
vars_files:
|
||||||
|
- inventory/group_vars/all.yml
|
||||||
|
roles:
|
||||||
|
- os_hardening
|
||||||
|
- firewall_nftables
|
||||||
|
- fail2ban
|
||||||
|
- frontend_assets # baut static/js/vendor/* lokal VOR dem Sync in python_runtime
|
||||||
|
- python_runtime
|
||||||
|
- sqlite_init
|
||||||
|
- guacd
|
||||||
|
- jumphost_app
|
||||||
|
- tls_certificates
|
||||||
|
- { role: nginx_proxy, when: enable_nginx_proxy | default(false) }
|
||||||
|
- backup
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/admin/__init__.py
Normal file
0
app/admin/__init__.py
Normal file
340
app/admin/routes.py
Normal file
340
app/admin/routes.py
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
"""
|
||||||
|
Administrative CRUD-API: User, Hostgruppen, Hosts, Rollenvergabe, SSH-Keys.
|
||||||
|
|
||||||
|
Alle zustandsaendernden Endpunkte sind auf globale Admins beschraenkt
|
||||||
|
(`require_global_admin`) und schreiben einen Audit-Log-Eintrag (Konzept 4.7).
|
||||||
|
Eine feingranulare, auf `admin_hostgroup` beschraenkte Admin-Rolle ist im
|
||||||
|
Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht
|
||||||
|
vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, require_global_admin
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models.schemas import (
|
||||||
|
HostCreateRequest,
|
||||||
|
HostGroupCreateRequest,
|
||||||
|
RdpCredentialsRequest,
|
||||||
|
RoleGrantRequest,
|
||||||
|
SshKeyCreateRequest,
|
||||||
|
UserCreateRequest,
|
||||||
|
)
|
||||||
|
from app.security.audit import verify_chain, write_audit_event
|
||||||
|
from app.security.crypto import encrypt_secret
|
||||||
|
from app.security.passwords import hash_password
|
||||||
|
from app.ssh_proxy.proxy import discover_and_store_host_key
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Users -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/users", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
payload: UserCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (payload.username,))
|
||||||
|
if await cursor.fetchone() is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits")
|
||||||
|
|
||||||
|
pw_hash = hash_password(payload.initial_password)
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||||
|
"VALUES (?, ?, ?, 1)",
|
||||||
|
(payload.username, pw_hash, int(payload.is_admin)),
|
||||||
|
)
|
||||||
|
new_id = cursor.lastrowid
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="user_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"new_user_id": new_id, "username": payload.username, "is_admin": payload.is_admin},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": new_id, "username": payload.username}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users(admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, username, is_admin, is_active, totp_enrolled, created_at FROM users ORDER BY id"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0], "username": r[1], "is_admin": bool(r[2]), "is_active": bool(r[3]),
|
||||||
|
"totp_enrolled": bool(r[4]), "created_at": r[5],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/deactivate")
|
||||||
|
async def deactivate_user(
|
||||||
|
user_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET is_active = 0, session_version = session_version + 1 WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="user_deactivated", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"target_user_id": user_id},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hostgruppen -------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/host-groups", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_host_group(
|
||||||
|
payload: HostGroupCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO host_groups (name, description) VALUES (?, ?)",
|
||||||
|
(payload.name, payload.description),
|
||||||
|
)
|
||||||
|
new_id = cursor.lastrowid
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="host_group_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"id": new_id, "name": payload.name},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": new_id, "name": payload.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/host-groups")
|
||||||
|
async def list_host_groups(admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [{"id": r[0], "name": r[1], "description": r[2]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hosts -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/hosts", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_host(
|
||||||
|
payload: HostCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO hosts (
|
||||||
|
host_group_id, hostname, address, protocol, port, os_type,
|
||||||
|
ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain,
|
||||||
|
rdp_require_nla, clipboard_enabled, file_transfer_enabled
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payload.host_group_id, payload.hostname, payload.address, payload.protocol,
|
||||||
|
payload.port, payload.os_type, payload.ssh_host_key_fingerprint,
|
||||||
|
payload.ssh_username, payload.rdp_username, payload.rdp_domain,
|
||||||
|
int(payload.rdp_require_nla), int(payload.clipboard_enabled),
|
||||||
|
int(payload.file_transfer_enabled),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
new_id = cursor.lastrowid
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="host_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"id": new_id, "hostname": payload.hostname, "protocol": payload.protocol},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": new_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hosts")
|
||||||
|
async def list_hosts(host_group_id: int | None = None, admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
conn = get_db()
|
||||||
|
if host_group_id is not None:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, hostname, address, protocol, port, os_type, host_group_id "
|
||||||
|
"FROM hosts WHERE host_group_id = ? ORDER BY id",
|
||||||
|
(host_group_id,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, hostname, address, protocol, port, os_type, host_group_id FROM hosts ORDER BY id"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0], "hostname": r[1], "address": r[2], "protocol": r[3],
|
||||||
|
"port": r[4], "os_type": r[5], "host_group_id": r[6],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hosts/{host_id}/discover-host-key")
|
||||||
|
async def discover_host_key(
|
||||||
|
host_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
|
||||||
|
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
|
||||||
|
alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird
|
||||||
|
prominent im Audit-Log vermerkt."""
|
||||||
|
conn = get_db()
|
||||||
|
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
|
||||||
|
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"host_id": host_id, "fingerprint": fingerprint}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/hosts/{host_id}/rdp-credentials")
|
||||||
|
async def set_rdp_credentials(
|
||||||
|
host_id: int, payload: RdpCredentialsRequest, request: Request,
|
||||||
|
admin: CurrentUser = Depends(require_global_admin),
|
||||||
|
):
|
||||||
|
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
|
||||||
|
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
|
||||||
|
conn = get_db()
|
||||||
|
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO rdp_credentials (host_id, password_enc, updated_at) "
|
||||||
|
"VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
|
||||||
|
"ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, "
|
||||||
|
"updated_at = excluded.updated_at",
|
||||||
|
(host_id, encrypted),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"host_id": host_id},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rollenvergabe -------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/roles/grant")
|
||||||
|
async def grant_role(
|
||||||
|
payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
||||||
|
role_row = await role_cursor.fetchone()
|
||||||
|
if role_row is None:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO user_hostgroup_roles "
|
||||||
|
"(user_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(payload.user_id, payload.host_group_id, role_row[0], admin.id, payload.expires_at),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="role_granted", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={
|
||||||
|
"target_user_id": payload.user_id, "host_group_id": payload.host_group_id,
|
||||||
|
"role": payload.role_name, "expires_at": payload.expires_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/roles/revoke")
|
||||||
|
async def revoke_role(
|
||||||
|
payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
||||||
|
role_row = await role_cursor.fetchone()
|
||||||
|
if role_row is None:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM user_hostgroup_roles WHERE user_id = ? AND host_group_id = ? AND role_id = ?",
|
||||||
|
(payload.user_id, payload.host_group_id, role_row[0]),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="role_revoked", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={
|
||||||
|
"target_user_id": payload.user_id, "host_group_id": payload.host_group_id,
|
||||||
|
"role": payload.role_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- SSH-Keyverwaltung ---------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_ssh_key(
|
||||||
|
payload: SshKeyCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
"""Nimmt einen privaten Schluessel entgegen, verschluesselt ihn sofort mit
|
||||||
|
dem KEK (AES-256-GCM) und haelt den Klartext nur fuer die Dauer dieses
|
||||||
|
Requests im Prozessspeicher (siehe Konzept 6.4: Key verlaesst den Server nie)."""
|
||||||
|
conn = get_db()
|
||||||
|
encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key")
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type),
|
||||||
|
)
|
||||||
|
new_id = cursor.lastrowid
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="ssh_key_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"id": new_id, "label": payload.label, "key_type": payload.key_type},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"id": new_id, "label": payload.label}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
|
||||||
|
async def map_ssh_key_to_host(
|
||||||
|
host_id: int, key_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)",
|
||||||
|
(host_id, key_id),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="ssh_key_mapped", user_id=admin.id, client_ip=_client_ip(request),
|
||||||
|
details={"host_id": host_id, "ssh_key_id": key_id},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Audit-Log -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/audit-log")
|
||||||
|
async def get_audit_log(
|
||||||
|
limit: int = 100, offset: int = 0, admin: CurrentUser = Depends(require_global_admin)
|
||||||
|
):
|
||||||
|
limit = max(1, min(limit, 1000))
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, ts, user_id, client_ip, event_type, details_json FROM audit_log "
|
||||||
|
"ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||||
|
(limit, offset),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r[0], "ts": r[1], "user_id": r[2], "client_ip": r[3], "event_type": r[4], "details": r[5]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-log/verify")
|
||||||
|
async def verify_audit_log(admin: CurrentUser = Depends(require_global_admin)):
|
||||||
|
"""Prueft die Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7)."""
|
||||||
|
conn = get_db()
|
||||||
|
intact, broken_at = await verify_chain(conn)
|
||||||
|
return {"intact": intact, "first_broken_id": broken_at}
|
||||||
0
app/auth/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
122
app/auth/deps.py
Normal file
122
app/auth/deps.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import Cookie, Depends, HTTPException, Request, Response, WebSocket, status
|
||||||
|
|
||||||
|
from app.db import get_db
|
||||||
|
from app.rbac import user_has_role, user_has_role_for_host
|
||||||
|
from app.security.sessions import (
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
decode_session_token,
|
||||||
|
is_expired,
|
||||||
|
refresh_session_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CurrentUser:
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
response: Response,
|
||||||
|
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if jh_session is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht angemeldet")
|
||||||
|
|
||||||
|
payload = decode_session_token(jh_session)
|
||||||
|
if payload is None or is_expired(payload):
|
||||||
|
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session abgelaufen oder ungueltig")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
|
||||||
|
(payload.user_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or not row[3] or row[4] != payload.session_version:
|
||||||
|
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session ungueltig")
|
||||||
|
|
||||||
|
# Gleitenden Idle-Timeout verlaengern (gleiche session_version/login_ts).
|
||||||
|
new_token = refresh_session_token(payload)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
new_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="strict",
|
||||||
|
max_age=None, # Session-Cookie; Ablauf wird serverseitig durchgesetzt
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
|
||||||
|
"""Wie get_current_user(), aber fuer WebSocket-Handshakes: kein Cookie-Refresh
|
||||||
|
(WebSockets erlauben nach dem Handshake kein Set-Cookie mehr), stattdessen
|
||||||
|
wird der Idle-Timeout beim naechsten regulaeren HTTP-Request durchgesetzt."""
|
||||||
|
token = websocket.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if token is None:
|
||||||
|
return None
|
||||||
|
payload = decode_session_token(token)
|
||||||
|
if payload is None or is_expired(payload):
|
||||||
|
return None
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
|
||||||
|
(payload.user_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or not row[3] or row[4] != payload.session_version:
|
||||||
|
return None
|
||||||
|
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
|
||||||
|
|
||||||
|
|
||||||
|
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_host_group_role(role_name: str):
|
||||||
|
"""Dependency-Factory: prueft Rolle des Users fuer eine per Pfad-/Query-Param
|
||||||
|
uebergebene host_group_id. Globale Admins duerfen immer."""
|
||||||
|
|
||||||
|
async def _dep(host_group_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
if user.is_admin:
|
||||||
|
return user
|
||||||
|
conn = get_db()
|
||||||
|
allowed = await user_has_role(
|
||||||
|
conn, user_id=user.id, host_group_id=host_group_id, role_name=role_name
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Hostgruppe")
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
def require_host_role(role_name: str):
|
||||||
|
"""Dependency-Factory: prueft Rolle des Users fuer einen konkreten host_id."""
|
||||||
|
|
||||||
|
async def _dep(host_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
if user.is_admin:
|
||||||
|
return user
|
||||||
|
conn = get_db()
|
||||||
|
allowed = await user_has_role_for_host(
|
||||||
|
conn, user_id=user.id, host_id=host_id, role_name=role_name
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diesen Host")
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _dep
|
||||||
330
app/auth/routes.py
Normal file
330
app/auth/routes.py
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
"""Login-Flow: Passwort -> Pflicht-TOTP -> Session-Cookie (siehe Konzept 4.5/6.2)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models.schemas import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
TotpConfirmRequest,
|
||||||
|
TotpLoginRequest,
|
||||||
|
)
|
||||||
|
from app.security.audit import write_audit_event
|
||||||
|
from app.security.passwords import hash_password, needs_rehash, verify_password
|
||||||
|
from app.security.pending_totp import create_pending_token, decode_pending_token
|
||||||
|
from app.security.rate_limit import login_rate_limiter
|
||||||
|
from app.security.sessions import SESSION_COOKIE_NAME, create_session_token
|
||||||
|
from app.security.totp import (
|
||||||
|
decrypt_totp_secret,
|
||||||
|
encrypt_totp_secret,
|
||||||
|
generate_recovery_codes,
|
||||||
|
generate_totp_secret,
|
||||||
|
hash_recovery_code,
|
||||||
|
provisioning_uri,
|
||||||
|
verify_totp_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Konstante Dummy-Hash-Verifikation gegen Username-Enumeration per Timing-Seitenkanal.
|
||||||
|
_DUMMY_HASH = hash_password(secrets.token_hex(16))
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
# Nur die direkte Peer-IP; X-Forwarded-For wird ausschliesslich vertrauenswuerdig
|
||||||
|
# ausgewertet, wenn nginx mit set_real_ip_from konfiguriert ist (Konzept 7.2a).
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(payload: LoginRequest, request: Request):
|
||||||
|
ip = _client_ip(request)
|
||||||
|
if not login_rate_limiter.allow(ip):
|
||||||
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anmeldeversuche, bitte warten.")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, password_hash, is_active, failed_logins, locked_until, totp_enrolled "
|
||||||
|
"FROM users WHERE username = ?",
|
||||||
|
(payload.username,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
verify_password(_DUMMY_HASH, payload.password) # Timing angleichen
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="login_failed", user_id=None, client_ip=ip,
|
||||||
|
details={"reason": "unknown_user", "username": payload.username},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
|
||||||
|
|
||||||
|
user_id, pw_hash, is_active, failed_logins, locked_until, totp_enrolled = row
|
||||||
|
|
||||||
|
if locked_until and locked_until > datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"):
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="login_failed", user_id=user_id, client_ip=ip,
|
||||||
|
details={"reason": "locked"},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
raise HTTPException(status.HTTP_423_LOCKED, "Konto vorruebergehend gesperrt")
|
||||||
|
|
||||||
|
if not is_active or not verify_password(pw_hash, payload.password):
|
||||||
|
new_failed = failed_logins + 1
|
||||||
|
# Bewusst OHNE dynamisch zusammengesetztes SQL (kein f-String mit
|
||||||
|
# Query-Fragmenten, auch wenn hier nie Nutzereingaben einfliessen) --
|
||||||
|
# zwei feste, vollstaendig parametrisierte Statements statt eines
|
||||||
|
# "SQL-Query-Building"-Musters, das Scanner (z.B. bandit B608) und
|
||||||
|
# Reviewer sonst jedes Mal erneut pruefen muessten.
|
||||||
|
if new_failed >= 5:
|
||||||
|
delay_s = 30 * (2 ** min(new_failed - 5, 6)) # progressive Verzoegerung, gedeckelt
|
||||||
|
locked_until_ts = (datetime.now(timezone.utc) + timedelta(seconds=delay_s)).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%S.%fZ"
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?",
|
||||||
|
(new_failed, locked_until_ts, user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET failed_logins = ? WHERE id = ?", (new_failed, user_id)
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="login_failed", user_id=user_id, client_ip=ip,
|
||||||
|
details={"reason": "bad_password", "failed_logins": new_failed},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?", (user_id,)
|
||||||
|
)
|
||||||
|
if needs_rehash(pw_hash):
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||||
|
(hash_password(payload.password), user_id),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="login_password_ok", user_id=user_id, client_ip=ip, details={}
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
pending_token = create_pending_token(user_id)
|
||||||
|
return {"pending_token": pending_token, "totp_enrolled": bool(totp_enrolled)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/enroll/start")
|
||||||
|
async def totp_enroll_start(body: dict, request: Request):
|
||||||
|
"""Erster Schritt der TOTP-Pflicht-Einrichtung (nur wenn noch nicht enrolled)."""
|
||||||
|
pending_token = body.get("pending_token", "")
|
||||||
|
user_id = decode_pending_token(pending_token)
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT username, totp_enrolled FROM users WHERE id = ?", (user_id,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltig")
|
||||||
|
username, totp_enrolled = row
|
||||||
|
if totp_enrolled:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP ist bereits eingerichtet")
|
||||||
|
|
||||||
|
secret = generate_totp_secret()
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET totp_secret_enc = ? WHERE id = ?",
|
||||||
|
(encrypt_totp_secret(secret), user_id),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="totp_enroll_started", user_id=user_id,
|
||||||
|
client_ip=_client_ip(request), details={},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
uri = provisioning_uri(secret, username)
|
||||||
|
qr_img = qrcode.make(uri)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
qr_img.save(buf, format="PNG")
|
||||||
|
qr_b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provisioning_uri": uri,
|
||||||
|
"qr_png_base64": qr_b64,
|
||||||
|
"recovery_codes_hint": "Recovery-Codes werden erst nach erfolgreicher Bestaetigung angezeigt.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/enroll/confirm")
|
||||||
|
async def totp_enroll_confirm(body: dict, request: Request, response: Response):
|
||||||
|
pending_token = body.get("pending_token", "")
|
||||||
|
code = str(body.get("code", ""))
|
||||||
|
user_id = decode_pending_token(pending_token)
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT totp_secret_enc, session_version FROM users WHERE id = ?", (user_id,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or row[0] is None:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP-Einrichtung wurde nicht gestartet")
|
||||||
|
|
||||||
|
secret = decrypt_totp_secret(row[0])
|
||||||
|
if not verify_totp_code(secret, code):
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="totp_enroll_failed", user_id=user_id,
|
||||||
|
client_ip=_client_ip(request), details={},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Code ungueltig")
|
||||||
|
|
||||||
|
recovery_codes = generate_recovery_codes()
|
||||||
|
await conn.execute("UPDATE users SET totp_enrolled = 1 WHERE id = ?", (user_id,))
|
||||||
|
for rc in recovery_codes:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)",
|
||||||
|
(user_id, hash_recovery_code(rc)),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="totp_enroll_confirmed", user_id=user_id,
|
||||||
|
client_ip=_client_ip(request), details={},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
token = create_session_token(user_id, row[1])
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
|
||||||
|
)
|
||||||
|
return {"recovery_codes": recovery_codes}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login/totp")
|
||||||
|
async def login_totp(payload: TotpLoginRequest, request: Request, response: Response):
|
||||||
|
user_id = decode_pending_token(payload.pending_token)
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT totp_secret_enc, totp_enrolled, session_version FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or not row[1]:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP nicht eingerichtet")
|
||||||
|
|
||||||
|
secret_enc, _enrolled, session_version = row
|
||||||
|
ip = _client_ip(request)
|
||||||
|
ok = verify_totp_code(decrypt_totp_secret(secret_enc), payload.code)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
# Recovery-Code als Fallback pruefen.
|
||||||
|
code_hash = hash_recovery_code(payload.code)
|
||||||
|
rc_cursor = await conn.execute(
|
||||||
|
"SELECT id FROM recovery_codes WHERE user_id = ? AND code_hash = ? AND used_at IS NULL",
|
||||||
|
(user_id, code_hash),
|
||||||
|
)
|
||||||
|
rc_row = await rc_cursor.fetchone()
|
||||||
|
if rc_row is not None:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE recovery_codes SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||||
|
(rc_row[0],),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="login_recovery_code_used", user_id=user_id, client_ip=ip, details={}
|
||||||
|
)
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="login_totp_failed", user_id=user_id, client_ip=ip, details={}
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "TOTP-Code ungueltig")
|
||||||
|
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="login_success", user_id=user_id, client_ip=ip, details={}
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
token = create_session_token(user_id, session_version)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(request: Request, response: Response, user: CurrentUser = Depends(get_current_user)):
|
||||||
|
conn = get_db()
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={}
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout-everywhere")
|
||||||
|
async def logout_everywhere(
|
||||||
|
request: Request, response: Response, user: CurrentUser = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Invalidiert alle ausgestellten Session-Cookies dieses Users sofort."""
|
||||||
|
conn = get_db()
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET session_version = session_version + 1 WHERE id = ?", (user.id,)
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={}
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password(
|
||||||
|
payload: ChangePasswordRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute("SELECT password_hash, session_version FROM users WHERE id = ?", (user.id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or not verify_password(row[0], payload.current_password):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Aktuelles Passwort falsch")
|
||||||
|
|
||||||
|
new_hash = hash_password(payload.new_password)
|
||||||
|
new_version = row[1] + 1 # invalidiert alle anderen laufenden Sessions dieses Users
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET password_hash = ?, session_version = ?, must_change_password = 0, "
|
||||||
|
"password_changed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||||
|
(new_hash, new_version, user.id),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="password_changed", user_id=user.id, client_ip=_client_ip(request), details={}
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
token = create_session_token(user.id, new_version)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(user: CurrentUser = Depends(get_current_user)):
|
||||||
|
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
|
||||||
0
app/catalog/__init__.py
Normal file
0
app/catalog/__init__.py
Normal file
67
app/catalog/routes.py
Normal file
67
app/catalog/routes.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Sicht fuer normale Nutzer: nur die Hosts/Aktionen, fuer die RBAC eine
|
||||||
|
Rolle in der jeweiligen Hostgruppe vergeben hat (Konzept 4.6)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/catalog", tags=["catalog"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hosts")
|
||||||
|
async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
||||||
|
conn = get_db()
|
||||||
|
if user.is_admin:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, "
|
||||||
|
"g.name, h.clipboard_enabled, h.file_transfer_enabled "
|
||||||
|
"FROM hosts h JOIN host_groups g ON g.id = h.host_group_id "
|
||||||
|
"WHERE h.is_active = 1 ORDER BY g.name, h.hostname"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
hosts = [dict(zip(
|
||||||
|
("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name",
|
||||||
|
"clipboard_enabled", "file_transfer_enabled"), r
|
||||||
|
)) for r in rows]
|
||||||
|
for h in hosts:
|
||||||
|
h["can_connect"] = True
|
||||||
|
h["can_file_transfer"] = True
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id,
|
||||||
|
g.name, h.clipboard_enabled, h.file_transfer_enabled
|
||||||
|
FROM hosts h
|
||||||
|
JOIN host_groups g ON g.id = h.host_group_id
|
||||||
|
JOIN user_hostgroup_roles uhr ON uhr.host_group_id = h.host_group_id
|
||||||
|
JOIN roles r ON r.id = uhr.role_id
|
||||||
|
WHERE h.is_active = 1 AND uhr.user_id = ?
|
||||||
|
AND r.name IN ('ssh_connect', 'rdp_connect')
|
||||||
|
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
ORDER BY g.name, h.hostname
|
||||||
|
""",
|
||||||
|
(user.id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
hosts = [dict(zip(
|
||||||
|
("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name",
|
||||||
|
"clipboard_enabled", "file_transfer_enabled"), r
|
||||||
|
)) for r in rows]
|
||||||
|
|
||||||
|
ft_cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT uhr.host_group_id FROM user_hostgroup_roles uhr
|
||||||
|
JOIN roles r ON r.id = uhr.role_id
|
||||||
|
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
|
||||||
|
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
""",
|
||||||
|
(user.id,),
|
||||||
|
)
|
||||||
|
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
|
||||||
|
for h in hosts:
|
||||||
|
h["can_connect"] = True
|
||||||
|
h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"])
|
||||||
|
return hosts
|
||||||
75
app/config.py
Normal file
75
app/config.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Zentrale Konfiguration der Jumphost-Anwendung.
|
||||||
|
|
||||||
|
Secrets (KEK, Session-Signaturschluessel) werden bevorzugt ueber systemd
|
||||||
|
Credentials geladen (siehe systemd LoadCredentialEncrypted= im Unit-File,
|
||||||
|
$CREDENTIALS_DIRECTORY zur Laufzeit). Fuer lokale Entwicklung/Tests wird auf
|
||||||
|
Umgebungsvariablen bzw. eine lokale .env-Datei zurueckgefallen -- das ist
|
||||||
|
NICHT fuer den Produktivbetrieb gedacht.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _read_credential(name: str, env_fallback: str | None = None, *, required: bool = True) -> bytes | None:
|
||||||
|
"""Liest ein Secret aus $CREDENTIALS_DIRECTORY (systemd-creds) oder Fallback-Env."""
|
||||||
|
cred_dir = os.environ.get("CREDENTIALS_DIRECTORY")
|
||||||
|
if cred_dir:
|
||||||
|
cred_path = Path(cred_dir) / name
|
||||||
|
if cred_path.exists():
|
||||||
|
return cred_path.read_bytes().strip()
|
||||||
|
if env_fallback and env_fallback in os.environ:
|
||||||
|
return os.environ[env_fallback].encode()
|
||||||
|
if required:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Secret '{name}' weder ueber systemd-creds noch ueber Env-Variable "
|
||||||
|
f"'{env_fallback}' verfuegbar. In Produktion MUSS dies ueber "
|
||||||
|
f"systemd LoadCredentialEncrypted= bereitgestellt werden."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Settings:
|
||||||
|
app_env: str = os.environ.get("JUMPHOST_ENV", "development")
|
||||||
|
data_dir: Path = Path(os.environ.get("JUMPHOST_DATA_DIR", "/var/lib/jumphost"))
|
||||||
|
db_path: Path = field(init=False)
|
||||||
|
recordings_dir: Path = field(init=False)
|
||||||
|
|
||||||
|
# Key-Encryption-Key fuer AES-256-GCM (verschluesselt SSH-Keys/TOTP-Secrets in der DB)
|
||||||
|
kek: bytes = field(init=False)
|
||||||
|
# separater Schluessel fuer Session-Cookie-Signatur (Schluesseltrennung, siehe Konzept 6.2)
|
||||||
|
session_secret: bytes = field(init=False)
|
||||||
|
|
||||||
|
listen_uds: str = os.environ.get("JUMPHOST_LISTEN_UDS", "/run/jumphost/app.sock")
|
||||||
|
guacd_host: str = os.environ.get("JUMPHOST_GUACD_HOST", "127.0.0.1")
|
||||||
|
guacd_port: int = int(os.environ.get("JUMPHOST_GUACD_PORT", "4822"))
|
||||||
|
|
||||||
|
session_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_IDLE_TIMEOUT", "900"))
|
||||||
|
session_absolute_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_ABS_TIMEOUT", "28800"))
|
||||||
|
max_failed_logins: int = int(os.environ.get("JUMPHOST_MAX_FAILED_LOGINS", "5"))
|
||||||
|
lockout_base_seconds: int = int(os.environ.get("JUMPHOST_LOCKOUT_BASE_SECONDS", "30"))
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.db_path = self.data_dir / "jumphost.db"
|
||||||
|
self.recordings_dir = self.data_dir / "recordings"
|
||||||
|
|
||||||
|
if self.app_env == "development":
|
||||||
|
# Nur fuer lokale Entwicklung: deterministisch aus Env oder Zufallswert je Prozessstart.
|
||||||
|
kek_hex = os.environ.get("JUMPHOST_DEV_KEK")
|
||||||
|
self.kek = bytes.fromhex(kek_hex) if kek_hex else secrets.token_bytes(32)
|
||||||
|
sess_hex = os.environ.get("JUMPHOST_DEV_SESSION_SECRET")
|
||||||
|
self.session_secret = bytes.fromhex(sess_hex) if sess_hex else secrets.token_bytes(32)
|
||||||
|
else:
|
||||||
|
self.kek = _read_credential("jumphost_kek", "JUMPHOST_KEK")
|
||||||
|
self.session_secret = _read_credential("jumphost_session_secret", "JUMPHOST_SESSION_SECRET")
|
||||||
|
|
||||||
|
if len(self.kek) != 32:
|
||||||
|
raise RuntimeError("KEK muss genau 32 Bytes (256 Bit) lang sein.")
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
69
app/db.py
Normal file
69
app/db.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Datenbankzugriff (SQLite, WAL-Modus) + einfacher, idempotenter Migration-Runner.
|
||||||
|
|
||||||
|
Bewusst ohne schweres ORM gehalten: alle Queries sind strikt parametrisiert
|
||||||
|
(nie String-Concat mit Nutzereingaben), siehe Security-Konzept 6.6.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("jumphost.db")
|
||||||
|
|
||||||
|
MIGRATIONS_DIR = Path(__file__).parent / "db" / "migrations"
|
||||||
|
|
||||||
|
_connection: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
"""Legt das Datenverzeichnis an, oeffnet die DB und wendet Migrationen an."""
|
||||||
|
settings.data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
settings.recordings_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
|
||||||
|
global _connection
|
||||||
|
_connection = await aiosqlite.connect(settings.db_path, isolation_level=None)
|
||||||
|
await _connection.execute("PRAGMA journal_mode = WAL;")
|
||||||
|
await _connection.execute("PRAGMA foreign_keys = ON;")
|
||||||
|
await _connection.execute("PRAGMA busy_timeout = 5000;")
|
||||||
|
await _apply_migrations(_connection)
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings.db_path.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Konnte Dateirechte der DB nicht setzen (%s)", settings.db_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_migrations(conn: aiosqlite.Connection) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_migrations "
|
||||||
|
"(filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')))"
|
||||||
|
)
|
||||||
|
applied = {row[0] async for row in await conn.execute("SELECT filename FROM schema_migrations")}
|
||||||
|
|
||||||
|
for migration_file in sorted(MIGRATIONS_DIR.glob("*.sql")):
|
||||||
|
if migration_file.name in applied:
|
||||||
|
continue
|
||||||
|
logger.info("Wende Migration an: %s", migration_file.name)
|
||||||
|
sql = migration_file.read_text(encoding="utf-8")
|
||||||
|
await conn.executescript(sql)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO schema_migrations (filename) VALUES (?)", (migration_file.name,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
global _connection
|
||||||
|
if _connection is not None:
|
||||||
|
await _connection.close()
|
||||||
|
_connection = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> aiosqlite.Connection:
|
||||||
|
if _connection is None:
|
||||||
|
raise RuntimeError("Datenbank ist nicht initialisiert (init_db() aufrufen).")
|
||||||
|
return _connection
|
||||||
144
app/db/migrations/0001_initial.sql
Normal file
144
app/db/migrations/0001_initial.sql
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
-- Initiales Schema. Siehe Konzeptdokument Kap. 5.
|
||||||
|
-- Wird vom Migration-Runner (app/db.py) einmalig und idempotent angewendet.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
totp_secret_enc BLOB,
|
||||||
|
totp_enrolled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_logins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked_until TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
password_changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
must_change_password INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recovery_codes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
used_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user ON recovery_codes(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO roles (id, name) VALUES
|
||||||
|
(1, 'ssh_connect'),
|
||||||
|
(2, 'rdp_connect'),
|
||||||
|
(3, 'file_transfer'),
|
||||||
|
(4, 'clipboard'),
|
||||||
|
(5, 'session_recording_view'),
|
||||||
|
(6, 'admin_hostgroup');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS host_groups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hosts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
host_group_id INTEGER NOT NULL REFERENCES host_groups(id),
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL CHECK (protocol IN ('ssh','rdp')),
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
os_type TEXT NOT NULL CHECK (os_type IN ('linux','windows')),
|
||||||
|
ssh_host_key_fingerprint TEXT,
|
||||||
|
ssh_username TEXT,
|
||||||
|
rdp_username TEXT,
|
||||||
|
rdp_domain TEXT,
|
||||||
|
rdp_require_nla INTEGER NOT NULL DEFAULT 1,
|
||||||
|
clipboard_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
file_transfer_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hosts_group ON hosts(host_group_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
owner_user_id INTEGER REFERENCES users(id),
|
||||||
|
private_key_enc BLOB NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
key_type TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
rotated_at TEXT,
|
||||||
|
expires_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS host_ssh_key_map (
|
||||||
|
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
ssh_key_id INTEGER NOT NULL REFERENCES ssh_keys(id),
|
||||||
|
PRIMARY KEY (host_id, ssh_key_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_hostgroup_roles (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
host_group_id INTEGER NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE,
|
||||||
|
role_id INTEGER NOT NULL REFERENCES roles(id),
|
||||||
|
granted_by INTEGER REFERENCES users(id),
|
||||||
|
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
expires_at TEXT,
|
||||||
|
PRIMARY KEY (user_id, host_group_id, role_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uhr_user ON user_hostgroup_roles(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
host_id INTEGER NOT NULL REFERENCES hosts(id),
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
ended_at TEXT,
|
||||||
|
client_ip TEXT NOT NULL,
|
||||||
|
recording_path TEXT,
|
||||||
|
end_reason TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_host ON sessions(host_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS file_transfers (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_id INTEGER NOT NULL REFERENCES sessions(id),
|
||||||
|
direction TEXT NOT NULL CHECK (direction IN ('upload','download')),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
av_scan_result TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ft_session ON file_transfers(session_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
client_ip TEXT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL,
|
||||||
|
prev_hash TEXT NOT NULL,
|
||||||
|
entry_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
||||||
|
|
||||||
|
-- Audit-Log ist auf DB-Ebene append-only: UPDATE/DELETE werden hart verweigert.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS no_audit_update BEFORE UPDATE ON audit_log
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'audit_log ist append-only'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS no_audit_delete BEFORE DELETE ON audit_log
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'audit_log ist append-only'); END;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
5
app/db/migrations/0002_session_version.sql
Normal file
5
app/db/migrations/0002_session_version.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- Ermoeglicht serverseitiges Invalidieren aller Sessions eines Users
|
||||||
|
-- (Passwortaenderung, "ueberall abmelden", Admin-Sperre) ohne eigene
|
||||||
|
-- Session-Tabelle: das Session-Cookie enthaelt session_version und wird nur
|
||||||
|
-- akzeptiert, wenn der Wert mit dem aktuellen DB-Wert uebereinstimmt.
|
||||||
|
ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 1;
|
||||||
7
app/db/migrations/0003_rdp_credentials.sql
Normal file
7
app/db/migrations/0003_rdp_credentials.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- RDP-Zugangsdaten getrennt von SSH-Keys, ebenfalls AES-256-GCM-verschluesselt
|
||||||
|
-- (KEK, associated_data="rdp_password"). Ein Datensatz pro Host.
|
||||||
|
CREATE TABLE IF NOT EXISTS rdp_credentials (
|
||||||
|
host_id INTEGER PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
password_enc BLOB NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
99
app/main.py
Normal file
99
app/main.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
FastAPI-Einstiegspunkt. Bindet Security-Header, Router und Static/Template-
|
||||||
|
Auslieferung zusammen (siehe Konzept 6.6 Web-Anwendungs-Hardening).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.admin.routes import router as admin_router
|
||||||
|
from app.auth.deps import get_current_user
|
||||||
|
from app.auth.routes import router as auth_router
|
||||||
|
from app.catalog.routes import router as catalog_router
|
||||||
|
from app.db import close_db, init_db
|
||||||
|
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
|
||||||
|
from app.ssh_proxy.sftp import router as sftp_router
|
||||||
|
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Jumphost Gateway", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
app.include_router(catalog_router)
|
||||||
|
app.include_router(ssh_ws_router)
|
||||||
|
app.include_router(sftp_router)
|
||||||
|
app.include_router(rdp_ws_router)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def security_headers_middleware(request: Request, call_next):
|
||||||
|
"""Setzt die in Konzept 6.6 geforderten Security-Header auf jede Antwort.
|
||||||
|
Laeuft unabhaengig davon, ob nginx vorgeschaltet ist (Defense-in-Depth --
|
||||||
|
nginx setzt in der Praxis dieselben Header zusaetzlich, siehe ansible/roles/nginx_proxy)."""
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
response.headers["Permissions-Policy"] = "clipboard-read=(self), clipboard-write=(self), fullscreen=(self)"
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"connect-src 'self' ws: wss:; "
|
||||||
|
"frame-ancestors 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'"
|
||||||
|
)
|
||||||
|
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request):
|
||||||
|
# Hinweis: seit Starlette >=1.x ist "TemplateResponse(request, name, ...)"
|
||||||
|
# die aktuelle Aufrufkonvention (die alte "TemplateResponse(name, {...})"
|
||||||
|
# wurde entfernt) -- beim Dependency-Upgrade im Rahmen der pip-audit-
|
||||||
|
# Bereinigung angepasst und per Pentest-Testsuite regressionsgetestet.
|
||||||
|
return templates.TemplateResponse(request, "login.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
# Auth-Pruefung erfolgt clientseitig ueber GET /auth/me (401 -> Redirect zu /);
|
||||||
|
# serverseitig zusaetzlich abgesichert, sobald Templates dynamische Inhalte rendern.
|
||||||
|
return templates.TemplateResponse(request, "dashboard.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/terminal/{host_id}", response_class=HTMLResponse)
|
||||||
|
async def terminal_page(request: Request, host_id: int):
|
||||||
|
return templates.TemplateResponse(request, "terminal.html", {"host_id": host_id})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/rdp/{host_id}", response_class=HTMLResponse)
|
||||||
|
async def rdp_page(request: Request, host_id: int):
|
||||||
|
return templates.TemplateResponse(request, "rdp.html", {"host_id": host_id})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz():
|
||||||
|
return {"status": "ok"}
|
||||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
109
app/models/schemas.py
Normal file
109
app/models/schemas.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
Pydantic-Schemas fuer alle API-Eingaben/-Ausgaben.
|
||||||
|
|
||||||
|
Strikte Validierung ist Teil des Hardening-Konzepts (6.6): Laenge, Typ und
|
||||||
|
erlaubte Zeichen werden hier durchgesetzt, bevor irgendein Wert die
|
||||||
|
Business-Logik oder die Datenbank erreicht.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$")
|
||||||
|
HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=64)
|
||||||
|
password: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def check_username(cls, v: str) -> str:
|
||||||
|
if not USERNAME_RE.match(v):
|
||||||
|
raise ValueError("Ungueltiger Benutzername")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TotpLoginRequest(BaseModel):
|
||||||
|
pending_token: str
|
||||||
|
code: str = Field(min_length=6, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class TotpConfirmRequest(BaseModel):
|
||||||
|
code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=256)
|
||||||
|
new_password: str = Field(min_length=12, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=64)
|
||||||
|
initial_password: str = Field(min_length=12, max_length=256)
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def check_username(cls, v: str) -> str:
|
||||||
|
if not USERNAME_RE.match(v):
|
||||||
|
raise ValueError("Ungueltiger Benutzername")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class HostGroupCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class HostCreateRequest(BaseModel):
|
||||||
|
host_group_id: int
|
||||||
|
hostname: str = Field(min_length=1, max_length=128)
|
||||||
|
address: str = Field(min_length=1, max_length=255)
|
||||||
|
protocol: Literal["ssh", "rdp"]
|
||||||
|
port: int = Field(gt=0, le=65535)
|
||||||
|
os_type: Literal["linux", "windows"]
|
||||||
|
ssh_host_key_fingerprint: str | None = Field(default=None, max_length=512)
|
||||||
|
ssh_username: str | None = Field(default=None, max_length=128)
|
||||||
|
rdp_username: str | None = Field(default=None, max_length=128)
|
||||||
|
rdp_domain: str | None = Field(default=None, max_length=128)
|
||||||
|
rdp_require_nla: bool = True
|
||||||
|
clipboard_enabled: bool = True
|
||||||
|
file_transfer_enabled: bool = True
|
||||||
|
|
||||||
|
@field_validator("hostname")
|
||||||
|
@classmethod
|
||||||
|
def check_hostname(cls, v: str) -> str:
|
||||||
|
if not HOSTNAME_LABEL_RE.match(v):
|
||||||
|
raise ValueError("Ungueltiger Hostname")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class RoleGrantRequest(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
host_group_id: int
|
||||||
|
role_name: Literal[
|
||||||
|
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||||
|
"session_recording_view", "admin_hostgroup",
|
||||||
|
]
|
||||||
|
expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SshKeyCreateRequest(BaseModel):
|
||||||
|
label: str = Field(min_length=1, max_length=128)
|
||||||
|
owner_user_id: int | None = None
|
||||||
|
private_key_pem: str = Field(min_length=1, max_length=32_768)
|
||||||
|
public_key: str = Field(min_length=1, max_length=8192)
|
||||||
|
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectRequest(BaseModel):
|
||||||
|
host_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class RdpCredentialsRequest(BaseModel):
|
||||||
|
password: str = Field(min_length=1, max_length=512)
|
||||||
33
app/rbac.py
Normal file
33
app/rbac.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
|
||||||
|
async def user_has_role(
|
||||||
|
conn: aiosqlite.Connection, *, user_id: int, host_group_id: int, role_name: str
|
||||||
|
) -> bool:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM user_hostgroup_roles uhr
|
||||||
|
JOIN roles r ON r.id = uhr.role_id
|
||||||
|
WHERE uhr.user_id = ?
|
||||||
|
AND uhr.host_group_id = ?
|
||||||
|
AND r.name = ?
|
||||||
|
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id, host_group_id, role_name),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def user_has_role_for_host(
|
||||||
|
conn: aiosqlite.Connection, *, user_id: int, host_id: int, role_name: str
|
||||||
|
) -> bool:
|
||||||
|
cursor = await conn.execute("SELECT host_group_id FROM hosts WHERE id = ?", (host_id,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
return await user_has_role(conn, user_id=user_id, host_group_id=row[0], role_name=role_name)
|
||||||
0
app/rdp_proxy/__init__.py
Normal file
0
app/rdp_proxy/__init__.py
Normal file
166
app/rdp_proxy/guacd_client.py
Normal file
166
app/rdp_proxy/guacd_client.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Guacamole-Protokoll-Tunnel zu guacd (siehe Konzept 3, 4.3).
|
||||||
|
|
||||||
|
guacd selbst spricht RDP zum Windows-Ziel; der Jumphost tauscht mit guacd nur
|
||||||
|
das textbasierte Guacamole-Protokoll aus (laengenpraefigierte Elemente,
|
||||||
|
Instruktionen durch ';' abgeschlossen). Zugangsdaten werden ausschliesslich
|
||||||
|
serverseitig in die "connect"-Instruktion eingefuegt -- der Browser sieht sie
|
||||||
|
nie (analog zum SSH-Keyhandling in app/ssh_proxy/proxy.py).
|
||||||
|
|
||||||
|
Hinweis: Die exakten von guacd erwarteten RDP-Parameter (Namen/Reihenfolge)
|
||||||
|
haengen von der eingesetzten guacd/FreeRDP-Version ab. Diese Implementierung
|
||||||
|
fragt sie dynamisch per "args"-Instruktion ab (kein Hardcoding einer festen
|
||||||
|
Parameterliste) und ist daher robust gegen kleinere Versionsunterschiede --
|
||||||
|
sollte vor Produktivbetrieb dennoch gegen die Ziel-guacd-Version getestet werden.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("jumphost.rdp_proxy.guacd")
|
||||||
|
|
||||||
|
|
||||||
|
class GuacamoleProtocolError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def encode_instruction(*elements: str) -> str:
|
||||||
|
parts = []
|
||||||
|
for element in elements:
|
||||||
|
encoded = element.encode("utf-8")
|
||||||
|
parts.append(f"{len(encoded)}.{element}")
|
||||||
|
return ",".join(parts) + ";"
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes:
|
||||||
|
buf = bytearray()
|
||||||
|
while True:
|
||||||
|
b = await reader.readexactly(1)
|
||||||
|
if b == delimiter:
|
||||||
|
return bytes(buf)
|
||||||
|
buf += b
|
||||||
|
|
||||||
|
|
||||||
|
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
|
||||||
|
elements: list[str] = []
|
||||||
|
while True:
|
||||||
|
length_bytes = await _read_until(reader, b".")
|
||||||
|
try:
|
||||||
|
length = int(length_bytes)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
|
||||||
|
content = (await reader.readexactly(length)).decode("utf-8")
|
||||||
|
elements.append(content)
|
||||||
|
sep = await reader.readexactly(1)
|
||||||
|
if sep == b";":
|
||||||
|
return elements
|
||||||
|
if sep != b",":
|
||||||
|
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_instruction_text(text: str) -> list[str]:
|
||||||
|
"""Parst genau EINE Instruktion aus einem bereits vollstaendig vorliegenden
|
||||||
|
String (z.B. eine einzelne WebSocket-Textnachricht vom Browser)."""
|
||||||
|
elements: list[str] = []
|
||||||
|
i = 0
|
||||||
|
n = len(text)
|
||||||
|
while i < n:
|
||||||
|
dot = text.index(".", i)
|
||||||
|
length = int(text[i:dot])
|
||||||
|
start = dot + 1
|
||||||
|
end = start + length
|
||||||
|
elements.append(text[start:end])
|
||||||
|
sep = text[end] if end < n else ""
|
||||||
|
i = end + 1
|
||||||
|
if sep == ";":
|
||||||
|
break
|
||||||
|
if sep != ",":
|
||||||
|
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}")
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
class GuacdTunnel:
|
||||||
|
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str) -> None:
|
||||||
|
self.reader = reader
|
||||||
|
self.writer = writer
|
||||||
|
self.connection_id = connection_id
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
try:
|
||||||
|
self.writer.close()
|
||||||
|
await self.writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
# Aufraeumpfad: ein bereits getrenntes/fehlerhaftes Socket beim
|
||||||
|
# Schliessen darf den Session-Teardown (Audit-Log-Eintrag,
|
||||||
|
# DB-Update in ws_tunnel.py) nicht verhindern. Bewusst breit
|
||||||
|
# gefangen, aber protokolliert statt stillschweigend verschluckt.
|
||||||
|
logger.debug("Fehler beim Schliessen des guacd-Tunnels (ignoriert)", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_tunnel(
|
||||||
|
*,
|
||||||
|
guacd_host: str,
|
||||||
|
guacd_port: int,
|
||||||
|
protocol: str,
|
||||||
|
params: dict[str, str],
|
||||||
|
screen_width: int = 1024,
|
||||||
|
screen_height: int = 768,
|
||||||
|
dpi: int = 96,
|
||||||
|
) -> GuacdTunnel:
|
||||||
|
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
|
||||||
|
|
||||||
|
writer.write(encode_instruction("select", protocol).encode("utf-8"))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
args_instr = await read_instruction(reader)
|
||||||
|
if args_instr[0] != "args":
|
||||||
|
raise GuacamoleProtocolError(f"Erwartete 'args', erhalten: {args_instr[0]}")
|
||||||
|
arg_names = args_instr[1:]
|
||||||
|
|
||||||
|
handshake = (
|
||||||
|
encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
|
||||||
|
+ encode_instruction("audio")
|
||||||
|
+ encode_instruction("video")
|
||||||
|
+ encode_instruction("image", "image/png", "image/jpeg")
|
||||||
|
)
|
||||||
|
writer.write(handshake.encode("utf-8"))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
values = [params.get(name, "") for name in arg_names]
|
||||||
|
writer.write(encode_instruction("connect", *values).encode("utf-8"))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
ready_instr = await read_instruction(reader)
|
||||||
|
if ready_instr[0] != "ready":
|
||||||
|
raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}")
|
||||||
|
connection_id = ready_instr[1] if len(ready_instr) > 1 else ""
|
||||||
|
|
||||||
|
return GuacdTunnel(reader, writer, connection_id)
|
||||||
|
|
||||||
|
|
||||||
|
def build_rdp_params(host: dict, password: str) -> dict[str, str]:
|
||||||
|
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
|
||||||
|
|
||||||
|
Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern
|
||||||
|
rdp_require_nla gesetzt ist (Standard), Zertifikatspruefung ist standardmaessig
|
||||||
|
AKTIV (ignore-cert=false) -- bei selbstsignierten Zertifikaten auf den
|
||||||
|
Zielsystemen muss dies bewusst pro Host ueberschrieben werden, kein stiller
|
||||||
|
Bypass.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"hostname": host["address"],
|
||||||
|
"port": str(host["port"]),
|
||||||
|
"username": host.get("rdp_username") or "",
|
||||||
|
"password": password,
|
||||||
|
"domain": host.get("rdp_domain") or "",
|
||||||
|
"security": "nla" if host.get("rdp_require_nla", True) else "any",
|
||||||
|
"ignore-cert": "false",
|
||||||
|
"disable-audio": "true",
|
||||||
|
"enable-drive": "true" if host.get("file_transfer_enabled") else "false",
|
||||||
|
"drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}",
|
||||||
|
"create-drive-path": "true",
|
||||||
|
"disable-copy": "false" if host.get("clipboard_enabled") else "true",
|
||||||
|
"disable-paste": "false" if host.get("clipboard_enabled") else "true",
|
||||||
|
"resize-method": "display-update",
|
||||||
|
}
|
||||||
170
app/rdp_proxy/ws_tunnel.py
Normal file
170
app/rdp_proxy/ws_tunnel.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
WebSocket-Bruecke Browser (guacamole-common-js) <-> guacd (Konzept 4.3).
|
||||||
|
|
||||||
|
Setzt pro Hostgruppe/Host konfigurierbare Policies durch, die guacd selbst
|
||||||
|
zwar schon per connect-Parameter bekommt (disable-copy/-paste, enable-drive),
|
||||||
|
zusaetzlich werden Clipboard-Instruktionen aber auch hier auf Protokollebene
|
||||||
|
gefiltert -- Defense-in-Depth, falls sich guacd-Parameter je nach Version
|
||||||
|
unterscheiden.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user_ws
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import get_db
|
||||||
|
from app.rbac import user_has_role_for_host
|
||||||
|
from app.recordings.recorder import SessionRecorder
|
||||||
|
from app.security.audit import write_audit_event
|
||||||
|
from app.security.crypto import decrypt_secret
|
||||||
|
from app.rdp_proxy.guacd_client import (
|
||||||
|
GuacamoleProtocolError,
|
||||||
|
build_rdp_params,
|
||||||
|
encode_instruction,
|
||||||
|
open_tunnel,
|
||||||
|
parse_instruction_text,
|
||||||
|
read_instruction,
|
||||||
|
)
|
||||||
|
from app.ssh_proxy.proxy import HostNotConfiguredError, load_host
|
||||||
|
|
||||||
|
logger = logging.getLogger("jumphost.rdp_proxy.ws")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None:
|
||||||
|
while True:
|
||||||
|
instr = await read_instruction(tunnel.reader)
|
||||||
|
text = encode_instruction(*instr)
|
||||||
|
recorder.record("output", text)
|
||||||
|
await websocket.send_text(text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool) -> None:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_text()
|
||||||
|
try:
|
||||||
|
instr = parse_instruction_text(message)
|
||||||
|
except GuacamoleProtocolError:
|
||||||
|
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen
|
||||||
|
|
||||||
|
if not clipboard_enabled and instr and instr[0] == "clipboard":
|
||||||
|
continue # Defense-in-Depth: Clipboard serverseitig blocken
|
||||||
|
|
||||||
|
recorder.record("input", message)
|
||||||
|
tunnel.writer.write(message.encode("utf-8"))
|
||||||
|
await tunnel.writer.drain()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/rdp/{host_id}")
|
||||||
|
async def rdp_tunnel(
|
||||||
|
websocket: WebSocket,
|
||||||
|
host_id: int,
|
||||||
|
width: int = Query(default=1280, ge=320, le=7680),
|
||||||
|
height: int = Query(default=800, ge=240, le=4320),
|
||||||
|
dpi: int = Query(default=96, ge=48, le=384),
|
||||||
|
):
|
||||||
|
user = await get_current_user_ws(websocket)
|
||||||
|
if user is None:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
if not user.is_admin and not await user_has_role_for_host(
|
||||||
|
conn, user_id=user.id, host_id=host_id, role_name="rdp_connect"
|
||||||
|
):
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
client_ip = websocket.client.host if websocket.client else "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = await load_host(conn, host_id)
|
||||||
|
except HostNotConfiguredError as exc:
|
||||||
|
await websocket.close(code=4404)
|
||||||
|
return
|
||||||
|
|
||||||
|
if host["protocol"] != "rdp":
|
||||||
|
await websocket.close(code=4400)
|
||||||
|
return
|
||||||
|
|
||||||
|
cred_cursor = await conn.execute(
|
||||||
|
"SELECT password_enc FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||||
|
)
|
||||||
|
cred_row = await cred_cursor.fetchone()
|
||||||
|
if cred_row is None:
|
||||||
|
await websocket.close(code=4404)
|
||||||
|
return
|
||||||
|
password = decrypt_secret(cred_row[0], associated_data=b"rdp_password")
|
||||||
|
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'rdp', ?)",
|
||||||
|
(user.id, host_id, client_ip),
|
||||||
|
)
|
||||||
|
session_id = cursor.lastrowid
|
||||||
|
recorder = SessionRecorder(session_id)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id)
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="rdp_session_start", user_id=user.id, client_ip=client_ip,
|
||||||
|
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
end_reason = "logout"
|
||||||
|
tunnel = None
|
||||||
|
tasks: list[asyncio.Task] = []
|
||||||
|
try:
|
||||||
|
params = build_rdp_params(host, password.decode())
|
||||||
|
tunnel = await open_tunnel(
|
||||||
|
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
|
||||||
|
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
|
||||||
|
)
|
||||||
|
clipboard_enabled = bool(host.get("clipboard_enabled", True))
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
|
||||||
|
asyncio.create_task(
|
||||||
|
_ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
for task in done:
|
||||||
|
exc = task.exception()
|
||||||
|
if exc:
|
||||||
|
raise exc
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
end_reason = "logout"
|
||||||
|
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
|
||||||
|
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||||
|
end_reason = "error"
|
||||||
|
finally:
|
||||||
|
del password # Klartext-Passwort so schnell wie moeglich freigeben
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
if tunnel:
|
||||||
|
await tunnel.close()
|
||||||
|
recorder.close()
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(end_reason, session_id),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="rdp_session_end", user_id=user.id, client_ip=client_ip,
|
||||||
|
details={"host_id": host_id, "session_id": session_id, "reason": end_reason},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
# Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind
|
||||||
|
# zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client
|
||||||
|
# getrenntes WebSocket darf das nicht rueckwirkend fehlschlagen lassen.
|
||||||
|
logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)
|
||||||
0
app/recordings/__init__.py
Normal file
0
app/recordings/__init__.py
Normal file
63
app/recordings/recorder.py
Normal file
63
app/recordings/recorder.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Session-Aufzeichnung mit Hash-Verkettung (siehe Konzept 6.5).
|
||||||
|
|
||||||
|
Jede Session schreibt eine eigene JSONL-Datei unter settings.recordings_dir.
|
||||||
|
Jede Zeile verkettet sich mit der vorherigen (gleiches Prinzip wie das
|
||||||
|
Audit-Log, app/security/audit.py), damit nachtraegliche Manipulation der
|
||||||
|
Aufzeichnung erkennbar ist.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
GENESIS_HASH = "0" * 64
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRecorder:
|
||||||
|
def __init__(self, session_id: int) -> None:
|
||||||
|
self.session_id = session_id
|
||||||
|
self.path = settings.recordings_dir / f"session_{session_id}.jsonl"
|
||||||
|
self._prev_hash = GENESIS_HASH
|
||||||
|
self._start_ts = time.time()
|
||||||
|
self._fh = open(self.path, "a", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
self.path.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def record(self, direction: str, data: str) -> None:
|
||||||
|
"""direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe)."""
|
||||||
|
offset = round(time.time() - self._start_ts, 4)
|
||||||
|
entry = {"t": offset, "dir": direction, "data": data}
|
||||||
|
entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True)
|
||||||
|
entry_hash = hashlib.sha256((self._prev_hash + "|" + entry_json).encode()).hexdigest()
|
||||||
|
line = json.dumps({"entry": entry, "prev_hash": self._prev_hash, "hash": entry_hash})
|
||||||
|
self._fh.write(line + "\n")
|
||||||
|
self._fh.flush()
|
||||||
|
self._prev_hash = entry_hash
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if not self._fh.closed:
|
||||||
|
self._fh.close()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_recording(path: Path) -> bool:
|
||||||
|
prev_hash = GENESIS_HASH
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
row = json.loads(line)
|
||||||
|
if row["prev_hash"] != prev_hash:
|
||||||
|
return False
|
||||||
|
entry_json = json.dumps(row["entry"], ensure_ascii=False, sort_keys=True)
|
||||||
|
expected = hashlib.sha256((prev_hash + "|" + entry_json).encode()).hexdigest()
|
||||||
|
if expected != row["hash"]:
|
||||||
|
return False
|
||||||
|
prev_hash = row["hash"]
|
||||||
|
return True
|
||||||
0
app/security/__init__.py
Normal file
0
app/security/__init__.py
Normal file
70
app/security/audit.py
Normal file
70
app/security/audit.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Manipulationssicheres, hash-verkettetes Audit-Log (siehe Konzept 4.7 / 6.1).
|
||||||
|
|
||||||
|
Jeder Eintrag verkettet sich kryptographisch mit seinem Vorgaenger:
|
||||||
|
entry_hash = sha256(prev_hash || ts || event_type || details_json)
|
||||||
|
|
||||||
|
Nachtraegliches Aendern oder Herausloeschen eines Eintrags bricht die Kette
|
||||||
|
ab dieser Stelle - erkennbar durch verify_chain(). Zusaetzlich verhindern
|
||||||
|
DB-Trigger (0001_initial.sql) UPDATE/DELETE auf Anwendungsebene.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
GENESIS_HASH = "0" * 64
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) -> str:
|
||||||
|
payload = f"{prev_hash}|{ts}|{event_type}|{details_json}".encode()
|
||||||
|
return hashlib.sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def write_audit_event(
|
||||||
|
conn: aiosqlite.Connection,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
user_id: int | None,
|
||||||
|
client_ip: str | None,
|
||||||
|
details: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Schreibt einen Audit-Eintrag; haengt ihn an die bestehende Hash-Chain an.
|
||||||
|
|
||||||
|
Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder
|
||||||
|
zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt.
|
||||||
|
"""
|
||||||
|
cursor = await conn.execute("SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1")
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
prev_hash = row[0] if row else GENESIS_HASH
|
||||||
|
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
|
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
|
||||||
|
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]:
|
||||||
|
"""Prueft die gesamte Audit-Log-Kette. Rueckgabe: (intakt?, erste kaputte id)."""
|
||||||
|
prev_hash = GENESIS_HASH
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, ts, event_type, details_json, prev_hash, entry_hash FROM audit_log ORDER BY id ASC"
|
||||||
|
)
|
||||||
|
async for row in cursor:
|
||||||
|
entry_id, ts, event_type, details_json, stored_prev, stored_entry = row
|
||||||
|
if stored_prev != prev_hash:
|
||||||
|
return False, entry_id
|
||||||
|
expected = _entry_hash(prev_hash, ts, event_type, details_json)
|
||||||
|
if expected != stored_entry:
|
||||||
|
return False, entry_id
|
||||||
|
prev_hash = stored_entry
|
||||||
|
return True, None
|
||||||
38
app/security/av_scan.py
Normal file
38
app/security/av_scan.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
AV-Scan-Hook fuer Datei-Uploads (Konzept 4.3/6.6).
|
||||||
|
|
||||||
|
Bewusst als duenner Wrapper um ein optionales ClamAV (clamd) gehalten: ist
|
||||||
|
kein Scanner konfiguriert/erreichbar, wird das Ergebnis "skipped" vermerkt
|
||||||
|
statt die Datei stillschweigend als "sauber" zu markieren -- Admins sehen im
|
||||||
|
Audit-/Filetransfer-Log damit ehrlich, ob wirklich gescannt wurde.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess # nosec B404 -- benoetigt fuer den optionalen ClamAV-Aufruf, siehe unten
|
||||||
|
|
||||||
|
|
||||||
|
def scan_bytes(data: bytes) -> str:
|
||||||
|
"""Rueckgabe: 'clean', 'infected:<signature>' oder 'skipped:<grund>'."""
|
||||||
|
clamdscan = shutil.which("clamdscan")
|
||||||
|
if not clamdscan:
|
||||||
|
return "skipped:clamdscan_not_installed"
|
||||||
|
try:
|
||||||
|
# Argumentliste ist vollstaendig fest (kein shell=True, keine
|
||||||
|
# Nutzereingabe im Kommando selbst); die hochgeladenen Datei-Bytes
|
||||||
|
# werden ausschliesslich ueber stdin (input=data) uebergeben, nie als
|
||||||
|
# Kommandozeilen-/Pfadargument -- Command-Injection ueber Dateinamen
|
||||||
|
# o.ae. ist damit ausgeschlossen.
|
||||||
|
proc = subprocess.run( # nosec B603
|
||||||
|
[clamdscan, "--stdout", "--no-summary", "-"],
|
||||||
|
input=data, capture_output=True, timeout=30,
|
||||||
|
)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return "skipped:scan_error"
|
||||||
|
|
||||||
|
output = proc.stdout.decode(errors="replace")
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return "clean"
|
||||||
|
if proc.returncode == 1:
|
||||||
|
return f"infected:{output.strip()}"
|
||||||
|
return "skipped:scan_error"
|
||||||
32
app/security/crypto.py
Normal file
32
app/security/crypto.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
AES-256-GCM Verschluesselung fuer Secrets at rest (SSH-Private-Keys, TOTP-Secrets).
|
||||||
|
|
||||||
|
Prinzip (siehe Konzept 6.4): Der Key-Encryption-Key (KEK) liegt NICHT in der
|
||||||
|
Datenbank, sondern kommt aus app.config.settings (systemd-creds/Env). Jeder
|
||||||
|
verschluesselte Datensatz erhaelt einen frischen, zufaelligen Nonce; Nonce +
|
||||||
|
Ciphertext + Auth-Tag werden gemeinsam gespeichert.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
NONCE_LEN = 12 # 96 Bit, empfohlene GCM-Noncelaenge
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(plaintext: bytes, *, associated_data: bytes = b"") -> bytes:
|
||||||
|
"""Verschluesselt plaintext mit dem globalen KEK. Rueckgabe: nonce || ciphertext."""
|
||||||
|
aesgcm = AESGCM(settings.kek)
|
||||||
|
nonce = os.urandom(NONCE_LEN)
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data or None)
|
||||||
|
return nonce + ciphertext
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(blob: bytes, *, associated_data: bytes = b"") -> bytes:
|
||||||
|
"""Entschluesselt einen mit encrypt_secret() erzeugten Blob."""
|
||||||
|
aesgcm = AESGCM(settings.kek)
|
||||||
|
nonce, ciphertext = blob[:NONCE_LEN], blob[NONCE_LEN:]
|
||||||
|
return aesgcm.decrypt(nonce, ciphertext, associated_data or None)
|
||||||
28
app/security/passwords.py
Normal file
28
app/security/passwords.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""Argon2id-Passwort-Hashing (siehe Konzept 6.2)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError, InvalidHash
|
||||||
|
|
||||||
|
# Parameter angelehnt an aktuelle OWASP-Empfehlung; in Produktion je nach
|
||||||
|
# Server-Hardware kalibrieren (siehe Konzept 6.2).
|
||||||
|
_hasher = PasswordHasher(time_cost=2, memory_cost=19 * 1024, parallelism=1)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
try:
|
||||||
|
_hasher.verify(password_hash, password)
|
||||||
|
except (VerifyMismatchError, InvalidHash):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def needs_rehash(password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.check_needs_rehash(password_hash)
|
||||||
|
except InvalidHash:
|
||||||
|
return True
|
||||||
29
app/security/pending_totp.py
Normal file
29
app/security/pending_totp.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Kurzlebige, signierte Tokens fuer den Zwischenschritt Passwort -> TOTP.
|
||||||
|
|
||||||
|
Es wird bewusst KEIN Session-Cookie ausgestellt, solange der zweite Faktor
|
||||||
|
nicht bestaetigt ist (siehe Konzept 6.2: "kein Login ohne TOTP moeglich").
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
_PENDING_MAX_AGE_S = 300 # 5 Minuten Zeitfenster fuer den TOTP-Schritt
|
||||||
|
|
||||||
|
_serializer = URLSafeTimedSerializer(settings.session_secret.hex(), salt="jumphost-pending-totp")
|
||||||
|
|
||||||
|
|
||||||
|
def create_pending_token(user_id: int) -> str:
|
||||||
|
return _serializer.dumps({"uid": user_id})
|
||||||
|
|
||||||
|
|
||||||
|
def decode_pending_token(token: str) -> int | None:
|
||||||
|
try:
|
||||||
|
data = _serializer.loads(token, max_age=_PENDING_MAX_AGE_S)
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(data["uid"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
34
app/security/rate_limit.py
Normal file
34
app/security/rate_limit.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Einfacher In-Memory Rate-Limiter fuer den Login-Endpunkt (pro Quell-IP).
|
||||||
|
|
||||||
|
Fuer einen Single-Process-ASGI-Deployment (siehe Konzept: kleine/mittlere
|
||||||
|
Umgebung) ausreichend. Bei horizontaler Skalierung auf mehrere Prozesse/Hosts
|
||||||
|
muss dies durch einen geteilten Store (z.B. Redis) ersetzt werden -- als
|
||||||
|
Erweiterungspunkt bewusst hinter einer kleinen Klasse gekapselt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindowRateLimiter:
|
||||||
|
def __init__(self, max_events: int, window_seconds: int) -> None:
|
||||||
|
self.max_events = max_events
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
def allow(self, key: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
window = self._events[key]
|
||||||
|
while window and now - window[0] > self.window_seconds:
|
||||||
|
window.popleft()
|
||||||
|
if len(window) >= self.max_events:
|
||||||
|
return False
|
||||||
|
window.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Max. 10 Login-Versuche pro Minute und Quell-IP; ergaenzt den
|
||||||
|
# Account-basierten Lockout in app/auth/routes.py.
|
||||||
|
login_rate_limiter = SlidingWindowRateLimiter(max_events=10, window_seconds=60)
|
||||||
70
app/security/sessions.py
Normal file
70
app/security/sessions.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Signierte, serverseitig invalidierbare Session-Cookies.
|
||||||
|
|
||||||
|
Kein separates Session-Store noetig: Das Cookie traegt user_id,
|
||||||
|
session_version (fuer harte Invalidierung, z.B. bei Passwortwechsel) und
|
||||||
|
zwei Zeitstempel (Login-Zeit fuer den absoluten Timeout, Last-Seen fuer den
|
||||||
|
gleitenden Idle-Timeout). Signatur ueber einen vom KEK getrennten Secret
|
||||||
|
(Schluesseltrennung, Konzept 6.2).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from itsdangerous import BadSignature, URLSafeSerializer
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
_serializer = URLSafeSerializer(settings.session_secret.hex(), salt="jumphost-session")
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = "jh_session"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionPayload:
|
||||||
|
user_id: int
|
||||||
|
session_version: int
|
||||||
|
login_ts: float
|
||||||
|
last_seen_ts: float
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_token(user_id: int, session_version: int) -> str:
|
||||||
|
now = time.time()
|
||||||
|
payload = {"uid": user_id, "sv": session_version, "iat": now, "seen": now}
|
||||||
|
return _serializer.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_session_token(payload: SessionPayload) -> str:
|
||||||
|
data = {
|
||||||
|
"uid": payload.user_id,
|
||||||
|
"sv": payload.session_version,
|
||||||
|
"iat": payload.login_ts,
|
||||||
|
"seen": time.time(),
|
||||||
|
}
|
||||||
|
return _serializer.dumps(data)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_session_token(token: str) -> SessionPayload | None:
|
||||||
|
try:
|
||||||
|
data = _serializer.loads(token)
|
||||||
|
except BadSignature:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return SessionPayload(
|
||||||
|
user_id=int(data["uid"]),
|
||||||
|
session_version=int(data["sv"]),
|
||||||
|
login_ts=float(data["iat"]),
|
||||||
|
last_seen_ts=float(data["seen"]),
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_expired(payload: SessionPayload) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
if now - payload.last_seen_ts > settings.session_idle_timeout_s:
|
||||||
|
return True
|
||||||
|
if now - payload.login_ts > settings.session_absolute_timeout_s:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
52
app/security/totp.py
Normal file
52
app/security/totp.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
TOTP-Enrollment und -Verifikation (RFC 6238) inkl. Recovery-Codes.
|
||||||
|
|
||||||
|
Pflicht-2FA: siehe Konzept 4.5 / 6.2. Das TOTP-Secret wird mit einem eigenen
|
||||||
|
AAD-Kontext ("totp") verschluesselt gespeichert -- Schluesseltrennung vom
|
||||||
|
SSH-Key-Material ist ueber den associated_data-Parameter realisiert (beide
|
||||||
|
nutzen zwar denselben KEK, sind aber durch AAD kontextgebunden und nicht
|
||||||
|
gegeneinander austauschbar).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
from app.security.crypto import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
_TOTP_AAD = b"totp_secret"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_totp_secret() -> str:
|
||||||
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_totp_secret(secret: str) -> bytes:
|
||||||
|
return encrypt_secret(secret.encode(), associated_data=_TOTP_AAD)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_totp_secret(blob: bytes) -> str:
|
||||||
|
return decrypt_secret(blob, associated_data=_TOTP_AAD).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def provisioning_uri(secret: str, username: str, issuer: str = "Jumphost") -> str:
|
||||||
|
return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_totp_code(secret: str, code: str) -> bool:
|
||||||
|
"""Verifiziert mit +-1 Zeitfenster Toleranz gegen Clock-Drift."""
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
return totp.verify(code, valid_window=1)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_recovery_codes(count: int = 10) -> list[str]:
|
||||||
|
"""Erzeugt Einmal-Recovery-Codes im Klartext (nur zur einmaligen Anzeige)."""
|
||||||
|
return [secrets.token_hex(5) for _ in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
def hash_recovery_code(code: str) -> str:
|
||||||
|
# Recovery-Codes sind hochentropisch (40 Bit hex) -- ein schneller,
|
||||||
|
# gesalzener Hash reicht hier aus; dennoch SHA-256 mit Pfeffer aus KEK-Kontext.
|
||||||
|
return hashlib.sha256(code.encode() + b"recovery_code_pepper").hexdigest()
|
||||||
0
app/ssh_proxy/__init__.py
Normal file
0
app/ssh_proxy/__init__.py
Normal file
130
app/ssh_proxy/proxy.py
Normal file
130
app/ssh_proxy/proxy.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Serverseitiger SSH-Verbindungsaufbau (asyncssh).
|
||||||
|
|
||||||
|
Zentrales Sicherheitsprinzip (Konzept 4.2/4.4/6.4): der private Schluessel
|
||||||
|
wird pro Verbindung aus der DB geladen, entschluesselt, an asyncssh
|
||||||
|
uebergeben und danach nicht weiter referenziert -- er verlaesst den
|
||||||
|
Serverprozess nie und wird nicht geloggt. Strict Host Key Checking ist
|
||||||
|
Pflicht: ohne gepinnten Fingerprint wird die Verbindung abgelehnt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.security.crypto import decrypt_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger("jumphost.ssh_proxy")
|
||||||
|
|
||||||
|
|
||||||
|
class HostNotConfiguredError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HostKeyMismatchError(Exception):
|
||||||
|
def __init__(self, expected: str | None, observed: str | None) -> None:
|
||||||
|
self.expected = expected
|
||||||
|
self.observed = observed
|
||||||
|
super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}")
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||||
|
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||||
|
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||||
|
|
||||||
|
def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None:
|
||||||
|
self.expected_fingerprint = expected_fingerprint
|
||||||
|
self.discovery_mode = discovery_mode
|
||||||
|
self.observed_fingerprint: str | None = None
|
||||||
|
|
||||||
|
def validate_host_public_key(self, host, addr, port, key) -> bool: # noqa: D102
|
||||||
|
self.observed_fingerprint = key.get_fingerprint("sha256")
|
||||||
|
if self.discovery_mode:
|
||||||
|
# Nur ueber den expliziten Admin-Discovery-Endpunkt erreichbar,
|
||||||
|
# niemals im regulaeren Verbindungspfad (siehe admin/routes.py).
|
||||||
|
return True
|
||||||
|
if not self.expected_fingerprint:
|
||||||
|
return False
|
||||||
|
return self.observed_fingerprint == self.expected_fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, "
|
||||||
|
"ssh_username, file_transfer_enabled, host_group_id FROM hosts WHERE id = ? AND is_active = 1",
|
||||||
|
(host_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HostNotConfiguredError(f"Host {host_id} nicht gefunden oder inaktiv")
|
||||||
|
keys = (
|
||||||
|
"id", "hostname", "address", "port", "os_type", "protocol",
|
||||||
|
"ssh_host_key_fingerprint", "ssh_username", "file_transfer_enabled", "host_group_id",
|
||||||
|
)
|
||||||
|
return dict(zip(keys, row))
|
||||||
|
|
||||||
|
|
||||||
|
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT sk.private_key_enc FROM ssh_keys sk "
|
||||||
|
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
|
||||||
|
"WHERE m.host_id = ? LIMIT 1",
|
||||||
|
(host_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
|
||||||
|
pem = decrypt_secret(row[0], associated_data=b"ssh_private_key")
|
||||||
|
try:
|
||||||
|
return asyncssh.import_private_key(pem)
|
||||||
|
finally:
|
||||||
|
# Bestpraxis: Referenz auf den Klartext-PEM-Bytes so schnell wie moeglich loslassen.
|
||||||
|
del pem
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
|
||||||
|
host = await load_host(conn, host_id)
|
||||||
|
if host["protocol"] != "ssh":
|
||||||
|
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
|
||||||
|
|
||||||
|
private_key = await load_private_key_for_host(conn, host_id)
|
||||||
|
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = await asyncssh.connect(
|
||||||
|
host["address"],
|
||||||
|
port=host["port"],
|
||||||
|
username=host["ssh_username"],
|
||||||
|
client_keys=[private_key],
|
||||||
|
known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key
|
||||||
|
client_factory=client_factory,
|
||||||
|
connect_timeout=10,
|
||||||
|
)
|
||||||
|
except asyncssh.Error as exc:
|
||||||
|
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
|
||||||
|
raise
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_and_store_host_key(
|
||||||
|
conn: aiosqlite.Connection, host_id: int, *, admin_user_id: int
|
||||||
|
) -> str:
|
||||||
|
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
|
||||||
|
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
||||||
|
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
|
||||||
|
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py)."""
|
||||||
|
host = await load_host(conn, host_id)
|
||||||
|
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
||||||
|
connection = await asyncssh.connect(
|
||||||
|
host["address"], port=host["port"], username=host["ssh_username"],
|
||||||
|
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||||
|
)
|
||||||
|
connection.close()
|
||||||
|
fingerprint = client.observed_fingerprint
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return fingerprint
|
||||||
145
app/ssh_proxy/sftp.py
Normal file
145
app/ssh_proxy/sftp.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"""Dateitransfer zu SSH-Zielen per SFTP (Upload/Download ueber den Jumphost).
|
||||||
|
|
||||||
|
Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept
|
||||||
|
6.6). Jeder Transfer wird in file_transfers + audit_log protokolliert.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, get_current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.rbac import user_has_role_for_host
|
||||||
|
from app.security.audit import write_audit_event
|
||||||
|
from app.security.av_scan import scan_bytes
|
||||||
|
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ssh", tags=["file-transfer"])
|
||||||
|
|
||||||
|
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_file_transfer(host_id: int, request: Request, user: CurrentUser = Depends(get_current_user)):
|
||||||
|
conn = get_db()
|
||||||
|
if not user.is_admin and not await user_has_role_for_host(
|
||||||
|
conn, user_id=user.id, host_id=host_id, role_name="file_transfer"
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Filetransfer-Berechtigung fuer diesen Host")
|
||||||
|
host = await load_host(conn, host_id)
|
||||||
|
if not host["file_transfer_enabled"]:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Dateitransfer ist fuer diesen Host deaktiviert")
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_transfer(conn, *, user: CurrentUser, host_id: int, client_ip: str, direction: str,
|
||||||
|
filename: str, size: int, sha256: str, av_result: str) -> None:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO sessions (user_id, host_id, protocol, client_ip, ended_at, end_reason) "
|
||||||
|
"VALUES (?, ?, 'ssh', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'file_transfer')",
|
||||||
|
(user.id, host_id, client_ip),
|
||||||
|
)
|
||||||
|
session_id = cursor.lastrowid
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO file_transfers (session_id, direction, filename, size_bytes, sha256, av_scan_result) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, direction, filename, size, sha256, av_result),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="file_transfer", user_id=user.id, client_ip=client_ip,
|
||||||
|
details={
|
||||||
|
"host_id": host_id, "direction": direction, "filename": filename,
|
||||||
|
"size_bytes": size, "sha256": sha256, "av_scan_result": av_result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/files/upload")
|
||||||
|
async def upload_file(
|
||||||
|
host_id: int,
|
||||||
|
request: Request,
|
||||||
|
remote_path: str = Query(..., max_length=1024),
|
||||||
|
file: UploadFile = ...,
|
||||||
|
host=Depends(_require_file_transfer),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
data = await file.read(MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(data) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
|
||||||
|
|
||||||
|
av_result = scan_bytes(data)
|
||||||
|
if av_result.startswith("infected"):
|
||||||
|
conn = get_db()
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="file_transfer_blocked_malware", user_id=user.id,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
|
||||||
|
|
||||||
|
sha256 = hashlib.sha256(data).hexdigest()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
ssh_conn = await connect_to_host(conn, host_id)
|
||||||
|
try:
|
||||||
|
async with ssh_conn.start_sftp_client() as sftp:
|
||||||
|
async with sftp.open(remote_path, "wb") as remote_file:
|
||||||
|
await remote_file.write(data)
|
||||||
|
finally:
|
||||||
|
ssh_conn.close()
|
||||||
|
except HostNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||||
|
|
||||||
|
await _log_transfer(
|
||||||
|
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
|
||||||
|
filename=file.filename or remote_path, size=len(data), sha256=sha256, av_scan_result=av_result,
|
||||||
|
)
|
||||||
|
return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{host_id}/files/download")
|
||||||
|
async def download_file(
|
||||||
|
host_id: int,
|
||||||
|
request: Request,
|
||||||
|
remote_path: str = Query(..., max_length=1024),
|
||||||
|
host=Depends(_require_file_transfer),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
ssh_conn = await connect_to_host(conn, host_id)
|
||||||
|
try:
|
||||||
|
async with ssh_conn.start_sftp_client() as sftp:
|
||||||
|
stat = await sftp.stat(remote_path)
|
||||||
|
if stat.size and stat.size > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
|
||||||
|
async with sftp.open(remote_path, "rb") as remote_file:
|
||||||
|
data = await remote_file.read()
|
||||||
|
finally:
|
||||||
|
ssh_conn.close()
|
||||||
|
except HostNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||||
|
|
||||||
|
sha256 = hashlib.sha256(data).hexdigest()
|
||||||
|
filename = remote_path.rsplit("/", 1)[-1]
|
||||||
|
await _log_transfer(
|
||||||
|
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download",
|
||||||
|
filename=filename, size=len(data), sha256=sha256, av_scan_result="not_applicable_download",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _iter():
|
||||||
|
yield data
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_iter(),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
144
app/ssh_proxy/terminal_ws.py
Normal file
144
app/ssh_proxy/terminal_ws.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
Browser-Terminal <-> SSH-Ziel per WebSocket (xterm.js-kompatibel).
|
||||||
|
|
||||||
|
Framing: JSON-Textframes.
|
||||||
|
Client -> Server: {"type":"input","data":"<base64>"} | {"type":"resize","cols":n,"rows":n}
|
||||||
|
Server -> Client: {"type":"output","data":"<base64>"} | {"type":"error","message":"..."} | {"type":"closed"}
|
||||||
|
|
||||||
|
Jede Session wird aufgezeichnet (app.recordings.recorder) und im Audit-Log
|
||||||
|
mit Start/Ende vermerkt (Konzept 4.2, 4.7, 6.5).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.auth.deps import get_current_user_ws
|
||||||
|
from app.db import get_db
|
||||||
|
from app.rbac import user_has_role_for_host
|
||||||
|
from app.recordings.recorder import SessionRecorder
|
||||||
|
from app.security.audit import write_audit_event
|
||||||
|
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||||
|
|
||||||
|
logger = logging.getLogger("jumphost.ssh_proxy.ws")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_SESSION_SECONDS = 8 * 3600
|
||||||
|
IDLE_TIMEOUT_SECONDS = 15 * 60
|
||||||
|
|
||||||
|
|
||||||
|
async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await process.stdout.read(65536)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = data.encode("utf-8", errors="replace")
|
||||||
|
recorder.record("output", base64.b64encode(data).decode())
|
||||||
|
await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()})
|
||||||
|
except (asyncssh.Error, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/ssh/{host_id}")
|
||||||
|
async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||||
|
user = await get_current_user_ws(websocket)
|
||||||
|
if user is None:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
if not user.is_admin and not await user_has_role_for_host(
|
||||||
|
conn, user_id=user.id, host_id=host_id, role_name="ssh_connect"
|
||||||
|
):
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
client_ip = websocket.client.host if websocket.client else "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = await load_host(conn, host_id)
|
||||||
|
except HostNotConfiguredError as exc:
|
||||||
|
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||||
|
await websocket.close(code=4404)
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', ?)",
|
||||||
|
(user.id, host_id, client_ip),
|
||||||
|
)
|
||||||
|
session_id = cursor.lastrowid
|
||||||
|
recorder = SessionRecorder(session_id)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id)
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="ssh_session_start", user_id=user.id, client_ip=client_ip,
|
||||||
|
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
end_reason = "logout"
|
||||||
|
ssh_conn = None
|
||||||
|
process = None
|
||||||
|
pump_task = None
|
||||||
|
try:
|
||||||
|
ssh_conn = await connect_to_host(conn, host_id)
|
||||||
|
process = await ssh_conn.create_process(term_type="xterm-256color")
|
||||||
|
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(websocket.receive_json(), timeout=IDLE_TIMEOUT_SECONDS)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
end_reason = "idle_timeout"
|
||||||
|
break
|
||||||
|
|
||||||
|
if msg.get("type") == "input":
|
||||||
|
raw = base64.b64decode(msg.get("data", ""))
|
||||||
|
recorder.record("input", base64.b64encode(raw).decode())
|
||||||
|
process.stdin.write(raw.decode("utf-8", errors="replace"))
|
||||||
|
elif msg.get("type") == "resize":
|
||||||
|
cols, rows = int(msg.get("cols", 80)), int(msg.get("rows", 24))
|
||||||
|
process.change_terminal_size(cols, rows)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
end_reason = "logout"
|
||||||
|
except asyncssh.Error as exc:
|
||||||
|
logger.warning("SSH-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||||
|
end_reason = "error"
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "error", "message": "Verbindung zum Zielsystem fehlgeschlagen"})
|
||||||
|
except Exception:
|
||||||
|
# Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client;
|
||||||
|
# der eigentliche Fehler ist bereits oben geloggt (logger.warning).
|
||||||
|
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||||
|
except HostNotConfiguredError:
|
||||||
|
end_reason = "error"
|
||||||
|
finally:
|
||||||
|
if pump_task:
|
||||||
|
pump_task.cancel()
|
||||||
|
if process:
|
||||||
|
process.close()
|
||||||
|
if ssh_conn:
|
||||||
|
ssh_conn.close()
|
||||||
|
recorder.close()
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(end_reason, session_id),
|
||||||
|
)
|
||||||
|
await write_audit_event(
|
||||||
|
conn, event_type="ssh_session_end", user_id=user.id, client_ip=client_ip,
|
||||||
|
details={"host_id": host_id, "session_id": session_id, "reason": end_reason},
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)
|
||||||
BIN
jumphost.zip
BIN
jumphost.zip
Binary file not shown.
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
6
requirements-dev.txt
Normal file
6
requirements-dev.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.1.1
|
||||||
|
pytest-asyncio==1.4.0
|
||||||
|
httpx==0.27.2
|
||||||
|
bandit==1.9.4
|
||||||
|
pip-audit==2.9.*
|
||||||
17
requirements.txt
Normal file
17
requirements.txt
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Lockfile-artig auf exakte, per pip-audit geprüfte Versionen gepinnt
|
||||||
|
# (Konzept 6.6: "Abhaengigkeiten fixiert, regelmaessiger Scan mit pip-audit").
|
||||||
|
# Stand: pip-audit-Lauf vom 2026-08-19, 0 bekannte Schwachstellen.
|
||||||
|
fastapi==0.141.1
|
||||||
|
starlette==1.6.0
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
aiosqlite==0.20.0
|
||||||
|
asyncssh==2.18.0
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
pyotp==2.9.0
|
||||||
|
qrcode[pil]==7.4.2
|
||||||
|
pydantic==2.9.2
|
||||||
|
jinja2==3.1.6
|
||||||
|
python-multipart==0.0.32
|
||||||
|
cryptography==50.0.0
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
python-magic==0.4.27
|
||||||
56
scripts/create_admin.py
Normal file
56
scripts/create_admin.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Legt einmalig den ersten Admin-User an (interaktive Passwortabfrage).
|
||||||
|
|
||||||
|
Aufruf (nach Deployment, siehe ansible/roles/sqlite_init):
|
||||||
|
<venv>/bin/python scripts/create_admin.py --username admin
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.db import close_db, get_db, init_db # noqa: E402
|
||||||
|
from app.security.passwords import hash_password # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
async def main(username: str) -> None:
|
||||||
|
await init_db()
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (username,))
|
||||||
|
if await cursor.fetchone() is not None:
|
||||||
|
print(f"Benutzer '{username}' existiert bereits.", file=sys.stderr)
|
||||||
|
await close_db()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
password = getpass.getpass("Initiales Passwort (min. 12 Zeichen): ")
|
||||||
|
if len(password) < 12:
|
||||||
|
print("Passwort zu kurz.", file=sys.stderr)
|
||||||
|
await close_db()
|
||||||
|
sys.exit(1)
|
||||||
|
confirm = getpass.getpass("Passwort wiederholen: ")
|
||||||
|
if password != confirm:
|
||||||
|
print("Passwoerter stimmen nicht ueberein.", file=sys.stderr)
|
||||||
|
await close_db()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||||
|
"VALUES (?, ?, 1, 1)",
|
||||||
|
(username, hash_password(password)),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
await close_db()
|
||||||
|
print(f"Admin-User '{username}' angelegt. TOTP-Einrichtung erfolgt beim ersten Login.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--username", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(main(args.username))
|
||||||
40
scripts/fetch_frontend_assets.sh
Normal file
40
scripts/fetch_frontend_assets.sh
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Laedt xterm.js und guacamole-common-js lokal herunter (npm) und kopiert die
|
||||||
|
# fertigen Dist-Dateien nach static/js/vendor/ bzw. static/js/vendor/.
|
||||||
|
#
|
||||||
|
# Bewusst KEIN CDN-Bezug zur Laufzeit im Browser (Hardening-Konzept 4.1/6.6) --
|
||||||
|
# der Download passiert einmalig zur Build-/Deployzeit, im Betrieb werden die
|
||||||
|
# Assets ausschliesslich lokal vom Jumphost selbst ausgeliefert.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
VENDOR_DIR="$PROJECT_ROOT/static/js/vendor"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$VENDOR_DIR"
|
||||||
|
|
||||||
|
echo "==> Installiere xterm.js + Addon 'fit' + guacamole-common-js nach $TMP_DIR"
|
||||||
|
cd "$TMP_DIR"
|
||||||
|
npm init -y >/dev/null
|
||||||
|
npm install --no-audit --no-fund \
|
||||||
|
xterm@5 \
|
||||||
|
xterm-addon-fit@0.8 \
|
||||||
|
guacamole-common-js@1.5
|
||||||
|
|
||||||
|
echo "==> Kopiere Dist-Dateien nach $VENDOR_DIR"
|
||||||
|
cp node_modules/xterm/lib/xterm.js "$VENDOR_DIR/xterm.js"
|
||||||
|
cp node_modules/xterm/css/xterm.css "$VENDOR_DIR/xterm.css"
|
||||||
|
cp node_modules/xterm-addon-fit/lib/xterm-addon-fit.js "$VENDOR_DIR/xterm-addon-fit.js"
|
||||||
|
|
||||||
|
# guacamole-common-js liefert nur ESM/CJS-Builds aus, keinen fertigen
|
||||||
|
# Browser-Global-Build. Fuer die Einbindung per klassischem <script>-Tag
|
||||||
|
# (kein CDN, kein <script type=module> noetig) wird die CJS-Variante
|
||||||
|
# genommen und das abschliessende `module.exports = Guacamole;` durch eine
|
||||||
|
# Zuweisung auf window ersetzt.
|
||||||
|
cp node_modules/guacamole-common-js/dist/cjs/guacamole-common.js "$VENDOR_DIR/guacamole-common.js"
|
||||||
|
sed -i '$ d' "$VENDOR_DIR/guacamole-common.js" # letzte Zeile (module.exports=...) entfernen
|
||||||
|
echo "window.Guacamole = Guacamole;" >> "$VENDOR_DIR/guacamole-common.js"
|
||||||
|
|
||||||
|
echo "==> Fertig. Assets liegen unter $VENDOR_DIR"
|
||||||
90
static/css/app.css
Normal file
90
static/css/app.css
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--border: #2a2f3a;
|
||||||
|
--text: #e6e8eb;
|
||||||
|
--muted: #9aa3af;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--ok: #22c55e;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.center-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.card h1 { font-size: 1.25rem; margin: 0 0 1.25rem; }
|
||||||
|
label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0.75rem 0 0.25rem; }
|
||||||
|
input[type=text], input[type=password] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: #0f1218;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { filter: brightness(1.1); }
|
||||||
|
.error { color: var(--danger); font-size: 0.85rem; margin-top: 0.75rem; min-height: 1em; }
|
||||||
|
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
|
||||||
|
.qr { display: block; margin: 1rem auto; border-radius: 6px; }
|
||||||
|
.recovery-codes { font-family: monospace; background: #0f1218; padding: 0.75rem; border-radius: 6px; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||||
|
}
|
||||||
|
.topbar .brand { font-weight: 700; }
|
||||||
|
.topbar button { width: auto; margin: 0; padding: 0.4rem 0.9rem; font-size: 0.85rem; }
|
||||||
|
.container { padding: 1.25rem; max-width: 960px; margin: 0 auto; }
|
||||||
|
.group { margin-bottom: 1.5rem; }
|
||||||
|
.group h2 { font-size: 1rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.host-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.75rem; }
|
||||||
|
.host-card {
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 1rem;
|
||||||
|
}
|
||||||
|
.host-card .hostname { font-weight: 600; }
|
||||||
|
.host-card .meta { color: var(--muted); font-size: 0.8rem; margin: 0.25rem 0 0.75rem; }
|
||||||
|
.host-card .actions { display: flex; gap: 0.5rem; }
|
||||||
|
.host-card .actions a, .host-card .actions button {
|
||||||
|
flex: 1; text-align: center; text-decoration: none; padding: 0.4rem; border-radius: 6px;
|
||||||
|
background: var(--accent); color: white; font-size: 0.85rem; margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-shell { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
.session-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem;
|
||||||
|
background: var(--panel); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.session-toolbar button { width: auto; margin: 0; padding: 0.35rem 0.8rem; font-size: 0.8rem; }
|
||||||
|
.session-toolbar .spacer { flex: 1; }
|
||||||
|
.session-toolbar .status { font-size: 0.8rem; color: var(--muted); }
|
||||||
|
#terminal, #rdp-display { flex: 1; background: black; }
|
||||||
|
#rdp-display canvas { display: block; margin: 0 auto; }
|
||||||
74
static/js/dashboard.js
Normal file
74
static/js/dashboard.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
async function getJson(url) {
|
||||||
|
const res = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "/";
|
||||||
|
throw new Error("nicht angemeldet");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostCard(host) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "host-card";
|
||||||
|
|
||||||
|
const name = document.createElement("div");
|
||||||
|
name.className = "hostname";
|
||||||
|
name.textContent = host.hostname;
|
||||||
|
div.appendChild(name);
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "meta";
|
||||||
|
meta.textContent = `${host.protocol.toUpperCase()} · ${host.os_type} · ${host.address}`;
|
||||||
|
div.appendChild(meta);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "actions";
|
||||||
|
|
||||||
|
const connect = document.createElement("a");
|
||||||
|
connect.href = host.protocol === "ssh" ? `/terminal/${host.id}` : `/rdp/${host.id}`;
|
||||||
|
connect.textContent = "Verbinden";
|
||||||
|
actions.appendChild(connect);
|
||||||
|
|
||||||
|
div.appendChild(actions);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const me = await getJson("/auth/me");
|
||||||
|
document.getElementById("whoami").textContent = `${me.username}${me.is_admin ? " (Admin)" : ""}`;
|
||||||
|
|
||||||
|
const hosts = await getJson("/catalog/hosts");
|
||||||
|
const byGroup = {};
|
||||||
|
for (const h of hosts) {
|
||||||
|
(byGroup[h.host_group_name] = byGroup[h.host_group_name] || []).push(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById("groups");
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
container.innerHTML = '<p class="hint">Keine Hosts zugewiesen. Bitte an einen Administrator wenden.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [groupName, groupHosts] of Object.entries(byGroup)) {
|
||||||
|
const section = document.createElement("div");
|
||||||
|
section.className = "group";
|
||||||
|
const h2 = document.createElement("h2");
|
||||||
|
h2.textContent = groupName;
|
||||||
|
section.appendChild(h2);
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "host-list";
|
||||||
|
for (const host of groupHosts) list.appendChild(hostCard(host));
|
||||||
|
section.appendChild(list);
|
||||||
|
container.appendChild(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("logout-btn").addEventListener("click", async () => {
|
||||||
|
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
|
||||||
|
main().catch((err) => console.error(err));
|
||||||
|
})();
|
||||||
85
static/js/login.js
Normal file
85
static/js/login.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const form = document.getElementById("login-form");
|
||||||
|
const passwordFields = document.getElementById("password-fields");
|
||||||
|
const totpFields = document.getElementById("totp-fields");
|
||||||
|
const enrollBox = document.getElementById("enroll-box");
|
||||||
|
const recoveryBox = document.getElementById("recovery-box");
|
||||||
|
const errorBox = document.getElementById("error-box");
|
||||||
|
const submitBtn = document.getElementById("submit-btn");
|
||||||
|
|
||||||
|
let pendingToken = null;
|
||||||
|
let mode = "password"; // password -> totp | enroll_start -> enroll_confirm -> done
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(url, body) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.detail || "Unbekannter Fehler");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
showError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === "password") {
|
||||||
|
const username = document.getElementById("username").value.trim();
|
||||||
|
const password = document.getElementById("password").value;
|
||||||
|
const result = await postJson("/auth/login", { username, password });
|
||||||
|
pendingToken = result.pending_token;
|
||||||
|
|
||||||
|
passwordFields.style.display = "none";
|
||||||
|
totpFields.style.display = "block";
|
||||||
|
|
||||||
|
if (!result.totp_enrolled) {
|
||||||
|
mode = "enroll_start";
|
||||||
|
const enroll = await postJson("/auth/totp/enroll/start", { pending_token: pendingToken });
|
||||||
|
document.getElementById("qr-img").src = "data:image/png;base64," + enroll.qr_png_base64;
|
||||||
|
enrollBox.style.display = "block";
|
||||||
|
mode = "enroll_confirm";
|
||||||
|
submitBtn.textContent = "TOTP bestaetigen & einrichten";
|
||||||
|
} else {
|
||||||
|
mode = "totp";
|
||||||
|
submitBtn.textContent = "Code bestaetigen";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "totp") {
|
||||||
|
const code = document.getElementById("totp-code").value.trim();
|
||||||
|
await postJson("/auth/login/totp", { pending_token: pendingToken, code });
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "enroll_confirm") {
|
||||||
|
const code = document.getElementById("totp-code").value.trim();
|
||||||
|
const result = await postJson("/auth/totp/enroll/confirm", { pending_token: pendingToken, code });
|
||||||
|
recoveryBox.style.display = "block";
|
||||||
|
document.getElementById("recovery-codes").textContent = result.recovery_codes.join("\n");
|
||||||
|
submitBtn.textContent = "Weiter zum Dashboard";
|
||||||
|
mode = "done";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "done") {
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
80
static/js/rdp.js
Normal file
80
static/js/rdp.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* RDP-Session-Client: guacamole-common-js <-> /ws/rdp/{host_id}.
|
||||||
|
*
|
||||||
|
* Vollbild: native Browser-Fullscreen-API (Konzept 4.3).
|
||||||
|
* Copy & Paste: bidirektionale Synchronisation ueber Guacamole.Client
|
||||||
|
* onclipboard-Event (Ziel -> Browser) und den `paste`-Browser-Event
|
||||||
|
* (Browser -> Ziel, sendet eine "clipboard"-Instruktion). Wird serverseitig
|
||||||
|
* zusaetzlich blockiert, wenn fuer den Host clipboard_enabled=false ist
|
||||||
|
* (siehe app/rdp_proxy/ws_tunnel.py) -- das UI blendet den Hinweis dann ein.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const hostId = window.JUMPHOST_HOST_ID;
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const shell = document.getElementById("session-shell");
|
||||||
|
const displayDiv = document.getElementById("rdp-display");
|
||||||
|
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const width = Math.round(window.innerWidth);
|
||||||
|
const height = Math.round(window.innerHeight - 40);
|
||||||
|
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
|
||||||
|
|
||||||
|
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}?width=${width}&height=${height}&dpi=${dpi}`;
|
||||||
|
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
||||||
|
const client = new Guacamole.Client(tunnel);
|
||||||
|
|
||||||
|
displayDiv.appendChild(client.getDisplay().getElement());
|
||||||
|
|
||||||
|
client.onstatechange = (state) => {
|
||||||
|
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
|
||||||
|
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
|
||||||
|
statusEl.textContent = labels[state] || `Status ${state}`;
|
||||||
|
};
|
||||||
|
client.onerror = (err) => {
|
||||||
|
statusEl.textContent = "Fehler: " + (err.message || "unbekannt");
|
||||||
|
};
|
||||||
|
|
||||||
|
client.onclipboard = (stream, mimetype) => {
|
||||||
|
if (!mimetype.startsWith("text/")) return;
|
||||||
|
const reader = new Guacamole.StringReader(stream);
|
||||||
|
let data = "";
|
||||||
|
reader.ontext = (text) => { data += text; };
|
||||||
|
reader.onend = () => {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(data).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
client.connect();
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => client.disconnect());
|
||||||
|
|
||||||
|
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
|
||||||
|
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (mouseState) => {
|
||||||
|
client.sendMouseState(mouseState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyboard = new Guacamole.Keyboard(document);
|
||||||
|
keyboard.onkeydown = (keysym) => client.sendKeyEvent(1, keysym);
|
||||||
|
keyboard.onkeyup = (keysym) => client.sendKeyEvent(0, keysym);
|
||||||
|
|
||||||
|
document.addEventListener("paste", (ev) => {
|
||||||
|
const text = (ev.clipboardData || window.clipboardData).getData("text");
|
||||||
|
if (!text) return;
|
||||||
|
const stream = client.createClipboardStream("text/plain");
|
||||||
|
const writer = new Guacamole.StringWriter(stream);
|
||||||
|
writer.sendText(text);
|
||||||
|
writer.sendEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fullscreen-btn").addEventListener("click", () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
shell.requestFullscreen().catch(() => {});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
100
static/js/terminal.js
Normal file
100
static/js/terminal.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* SSH-Terminal-Client: xterm.js <-> /ws/ssh/{host_id}.
|
||||||
|
*
|
||||||
|
* Vollbild: native Browser-Fullscreen-API auf dem Session-Container (Konzept 4.3).
|
||||||
|
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die
|
||||||
|
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine
|
||||||
|
* serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const hostId = window.JUMPHOST_HOST_ID;
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const shell = document.getElementById("session-shell");
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily: "Menlo, Consolas, monospace",
|
||||||
|
fontSize: 14,
|
||||||
|
theme: { background: "#000000" },
|
||||||
|
});
|
||||||
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.open(document.getElementById("terminal"));
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
function b64encode(str) {
|
||||||
|
return btoa(unescape(encodeURIComponent(str)));
|
||||||
|
}
|
||||||
|
function b64decode(b64) {
|
||||||
|
return decodeURIComponent(escape(atob(b64)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`);
|
||||||
|
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
statusEl.textContent = "Verbunden";
|
||||||
|
sendResize();
|
||||||
|
});
|
||||||
|
ws.addEventListener("close", () => { statusEl.textContent = "Verbindung beendet"; });
|
||||||
|
ws.addEventListener("error", () => { statusEl.textContent = "Verbindungsfehler"; });
|
||||||
|
|
||||||
|
ws.addEventListener("message", (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg.type === "output") {
|
||||||
|
term.write(b64decode(msg.data));
|
||||||
|
} else if (msg.type === "error") {
|
||||||
|
statusEl.textContent = "Fehler: " + msg.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "input", data: b64encode(data) }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendResize() {
|
||||||
|
fitAddon.fit();
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", sendResize);
|
||||||
|
|
||||||
|
document.getElementById("fullscreen-btn").addEventListener("click", () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
shell.requestFullscreen().catch(() => {});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("fullscreenchange", sendResize);
|
||||||
|
|
||||||
|
const fileInput = document.getElementById("file-input");
|
||||||
|
document.getElementById("upload-btn").addEventListener("click", () => fileInput.click());
|
||||||
|
fileInput.addEventListener("change", async () => {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const remotePath = prompt("Zielpfad auf dem Server:", `/tmp/${file.name}`);
|
||||||
|
if (!remotePath) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
statusEl.textContent = `Lade ${file.name} hoch ...`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
|
||||||
|
{ method: "POST", credentials: "same-origin", body: formData }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
|
||||||
|
statusEl.textContent = `Upload ok (AV: ${data.av_scan_result})`;
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = "Upload-Fehler: " + err.message;
|
||||||
|
}
|
||||||
|
fileInput.value = "";
|
||||||
|
});
|
||||||
|
})();
|
||||||
21
templates/dashboard.html
Normal file
21
templates/dashboard.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Jumphost Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="brand">Jumphost</div>
|
||||||
|
<div>
|
||||||
|
<span class="hint" id="whoami"></span>
|
||||||
|
<button id="logout-btn">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container" id="groups"></div>
|
||||||
|
|
||||||
|
<script src="/static/js/dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
templates/login.html
Normal file
46
templates/login.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Jumphost Login</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="center-screen">
|
||||||
|
<div class="card">
|
||||||
|
<h1>Jumphost Anmeldung</h1>
|
||||||
|
|
||||||
|
<form id="login-form" data-step="password">
|
||||||
|
<div id="password-fields">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input type="text" id="username" autocomplete="username" required>
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input type="password" id="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="totp-fields" style="display:none">
|
||||||
|
<label for="totp-code">Code aus der Authenticator-App</label>
|
||||||
|
<input type="text" id="totp-code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code">
|
||||||
|
<p class="hint">Bei erstmaliger Anmeldung: Secret unten scannen, danach den 6-stelligen Code eingeben.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="enroll-box" style="display:none">
|
||||||
|
<img id="qr-img" class="qr" alt="TOTP QR-Code" width="180" height="180">
|
||||||
|
<p class="hint">Mit einer Authenticator-App (z.B. Aegis, FreeOTP) scannen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="recovery-box" style="display:none">
|
||||||
|
<p class="hint">Recovery-Codes -- jetzt sicher speichern, werden nicht erneut angezeigt:</p>
|
||||||
|
<div id="recovery-codes" class="recovery-codes"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="submit-btn">Anmelden</button>
|
||||||
|
<div class="error" id="error-box"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
templates/rdp.html
Normal file
23
templates/rdp.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>RDP-Session</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="session-shell" id="session-shell">
|
||||||
|
<div class="session-toolbar">
|
||||||
|
<button id="fullscreen-btn">Vollbild</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="status" id="status">Verbinde ...</span>
|
||||||
|
</div>
|
||||||
|
<div id="rdp-display"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/vendor/guacamole-common.js"></script>
|
||||||
|
<script>window.JUMPHOST_HOST_ID = {{ host_id }};</script>
|
||||||
|
<script src="/static/js/rdp.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user