Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
478ba9075c |
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user