c4574927fd
- hms_cluster: ClusterMessage (§6.5: Pflichtfelder, Sequenzen, Revisionen, execute_at, Trace-ID), CommandTracker (idempotent, vorwärts-only: accepted->armed->executed/failed) - NodeRegistry: online/degraded/stale/offline über Schwellen, UI-Kategorien discovered/paired/unknown/incompatible/offline, Doppel-Node-ID blockiert, IP-Wechsel erhaelt node_id (§6.3, §6.5) - Pairing: PIN (TTL 120s, Versuchslimit), Fingerprint (SHA-256 gruppiert), Token nur als Hash, Scopes read/control/content_sync/admin, Ablauf + sofortiger Widerruf (§6.3, §27.1, ADR-0010) - Discovery-Modell: _hmsmedia._tcp.local. TXT ohne Secrets, Capability- Digest, persistente manuelle Fallback-Liste (ADR-0009) - ADR-0009 (mDNS + Fallback) und ADR-0010 (Paarung) dokumentiert - 25 Unit-Tests; Gesamtsuite 183 Tests gruen, Ruff gruen
132 lines
4.3 KiB
Python
132 lines
4.3 KiB
Python
"""Discovery: mDNS-Service-Modell + manuelle Fallback-Liste (PLAN.md §6.3;
|
|
ADR-0009).
|
|
|
|
- Service-Typ: _hmsmedia._tcp.local.
|
|
- TXT nur kleine, nicht vertrauliche Daten: proto, node, roles, port, caps
|
|
- manuelle Node-Liste für VLANs/geroutete Netze (§6.3)
|
|
- IP-Wechsel ändert die node_id nicht (§6.3)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
SERVICE_TYPE = "_hmsmedia._tcp.local."
|
|
DISCOVERY_PROTOCOL_VERSION = 1
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServiceInfo:
|
|
"""mDNS-Ankündigung eines Nodes (TXT-Inhalt, ADR-0009)."""
|
|
|
|
node_id: str
|
|
display_name: str
|
|
port: int
|
|
roles: tuple[str, ...]
|
|
protocol_version: int = DISCOVERY_PROTOCOL_VERSION
|
|
capability_digest: str = ""
|
|
|
|
@property
|
|
def instance_name(self) -> str:
|
|
"""Eindeutiger Instanzname: bereinigter Anzeigename."""
|
|
safe = "".join(c for c in self.display_name if c.isalnum() or c in " -_")
|
|
return safe[:63] or self.node_id[:8]
|
|
|
|
def txt(self) -> dict[str, str]:
|
|
"""TXT-Record: klein, nicht vertraulich (ADR-0009, §27.1)."""
|
|
return {
|
|
"proto": str(self.protocol_version),
|
|
"node": self.node_id,
|
|
"roles": ",".join(self.roles),
|
|
"port": str(self.port),
|
|
"caps": self.capability_digest[:16],
|
|
}
|
|
|
|
@classmethod
|
|
def from_txt(cls, instance_name: str, port: int, txt: dict[str, str]) -> ServiceInfo:
|
|
"""Parst eine fremde Ankündigung; wirft bei unvollständigen Daten."""
|
|
required = ("proto", "node", "port")
|
|
missing = [k for k in required if k not in txt]
|
|
if missing:
|
|
raise ValueError(f"TXT unvollstaendig, fehlt: {missing}")
|
|
node_id = txt["node"]
|
|
if len(node_id) < 8:
|
|
raise ValueError("node-Eintrag ungueltig")
|
|
roles = tuple(r for r in txt.get("roles", "").split(",") if r)
|
|
return cls(
|
|
node_id=node_id,
|
|
display_name=instance_name,
|
|
port=int(txt["port"]) or port,
|
|
roles=roles,
|
|
protocol_version=int(txt["proto"]),
|
|
capability_digest=txt.get("caps", ""),
|
|
)
|
|
|
|
|
|
def capability_digest(capabilities: dict) -> str:
|
|
"""Kurzer, stabiler Digest über Capabilities (ADR-0009 TXT 'caps')."""
|
|
canonical = json.dumps(capabilities, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
@dataclass
|
|
class ManualNodeList:
|
|
"""Persistente manuelle Node-Liste (Fallback ohne mDNS, §6.3).
|
|
|
|
JSON-Format: Liste von {"host", "port", "node_id"}; ungeprüfte Hosts
|
|
bleiben Kategorie `unknown`, bis sich die Node legitim identifiziert.
|
|
"""
|
|
|
|
path: Path
|
|
|
|
def save(self, entries: list[dict]) -> None:
|
|
clean = [
|
|
{
|
|
"host": str(e.get("host", "")),
|
|
"port": int(e.get("port", 0)),
|
|
"node_id": str(e.get("node_id", "")),
|
|
}
|
|
for e in entries
|
|
]
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.path.write_text(
|
|
json.dumps(clean, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
|
|
def load(self) -> list[dict]:
|
|
if not self.path.is_file():
|
|
return []
|
|
try:
|
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return []
|
|
entries = []
|
|
for item in data if isinstance(data, list) else []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
host = str(item.get("host", ""))
|
|
if not host:
|
|
continue
|
|
try:
|
|
port = int(item.get("port", 0))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
entries.append(
|
|
{"host": host, "port": port, "node_id": str(item.get("node_id", ""))}
|
|
)
|
|
return entries
|
|
|
|
def add(self, host: str, port: int, node_id: str = "") -> None:
|
|
entries = self.load()
|
|
if any(e["host"] == host and e["port"] == port for e in entries):
|
|
return
|
|
entries.append({"host": host, "port": port, "node_id": node_id})
|
|
self.save(entries)
|
|
|
|
def remove(self, host: str, port: int) -> None:
|
|
entries = [e for e in self.load() if not (e["host"] == host and e["port"] == port)]
|
|
self.save(entries)
|