diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..09a440e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,118 @@ +name: Validate and deploy error pages + +on: + push: + pull_request: + +env: + HELM_VERSION: v4.2.2 + KUBECTL_VERSION: v1.36.2 + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 + NAMESPACE: default + RELEASE_NAME: traefik-error-pages + +jobs: + validate: + name: Validate manifests + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install Helm + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz.sha256sum" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" + (cd "${RUNNER_TEMP}" && sha256sum --check helm.tar.gz.sha256sum) + tar --extract --gzip --file "${RUNNER_TEMP}/helm.tar.gz" \ + --directory "${RUNNER_TEMP}" linux-amd64/helm + mv "${RUNNER_TEMP}/linux-amd64/helm" "${RUNNER_TEMP}/helm" + chmod 0700 "${RUNNER_TEMP}/helm" + + - name: Lint and render + run: | + "${RUNNER_TEMP}/helm" lint . --values values-brunner-ninja.yaml + "${RUNNER_TEMP}/helm" template "${RELEASE_NAME}" . \ + --namespace "${NAMESPACE}" \ + --values values-brunner-ninja.yaml > "${RUNNER_TEMP}/rendered.yaml" + test -s "${RUNNER_TEMP}/rendered.yaml" + + deploy: + name: Deploy to Kubernetes + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install Helm and kubectl + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz.sha256sum" \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" + (cd "${RUNNER_TEMP}" && sha256sum --check helm.tar.gz.sha256sum) + tar --extract --gzip --file "${RUNNER_TEMP}/helm.tar.gz" \ + --directory "${RUNNER_TEMP}" linux-amd64/helm + mv "${RUNNER_TEMP}/linux-amd64/helm" "${RUNNER_TEMP}/helm" + chmod 0700 "${RUNNER_TEMP}/helm" + + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" + printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check + chmod 0700 "${RUNNER_TEMP}/kubectl" + + - name: Configure Kubernetes access + env: + KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }} + run: | + test -n "${KUBE_CONFIG_BASE64}" + printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig" + chmod 0600 "${RUNNER_TEMP}/kubeconfig" + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" config set-cluster cluster \ + --server="${KUBERNETES_API}" \ + --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" + + - name: Apply and verify + run: | + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/helm" template "${RELEASE_NAME}" . \ + --namespace "${NAMESPACE}" \ + --values values-brunner-ninja.yaml > "${RUNNER_TEMP}/rendered.yaml" + "${RUNNER_TEMP}/kubectl" apply \ + --validate=false \ + --filename "${RUNNER_TEMP}/rendered.yaml" + + for attempt in {1..24}; do + IFS='|' read -r generation observed desired updated available unavailable <<< "$( + "${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" \ + get deployment/traefik-error-pages \ + --output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}' + )" + + echo "Rollout ${attempt}/24: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${available}/${desired} available" + if [[ "${observed}" == "${generation}" \ + && "${updated}" == "${desired}" \ + && "${available}" == "${desired}" \ + && ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then + echo "Error-page deployment successfully rolled out" + exit 0 + fi + sleep 5 + done + + echo "Error-page rollout did not complete within two minutes" >&2 + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc49698 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +kubeconfig +kubeconfig.* +rendered.yaml +.DS_Store + diff --git a/Chart.yaml b/Chart.yaml new file mode 100644 index 0000000..99b9138 --- /dev/null +++ b/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: traefik-error-pages +description: Cloudflare-style upstream error pages for Traefik on Kubernetes +type: application +version: 0.1.0 +appVersion: "1.0.0" + diff --git a/README.md b/README.md index c3513c7..93a2290 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,195 @@ -# traefik-error-pages +# Traefik error pages -This helm chart renders nicer cloudflare-like error pages in case the upstream service behind the traefik reverse proxy doesnt respond. \ No newline at end of file +A small, portable Kubernetes deployment that replaces Traefik-generated HTTP +5xx response bodies with a Cloudflare-style diagnostic page while preserving +the original status code. API clients still receive `500`, `502`, `503`, or +`504`; only the response body and diagnostic headers are replaced. + +The page reports the browser, reverse proxy, optional ForwardAuth provider, and +upstream states. Routes without ForwardAuth omit the authentication stage +entirely. Every generated response includes an `X-Error-ID` header and the same +identifier in the HTML footer. + +## Repository contents + +- `values.yaml`: portable defaults with authentication integration disabled. +- `values-brunner-ninja.yaml`: the live brunner.ninja branding and Authentik + integration. +- `templates/`: ConfigMap, nginx Deployment, Service, Traefik Errors middleware, + and optional ForwardAuth chain. +- `install.sh`: validates, renders, applies, and waits for a healthy rollout. +- `test.sh`: renders both the generic and brunner.ninja configurations locally. +- `ci-deployer.yaml`: restricted Gitea Actions ServiceAccount and token request. +- `create-ci-kubeconfig.sh`: bootstraps that identity and prints the one-line CI + kubeconfig secret. +- `.gitea/workflows/deploy.yml`: validates every push and deploys `main`. +- `examples/`: global and per-Ingress Traefik attachment examples. + +No credential value or generated kubeconfig belongs in this repository. + +## Prerequisites + +- Kubernetes with a working Traefik installation. +- Traefik's `middlewares.traefik.io` CRD. +- Helm 3 or 4 for local rendering. +- kubectl access with permission to create the initial namespaced resources. + +The nginx image is pinned by digest. No registry credentials, persistent +volumes, cert-manager resources, or public Ingress are required. + +## Install on brunner.ninja + +Run the first installation with an administrator kubeconfig: + +```sh +./install.sh +``` + +The script uses namespace `default` and `values-brunner-ninja.yaml` by default. +It performs a server-side dry run before changing anything, then waits for the +new pod to become Ready. + +To use another namespace or values file: + +```sh +NAMESPACE=traefik VALUES_FILE=values.yaml ./install.sh +``` + +The target namespace must already exist. If `fullnameOverride` is changed, +also pass the resulting Deployment name through `DEPLOYMENT_NAME`. + +## Connect it to Traefik + +Installing the chart creates the error backend and middleware but does not edit +an independently managed Traefik release. Attach the middleware globally to +the HTTPS entrypoint in Traefik's Helm values: + +```yaml +ports: + websecure: + http: + middlewares: + - default-upstream-errors@kubernetescrd +``` + +The reference format is `-@kubernetescrd`. The included +`examples/traefik-values.yaml` contains this configuration. Restart or upgrade +Traefik after changing its static entrypoint configuration. + +To use the page only on selected routes, omit the global entrypoint middleware +and use the annotation shown in `examples/ingress.yaml`, or add this middleware +to an IngressRoute: + +```yaml +middlewares: + - name: upstream-errors + namespace: default +``` + +Traefik's Errors middleware preserves the original status. It asks this service +for `/{status}.html`, replaces the body, and returns the original 5xx code. + +## Optional ForwardAuth diagnostics + +Generic installations leave `auth.enabled: false`. Direct upstream failures +then render three stages and contain no authentication-provider wording. + +The brunner.ninja values enable a generated chain named `authentik`: + +1. `authentik-error-context` marks the request and clears spoofed completion + headers. +2. `authentik-forward` calls Authentik. +3. `authentik-error-checked` marks a successful access check. + +Existing routes continue to reference: + +```text +default-authentik@kubernetescrd +``` + +If Authentik fails, the page shows `brunner.ninja Auth` as failed and the +upstream as not accessed. If Authentik succeeds but the application fails, Auth +is shown as working. Customize these resources through the `auth` and +`branding` sections of the values file. + +## Validate and update manually + +Run local rendering tests before committing: + +```sh +./test.sh +``` + +Update `values-brunner-ninja.yaml` or a template, then deploy with the same +idempotent command used for installation: + +```sh +./install.sh +``` + +The Deployment contains a hash of the rendered ConfigMap. HTML or nginx changes +therefore create a rolling update automatically. With one replica, +`maxUnavailable: 0` keeps the old healthy pod until the replacement passes its +readiness probe. A broken nginx configuration should fail the rollout without +removing the last working pod. + +Rollback by reverting the Git commit and running `./install.sh` again. + +## Automatic deployment with Gitea Actions + +The workflow validates all pushes and pull requests. A successful push to +`main` renders `values-brunner-ninja.yaml`, applies the fixed set of resources, +and polls the Deployment for up to two minutes. + +CI deliberately does not use `helm upgrade`. It cannot read or write Helm +release Secrets. Its Role can only patch the already-created ConfigMap, +Deployment, Service, and five named Traefik Middleware objects. It cannot create +or delete workloads, read Secrets, or modify Traefik itself. + +### One-time CI setup + +1. Install the application manually with `./install.sh`. +2. With your administrator kubeconfig active, generate the restricted CI + kubeconfig: + + ```sh + ./create-ci-kubeconfig.sh + ``` + + The script applies `ci-deployer.yaml`, waits for Kubernetes to populate the + service-account token, and prints one base64 line. + +3. Create the Gitea Actions repository secret `KUBE_CONFIG_BASE64` and paste + that line into it. Never add the output to a file in Git. +4. Confirm `KUBERNETES_API` and `KUBERNETES_TLS_SERVER_NAME` in + `.gitea/workflows/deploy.yml` match the networking seen by the runner. +5. Protect the `main` branch. Merging to it changes the globally used error + backend. + +The committed `ci-deployer.yaml` is safe: it contains only RBAC declarations +and an empty service-account-token Secret request. Kubernetes populates the +actual credential only inside the cluster. + +To revoke the CI credential: + +```sh +kubectl --namespace default delete secret traefik-error-pages-deployer-token +``` + +Remove or replace `KUBE_CONFIG_BASE64` in Gitea immediately afterward. Running +`./create-ci-kubeconfig.sh` again recreates a token and prints a replacement +kubeconfig. + +## Making another cluster-specific configuration + +Copy the generic values and edit only the override: + +```sh +cp values.yaml values-my-cluster.yaml +./test.sh values-my-cluster.yaml +``` + +In practice, keep `values.yaml` as the reusable base and create a small override +containing only branding and optional ForwardAuth settings, like +`values-brunner-ninja.yaml`. Do not put API tokens, passwords, TLS keys, or +kubeconfigs into a values file. diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..cb4284c --- /dev/null +++ b/ci-deployer.yaml @@ -0,0 +1,84 @@ +# Bootstrap this file once with an administrator identity. CI can update only +# the already-existing error-page resources named below; it cannot create or +# delete workloads and it cannot read Kubernetes Secrets. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: traefik-error-pages-deployer + namespace: default +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: traefik-error-pages-deployer + namespace: default +rules: + - apiGroups: + - "" + resources: + - configmaps + resourceNames: + - traefik-error-pages + verbs: + - get + - patch + - update + - apiGroups: + - "" + resources: + - services + resourceNames: + - traefik-error-pages + verbs: + - get + - patch + - update + - apiGroups: + - apps + resources: + - deployments + resourceNames: + - traefik-error-pages + verbs: + - get + - patch + - update + - apiGroups: + - traefik.io + resources: + - middlewares + resourceNames: + - upstream-errors + - authentik-error-context + - authentik-forward + - authentik-error-checked + - authentik + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: traefik-error-pages-deployer + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: traefik-error-pages-deployer +subjects: + - kind: ServiceAccount + name: traefik-error-pages-deployer + namespace: default +--- +apiVersion: v1 +kind: Secret +metadata: + name: traefik-error-pages-deployer-token + namespace: default + annotations: + kubernetes.io/service-account.name: traefik-error-pages-deployer +type: kubernetes.io/service-account-token + diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh new file mode 100755 index 0000000..a7bf5e4 --- /dev/null +++ b/create-ci-kubeconfig.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +namespace=${NAMESPACE:-default} +service_account=${SERVICE_ACCOUNT:-traefik-error-pages-deployer} +secret=${TOKEN_SECRET:-traefik-error-pages-deployer-token} + +kubectl apply --filename "${project_dir}/ci-deployer.yaml" >&2 + +for attempt in {1..30}; do + if kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' 2>/dev/null | grep -q .; then + break + fi + if [[ "${attempt}" == 30 ]]; then + echo "Timed out waiting for the service-account token" >&2 + exit 1 + fi + sleep 1 +done + +workdir=$(mktemp --directory) +trap 'rm -rf "${workdir}"' EXIT + +server=$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}') +kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.ca\.crt}' | base64 --decode > "${workdir}/ca.crt" +token=$(kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' | base64 --decode) + +export KUBECONFIG="${workdir}/config" +kubectl config set-cluster cluster \ + --server="${server}" \ + --certificate-authority="${workdir}/ca.crt" \ + --embed-certs=true >/dev/null +kubectl config set-credentials "${service_account}" --token="${token}" >/dev/null +kubectl config set-context traefik-error-pages \ + --cluster=cluster \ + --user="${service_account}" \ + --namespace="${namespace}" >/dev/null +kubectl config use-context traefik-error-pages >/dev/null + +echo "Store the following single line as the Gitea Actions secret KUBE_CONFIG_BASE64." >&2 +echo "Treat it as a password; do not commit it." >&2 +base64 --wrap=0 "${KUBECONFIG}" +printf '\n' + diff --git a/examples/ingress.yaml b/examples/ingress.yaml new file mode 100644 index 0000000..6c38f54 --- /dev/null +++ b/examples/ingress.yaml @@ -0,0 +1,23 @@ +# Alternative to the global entrypoint configuration: attach the page to one +# Kubernetes Ingress. Prefix the middleware with its Kubernetes namespace. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: example + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.middlewares: default-upstream-errors@kubernetescrd +spec: + ingressClassName: traefik + rules: + - host: example.invalid + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: example + port: + number: 80 + diff --git a/examples/traefik-values.yaml b/examples/traefik-values.yaml new file mode 100644 index 0000000..a64c748 --- /dev/null +++ b/examples/traefik-values.yaml @@ -0,0 +1,8 @@ +# Add the generated Errors middleware globally to Traefik's HTTPS entrypoint. +# Replace "default" if this chart is installed into another namespace. +ports: + websecure: + http: + middlewares: + - default-upstream-errors@kubernetescrd + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..014d25f --- /dev/null +++ b/install.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +namespace=${NAMESPACE:-default} +release_name=${RELEASE_NAME:-traefik-error-pages} +values_file=${VALUES_FILE:-values-brunner-ninja.yaml} +deployment_name=${DEPLOYMENT_NAME:-traefik-error-pages} + +case "${values_file}" in + /*) ;; + *) values_file="${project_dir}/${values_file}" ;; +esac + +rendered=$(mktemp) +trap 'rm -f "${rendered}"' EXIT + +kubectl get crd middlewares.traefik.io >/dev/null +helm lint "${project_dir}" --values "${values_file}" +helm template "${release_name}" "${project_dir}" \ + --namespace "${namespace}" \ + --values "${values_file}" > "${rendered}" + +kubectl apply --dry-run=server --filename "${rendered}" +kubectl apply --filename "${rendered}" +kubectl --namespace "${namespace}" rollout status \ + "deployment/${deployment_name}" --timeout=120s + +printf 'Installed %s in namespace %s\n' "${release_name}" "${namespace}" + diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl new file mode 100644 index 0000000..4614037 --- /dev/null +++ b/templates/_helpers.tpl @@ -0,0 +1,19 @@ +{{- define "traefik-error-pages.fullname" -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "traefik-error-pages.labels" -}} +app.kubernetes.io/name: {{ include "traefik-error-pages.fullname" . }} +app.kubernetes.io/managed-by: kubectl +app.kubernetes.io/part-of: traefik-error-pages +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }} +{{- end -}} + +{{- define "traefik-error-pages.selectorLabels" -}} +app.kubernetes.io/name: {{ include "traefik-error-pages.fullname" . }} +{{- end -}} + +{{- define "traefik-error-pages.nginxEscape" -}} +{{- . | replace "\\" "\\\\" | replace "\"" "\\\"" -}} +{{- end -}} + diff --git a/templates/auth-middleware.yaml b/templates/auth-middleware.yaml new file mode 100644 index 0000000..62314ba --- /dev/null +++ b/templates/auth-middleware.yaml @@ -0,0 +1,59 @@ +{{- if .Values.auth.enabled }} +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ .Values.auth.contextMiddlewareName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "traefik-error-pages.labels" . | nindent 4 }} +spec: + headers: + customRequestHeaders: + # Clear the completion marker before checking access so a client cannot + # spoof a successful authentication check. + X-Error-Auth-Provider: {{ .Values.auth.marker | quote }} + X-Error-Auth-Checked: "" +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ .Values.auth.forwardMiddlewareName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "traefik-error-pages.labels" . | nindent 4 }} +spec: + forwardAuth: + address: {{ required "auth.forwardAddress is required when auth.enabled is true" .Values.auth.forwardAddress | quote }} + trustForwardHeader: {{ .Values.auth.trustForwardHeader }} + {{- with .Values.auth.responseHeaders }} + authResponseHeaders: + {{- toYaml . | nindent 6 }} + {{- end }} +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ .Values.auth.checkedMiddlewareName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "traefik-error-pages.labels" . | nindent 4 }} +spec: + headers: + customRequestHeaders: + X-Error-Auth-Checked: passed +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ .Values.auth.chainMiddlewareName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "traefik-error-pages.labels" . | nindent 4 }} +spec: + chain: + middlewares: + - name: {{ .Values.auth.contextMiddlewareName }} + - name: {{ .Values.auth.forwardMiddlewareName }} + - name: {{ .Values.auth.checkedMiddlewareName }} +{{- end }} + diff --git a/templates/configmap.yaml b/templates/configmap.yaml new file mode 100644 index 0000000..5dad214 --- /dev/null +++ b/templates/configmap.yaml @@ -0,0 +1,294 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "traefik-error-pages.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "traefik-error-pages.labels" . | nindent 4 }} +data: + default.conf: | + map $request_uri $error_code { + default "500"; + ~^/(?5[0-9][0-9])\.html(?:\?.*)?$ $error_code_from_request; + } + + map $error_code $error_title { + default "Upstream service error"; + "500" "Internal server error"; + "501" "Not implemented"; + "502" "Bad gateway"; + "503" "Service unavailable"; + "504" "Gateway timeout"; + "505" "HTTP version not supported"; + "507" "Insufficient storage"; + "508" "Loop detected"; + "511" "Network authentication required"; + } + + # The optional ForwardAuth chain sets the first marker before access is + # checked and the second marker only after ForwardAuth succeeds. + map "$http_x_error_auth_provider:$http_x_error_auth_checked" $failure_mode { + default upstream-direct; + "{{ include "traefik-error-pages.nginxEscape" .Values.auth.marker }}:passed" upstream-authenticated; + "{{ include "traefik-error-pages.nginxEscape" .Values.auth.marker }}:" auth-failed; + } + + map $failure_mode $page_title { + default $error_title; + auth-failed "Authentication service unavailable"; + } + + map $failure_mode $page_detail { + default "The upstream service for $host returned an error."; + auth-failed "{{ include "traefik-error-pages.nginxEscape" .Values.branding.authenticationName }} could not validate this request, so $host was not contacted."; + } + + map $failure_mode $stages_class { + default "stages"; + upstream-authenticated "stages four-stages"; + auth-failed "stages four-stages"; + } + + map $failure_mode $show_auth { + default 0; + upstream-authenticated 1; + auth-failed 1; + } + + map $failure_mode $auth_css { + default neutral; + upstream-authenticated ok; + auth-failed failed; + } + + map $failure_mode $auth_status { + default "Not used"; + upstream-authenticated "Working"; + auth-failed "Error"; + } + + map $failure_mode $auth_detail { + default "Authentication is not used by this route"; + upstream-authenticated "Access check completed"; + auth-failed "Access check could not complete"; + } + + map $failure_mode $upstream_css { + default failed; + auth-failed neutral; + } + + map $failure_mode $error_upstream_status { + default "Error"; + auth-failed "Not accessed"; + } + + map $failure_mode $upstream_detail { + default "$host returned HTTP $error_code"; + auth-failed "Blocked safely before forwarding"; + } + + log_format error_page escape=json + '{"time":"$time_iso8601","error_id":"$request_id","host":"$host",' + '"status":$error_code,"failure_mode":"$failure_mode","client":"$remote_addr"}'; + + server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + server_tokens off; + charset utf-8; + etag off; + + access_log /dev/stdout error_page; + error_log /dev/stderr warn; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location ~ ^/5[0-9][0-9]\.html$ { + ssi on; + try_files /error-page.html =404; + + add_header Cache-Control "no-store" always; + add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Error-ID $request_id always; + add_header X-Robots-Tag "noindex, nofollow" always; + } + + location / { + return 404; + } + } + + error-page.html: | + + + + + + <!--# echo var="page_title" encoding="entity" --> | <!--# echo var="host" encoding="entity" --> + + + +
+
+ HTTP +

