homelab-showcase/06-vps-strato.md

14 KiB
Raw Blame History

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

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

curl -fsSL https://get.docker.com | sh

4. Tailscale (mit Subnet-Route-Akzeptanz — dieser Host braucht sie tatsächlich)

curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --authkey=<AUTH_KEY> --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_SUBNET>-LAN, braucht die vom PVE-Host advertiste Subnet-Route also tatsächlich, um das Heimnetz zu erreichen (NAS, AdGuard etc.).

Verifikation:

ping -c3 <LAN_IP_ADGUARD>    # AdGuard
ping -c3 <LAN_IP_NAS>   # NAS
getent hosts nas.pve adguard.pve   # DNS-Override auf AdGuard sollte greifen

5. Guacamole: Grundgerüst (guacd + Postgres + Webapp)

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

DBPASS=$(openssl rand -base64 24 | tr -d '=+/')

cat > /opt/guacamole/docker-compose.yml <<EOF
services:
  guacd:
    image: guacamole/guacd:1.6.0
    container_name: guacd
    restart: unless-stopped
    networks:
      - guac-net

  postgres:
    image: postgres:16-alpine
    container_name: guac-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: guacamole_db
      POSTGRES_USER: guacamole_user
      POSTGRES_PASSWORD: "$DBPASS"
    volumes:
      - ./pg-data:/var/lib/postgresql/data
      - ./jdbc-schema:/docker-entrypoint-initdb.d:ro
    networks:
      - guac-net

  guacamole:
    image: guacamole/guacamole:1.6.0
    container_name: guacamole
    restart: unless-stopped
    environment:
      GUACD_HOSTNAME: guacd
      POSTGRESQL_HOSTNAME: postgres
      POSTGRESQL_DATABASE: guacamole_db
      POSTGRESQL_USER: guacamole_user
      POSTGRESQL_PASSWORD: "$DBPASS"
    volumes:
      - ./extensions:/etc/guacamole/extensions:ro
      - ./guac-home:/etc/guacamole/data
    ports:
      - "127.0.0.1:8080:8080"
      - "<TS_IP_VPS>: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.

python3 -c "
import hashlib, secrets
pw = '<Passwort>'
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:

HASH="<oben berechnet>"
SALT="<oben berechnet>"
docker exec -i guac-postgres psql -U guacamole_user -d guacamole_db <<EOSQL
BEGIN;
INSERT INTO guacamole_entity (name, type) VALUES ('<PRIMARY_USER>', '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='<PRIMARY_USER>' AND type='USER';
INSERT INTO guacamole_system_permission (entity_id, permission)
SELECT entity_id, 'ADMINISTER' FROM guacamole_entity WHERE name='<PRIMARY_USER>' AND type='USER';
COMMIT;
EOSQL

Verbindungen (Beispiel NAS-SFTP, analog für RDP/SSH):

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', '<LAN_IP_NAS>' 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', '<PRIMARY_USER>' 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='<PRIMARY_USER>' 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, <PVE_HOSTNAME>) 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: <LAN_IP_NAS> (NAS), <LAN_IP_PC1> (PC1), <LAN_IP_PVE> (PVE-Host für den WoL-Trigger).

Weitere Verbindungen nach demselben Muster:

  • "PC1 RDP": protocol=rdp, hostname=<LAN_IP_PC1>, domain=pc1 (lokales Windows-Konto, nicht Domain-Konto!), port=3389, username=<PRIMARY_USER>, ignore-cert=true
  • "Wake PC1": protocol=ssh, hostname=<LAN_IP_PVE>, port=22, username=wolonly, private-key=<Inhalt von /root/.ssh/wol_vps_key vom PVE-Host>, 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 57 erfüllt.

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
    DELETE FROM guacamole_user_attribute WHERE user_id = <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.<DOMAIN><VPS-öffentliche-IP> im STRATO-DNS-Panel gesetzt (kein CLI-Schritt).

apt-get install -y caddy

cat > /etc/caddy/Caddyfile <<'EOF'
guac.<DOMAIN> {
	@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).

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

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.<DOMAIN> öffentlich, überall Caddy/Let's-Encrypt-TLS + Guacamole-Login (Passwort + TOTP) + Brute-Force-Ban (5 Versuche → 5 Min Sperre)
http://<TS_IP_VPS>:8080/guacamole/ nur Tailnet wie oben, ohne öffentliche Exposition

Verifikation

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.<DOMAIN>/guacamole/
docker ps --format 'table {{.Names}}\t{{.Status}}'