Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 aa1af65807 feat(trmnl): serve the device endpoint over TLS
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The cleartext LoadBalancer was justified with "the firmware cannot be
handed the internal CA". There is no internal CA in that path:
letsencrypt-cluster solves DNS-01 against the renner.uno Cloudflare zone,
so a LAN-only name gets a publicly-trusted certificate without ever being
publicly reachable — which is why trmnl.renner.uno has always served an
issuer CN=YR2 cert.

trmnl-device.renner.uno therefore moves onto Traefik with its own
Ingress, and API_URI follows it to https so the image_url handed back on
/api/display is fetched over the same scheme the device polls with. The
name moves rather than being duplicated: external-dns runs --policy=sync
and would flap the record between the Traefik VIP and the LB address if
both an Ingress and the Service claimed it.

The LoadBalancer stays, without a hostname. http://10.2.99.89 remains a
path to /api/setup that depends on neither Traefik nor cert renewal, and
the captive portal takes a bare address, so it is the recovery route if
TLS is ever what breaks the panel.

Note Traefik's web entrypoint carries a global http -> websecure
redirect, so http://trmnl-device.renner.uno now answers 308 rather than
serving. The device is provisioned with the https URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 17:20:46 +02:00
4 changed files with 87 additions and 15 deletions
+52
View File
@@ -0,0 +1,52 @@
# Device-facing endpoint, over TLS.
#
# This replaces the reason the cleartext LoadBalancer in device-service.yaml
# was given a hostname at all. That file claimed the firmware "would reject
# the internal CA" — but nothing here is signed by an internal CA:
# letsencrypt-cluster solves DNS-01 against the renner.uno Cloudflare zone,
# so trmnl.renner.uno already serves a public Let's Encrypt certificate
# (issuer CN=YR2) that any client with a normal CA bundle trusts. A
# LAN-only name gets the same treatment — DNS-01 never needs the host to be
# publicly reachable — so the device can speak HTTPS like everything else.
#
# The hostname moves here from the Service's external-dns annotation rather
# than being duplicated: two sources claiming trmnl-device.renner.uno would
# have external-dns flapping the record between the Traefik VIP and the
# device LB. The LoadBalancer keeps its address and stays reachable at
# http://10.2.99.89 as a nameless fallback if TLS ever turns out to be the
# thing that breaks.
#
# CONSEQUENCE, and the reason API_URI in terminus.yaml changes with this:
# Traefik's `web` entrypoint carries a global http -> websecure redirect
# (helmrelease.yaml: `to: websecure, scheme: https, permanent: true`), so
# once this name points at Traefik, http://trmnl-device.renner.uno answers
# 308 rather than serving. Whether the firmware follows that is not
# documented, so the device is provisioned with the https URL and Terminus
# hands back https image_urls to match. No mixed-scheme fetch.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: terminus-device
namespace: trmnl
# No external-dns hostname annotation: external-dns-unifi runs with
# --source=ingress, so it registers the hosts in spec.rules on its own.
# Same as the terminus Ingress next door.
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- trmnl-device.renner.uno
secretName: trmnl-device-tls
rules:
- host: trmnl-device.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: terminus
port:
number: 80
+21 -10
View File
@@ -1,15 +1,24 @@
# Device-facing endpoint. Cleartext HTTP, deliberately.
# Cleartext fallback for the device, reachable by address only.
#
# The TRMNL firmware initiates every request and cannot be handed the
# internal CA, so terminating TLS here would simply break polling. Scope of
# the exposure: this Service is LAN-only (no public DNS label, no Cloudflare
# record), it carries no admin session — the device authenticates with its
# MAC in the `ID` header plus a per-device api_key — and the Terminus admin
# UI stays on the TLS route at trmnl.renner.uno.
# This used to be THE device endpoint, on the premise that the firmware
# "cannot be handed the internal CA". The premise was wrong: nothing here
# hands out an internal CA for these names — letsencrypt-cluster solves
# DNS-01 against the renner.uno Cloudflare zone and issues publicly-trusted
# certificates for LAN-only hosts, which is why trmnl.renner.uno has always
# served an issuer CN=YR2 certificate. The device speaks HTTPS via
# device-ingress.yaml, which now owns the trmnl-device.renner.uno name.
#
# Kept, without a hostname, because it is the one path to Terminus that
# depends on neither Traefik nor certificate renewal: http://10.2.99.89
# still serves /api/setup and /api/display. If TLS is ever what breaks the
# panel, the recovery is to type that address into the captive portal.
#
# Scope of the exposure is unchanged: LAN-only (no public DNS label, no
# Cloudflare record), no admin session — the device authenticates with its
# MAC in the `ID` header plus a per-device api_key.
#
# Same shape as home-assistant-bgp: a dedicated address on the Cilium BGP
# pool rather than a TLS-less Ingress, which would be a new pattern for this
# repo. The device is provisioned with the HOSTNAME, not the address.
# pool.
#
# The address is requested explicitly, matching every other LB service here.
# Left to allocate freely, Cilium IPAM handed out the first free address in
@@ -30,8 +39,10 @@ metadata:
labels:
app.kubernetes.io/name: terminus
lb-pool: bgp
# No external-dns hostname: device-ingress.yaml claims that name. Two
# sources claiming trmnl-device.renner.uno would flap the record between
# the Traefik VIP and this address (external-dns runs --policy=sync).
annotations:
external-dns.alpha.kubernetes.io/hostname: trmnl-device.renner.uno
lbipam.cilium.io/ips: "10.2.99.89,fd99::89"
spec:
type: LoadBalancer
+3
View File
@@ -6,6 +6,9 @@ resources:
- valkey.yaml
- terminus.yaml
- ingress.yaml
# TLS device endpoint (owns trmnl-device.renner.uno) plus the nameless
# cleartext LoadBalancer it falls back to.
- device-ingress.yaml
- device-service.yaml
# Headless HA screenshotter. Lives in this namespace because Terminus
# FETCHES rendered screens back from it (uri delivery mode), so the
+11 -5
View File
@@ -7,11 +7,17 @@ data:
TZ: "Europe/Berlin"
HANAMI_PORT: "2345"
# What the device is told to fetch from. Terminus bakes this into the
# absolute `image_url` it hands back on /api/display, so it MUST be the
# cleartext LAN name, not trmnl.renner.uno — the firmware speaks plain
# HTTP and would reject the internal CA. No trailing slash: the same
# string is typed into the captive portal at provisioning time.
API_URI: "http://trmnl-device.renner.uno"
# absolute `image_url` it hands back on /api/display, so it has to be a
# name the DEVICE can resolve and fetch — and it has to carry the same
# scheme the device polls with, or every screen fetch is a second,
# cleartext connection. No trailing slash: the same string is typed into
# the captive portal at provisioning time.
#
# https since device-ingress.yaml took over trmnl-device.renner.uno. The
# earlier value was http on the theory that the firmware would reject an
# internal CA; there is no internal CA here — letsencrypt-cluster issues
# publicly-trusted certs for LAN-only names via DNS-01.
API_URI: "https://trmnl-device.renner.uno"
KEYVALUE_URL: "redis://terminus-valkey:6379"
# Rack::Attack rate-limit bypass. Beyond upstream's RFC1918 defaults this
# adds fc00::/7, which covers the pod CIDR (fd10:244::/48), the Cilium LB