Compare commits

...
Author SHA1 Message Date
atlas-botandClaude Opus 5.5 478ba9075c feat(ha-hacs-proxy): scoped HACS update.install proxy for Hestia (REN-268)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
New workload in the paperclip namespace, own Flux Kustomization. Hestia gets a
proxy bearer only; the proxy holds the non-admin hestia-hacs HA token and
allows GET of allowlisted HACS update.* entities and update/install pinned to
the reviewed latest_version, one install per 10 min, integrations 02:00-05:00
Europe/Berlin. Deny by default. Allowlist seeded from the REN-254 inventory.

No change to Home Assistant, its policy, or any existing workload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:10:36 +00:00
6 changed files with 629 additions and 0 deletions
@@ -0,0 +1,252 @@
{
"_comment": [
"HACS-provided update.* entities that Hestia may install via ha-hacs-proxy (REN-267 design, REN-268).",
"Source: REN-254 hacs-inventory rev 2 (18 cards, 22 integrations; switch_manager left out, it is removed).",
"entity_id is HACS's default '<slug of repository name>_update' and was NOT read from HA. The proxy logs",
"'allowlisted_but_missing_in_ha' / 'github_update_not_allowlisted' at startup; fix the ids by PR from that log.",
"Every install also needs the entity's release_url to start with https://github.com/<repo>/, so a wrong id cannot",
"reach core, supervisor, OS, ESPHome or device firmware. Never add those here. New HACS component: PR to this list."
],
"entities": [
{
"entity_id": "update.bubble_card_update",
"kind": "card",
"repo": "Clooos/Bubble-Card",
"name": "Bubble-Card"
},
{
"entity_id": "update.alarmo_card_update",
"kind": "card",
"repo": "nielsfaber/alarmo-card",
"name": "alarmo-card"
},
{
"entity_id": "update.apexcharts_card_update",
"kind": "card",
"repo": "RomRider/apexcharts-card",
"name": "apexcharts-card"
},
{
"entity_id": "update.battery_state_card_update",
"kind": "card",
"repo": "maxwroc/battery-state-card",
"name": "battery-state-card"
},
{
"entity_id": "update.button_card_update",
"kind": "card",
"repo": "custom-cards/button-card",
"name": "button-card"
},
{
"entity_id": "update.calendar_card_pro_update",
"kind": "card",
"repo": "alexpfau/calendar-card-pro",
"name": "calendar-card-pro"
},
{
"entity_id": "update.decluttering_card_update",
"kind": "card",
"repo": "custom-cards/decluttering-card",
"name": "decluttering-card"
},
{
"entity_id": "update.sankey_chart_update",
"kind": "card",
"repo": "MindFreeze/ha-sankey-chart",
"name": "ha-sankey-chart"
},
{
"entity_id": "update.kiosk_mode_update",
"kind": "card",
"repo": "NemesisRE/kiosk-mode",
"name": "kiosk-mode"
},
{
"entity_id": "update.auto_entities_update",
"kind": "card",
"repo": "thomasloven/lovelace-auto-entities",
"name": "auto-entities"
},
{
"entity_id": "update.card_mod_update",
"kind": "card",
"repo": "thomasloven/lovelace-card-mod",
"name": "card-mod"
},
{
"entity_id": "update.mushroom_update",
"kind": "card",
"repo": "piitaya/lovelace-mushroom",
"name": "mushroom"
},
{
"entity_id": "update.nina_dwd_card_update",
"kind": "card",
"repo": "timmaurice/lovelace-nina-dwd-card",
"name": "nina-dwd-card"
},
{
"entity_id": "update.template_entity_row_update",
"kind": "card",
"repo": "thomasloven/lovelace-template-entity-row",
"name": "template-entity-row"
},
{
"entity_id": "update.xiaomi_vacuum_map_card_update",
"kind": "card",
"repo": "PiotrMachowski/lovelace-xiaomi-vacuum-map-card",
"name": "xiaomi-vacuum-map-card"
},
{
"entity_id": "update.mini_graph_card_update",
"kind": "card",
"repo": "kalkih/mini-graph-card",
"name": "mini-graph-card"
},
{
"entity_id": "update.power_flow_card_plus_update",
"kind": "card",
"repo": "flixlix/power-flow-card-plus",
"name": "power-flow-card-plus"
},
{
"entity_id": "update.simon42_dashboard_strategy_update",
"kind": "card",
"repo": "TheRealSimon42/simon42-dashboard-strategy",
"name": "simon42-dashboard-strategy"
},
{
"entity_id": "update.powercalc_update",
"kind": "integration",
"repo": "bramstroker/homeassistant-powercalc",
"name": "powercalc"
},
{
"entity_id": "update.openid_update",
"kind": "integration",
"repo": "cavefire/hass-openid",
"name": "openid"
},
{
"entity_id": "update.hacs_update",
"kind": "integration",
"repo": "hacs/integration",
"name": "hacs"
},
{
"entity_id": "update.adaptive_lighting_update",
"kind": "integration",
"repo": "basnijholt/adaptive-lighting",
"name": "adaptive_lighting"
},
{
"entity_id": "update.alarmo_update",
"kind": "integration",
"repo": "nielsfaber/alarmo",
"name": "alarmo"
},
{
"entity_id": "update.battery_notes_update",
"kind": "integration",
"repo": "andrew-codechimp/HA-Battery-Notes",
"name": "battery_notes"
},
{
"entity_id": "update.bwt_perla_update",
"kind": "integration",
"repo": "dkarv/ha-bwt-perla",
"name": "bwt_perla"
},
{
"entity_id": "update.dreame_vacuum_update",
"kind": "integration",
"repo": "Tasshack/dreame-vacuum",
"name": "dreame_vacuum"
},
{
"entity_id": "update.dwd_pollenflug_update",
"kind": "integration",
"repo": "mampfes/hacs_dwd_pollenflug",
"name": "dwd_pollenflug"
},
{
"entity_id": "update.dwd_weather_update",
"kind": "integration",
"repo": "FL550/dwd_weather",
"name": "dwd_weather"
},
{
"entity_id": "update.washdata_update",
"kind": "integration",
"repo": "3dg1luk43/ha_washdata",
"name": "ha_washdata"
},
{
"entity_id": "update.global_health_score_update",
"kind": "integration",
"repo": "HA-Pulse/home-assistant-global-health-score",
"name": "haghs"
},
{
"entity_id": "update.magic_areas_update",
"kind": "integration",
"repo": "jseidl/magic-areas",
"name": "magic_areas"
},
{
"entity_id": "update.philips_airpurifier_coap_update",
"kind": "integration",
"repo": "kongo09/philips-airpurifier-coap",
"name": "philips_airpurifier_coap"
},
{
"entity_id": "update.rademacher_update",
"kind": "integration",
"repo": "peribeir/homeassistant-rademacher",
"name": "rademacher"
},
{
"entity_id": "update.siku_update",
"kind": "integration",
"repo": "hmn/siku-integration",
"name": "siku"
},
{
"entity_id": "update.spook_update",
"kind": "integration",
"repo": "frenck/spook",
"name": "spook"
},
{
"entity_id": "update.variables_update",
"kind": "integration",
"repo": "snarky-snark/home-assistant-variables",
"name": "var"
},
{
"entity_id": "update.waste_collection_schedule_update",
"kind": "integration",
"repo": "mampfes/hacs_waste_collection_schedule",
"name": "waste_collection_schedule"
},
{
"entity_id": "update.watchman_update",
"kind": "integration",
"repo": "dummylabs/thewatchman",
"name": "watchman"
},
{
"entity_id": "update.bermuda_update",
"kind": "integration",
"repo": "agittins/bermuda",
"name": "bermuda"
},
{
"entity_id": "update.solcast_solar_update",
"kind": "integration",
"repo": "BJReplay/ha-solcast-solar",
"name": "solcast_solar"
}
]
}
@@ -0,0 +1,103 @@
# ha-hacs-proxy (REN-268): the only path by which Hestia can change Home Assistant.
# Contract and allowlist rules: proxy.py docstring and the `design` document on REN-267.
#
# Lives in the `paperclip` namespace on purpose: Hestia runs inside the paperclip
# pod, and the namespace baseline (infrastructure/network-policies/paperclip-ns.yaml)
# already allows paperclip -> paperclip and paperclip -> traefik. So this adds no
# network policy and changes none. The proxy reaches HA the same way the agents do,
# https://home.renner.uno through Traefik; HA's own policy and pod are untouched.
#
# One replica and Recreate, not RollingUpdate: the one-install-per-10-min guard is
# in memory, and two pods side by side during a rollout would each allow one.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ha-hacs-proxy
namespace: paperclip
labels:
app.kubernetes.io/name: ha-hacs-proxy
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ha-hacs-proxy
template:
metadata:
labels:
app.kubernetes.io/name: ha-hacs-proxy
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: proxy
image: python:3.14.7-alpine3.24
command: ["python", "-u", "/etc/ha-hacs-proxy/proxy.py"]
env:
- name: HA_URL
value: https://home.renner.uno
- name: PYTHONDONTWRITEBYTECODE
value: "1"
- name: HA_TOKEN
valueFrom:
secretKeyRef:
name: ha-hacs-proxy
key: HA_TOKEN
- name: PROXY_TOKEN
valueFrom:
secretKeyRef:
name: ha-hacs-proxy
key: PROXY_TOKEN
ports:
- containerPort: 8080
name: http
livenessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 30
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: code
mountPath: /etc/ha-hacs-proxy
readOnly: true
volumes:
- name: code
configMap:
name: ha-hacs-proxy
---
apiVersion: v1
kind: Service
metadata:
name: ha-hacs-proxy
namespace: paperclip
labels:
app.kubernetes.io/name: ha-hacs-proxy
spec:
selector:
app.kubernetes.io/name: ha-hacs-proxy
ports:
- name: http
port: 80
targetPort: 8080
@@ -0,0 +1,27 @@
# Both values are placed in OpenBao by the owner (REN-267 design §Owner actions):
# infrastructure/homeassistant/hestia-hacs ha_token long-lived token of the NON-admin HA user `hestia-hacs`
# infrastructure/homeassistant/hestia-hacs proxy_token bearer Hestia presents to this proxy (same value as her
# Paperclip secret env.HA_HACS_PROXY_TOKEN)
# Only the proxy pod mounts this Secret. Hestia never sees ha_token; the paperclip
# pod's ServiceAccount has the `view` role, which excludes Secrets.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ha-hacs-proxy
namespace: paperclip
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: ha-hacs-proxy
data:
- secretKey: HA_TOKEN
remoteRef:
key: infrastructure/homeassistant/hestia-hacs
property: ha_token
- secretKey: PROXY_TOKEN
remoteRef:
key: infrastructure/homeassistant/hestia-hacs
property: proxy_token
@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- deployment.yaml
# Hash-suffixed, so an edit to the code or the allowlist rolls the pod.
configMapGenerator:
- name: ha-hacs-proxy
namespace: paperclip
files:
- proxy.py
- allowlist.json
+215
View File
@@ -0,0 +1,215 @@
"""ha-hacs-proxy: lets Hestia install reviewed HACS updates and nothing else (REN-267/REN-268).
Deny by default. The only routes are:
GET /api/states/<entity_id> entity_id on the allowlist
GET /api/states filtered to allowlisted entities
POST /api/services/update/install body exactly {"entity_id": "<id>", "version": "<v>"}
GET /healthz unauthenticated, for kubelet probes; returns "ok" only
Standard library only, so the image is a stock pinned python and there is no build.
Never logs a token or the Authorization header.
"""
import hmac
import json
import os
import re
import sys
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from zoneinfo import ZoneInfo
HA_URL = os.environ["HA_URL"].rstrip("/")
HA_TOKEN = os.environ["HA_TOKEN"]
PROXY_TOKEN = os.environ["PROXY_TOKEN"]
ALLOWLIST_PATH = os.environ.get("ALLOWLIST_PATH", "/etc/ha-hacs-proxy/allowlist.json")
PORT = int(os.environ.get("PORT", "8080"))
INSTALL_COOLDOWN_S = 600 # one install per 10 min
INTEGRATION_WINDOW = (2, 5) # 02:00 <= local hour < 05:00
BERLIN = ZoneInfo("Europe/Berlin")
MAX_BODY = 4096
ENTITY_RE = re.compile(r"^update\.[a-z0-9_]+$")
if len(PROXY_TOKEN) < 32:
sys.exit("PROXY_TOKEN shorter than 32 characters; refusing to start")
with open(ALLOWLIST_PATH) as f:
ALLOWLIST = {e["entity_id"]: e for e in json.load(f)["entities"]}
for eid, e in ALLOWLIST.items():
if not ENTITY_RE.match(eid) or e["kind"] not in ("card", "integration") or "/" not in e["repo"]:
sys.exit(f"bad allowlist entry: {eid}")
_install_lock = threading.Lock()
_last_install = 0.0
def log(**fields):
fields["time"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
print(json.dumps(fields, sort_keys=True), flush=True)
def ha(method, path, body=None, timeout=30):
"""Call HA as user hestia-hacs. Returns (status, parsed JSON or None); status 0 = no answer."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(HA_URL + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + HA_TOKEN)
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read() or b"null")
except urllib.error.HTTPError as e:
return e.code, None
except (OSError, ValueError): # URLError, timeout, reset, non-JSON
return 0, None
def repo_matches(state, repo):
"""Second guard next to the static list: the entity must point at the allowlisted GitHub repo.
Core, OS, supervisor, ESPHome and Shelly firmware update entities never carry a
github.com/<owner>/<repo> release_url of a HACS repository.
"""
url = (state.get("attributes") or {}).get("release_url") or ""
return url.lower().startswith(f"https://github.com/{repo.lower()}/")
def self_check():
"""Log allowlist drift at startup: entity ids are not secret, the log is the discovery path."""
status, states = ha("GET", "/api/states")
if status != 200 or not isinstance(states, list):
log(event="self_check", result="ha_unreachable", ha_status=status)
return
updates = {s["entity_id"]: s for s in states if s.get("entity_id", "").startswith("update.")}
for eid, e in sorted(ALLOWLIST.items()):
if eid not in updates:
log(event="self_check", entity=eid, result="allowlisted_but_missing_in_ha")
elif not repo_matches(updates[eid], e["repo"]):
log(event="self_check", entity=eid, result="release_url_mismatch",
release_url=(updates[eid].get("attributes") or {}).get("release_url"))
for eid, s in sorted(updates.items()):
url = (s.get("attributes") or {}).get("release_url") or ""
if eid not in ALLOWLIST and url.startswith("https://github.com/"):
log(event="self_check", entity=eid, result="github_update_not_allowlisted", release_url=url)
log(event="self_check", result="done", allowlisted=len(ALLOWLIST), ha_update_entities=len(updates))
class Handler(BaseHTTPRequestHandler):
server_version = "ha-hacs-proxy"
sys_version = ""
def log_message(self, *args): # replaced by log() per request
pass
def reply(self, status, obj, **logfields):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
log(method=self.command, path=self.path.split("?")[0], status=status, **logfields)
def authorized(self):
got = self.headers.get("Authorization", "")
return got.startswith("Bearer ") and hmac.compare_digest(got[7:].encode(), PROXY_TOKEN.encode())
def do_GET(self):
path = self.path.split("?")[0]
if path == "/healthz":
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"ok")
return
if not self.authorized():
return self.reply(401, {"message": "unauthorized"}, result="no_or_bad_bearer")
if path == "/api/states":
status, states = ha("GET", "/api/states")
if status != 200 or not isinstance(states, list):
return self.reply(502, {"message": "upstream error"}, result="upstream", ha_status=status)
return self.reply(200, [s for s in states if s.get("entity_id") in ALLOWLIST], result="ok")
if path.startswith("/api/states/"):
eid = path[len("/api/states/"):]
if eid not in ALLOWLIST:
return self.reply(403, {"message": "entity not allowlisted"}, entity=eid[:100], result="denied")
status, state = ha("GET", "/api/states/" + eid)
return self.reply(status if status in (200, 404) else 502, state or {"message": "upstream"},
entity=eid, result="ok" if status == 200 else "upstream", ha_status=status)
return self.reply(403, {"message": "forbidden"}, result="denied")
def do_POST(self):
global _last_install
path = self.path.split("?")[0]
if not self.authorized():
return self.reply(401, {"message": "unauthorized"}, result="no_or_bad_bearer")
if path != "/api/services/update/install":
return self.reply(403, {"message": "forbidden"}, result="denied")
try:
n = int(self.headers.get("Content-Length", "0"))
except ValueError:
n = -1
if not 0 < n <= MAX_BODY:
return self.reply(400, {"message": "body required, max 4096 bytes"}, result="bad_body")
try:
body = json.loads(self.rfile.read(n))
except ValueError:
return self.reply(400, {"message": "invalid JSON"}, result="bad_body")
if not (isinstance(body, dict) and set(body) == {"entity_id", "version"}
and isinstance(body["entity_id"], str) and isinstance(body["version"], str)):
return self.reply(403, {"message": 'body must be exactly {"entity_id": "<id>", "version": "<v>"}'},
result="denied_body")
eid, version = body["entity_id"], body["version"]
entry = ALLOWLIST.get(eid)
if entry is None:
return self.reply(403, {"message": "entity not allowlisted"}, entity=eid[:100], result="denied")
if entry["kind"] == "integration":
hour = datetime.now(BERLIN).hour
if not INTEGRATION_WINDOW[0] <= hour < INTEGRATION_WINDOW[1]:
return self.reply(403, {"message": "integrations only 02:00-05:00 Europe/Berlin"},
entity=eid, version=version[:50], result="outside_window")
if not _install_lock.acquire(blocking=False):
return self.reply(429, {"message": "another install is running"}, entity=eid, result="busy")
try:
wait = _last_install + INSTALL_COOLDOWN_S - time.time()
if wait > 0:
self.reply(429, {"message": f"one install per 10 min, retry in {int(wait) + 1}s"},
entity=eid, version=version[:50], result="cooldown")
return
status, state = ha("GET", "/api/states/" + eid)
if status != 200 or not isinstance(state, dict):
self.reply(502, {"message": "cannot read entity"}, entity=eid, result="upstream", ha_status=status)
return
if not repo_matches(state, entry["repo"]):
self.reply(403, {"message": "entity release_url does not match allowlisted repo"},
entity=eid, result="repo_mismatch")
return
attrs = state.get("attributes") or {}
latest = attrs.get("latest_version")
if latest != version:
self.reply(409, {"message": "version is not the pending latest_version", "latest_version": latest},
entity=eid, version=version[:50], latest=latest, result="version_mismatch")
return
if attrs.get("installed_version") == latest:
self.reply(409, {"message": "already installed"}, entity=eid, version=version, result="no_update")
return
_last_install = time.time()
status, resp = ha("POST", "/api/services/update/install", {"entity_id": eid}, timeout=300)
self.reply(200 if status == 200 else 502, resp if status == 200 else {"message": "upstream error"},
entity=eid, version=version, kind=entry["kind"],
result="installed" if status == 200 else "upstream", ha_status=status)
finally:
_install_lock.release()
do_PUT = do_DELETE = do_PATCH = lambda self: self.reply(
401 if not self.authorized() else 403, {"message": "forbidden"}, result="denied")
if __name__ == "__main__":
log(event="start", allowlisted=len(ALLOWLIST), ha_url=HA_URL)
threading.Thread(target=self_check, daemon=True).start()
ThreadingHTTPServer(("", PORT), Handler).serve_forever()
@@ -0,0 +1,20 @@
# ha-hacs-proxy (REN-268). Own Kustomization, so the whole path is revertible
# (and prunable) on its own without touching the `paperclip` Kustomization that
# carries the agents' pod.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: ha-hacs-proxy
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/ha-hacs-proxy
prune: true
wait: true
dependsOn:
- name: infrastructure