diff --git a/backend/.env b/backend/.env index 6299bca..baa125f 100644 --- a/backend/.env +++ b/backend/.env @@ -46,6 +46,11 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=d75959918d9ccc5c89c62edbd6e6c6af82d6e2a3d303c53a6f3328e94a05b60a ###< lexik/jwt-authentication-bundle ### +###> App ### +# Portal-Domain (für On-Demand-TLS-Autorisierung). In Prod auf die echte Domain setzen. +APP_PORTAL_DOMAIN=localhost +###< App ### + ###> S3 / Object Storage (Druck-Assets) ### # Lokal: MinIO. Prod: Hetzner Object Storage (Werte in .env.local / Server-Env setzen). S3_ENDPOINT=http://minio:9000 diff --git a/backend/src/Controller/TlsCheckController.php b/backend/src/Controller/TlsCheckController.php new file mode 100644 index 0000000..a9e43b0 --- /dev/null +++ b/backend/src/Controller/TlsCheckController.php @@ -0,0 +1,44 @@ +query->get('domain'))); + if ('' === $host) { + return new Response('missing domain', 400); + } + + $portal = strtolower($this->portalDomain); + if ($host === $portal || $host === 'www.'.$portal) { + return new Response('ok', 200); + } + + if (null !== $domains->findVerifiedByHostname($host)) { + return new Response('ok', 200); + } + + return new Response('not allowed', 403); + } +} diff --git a/backend/src/Repository/DomainRepository.php b/backend/src/Repository/DomainRepository.php index e9aec09..1250a94 100644 --- a/backend/src/Repository/DomainRepository.php +++ b/backend/src/Repository/DomainRepository.php @@ -15,4 +15,10 @@ class DomainRepository extends ServiceEntityRepository { parent::__construct($registry, Domain::class); } + + /** Verifizierte (TLS-fähige) Domain anhand des Hostnamens. */ + public function findVerifiedByHostname(string $hostname): ?Domain + { + return $this->findOneBy(['hostname' => $hostname, 'status' => Domain::STATUS_VERIFIED]); + } } diff --git a/deploy/README.md b/deploy/README.md index b78a18a..f592bd8 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,14 +3,22 @@ Provisioniert einen **Multi-Node-Test** mit nachgewiesener Skalierbarkeit: ``` - Hetzner Load Balancer (lb11, :80 → /health) - / \ - vcard4-app-1 vcard4-app-2 (zustandslose App-Nodes) - \ / - ┌─────────┴───────────┐ ┌─────┴──────────────────┐ - vcard4-db (MariaDB, Volume) Hetzner Object Storage (S3, Assets) + Internet (HTTPS) + │ + vcard4-caddy ── TLS: Portal-Domain (automatisch) + Custom-Domains (On-Demand) + (Reverse-Proxy + Load-Balancing) + / \ + vcard4-app-1 vcard4-app-2 (zustandslose App-Nodes, HTTP im privaten Netz) + \ / + ┌─────────┴──────────┐ ┌────────┴───────────────┐ + vcard4-db (MariaDB+Vol) Hetzner Object Storage (S3, Assets) ``` +**Caddy** übernimmt TLS (Let's Encrypt) und Load-Balancing — kein Hetzner-LB nötig. +Für **Custom-Domains** der Firmenkunden (§11) macht Caddy *On-Demand-TLS* und fragt +vorher `GET /internal/tls-allowed?domain=…` in der App (Portal-Domain oder verifizierte +Domain aus der DB) → schützt vor unbegrenzten Zertifikatsanfragen. + App-Nodes sind zustandslos (Code + Docker), **State** liegt in DB (eigene VM) und Object Storage (Hintergrund-PDFs/Schriften). Dadurch beliebig horizontal skalierbar. @@ -27,8 +35,9 @@ Object Storage (Hintergrund-PDFs/Schriften). Dadurch beliebig horizontal skalier # → backend/config/jwt/private.pem & public.pem + Passphrase aus .env (JWT_PASSPHRASE) ``` Inhalt der beiden PEM-Dateien + Passphrase in `terraform.tfvars` eintragen. -5. **DNS**: nach `terraform apply` einen A-Record `domain → load_balancer_ip` setzen - (Hetzner DNS ist separat; A-Record auch für die Custom-Domain-Funktion, KONZEPT §11). +5. **DNS**: A-Record `domain → caddy_ip` (+ optional `*.zone → caddy_ip` für Subdomains). + Entweder manuell **oder** automatisch über die **Hetzner DNS API** (`manage_dns = true` + + `hetzner_dns_token` + `dns_zone_name`; Zone muss bei Hetzner DNS liegen). ## Deploy @@ -49,28 +58,36 @@ Deploy-Log auf dem Node: `/var/log/vcard4-deploy.log`. ## Skalierbarkeit verifizieren ```bash -# 1) Login + Token holen (LB-IP oder Domain) -TOKEN=$(curl -s -X POST http:///api/login \ +# 1) Login über die Domain (Caddy → App-Nodes) +TOKEN=$(curl -s -X POST https:///api/login \ -H 'Content-Type: application/json' \ -d '{"email":"admin@vcard4reseller.de","password":"admin"}' | jq -r .token) -# 2) Health über den LB (verteilt auf beide Nodes) -for i in $(seq 1 6); do curl -s http:///health; echo; done +# 2) Health über Caddy (round-robin auf beide Nodes) +for i in $(seq 1 6); do curl -s https:///health; echo; done -# 3) Cross-Node-Beweis: Hintergrund über den LB hochladen, dann +# 3) Cross-Node-Beweis: Hintergrund über die Domain hochladen, dann # auf BEIDEN Nodes rendern (per SSH) – identische PDFs aus Object Storage: ssh root@ 'cd /opt/vcard4 && docker compose -f deploy/compose/docker-compose.prod.yml exec -T php php bin/console app:render-card erika-mustermann' ssh root@ 'cd /opt/vcard4 && docker compose -f deploy/compose/docker-compose.prod.yml exec -T php php bin/console app:render-card erika-mustermann' ``` -Horizontal skalieren: `app_count` erhöhen → `terraform apply` (LB-Targets werden automatisch ergänzt). +## Skalieren + +`app_count` erhöhen → `terraform apply` legt neue App-Nodes an. **Achtung:** die +Caddy-Upstreams stehen in der Caddyfile (per cloud-init beim ersten Boot gerendert) — +neue Nodes werden **nicht automatisch** aufgenommen. Optionen: Caddy-Node neu erstellen +(`terraform taint hcloud_server.caddy && apply`) oder Caddyfile auf dem Caddy-Node +aktualisieren + `docker exec caddy caddy reload`. (Später besser: Caddy-Config-Templating +per CI oder Service-Discovery.) ## Noch offen / Hinweise -- **TLS**: aktuell HTTP am LB. Für HTTPS entweder Hetzner **Managed Certificate** - am LB (DNS bei Hetzner) oder **Caddy** auf den Nodes (On-Demand-TLS) — letzteres - ist auch der Weg für die **Custom-Domains** der Firmenkunden (§11). -- **Trusted Proxies**: für korrekte absolute URLs hinter dem LB +- **TLS**: erledigt Caddy (Portal automatisch, Custom-Domains On-Demand). Erste + Zertifikatsausstellung dauert ein paar Sekunden nach korrektem DNS. +- **Trusted Proxies**: für korrekte absolute URLs hinter Caddy `framework.trusted_proxies` auf `%env(TRUSTED_PROXIES)%` setzen. +- **Host-basiertes Routing** (Custom-Domain → richtige Firmenseite) ist §11-Folgearbeit; + Caddy stellt bereits Zertifikate für verifizierte Domains aus. - **Seed**: optional einmalig `app:seed` auf `app-1` für Demo-Daten. - **Updates**: neuen Stand ausrollen = auf den App-Nodes `git pull` + `docker compose ... up -d --build` (später per CI/Skript). diff --git a/deploy/compose/nginx.prod.conf b/deploy/compose/nginx.prod.conf index 901eb39..d2320e0 100644 --- a/deploy/compose/nginx.prod.conf +++ b/deploy/compose/nginx.prod.conf @@ -11,8 +11,8 @@ server { client_max_body_size 32m; - # Symfony-Pfade (API + serverseitige öffentliche Seiten) - location ~ ^/(api|p|t|css|bundles|health)(/|$) { + # Symfony-Pfade (API + serverseitige öffentliche Seiten + interne Endpunkte) + location ~ ^/(api|p|t|css|bundles|health|internal)(/|$) { root /app/public; try_files $uri /index.php$is_args$args; } diff --git a/deploy/terraform/cloud-init-app.yaml.tftpl b/deploy/terraform/cloud-init-app.yaml.tftpl index 92b5064..ead08d4 100644 --- a/deploy/terraform/cloud-init-app.yaml.tftpl +++ b/deploy/terraform/cloud-init-app.yaml.tftpl @@ -7,6 +7,7 @@ write_files: APP_ENV=prod APP_DEBUG=0 APP_SECRET=${app_secret} + APP_PORTAL_DOMAIN=${domain} DATABASE_URL="${database_url}" CORS_ALLOW_ORIGIN=${cors_allow_origin} TRUSTED_PROXIES=10.0.0.0/16 diff --git a/deploy/terraform/cloud-init-caddy.yaml.tftpl b/deploy/terraform/cloud-init-caddy.yaml.tftpl new file mode 100644 index 0000000..f8f8394 --- /dev/null +++ b/deploy/terraform/cloud-init-caddy.yaml.tftpl @@ -0,0 +1,40 @@ +#cloud-config +package_update: true +write_files: + - path: /opt/caddy/Caddyfile + permissions: '0644' + content: | + { + email ${acme_email} + on_demand_tls { + # Caddy fragt die App, ob es für die Domain ein Zertifikat ausstellen darf + ask http://${ask_upstream}/internal/tls-allowed + } + } + + # Portal (Haupt-Domain): automatisches TLS, Load-Balancing über die App-Nodes + ${domain}, www.${domain} { + reverse_proxy ${app_upstreams} { + lb_policy round_robin + } + } + + # Custom-Domains der Firmenkunden: On-Demand-TLS (nur erlaubte Hosts) + https:// { + tls { + on_demand + } + reverse_proxy ${app_upstreams} { + lb_policy round_robin + } + } +runcmd: + - command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh + - mkdir -p /opt/caddy/data /opt/caddy/config + - | + docker run -d --name caddy --restart unless-stopped \ + -p 80:80 -p 443:443 -p 443:443/udp \ + -v /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro \ + -v /opt/caddy/data:/data \ + -v /opt/caddy/config:/config \ + caddy:2 diff --git a/deploy/terraform/dns.tf b/deploy/terraform/dns.tf new file mode 100644 index 0000000..3c1999d --- /dev/null +++ b/deploy/terraform/dns.tf @@ -0,0 +1,32 @@ +# Optional: DNS-Records über die Hetzner DNS API anlegen (manage_dns = true). +# Voraussetzung: Zone liegt bei Hetzner DNS, separater DNS-API-Token. + +data "hetznerdns_zone" "zone" { + count = var.manage_dns ? 1 : 0 + name = var.dns_zone_name +} + +locals { + # Relativer Record-Name: "@" wenn Portal == Zone, sonst der Subdomain-Teil + portal_record_name = var.domain == var.dns_zone_name ? "@" : replace(var.domain, ".${var.dns_zone_name}", "") +} + +# Portal-Domain → Caddy +resource "hetznerdns_record" "portal" { + count = var.manage_dns ? 1 : 0 + zone_id = data.hetznerdns_zone.zone[0].id + name = local.portal_record_name + type = "A" + value = hcloud_server.caddy.ipv4_address + ttl = 300 +} + +# Wildcard für Firmen-Subdomains (KONZEPT §11) → Caddy (On-Demand-TLS) +resource "hetznerdns_record" "wildcard" { + count = var.manage_dns ? 1 : 0 + zone_id = data.hetznerdns_zone.zone[0].id + name = "*" + type = "A" + value = hcloud_server.caddy.ipv4_address + ttl = 300 +} diff --git a/deploy/terraform/main.tf b/deploy/terraform/main.tf index 22e13cf..8a04ac4 100644 --- a/deploy/terraform/main.tf +++ b/deploy/terraform/main.tf @@ -1,9 +1,13 @@ locals { - db_private_ip = "10.0.1.10" - lb_private_ip = "10.0.1.5" - app_base_ip = 20 # App-Nodes: 10.0.1.20, .21, ... + db_private_ip = "10.0.1.10" + caddy_private_ip = "10.0.1.5" + app_base_ip = 20 # App-Nodes: 10.0.1.20, .21, ... database_url = "mysql://${var.db_user}:${var.db_password}@${local.db_private_ip}:3306/${var.db_name}?serverVersion=11.4.0-MariaDB&charset=utf8mb4" + + # Caddy-Upstreams = private IPs der App-Nodes (:80) + app_upstreams = join(" ", [for i in range(var.app_count) : "10.0.1.${local.app_base_ip + i}:80"]) + ask_upstream = "10.0.1.${local.app_base_ip}" # app-1 für die On-Demand-TLS-Abfrage } resource "hcloud_ssh_key" "admin" { @@ -117,43 +121,54 @@ resource "hcloud_server" "app" { depends_on = [hcloud_network_subnet.subnet, hcloud_server.db] } -# --- Load Balancer --- -resource "hcloud_load_balancer" "lb" { - name = "vcard4-lb" - load_balancer_type = "lb11" - location = var.location -} - -resource "hcloud_load_balancer_network" "lb_net" { - load_balancer_id = hcloud_load_balancer.lb.id - network_id = hcloud_network.net.id - ip = local.lb_private_ip -} - -resource "hcloud_load_balancer_target" "app" { - count = var.app_count - type = "server" - load_balancer_id = hcloud_load_balancer.lb.id - server_id = hcloud_server.app[count.index].id - use_private_ip = true - depends_on = [hcloud_load_balancer_network.lb_net] -} - -resource "hcloud_load_balancer_service" "http" { - load_balancer_id = hcloud_load_balancer.lb.id - protocol = "http" - listen_port = 80 - destination_port = 80 - - health_check { - protocol = "http" - port = 80 - interval = 10 - timeout = 5 - retries = 3 - http { - path = "/health" - status_codes = ["2??"] - } +# --- Caddy-Edge (TLS-Terminierung + Reverse-Proxy/Load-Balancing) --- +resource "hcloud_firewall" "caddy" { + name = "vcard4-caddy-fw" + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = [var.admin_cidr] + } + rule { + direction = "in" + protocol = "tcp" + port = "80" + source_ips = ["0.0.0.0/0", "::/0"] + } + rule { + direction = "in" + protocol = "tcp" + port = "443" + source_ips = ["0.0.0.0/0", "::/0"] + } + rule { + direction = "in" + protocol = "udp" + port = "443" + source_ips = ["0.0.0.0/0", "::/0"] } } + +resource "hcloud_server" "caddy" { + name = "vcard4-caddy" + server_type = var.app_server_type + image = "ubuntu-24.04" + location = var.location + ssh_keys = [hcloud_ssh_key.admin.id] + firewall_ids = [hcloud_firewall.caddy.id] + + user_data = templatefile("${path.module}/cloud-init-caddy.yaml.tftpl", { + acme_email = var.acme_email + domain = var.domain + app_upstreams = local.app_upstreams + ask_upstream = local.ask_upstream + }) + + network { + network_id = hcloud_network.net.id + ip = local.caddy_private_ip + } + + depends_on = [hcloud_network_subnet.subnet, hcloud_server.app] +} diff --git a/deploy/terraform/outputs.tf b/deploy/terraform/outputs.tf index 21c6d74..dbdf088 100644 --- a/deploy/terraform/outputs.tf +++ b/deploy/terraform/outputs.tf @@ -1,6 +1,6 @@ -output "load_balancer_ip" { - description = "Öffentliche IP des Load Balancers → hierauf den DNS-A-Record setzen" - value = hcloud_load_balancer.lb.ipv4 +output "caddy_ip" { + description = "Öffentliche IP des Caddy-Edge → hierauf den DNS-A-Record (Portal + *.) setzen" + value = hcloud_server.caddy.ipv4_address } output "app_server_ips" { diff --git a/deploy/terraform/terraform.tfvars.example b/deploy/terraform/terraform.tfvars.example index b82f7dd..2ef8d0f 100644 --- a/deploy/terraform/terraform.tfvars.example +++ b/deploy/terraform/terraform.tfvars.example @@ -14,8 +14,14 @@ db_server_type = "cx22" # Anwendung repo_url = "https://github.com/DEIN-USER/vcard4reseller.git" # privat: Token in URL repo_branch = "main" -domain = "test.example.com" -app_secret = "GENERIERE_32_HEX" # z. B. openssl rand -hex 16 +domain = "test.example.com" # Portal-Domain (ins Portal einloggen) +acme_email = "admin@example.com" # Let's Encrypt +app_secret = "GENERIERE_32_HEX" # z. B. openssl rand -hex 16 + +# DNS optional über Hetzner DNS API (sonst A-Record manuell auf caddy_ip setzen) +manage_dns = false +hetzner_dns_token = "" # separater DNS-API-Token +dns_zone_name = "" # z. B. example.com # Datenbank db_password = "STARKES_PASSWORT" diff --git a/deploy/terraform/variables.tf b/deploy/terraform/variables.tf index 1f63c2b..3e74721 100644 --- a/deploy/terraform/variables.tf +++ b/deploy/terraform/variables.tf @@ -57,10 +57,35 @@ variable "repo_branch" { } variable "domain" { - description = "Öffentliche Domain (für CORS, Profil-URLs, später TLS)" + description = "Öffentliche Portal-Domain (CORS, Profil-URLs, TLS)" type = string } +variable "acme_email" { + description = "E-Mail für Let's Encrypt (Caddy ACME)" + type = string +} + +# --- DNS (optional, Hetzner DNS API) --- +variable "manage_dns" { + description = "true = A-Records (Portal + Wildcard) per Hetzner DNS anlegen" + type = bool + default = false +} + +variable "hetzner_dns_token" { + description = "Hetzner DNS API Token (separat vom Cloud-Token; nur bei manage_dns)" + type = string + default = "" + sensitive = true +} + +variable "dns_zone_name" { + description = "DNS-Zone bei Hetzner DNS (z. B. example.com), nur bei manage_dns" + type = string + default = "" +} + variable "app_secret" { description = "Symfony APP_SECRET" type = string diff --git a/deploy/terraform/versions.tf b/deploy/terraform/versions.tf index 934fc2a..1ad64b1 100644 --- a/deploy/terraform/versions.tf +++ b/deploy/terraform/versions.tf @@ -5,9 +5,17 @@ terraform { source = "hetznercloud/hcloud" version = "~> 1.48" } + hetznerdns = { + source = "germanbrew/hetznerdns" + version = "~> 3.0" + } } } provider "hcloud" { token = var.hcloud_token } + +provider "hetznerdns" { + api_token = var.hetzner_dns_token +}