Enforce mTLS on private-admin Traefik routes
This commit is contained in:
@@ -25,3 +25,5 @@ monitoring/influxdb/*
|
|||||||
secrets/*
|
secrets/*
|
||||||
!secrets/.env.secrets.example
|
!secrets/.env.secrets.example
|
||||||
!.env.example
|
!.env.example
|
||||||
|
core/traefik/certs/*
|
||||||
|
!core/traefik/certs/.gitkeep
|
||||||
|
|||||||
@@ -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/<client-name>/`
|
||||||
|
|
||||||
|
## 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 <client-name>` 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
|
||||||
|
```
|
||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${PROJECT_ROOT}/core/traefik/data/letsencrypt:/letsencrypt
|
- ${PROJECT_ROOT}/core/traefik/data/letsencrypt:/letsencrypt
|
||||||
- ${PROJECT_ROOT}/core/traefik/data/logs:/logs
|
- ${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/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
- ${PROJECT_ROOT}/core/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
- ${PROJECT_ROOT}/core/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
- ${PROJECT_ROOT}/core/traefik/data/plugins:/plugins-storage
|
- ${PROJECT_ROOT}/core/traefik/data/plugins:/plugins-storage
|
||||||
@@ -37,6 +38,7 @@ services:
|
|||||||
- "traefik.http.routers.traefik.service=api@internal"
|
- "traefik.http.routers.traefik.service=api@internal"
|
||||||
- "traefik.http.routers.traefik.entrypoints=websecure"
|
- "traefik.http.routers.traefik.entrypoints=websecure"
|
||||||
- "traefik.http.routers.traefik.tls.certresolver=myresolver"
|
- "traefik.http.routers.traefik.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.routers.traefik.tls.options=mtls-private-admin@file"
|
||||||
- "traefik.http.routers.traefik.middlewares=authelia"
|
- "traefik.http.routers.traefik.middlewares=authelia"
|
||||||
- "io.portainer.accesscontrol.public"
|
- "io.portainer.accesscontrol.public"
|
||||||
- "traefik.docker.network=core_traefik"
|
- "traefik.docker.network=core_traefik"
|
||||||
|
|||||||
@@ -31,3 +31,13 @@ http:
|
|||||||
- crowdsec@file
|
- crowdsec@file
|
||||||
# - tracing-middleware@file
|
# - tracing-middleware@file
|
||||||
- error-pages-middleware@docker
|
- error-pages-middleware@docker
|
||||||
|
|
||||||
|
tls:
|
||||||
|
options:
|
||||||
|
mtls-private-admin:
|
||||||
|
minVersion: VersionTLS12
|
||||||
|
sniStrict: true
|
||||||
|
clientAuth:
|
||||||
|
caFiles:
|
||||||
|
- /etc/traefik/certs/ca/clients-ca.crt
|
||||||
|
clientAuthType: RequireAndVerifyClientCert
|
||||||
|
|||||||
Executable
+38
@@ -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."
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "Usage: $0 <client-name> [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}" <<EXT
|
||||||
|
basicConstraints=CA:FALSE
|
||||||
|
keyUsage = digitalSignature, keyEncipherment
|
||||||
|
extendedKeyUsage = clientAuth
|
||||||
|
subjectAltName = DNS:${CLIENT_NAME}
|
||||||
|
EXT
|
||||||
|
|
||||||
|
openssl genrsa -out "${CLIENT_KEY}" 2048
|
||||||
|
chmod 600 "${CLIENT_KEY}"
|
||||||
|
|
||||||
|
openssl req -new -key "${CLIENT_KEY}" -subj "/CN=${CLIENT_NAME}" -out "${CLIENT_CSR}"
|
||||||
|
|
||||||
|
openssl x509 -req \
|
||||||
|
-in "${CLIENT_CSR}" \
|
||||||
|
-CA "${CA_CERT}" \
|
||||||
|
-CAkey "${CA_KEY}" \
|
||||||
|
-CAcreateserial \
|
||||||
|
-out "${CLIENT_CERT}" \
|
||||||
|
-days "${DAYS}" \
|
||||||
|
-sha256 \
|
||||||
|
-extfile "${OPENSSL_EXT}"
|
||||||
|
chmod 644 "${CLIENT_CERT}"
|
||||||
|
|
||||||
|
openssl pkcs12 -export \
|
||||||
|
-inkey "${CLIENT_KEY}" \
|
||||||
|
-in "${CLIENT_CERT}" \
|
||||||
|
-certfile "${CA_CERT}" \
|
||||||
|
-name "${CLIENT_NAME}" \
|
||||||
|
-out "${CLIENT_P12}"
|
||||||
|
chmod 600 "${CLIENT_P12}"
|
||||||
|
|
||||||
|
rm -f "${CLIENT_CSR}" "${OPENSSL_EXT}"
|
||||||
|
|
||||||
|
echo "Issued client certificate for ${CLIENT_NAME}."
|
||||||
|
echo "CRT: ${CLIENT_CERT}"
|
||||||
|
echo "KEY: ${CLIENT_KEY}"
|
||||||
|
echo "P12: ${CLIENT_P12}"
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "Usage: $0 <client-name>"
|
||||||
|
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."
|
||||||
@@ -23,4 +23,5 @@ services:
|
|||||||
- "traefik.http.routers.gotify.rule=Host(`gotify.lan.ddnsgeek.com`)"
|
- "traefik.http.routers.gotify.rule=Host(`gotify.lan.ddnsgeek.com`)"
|
||||||
- "traefik.http.routers.gotify.entrypoints=websecure"
|
- "traefik.http.routers.gotify.entrypoints=websecure"
|
||||||
- "traefik.http.routers.gotify.tls.certresolver=myresolver"
|
- "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"
|
- "traefik.http.services.gotify.loadbalancer.server.port=80"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.grafana.entrypoints=websecure"
|
- "traefik.http.routers.grafana.entrypoints=websecure"
|
||||||
- "traefik.http.routers.grafana.tls.certresolver=myresolver"
|
- "traefik.http.routers.grafana.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.routers.grafana.tls.options=mtls-private-admin@file"
|
||||||
- "io.portainer.accesscontrol.public"
|
- "io.portainer.accesscontrol.public"
|
||||||
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
||||||
- "traefik.docker.network=core_traefik"
|
- "traefik.docker.network=core_traefik"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.influxdb.entrypoints=websecure"
|
- "traefik.http.routers.influxdb.entrypoints=websecure"
|
||||||
- "traefik.http.routers.influxdb.tls.certresolver=myresolver"
|
- "traefik.http.routers.influxdb.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.routers.influxdb.tls.options=mtls-private-admin@file"
|
||||||
- "io.portainer.accesscontrol.public"
|
- "io.portainer.accesscontrol.public"
|
||||||
- "traefik.http.services.influxdb.loadbalancer.server.port=8086"
|
- "traefik.http.services.influxdb.loadbalancer.server.port=8086"
|
||||||
- "traefik.http.routers.influxdb.middlewares=authelia"
|
- "traefik.http.routers.influxdb.middlewares=authelia"
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ services:
|
|||||||
# - "traefik.http.routers.node-red.service=api@internal"
|
# - "traefik.http.routers.node-red.service=api@internal"
|
||||||
- "traefik.http.routers.node-red.entrypoints=websecure"
|
- "traefik.http.routers.node-red.entrypoints=websecure"
|
||||||
- "traefik.http.routers.node-red.tls.certresolver=myresolver"
|
- "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"
|
- "traefik.http.routers.node-red.middlewares=authelia"
|
||||||
- "io.portainer.accesscontrol.public"
|
- "io.portainer.accesscontrol.public"
|
||||||
- "traefik.docker.network=core_traefik"
|
- "traefik.docker.network=core_traefik"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
- traefik.http.routers.portainer.entrypoints=websecure
|
- traefik.http.routers.portainer.entrypoints=websecure
|
||||||
- traefik.http.routers.portainer.tls=true
|
- traefik.http.routers.portainer.tls=true
|
||||||
- traefik.http.routers.portainer.tls.certresolver=myresolver
|
- traefik.http.routers.portainer.tls.certresolver=myresolver
|
||||||
|
- traefik.http.routers.portainer.tls.options=mtls-private-admin@file
|
||||||
- io.portainer.accesscontrol.public
|
- io.portainer.accesscontrol.public
|
||||||
# Service -> Portainer listens on 9000 inside the container
|
# Service -> Portainer listens on 9000 inside the container
|
||||||
- traefik.http.services.portainer.loadbalancer.server.port=9000
|
- traefik.http.services.portainer.loadbalancer.server.port=9000
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.prometheus.entrypoints=websecure"
|
- "traefik.http.routers.prometheus.entrypoints=websecure"
|
||||||
- "traefik.http.routers.prometheus.tls.certresolver=myresolver"
|
- "traefik.http.routers.prometheus.tls.certresolver=myresolver"
|
||||||
|
- "traefik.http.routers.prometheus.tls.options=mtls-private-admin@file"
|
||||||
- "io.portainer.accesscontrol.public"
|
- "io.portainer.accesscontrol.public"
|
||||||
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
|
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
|
||||||
- "traefik.http.routers.prometheus.middlewares=authelia"
|
- "traefik.http.routers.prometheus.middlewares=authelia"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
- traefik.http.routers.monitor.entrypoints=websecure
|
- traefik.http.routers.monitor.entrypoints=websecure
|
||||||
- traefik.http.routers.monitor.tls=true
|
- traefik.http.routers.monitor.tls=true
|
||||||
- traefik.http.routers.monitor.tls.certresolver=myresolver
|
- traefik.http.routers.monitor.tls.certresolver=myresolver
|
||||||
|
- traefik.http.routers.monitor.tls.options=mtls-private-admin@file
|
||||||
- io.portainer.accesscontrol.public
|
- io.portainer.accesscontrol.public
|
||||||
- traefik.docker.network=core_traefik
|
- traefik.docker.network=core_traefik
|
||||||
# Service -> container port
|
# Service -> container port
|
||||||
|
|||||||
Reference in New Issue
Block a user