3 06 VPS STRATO
arnol edited this page 2026-08-07 19:05:46 +02:00
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 <DEBIAN_RELEASE> (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/<GUACAMOLE_VERSION>/binary/guacamole-auth-jdbc-<GUACAMOLE_VERSION>.tar.gz
tar xzf /tmp/jdbc.tar.gz -C /tmp
cp /tmp/guacamole-auth-jdbc-<GUACAMOLE_VERSION>/postgresql/schema/001-create-schema.sql /opt/guacamole/jdbc-schema/
rm -rf /tmp/jdbc.tar.gz /tmp/guacamole-auth-jdbc-<GUACAMOLE_VERSION>

# TOTP-Extension (wird tatsächlich gebraucht, nicht im Image enthalten)
curl -fsSL -o /tmp/totp.tar.gz https://downloads.apache.org/guacamole/<GUACAMOLE_VERSION>/binary/guacamole-auth-totp-<GUACAMOLE_VERSION>.tar.gz
tar xzf /tmp/totp.tar.gz -C /tmp
cp /tmp/guacamole-auth-totp-<GUACAMOLE_VERSION>/guacamole-auth-totp-<GUACAMOLE_VERSION>.jar /opt/guacamole/extensions/
rm -rf /tmp/totp.tar.gz /tmp/guacamole-auth-totp-<GUACAMOLE_VERSION>

⚠️ 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:<GUACAMOLE_VERSION>
    container_name: guacd
    restart: unless-stopped
    networks:
      - guac-net

  postgres:
    image: postgres:<POSTGRES_VERSION>-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:<GUACAMOLE_VERSION>
    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_PUBLIC_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).

12. SSH: nur über Tailscale erreichbar

SSH (Port 22) muss nach Abschluss der Ersteinrichtung nicht mehr öffentlich erreichbar sein — alle künftigen Änderungen laufen ausschließlich von Tailscale-verbundenen Geräten aus. Trotz Key-only-Auth + fail2ban erzeugt ein offener Port dauerhaftes Scan-/Bot-Rauschen ohne Nutzen (Fund eines externen Security-Reviews).

cat > /etc/ssh/sshd_config.d/tailscale-only.conf <<'EOF'
ListenAddress <TS_IP_VPS>
ListenAddress 127.0.0.1
ListenAddress ::1
EOF

mkdir -p /etc/systemd/system/ssh.service.d
cat > /etc/systemd/system/ssh.service.d/after-tailscale.conf <<'EOF'
[Unit]
After=tailscaled.service
Wants=tailscaled.service
EOF

sshd -t && systemctl daemon-reload && systemctl restart ssh.service

ListenAddress bindet sshd nur noch an die Tailscale-IP + Loopback statt an 0.0.0.0/[::]:22 — Port 22 verschwindet komplett aus dem öffentlichen Internet, bleibt aber über Tailscale normal erreichbar.

⚠️ NICHT TUN: ListenAddress auf die Tailscale-IP setzen, ohne ip_nonlocal_bind zu aktivieren. Beim Booten startet sshd, bevor tailscaled die Adresse vergeben hat. Der Bind scheitert:

sshd[848]: Server listening on 127.0.0.1 port 22.
sshd[848]: error: Bind to port 22 on <TS_IP_VPS> failed: Cannot assign requested address.

Tückisch daran: der Dienst bleibt active (running). Weil der Bind an Loopback gelingt, meldet systemd keinen Fehlschlag — von außen sieht es aus wie „SSH tot", intern ist alles grün. Ohne den Blick ins Journal sucht man am falschen Ende.

Ein Drop-In mit After=tailscaled.service reicht nicht: das garantiert nur, dass der Dienst gestartet wurde, nicht dass die IP schon zugewiesen ist. Korrekt ist:

echo 'net.ipv4.ip_nonlocal_bind=1' > /etc/sysctl.d/99-nonlocal-bind.conf
sysctl -p /etc/sysctl.d/99-nonlocal-bind.conf

Damit darf sich ein Prozess an eine Adresse binden, die es noch nicht gibt. Gegenprobe ohne Reboot — an eine nicht vergebene Adresse binden, das muss gelingen:

python3 -c "import socket; s=socket.socket(); s.bind(('<eine noch nicht vergebene Tailscale-IP>', 22222)); print('ok')"

