Zum Inhalt

Caddy

Diese Seite erklärt, was unsere Webanwendungen brauchen, um aus dem Netz sauber erreichbar zu sein, was ein Reverse Proxy und ein Ingress sind und warum wir dafür Caddy einsetzen. Wie eine App per Ingress-Eintrag an Caddy angebunden wird, steht in syslet in diesem Repo.

Webanwendungen brauchen HTTP und HTTPS

Alle unsere Apps sind Webanwendungen: Man öffnet sie im Browser, und sie sprechen HTTP. Damit sie im Internet sauber benutzbar sind, kommt eine Reihe von Aufgaben zusammen, die mit der App selbst wenig zu tun haben:

  • HTTPS: Jede Verbindung soll verschlüsselt sein. Dafür braucht jeder Hostname ein gültiges TLS-Zertifikat, und das muss alle paar Wochen erneuert werden.
  • Umleitung: Wer http:// aufruft, soll auf https:// umgeleitet werden.
  • Viele Apps, eine Adresse: Ein Host hat eine IP-Adresse und je einen Port 80 und 443, soll aber viele Apps unter eigenen Hostnamen anbieten.

Jede App könnte das grundsätzlich selbst erledigen, aber das hat Nachteile:

  • Viele Apps können kein HTTPS oder setzen es schlecht um. Und ein TLS-Stack muss laufend aktuell gehalten werden, damit die Verbindungen sicher bleiben; das müsste dann jede App für sich leisten.
  • Das automatische Beantragen und Erneuern der Zertifikate bei Let's Encrypt müsste in jeder App eigens eingerichtet und im Blick behalten werden; mit jeder App wächst dieser Aufwand.
  • Querschnittsaufgaben wie Messwerte für das Monitoring müsste jede App selbst mitbringen. An einer zentralen Stelle sind sie für alle Apps gleich und nur einmal einzurichten.

Wir lösen diese Aufgaben deshalb an einer zentralen Stelle vor allen Apps.

Diese Stelle ist auf jedem Host Caddy. Es gibt viele andere Webserver und Proxies, die als Reverse Proxy arbeiten können, etwa Apache, nginx oder HAProxy. Caddy ist nicht perfekt, aber es löst die Aufgaben oben gut genug, die meisten ohne eigene Einstellungen. HTTPS mit automatischen Zertifikaten und die Umleitung von HTTP auf HTTPS sind eingebaut. Messwerte je Hostname für das Monitoring liefert es selbst. Im Betrieb ist es einfach: ein Container, eine kurze Konfigurationsdatei, und um die Zertifikate muss sich niemand kümmern.

Reverse Proxy und Ingress

Ein Reverse Proxy nimmt Anfragen entgegen, reicht sie an einen anderen Server weiter und schickt dessen Antwort zurück.

Ein Ingress ist der Reverse Proxy am Rand eines Systems, über den alle Anfragen von außen hereinkommen. Den Begriff kennt man vor allem aus Cloud-Umgebungen wie Kubernetes, wo hinter dem Ingress viele Server stehen. Kubernetes nutzen wir nicht, übernehmen aber das Konzept: Caddy läuft auf jedem Host und sitzt an dessen Außenkante; dahinter erreicht es die Container der Apps über Podman-Netzwerke. Er übernimmt zentral für alle Apps des Hosts:

  • die TLS-Terminierung: Die HTTPS-Verbindung des Browsers endet bei Caddy; zu den Apps geht es per HTTP weiter
  • die Messwerte je Hostname für das Monitoring
  • das Verteilen an die internen Apps: Caddy sieht nach, an welchen Hostnamen eine Anfrage gerichtet ist, etwa md.garage-lab.de, und reicht sie an den Container weiter, der dafür zuständig ist

Einzelne Apps bringen zusätzlich einen eigenen, internen Reverse Proxy mit, der keine Ingress-Aufgaben hat. Im Container von Discourse zum Beispiel läuft nginx: Es liefert statische Dateien wie Bilder und Skripte selbst aus und reicht alle anderen Anfragen an die eigentliche Ruby-Anwendung (Unicorn) weiter. Dieser Reverse Proxy ist von außen nicht erreichbar und kümmert sich nicht um TLS; davor steht Caddy als Ingress.

flowchart LR
    browser(["Browser"])

    subgraph host ["Host"]
        caddy["Caddy (Ingress)<br>Port 80 und 443<br>TLS, Messwerte, Verteilen"]
        hedgedoc["Container<br>HedgeDoc"]

        subgraph discourse ["Container Discourse"]
            nginx["nginx<br>statische Dateien"]
            unicorn["Unicorn<br>Ruby-Anwendung"]
        end
    end

    browser -->|"HTTPS"| caddy
    caddy -->|"HTTP, md.garage-lab.de"| hedgedoc
    caddy -->|"HTTP, forum.garage-lab.de"| nginx
    nginx --> unicorn

