From 1daf39f4fcf9de3f70574b4b215f74f599457d5c Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sat, 5 Sep 2026 17:56:41 +0200 Subject: [PATCH] Introduce CI/CD --- .gitea/workflows/deploy.yml | 76 ++++++++ .gitignore | 6 + README.md | 50 ++++- auth-proxy.yaml | 248 +++++++++++++++++++++++++ ci-deployer.yaml | 130 +++++++++++++ create-ci-kubeconfig.sh | 50 +++++ filter-secrets.awk | 23 +++ helm-plugins/pin-images/plugin.yaml | 8 + install.sh | 51 +++++ kubernetes-dashboard-ingressroute.yaml | 40 ++++ kubernetes-dashboard-user.yaml | 21 +++ kubernetes-dashboard-values.yaml | 77 ++++++++ pin-images.sh | 9 + render.sh | 27 +++ test.sh | 54 ++++++ 15 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 auth-proxy.yaml create mode 100644 ci-deployer.yaml create mode 100755 create-ci-kubeconfig.sh create mode 100644 filter-secrets.awk create mode 100644 helm-plugins/pin-images/plugin.yaml create mode 100755 install.sh create mode 100644 kubernetes-dashboard-ingressroute.yaml create mode 100644 kubernetes-dashboard-user.yaml create mode 100644 kubernetes-dashboard-values.yaml create mode 100755 pin-images.sh create mode 100755 render.sh create mode 100755 test.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..42c1ccf --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,76 @@ +name: Validate and deploy Kubernetes Dashboard + +on: + push: + pull_request: + +env: + DASHBOARD_CHART_VERSION: 7.14.0 + DASHBOARD_CHART_SHA256: 37be462a39258fda57a2a5e5d81082d8d28a8083c7977f4f4632fa374b1a616d + HELM_VERSION: v4.2.2 + HELM_SHA256: 9adafecab4d406853bba163a70e9f104f47dbbf65ce24b7653bae7e36150bcb6 + KUBECTL_VERSION: v1.36.3 + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 + NAMESPACE: kubernetes-dashboard + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install pinned Helm + run: | + curl -fsSL -o "${RUNNER_TEMP}/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + printf '%s %s\n' "${HELM_SHA256}" "${RUNNER_TEMP}/helm.tgz" | sha256sum --check + tar -xzf "${RUNNER_TEMP}/helm.tgz" -C "${RUNNER_TEMP}" --strip-components=1 linux-amd64/helm + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + - run: bash test.sh + + deploy: + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install pinned tools + run: | + curl -fsSL -o "${RUNNER_TEMP}/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + printf '%s %s\n' "${HELM_SHA256}" "${RUNNER_TEMP}/helm.tgz" | sha256sum --check + tar -xzf "${RUNNER_TEMP}/helm.tgz" -C "${RUNNER_TEMP}" --strip-components=1 linux-amd64/helm + curl -fsSL -o "${RUNNER_TEMP}/kubectl" "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl -fsSL -o "${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}/helm" "${RUNNER_TEMP}/kubectl" + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + - 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" + kubectl config set-cluster cluster --server="${KUBERNETES_API}" --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" + - name: Apply public desired state and verify + run: | + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + ./render.sh "${RUNNER_TEMP}/dashboard.yaml" + kubectl apply --server-side --force-conflicts --dry-run=server --validate=false \ + --filename "${RUNNER_TEMP}/dashboard.yaml" \ + --filename auth-proxy.yaml \ + --filename kubernetes-dashboard-ingressroute.yaml + kubectl apply --server-side --force-conflicts --validate=false \ + --filename "${RUNNER_TEMP}/dashboard.yaml" \ + --filename auth-proxy.yaml \ + --filename kubernetes-dashboard-ingressroute.yaml + + for deployment in kubernetes-dashboard-api kubernetes-dashboard-auth kubernetes-dashboard-web kubernetes-dashboard-metrics-scraper kubernetes-dashboard-kong kubernetes-dashboard-auth-proxy; do + kubectl --namespace "${NAMESPACE}" rollout status "deployment/${deployment}" --timeout=10m + done + + status=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' --max-time 15 https://kubernetes.brunner.ninja/) + case "${status}" in + 302|303|307) ;; + *) echo "Expected Authentik redirect, received HTTP ${status}" >&2; exit 1 ;; + esac diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45a944a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.kubeconfig +kubeconfig* +rendered*.yaml +*.tgz +.env +.DS_Store diff --git a/README.md b/README.md index e5fc46f..c14400b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,50 @@ -# kubernetes-dashboard-deployment +# Kubernetes Dashboard on arschrock +This repository pins the final official Kubernetes Dashboard chart, `7.14.0`, +directly from its GitHub release with SHA-256 verification. The old Helm index +now returns 404 because Kubernetes Dashboard is archived and unmaintained. +Upstream recommends migrating to Headlamp; this deployment should therefore be +treated as an interim compatibility service, not a long-term platform choice. + +## Authentication and TLS + +The public route at `https://kubernetes.brunner.ninja` is protected by a +namespace-local Traefik forward-auth middleware that uses the shared Authentik +embedded outpost. Keeping the middleware local allows Traefik's safer +`allowCrossNamespace=false` setting to remain enabled. Authentik permits only +members of `authentik Admins`. A private nginx proxy then adds a projected token for the +existing `admin-user` service account. Kubernetes issues and rotates that token +automatically every hour; it is never stored in Git, a static Secret, Gitea, or +Traefik configuration. Supplying the Authorization header makes Dashboard skip +its bearer-token login page. + +This preserves the previous cluster-admin behavior, but Kubernetes audit events +identify every Dashboard action as the same service account. Native Authentik +OIDC would provide per-user identity, but requires kube-apiserver configuration +and another OAuth proxy because this Dashboard has no native OIDC login. Prefer +Headlamp with native OIDC when replacing this retired application. + +The Authentik application and provider are owned by the adjacent `authentik` +repository. Run its `install.sh` once before exposing this route; that installer +also attaches the provider to the embedded outpost. No Dashboard OAuth client +secret is required for forward authentication. + +Traefik terminates browser-facing HTTPS. Kong's internal HTTPS listener remains +enabled because the auth proxy sends a privileged bearer token to it. Kong's +plain HTTP listener is disabled. + +## Bootstrap and CI/CD + +Run `./install.sh` once with administrator access. It downloads and verifies the +pinned chart, repairs the existing failed Helm release, creates the bootstrap +RBAC, and waits for all six deployments. Run `./test.sh` for a secret-free chart +render and schema validation. + +Then run `./create-ci-kubeconfig.sh` and store its final line as the Gitea +Actions secret `KUBE_CONFIG_BASE64`. The CI identity can update only named, +already-existing Dashboard objects. It cannot read Secrets, edit the +`admin-user` cluster-admin binding, create resources, or delete resources. + +The deploy workflow runs only for `main`; pull requests and all pushes run the +validation job. The chart's generated CSRF Secret is created by the bootstrap +Helm installation and deliberately filtered out of the restricted CI render. diff --git a/auth-proxy.yaml b/auth-proxy.yaml new file mode 100644 index 0000000..5e50487 --- /dev/null +++ b/auth-proxy.yaml @@ -0,0 +1,248 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: kubernetes-dashboard-auth-proxy + namespace: kubernetes-dashboard +data: + nginx.conf.template: | + pid /tmp/nginx.pid; + error_log /dev/stderr warn; + + events {} + + http { + access_log /dev/stdout; + client_body_temp_path /tmp/client-body; + proxy_temp_path /tmp/proxy; + fastcgi_temp_path /tmp/fastcgi; + uwsgi_temp_path /tmp/uwsgi; + scgi_temp_path /tmp/scgi; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 8080; + server_name _; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 'ok\n'; + } + + location / { + proxy_http_version 1.1; + proxy_pass https://kubernetes-dashboard-kong-proxy.kubernetes-dashboard.svc.cluster.local:443; + proxy_ssl_name kubernetes-dashboard-kong-proxy.kubernetes-dashboard.svc.cluster.local; + proxy_ssl_server_name on; + # Kong uses its chart-generated internal certificate. + proxy_ssl_verify off; + + proxy_set_header Authorization "Bearer @@DASHBOARD_TOKEN@@"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + } + } + run.sh: | + #!/bin/sh + set -eu + + token_file=/var/run/secrets/dashboard/token + template=/config/nginx.conf.template + rendered=/tmp/nginx.conf + + render_config() { + dashboard_token=$(tr -d '\n' < "$token_file") + test -n "$dashboard_token" + sed "s|@@DASHBOARD_TOKEN@@|$dashboard_token|g" "$template" > "$rendered.new" + chmod 0600 "$rendered.new" + mv "$rendered.new" "$rendered" + unset dashboard_token + } + + stop() { + nginx -c "$rendered" -s quit 2>/dev/null || true + exit 0 + } + + trap stop INT TERM + render_config + token_digest=$(sha256sum "$token_file" | awk '{print $1}') + nginx -t -c "$rendered" + nginx -c "$rendered" + + while :; do + sleep 60 + next_digest=$(sha256sum "$token_file" | awk '{print $1}') + if [ "$next_digest" != "$token_digest" ]; then + render_config + nginx -t -c "$rendered" + nginx -c "$rendered" -s reload + token_digest=$next_digest + fi + done +--- +apiVersion: v1 +kind: Service +metadata: + name: kubernetes-dashboard-auth-proxy + namespace: kubernetes-dashboard +spec: + selector: + app.kubernetes.io/name: kubernetes-dashboard-auth-proxy + ports: + - name: http + port: 8080 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kubernetes-dashboard-auth-proxy + namespace: kubernetes-dashboard +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: kubernetes-dashboard-auth-proxy + template: + metadata: + labels: + app.kubernetes.io/name: kubernetes-dashboard-auth-proxy + app.kubernetes.io/part-of: kubernetes-dashboard + spec: + serviceAccountName: admin-user + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + fsGroup: 101 + seccompProfile: + type: RuntimeDefault + containers: + - name: proxy + image: docker.io/nginxinc/nginx-unprivileged:1.29.1-alpine@sha256:6742337773a7ca839e7a76f489cc684cc410e3de46c0c83be8bb2bb9944360ef + imagePullPolicy: IfNotPresent + command: [/bin/sh, /config/run.sh] + ports: + - name: http + containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 20 + volumeMounts: + - name: config + mountPath: /config + readOnly: true + - name: token + mountPath: /var/run/secrets/dashboard + readOnly: true + - name: temporary + mountPath: /tmp + volumes: + - name: config + configMap: + name: kubernetes-dashboard-auth-proxy + defaultMode: 0555 + - name: token + projected: + defaultMode: 0440 + sources: + - serviceAccountToken: + path: token + audience: https://kubernetes.default.svc.cluster.local + expirationSeconds: 3600 + - name: temporary + emptyDir: + sizeLimit: 64Mi +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: kubernetes-dashboard-auth-proxy + namespace: kubernetes-dashboard +spec: + maxUnavailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: kubernetes-dashboard-auth-proxy +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: kubernetes-dashboard-auth-proxy + namespace: kubernetes-dashboard +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: kubernetes-dashboard-auth-proxy + policyTypes: [Ingress, Egress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: default + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8080 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + - to: + - podSelector: + matchLabels: + app.kubernetes.io/instance: kubernetes-dashboard + app.kubernetes.io/name: kong + ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 8443 diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..858eebc --- /dev/null +++ b/ci-deployer.yaml @@ -0,0 +1,130 @@ +# Bootstrap once with an administrator identity. This credential can update +# only the existing Dashboard resources and cannot read Kubernetes Secrets. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kubernetes-dashboard-deployer + namespace: kubernetes-dashboard +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: kubernetes-dashboard-deployer + namespace: kubernetes-dashboard +rules: + - apiGroups: [""] + resources: [serviceaccounts] + resourceNames: + - kubernetes-dashboard-kong + - kubernetes-dashboard-api + - kubernetes-dashboard-metrics-scraper + - kubernetes-dashboard-web + verbs: [get, patch, update] + - apiGroups: [""] + resources: [configmaps] + resourceNames: + - kong-dbless-config + - kubernetes-dashboard-web-settings + - kubernetes-dashboard-auth-proxy + verbs: [get, patch, update] + - apiGroups: [""] + resources: [services] + resourceNames: + - kubernetes-dashboard-kong-proxy + - kubernetes-dashboard-api + - kubernetes-dashboard-auth + - kubernetes-dashboard-metrics-scraper + - kubernetes-dashboard-web + - kubernetes-dashboard-auth-proxy + verbs: [get, patch, update] + - apiGroups: [apps] + resources: [deployments] + resourceNames: + - kubernetes-dashboard-kong + - kubernetes-dashboard-api + - kubernetes-dashboard-auth + - kubernetes-dashboard-metrics-scraper + - kubernetes-dashboard-web + - kubernetes-dashboard-auth-proxy + verbs: [get, patch, update] + - apiGroups: [policy] + resources: [poddisruptionbudgets] + resourceNames: + - kubernetes-dashboard + - kubernetes-dashboard-auth-proxy + verbs: [get, patch, update] + - apiGroups: [networking.k8s.io] + resources: [networkpolicies] + resourceNames: [kubernetes-dashboard-auth-proxy] + verbs: [get, patch, update] + - apiGroups: [rbac.authorization.k8s.io] + resources: [roles] + resourceNames: + - kubernetes-dashboard-api + - kubernetes-dashboard-web + verbs: [get, patch, update, escalate] + - apiGroups: [rbac.authorization.k8s.io] + resources: [rolebindings] + resourceNames: + - kubernetes-dashboard-api + - kubernetes-dashboard-web + verbs: [get, patch, update, bind] + - apiGroups: [traefik.io] + resources: [ingressroutes] + resourceNames: [kubernetes-dashboard-ingressroute] + verbs: [get, patch, update] + - apiGroups: [traefik.io] + resources: [middlewares] + resourceNames: [kubernetes-dashboard-authentik] + verbs: [get, patch, update] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: kubernetes-dashboard-deployer + namespace: kubernetes-dashboard +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: kubernetes-dashboard-deployer +subjects: + - kind: ServiceAccount + name: kubernetes-dashboard-deployer + namespace: kubernetes-dashboard +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kubernetes-dashboard-deployer +rules: + - apiGroups: [rbac.authorization.k8s.io] + resources: [clusterroles] + resourceNames: [kubernetes-dashboard-metrics-scraper] + verbs: [get, patch, update, escalate] + - apiGroups: [rbac.authorization.k8s.io] + resources: [clusterrolebindings] + resourceNames: [kubernetes-dashboard-metrics-scraper] + verbs: [get, patch, update, bind] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kubernetes-dashboard-deployer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kubernetes-dashboard-deployer +subjects: + - kind: ServiceAccount + name: kubernetes-dashboard-deployer + namespace: kubernetes-dashboard +--- +apiVersion: v1 +kind: Secret +metadata: + name: kubernetes-dashboard-deployer-token + namespace: kubernetes-dashboard + annotations: + kubernetes.io/service-account.name: kubernetes-dashboard-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..0f7a4df --- /dev/null +++ b/create-ci-kubeconfig.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +namespace=kubernetes-dashboard +account=kubernetes-dashboard-deployer +secret=kubernetes-dashboard-deployer-token +remote=${KUBECTL_SSH_HOST:-} + +cluster_kubectl() { + if [[ -n "${remote}" ]]; then + ssh "${remote}" kubectl "$@" + else + kubectl "$@" + fi +} + +if [[ -n "${remote}" ]]; then + cluster_kubectl apply --filename=- < "${project_dir}/ci-deployer.yaml" >&2 +else + cluster_kubectl apply --filename "${project_dir}/ci-deployer.yaml" >&2 +fi + +for attempt in {1..30}; do + data=$(cluster_kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' 2>/dev/null || true) + [[ -n "${data}" ]] && break + sleep 1 +done +[[ -n "${data:-}" ]] || { echo "Token was not issued" >&2; exit 1; } + +workdir=$(mktemp --directory) +trap 'rm -rf "${workdir}"' EXIT +server=$(cluster_kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}') +cluster_kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.ca\.crt}' | base64 --decode > "${workdir}/ca.crt" + +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 "${account}" \ + --token="$(printf '%s' "${data}" | base64 --decode)" >/dev/null +kubectl config set-context kubernetes-dashboard --cluster=cluster \ + --user="${account}" --namespace="${namespace}" >/dev/null +kubectl config use-context kubernetes-dashboard >/dev/null + +echo "Store the following line as Gitea Actions secret KUBE_CONFIG_BASE64; do not commit it." >&2 +base64 --wrap=0 "${KUBECONFIG}" +printf '\n' diff --git a/filter-secrets.awk b/filter-secrets.awk new file mode 100644 index 0000000..e67e05b --- /dev/null +++ b/filter-secrets.awk @@ -0,0 +1,23 @@ +function emit_document() { + if (document != "" && !is_secret) { + printf "%s", document + } +} + +$0 == "---" { + emit_document() + document = "---\n" + is_secret = 0 + next +} + +{ + document = document $0 "\n" + if ($0 ~ /^kind:[[:space:]]*Secret[[:space:]]*$/) { + is_secret = 1 + } +} + +END { + emit_document() +} diff --git a/helm-plugins/pin-images/plugin.yaml b/helm-plugins/pin-images/plugin.yaml new file mode 100644 index 0000000..de15b05 --- /dev/null +++ b/helm-plugins/pin-images/plugin.yaml @@ -0,0 +1,8 @@ +name: pin-images +version: 1.0.0 +type: postrenderer/v1 +apiVersion: v1 +runtime: subprocess +runtimeConfig: + platformCommand: + - command: "${HELM_PLUGIN_DIR}/../../pin-images.sh" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..eb4d4f1 --- /dev/null +++ b/install.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +set -eu + +chart_version=${DASHBOARD_CHART_VERSION:-7.14.0} +chart_sha256=${DASHBOARD_CHART_SHA256:-37be462a39258fda57a2a5e5d81082d8d28a8083c7977f4f4632fa374b1a616d} +project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +chart_archive=$(mktemp) +helm_cache=$(mktemp -d) +trap 'rm -f "$chart_archive"; rm -rf "$helm_cache"' EXIT HUP INT TERM + +for command in curl helm kubectl sha256sum; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command not found: $command" >&2 + exit 1 + fi +done + +kubectl create namespace kubernetes-dashboard --dry-run=client --output=yaml \ + | kubectl apply --filename=- + +# This privileged identity is deliberately bootstrap-only. CI cannot edit the +# cluster-admin binding, and no long-lived service-account token is created. +kubectl apply --filename "$project_dir/kubernetes-dashboard-user.yaml" + +curl --fail --silent --show-error --location --output "$chart_archive" \ + "https://github.com/kubernetes-retired/dashboard/releases/download/kubernetes-dashboard-${chart_version}/kubernetes-dashboard-${chart_version}.tgz" +printf '%s %s\n' "$chart_sha256" "$chart_archive" | sha256sum --check >&2 + +HELM_CACHE_HOME="$helm_cache" HELM_PLUGINS="$project_dir/helm-plugins" \ +helm upgrade --install kubernetes-dashboard "$chart_archive" \ + --namespace kubernetes-dashboard \ + --values "$project_dir/kubernetes-dashboard-values.yaml" \ + --post-renderer pin-images \ + --server-side=true --take-ownership --force-conflicts --rollback-on-failure \ + --wait --timeout 15m + +kubectl apply --filename "$project_dir/auth-proxy.yaml" +kubectl apply --filename "$project_dir/kubernetes-dashboard-ingressroute.yaml" + +for deployment in \ + kubernetes-dashboard-api \ + kubernetes-dashboard-auth \ + kubernetes-dashboard-web \ + kubernetes-dashboard-metrics-scraper \ + kubernetes-dashboard-kong \ + kubernetes-dashboard-auth-proxy +do + kubectl --namespace kubernetes-dashboard rollout status \ + "deployment/$deployment" --timeout=10m +done diff --git a/kubernetes-dashboard-ingressroute.yaml b/kubernetes-dashboard-ingressroute.yaml new file mode 100644 index 0000000..f33a0bb --- /dev/null +++ b/kubernetes-dashboard-ingressroute.yaml @@ -0,0 +1,40 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: kubernetes-dashboard-authentik + namespace: kubernetes-dashboard +spec: + forwardAuth: + address: http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik + authResponseHeaders: + - 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 + trustForwardHeader: true +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: kubernetes-dashboard-ingressroute + namespace: kubernetes-dashboard +spec: + entryPoints: + - websecure + routes: + - kind: Rule + match: Host(`kubernetes.brunner.ninja`) + middlewares: + - name: kubernetes-dashboard-authentik + services: + - kind: Service + name: kubernetes-dashboard-auth-proxy + port: http diff --git a/kubernetes-dashboard-user.yaml b/kubernetes-dashboard-user.yaml new file mode 100644 index 0000000..1711d0a --- /dev/null +++ b/kubernetes-dashboard-user.yaml @@ -0,0 +1,21 @@ +# Bootstrap-only privileged identity. No static token Secret is created. +# The auth proxy receives an automatically rotated projected token. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: admin-user + namespace: kubernetes-dashboard +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: admin-user +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: admin-user + namespace: kubernetes-dashboard diff --git a/kubernetes-dashboard-values.yaml b/kubernetes-dashboard-values.yaml new file mode 100644 index 0000000..8011a4a --- /dev/null +++ b/kubernetes-dashboard-values.yaml @@ -0,0 +1,77 @@ +# Public, secret-free values for the final upstream Kubernetes Dashboard chart. +# pin-images.sh adds immutable linux/amd64 digests after Helm has rendered the +# version labels; the chart otherwise puts invalid digest strings in labels. +app: + image: + pullPolicy: IfNotPresent + security: + podDisruptionBudget: + enabled: true + maxUnavailable: 1 + settings: + global: + clusterName: arschrock + defaultNamespace: default + +auth: + image: + repository: docker.io/kubernetesui/dashboard-auth + tag: 1.4.0 + scaling: + replicas: 2 + revisionHistoryLimit: 3 + +api: + image: + repository: docker.io/kubernetesui/dashboard-api + tag: 1.14.0 + scaling: + replicas: 2 + revisionHistoryLimit: 3 + +web: + image: + repository: docker.io/kubernetesui/dashboard-web + tag: 1.7.0 + scaling: + replicas: 2 + revisionHistoryLimit: 3 + +metricsScraper: + enabled: true + image: + repository: docker.io/kubernetesui/dashboard-metrics-scraper + tag: 1.2.2 + scaling: + revisionHistoryLimit: 3 + +# Browser TLS terminates at Traefik. Kong still uses TLS because the private +# auth proxy sends the privileged projected bearer token on this hop. +kong: + enabled: true + image: + unifiedRepoTag: docker.io/library/kong:3.9@sha256:8a639948bf940868c8b4bed32fccc53d27fdd5766dbc8702b65cacbec48dcfd0 + effectiveSemver: 3.9.0 + replicaCount: 2 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + proxy: + type: ClusterIP + http: + enabled: false + tls: + enabled: true + +cert-manager: + enabled: false + +nginx: + enabled: false + +metrics-server: + enabled: false diff --git a/pin-images.sh b/pin-images.sh new file mode 100755 index 0000000..27a4d20 --- /dev/null +++ b/pin-images.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +sed \ + -e 's|docker.io/kubernetesui/dashboard-api:1.14.0|docker.io/kubernetesui/dashboard-api:1.14.0@sha256:2bd14c0ffee99d15fb1595644ebd1083ac32c5157c6e6fd8615b0f556a1390c2|g' \ + -e 's|docker.io/kubernetesui/dashboard-auth:1.4.0|docker.io/kubernetesui/dashboard-auth:1.4.0@sha256:484c65877a3aa9e7095745576e55310f09f135b2fe5d9694289352fe65986052|g' \ + -e 's|docker.io/kubernetesui/dashboard-web:1.7.0|docker.io/kubernetesui/dashboard-web:1.7.0@sha256:5924e55a2eb65774b68e35fd0d0b41a3ef5dc6ef3e02dce6e340cf59b6d67d30|g' \ + -e 's|docker.io/kubernetesui/dashboard-metrics-scraper:1.2.2|docker.io/kubernetesui/dashboard-metrics-scraper:1.2.2@sha256:0cdefa0492f2868b93f43880ffad4bd8ae8105c02dc5661352a0a40723b05dbc|g' diff --git a/render.sh b/render.sh new file mode 100755 index 0000000..21892f6 --- /dev/null +++ b/render.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +version=${DASHBOARD_CHART_VERSION:-7.14.0} +digest=${DASHBOARD_CHART_SHA256:-37be462a39258fda57a2a5e5d81082d8d28a8083c7977f4f4632fa374b1a616d} +output=${1:?Usage: render.sh OUTPUT_FILE} +chart=$(mktemp) +raw=$(mktemp) +helm_cache=$(mktemp --directory) +trap 'rm -f "${chart}" "${raw}"; rm -rf "${helm_cache}"' EXIT + +curl --fail --silent --show-error --location --output "${chart}" \ + "https://github.com/kubernetes-retired/dashboard/releases/download/kubernetes-dashboard-${version}/kubernetes-dashboard-${version}.tgz" +printf '%s %s\n' "${digest}" "${chart}" | sha256sum --check >&2 + +HELM_CACHE_HOME="${helm_cache}" HELM_PLUGINS="${project_dir}/helm-plugins" \ +helm template kubernetes-dashboard "${chart}" \ + --namespace kubernetes-dashboard \ + --values "${project_dir}/kubernetes-dashboard-values.yaml" \ + --set-string app.security.csrfKey=ci-render-only-not-used \ + --post-renderer pin-images > "${raw}" + +# The chart always renders a random CSRF Secret. It is created and retained by +# the bootstrap Helm installation; restricted CI never receives Secret access. +awk -f "${project_dir}/filter-secrets.awk" "${raw}" > "${output}" diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..d25d0a3 --- /dev/null +++ b/test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +workdir=$(mktemp --directory) +trap 'rm -rf "${workdir}"' EXIT + +sh -n "${project_dir}/install.sh" +bash -n \ + "${project_dir}/pin-images.sh" \ + "${project_dir}/render.sh" \ + "${project_dir}/create-ci-kubeconfig.sh" \ + "${project_dir}/test.sh" +awk -f "${project_dir}/filter-secrets.awk" /dev/null >/dev/null + +"${project_dir}/render.sh" "${workdir}/dashboard.yaml" + +if grep -Eq '^kind:[[:space:]]*Secret[[:space:]]*$' "${workdir}/dashboard.yaml"; then + echo "Restricted CI render contains a Secret" >&2 + exit 1 +fi +if rg -i 'Bearer [A-Za-z0-9._-]{20,}|token:[[:space:]]*[A-Za-z0-9._-]{20,}' "${project_dir}" \ + --glob '*.yaml' --glob '*.yml'; then + echo "A bearer token appears to be committed" >&2 + exit 1 +fi + +grep -Fq 'kubernetes-dashboard-auth-proxy' "${project_dir}/kubernetes-dashboard-ingressroute.yaml" +grep -Fq 'name: kubernetes-dashboard-authentik' "${project_dir}/kubernetes-dashboard-ingressroute.yaml" +grep -Fq 'authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik' \ + "${project_dir}/kubernetes-dashboard-ingressroute.yaml" +if grep -Fq 'namespace: default' "${project_dir}/kubernetes-dashboard-ingressroute.yaml"; then + echo "Dashboard route must not use a cross-namespace middleware" >&2 + exit 1 +fi +grep -Fq 'expirationSeconds: 3600' "${project_dir}/auth-proxy.yaml" +grep -Fq 'proxy_pass https://kubernetes-dashboard-kong-proxy' "${project_dir}/auth-proxy.yaml" +grep -Fq 'containerPort: 8443' "${workdir}/dashboard.yaml" +grep -Fq 'dashboard-api:1.14.0@sha256:2bd14c0ffee99d15fb1595644ebd1083ac32c5157c6e6fd8615b0f556a1390c2' "${workdir}/dashboard.yaml" + +image=ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c +for file in \ + "${workdir}/dashboard.yaml" \ + "${project_dir}/kubernetes-dashboard-user.yaml" \ + "${project_dir}/auth-proxy.yaml" \ + "${project_dir}/kubernetes-dashboard-ingressroute.yaml" \ + "${project_dir}/ci-deployer.yaml" +do + docker run --rm --interactive "${image}" \ + -strict -exit-on-error -ignore-missing-schemas -summary < "${file}" +done + +echo "Dashboard public desired state is valid and contains no static credentials."