Deployment: Caddy-Edge (TLS + On-Demand für Custom-Domains) + Hetzner DNS
- Caddy ersetzt den Hetzner-LB: terminiert TLS (Portal-Domain automatisch) und load-balanced per reverse_proxy über die App-Nodes. Für Custom-Domains (§11) On-Demand-TLS, autorisiert über GET /internal/tls-allowed. - TlsCheckController + DomainRepository::findVerifiedByHostname: erlaubt Zertifikate nur für Portal-Domain oder verifizierte Domains (Schutz vor Cert-Flooding). - Terraform: hcloud_load_balancer entfernt, Caddy-Server + Firewall (80/443) + cloud-init-caddy (Caddyfile templated mit Upstreams/Domain/ACME). - Optional Hetzner DNS via API (manage_dns): A-Record Portal + Wildcard → Caddy. - nginx.prod: /internal zu Symfony geroutet; APP_PORTAL_DOMAIN-Env. Validiert: Caddyfile (caddy validate), Terraform (validate), /internal/tls-allowed (200/403/400). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c3e05257cb
commit
79e996ab03
@ -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
|
||||
|
||||
44
backend/src/Controller/TlsCheckController.php
Normal file
44
backend/src/Controller/TlsCheckController.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\DomainRepository;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* On-Demand-TLS-Autorisierung für Caddy (KONZEPT §11): Caddy fragt vor dem
|
||||
* Ausstellen eines Let's-Encrypt-Zertifikats hier nach, ob die Domain erlaubt ist.
|
||||
* Erlaubt = Portal-Domain oder eine verifizierte Custom-Domain aus der DB.
|
||||
* 200 → ausstellen, sonst ablehnen (verhindert unbegrenzte Zertifikatsanfragen).
|
||||
*/
|
||||
final class TlsCheckController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
|
||||
private readonly string $portalDomain,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/internal/tls-allowed', name: 'tls_allowed', methods: ['GET'])]
|
||||
public function __invoke(Request $request, DomainRepository $domains): Response
|
||||
{
|
||||
$host = strtolower(trim((string) $request->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);
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,14 +3,22 @@
|
||||
Provisioniert einen **Multi-Node-Test** mit nachgewiesener Skalierbarkeit:
|
||||
|
||||
```
|
||||
Hetzner Load Balancer (lb11, :80 → /health)
|
||||
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)
|
||||
vcard4-app-1 vcard4-app-2 (zustandslose App-Nodes, HTTP im privaten Netz)
|
||||
\ /
|
||||
┌─────────┴───────────┐ ┌─────┴──────────────────┐
|
||||
vcard4-db (MariaDB, Volume) Hetzner Object Storage (S3, Assets)
|
||||
┌─────────┴──────────┐ ┌────────┴───────────────┐
|
||||
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://<LB-IP>/api/login \
|
||||
# 1) Login über die Domain (Caddy → App-Nodes)
|
||||
TOKEN=$(curl -s -X POST https://<domain>/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://<LB-IP>/health; echo; done
|
||||
# 2) Health über Caddy (round-robin auf beide Nodes)
|
||||
for i in $(seq 1 6); do curl -s https://<domain>/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@<app-1-ip> '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@<app-2-ip> '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).
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
40
deploy/terraform/cloud-init-caddy.yaml.tftpl
Normal file
40
deploy/terraform/cloud-init-caddy.yaml.tftpl
Normal file
@ -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
|
||||
32
deploy/terraform/dns.tf
Normal file
32
deploy/terraform/dns.tf
Normal file
@ -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
|
||||
}
|
||||
@ -1,9 +1,13 @@
|
||||
locals {
|
||||
db_private_ip = "10.0.1.10"
|
||||
lb_private_ip = "10.0.1.5"
|
||||
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"
|
||||
# --- 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]
|
||||
|
||||
resource "hcloud_load_balancer_network" "lb_net" {
|
||||
load_balancer_id = hcloud_load_balancer.lb.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.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??"]
|
||||
}
|
||||
ip = local.caddy_private_ip
|
||||
}
|
||||
|
||||
depends_on = [hcloud_network_subnet.subnet, hcloud_server.app]
|
||||
}
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -14,9 +14,15 @@ 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"
|
||||
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"
|
||||
db_root_password = "STARKES_ROOT_PASSWORT"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user