homelab-showcase/06-vps-strato.md

312 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=<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:
```bash
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)
```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 <<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.
```bash
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:
```bash
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):
```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', '<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.
```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 = <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).
```bash
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).
```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.<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
```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.<DOMAIN>/guacamole/
docker ps --format 'table {{.Names}}\t{{.Status}}'
```