umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -27,8 +27,45 @@ 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"
# Netze der Zielsysteme (fuer ausgehende Regeln der Output-Chain, siehe
# ansible/roles/firewall_nftables/templates/jumphost.nft.j2). War bislang
# 10.20.0.0/16 -- die tatsaechlichen RDP-/SSH-Ziele liegen in 10.0.0.0/8
# (Befund B5, Umsetzungsauftrag_Sonnet5.md Teil A.3): stand die Adresse
# ausserhalb dieses Bereichs, wurden guacds ausgehende RDP-Pakete von der
# Output-Chain (policy drop) still verworfen -- kein Reject, kein Log --
# und der Benutzer sah nur "Server timeout" statt eines erklaerbaren
# Fehlers. An das tatsaechliche Zielnetz anpassen, falls es abweicht.
target_networks:
- "10.0.0.0/8"
# --- Login-Rate-Limiting (E8, Umsetzungsauftrag Teil E) ----------------------
# Drei unabhaengige Bremsen, alle IP-basiert (siehe app/security/rate_limit.py,
# app/config.py login_rate_limit_*): App-Rate-Limiter (JUMPHOST_LOGIN_RATE_
# LIMIT_MAX_EVENTS/_WINDOW_S), dieses nginx-limit_req und die fail2ban-
# Schwelle unten. ACHTUNG: greifen pro Quell-IP -- sitzen mehrere interne
# Benutzer hinter derselben NAT-Adresse (typischer Buerofall), teilen sie
# sich dieses Budget, und ein paar Tippfehler beliebiger Kollegen koennen
# die ganze Gruppe aussperren. Der kontenbezogene Lockout
# (JUMPHOST_MAX_FAILED_LOGINS) bleibt davon unberuehrt und ist die
# eigentliche scharfe Bremse. Bewusst an die erwartete Umgebung anpassen
# (z.B. hier anheben und/oder bekannte interne Netze in der Firewall-Regel
# gesondert behandeln) und die Entscheidung im Betriebshandbuch festhalten
# -- NICHT ungeprueft mit den unten stehenden Standardwerten produktiv gehen,
# wenn mehrere Benutzer hinter derselben Adresse sitzen koennen.
jumphost_login_rate_limit: "10r/m" # nginx limit_req_zone rate=
jumphost_fail2ban_maxretry: 8 # fail2ban [jumphost-login] maxretry
# --- Obergrenzen gleichzeitiger Sitzungen (E.4, Umsetzungsauftrag Teil E) ----
# jumphost_uvicorn_limit_concurrency ist die AEUSSERSTE Bremse (uvicorn lehnt
# neue Verbindungen ab, bevor die Anwendung ueberhaupt beteiligt ist) und
# sollte daher spuerbar UEBER JUMPHOST_MAX_SESSIONS_GLOBAL liegen (siehe
# app/config.py) -- sie deckt zusaetzlich kurzlebige HTTP-Anfragen
# (Katalog, Dateitransfer, Adminbereich) mit ab, nicht nur WS-Sitzungen.
# jumphost_limit_nofile mit Marge fuer: 2x Sockets je Sitzung + guacd-
# Verbindung (RDP) + Aufzeichnungsdatei + normale Prozess-FDs.
jumphost_uvicorn_limit_concurrency: 150
jumphost_uvicorn_backlog: 100
jumphost_limit_nofile: 4096
# --- Backup --------------------------------------------------------------------
backup_dir: /var/backups/jumphost

View File

@ -28,11 +28,16 @@
group: root
mode: "0644"
content: |
# E8 (Umsetzungsauftrag Teil E): jumphost_fail2ban_maxretry ist ein
# group_vars-Wert (siehe ansible/inventory/group_vars/all.yml) --
# greift wie das nginx-limit_req und der App-Rate-Limiter pro
# Quell-IP und muss zusammen mit diesen als bewusste
# Betriebsentscheidung festgelegt werden.
[jumphost-login]
enabled = true
filter = jumphost-login
logpath = /var/log/jumphost/access.log
maxretry = 8
maxretry = {{ jumphost_fail2ban_maxretry }}
findtime = 300
bantime = 1800
notify: restart fail2ban

View File

@ -42,5 +42,14 @@ table inet filter {
# ACME/interne PKI (tls_certificates-Rolle) und OS-Paketquellen
tcp dport 443 accept
tcp dport 80 accept
# Befund B5 (Umsetzungsauftrag_Sonnet5.md Teil A.3): ohne diese Zeile
# verwirft "policy drop" alles Uebrige lautlos -- z.B. guacds RDP-Pakete
# zu einem Ziel ausserhalb von target_networks. Das sieht der Benutzer
# nur als "Server timeout" (guacamole-common-js), im Journal steht dazu
# nichts. Rate-limitiert geloggt, damit ein Fehlkonfigurationsfall
# (falsches/fehlendes target_networks-Netz) in Sekunden statt Tagen
# auffaellt, ohne das Journal bei einem Portscan zu fluten.
limit rate 10/second log prefix "jumphost-output-drop: " counter drop
}
}

