Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 2f25145cd1 fix(trmnl): drop the timestamp overlay, the dashboard has a clock card
The dashboard now carries a type: clock card, which the browser draws
at capture time — so the time in the image is already the time the
screenshot was taken, and the renderer's overlay would print it twice.

Supersedes the overlay added a commit ago, which was itself replacing a
Home Assistant template sensor that fired a state-changed event and
wrote a recorder row every minute. The card does the same job with no
entity, no event and no row.

Pairs with homeassistant#221.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:33:55 +02:00
claude-botandClaude Opus 5 e2fa885fa3 fix(trmnl): burn the render time into the image, not into an entity
Sets the schedule's `timestamp` option, which the renderer already
supports and which I had written out as false without asking what it
did — the same mistake as leaving `lang` null.

The freshness stamp was a Home Assistant template sensor formatting
now(). That fired a state-changed event, woke every listener and wrote
a recorder row EVERY MINUTE, forever, so the panel could draw a line of
text. Pairs with homeassistant#221, which deletes it.

Baking it into the PNG is also more correct, not just cheaper. The
panel is poll-only and shows the last image indefinitely if the
renderer dies, so the stamp exists to prove the image is fresh. A time
read from an entity proves Home Assistant was alive when the shot was
taken; a time drawn by the renderer proves the RENDER happened, which
is the failure actually being guarded against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:31:18 +02:00
26 changed files with 69 additions and 1071 deletions
@@ -1326,121 +1326,7 @@ data:
name: Caddy docs.renner.uno
state: absent
# ---------------------------------------------------------------
# wordpress-gate — www.renner.uno
#
# Unlike every other gate here, this one does NOT front a whole
# host. www.renner.uno is a public family blog; only /wp-admin and
# /wp-login.php are routed to this outpost (see
# kubernetes/apps/wordpress/ingress.yaml). The front end never
# touches it.
#
# The reverse carve-out — gate everything, skip_path_regex the
# public parts, as paperless-gate does for /static/ — is not
# possible here: the public set is every post URL, so the pattern
# would need a negative lookahead and the outpost's RE2 has none.
- id: wordpress-authors-group
model: authentik_core.group
identifiers:
name: WordPress Authors
state: present
attrs:
is_superuser: false
# Git owns membership, as with the other gates: this group is the
# explicit allowlist for who may publish on www.renner.uno.
# Add/remove members HERE, not in the UI — blueprint apply is
# authoritative for this M2M and reverts UI edits.
#
# s.renner is the Authentik account for the site's WordPress
# Editor, which still carries the pre-2019 login `s.birzer` — the
# two names do not match, and that is expected.
users:
- !Find [authentik_core.user, [username, k.renner]]
- !Find [authentik_core.user, [username, s.renner]]
- id: wordpress-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: wordpress-gate
state: present
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://wordpress.wordpress.svc.cluster.local
internal_host_ssl_validation: false
external_host: https://www.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
# No skip_path_regex: wp-admin's assets are only ever fetched by an
# already-authenticated session, and wp-admin has no manifest or
# service worker — the credentials:'omit' PWA problem that forced
# carve-outs on vscode-gate and retired music-assistant-gate does
# not arise here.
- model: authentik_core.application
identifiers:
slug: wordpress-gate
state: present
attrs:
name: WordPress (Gate)
slug: wordpress-gate
provider: !KeyOf wordpress-gate-provider
meta_launch_url: https://www.renner.uno/wp-admin/
meta_description: "Forward-auth gate in front of wp-admin on www.renner.uno"
# Only WordPress Authors pass the gate — see the group above for the
# membership list. Both accounts that exist on the migrated site are
# covered, so the gate cannot lock out the editor the site is
# currently being kept alive for.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, wordpress-gate]]
group: !KeyOf wordpress-authors-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
- model: authentik_outposts.outpost
identifiers:
name: wordpress-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf wordpress-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# Passkey login for selected apps. Passkey is mandatory for accounts
# that hold one and unavailable to accounts that do not, so both flows
# branch on enrollment (policy passkey-user-has-webauthn): enrolled →
# WebAuthn, not enrolled → password + MFA. Enrollment status is a
# property of the identified account, so the weaker branch is never
# something a requester can select.
#
# Two parts:
# Passkey-only (WebAuthn) login for selected apps, in two parts:
#
# 1. A passwordless AUTHENTICATION flow (username → passkey, no
# password prompt) bound per-provider, so fresh logins to these
@@ -1449,13 +1335,7 @@ data:
# 2. A step-up AUTHORIZATION flow — unlike the authentication flow,
# this runs on EVERY interactive authorize, so an existing
# password-only SSO session from another app cannot slip into
# these apps without presenting a passkey. Two things narrow that:
# the stage is skipped outright for accounts with no passkey (they
# have no way to satisfy it), and last_auth_threshold reduces it to
# "without presenting a passkey in this browser within the window"
# — one touch then covers every app, including a later
# password-only session for the same user. The cookie is keyed to
# that user's device, so it never carries across users.
# these apps without presenting a passkey.
passkey-stepup.yaml: |
version: 1
metadata:
@@ -1476,99 +1356,43 @@ data:
# flow this replaces.
authentication: require_authenticated
# ONE stage object, shared by the step-up authorization flow and
# the passwordless authentication flow below.
#
# `last_auth_threshold` is enforced with a single browser cookie
# (`authentik_mfa`) whose JWT is both signed with and carries the
# stage's PK, so a validation stage only ever honours a cookie it
# wrote itself. Two separate stage objects therefore each demand
# their own touch, and — sharing the one cookie name — overwrite
# each other, so neither window survives. Sharing this object is
# what lets the login touch satisfy the per-app step-up.
#
# Declared here, ahead of every !KeyOf reference to it: the
# blueprint importer resolves entries top-to-bottom.
- id: passkey-webauthn-validation
- id: passkey-stepup-webauthn-validation
model: authentik_stages_authenticator_validate.authenticatorvalidatestage
identifiers:
name: passkey-webauthn-validation
name: passkey-stepup-webauthn-validation
state: present
attrs:
device_classes:
- webauthn
# `deny`, not `configure`: the authentication flow reaches this
# stage with the user merely *identified* (a typed username)
# and no password stage in front of it, so inline enrollment
# would let anyone who knows a passkey-less username enroll
# their own passkey and take over that account. The policy
# binding means only accounts that already hold a passkey reach
# this stage at all, so `deny` is defence in depth rather than
# a path anyone walks — accounts without one branch to password
# + MFA instead.
not_configured_action: deny
# Window within which an already-presented passkey satisfies a
# later run of this stage. Non-zero for two reasons: (1) the
# per-app step-up must not demand a second touch seconds after
# the login touch; (2) NetBird's dashboard silently
# re-authorizes via /silent-auth (prompt=none) on token expiry —
# a stage that always demands interaction fails those and
# forces a visible re-login every access-token lifetime.
#
# The cookie is browser-scoped, so inside the window a re-login
# in that same browser is satisfied by the cookie alone and the
# username is all that gets typed. Accepted for a single touch
# per app.
# `configure`, not `deny`: g.renner has Home Assistant access
# but no enrolled WebAuthn device (live check 2026-07-07), so
# a hard deny would lock her out with no self-service path.
# `configure` injects the default WebAuthn enrollment stage
# inline — first login without a passkey becomes "enroll one
# now, then validate it". Still passkey-only: there is no way
# through this stage without a WebAuthn credential.
not_configured_action: configure
configuration_stages:
- !Find [authentik_stages_authenticator_webauthn.authenticatorwebauthnstage, [name, default-authenticator-webauthn-setup]]
# Skip re-validation if a passkey was already presented within
# the window. Non-zero for two reasons: (1) no double-touch
# when the login itself used a passkey seconds earlier;
# (2) NetBird's dashboard silently re-authorizes via
# /silent-auth (prompt=none) on token expiry — a flow that
# always demands interaction would fail those and force a
# visible re-login every access-token lifetime.
last_auth_threshold: hours=8
webauthn_user_verification: preferred
# Gate on "this account has a passkey", so enrollment status picks
# the branch. Enrolled accounts must use WebAuthn — at login and at
# every authorize — while accounts with no passkey fall back to
# password + MFA. An attacker cannot pick the weaker branch: the
# user is already identified when this runs, so the branch follows
# the victim's enrollment, not the requester's choice.
#
# request.user is the flow's pending_user, not the (anonymous)
# requester: ReevaluateMarker builds the engine with
# plan.context[PLAN_CONTEXT_PENDING_USER] (flows/markers.py). That
# requires the bindings below to keep evaluate_on_plan false and
# re_evaluate_policies true — on-plan evaluation happens before the
# identification stage has resolved a user.
- id: policy-has-passkey
model: authentik_policies_expression.expressionpolicy
identifiers:
name: passkey-user-has-webauthn
state: present
attrs:
expression: |
return ak_user_has_authenticator(request.user, "webauthn")
- id: binding-stepup-webauthn
model: authentik_flows.flowstagebinding
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf passkey-stepup-flow
stage: !KeyOf passkey-webauthn-validation
stage: !KeyOf passkey-stepup-webauthn-validation
order: 10
state: present
attrs:
evaluate_on_plan: false
re_evaluate_policies: true
- model: authentik_policies.policybinding
identifiers:
policy: !KeyOf policy-has-passkey
target: !KeyOf binding-stepup-webauthn
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# Passwordless authentication flow: identification (username or
# email, NO password stage) → WebAuthn validation → login, with a
# password + MFA branch for accounts that have no passkey.
# email, NO password stage) → WebAuthn validation → login.
- id: passkey-passwordless-flow
model: authentik_flows.flow
identifiers:
@@ -1579,6 +1403,37 @@ data:
title: "Sign in with your passkey"
designation: authentication
# SEPARATE stage from the step-up one, and deliberately `deny`:
# at this point in an authentication flow the user has only been
# *identified* (typed a username), not authenticated — `configure`
# here would let anyone who knows a passkey-less username enroll
# their own passkey and take over that account. Inline enrollment
# for passkey-less users happens exclusively in the authorization
# step-up stage above, where require_authenticated guarantees a
# fully authenticated session. A passkey-less user therefore
# bootstraps via password login on auth.renner.uno, then gets the
# enrollment prompt on first app access.
#
# Declared BEFORE the identification stage because the latter
# references it via !KeyOf (webauthn_stage), and the blueprint
# importer resolves entries top-to-bottom.
- id: passkey-passwordless-webauthn-validation
model: authentik_stages_authenticator_validate.authenticatorvalidatestage
identifiers:
name: passkey-passwordless-webauthn-validation
state: present
attrs:
device_classes:
- webauthn
not_configured_action: deny
# Small non-zero window so a passkey presented via the
# identification stage's autofill (webauthn_stage below) is
# not challenged a second time by this stage at order 20 in
# the same flow run. The skip is session-scoped, so a fresh
# (attacker) session never inherits it.
last_auth_threshold: minutes=5
webauthn_user_verification: preferred
# webauthn_stage enables passkey autofill (conditional WebAuthn,
# authentik >= 2025.12): the browser offers enrolled passkeys in
# the username field's autofill — one tap, no username typed.
@@ -1594,7 +1449,7 @@ data:
user_fields:
- username
- email
webauthn_stage: !KeyOf passkey-webauthn-validation
webauthn_stage: !KeyOf passkey-passwordless-webauthn-validation
- model: authentik_flows.flowstagebinding
identifiers:
@@ -1603,87 +1458,12 @@ data:
order: 10
state: present
# Order 20 and 21/22 are the two mutually exclusive branches.
- id: binding-passwordless-webauthn
model: authentik_flows.flowstagebinding
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf passkey-passwordless-flow
stage: !KeyOf passkey-webauthn-validation
stage: !KeyOf passkey-passwordless-webauthn-validation
order: 20
state: present
attrs:
evaluate_on_plan: false
re_evaluate_policies: true
- model: authentik_policies.policybinding
identifiers:
policy: !KeyOf policy-has-passkey
target: !KeyOf binding-passwordless-webauthn
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# The negated branch. failure_result: true is the safety net — the
# UserLoginStage defaults a missing authentication backend to
# BACKEND_INBUILT instead of refusing (user_login/stage.py), so a
# flow that drops BOTH branches would log the user in on a typed
# username alone. A PolicyException is resolved to failure_result
# before negate is applied (policies/process.py), so a broken
# expression pulls the password stage IN and degrades everyone to
# password + MFA rather than to no factor at all.
- id: binding-passwordless-password
model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf passkey-passwordless-flow
stage: !Find [authentik_stages_password.passwordstage, [name, default-authentication-password]]
order: 21
state: present
attrs:
evaluate_on_plan: false
re_evaluate_policies: true
- model: authentik_policies.policybinding
identifiers:
policy: !KeyOf policy-has-passkey
target: !KeyOf binding-passwordless-password
order: 0
state: present
attrs:
enabled: true
negate: true
failure_result: true
# Same MFA validation the default authentication flow runs at its
# order 30, so the fallback is not a weaker way into these apps
# than auth.renner.uno's own login already is. It carries
# not_configured_action: configure, which is safe here and nowhere
# else in this flow: the password stage above has established a
# first factor by the time it runs, so it doubles as the enrollment
# path for accounts holding no authenticator at all.
- id: binding-passwordless-mfa
model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf passkey-passwordless-flow
stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, default-authentication-mfa-validation]]
order: 22
state: present
attrs:
evaluate_on_plan: false
re_evaluate_policies: true
- model: authentik_policies.policybinding
identifiers:
policy: !KeyOf policy-has-passkey
target: !KeyOf binding-passwordless-mfa
order: 0
state: present
attrs:
enabled: true
negate: true
failure_result: true
- model: authentik_flows.flowstagebinding
identifiers:
@@ -1797,19 +1577,3 @@ data:
redirect_uris:
- url: https://dash.renner.uno/login/generic_oauth
matching_mode: strict
# Superseded by the single passkey-webauthn-validation stage above.
# Their flowstagebindings are FK-CASCADEd away with them; the
# identification stage's webauthn_stage FK is SET_NULL and is
# re-pointed above, so no dangling reference survives. Declared
# last: the importer applies entries top-to-bottom inside one
# transaction, so the replacements exist before these go.
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
identifiers:
name: passkey-stepup-webauthn-validation
state: absent
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
identifiers:
name: passkey-passwordless-webauthn-validation
state: absent
@@ -14,7 +14,6 @@ resources:
- zone-configurator-gate-egress.yaml
- alertmanager-gate-egress.yaml
- prometheus-gate-egress.yaml
- wordpress-gate-egress.yaml
- outpost-servicemonitors.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
@@ -1,28 +0,0 @@
---
# Allow the dedicated wordpress-gate outpost to egress into the wordpress
# namespace and reach WordPress (:80). Without this, the authentik-namespace
# baseline CNP has no egress rule to wordpress, so the gate outpost's
# forwarded requests are dropped. Scoped to the gate outpost's pod label so no
# other authentik component can reach it. Mirrors esphome-gate-egress.yaml.
#
# Note this gate only ever sees /wp-admin and /wp-login.php — the public site
# is routed straight to the same Service by a separate Ingress, so this rule
# is not the only path into the wordpress namespace, unlike the paperless case.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: wordpress-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: wordpress-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": wordpress
app.kubernetes.io/name: wordpress
toPorts:
- ports:
- port: "80"
protocol: TCP
@@ -38,7 +38,7 @@ spec:
# gets the heavy HA pod (8Gi) off the soon-to-be-tiny edge box.
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.8.2 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.8.1 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
+1 -27
View File
@@ -17,33 +17,7 @@ spec:
spec:
containers:
- name: ical-proxy
# Pinned to a commit build rather than :latest.
#
# :latest never rolls. The tag moves when a build publishes, but
# nothing tells the Deployment about it: the pod template is
# unchanged, so Kubernetes has no reason to replace anything, and
# this image is deliberately NOT enrolled in Flux image automation
# — see kubernetes/infrastructure/image-automation/README.md, which
# lists ghcr.io/konairius/ical-proxy as having "only main, latest,
# and main-<sha> commit builds; no release tags", so there is no
# ImagePolicy watching it. The pods therefore sat on a 21-day-old
# image while :latest moved underneath them, and the only way to
# pick up a new build was a manual restart.
#
# A pinned tag makes the rollout a git change: Flux applies the new
# pod template and Kubernetes rolls it, which is also what makes
# what is RUNNING recoverable from this repo rather than "whatever
# :latest meant the last time a pod happened to restart".
#
# main-99a912b is the merge of konairius/ical-proxy#12, which adds
# the summary_match/summary_replace parameters this deployment
# needs to strip "| Abfuhrkalender - Landkreis Amberg-Sulzbach"
# from every waste collection.
#
# Bumping this is a manual, reviewed edit. The tag format is
# main-<short-sha> (docker/metadata-action type=sha with the branch
# as prefix).
image: ghcr.io/konairius/ical-proxy:main-99a912b
image: ghcr.io/konairius/ical-proxy:latest
ports:
- containerPort: 8080
name: http
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
containers:
- name: gotenberg
image: gotenberg/gotenberg:8.36.0 # {"$imagepolicy": "flux-system:gotenberg"}
image: gotenberg/gotenberg:8.35.0 # {"$imagepolicy": "flux-system:gotenberg"}
args:
- "gotenberg"
- "--chromium-disable-javascript=true"
+5
View File
@@ -29,6 +29,11 @@ data:
# The app re-reads schedules.json every 60 s, so edits here take
# effect without a restart once the init container has rewritten it.
#
# timestamp stays false: the dashboard carries a clock CARD, which
# the browser draws at capture time — so the time in the image is
# already the time the screenshot was taken, and the renderer's own
# overlay would just print it twice.
#
# lang is "de", not null. It calls Home Assistant's own
# _selectLanguage() for the screenshot session, so the rendered page
# is localised by HA rather than by hand. With it left null the
+1 -1
View File
@@ -18,7 +18,7 @@ spec:
spec:
containers:
- name: whisper
image: rhasspy/wyoming-whisper:3.6.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
image: rhasspy/wyoming-whisper:3.5.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
args:
- --model=base-int8
- --language=de
-113
View File
@@ -1,113 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: wordpress
namespace: wordpress
annotations:
secret.reloader.stakater.com/reload: wordpress-secrets
spec:
replicas: 1
strategy:
# RWO volume, single writer — the rolling default would deadlock on the
# new pod waiting for a volume the old pod still holds.
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: wordpress
template:
metadata:
labels:
app.kubernetes.io/name: wordpress
spec:
securityContext:
# wp-content must be writable by Apache (www-data, uid/gid 33) for
# media uploads and plugin installs.
fsGroup: 33
containers:
- name: wordpress
image: wordpress:7.0.4-php8.3-apache
ports:
- name: http
containerPort: 80
env:
# The operator names the base Service after the MariaDB resource;
# the CRD's separate primaryService/secondaryService fields are the
# suffixed variants for replicated setups, which this is not
# (replicas: 1). Not verified against a running cluster — if it is
# wrong the pod fails loudly on connect at startup rather than
# degrading quietly. Confirm with `kubectl get svc -n wordpress`
# on first deploy.
- name: WORDPRESS_DB_HOST
value: wordpress-mariadb.wordpress.svc.cluster.local:3306
- name: WORDPRESS_DB_NAME
value: wordpress
- name: WORDPRESS_DB_USER
value: wordpress
- name: WORDPRESS_DB_PASSWORD
valueFrom:
secretKeyRef:
# Generated by the operator from the MariaDB CR — never
# stored in Git or OpenBao.
name: wordpress-mariadb-app
key: password
- name: WORDPRESS_CONFIG_EXTRA
value: |
define('WP_HOME', 'https://www.renner.uno');
define('WP_SITEURL', 'https://www.renner.uno');
define('FORCE_SSL_ADMIN', true);
/* Traefik terminates TLS; without this WordPress sees plain
HTTP and builds http:// URLs into every page and redirect. */
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
/* Core lives in the image, not on the volume — the updater
would only fail against a read-only docroot. Plugin and
theme installs are unaffected. */
define('WP_AUTO_UPDATE_CORE', false);
/* xmlrpc.php is blocked at the ingress; this closes the same
door from the inside. Nothing here uses it: no Jetpack,
no mobile app, no pingbacks worth keeping. */
add_filter('xmlrpc_enabled', '__return_false');
envFrom:
# The eight WordPress salts. Rotating them in OpenBao invalidates
# every session — which is the intended lever, not a side effect.
- secretRef:
name: wordpress-secrets
volumeMounts:
- name: content
mountPath: /var/www/html/wp-content
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 60
periodSeconds: 30
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
memory: 768Mi
volumes:
- name: content
persistentVolumeClaim:
claimName: wordpress-content
---
apiVersion: v1
kind: Service
metadata:
name: wordpress
namespace: wordpress
spec:
selector:
app.kubernetes.io/name: wordpress
ports:
- name: http
port: 80
targetPort: http
@@ -1,46 +0,0 @@
# The eight WordPress salts (AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY,
# NONCE_KEY, AUTH_SALT, SECURE_AUTH_SALT, LOGGED_IN_SALT, NONCE_SALT).
# Expected keys in OpenBao at homelab/apps/wordpress, named exactly as the
# image's env vars: WORDPRESS_AUTH_KEY, WORDPRESS_SECURE_AUTH_KEY, ...
#
# Deliberately NOT carried over from the old wp-config.php: the Hetzner host
# is being decommissioned and its salts have been sitting on a VM whose
# neighbour site was compromised this month. New salts cost one logout.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: wordpress-secrets
namespace: wordpress
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: wordpress-secrets
dataFrom:
- extract:
key: apps/wordpress
---
# S3 credentials for the MariaDB Backup.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: wordpress
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
# Reuses the paperless copy of the shared Hetzner S3 credentials (identical
# ACCESS_KEY_ID / ACCESS_SECRET_KEY / REGION) to avoid seeding a new OpenBao
# path for this migration — same approach woodpecker took. FOLLOW-UP: give
# wordpress its own seed/wordpress/hetzner-creds path once OpenBao can be
# written, and repoint this here (the value is the same, so it's a drop-in
# swap).
- extract:
key: seed/paperless/hetzner-creds
@@ -1,15 +0,0 @@
# A plain Ingress cannot point at a Service in another namespace, and the
# Authentik outposts all live in `authentik`. Same workaround as
# zone-configurator and paperless: mirror the outpost Service in as an
# ExternalName.
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-wordpress-gate
namespace: wordpress
spec:
type: ExternalName
externalName: ak-outpost-wordpress-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
-78
View File
@@ -1,78 +0,0 @@
# Two routers on one host. The site itself must stay readable by anyone --
# it is a family blog -- while the editing surface sits behind Authentik.
#
# Why not one gated Ingress with skip_path_regex, the way paperless-gate
# carves out /static/: that works when the PUBLIC paths are enumerable. Here
# it is the reverse -- every post URL is public and only two paths are gated
# -- which would need a negative lookahead. The outpost is Go, and RE2 has no
# lookahead. So the split happens at the router instead.
#
# Traefik orders routers by rule length, so PathPrefix(`/wp-admin`) is
# evaluated before PathPrefix(`/`); no explicit priority is needed.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wordpress
namespace: wordpress
labels:
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
rules:
- host: www.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
tls:
- secretName: wordpress-tls
hosts:
- www.renner.uno
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wordpress-admin
namespace: wordpress
labels:
# Same label as the public Ingress on purpose. external-dns-unifi runs
# with --label-filter=dns.renner.uno/public!=true, so an unlabelled
# Ingress on this host would quietly gain a UDM A-record pointing LAN
# clients straight at the Traefik VIP -- a second, unintended path to
# the same host that skips the public route entirely.
dns.renner.uno/public: "true"
# No cert-manager annotation and no tls block: the public Ingress above
# already owns the certificate for this host. Declaring it twice would
# create two Certificates racing for the same wordpress-tls Secret.
spec:
ingressClassName: traefik
rules:
- host: www.renner.uno
http:
paths:
# Covers admin-ajax.php too. That is only safe because the gift
# registry plugin -- the sole thing on this site that ever emitted
# an ajaxurl -- is being dropped in the migration. Verified against
# the live site: 121 admin-ajax hits in the current log window, not
# one of them a 200.
- path: /wp-admin
pathType: Prefix
backend:
service:
name: ak-outpost-wordpress-gate
port:
number: 9000
- path: /wp-login.php
pathType: Exact
backend:
service:
name: ak-outpost-wordpress-gate
port:
number: 9000
@@ -1,9 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- mariadb.yaml
- pvc.yaml
- deployment.yaml
- gate-service.yaml
- ingress.yaml
-86
View File
@@ -1,86 +0,0 @@
# The database for www.renner.uno. Named `wordpress-mariadb`, not `wp-*`:
# `wp-pg` in the woodpecker namespace is Woodpecker's Postgres, and reusing
# the prefix for an unrelated engine invites exactly the wrong guess.
#
# Mirrors the CNPG convention: the operator lives in `database`, the database
# resource lives with its consumer (ak-pg in authentik, im-pg in immich).
apiVersion: k8s.mariadb.com/v1alpha1
kind: MariaDB
metadata:
name: wordpress-mariadb
namespace: wordpress
annotations:
# Data guard — a Kustomization rebuild must never take the database with it.
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
# generate: true lets the operator create both Secrets on first reconcile.
# Neither password is ever typed by a human or parked in OpenBao, so there
# is nothing to rotate by hand and nothing to leak through Git.
rootPasswordSecretKeyRef:
name: wordpress-mariadb-root
key: password
generate: true
username: wordpress
passwordSecretKeyRef:
name: wordpress-mariadb-app
key: password
generate: true
database: wordpress
port: 3306
replicas: 1
storage:
size: 5Gi
storageClassName: ceph-block
# The source database is 527 KB. 5Gi is not a sizing estimate, it is the
# smallest allocation that leaves room for binlogs and a decade of posts.
myCnf: |
[mariadb]
bind-address=*
default_storage_engine=InnoDB
innodb_buffer_pool_size=256M
# WordPress writes utf8mb4 throughout; the source dump carries
# utf8mb4_unicode_520_ci on every table that survives the migration.
character_set_server=utf8mb4
collation_server=utf8mb4_unicode_520_ci
max_allowed_packet=64M
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 1Gi
---
# Logical backup to Hetzner Object Storage. Velero only captures PVC data,
# which for a running database is crash-consistent, not transaction-consistent
# — that is a snapshot, not a backup you would want to restore from.
apiVersion: k8s.mariadb.com/v1alpha1
kind: Backup
metadata:
name: wordpress-mariadb-backup
namespace: wordpress
spec:
mariaDbRef:
name: wordpress-mariadb
schedule:
# 02:45, ahead of the Velero window that starts at 03:15.
cron: "45 2 * * *"
suspend: false
maxRetention: 720h # 30d, matching the CNPG ObjectStore retentionPolicy
compression: gzip
storage:
s3:
bucket: renner-uno-k8s-mariadb
prefix: wordpress
endpoint: fsn1.your-objectstorage.com
region: fsn1
accessKeyIdSecretKeyRef:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKeySecretKeyRef:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
tls:
enabled: true
-35
View File
@@ -1,35 +0,0 @@
# Only wp-content is persisted, not the whole docroot. WordPress core comes
# from the pinned image, so upgrading the site means bumping a tag in Git
# rather than clicking "update" in wp-admin — which is the entire point of
# moving off the Hetzner VM, where core was a mutable pile of PHP on disk.
#
# Consequence to be aware of: WordPress's own core updater will fail against
# a read-only core. Plugin and theme installs still work, because those land
# in wp-content.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wordpress-content
namespace: wordpress
labels:
# The Velero Schedule selects ON this label rather than excluding the
# database PVC by one the mariadb-operator is merely assumed to set. If
# that assumption were wrong the exclusion would fail silently and Velero
# would snapshot the live database volume; selecting a label this repo
# owns cannot fail that way. Same direction as openbao-daily, which picks
# its snapshot PVC by app.kubernetes.io/component.
app.kubernetes.io/name: wordpress
app.kubernetes.io/component: content
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
# RWO is sufficient: a single replica with strategy Recreate. ceph-block is
# the cluster default; named explicitly so a default change can't silently
# move the media library onto another class.
storageClassName: ceph-block
resources:
requests:
# The migrated wp-content is 29 MB compressed, 13 MB of it uploads.
storage: 10Gi
@@ -48,10 +48,6 @@ spec:
kind: HelmRelease
name: cloudnative-pg # DB operator — seed jobs create databases
namespace: database
- apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
name: mariadb-operator # the MySQL half — WordPress can't use CNPG
namespace: database
# decryption block dropped 2026-07-13: no *.sops.yaml left under this
# path — secrets come from OpenBao via ESO (MIGRATION-sops-openbao.md).
---
@@ -1,29 +0,0 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: wordpress
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 15m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/wordpress
prune: true
dependsOn:
# infrastructure gates on the mariadb-operator HelmRelease being Ready,
# so the MariaDB CRD exists and has a controller before this applies.
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
# kstatus has no built-in rule for k8s.mariadb.com resources, so without
# this the Kustomization would report Ready the moment the manifests are
# accepted — before the database is actually serving. Same reasoning as
# the CephCluster expressions in infrastructure.yaml.
healthCheckExprs:
- apiVersion: k8s.mariadb.com/v1alpha1
kind: MariaDB
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && c.reason == 'Failed')"
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
@@ -15,7 +15,6 @@ resources:
- rook-ceph/
- ceph-csi-drivers/
- cloudnative-pg/
- mariadb-operator/
- netbird-ingress/
- netbird-backup/
- coredns-config/
@@ -1,81 +0,0 @@
# MariaDB operator, for the one workload in this cluster that cannot use
# CloudNativePG: WordPress. WordPress speaks MySQL natively — the PostgreSQL
# route would mean pg4wp, an SQL-rewriting shim whose own README lists
# WordPress 6.5.3 as its newest tested version while renner.uno runs 7.0.4.
#
# The charts are OCI artifacts (ghcr.io), so this follows the OCIRepository
# pattern from infrastructure/spegel rather than the HelmRepository pattern
# used for CNPG.
#
# CRDs ship as a SEPARATE chart that must be installed first — upstream is
# explicit about the ordering, hence the dependsOn below rather than a single
# release with crds: CreateReplace.
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: mariadb-operator-crds
namespace: database
spec:
interval: 24h
url: oci://ghcr.io/mariadb-operator/charts/mariadb-operator-crds
ref:
# Calendar versioning: the major is the year. Upstream ships an upgrade
# guide with each minor (Helm repo deprecations, registry moves), so the
# range is capped at the year boundary instead of the usual open ">=".
semver: ">=26.6.0 <27.0.0"
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: mariadb-operator-crds
namespace: database
spec:
interval: 30m
chartRef:
kind: OCIRepository
name: mariadb-operator-crds
namespace: database
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: mariadb-operator
namespace: database
spec:
interval: 24h
url: oci://ghcr.io/mariadb-operator/charts/mariadb-operator
ref:
semver: ">=26.6.0 <27.0.0"
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: mariadb-operator
namespace: database
spec:
interval: 30m
dependsOn:
- name: mariadb-operator-crds
namespace: database
chartRef:
kind: OCIRepository
name: mariadb-operator
namespace: database
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
# The operator watches all namespaces; the MariaDB CR itself lives with
# the app in kubernetes/apps/wordpress/, mirroring how CNPG Clusters sit
# in their consumer's namespace (ak-pg in authentik, im-pg in immich).
metrics:
enabled: false
@@ -1,4 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- helmrelease.yaml
@@ -151,12 +151,3 @@ apiVersion: v1
kind: Namespace
metadata:
name: trmnl
---
# WordPress for www.renner.uno, migrated off the Hetzner VM `wordpress-hochzeit`.
# No privileged label needed: the cluster enforces `baseline`, which permits the
# official Apache image's root master process binding :80. It does trip the
# `restricted` audit/warn level — that is expected and not an error.
apiVersion: v1
kind: Namespace
metadata:
name: wordpress
@@ -22,5 +22,4 @@ resources:
- external-secrets-ns.yaml
- woodpecker-ci-ns.yaml
- trmnl-ns.yaml
- wordpress-ns.yaml
- allow-cilium-health.yaml
@@ -1,167 +0,0 @@
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny-ingress
namespace: wordpress
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": wordpress
# Unlike paperless, traefik IS allowed here. www.renner.uno is a public
# blog: the front end must be reachable without authentication, so the
# public router terminates directly in this namespace.
#
# Be clear about what this does and does not buy: the public/admin split
# is enforced by the two Ingress objects' path rules (`/` straight to the
# Service, /wp-admin and /wp-login.php to the gate outpost) and by
# Authentik — NOT at the network layer. paperless can point a
# workload-scoped CNP at its gate because nothing else may reach it;
# here traefik must reach the pod directly for every public page, so the
# equivalent rule would be a no-op. Anyone auditing this namespace for
# defence in depth should know the network layer is not where the admin
# surface is protected.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
# Scoped to the gate outpost rather than the whole authentik namespace:
# only that pod has business reaching wp-admin.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: wordpress-gate
# The mariadb-operator reconciles the MariaDB CR from the database
# namespace — same cross-namespace requirement CNPG consumers have.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
- fromEntities:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": wordpress
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": rook-ceph
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
# world covers two things WordPress genuinely needs outbound: the
# wordpress.org update/plugin API, and the Hetzner Object Storage
# endpoint the MariaDB Backup writes to.
- toEntities:
- host
- remote-node
- world
# ICMP — egress mirror.
- toEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 4, family: IPv4 }
- { type: 5, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 9, family: IPv4 }
- { type: 10, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 13, family: IPv4 }
- { type: 14, family: IPv4 }
- { type: 15, family: IPv4 }
- { type: 16, family: IPv4 }
- { type: 17, family: IPv4 }
- { type: 18, family: IPv4 }
- { type: 30, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 130, family: IPv6 }
- { type: 131, family: IPv6 }
- { type: 132, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 137, family: IPv6 }
- { type: 138, family: IPv6 }
- { type: 141, family: IPv6 }
- { type: 142, family: IPv6 }
- { type: 143, family: IPv6 }
- { type: 148, family: IPv6 }
- { type: 149, family: IPv6 }
- { type: 151, family: IPv6 }
- { type: 152, family: IPv6 }
- { type: 153, family: IPv6 }
@@ -30,7 +30,6 @@ spec:
- paperless
- trmnl
- velero
- wordpress
provider:
vault: # ESO's provider name; speaks OpenBao fine
server: http://openbao.openbao.svc.cluster.local:8200
@@ -312,38 +312,3 @@ spec:
matchExpressions:
- key: cnpg.io/cluster
operator: DoesNotExist
---
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: wordpress-daily
namespace: velero
labels:
app.kubernetes.io/part-of: homelab-backup
spec:
# Daily at 08:45 local (next free stagger slot after trmnl-daily at 08:15).
# Covers the wordpress-content PVC: the media library and any plugins or
# themes installed through wp-admin. WordPress core is not in here — it
# comes from the pinned image, so Git is its backup.
schedule: "45 8 * * *"
useOwnerReferencesInBackup: false
template:
includedNamespaces:
- wordpress
includedResources:
- persistentvolumeclaims
storageLocation: default
ttl: 720h0m0s
snapshotMoveData: true
# Selects the content PVC by a label this repo sets, rather than
# excluding the database PVC by one the mariadb-operator is assumed to
# set — an exclusion built on a wrong assumption fails silently and
# sweeps the live database volume in. A crash-consistent snapshot of a
# running database is not a restorable backup; the operator's own Backup
# resource takes a logical dump to Hetzner Object Storage at 02:45, the
# same division of labour as CNPG/Barman elsewhere in this file.
# openbao-daily picks its PVC the same way.
labelSelector:
matchLabels:
app.kubernetes.io/name: wordpress
app.kubernetes.io/component: content
+2 -4
View File
@@ -42,7 +42,6 @@ locals {
"renner-uno-k8s-velero", # Velero PVC backups (Kopia data mover)
"renner-uno-k8s-secrets", # SOPS talosconfig/kubeconfig for cluster bootstrap
"renner-uno-tfstate", # OpenTofu remote state (migrated off the nas-1 Garage S3)
"renner-uno-k8s-mariadb", # mariadb-operator logical dumps (wordpress-mariadb)
]
}
@@ -96,9 +95,8 @@ locals {
# precedent in this repo — left uncapped (keep every version) since
# storage cost there is negligible.
hetzner_buckets_noncurrent_expiry_days = {
"renner-uno-k8s-cnpg" = 30
"renner-uno-k8s-velero" = 30
"renner-uno-k8s-mariadb" = 30 # Backup maxRetention: 720h (kubernetes/apps/wordpress/mariadb.yaml)
"renner-uno-k8s-cnpg" = 30
"renner-uno-k8s-velero" = 30
}
}