Ghost sicherer machen

blog banner

Veröffentlicht am Mar 22, 2025

Lesezeit 4 min

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:

Ghost OAuth2 Proxy
Ghost OAuth2 Proxy

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 Ingresse:

  1. ein Ingress, der die Domain und den Pfad ghost/* auf einen OAuth2 Endpunkt weiterleitet
  2. ein Ingress, der diesen OAuth2 Endpunkt bereitstellt
  3. ein Ingress, der den Service 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 Ingressen 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:

Reddit Kommentar

Immerhin gibt es mit diesem Post und einigen Commits auf Github ein wenig Hoffnung:

Dann hoffe ich weiter…

Source