⚠️ Die STRATO-VNC-Konsole liegt auf en-US. Ein deutsches Passwort mit z, y oder Sonderzeichen kommt dort anders an, als es getippt wird — das korrekte Passwort wirkt dann falsch. Beim Tippen die US-Belegung mitdenken. Genau daran ist auch der Fix zunächst gescheitert: in der Datei landete ein falscher, ähnlich klingender sysctl-Schlüssel statt net.ipv4.ip_nonlocal_bind=1. sysctl quittiert einen falschen Schlüssel mit cannot stat ... — wer die Meldung übersieht, hat eine Datei, die nach Lösung aussieht und nichts tut. Nach jedem sysctl-Eingriff den Laufzeitwert prüfen, nicht die Datei:

sysctl -n net.ipv4.ip_nonlocal_bind    # muss 1 liefern

⚠️ NICHT TUN: Stattdessen eine eigene Firewall-Regel (iptables/nftables) einziehen, um Port 22 auf öffentlichen Interfaces zu blocken. Das VPS-Regelwerk wird bereits dicht von drei Stellen dynamisch verwaltet — Tailscale selbst (ts-input/ts-forward-Chains, nft list ruleset markiert sie explizit als „managed by iptables-nft, do not touch!"), Docker (eigene DOCKER-*-Chains) und fail2ban (f2b-table). Eine eigene Regel (z.B. über den bisher deaktivierten nftables.service, dessen Default-Config mit flush ruleset startet) riskiert, Tailscales/Dockers Regeln beim Boot zu überschreiben, abhängig von der Start-Reihenfolge der systemd-Units. Die sshd-Bindung ist der isolierte, leicht zu verifizierende/revertierende Weg.

⚠️ NICHT TUN: Den systemd-Override fürs Boot-Ordering vergessen. sshd versucht beim Start sofort an die Tailscale-IP zu binden — existiert das tailscale0-Interface zu dem Zeitpunkt noch nicht (z.B. direkt nach einem Reboot, bevor tailscaled hochgefahren ist), schlägt der Bind fehl und sshd startet gar nicht, auch nicht über Tailscale. After=/Wants=tailscaled.service in ssh.service.d/ stellt die richtige Reihenfolge sicher.

Verifikation: ss -tlnp | grep :22 zeigt nur noch die Tailscale-IP/127.0.0.1:22/[::1]:22, kein 0.0.0.0:22 mehr; SSH über die Tailscale-IP weiterhin normal getestet. Externe Nicht-Erreichbarkeit lässt sich nicht von innerhalb des Tailnets verifizieren — dafür ein Gerät ohne Tailscale-Verbindung (z.B. Handy mit Mobilfunk, Tailscale aus) oder einen Online-Port-Checker nutzen.

Rollback: beide neuen Dateien löschen, systemctl daemon-reload && systemctl restart ssh.service — SSH ist sofort wieder auf allen Interfaces erreichbar.

root-authorized_keys: persönlicher Key jetzt über den Passwortmanager

Ein eingebautes SSH-Key-Feature des Passwortmanagers (Key-Erzeugung + SSH-Agent, privater Schlüssel verlässt den Vault nie) ersetzt den bisherigen, lokal auf einem Windows-PC liegenden Key (<PRIMARY_USER>@PC1) dagegen. Alter Key entfernt (lokal und aus authorized_keys), neuer Key zunächst als geteilter Eintrag (<PRIMARY_USER>@vaultwarden) ergänzt — auf VPS und PVE-Host identisch.

⚠️ Bewusst nur als Zwischenschritt dokumentiert, nicht als Zielzustand: ein einzelner Key für mehrere Systeme mit unterschiedlichem Risikoprofil (VPS öffentlich exponiert, PVE-Host nie) hebt die Kompromiss-Eindämmung teilweise wieder auf, die getrennte Keys bringen würden. Vollständige Individualisierung (ein dedizierter Key je System) ist der eigentliche Zielzustand — bewusst als eigene, offene Übung dokumentiert statt vom KI-Agenten automatisiert.

Zweiter, nicht eindeutig zugeordneter Altkey auf dem PVE-Host — Herkunft nicht mehr rekonstruierbar, bewusst nicht blind entfernt, sondern als offener Punkt dokumentiert.

Service-Keys bleiben davon unberührt (z. B. ein Backup-Pull-Key mit erzwungenem restrict,command="rrsync -ro /root/backups/" — kein interaktiver Zugriff, eigener Zweck, kein Teil dieser Aufräumaktion).

13. Tailscale Exit-Node

Bewusst auf dem VPS statt dem PVE-Host eingerichtet — Speedtest zeigt einen deutlich höheren Upload auf dem VPS gegen die vergleichsweise langsame Heimleitung (DS-Lite). Der PVE-Host bleibt reiner Subnet-Router, beide Rollen laufen gleichzeitig.

cat > /etc/sysctl.d/99-tailscale.conf <<'EOF'
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
EOF
sysctl -p /etc/sysctl.d/99-tailscale.conf

tailscale set --advertise-exit-node

⚠️ NICHT TUN: tailscale up mit neuen Flags absetzen, ohne alle bisherigen Flags (insbesondere --accept-routes) erneut mitzugeben — tailscale up ersetzt die komplette Flag-Menge, nicht nur die neu angegebenen. tailscale set ergänzt dagegen inkrementell, ohne Bestehendes zu verwerfen. Nach der Umstellung mit tailscale debug prefs prüfen, dass AdvertiseRoutes jetzt 0.0.0.0/0/::/0 enthält UND das Heimnetz weiterhin erreichbar ist (z.B. ping <LAN_IP_NAS>).

Danach in der Tailscale-Admin-Konsole (Machines → vps → „Edit route settings") den Exit-Node genehmigen — analog zur Subnet-Route-Genehmigung für den PVE-Host.

Firewall-Check (Docker + Exit-Node auf derselben Maschine): FORWARD-Chain hat policy DROP, DOCKER-USER/DOCKER-FORWARD laufen vor Tailscales eigener ts-forward-Chain. Kein Problem, solange DOCKER-USER leer ist und DOCKER-FORWARD nur die Docker-Bridge-Interfaces explizit behandelt (alles andere fällt durch zu ts-forward, das Tailscale selbst verwaltet) — mit iptables -L DOCKER-USER -n -v und iptables -L DOCKER-FORWARD -n -v verifizieren, bevor man vorschnell eine eigene FORWARD-Regel ergänzt (siehe auch die „NICHT TUN"-Warnung zu eigenen Firewall-Regeln in Abschnitt 12).

Verifikation: tailscale debug prefs | grep AdvertiseRoutes zeigt 0.0.0.0/0/::/0. Auf einem Testgerät in der Tailscale-App den Exit-Node auswählen, dann öffentliche IP prüfen (z.B. curl icanhazip.com) — muss die <VPS_PUBLIC_IP> zeigen. Parallel dazu Heimnetz-Zugriff (NAS/PVE-Web) testen, um zu bestätigen, dass Subnet-Router und Exit-Node gleichzeitig funktionieren.

⚠️ Geräteseitige Auswahl des Exit-Node ist kein Server-Schritt — jedes Gerät wählt seinen Exit-Node selbst in der jeweiligen Tailscale-App (Einstellungen → Exit-Node), das lässt sich nicht zentral vom VPS oder PVE-Host aus vorkonfigurieren.

14. Projekt-Übersichtsseite

Statische Einzelseite am Apex, kein Backend, kein Build-Schritt — Inhalt liegt versioniert im Doku-Repo unter website/index.html.

mkdir -p /var/www/homepage
scp website/index.html root@<VPS_PUBLIC_IP>:/var/www/homepage/index.html
ssh root@<VPS_PUBLIC_IP> 'chown -R caddy:caddy /var/www/homepage'
<DOMAIN> {
	import security_headers
	root * /var/www/homepage
	encode gzip
	file_server
}

www.<DOMAIN> {
	redir https://<DOMAIN>{uri} permanent
}

DNS: A-Record auf die Apex-Domain → VPS-IP, optional CNAME www → Apex-Domain (folgt damit automatisch der Apex-IP, kein zweiter A-Record nötig).

⚠️ NICHT TUN: beim Setzen des Apex-A-Records aus Versehen die MX-Records mit anfassen — beide stehen im selben DNS-Panel-Bereich, sind aber komplett unabhängig. Falls ein Mail-Postfach an der Domain hängt, vorher gegenchecken (dig MX), dass sich daran nichts ändert.

Update-Workflow: Datei im Repo editieren → scp aufs VPS → fertig. Kein Caddy-Reload nötig für reine Inhaltsänderungen (nur bei neuen Vhost-Blöcken).

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