View File

@ -157,9 +157,17 @@
when: _guacd_install_mode == 'source' and _guacd_build_mode == 'git' and _guacd_build_needed
- name: guacamole-server konfigurieren (nur RDP-Plugin, kein VNC/Telnet/guacenc)
# --sysconfdir=/etc ist Pflicht (Befund 3, Umsetzungsauftrag_Sonnet5.md
# Teil A.2): ohne diesen Schalter sucht guacd sein Konfigverzeichnis unter
# /usr/local/etc/guacamole, waehrend die Tasks weiter unten unveraendert
# nach /etc/guacamole schreiben. Solange guacd_port/bind_host auf den
# (unwirksamen) Defaults stehen faellt das nicht auf; sobald einer davon
# abweicht, lauscht guacd auf dem falschen Port/Host und die App bekommt
# "[Errno 111] Connection refused".
ansible.builtin.command:
cmd: >-
./configure --with-systemd-dir=/etc/systemd/system
--sysconfdir=/etc
--disable-guacenc --disable-guaclog
chdir: "{{ _guacd_src_dir }}"
when: _guacd_install_mode == 'source' and _guacd_build_needed
@ -185,14 +193,67 @@
when: _guacd_install_mode == 'source' and _guacd_build_needed
changed_when: false
- name: Pruefen, dass das RDP-Plugin tatsaechlich gebaut und installiert wurde
# Der Build ueberspringt das RDP-Plugin STILLSCHWEIGEND, wenn die
# FreeRDP-Header (_guacd_freerdp_pkg) beim configure-Lauf fehlten oder
# nicht gefunden wurden -- "make"/"make install" laufen trotzdem
# fehlerfrei durch. Ohne diese Pruefung faellt das erst zur Laufzeit auf,
# als "Support for protocol "rdp" is not installed" von guacd, mitten in
# einem Verbindungsversuch (Umsetzungsauftrag_Sonnet5.md Teil A.2, "Zu
# tun"-Liste). Beide moeglichen Installationspfade des Plugins abdecken:
# /usr/local/lib direkt (aeltere guacamole-server-Versionen) und
# /usr/local/lib/guacamole (aktuelle Versionen, eigenes Plugin-Verzeichnis).
ansible.builtin.find:
paths:
- /usr/local/lib
- /usr/local/lib/guacamole
patterns: "libguac-client-rdp.so*"
recurse: false
register: _guacd_rdp_plugin
when: _guacd_install_mode == 'source'
- name: Abbrechen, wenn das RDP-Plugin fehlt
ansible.builtin.fail:
msg: >-
guacd wurde gebaut, aber libguac-client-rdp.so* wurde weder unter
/usr/local/lib noch /usr/local/lib/guacamole gefunden. Vermutliche
Ursache: die FreeRDP-Entwicklungspakete ({{ _guacd_freerdp_pkg }})
waren beim './configure'-Lauf nicht vollstaendig installiert, das
RDP-Plugin wurde daraufhin stillschweigend uebersprungen. Pruefen:
'apt policy {{ _guacd_freerdp_pkg }}' auf dem Zielsystem, danach den
Build-Ordner ({{ _guacd_src_dir }}) loeschen und die Rolle erneut
ausfuehren, damit configure/make sauber neu laufen.
when: _guacd_install_mode == 'source' and (_guacd_rdp_plugin.matched | default(0)) == 0
# --- Gemeinsame Konfiguration (beide Pfade) ---------------------------------
- name: Dedizierten guacd-User sicherstellen (apt-Paket legt ihn i.d.R. bereits an)
# Befund B6: als Zweitgruppe "{{ jumphost_app_group }}" (append, nicht
# ersetzen) bekommt guacd das Traversal-Bit auf {{ jumphost_data_dir }}
# (siehe Rolle sqlite_init) und kann damit ueberhaupt erst bis zu
# rdp-drives/ vordringen. Ohne diese Zeile schlaegt jede RDP-Sitzung mit
# aktivierter Laufwerksumleitung mit EACCES fehl.
ansible.builtin.user:
name: guacd
system: true
shell: /usr/sbin/nologin
create_home: false
groups: "{{ jumphost_app_group }}"
append: true
notify: restart guacd # neue Gruppenmitgliedschaft wirkt erst nach einem Neustart des Prozesses
- name: Konfigurationsverzeichnis /etc/guacamole anlegen
# Befund 4 (Umsetzungsauftrag_Sonnet5.md Teil A.2): lineinfile mit
# create:true legt nur die FEHLENDE DATEI an, nicht das Verzeichnis --
# ohne diesen Task bricht die Rolle bei den beiden folgenden lineinfile-
# Tasks ab, wenn /etc/guacamole noch nicht existiert (frischer Host, oder
# apt-Pfad ohne vorherigen Paketaufruf).
ansible.builtin.file:
path: /etc/guacamole
state: directory
owner: root
group: root
mode: "0755"
- name: guacd nur an localhost binden
ansible.builtin.lineinfile:
@ -236,3 +297,67 @@
notify:
- reload systemd
- restart guacd
# --- Start-/Erreichbarkeitspruefung (Befunde 1, 2, 7 aus Umsetzungsauftrag_Sonnet5.md Teil A.2) ---
- name: Anstehende Handler (z.B. "restart guacd" nach Config-/Unit-Aenderung) sofort anwenden
# Befund 7: Handler werden von Ansible standardmaessig erst am ENDE DES
# GESAMTEN PLAYS geflusht, nicht am Ende dieser Rolle. Ohne dieses meta
# wuerde "restart guacd" erst NACH dem ersten Start von jumphost_app
# laufen (site.yml ordnet die Rollen zwar guacd -> jumphost_app, aber der
# Handler-Flush ist rollenuebergreifend ans Play-Ende gebunden) -- der
# allererste App-Start faende dann einen noch alten/toten guacd-Prozess vor.
ansible.builtin.meta: flush_handlers
- name: guacd aktivieren und starten (unabhaengig davon, ob gerade ein Handler ausgeloest wurde)
# Befund 1: die Rolle hatte bislang KEINEN expliziten service-Task, nur
# den Handler "restart guacd" (laeuft ausschliesslich bei einer
# Aenderung). Ein zweiter Ansible-Lauf ohne Aenderung meldete bisher
# ueberall "ok", auch wenn guacd zwischenzeitlich (Absturz, manuelles
# "systemctl stop", OOM-Kill) tot war -- das ist nach dem Debian-12-Wechsel
# die wahrscheinlichste verbleibende Ursache fuer
# "[Errno 111] Connection refused" auf einem sonst funktionierenden Build.
ansible.builtin.service:
name: guacd
state: started
enabled: true
- name: Warten, bis guacd tatsaechlich auf dem konfigurierten Port lauscht
# Befund 2: keine Startpruefung. Ohne wait_for bleibt ein fehlgeschlagener
# Start unbemerkt, bis der erste Benutzer eine RDP-Sitzung versucht --
# genau das Symptom aus dem gemeldeten Vorfall (Teil A.0).
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ guacd_port }}"
timeout: 15
state: started
register: _guacd_port_check
ignore_errors: true
- name: Abbrechen mit Klartext, wenn guacd nicht auf dem konfigurierten Port lauscht
ansible.builtin.fail:
msg: >-
guacd lauscht nach dem Start NICHT auf 127.0.0.1:{{ guacd_port }}
(Timeout nach 15s). Auf dem Zielsystem pruefen: 'systemctl status
guacd', 'journalctl -u guacd -n 50', 'ss -ltnp | grep {{ guacd_port }}'.
Haeufigste Ursachen laut Umsetzungsauftrag_Sonnet5.md Teil A.2: das
systemd-Hardening-Drop-in (ProtectSystem=strict) blockiert ein von
guacd benoetigtes Laufzeit-/Schreibverzeichnis, oder
/etc/guacamole/guacd.conf wird wegen eines fehlenden
--sysconfdir=/etc beim configure-Lauf gar nicht erst gelesen.
when: _guacd_port_check is failed
- name: Smoke-Test - lauschenden Port gegen die App-Konfiguration abgleichen
# Letzter Punkt aus der "Zu tun"-Liste (Teil A.2): der oben erfolgreich
# geprüfte Port ist bereits exakt derselbe Wert wie guacd_port, aus dem
# sowohl diese Rolle als auch app/config.py::settings.guacd_port (ueber
# die Rolle jumphost_app) gespeist werden -- ein Auseinanderlaufen ist
# durch die gemeinsame Variable strukturell ausgeschlossen. Trotzdem
# explizit sichtbar machen, damit ein kuenftiger Review sofort sieht, WO
# dieser Abgleich stattfindet, falls App- und guacd-Port je entkoppelt
# werden sollten.
ansible.builtin.debug:
msg: >-
guacd-Smoke-Test erfolgreich: guacd lauscht auf
127.0.0.1:{{ guacd_port }}, identisch mit dem von der App erwarteten
Port (Variable guacd_port, siehe app/config.py::settings.guacd_port).

