From 24047b0eaabbd087e8f1ffba998b4dede9191d89 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 13 Apr 2026 12:05:43 +1000 Subject: [PATCH] Enforce mTLS on private-admin Traefik routes --- .gitignore | 2 + core/traefik/MTLS.md | 38 ++++++++++ core/traefik/certs/.gitkeep | 0 core/traefik/docker-compose.yml | 2 + core/traefik/dynamic.yml | 10 +++ core/traefik/scripts/init-mtls-ca.sh | 38 ++++++++++ .../traefik/scripts/issue-mtls-client-cert.sh | 76 +++++++++++++++++++ .../scripts/revoke-mtls-client-cert.sh | 27 +++++++ monitoring/gotify/docker-compose.yml | 1 + monitoring/grafana/docker-compose.yml | 1 + monitoring/influxdb/docker-compose.yml | 1 + monitoring/node-red/docker-compose.yml | 1 + monitoring/portainer/docker-compose.yml | 1 + monitoring/prometheus/docker-compose.yml | 1 + monitoring/uptime-kuma/docker-compose.yml | 1 + 15 files changed, 200 insertions(+) create mode 100644 core/traefik/MTLS.md create mode 100644 core/traefik/certs/.gitkeep create mode 100755 core/traefik/scripts/init-mtls-ca.sh create mode 100755 core/traefik/scripts/issue-mtls-client-cert.sh create mode 100755 core/traefik/scripts/revoke-mtls-client-cert.sh diff --git a/.gitignore b/.gitignore index 0faba90..2cb6149 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ monitoring/influxdb/* secrets/* !secrets/.env.secrets.example !.env.example +core/traefik/certs/* +!core/traefik/certs/.gitkeep diff --git a/core/traefik/MTLS.md b/core/traefik/MTLS.md new file mode 100644 index 0000000..a7cfb87 --- /dev/null +++ b/core/traefik/MTLS.md @@ -0,0 +1,38 @@ +# Private-admin mTLS for Traefik + +`private-admin` routers are configured to require client certificates via the Traefik TLS option `mtls-private-admin@file`. + +## Certificate paths + +- Trusted client CA bundle expected by Traefik: + - `core/traefik/certs/ca/clients-ca.crt` +- CA private key (keep secret, never commit): + - `core/traefik/certs/ca/clients-ca.key` +- Issued client certs: + - `core/traefik/certs/clients//` + +## Bootstrap + +From repository root: + +```bash +./core/traefik/scripts/init-mtls-ca.sh +./core/traefik/scripts/issue-mtls-client-cert.sh admin-laptop +``` + +The second command exports a PKCS#12 bundle (`.p12`) for browser import and also leaves PEM `.crt`/`.key` artifacts for CLI usage. + +## Revocation workflow + +Because Traefik is configured with `clientAuth.caFiles`, revoked cert serials are not enforced by default. + +- Use `./core/traefik/scripts/revoke-mtls-client-cert.sh ` to quarantine a client cert bundle. +- For strict revocation, rotate the CA (`init-mtls-ca.sh` after removing old CA) and re-issue all trusted client certs. + +## Deploy + +After CA/certs are in place, restart Traefik to ensure updated files are loaded: + +```bash +docker compose -f core/traefik/docker-compose.yml up -d traefik +``` diff --git a/core/traefik/certs/.gitkeep b/core/traefik/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/traefik/docker-compose.yml b/core/traefik/docker-compose.yml index aff9dc5..5e41a58 100644 --- a/core/traefik/docker-compose.yml +++ b/core/traefik/docker-compose.yml @@ -24,6 +24,7 @@ services: volumes: - ${PROJECT_ROOT}/core/traefik/data/letsencrypt:/letsencrypt - ${PROJECT_ROOT}/core/traefik/data/logs:/logs + - ${PROJECT_ROOT}/core/traefik/certs:/etc/traefik/certs:ro - ${PROJECT_ROOT}/core/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro - ${PROJECT_ROOT}/core/traefik/traefik.yml:/etc/traefik/traefik.yml:ro - ${PROJECT_ROOT}/core/traefik/data/plugins:/plugins-storage @@ -37,6 +38,7 @@ services: - "traefik.http.routers.traefik.service=api@internal" - "traefik.http.routers.traefik.entrypoints=websecure" - "traefik.http.routers.traefik.tls.certresolver=myresolver" + - "traefik.http.routers.traefik.tls.options=mtls-private-admin@file" - "traefik.http.routers.traefik.middlewares=authelia" - "io.portainer.accesscontrol.public" - "traefik.docker.network=core_traefik" diff --git a/core/traefik/dynamic.yml b/core/traefik/dynamic.yml index 47f950e..d62086f 100644 --- a/core/traefik/dynamic.yml +++ b/core/traefik/dynamic.yml @@ -31,3 +31,13 @@ http: - crowdsec@file # - tracing-middleware@file - error-pages-middleware@docker + +tls: + options: + mtls-private-admin: + minVersion: VersionTLS12 + sniStrict: true + clientAuth: + caFiles: + - /etc/traefik/certs/ca/clients-ca.crt + clientAuthType: RequireAndVerifyClientCert diff --git a/core/traefik/scripts/init-mtls-ca.sh b/core/traefik/scripts/init-mtls-ca.sh new file mode 100755 index 0000000..535334f --- /dev/null +++ b/core/traefik/scripts/init-mtls-ca.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRAEFIK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CA_DIR="${TRAEFIK_ROOT}/certs/ca" + +CA_KEY="${CA_DIR}/clients-ca.key" +CA_CERT="${CA_DIR}/clients-ca.crt" +CA_SERIAL="${CA_DIR}/clients-ca.srl" + +DAYS="${DAYS:-3650}" +SUBJECT="${SUBJECT:-/CN=Traefik Private Admin Client CA/O=Homelab}" + +mkdir -p "${CA_DIR}" +chmod 700 "${CA_DIR}" + +if [[ -f "${CA_KEY}" || -f "${CA_CERT}" ]]; then + echo "Refusing to overwrite existing CA material in ${CA_DIR}." + echo "Delete existing files first if you intentionally want to rotate the CA." + exit 1 +fi + +openssl genrsa -out "${CA_KEY}" 4096 +chmod 600 "${CA_KEY}" + +openssl req -x509 -new -nodes \ + -key "${CA_KEY}" \ + -sha256 \ + -days "${DAYS}" \ + -subj "${SUBJECT}" \ + -out "${CA_CERT}" +chmod 644 "${CA_CERT}" + +rm -f "${CA_SERIAL}" + +echo "Created mTLS client CA: ${CA_CERT}" +echo "Use issue-mtls-client-cert.sh to issue client certificates signed by this CA." diff --git a/core/traefik/scripts/issue-mtls-client-cert.sh b/core/traefik/scripts/issue-mtls-client-cert.sh new file mode 100755 index 0000000..a4028fb --- /dev/null +++ b/core/traefik/scripts/issue-mtls-client-cert.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [days]" + exit 1 +fi + +CLIENT_NAME="$1" +DAYS="${2:-825}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRAEFIK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CA_DIR="${TRAEFIK_ROOT}/certs/ca" +CLIENT_DIR="${TRAEFIK_ROOT}/certs/clients/${CLIENT_NAME}" + +CA_KEY="${CA_DIR}/clients-ca.key" +CA_CERT="${CA_DIR}/clients-ca.crt" +CA_SERIAL="${CA_DIR}/clients-ca.srl" + +CLIENT_KEY="${CLIENT_DIR}/${CLIENT_NAME}.key" +CLIENT_CSR="${CLIENT_DIR}/${CLIENT_NAME}.csr" +CLIENT_CERT="${CLIENT_DIR}/${CLIENT_NAME}.crt" +CLIENT_P12="${CLIENT_DIR}/${CLIENT_NAME}.p12" +OPENSSL_EXT="${CLIENT_DIR}/client.ext" + +if [[ ! -f "${CA_KEY}" || ! -f "${CA_CERT}" ]]; then + echo "Missing CA material. Run init-mtls-ca.sh first." + exit 1 +fi + +if [[ -d "${CLIENT_DIR}" ]]; then + echo "Client directory already exists (${CLIENT_DIR}); refusing to overwrite." + exit 1 +fi + +mkdir -p "${CLIENT_DIR}" +chmod 700 "${CLIENT_DIR}" + +cat > "${OPENSSL_EXT}" <" + exit 1 +fi + +CLIENT_NAME="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRAEFIK_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CLIENT_DIR="${TRAEFIK_ROOT}/certs/clients/${CLIENT_NAME}" +REVOKED_DIR="${TRAEFIK_ROOT}/certs/revoked" + +if [[ ! -d "${CLIENT_DIR}" ]]; then + echo "No certificate directory found for client '${CLIENT_NAME}'." + exit 1 +fi + +mkdir -p "${REVOKED_DIR}" + +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +mv "${CLIENT_DIR}" "${REVOKED_DIR}/${CLIENT_NAME}-${STAMP}" + +echo "Moved client certificate material to ${REVOKED_DIR}/${CLIENT_NAME}-${STAMP}." +echo "Note: Traefik clientAuth with a CA file does not enforce revocation lists by default." +echo "For immediate hard revocation, rotate the client CA and re-issue trusted client certificates." diff --git a/monitoring/gotify/docker-compose.yml b/monitoring/gotify/docker-compose.yml index 1ff0517..b0e2c62 100644 --- a/monitoring/gotify/docker-compose.yml +++ b/monitoring/gotify/docker-compose.yml @@ -23,4 +23,5 @@ services: - "traefik.http.routers.gotify.rule=Host(`gotify.lan.ddnsgeek.com`)" - "traefik.http.routers.gotify.entrypoints=websecure" - "traefik.http.routers.gotify.tls.certresolver=myresolver" + - "traefik.http.routers.gotify.tls.options=mtls-private-admin@file" - "traefik.http.services.gotify.loadbalancer.server.port=80" diff --git a/monitoring/grafana/docker-compose.yml b/monitoring/grafana/docker-compose.yml index 227b770..39fadf6 100644 --- a/monitoring/grafana/docker-compose.yml +++ b/monitoring/grafana/docker-compose.yml @@ -18,6 +18,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.grafana.entrypoints=websecure" - "traefik.http.routers.grafana.tls.certresolver=myresolver" + - "traefik.http.routers.grafana.tls.options=mtls-private-admin@file" - "io.portainer.accesscontrol.public" - "traefik.http.services.grafana.loadbalancer.server.port=3000" - "traefik.docker.network=core_traefik" diff --git a/monitoring/influxdb/docker-compose.yml b/monitoring/influxdb/docker-compose.yml index 9f37c86..bd56fb7 100644 --- a/monitoring/influxdb/docker-compose.yml +++ b/monitoring/influxdb/docker-compose.yml @@ -26,6 +26,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.influxdb.entrypoints=websecure" - "traefik.http.routers.influxdb.tls.certresolver=myresolver" + - "traefik.http.routers.influxdb.tls.options=mtls-private-admin@file" - "io.portainer.accesscontrol.public" - "traefik.http.services.influxdb.loadbalancer.server.port=8086" - "traefik.http.routers.influxdb.middlewares=authelia" diff --git a/monitoring/node-red/docker-compose.yml b/monitoring/node-red/docker-compose.yml index 3ab5d7f..bd1551b 100644 --- a/monitoring/node-red/docker-compose.yml +++ b/monitoring/node-red/docker-compose.yml @@ -56,6 +56,7 @@ services: # - "traefik.http.routers.node-red.service=api@internal" - "traefik.http.routers.node-red.entrypoints=websecure" - "traefik.http.routers.node-red.tls.certresolver=myresolver" + - "traefik.http.routers.node-red.tls.options=mtls-private-admin@file" - "traefik.http.routers.node-red.middlewares=authelia" - "io.portainer.accesscontrol.public" - "traefik.docker.network=core_traefik" diff --git a/monitoring/portainer/docker-compose.yml b/monitoring/portainer/docker-compose.yml index f4e35ef..e59046f 100644 --- a/monitoring/portainer/docker-compose.yml +++ b/monitoring/portainer/docker-compose.yml @@ -20,6 +20,7 @@ services: - traefik.http.routers.portainer.entrypoints=websecure - traefik.http.routers.portainer.tls=true - traefik.http.routers.portainer.tls.certresolver=myresolver + - traefik.http.routers.portainer.tls.options=mtls-private-admin@file - io.portainer.accesscontrol.public # Service -> Portainer listens on 9000 inside the container - traefik.http.services.portainer.loadbalancer.server.port=9000 diff --git a/monitoring/prometheus/docker-compose.yml b/monitoring/prometheus/docker-compose.yml index 3c3b4b1..59383e1 100644 --- a/monitoring/prometheus/docker-compose.yml +++ b/monitoring/prometheus/docker-compose.yml @@ -30,6 +30,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.prometheus.entrypoints=websecure" - "traefik.http.routers.prometheus.tls.certresolver=myresolver" + - "traefik.http.routers.prometheus.tls.options=mtls-private-admin@file" - "io.portainer.accesscontrol.public" - "traefik.http.services.prometheus.loadbalancer.server.port=9090" - "traefik.http.routers.prometheus.middlewares=authelia" diff --git a/monitoring/uptime-kuma/docker-compose.yml b/monitoring/uptime-kuma/docker-compose.yml index fd045a9..62d1a94 100644 --- a/monitoring/uptime-kuma/docker-compose.yml +++ b/monitoring/uptime-kuma/docker-compose.yml @@ -20,6 +20,7 @@ services: - traefik.http.routers.monitor.entrypoints=websecure - traefik.http.routers.monitor.tls=true - traefik.http.routers.monitor.tls.certresolver=myresolver + - traefik.http.routers.monitor.tls.options=mtls-private-admin@file - io.portainer.accesscontrol.public - traefik.docker.network=core_traefik # Service -> container port