From 948852d83f3835df571732be7c62de059d750c26 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 12 Jul 2026 19:08:59 +0200 Subject: [PATCH] feat(esphome): deploy ESPHome Device Builder behind an Authentik gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ESPHome Device Builder (ghcr.io/esphome/esphome:2026.6.2, :6052) to the home-assistant namespace so ESPHome devices — starting with an M5 Atom Echo BLE proxy for shelly-manager's BLE provisioning — can be built, browser/USB-flashed (ESP Web Tools / Web Serial), and config-managed in-cluster. v1 deliberately runs with NO macvlan. Unlike shelly-manager it needs no L2 presence on the IoT VLAN: initial flashing is browser-side over USB and builds are offline. That also sidesteps the macvlan-on-0.0.0.0 hole (a gated pod with a macvlan bypasses Cilium policy and exposes its unauthenticated UI on the IoT VLAN — confirmed against shelly-manager/matter-server/otbr). Live status / logs / OTA would need cluster→v30iot reachability (a separate, still-open item); a macvlan can be added later if wanted. Mirrors the shelly-manager gate pattern exactly: - home-assistant: esphome deployment (Recreate, RWO 10Gi ceph-block PVC for the PlatformIO build cache), service, gate ExternalName, and a Traefik ingress that backends the Authentik esphome-gate outpost (not esphome directly). - authentik: an esphome-gate proxy provider + application + dedicated outpost (passkey step-up, "ESPHome Managers" group, git-owned membership) in blueprints-configmap.yaml, plus esphome-gate-egress CNP (outpost → :6052). - network-policies: the home-assistant default-deny-ingress grants ONLY the esphome-gate outpost → :6052. :6052 is unique (no collision with the shared :8080 the vscode/shelly gates ride on). Validated: kubectl kustomize builds cleanly for the home-assistant, authentik, and network-policies kustomizations, all rendering the new resources. Post-deploy checks: confirm the dashboard has no credentials:'omit' service worker/manifest that 302-loops at the gate (add skip_path_regex if so); confirm the image serves the dashboard on :6052 with the default entrypoint. Co-Authored-By: Claude Opus 4.8 --- .../apps/authentik/blueprints-configmap.yaml | 99 +++++++++++++++++++ .../apps/authentik/esphome-gate-egress.yaml | 24 +++++ kubernetes/apps/authentik/kustomization.yaml | 1 + .../home-assistant/esphome-deployment.yaml | 81 +++++++++++++++ .../home-assistant/esphome-gate-service.yaml | 22 +++++ .../apps/home-assistant/esphome-ingress.yaml | 37 +++++++ .../apps/home-assistant/esphome-pvc.yaml | 18 ++++ .../apps/home-assistant/esphome-service.yaml | 13 +++ .../apps/home-assistant/kustomization.yaml | 5 + .../network-policies/home-assistant-ns.yaml | 13 +++ 10 files changed, 313 insertions(+) create mode 100644 kubernetes/apps/authentik/esphome-gate-egress.yaml create mode 100644 kubernetes/apps/home-assistant/esphome-deployment.yaml create mode 100644 kubernetes/apps/home-assistant/esphome-gate-service.yaml create mode 100644 kubernetes/apps/home-assistant/esphome-ingress.yaml create mode 100644 kubernetes/apps/home-assistant/esphome-pvc.yaml create mode 100644 kubernetes/apps/home-assistant/esphome-service.yaml diff --git a/kubernetes/apps/authentik/blueprints-configmap.yaml b/kubernetes/apps/authentik/blueprints-configmap.yaml index 0487290e..4cfee1a4 100644 --- a/kubernetes/apps/authentik/blueprints-configmap.yaml +++ b/kubernetes/apps/authentik/blueprints-configmap.yaml @@ -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 # diff --git a/kubernetes/apps/authentik/esphome-gate-egress.yaml b/kubernetes/apps/authentik/esphome-gate-egress.yaml new file mode 100644 index 00000000..f5e376ed --- /dev/null +++ b/kubernetes/apps/authentik/esphome-gate-egress.yaml @@ -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 diff --git a/kubernetes/apps/authentik/kustomization.yaml b/kubernetes/apps/authentik/kustomization.yaml index 3e79f19b..c6be9fef 100644 --- a/kubernetes/apps/authentik/kustomization.yaml +++ b/kubernetes/apps/authentik/kustomization.yaml @@ -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 diff --git a/kubernetes/apps/home-assistant/esphome-deployment.yaml b/kubernetes/apps/home-assistant/esphome-deployment.yaml new file mode 100644 index 00000000..f8824dae --- /dev/null +++ b/kubernetes/apps/home-assistant/esphome-deployment.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 diff --git a/kubernetes/apps/home-assistant/esphome-gate-service.yaml b/kubernetes/apps/home-assistant/esphome-gate-service.yaml new file mode 100644 index 00000000..e35aac0f --- /dev/null +++ b/kubernetes/apps/home-assistant/esphome-gate-service.yaml @@ -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 diff --git a/kubernetes/apps/home-assistant/esphome-ingress.yaml b/kubernetes/apps/home-assistant/esphome-ingress.yaml new file mode 100644 index 00000000..6fb9267b --- /dev/null +++ b/kubernetes/apps/home-assistant/esphome-ingress.yaml @@ -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 diff --git a/kubernetes/apps/home-assistant/esphome-pvc.yaml b/kubernetes/apps/home-assistant/esphome-pvc.yaml new file mode 100644 index 00000000..9131ddcf --- /dev/null +++ b/kubernetes/apps/home-assistant/esphome-pvc.yaml @@ -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 diff --git a/kubernetes/apps/home-assistant/esphome-service.yaml b/kubernetes/apps/home-assistant/esphome-service.yaml new file mode 100644 index 00000000..7b076f20 --- /dev/null +++ b/kubernetes/apps/home-assistant/esphome-service.yaml @@ -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 diff --git a/kubernetes/apps/home-assistant/kustomization.yaml b/kubernetes/apps/home-assistant/kustomization.yaml index 1951f6e0..0d790c13 100644 --- a/kubernetes/apps/home-assistant/kustomization.yaml +++ b/kubernetes/apps/home-assistant/kustomization.yaml @@ -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 diff --git a/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml b/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml index 3b5cc64f..bb03ae10 100644 --- a/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml +++ b/kubernetes/infrastructure/network-policies/home-assistant-ns.yaml @@ -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: -- 2.54.0