diff --git a/kubernetes/apps/trmnl/renderer.yaml b/kubernetes/apps/trmnl/renderer.yaml index 0ee7b019..88f47ea4 100644 --- a/kubernetes/apps/trmnl/renderer.yaml +++ b/kubernetes/apps/trmnl/renderer.yaml @@ -154,13 +154,18 @@ spec: set -eu # obtained_at is stamped at write time, in MILLISECONDS. # It is not cosmetic: the scheduler derives token age from - # it, and a literal 0 in the template made the app treat - # freshly-minted tokens as ancient and refuse to push — + # it, and a literal 0 made the app treat freshly-minted + # tokens as ancient and skip the webhook entirely — # "BYOS tokens ... expired beyond the refresh window" - # — with no render attempt and no error, just a warning at - # startup. Stamping it here is honest enough: stock - # Terminus disables session expiry and issues effectively - # permanent tokens, so age is not load-bearing. + # + # Stamping it "now" is only safe because Terminus is + # configured with API_ACCESS_TOKEN_PERIOD = 10 years (see + # terminus.yaml). Against the 30-minute default it would be + # actively harmful: the app would believe a stale token had + # 30 fresh minutes left, skip its proactive refresh, and + # then be unable to recover, because refreshing requires a + # still-valid token. If that setting is ever reverted, this + # must become a real mint time carried alongside the tokens. jq --arg at "$BYOS_ACCESS_TOKEN" \ --arg rt "$BYOS_REFRESH_TOKEN" \ '(.[].webhook_format.byosConfig.auth.access_token) = $at diff --git a/kubernetes/apps/trmnl/terminus.yaml b/kubernetes/apps/trmnl/terminus.yaml index 339d7ee3..5cc5d3bc 100644 --- a/kubernetes/apps/trmnl/terminus.yaml +++ b/kubernetes/apps/trmnl/terminus.yaml @@ -18,6 +18,41 @@ data: # pool (fd99::/120) and the LAN ULA — without it every IPv6 client, # including the device and Traefik, is rate-limited as if it were remote. RACK_ATTACK_ALLOWED_SUBNETS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1,::1,fc00::/7" + # 10 years, against a default of 1800s (30 minutes). + # + # Documented upstream in doc/configuration.adoc: + # API_ACCESS_TOKEN_PERIOD — "The amount of time, in seconds, the JWT + # access token remains valid before needing to refresh your access + # token." Default: 1,800 seconds (30 minutes). + # Corroborated on this instance: a token minted here was rejected about + # thirty minutes later, i.e. the documented default behaving as written. + # + # This exists for the renderer, which pushes screens to /api/screens with + # a JWT held in OpenBao. At the default period that arrangement cannot + # work unattended, and it fails in a way that has no recovery: minting a + # replacement token via POST /api/jwt requires a still-VALID access token + # in the Authorization header, so once one expires it cannot be refreshed + # from itself. Observed exactly that on first deploy — + # [webhook] Webhook failed: 400 {"error":"expired JWT access token"} + # [webhook] BYOS auth: refresh failed (400) {"error":"expired JWT ..."} + # — the refresh attempt being rejected by the very expiry it was trying + # to repair. + # + # The renderer's own code assumes "stock Terminus disables session + # expiration and issues effectively permanent tokens". That is simply not + # true of this deployment, so the assumption is made true here rather + # than worked around in the client. + # + # The trade is deliberate: a long-lived JWT with full Terminus API rights + # now sits in OpenBao. Accepted because the alternative was putting the + # Terminus account PASSWORD into the cluster so an init container could + # mint a short-lived token on every pod start, which is a strictly worse + # secret to hold. The token is revocable by rotating the account's + # credentials; it grants access only to this LAN-only instance. + # + # NOTE: the period is fixed at mint time. Changing this does not extend + # tokens that already exist — they must be re-minted afterwards. + API_ACCESS_TOKEN_PERIOD: "315360000" --- apiVersion: apps/v1 kind: Deployment @@ -26,6 +61,20 @@ metadata: namespace: trmnl labels: app.kubernetes.io/name: terminus + annotations: + # terminus-config is consumed via envFrom, and env vars are read ONCE at + # container start — Kubernetes does not hot-reload them. The ConfigMap is + # a plain resource, not a configMapGenerator, so editing it does not + # change the pod template hash and Flux will not roll the pods either. + # Without this annotation a ConfigMap-only change (such as raising + # API_ACCESS_TOKEN_PERIOD) applies to git and to the ConfigMap object + # while the running app keeps the old value indefinitely — a silent + # no-op, with the manifest and reality disagreeing. + # + # Named per-ConfigMap rather than the blanket `reloader.stakater.com/ + # auto: "true"`, matching mosquitto-deployment.yaml: enrolling anything + # further stays a visible, reviewable edit to this line. + configmap.reloader.stakater.com/reload: terminus-config spec: replicas: 1 # RWO PVC shared by both containers in this pod, and APP_SETUP runs diff --git a/kubernetes/infrastructure/openbao/rotation/kustomization.yaml b/kubernetes/infrastructure/openbao/rotation/kustomization.yaml index dc26fe3f..5eaf696d 100644 --- a/kubernetes/infrastructure/openbao/rotation/kustomization.yaml +++ b/kubernetes/infrastructure/openbao/rotation/kustomization.yaml @@ -59,3 +59,22 @@ resources: - review-bot.yaml - claude-oauth.yaml - mosquitto-health.yaml +# NOT ROTATED, deliberately — recorded here because the whole point of this +# directory is that unrotated credentials should be a visible choice: +# +# homelab/apps/trmnl/renderer → BYOS_ACCESS_TOKEN / BYOS_REFRESH_TOKEN +# Terminus API JWTs used by the e-ink renderer to push screens. Terminus +# is configured with API_ACCESS_TOKEN_PERIOD = 10 years +# (kubernetes/apps/trmnl/terminus.yaml), so nothing will ever expire +# these or prompt a rotation. That is deliberate: the alternative was +# holding the Terminus account password in-cluster to mint short-lived +# tokens per pod start. There is no rotator because minting requires the +# account password, which is exactly what we declined to store. +# Rotate BY HAND whenever the Terminus account password changes, by +# re-running POST /login and re-seeding both keys. +# +# HA_ACCESS_TOKEN in the same path is a dedicated Home Assistant +# long-lived token. `ha-token` mode in secret-rotator already handles +# this class (see home-assistant.yaml) — enrolling it is a genuine +# follow-up, not a limitation, and would leave only the BYOS pair +# unrotated.