feat(esphome): deploy ESPHome Device Builder behind an Authentik gate #156

Merged
k.renner merged 1 commits from feat/esphome-device-builder into master 2026-07-12 17:11:09 +00:00
10 changed files with 313 additions and 0 deletions
@@ -433,6 +433,105 @@ data:
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# ------------------------------------------------------------------
# esphome-gate
#
# ESPHome Device Builder (esphome.home.renner.uno, home-assistant ns,
# :6052) has no auth of its own: anyone reaching :6052 can edit device
# YAML (incl. wifi/api secrets in secrets.yaml), compile, and flash/OTA
# firmware. Same proxy-outpost pattern as shelly-manager-gate above: a
# dedicated outpost terminates the request, enforces a passkey step-up,
# and forwards to the esphome Service. Traefik only ever routes the
# hostname to the outpost; the home-assistant CNP allows ONLY this outpost
# to reach :6052 (see home-assistant-ns.yaml + esphome-gate-egress.yaml).
# ------------------------------------------------------------------
- id: esphome-managers-group
model: authentik_core.group
identifiers:
name: ESPHome Managers
state: present
attrs:
is_superuser: false
# Git owns membership, as with Shelly Managers / HA Config Editors:
# the explicit allowlist for who may build/flash ESPHome firmware.
# Add/remove members HERE, not in the UI — blueprint apply is
# authoritative and reverts UI edits.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: esphome-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: esphome-gate
state: present
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://esphome.home-assistant.svc.cluster.local
internal_host_ssl_validation: false
external_host: https://esphome.home.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
# No skip_path_regex initially. Confirm post-deploy whether the ESPHome
# dashboard ships a service worker / manifest fetched with
# credentials:'omit' that 302-loops at the gate (the failure mode that
# forced carve-outs on paperless/vscode and killed the music-assistant
# gate). If so, add the offending paths here. The build/OTA log
# websocket carries the session cookie and is forwarded by the outpost.
- model: authentik_core.application
identifiers:
slug: esphome-gate
state: present
attrs:
name: ESPHome Device Builder (Gate)
slug: esphome-gate
provider: !KeyOf esphome-gate-provider
meta_launch_url: https://esphome.home.renner.uno
meta_description: "Passkey-gated ESPHome Device Builder"
# Only the ESPHome Managers group passes the gate.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, esphome-gate]]
group: !KeyOf esphome-managers-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
- model: authentik_outposts.outpost
identifiers:
name: esphome-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf esphome-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
# ------------------------------------------------------------------
# alertmanager gate
#
@@ -0,0 +1,24 @@
---
# Allow the dedicated esphome-gate outpost to egress into the home-assistant
# namespace and reach esphome (:6052). Without this, the authentik-namespace
# baseline CNP has no egress rule to home-assistant, 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 shelly-manager-gate-egress.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: esphome-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: esphome-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": home-assistant
app.kubernetes.io/name: esphome
toPorts:
- ports:
- port: "6052"
protocol: TCP
@@ -9,6 +9,7 @@ resources:
- paperless-gate-egress.yaml
- vscode-gate-egress.yaml
- shelly-manager-gate-egress.yaml
- esphome-gate-egress.yaml
- alertmanager-gate-egress.yaml
- prometheus-gate-egress.yaml
- outpost-servicemonitors.yaml
@@ -0,0 +1,81 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: esphome
namespace: home-assistant
labels:
app.kubernetes.io/name: esphome
spec:
replicas: 1
# RWO PVC → single writer; never run two pods against the same /config.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: esphome
template:
metadata:
labels:
app.kubernetes.io/name: esphome
spec:
# No macvlan on this pod (deliberately — v1). Unlike shelly-manager, the
# ESPHome Device Builder needs no L2 presence on the IoT VLAN: initial
# flashing is done in the browser over USB (ESP Web Tools / Web Serial),
# and building/compiling is offline. So the 0.0.0.0:6052 bind stays on the
# Cilium-governed pod network only, where the home-assistant CNP admits
# ONLY the esphome-gate Authentik outpost (see home-assistant-ns.yaml +
# esphome-gate-egress.yaml) — no unauthenticated IoT-VLAN exposure like the
# macvlan-on-0.0.0.0 hole shelly-manager had to pin Kestrel around.
#
# Live device status / logs / OTA would need to reach devices on the IoT
# VLAN; that rides on the still-open cluster→v30iot reachability work and
# a macvlan can be added later if wanted.
enableServiceLinks: false
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: esphome
image: ghcr.io/esphome/esphome:2026.6.2
imagePullPolicy: IfNotPresent
# Default entrypoint runs the dashboard (Device Builder) against the
# mounted /config — no command override needed.
ports:
- name: http
containerPort: 6052
env:
- name: TZ
value: Europe/Berlin
# Status/OTA reach devices by ping+API rather than mDNS (the pod has
# no L2 presence on the IoT VLAN; mDNS can't cross the L3 boundary).
- name: ESPHOME_DASHBOARD_USE_PING
value: "true"
volumeMounts:
- name: data
mountPath: /config
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
# Compiling firmware (GCC/LTO under PlatformIO) is memory-hungry
# and bursty; no CPU limit so a build isn't throttled to a crawl.
memory: 4Gi
volumes:
- name: data
persistentVolumeClaim:
claimName: esphome-data
@@ -0,0 +1,22 @@
---
# ExternalName mirror so the home-assistant-namespace esphome Ingress can
# backend to the esphome-gate outpost that Authentik deploys into the authentik
# namespace (cross-namespace Service refs aren't supported in a standard
# Ingress). Mirrors shelly-manager-gate-service.yaml.
#
# Traffic path:
# browser → traefik (esphome.home.renner.uno)
# → ak-outpost-esphome-gate (ExternalName → authentik ns)
# → outpost validates session + passkey step-up
# → http://esphome.home-assistant.svc:80 (esphome :6052)
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-esphome-gate
namespace: home-assistant
spec:
type: ExternalName
externalName: ak-outpost-esphome-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
@@ -0,0 +1,37 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: esphome
namespace: home-assistant
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
# ESPHome Device Builder has no auth of its own — anyone reaching :6052 can
# edit device YAML (incl. wifi/api secrets), compile, and flash/OTA firmware.
# So Traefik never routes to it directly; this Ingress backends the Authentik
# esphome-gate proxy outpost (ExternalName → authentik ns), which enforces a
# passkey step-up and forwards only after the session is validated. The
# home-assistant CNP allows ONLY that outpost to reach :6052 (see
# home-assistant-ns.yaml). Pattern mirrors shelly-manager-ingress.yaml.
#
# WebSocket: the dashboard streams build/OTA logs over a websocket; Traefik's
# websecure entrypoint passes it through (no response buffering, 180s
# idleTimeout). Browser USB flashing uses ESP Web Tools / Web Serial, which
# requires a secure context — served here over HTTPS in Chrome/Edge, the
# serial link is browser↔USB and never traverses the proxy.
ingressClassName: traefik
rules:
- host: esphome.home.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ak-outpost-esphome-gate
port:
number: 9000
tls:
- secretName: esphome-tls
hosts:
- esphome.home.renner.uno
@@ -0,0 +1,18 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: esphome-data
namespace: home-assistant
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
# Single writer (Recreate strategy on the Deployment). Holds the device YAML
# configs, secrets.yaml, and the PlatformIO/GCC build cache — the cache is
# what makes the 10Gi (vs shelly-manager's 1Gi): a first compile pulls the
# full toolchain + framework per platform.
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 10Gi
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: esphome
namespace: home-assistant
spec:
selector:
app.kubernetes.io/name: esphome
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
@@ -25,6 +25,11 @@ resources:
- shelly-manager-service.yaml
- shelly-manager-gate-service.yaml
- shelly-manager-ingress.yaml
- esphome-pvc.yaml
- esphome-deployment.yaml
- esphome-service.yaml
- esphome-gate-service.yaml
- esphome-ingress.yaml
configMapGenerator:
# nginx config for the reverse-proxy sidecar. Hash suffix disabled so
# the cm name stays stable; editing proxy.conf does not auto-roll the
@@ -61,6 +61,19 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# esphome (ESPHome Device Builder, :6052) is likewise reached only through
# its own Authentik gate outpost. Unlike the :8080 vscode/shelly rules
# above, :6052 is unique to esphome, so this namespace-wide (endpointSelector
# {}) toPorts grant doesn't silently apply to any other app — the
# fromEndpoints selector still scopes it to the esphome-gate outpost.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: esphome-gate
toPorts:
- ports:
- port: "6052"
protocol: TCP
# Prometheus scrape: kube-prometheus-stack hits HA's /api/prometheus.
- fromEndpoints:
- matchLabels: