Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
803cdb3db8 | ||
|
|
9e0ffae358 | ||
|
|
82573754b1 | ||
|
|
ba6ca6d0af | ||
|
|
9e8c1fa6fe | ||
|
|
7a80df3cfc | ||
|
|
a2de5b5f56 | ||
|
|
8573cdf220 | ||
|
|
507e86cb83 | ||
|
|
b2ed2beae4 | ||
|
|
f8c968c5a7 | ||
|
|
595e5eb7a3 | ||
|
|
7e293d3de7 | ||
|
|
9423381102 | ||
|
|
ae12ce760c | ||
|
|
641e8bce84 | ||
|
|
463e493ec0 | ||
|
|
8031a0ad7a | ||
|
|
18c8b74baa | ||
|
|
e84ea02100 | ||
|
|
fc53334b31 |
@@ -1326,7 +1326,14 @@ data:
|
||||
name: Caddy docs.renner.uno
|
||||
state: absent
|
||||
|
||||
# Passkey-only (WebAuthn) login for selected apps, in two parts:
|
||||
# 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:
|
||||
#
|
||||
# 1. A passwordless AUTHENTICATION flow (username → passkey, no
|
||||
# password prompt) bound per-provider, so fresh logins to these
|
||||
@@ -1335,7 +1342,13 @@ 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.
|
||||
# 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.
|
||||
passkey-stepup.yaml: |
|
||||
version: 1
|
||||
metadata:
|
||||
@@ -1356,43 +1369,99 @@ data:
|
||||
# flow this replaces.
|
||||
authentication: require_authenticated
|
||||
|
||||
- id: passkey-stepup-webauthn-validation
|
||||
# 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
|
||||
model: authentik_stages_authenticator_validate.authenticatorvalidatestage
|
||||
identifiers:
|
||||
name: passkey-stepup-webauthn-validation
|
||||
name: passkey-webauthn-validation
|
||||
state: present
|
||||
attrs:
|
||||
device_classes:
|
||||
- webauthn
|
||||
# `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.
|
||||
# `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.
|
||||
last_auth_threshold: hours=8
|
||||
webauthn_user_verification: preferred
|
||||
|
||||
- model: authentik_flows.flowstagebinding
|
||||
# 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
|
||||
identifiers:
|
||||
target: !KeyOf passkey-stepup-flow
|
||||
stage: !KeyOf passkey-stepup-webauthn-validation
|
||||
stage: !KeyOf passkey-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.
|
||||
# email, NO password stage) → WebAuthn validation → login, with a
|
||||
# password + MFA branch for accounts that have no passkey.
|
||||
- id: passkey-passwordless-flow
|
||||
model: authentik_flows.flow
|
||||
identifiers:
|
||||
@@ -1403,37 +1472,6 @@ 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.
|
||||
@@ -1449,7 +1487,7 @@ data:
|
||||
user_fields:
|
||||
- username
|
||||
- email
|
||||
webauthn_stage: !KeyOf passkey-passwordless-webauthn-validation
|
||||
webauthn_stage: !KeyOf passkey-webauthn-validation
|
||||
|
||||
- model: authentik_flows.flowstagebinding
|
||||
identifiers:
|
||||
@@ -1458,12 +1496,87 @@ data:
|
||||
order: 10
|
||||
state: present
|
||||
|
||||
- model: authentik_flows.flowstagebinding
|
||||
# Order 20 and 21/22 are the two mutually exclusive branches.
|
||||
- id: binding-passwordless-webauthn
|
||||
model: authentik_flows.flowstagebinding
|
||||
identifiers:
|
||||
target: !KeyOf passkey-passwordless-flow
|
||||
stage: !KeyOf passkey-passwordless-webauthn-validation
|
||||
stage: !KeyOf passkey-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:
|
||||
@@ -1577,3 +1690,19 @@ 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
|
||||
|
||||
@@ -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.1 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.8.2 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -17,7 +17,33 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: ical-proxy
|
||||
image: ghcr.io/konairius/ical-proxy:latest
|
||||
# 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
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gotenberg
|
||||
image: gotenberg/gotenberg:8.35.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
image: gotenberg/gotenberg:8.36.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
args:
|
||||
- "gotenberg"
|
||||
- "--chromium-disable-javascript=true"
|
||||
|
||||
@@ -29,6 +29,14 @@ 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.
|
||||
#
|
||||
# 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
|
||||
# dashboard grew a German weekday array, a German month array and a
|
||||
# fifteen-entry weather-condition dict — reimplementing translations
|
||||
# HA already ships. The instance itself stays language "en"; this
|
||||
# only affects the render session.
|
||||
#
|
||||
# model_id is 1 — TRMNL X (name "v2", 1872x1404, 16 colours) — and it
|
||||
# is an INTEGER, not a name. Confirmed against the running server:
|
||||
# GET /api/screens returns model_id as a number, and upstream's
|
||||
@@ -60,8 +68,8 @@ data:
|
||||
"rotate": null,
|
||||
"zoom": 1,
|
||||
"wait": null,
|
||||
"theme": "trmnl-eink",
|
||||
"lang": null,
|
||||
"theme": "Graphite E-ink Light",
|
||||
"lang": "de",
|
||||
"dark": false,
|
||||
"invert": false,
|
||||
"timestamp": false,
|
||||
@@ -107,6 +115,27 @@ metadata:
|
||||
namespace: trmnl
|
||||
labels:
|
||||
app.kubernetes.io/name: trmnl-renderer
|
||||
annotations:
|
||||
# BOTH sources need this, and for the same reason: the init
|
||||
# container writes /data/schedules.json ONCE, at pod start. Nothing
|
||||
# re-reads either source afterwards.
|
||||
#
|
||||
# The app re-reads schedules.json every 60s, which makes it look
|
||||
# self-updating — but only the FILE, and the file is only rewritten
|
||||
# by the init container. So a ConfigMap or Secret change lands in
|
||||
# git, updates the object, and the running renderer keeps the old
|
||||
# values indefinitely.
|
||||
#
|
||||
# Both halves were hit repeatedly in practice and each time needed
|
||||
# `kubectl delete pod` by hand: the theme name, the schedule's lang
|
||||
# field, and every re-mint of the BYOS tokens. A fix that depends on
|
||||
# someone remembering to restart a pod is not deployed.
|
||||
#
|
||||
# Named per-resource rather than the blanket
|
||||
# reloader.stakater.com/auto: "true", matching terminus.yaml and
|
||||
# mosquitto-deployment.yaml.
|
||||
configmap.reloader.stakater.com/reload: trmnl-renderer-schedule
|
||||
secret.reloader.stakater.com/reload: trmnl-renderer-secrets
|
||||
spec:
|
||||
replicas: 1
|
||||
# RWO PVC, and two renderers would push conflicting screens for the
|
||||
@@ -142,9 +171,16 @@ spec:
|
||||
# proxy that exchanges a Terminus username+password for these
|
||||
# tokens, but that would mean shipping the Terminus PASSWORD
|
||||
# into the cluster and doing a network call at every pod start.
|
||||
# Stock Terminus disables session expiry and issues effectively
|
||||
# permanent tokens, so they are minted once out of band and kept
|
||||
# in OpenBao — the password never enters the cluster.
|
||||
# Tokens are therefore minted once out of band and kept in
|
||||
# OpenBao — the password never enters the cluster.
|
||||
#
|
||||
# That only works because Terminus is configured with
|
||||
# API_ACCESS_TOKEN_PERIOD = 10 years (see terminus.yaml). It is
|
||||
# NOT, as an earlier version of this comment claimed, because
|
||||
# stock Terminus issues permanent tokens — the stock default is
|
||||
# 1800s, and this deployment demonstrated it by having the
|
||||
# webhook start failing with "expired JWT access token" about
|
||||
# thirty minutes after seeding.
|
||||
- name: render-schedule
|
||||
image: gitea.renner.uno/gitea_admin/alpine-jq:2026-07-30
|
||||
command:
|
||||
@@ -154,13 +190,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
|
||||
|
||||
@@ -18,6 +18,64 @@ 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"
|
||||
# The OTHER half of the same problem, and the reason the first fix
|
||||
# did not hold. Terminus has TWO independent expiries: the JWT's own
|
||||
# lifetime (above) and the SESSION behind it. Raising only the token
|
||||
# period left the session at its defaults, so pushes kept working for
|
||||
# a while and then failed with
|
||||
# HTTP 401 {"error":"This session has expired, please login again"}
|
||||
# which is a different error, from a different mechanism, than the
|
||||
# "expired JWT access token" the first fix addressed.
|
||||
#
|
||||
# Documented in doc/configuration.adoc:
|
||||
# SESSION_EXPIRATION_ENABLED — "Determines if your browser session
|
||||
# or JWT API token is expired or not." Default: true.
|
||||
# SESSION_INACTIVITY_LIMIT 1800s, SESSION_LIFETIME_LIMIT 86400s.
|
||||
#
|
||||
# Disabling it is exactly the state the renderer's own code assumes
|
||||
# ("stock Terminus disables session expiration and issues ~100-year
|
||||
# tokens") — an assumption that is false by default and has to be
|
||||
# made true here, same as the token period.
|
||||
#
|
||||
# Same trade as above, and it is the deciding one for an unattended
|
||||
# appliance: a session that expires needs a human with a password to
|
||||
# restore it, and this panel has neither.
|
||||
SESSION_EXPIRATION_ENABLED: "false"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -26,6 +84,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
|
||||
|
||||
@@ -18,7 +18,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: whisper
|
||||
image: rhasspy/wyoming-whisper:3.5.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
|
||||
image: rhasspy/wyoming-whisper:3.6.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
|
||||
args:
|
||||
- --model=base-int8
|
||||
- --language=de
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user