+

+
+ +
" aria-label="Request status"> +
+ +

You

+ Browser + Working + Connected successfully +
+ +
+ +

{{ .Values.branding.reverseProxyName | html }}

+ Reverse proxy + Working + {{ .Values.branding.clusterReachedText | html }} +
+ + +
+
" aria-hidden="true">!
+

{{ .Values.branding.authenticationName | html }}

+ Access control + "> + +
+ + +
+
" aria-hidden="true">!
+

+ Upstream service + "> + +
+
+ + +
+ + + diff --git a/templates/deployment.yaml b/templates/deployment.yaml new file mode 100644 index 0000000..bcb7e28 --- /dev/null +++ b/templates/deployment.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "traefik-error-pages.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "traefik-error-pages.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "traefik-error-pages.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "traefik-error-pages.selectorLabels" . | nindent 8 }} + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: nginx + image: {{ printf "%s:%s@%s" .Values.image.repository .Values.image.tag .Values.image.digest | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 1 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d + readOnly: true + - name: error-page + mountPath: /usr/share/nginx/html + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: nginx-config + configMap: + name: {{ include "traefik-error-pages.fullname" . }} + items: + - key: default.conf + path: default.conf + - name: error-page + configMap: + name: {{ include "traefik-error-pages.fullname" . }} + items: + - key: error-page.html + path: error-page.html + - name: tmp + emptyDir: {} + diff --git a/templates/middleware.yaml b/templates/middleware.yaml new file mode 100644 index 0000000..e035a83 --- /dev/null +++ b/templates/middleware.yaml @@ -0,0 +1,19 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ .Values.middleware.name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "traefik-error-pages.labels" . | nindent 4 }} +spec: + errors: + status: + {{- range .Values.middleware.status }} + - {{ . | quote }} + {{- end }} + query: /{status}.html + service: + name: {{ include "traefik-error-pages.fullname" . }} + port: http + passHostHeader: true + diff --git a/templates/service.yaml b/templates/service.yaml new file mode 100644 index 0000000..5af52d0 --- /dev/null +++ b/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "traefik-error-pages.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "traefik-error-pages.labels" . | nindent 4 }} +spec: + selector: + {{- include "traefik-error-pages.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..a9ebcd6 --- /dev/null +++ b/test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +workdir=$(mktemp --directory) +trap 'rm -rf "${workdir}"' EXIT + +if (( $# > 0 )); then + values_files=("$@") +else + values_files=(values.yaml values-brunner-ninja.yaml) +fi + +for values_file in "${values_files[@]}"; do + case "${values_file}" in + /*) values_path="${values_file}" ;; + *) values_path="${project_dir}/${values_file}" ;; + esac + output="${workdir}/$(basename "${values_file}")" + helm lint "${project_dir}" --values "${values_path}" + helm template traefik-error-pages "${project_dir}" \ + --namespace default \ + --values "${values_path}" > "${output}" + test -s "${output}" +done + +if [[ -f "${workdir}/values-brunner-ninja.yaml" ]]; then + grep --quiet 'brunner\.ninja Reverse Proxy' "${workdir}/values-brunner-ninja.yaml" + grep --quiet '^ name: authentik$' "${workdir}/values-brunner-ninja.yaml" +fi +if [[ -f "${workdir}/values.yaml" ]]; then + if grep --quiet '^ name: error-auth$' "${workdir}/values.yaml"; then + echo "Generic values unexpectedly enabled the authentication middleware" >&2 + exit 1 + fi +fi + +echo "All requested configurations rendered successfully" diff --git a/values-brunner-ninja.yaml b/values-brunner-ninja.yaml new file mode 100644 index 0000000..b3b32f9 --- /dev/null +++ b/values-brunner-ninja.yaml @@ -0,0 +1,30 @@ +branding: + reverseProxyName: brunner.ninja Reverse Proxy + authenticationName: brunner.ninja Auth + clusterReachedText: The request reached the basement cluster + +auth: + enabled: true + marker: authentik + contextMiddlewareName: authentik-error-context + forwardMiddlewareName: authentik-forward + checkedMiddlewareName: authentik-error-checked + # Preserve the public middleware name used by existing routes: + # default-authentik@kubernetescrd + chainMiddlewareName: authentik + forwardAddress: http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik + trustForwardHeader: true + responseHeaders: + - X-authentik-username + - X-authentik-groups + - X-authentik-entitlements + - X-authentik-email + - X-authentik-name + - X-authentik-uid + - X-authentik-jwt + - X-authentik-meta-jwks + - X-authentik-meta-outpost + - X-authentik-meta-provider + - X-authentik-meta-app + - X-authentik-meta-version + diff --git a/values.schema.json b/values.schema.json new file mode 100644 index 0000000..7ff69c3 --- /dev/null +++ b/values.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["replicaCount", "image", "branding", "middleware", "auth"], + "properties": { + "fullnameOverride": {"type": "string", "minLength": 1}, + "replicaCount": {"type": "integer", "minimum": 1}, + "image": { + "type": "object", + "required": ["repository", "tag", "digest", "pullPolicy"], + "properties": { + "repository": {"type": "string", "minLength": 1}, + "tag": {"type": "string", "minLength": 1}, + "digest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "pullPolicy": {"enum": ["Always", "IfNotPresent", "Never"]} + } + }, + "branding": { + "type": "object", + "required": ["reverseProxyName", "authenticationName", "clusterReachedText"], + "properties": { + "reverseProxyName": {"type": "string", "minLength": 1}, + "authenticationName": {"type": "string", "minLength": 1}, + "clusterReachedText": {"type": "string", "minLength": 1} + } + }, + "middleware": { + "type": "object", + "required": ["name", "status"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "status": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "pattern": "^[1-5][0-9]{2}(-[1-5][0-9]{2})?$"} + } + } + }, + "auth": { + "type": "object", + "required": ["enabled", "marker", "contextMiddlewareName", "forwardMiddlewareName", "checkedMiddlewareName", "chainMiddlewareName", "forwardAddress"], + "properties": { + "enabled": {"type": "boolean"}, + "marker": {"type": "string", "minLength": 1}, + "contextMiddlewareName": {"type": "string", "minLength": 1}, + "forwardMiddlewareName": {"type": "string", "minLength": 1}, + "checkedMiddlewareName": {"type": "string", "minLength": 1}, + "chainMiddlewareName": {"type": "string", "minLength": 1}, + "forwardAddress": {"type": "string"} + }, + "allOf": [ + { + "if": {"properties": {"enabled": {"const": true}}}, + "then": {"properties": {"forwardAddress": {"type": "string", "format": "uri", "minLength": 1}}} + } + ] + } + } +} + diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..95fe315 --- /dev/null +++ b/values.yaml @@ -0,0 +1,47 @@ +# Keep resource names stable so a restricted CI identity can update only these +# known objects. Change this before the first installation if desired. +fullnameOverride: traefik-error-pages + +replicaCount: 1 + +image: + repository: nginxinc/nginx-unprivileged + tag: 1.29.4-alpine + digest: sha256:a6c4f61f456b85b8fdf7ec7ab28cc3e299440e6fb4a9dea520e5fd8fd440025e + pullPolicy: IfNotPresent + +branding: + reverseProxyName: Kubernetes Reverse Proxy + authenticationName: Cluster Authentication + clusterReachedText: The request reached the Kubernetes cluster + +service: + port: 80 + +middleware: + name: upstream-errors + status: + - 500-599 + +# Optional ForwardAuth chain. When enabled, routes use this generated chain +# instead of their original ForwardAuth middleware. The markers allow the page +# to distinguish an authentication outage from an upstream outage. +auth: + enabled: false + marker: forward-auth + contextMiddlewareName: error-auth-context + forwardMiddlewareName: error-auth-forward + checkedMiddlewareName: error-auth-checked + chainMiddlewareName: error-auth + forwardAddress: "" + trustForwardHeader: true + responseHeaders: [] + +resources: + requests: + cpu: 5m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi +