second commit

This commit is contained in:
2026-08-19 22:33:19 +02:00
parent 411812e954
commit 199f306993
107 changed files with 5984 additions and 0 deletions

14
.gitignore vendored Normal file
View 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
View 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
View 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.

View 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

View File

@ -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

View File

@ -0,0 +1,4 @@
---
# Beispiel-Override fuer eine Umgebung MIT nginx (siehe Konzept 7.2/9).
enable_nginx_proxy: true
tls_mode: internal_pki

View 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"

View 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

View File

@ -0,0 +1,4 @@
---
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true

View 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

View 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

View 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

View File

@ -0,0 +1,10 @@
[Unit]
Description=Taeglicher Jumphost-Backup-Timer
[Timer]
OnCalendar=daily
RandomizedDelaySec=1800
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,5 @@
---
- name: restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted

View 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

View File

@ -0,0 +1,5 @@
---
- name: reload nftables
ansible.builtin.service:
name: nftables
state: restarted

View 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

View 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
}
}

View 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.

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -0,0 +1,5 @@
---
- name: reload nginx
ansible.builtin.service:
name: nginx
state: reloaded

View 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

View 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

View 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; }
}

View File

@ -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 %}

View 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.11.1.1.8 | `tasks/kernel_modules.yml` | Implementiert |
| Deaktivierung seltener Netzwerkprotokolle | 3.4.13.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.15.4.1.4 | `tasks/pam_password_policy.yml` | Implementiert |
| Erweiterte auditd-Regeln (Identität, Zeit, Netzwerk, Logins, Kernelmodule) | 4.1.34.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.11.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.

View 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

View 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

View 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

View 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"

View 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).

View 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

View 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'

View 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

View 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

View 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 == '')

View 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";

View 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"

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View File

0
app/admin/__init__.py Normal file
View File

340
app/admin/routes.py Normal file
View 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
View File

122
app/auth/deps.py Normal file
View 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
View 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
View File

67
app/catalog/routes.py Normal file
View 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
View 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
View 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

View 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'))
);

View 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;

View 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
View 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
View File

109
app/models/schemas.py Normal file
View 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
View 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)

View File

View 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
View 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)

View File

View 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
View File

70
app/security/audit.py Normal file
View 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
View 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
View 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
View 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

View 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

View 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
View 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
View 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()

View File

130
app/ssh_proxy/proxy.py Normal file
View 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
View 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}"'},
)

View 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)

Binary file not shown.

3
pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function

6
requirements-dev.txt Normal file
View 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
View 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
View 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))

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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