# VPS (STRATO) — Tailscale-Bridge + Guacamole-Gateway — Rebuild-Runbook Rolle: Tailscale-Mitglied + Subnet-Router-Bridge (für den Fall, dass kein Tailscale-Client verfügbar ist), Guacamole als öffentlich erreichbares HTML5-Gateway für den Internetcafé-Fall. ## 1. OS-Installation (manueller Schritt im STRATO-Panel, nicht CLI) - Debian 13 (Konsistenz mit dem Rest der Infrastruktur) - Public SSH-Key beim Anlegen hinterlegen (Key-only, kein Passwort-Login) — Key vom PVE-Host: `cat /root/.ssh/id_rsa.pub` - Root-Passwort trotzdem setzen (nur als VNC-Konsolen-Fallback, falls SSH mal nicht geht) ⚠️ **NICHT TUN:** Plesk oder n8n beim Setup mitinstallieren. Die VPS-Rolle bleibt bewusst minimal (nur Tailscale-Bridge + Guacamole) — Plesk ist für Webhosting mit mehreren Domains gedacht (hier nicht gebraucht, frisst RAM auf einem 2-GB-VPS), n8n ist ein eigenes Projekt-Thema. ## 2. Grundsetup ```bash hostnamectl set-hostname vps echo '127.0.1.1 vps' >> /etc/hosts export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq apt-get upgrade -y -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" ``` ## 3. Docker ```bash curl -fsSL https://get.docker.com | sh ``` ## 4. Tailscale (mit Subnet-Route-Akzeptanz — dieser Host braucht sie tatsächlich) ```bash curl -fsSL https://tailscale.com/install.sh | sh tailscale up --authkey= --accept-routes ``` Anders als bei CT 102 (siehe `04-ct102-management-desktop.md`) ist `--accept-routes` hier **richtig und nötig** — der VPS sitzt nicht selbst im ``-LAN, braucht die vom PVE-Host advertiste Subnet-Route also tatsächlich, um das Heimnetz zu erreichen (NAS, AdGuard etc.). Verifikation: ```bash ping -c3 # AdGuard ping -c3 # NAS getent hosts nas.pve adguard.pve # DNS-Override auf AdGuard sollte greifen ``` ## 5. Guacamole: Grundgerüst (guacd + Postgres + Webapp) ```bash mkdir -p /opt/guacamole/{extensions,guac-home,jdbc-schema,pg-data} # Postgres-JDBC-Schema besorgen (nur die Schema-Datei, NICHT die postgresql-Extension-Jar — siehe unten) curl -fsSL -o /tmp/jdbc.tar.gz https://downloads.apache.org/guacamole/1.6.0/binary/guacamole-auth-jdbc-1.6.0.tar.gz tar xzf /tmp/jdbc.tar.gz -C /tmp cp /tmp/guacamole-auth-jdbc-1.6.0/postgresql/schema/001-create-schema.sql /opt/guacamole/jdbc-schema/ rm -rf /tmp/jdbc.tar.gz /tmp/guacamole-auth-jdbc-1.6.0 # TOTP-Extension (wird tatsächlich gebraucht, nicht im Image enthalten) curl -fsSL -o /tmp/totp.tar.gz https://downloads.apache.org/guacamole/1.6.0/binary/guacamole-auth-totp-1.6.0.tar.gz tar xzf /tmp/totp.tar.gz -C /tmp cp /tmp/guacamole-auth-totp-1.6.0/guacamole-auth-totp-1.6.0.jar /opt/guacamole/extensions/ rm -rf /tmp/totp.tar.gz /tmp/guacamole-auth-totp-1.6.0 ``` ⚠️ **NICHT TUN:** die `guacamole-auth-jdbc-postgresql-*.jar` manuell in `extensions/` legen. Das offizielle `guacamole/guacamole`-Docker-Image bringt diese Extension bereits eingebaut mit und aktiviert sie automatisch über die `POSTGRESQL_*`-Umgebungsvariablen (siehe Compose-Datei unten). Eine manuell hinzugefügte zweite Kopie führt zu einer Namenskollision (zwei `[postgresql]`-Provider gleichzeitig geladen) und macht den Login kaputt ("Invalid login" trotz korrektem Passwort). ⚠️ **NICHT TUN:** `002-create-admin-user.sql` mit einspielen. Das legt den Standard-Account `guacadmin`/`guacadmin` an. Stattdessen den eigenen Nutzer direkt per SQL anlegen (Schritt 7) — kein Zeitfenster mit bekanntem Standard-Passwort. ## 6. Docker Compose ```bash DBPASS=$(openssl rand -base64 24 | tr -d '=+/') cat > /opt/guacamole/docker-compose.yml <:8080:8080" networks: - guac-net depends_on: - guacd - postgres networks: guac-net: EOF cd /opt/guacamole docker compose up -d postgres # erst Postgres, damit das Schema importiert wird sleep 10 docker compose up -d # dann den Rest ``` `127.0.0.1:8080` + Tailscale-IP `:8080` gebunden — **nie an `0.0.0.0`**, sonst wäre Guacamole ungeschützt (kein TLS) direkt öffentlich erreichbar. Öffentlicher Zugriff läuft ausschließlich über Caddy (Schritt 9). ## 7. Nutzer + Verbindungen per SQL anlegen Passwort-Hash-Formel (Guacamole-JDBC-spezifisch, **nicht** bcrypt wie bei AdGuard Home): ``` password_hash = SHA256(UTF8(password + HEX_UPPERCASE(salt))) ``` Salt wird als **Hex-String an das Passwort angehängt**, nicht als rohe Bytes verknüpft. ```bash python3 -c " import hashlib, secrets pw = '' salt = secrets.token_bytes(32) salt_hex = salt.hex().upper() h = hashlib.sha256((pw + salt_hex).encode('utf-8')).hexdigest() print('HASH:' + h) print('SALT:' + salt_hex) " ``` ⚠️ **NICHT TUN:** Passwort und **rohe** Salt-Bytes verketten (`password.encode() + salt_bytes`). Das ist falsch und führt zu "Invalid login" trotz korrektem Passwort — die tatsächliche Formel verkettet den **Hex-String** des Salts (als Text), nicht die Binärdaten. (Nachgeprüft im Guacamole-Quellcode: `SHA256PasswordEncryptionService`.) Nutzer + Admin-Recht: ```bash HASH="" SALT="" docker exec -i guac-postgres psql -U guacamole_user -d guacamole_db <', 'USER'); INSERT INTO guacamole_user (entity_id, password_hash, password_salt, password_date) SELECT entity_id, decode('${HASH}','hex'), decode('${SALT}','hex'), now() FROM guacamole_entity WHERE name='' AND type='USER'; INSERT INTO guacamole_system_permission (entity_id, permission) SELECT entity_id, 'ADMINISTER' FROM guacamole_entity WHERE name='' AND type='USER'; COMMIT; EOSQL ``` Verbindungen (Beispiel NAS-SFTP, analog für RDP/SSH): ```sql INSERT INTO guacamole_connection (connection_name, protocol) VALUES ('NAS Dateien (SSH/SFTP)', 'ssh'); INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) SELECT connection_id, 'hostname', '' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) SELECT connection_id, 'port', '22' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) SELECT connection_id, 'username', '' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) SELECT connection_id, 'enable-sftp', 'true' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) SELECT connection_id, 'sftp-root-directory', '/srv/freigabe' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; INSERT INTO guacamole_connection_permission (entity_id, connection_id, permission) SELECT e.entity_id, c.connection_id, 'READ' FROM guacamole_entity e, guacamole_connection c WHERE e.name='' AND e.type='USER' AND c.connection_name='NAS Dateien (SSH/SFTP)'; ``` **Passwort bewusst nicht als Parameter gesetzt** — Guacamole fragt es beim Verbinden ab, kein Klartext in der DB. ⚠️ **NICHT TUN:** Hostnamen wie `nas.pve` oder MagicDNS-Namen (`pc1`, ``) als `hostname`-Parameter in Guacamole-Verbindungen verwenden. Der `guacd`-Container läuft in einem eigenen Docker-Netzwerk und löst diese Namen nicht zuverlässig auf (Docker ersetzt den DNS-Resolver). Stattdessen **immer IP-Adressen** verwenden: `` (NAS), `` (PC1), `` (PVE-Host für den WoL-Trigger). Weitere Verbindungen nach demselben Muster: - **"PC1 RDP"**: `protocol=rdp`, `hostname=`, `domain=pc1` (lokales Windows-Konto, nicht Domain-Konto!), `port=3389`, `username=`, `ignore-cert=true` - **"Wake PC1"**: `protocol=ssh`, `hostname=`, `port=22`, `username=wolonly`, `private-key=`, `command=/usr/local/bin/wake-pc1.sh` ⚠️ **NICHT TUN (RDP):** Falls „Authentication failure (invalid credentials?)" trotz korrektem Passwort kommt — meist fehlt der `domain`-Parameter bei einem lokalen Windows-Konto. Mit `whoami` auf dem Windows-Rechner den echten Kontonamen prüfen (`RECHNERNAME\Username`), `domain` entsprechend setzen. Zum Testen, ob ein Windows-Passwort grundsätzlich stimmt (unabhängig von RDP): `runas /user:RECHNERNAME\Username cmd` direkt am Windows-Gerät. ⚠️ **NICHT TUN (private-key für "Wake PC1"):** Verschlüsselte SSH-Keys im neuen OpenSSH-Format (ed25519, mit Passphrase) funktionieren oft nicht mit Guacamoles `libssh2` ("Unable to extract public key ... Unsupported private key file format"). Falls ein Key mit Passphrase gebraucht wird: RSA im alten PEM-Format erzeugen (`ssh-keygen -m PEM -t rsa -b 4096 ...`), das ist kompatibel. Für "Wake PC1" reicht aber ein **unverschlüsselter** Key (kein Passphrase-Prompt gewünscht, da automatisiert ausgelöst). Zusätzliche Nutzer (z.B. Ehefrau) mit eingeschränktem Zugriff, gleiches Muster wie oben, aber nur die gewünschte(n) Verbindung(en) per `guacamole_connection_permission` freigeben, kein `guacamole_system_permission`. ## 8. TOTP-2FA aktivieren TOTP funktioniert **nur** mit Datenbank-Auth (nicht mit dateibasierter `user-mapping.xml`-Auth) — Voraussetzung ist also bereits mit Schritt 5–7 erfüllt. ```bash chown -R 1001:1001 /opt/guacamole/guac-home docker restart guacamole ``` ⚠️ **NICHT TUN:** `/opt/guacamole/guac-home` root-owned lassen. Der Guacamole-Container läuft intern als UID 1001 (`guacamole`-User) — ohne Schreibrecht auf dieses Verzeichnis schlägt die TOTP-Erstregistrierung **still** fehl (kein Fehler im Log, einfach kein QR-Code, Login funktioniert normal ohne 2FA weiter). Danach: beim ersten Login jedes Nutzers erscheint automatisch ein QR-Code zur Ersteinrichtung (kein weiterer Konfigurationsschritt nötig). **Backup/Recovery falls Gerät mit Authenticator-App verloren geht:** - Bevorzugt: TOTP-Secret zusätzlich auf ein zweites, unabhängiges Gerät legen (z.B. Partner/in), bevor es gebraucht wird - Absoluter Notfall (kein Gerät mit dem Secret mehr verfügbar): direkter DB-Reset erzwingt bei nächstem Login einen neuen QR-Code ```sql DELETE FROM guacamole_user_attribute WHERE user_id = AND attribute_name LIKE 'guac-totp%'; ``` TOTP-Secret selbst liegt in `guacamole_user_attribute` (Attribute `guac-totp-key-secret`, `guac-totp-key-confirmed`), keine eigene Tabelle. ⚠️ **NICHT TUN:** Email-basiertes 2FA suchen/erwarten. Guacamole unterstützt offiziell nur **Duo** und **TOTP**, kein E-Mail-OTP. ## 9. Caddy (öffentlicher Reverse Proxy mit Auto-HTTPS) Voraussetzung: Domain bei STRATO konnektiert (dauert ggf., bis DENIC-Registrierung durch ist — separater Schritt im STRATO-Panel), A-Record `guac.` → `` im STRATO-DNS-Panel gesetzt (kein CLI-Schritt). ```bash apt-get install -y caddy cat > /etc/caddy/Caddyfile <<'EOF' guac. { @root path / redir @root /guacamole/ reverse_proxy 127.0.0.1:8080 } EOF systemctl reload caddy ``` Caddy holt sich automatisch ein Let's-Encrypt-Zertifikat, sobald der A-Record propagiert ist (mehrere Versuche in Intervallen, kein manuelles Eingreifen nötig — ggf. `systemctl restart caddy` um einen sofortigen neuen Versuch zu erzwingen). ⚠️ **NICHT TUN:** Guacamole zusätzlich direkt öffentlich auf Port 8080 exponieren (`0.0.0.0:8080` in der Compose-Datei). Nur Caddy (80/443) und SSH (22, Key-only) sind öffentlich — Guacamole selbst bleibt hinter `127.0.0.1` + Tailscale-IP. ## 10. Log-Hygiene: fail2ban + journald-Deckel Ohne das wächst SSH-Scan-Rauschen unbegrenzt und kann den VPS-Speicher füllen (in der Praxis beobachtet: ~4 GB/Woche bei einem vergleichbaren Setup ohne diese Maßnahmen). ```bash apt-get install -y fail2ban cat > /etc/fail2ban/jail.local <<'EOF' [DEFAULT] bantime = 1h findtime = 10m maxretry = 4 backend = systemd [sshd] enabled = true port = 22 EOF systemctl enable --now fail2ban ``` ```bash sed -i 's/^#SystemMaxUse=/SystemMaxUse=200M/' /etc/systemd/journald.conf systemctl restart systemd-journald ``` ⚠️ **NICHT TUN:** fail2ban auch auf Containern installieren, die nur über Tailscale erreichbar sind (z.B. CT103/Vaultwarden). Ohne öffentliche Erreichbarkeit gibt es keinen Angriffsverkehr zum Bannen — reine Verschwendung. ## 11. Docker-Log-Limits Docker-Container loggen standardmäßig **unbegrenzt** (`json-file`-Treiber ohne Limit) — betrifft normale Betriebslogs, nicht nur Angriffsverkehr, daher unabhängig von fail2ban relevant. In `docker-compose.yml` per YAML-Anchor für alle Services: ```yaml x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "3" services: guacd: # ... logging: *default-logging postgres: # ... logging: *default-logging guacamole: # ... logging: *default-logging ``` Nach Änderung: `docker compose up -d` (Container-Neustart nötig, Limits gelten nur für neu erstellte Container, nicht rückwirkend). ## Zugriffswege (Zusammenfassung) | Weg | Erreichbarkeit | Absicherung | |---|---|---| | `https://guac.` | öffentlich, überall | Caddy/Let's-Encrypt-TLS + Guacamole-Login (Passwort + TOTP) + Brute-Force-Ban (5 Versuche → 5 Min Sperre) | | `http://:8080/guacamole/` | nur Tailnet | wie oben, ohne öffentliche Exposition | ## Verifikation ```bash curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/guacamole/ curl -s -o /dev/null -w "%{http_code}\n" https://guac./guacamole/ docker ps --format 'table {{.Names}}\t{{.Status}}' ```