Caddy ist das Einzige auf dem Host, das aus dem Netz erreichbar ist, und zwar über die Standard-Ports 80 (HTTP) und 443 (HTTPS). Zwischen Caddy und den Containern läuft die Verbindung unverschlüsselt, aber nur innerhalb des Hosts über ein Podman-Netzwerk. Die App-Container selbst geben keine Ports nach außen frei. Welcher Hostname zu welchem Container gehört, steht als Ingress-Eintrag in der Stack-Datei der App.

Alle Apps liegen hinter Caddy als Ingress.

  • Welche Apps ein Host aus dem Netz anbietet, steht an einer Stelle, und nur Caddy muss aus dem Netz erreichbar sein.
  • Keine App muss einen eigenen Port nach außen öffnen. Auch eine App, die selbst kein HTTPS kann oder einen ungewöhnlichen Port nutzt, ist sauber unter ihrem Namen per HTTPS erreichbar.
  • Caddy unterscheidet die Apps am Hostnamen; deshalb können sich alle Apps eines Hosts eine IP-Adresse und die Ports 80 und 443 teilen.
  • Weil alle Apps über die Standard-Ports erreichbar sind, kommen die Adressen ohne Portnummer aus: https://md.garage-lab.de statt https://md.garage-lab.de:3000. Man gibt einfach den Hostnamen ein, und es funktioniert.

Zertifikate automatisch

Damit der Browser HTTPS verwendet und der Verbindung traut, braucht jeder Hostname ein TLS-Zertifikat von einer Stelle, der der Browser vertraut. Wir bekommen die Zertifikate kostenlos von Let's Encrypt.

Caddy beantragt sie selbst, sobald ein neuer Hostname dazukommt, und verlängert sie rechtzeitig, bevor sie ablaufen. Dafür nutzt es das Verfahren ACME:

  1. Caddy bittet Let's Encrypt um ein Zertifikat für einen Hostnamen.
  2. Let's Encrypt stellt eine Aufgabe, mit der Caddy beweisen muss, dass ihm der Hostname gehört.
  3. Caddy löst die Aufgabe, Let's Encrypt prüft das Ergebnis und stellt das Zertifikat aus.

Für den Beweis gibt es mehrere Wege. Wir nutzen die DNS-Challenge: Caddy legt einen bestimmten TXT-Record im DNS der Domain an, und Let's Encrypt sieht nach, ob er dort steht. Wer die DNS-Einträge einer Domain ändern kann, dem gehört sie, so die Überlegung dahinter.

Für die DNS-Challenge braucht Caddy eine Erweiterung für deSEC, die im fertigen Caddy-Image fehlt. Wir nutzen deshalb nicht das fertige Image, sondern bauen es auf dem Host selbst, mit dieser Erweiterung (Block builds: "caddy-desec" in syslet/werner/infra-caddy.cue und syslet/containerhost/stack-caddy.cue).

Die Zertifikate gelten nur einige Wochen; darum kümmert sich niemand von Hand.

Nach dem Anlegen des TXT-Records wartet Caddy zwei Minuten, bis der Eintrag auf allen Nameservern angekommen ist (propagation_delay im Caddyfile), und löscht ihn nach der Prüfung wieder. Die Zertifikate und das Konto bei Let's Encrypt speichert Caddy in den Volumes caddy-data und caddy-config; sie werden nicht gesichert, weil Caddy alles neu beantragen kann (siehe Backup).

Bei deSEC meldet sich Caddy mit einem eigenen Token an, das als Secret caddy-desectoken in creds-caddy.enc.yaml des Hosts liegt. Jedes Caddy hat sein eigenes Token, und das darf nur die _acme-challenge-Einträge der Hostnamen seines Hosts schreiben, nach dem Prinzip Least Privilege. Das legt die Token-Policy in der Zonendatei fest; wie, und warum jedes Caddy ein eigenes Token hat, steht in Unsere Zonen.

Ohne Token-Policy kein Zertifikat

Steht _acme-challenge.<subdomain> nicht in der Token-Policy des Caddy auf dem Ziel-Host, lehnt deSEC den Eintrag ab, und die App bekommt kein Zertifikat. Caddy versucht es danach erst nach einer langen Wartezeit erneut, auch wenn die Policy inzwischen nachgetragen ist. Deshalb kommt bei einer neuen App zuerst der DNS-Teil, dann der Stack (siehe App hinzufügen).

Caddy kümmert sich um TLS, nicht die einzelnen Apps. Zertifikate beantragen, verlängern und HTTPS richtig einstellen erledigt Caddy für alle Apps gleich. Keine App braucht dafür eigene Einstellungen, und eine neue App bekommt HTTPS, ohne dass jemand etwas dafür tun muss. Weil Caddy die Zertifikate auch selbst verlängert, läuft keins ab, weil jemand einen Termin vergessen hat; ein abgelaufenes Zertifikat würde eine App für alle lahmlegen.

Zertifikate holen wir per DNS-Challenge. Bei der DNS-Challenge muss Let's Encrypt den Host selbst nicht erreichen können. Das funktioniert deshalb auch für Hosts, die gar nicht aus dem Internet erreichbar sind, wie containerhost im Maschinendorf.

Weiterlesen