fix(trmnl): make Terminus issue long-lived API tokens #485

Merged
k.renner merged 4 commits from fix-trmnl-token-lifetime into master 2026-08-12 17:34:47 +00:00
3 changed files with 79 additions and 6 deletions
+11 -6
View File
@@ -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
+49
View File
@@ -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
@@ -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.