Ghost sicherer machen

Veröffentlicht am Mar 22, 2025
Zu Ghost als CMS komme ich irgendwie immer wieder zurück. Natürlich habe ich auch schon ein paar andere Alternativen ausprobiert. Unter anderem Wordpress oder Hugo. Aber nach viel Herumprobieren und Reparieren von Pipelines bin ich dann doch wieder bei Ghost. Aber eine Sache stört mich dann doch sehr schnell:
Warum zur Hölle kann ich den Admin Account nicht mit einer 2 Faktor Authentifizierung absichern und so geschützter sein?
Nun, da Ghost auf meinem Cluster läuft und dort bereits einige Tools in Kubernetes deployed sind, kann ich einfach den entsprechenden Pfad absichern. Genauer gesagt geht es hier um die Pfade, die mit ghost/* beginnen.
Oauth2-proxy und Tailscale to the rescue
Zuerst möchte ich kurz erklären, was die beiden Tools OAuth2-proxy und Tailscale machen.
OAuth2-proxy
Ich finde die Webseite von OAuth2-Proxy erklärt es sehr gut in einem Satz:
A reverse proxy and static file server that provides authentication using Providers (Google, GitHub, and others) to validate accounts by email, domain or group. Quelle
Das heißt, durch die Möglichkeit eines Reverse Proxy habe ich die Option, bestimmte Pfade abzusichern und gegen einen (Social) Provider zu authentifizieren. In meinem Fall übernimmt das Gitea. Das bedeutet, dass ab diesem Zeitpunkt bei jedem Aufruf von Pfaden, die mit ghost/* beginnen, geprüft wird, ob ich ein gültiges oauth2-proxy-Token als Cookie habe.
Aber bekanntlich sagt ein Flow Chart mehr als 1000 Worte:

Tailscale
Es gibt einige Programme, die mit der Admin-Api von Ghost umgehen können (z.B. Ulysses). In diesen Programmen kann man dann zum Beispiel solche Beiträge schreiben. Sehr nützlich, aber leider überhaupt nicht kompatibel mit einem OAuth2-Flow. Hier kommt Tailscale ins Spiel. Auch hier wieder die Definition von der Homepage, die für sich spricht:
Tailscale is a mesh VPN (Virtual Private Network) service that streamlines connecting devices and services securely across different networks. It enables encrypted point-to-point connections using the open source WireGuard protocol, which means only devices on your private network can communicate with each other. Unlike traditional VPNs, which tunnel all network traffic through a central gateway server, Tailscale creates a peer-to-peer mesh network (known as a tailnet). However, you can still use Tailscale like a traditional VPN by routing all traffic through an exit node. Quelle
Umgangssprachlich: Man baut einen direkten VPN-Tunnel zu dem Dienst in Kubernetes auf. Diesen Tunnel kann man aber erst aufbauen, wenn man sich auch im Tailscale VPN eingeloggt hat. Ab diesem Zeitpunkt kann man aber ganz normal auf der Seite surfen. Um das zu ermöglichen, bekommt man von Tailscale eine eigene Domain dafür. D.h. man hat dann eine öffentliche URL (in diesem Fall nerdware.de) und man hat eine “private/interne” URL.
Umsetzung
Genug der Theorie, jetzt komme ich zu dem Teil, wo ich es implementiert habe.
Um den geneigten Leser nicht zu sehr zu langweilen, gehe ich an dieser Stelle davon aus, dass der OAuth2-Proxy und Tailscale deployed sind. Das heißt, ich werde nur noch die Details für die richtigen Ingresse in Kubernetes erklären. Nur als Hinweis:
Für OAuth2-Proxy habe ich diesen Helm Chart genutzt
und für Tailscale dieses:
Insgesamt braucht ich 3 Ingress
e:
- ein
Ingress
, der die Domain und den Pfadghost/*
auf einen OAuth2 Endpunkt weiterleitet - ein
Ingress
, der diesen OAuth2 Endpunkt bereitstellt - ein
Ingress
, der denService
von Ghost im Tailscale Netzwerk bereitstellt
⠀ Da ich so etwas vielleicht öfter machen möchte, habe ich es der Einfachheit halber in Jsonnet geschrieben.
In diesem Fall habe ich eine Hauptdatei, wo ich definiere, was ich haben will, und ich habe eine Bibliotheksdatei, die die Logik darstellt:
local o = import 'secure-ingresses.libsonnet';
local ingresses = [
{
name: 'ghost-admin',
namespace: 'ghost',
domain: 'nerdware.de',
service: {
name: 'ghost-ghost',
port: 2368,
},
need: {
origin: true,
oauth2: true,
tailscale: true,
},
path: '/ghost/',
secretName: 'ghost-cert-tls',
},
];
o.createIngresses(ingresses)
secure-ingresses.jsonnet
oder auch die Hauptdatei
Durch das need
kann ich beeinflussen, ob ein “normaler”, ein oauth2
oder/und ein tailscale
Ingress erstellt wird.
local nginxIngressClassName = 'nginx';
local tailscaleIngressClassname = 'tailscale';
local oauth2Namespace = 'oauth2-proxy';
local certManagerClusterIssuer = 'clusterIssuer';
local originIngress(ingress) = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: std.asciiLower(ingress.name) + '-oauth2',
annotations: {
'cert-manager.io/cluster-issuer': certManagerClusterIssuer,
'nginx.ingress.kubernetes.io/auth-signin': 'https://$host/oauth2/start?rd=$escaped_request_uri',
'nginx.ingress.kubernetes.io/auth-url': 'https://$host/oauth2/auth',
},
namespace: ingress.namespace,
},
spec: {
ingressClassName: nginxIngressClassName,
rules: [
{
host: ingress.domain,
http: {
paths: [
{
backend: {
service: {
name: ingress.service.name,
port: {
number: ingress.service.port,
},
},
},
path: ingress.path,
pathType: 'Prefix',
},
],
},
},
],
tls: [
{
hosts: [
ingress.domain,
],
secretName: ingress.secretName,
},
],
},
};
local oauth2Ingress(ingress) = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: std.asciiLower(ingress.name) + '-oauth2-endpoint',
annotations: {
'cert-manager.io/cluster-issuer': certManagerClusterIssuer,
},
namespace: oauth2Namespace,
},
spec: {
ingressClassName: nginxIngressClassName,
rules: [
{
host: ingress.domain,
http: {
paths: [
{
backend: {
service: {
name: 'oauth2-proxy',
port: {
number: 80,
},
},
},
path: '/oauth2',
pathType: 'Prefix',
},
],
},
},
],
tls: [
{
hosts: [
ingress.domain,
],
secretName: ingress.secretName,
},
],
},
};
local tailscaleIngress(ingress) = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: std.asciiLower(ingress.name) + '-tailscale',
namespace: ingress.namespace,
},
spec: {
ingressClassName: tailscaleIngressClassname,
rules: [
{
http: {
paths: [
{
backend: {
service: {
name: ingress.service.name,
port: {
number: ingress.service.port,
},
},
},
path: ingress.path,
pathType: 'Prefix',
},
],
},
},
],
tls: [
{
hosts: [
std.asciiLower(ingress.name),
],
},
],
},
};
local createIngresses(ingresses) = std.flattenArrays([
[
if ingress.need.origin then originIngress(ingress),
if ingress.need.oauth2 then oauth2Ingress(ingress),
if ingress.need.tailscale then tailscaleIngress(ingress),
]
for ingress in ingresses
]);
{
originIngress: function(ingress) originIngress(ingress),
oauth2Ingress: function(ingress) oauth2Ingress(ingress),
tailscaleIngress: function(ingress) tailscaleIngress(ingress),
createIngresses: function(ingresses) createIngresses(ingresses),
}
secure-ingresses.libsonnet
oder auch die Bibliotheksdatei
Da meine HTTP-Zertifikate im Cluster mit dem CertManager erzeugt werden, sind die richtigen Annotationen direkt enthalten. Der Tailscale Ingress bildet die HTTPs im VPN-Netzwerk ab und liefert eigene Zertifikate aus. D.h. hier wird der CertManager nicht benötigt.
Lässt man das Ganze rendern, erhält man folgendes Ergebnis:
jsonnet secure-ingresses.jsonnet| jq -c '.[]' | while read -r item; do echo "$separator"; echo "$item" | yq -P; separator="—"; done
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: clusterIssuer
nginx.ingress.kubernetes.io/auth-signin: https://$host/oauth2/start?rd=$escaped_request_uri
nginx.ingress.kubernetes.io/auth-url: https://$host/oauth2/auth
name: ghost-admin-oauth2
namespace: ghost
spec:
ingressClassName: nginx
rules:
- host: nerdware.de
http:
paths:
- backend:
service:
name: ghost-ghost
port:
number: 2368
path: /ghost/
pathType: Prefix
tls:
- hosts:
- nerdware.de
secretName: ghost-cert-tls
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: clusterIssuer
name: ghost-admin-oauth2-endpoint
namespace: oauth2-proxy
spec:
ingressClassName: nginx
rules:
- host: nerdware.de
http:
paths:
- backend:
service:
name: oauth2-proxy
port:
number: 80
path: /oauth2
pathType: Prefix
tls:
- hosts:
- nerdware.de
secretName: ghost-cert-tls
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ghost-admin-tailscale
namespace: ghost
spec:
ingressClassName: tailscale
rules:
- http:
paths:
- backend:
service:
name: ghost-ghost
port:
number: 2368
path: /ghost/
pathType: Prefix
tls:
- hosts:
- ghost-admin
Gerenderte Yaml Datei, die so in den Kubernetes Cluster deployed werden könnte
Nach dem erfolgreichen Deployment, muss ich mich erst authentifizieren, wenn ich die URL https://example.com/ghost/#/ ansurfe. So bleibt die Administrationsoberfläche geschützt.
Fazit
Mit 2 zusätzlichen Tools, und 3 Ingress
en kann man das Admininterface von Ghost ausreichend absichern. Was ich mir allerdings wünschen würde, wäre eine native Lösung von Ghost. Dies wird zumindest auch im Forum immer wieder gefragt und dann nie zufriedenstellend beantwortet:
Immerhin gibt es mit diesem Post und einigen Commits auf Github ein wenig Hoffnung:
Dann hoffe ich weiter…
