Files
Michael Sauer b8f3c94278 fix(update): Lampe bekommt genau die geprüften Bytes
Die Registry für Einmal-URLs speicherte bisher nur einen Pfad; View und
verify() lasen die Datei getrennt und zu unterschiedlichen Zeitpunkten
erneut ein. Wurde die Datei zwischen Prüfung und Abruf ersetzt (z. B.
gleicher Dateiname in build/ und build-nocreds/), erreichte ein nie
geprüftes Image die Lampe und hebelte damit die WLAN-Sicherung aus.

Jetzt werden die Bytes einmal im Install-Pfad gelesen, Gerätetyp/Version
und WLAN-SSID auf genau diesen Bytes geprüft (FirmwareCatalog.verify_and_read,
firmware.contains_ssid) und exakt diese Bytes in der Registry hinterlegt;
die View serviert nur noch das, was tatsächlich geprüft wurde.

Außerdem: Bewertungen (Katalog-Rescan-Signal) werden nicht mehr angestoßen,
während ein Update läuft - das würde eine gerade flashende Lampe zusätzlich
anfragen und Angebot/Issue unter dem laufenden Update verändern; nach jedem
Update wird stattdessen genau einmal neu bewertet. Kommentar zum Install-Lock
korrigiert (der eigentliche Lücke liegt vor dem Setzen der Flags, nicht in
HAs eigener "bereits läuft"-Prüfung). Testlücke geschlossen: die View liefert
jetzt nachweislich nur die zu ihrem Schlüssel gehörenden Bytes. Timeout in
einem WLAN-Regressionstest verkürzt, damit er bei einem Rückfall nicht
12 Minuten hängt.
2026-09-16 17:40:10 +02:00

75 lines
2.5 KiB
Python

"""Serve firmware images to lamps under one-time URLs."""
from __future__ import annotations
from collections.abc import Mapping
import secrets
from typing import Any
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.network import NoURLAvailableError, get_url
from .const import CONF_LAMP_BASE_URL, DOMAIN, FIRMWARE_URL_PREFIX
class OtaRegistry:
"""Random keys, each bound to exactly the verified bytes of one image, valid only while its
update runs.
Bytes rather than a path: the file on disk could be replaced between verification and
download (spec §7 guard would otherwise apply to a file the lamp never actually receives).
"""
def __init__(self) -> None:
self._images: dict[str, bytes] = {}
def create(self, data: bytes) -> str:
key = secrets.token_urlsafe(32)
self._images[key] = data
return key
def revoke(self, key: str) -> None:
self._images.pop(key, None)
def get(self, key: str) -> bytes | None:
return self._images.get(key)
def __len__(self) -> int:
return len(self._images)
class NinlightFirmwareView(HomeAssistantView):
"""No HA login: the lamp cannot authenticate. The unguessable key is the protection."""
url = FIRMWARE_URL_PREFIX + "{key}"
name = "api:ninlight:firmware"
requires_auth = False
def __init__(self, hass: HomeAssistant, registry: OtaRegistry) -> None:
self._hass = hass
self._registry = registry
async def get(self, request: web.Request, key: str) -> web.Response:
body = self._registry.get(key)
if body is None:
return web.Response(status=404)
return web.Response(body=body, content_type="application/octet-stream")
def lamp_base_url(hass: HomeAssistant, options: Mapping[str, Any]) -> str:
"""The http:// address lamps use to reach Home Assistant."""
base = options.get(CONF_LAMP_BASE_URL)
if not base:
try:
base = get_url(hass, allow_external=False, allow_cloud=False, prefer_external=False)
except NoURLAvailableError as err:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="no_http_url"
) from err
if not str(base).startswith("http://"):
raise HomeAssistantError(translation_domain=DOMAIN, translation_key="no_http_url")
return str(base)