View File

@ -24,16 +24,33 @@ LoadCredentialEncrypted=jumphost_session_secret:/etc/jumphost/jumphost_session_s
EnvironmentFile=/etc/jumphost/env
{% endif %}
# E.4 (Umsetzungsauftrag Teil E): --limit-concurrency/--backlog waren
# vorher gar nicht gesetzt (uvicorn-Standardwerte). Ohne Obergrenze kann ein
# Ansturm an Verbindungen (absichtlich oder nicht) den einzigen Worker-
# Prozess ueberlasten, bevor die anwendungsseitigen Sitzungsgrenzen
# (settings.max_sessions_*, app/security/active_sessions.py) je greifen
# koennen -- die laufen erst NACH Annahme der Verbindung. Werte ueber
# group_vars konfigurierbar (siehe ansible/inventory/group_vars/all.yml).
{% if enable_nginx_proxy %}
ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --uds {{ jumphost_listen_uds }}
ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --uds {{ jumphost_listen_uds }} \
--limit-concurrency {{ jumphost_uvicorn_limit_concurrency }} --backlog {{ jumphost_uvicorn_backlog }}
{% 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
--ssl-certfile /etc/jumphost/tls/server.crt --ssl-keyfile /etc/jumphost/tls/server.key \
--limit-concurrency {{ jumphost_uvicorn_limit_concurrency }} --backlog {{ jumphost_uvicorn_backlog }}
{% endif %}
Restart=on-failure
RestartSec=5
# E.4 (Umsetzungsauftrag Teil E): kein LimitNOFILE gesetzt bedeutet den
# ererbten systemd-Standard (i.d.R. 1024) statt eines bewusst gesetzten,
# dokumentierten Werts. Jede Sitzung kostet mindestens zwei Sockets
# (Browser-WebSocket, Ziel), RDP zusaetzlich die guacd-Verbindung, dazu
# die Aufzeichnungsdatei -- der Standardwert reicht fuer den geplanten
# Betrieb rechnerisch aus, gehoert aber explizit gesetzt statt geerbt.
LimitNOFILE={{ jumphost_limit_nofile }}
# --- Hardening (Konzept 6.7/8.1) ---------------------------------------------
NoNewPrivileges=true
ProtectSystem=strict

View File

@ -9,7 +9,13 @@ real_ip_header X-Forwarded-For;
real_ip_recursive on;
{% endif %}
limit_req_zone $binary_remote_addr zone=jumphost_login:10m rate=10r/m;
# E8 (Umsetzungsauftrag Teil E): jumphost_login_rate_limit ist ein
# group_vars-Wert (siehe ansible/inventory/group_vars/all.yml), keine
# feste Konstante -- muss zusammen mit app.config.settings.
# login_rate_limit_* und der fail2ban-Schwelle (ansible/roles/fail2ban)
# als bewusste Betriebsentscheidung festgelegt werden (IP-basiertes
# Limit, betrifft alle Benutzer hinter derselben NAT-Adresse gemeinsam).
limit_req_zone $binary_remote_addr zone=jumphost_login:10m rate={{ jumphost_login_rate_limit }};
server {
{% if tls_mode == 'external_reverse_proxy' and internal_hop_plaintext_accepted %}

View File

@ -4,12 +4,23 @@
# Start korrekt sind und legt optional den initialen Admin-User an.
- name: Datenverzeichnis-Rechte sicherstellen
# Befund B6 (Umsetzungsauftrag_Sonnet5.md Teil A.3): guacd (Rolle "guacd")
# laeuft als eigener Systemuser "guacd", nicht als "{{ jumphost_app_user }}",
# braucht aber Zugriff auf {{ jumphost_data_dir }}/rdp-drives fuer die
# RDP-Laufwerksumleitung. Mit reinem 0700 kommt guacd nicht einmal an
# diesem Verzeichnis VORBEI (Traversal-Rechte fehlen), unabhaengig vom
# systemd-ReadWritePaths-Hardening der guacd-Unit -- jeder Host mit
# file_transfer_enabled scheitert dann mit EACCES. 0710 gibt der Gruppe
# ausschliesslich das Traversal-Bit (kein Lesen/Auflisten von recordings/
# oder der DB-Datei) -- die guacd-Rolle nimmt den guacd-User dafuer in
# diese Gruppe auf. rdp-drives selbst bleibt zusaetzlich mit eigenen,
# engeren Rechten geschuetzt (guacd:guacd 0750, siehe Rolle "guacd").
ansible.builtin.file:
path: "{{ jumphost_data_dir }}"
state: directory
owner: "{{ jumphost_app_user }}"
group: "{{ jumphost_app_group }}"
mode: "0700"
mode: "0710"
- name: Pruefen, ob bereits eine DB existiert
ansible.builtin.stat: