Initial commit
Validate and deploy error pages / Validate manifests (push) Failing after 25s
Validate and deploy error pages / Deploy to Kubernetes (push) Has been skipped

This commit is contained in:
feedc0de committed 2026-08-08 20:42:38 +02:00
1 parent 01d69ac431
commit 4a68a78f0a
19 files changed
+1182 -2

No files matched your search

+118
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
kubeconfig
kubeconfig.*
rendered.yaml
.DS_Store
+7
View File
@@ -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"
+194 -2
View File
@@ -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.
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 `<namespace>-<middleware>@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.
+84
View File
@@ -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
+49
View File
@@ -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'
+23
View File
@@ -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
+8
View File
@@ -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
Executable
+31
View File
@@ -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}"
+19
View File
@@ -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 -}}
+59
View File
@@ -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 }}
+294
View File
@@ -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";
~^/(?<error_code_from_request>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: |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><!--# echo var="page_title" encoding="entity" --> | <!--# echo var="host" encoding="entity" --></title>
<style>
:root {
color-scheme: light dark;
--bg: #f7f8fa;
--card: #fff;
--text: #22252a;
--muted: #69717d;
--line: #d9dde3;
--ok: #28a745;
--failed: #d33c32;
--neutral: #9aa1aa;
--accent: #f48120;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 36px 20px;
background: var(--bg);
color: var(--text);
font: 16px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main { width: min(980px, 100%); }
.headline { text-align: center; margin-bottom: 42px; }
.eyebrow {
display: inline-block;
margin-bottom: 10px;
color: var(--accent);
font-size: .78rem;
font-weight: 750;
letter-spacing: .13em;
text-transform: uppercase;
}
h1 { margin: 0 0 10px; font-size: clamp(2rem, 5vw, 3.25rem); line-height: 1.08; }
.headline p { max-width: 680px; margin: 0 auto; color: var(--muted); font-size: 1.08rem; }
.code { white-space: nowrap; }
.stages {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.four-stages { grid-template-columns: repeat(4, 1fr); }
.stages::before {
content: "";
position: absolute;
top: 39px;
left: 16.666%;
right: 16.666%;
height: 2px;
background: var(--line);
}
.four-stages::before { left: 12.5%; right: 12.5%; }
.stage {
position: relative;
z-index: 1;
min-width: 0;
padding: 0 12px 20px;
text-align: center;
}
.icon {
width: 80px;
height: 80px;
display: grid;
place-items: center;
margin: 0 auto 18px;
border: 3px solid currentColor;
border-radius: 50%;
background: var(--card);
box-shadow: 0 0 0 8px var(--bg);
font-size: 2rem;
font-weight: 800;
}
.ok { color: var(--ok); }
.failed { color: var(--failed); }
.neutral { color: var(--neutral); }
.stage h2 { margin: 0; color: var(--text); font-size: 1.05rem; overflow-wrap: anywhere; }
.role { display: block; margin: 2px 0 8px; color: var(--muted); font-size: .82rem; }
.state { display: block; font-weight: 750; }
.detail { display: block; margin-top: 4px; color: var(--muted); font-size: .78rem; overflow-wrap: anywhere; }
footer {
margin-top: 42px;
padding-top: 20px;
border-top: 1px solid var(--line);
color: var(--muted);
text-align: center;
font-size: .82rem;
}
footer code { color: var(--text); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
@media (max-width: 720px) {
.stages { grid-template-columns: 1fr 1fr; row-gap: 26px; }
.stages::before { display: none; }
.headline { margin-bottom: 34px; }
}
@media (max-width: 430px) {
.stages { grid-template-columns: 1fr; }
.stage { display: grid; grid-template-columns: 64px 1fr; column-gap: 16px; text-align: left; }
.icon { grid-row: 1 / 5; width: 64px; height: 64px; margin: 0; font-size: 1.5rem; }
}
@media (prefers-color-scheme: dark) {
:root { --bg: #17191d; --card: #22252a; --text: #f0f1f3; --muted: #a9b0ba; --line: #3a3f47; }
}
</style>
</head>
<body>
<main>
<header class="headline">
<span class="eyebrow">HTTP <span class="code"><!--# echo var="error_code" encoding="entity" --></span></span>
<h1><!--# echo var="page_title" encoding="entity" --></h1>
<p><!--# echo var="page_detail" encoding="entity" --></p>
</header>
<section class="<!--# echo var="stages_class" encoding="none" -->" aria-label="Request status">
<article class="stage">
<div class="icon ok" aria-hidden="true">&#10003;</div>
<h2>You</h2>
<span class="role">Browser</span>
<span class="state ok">Working</span>
<span class="detail">Connected successfully</span>
</article>
<article class="stage">
<div class="icon ok" aria-hidden="true">&#10003;</div>
<h2>{{ .Values.branding.reverseProxyName | html }}</h2>
<span class="role">Reverse proxy</span>
<span class="state ok">Working</span>
<span class="detail">{{ .Values.branding.clusterReachedText | html }}</span>
</article>
<!--# if expr="$show_auth = 1" -->
<article class="stage">
<div class="icon <!--# echo var="auth_css" encoding="none" -->" aria-hidden="true">!</div>
<h2>{{ .Values.branding.authenticationName | html }}</h2>
<span class="role">Access control</span>
<span class="state <!--# echo var="auth_css" encoding="none" -->"><!--# echo var="auth_status" encoding="entity" --></span>
<span class="detail"><!--# echo var="auth_detail" encoding="entity" --></span>
</article>
<!--# endif -->
<article class="stage">
<div class="icon <!--# echo var="upstream_css" encoding="none" -->" aria-hidden="true">!</div>
<h2><!--# echo var="host" encoding="entity" --></h2>
<span class="role">Upstream service</span>
<span class="state <!--# echo var="upstream_css" encoding="none" -->"><!--# echo var="error_upstream_status" encoding="entity" --></span>
<span class="detail"><!--# echo var="upstream_detail" encoding="entity" --></span>
</article>
</section>
<footer>
Error ID: <code><!--# echo var="request_id" encoding="entity" --></code>
&nbsp;&bull;&nbsp;
<time><!--# echo var="time_iso8601" encoding="entity" --></time>
</footer>
</main>
</body>
</html>
+81
View File
@@ -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: {}
+19
View File
@@ -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
+15
View File
@@ -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
Executable
+39
View File
@@ -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"
+30
View File
@@ -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
+60
View File
@@ -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}}}
}
]
}
}
}
+47
View File
@@ -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