Device security and robustness (openpanel C0): check code, pnc, lenient report

- POST /devices/pair answers a 2-character `check`, listed by /devices/pending
  and shown beside each waiting entry; five codes a minute per address, and at
  50 waiting the oldest gives way instead of a 429
- portal-minted device tokens are asked for with the device's nonce (`pnc`) and
  held against it, so unpair and delete revoke them; one without `pnc` stands
  only while the nonce is 0
- a device credential acts as the device (`device_principal`), not as the
  approving superuser, so deleting that account unpairs nothing
- the report is validated port by port: unknown enum values read as `unknown`,
  unreadable ports and sections are left out and logged, `config` may name an
  entry of `config_schemas`; 422 only for no schema 1 or no object
- devices.json is parsed once per change (cached by stat), delete drops the
  report and keeps the nonce, a busy recheck runs in the background
- scope and io only from enabled flows (start/stop tells the devices), a port
  bound twice gives one io row
- the Devices tab's saves cancel and hold back device refetches until settled
This commit is contained in:
Melvin Strobl
2026-10-16 19:26:05 +02:00
parent 13d23f6baf
commit c604cfdf3b
21 changed files with 773 additions and 119 deletions
+13 -5
View File
@@ -619,6 +619,8 @@ class FlowController:
finally:
self._lock.release()
# A stopped flow wires no device ports.
self._rewired()
self._publish(
{
"type": "pipeline_rebuilt",
@@ -836,7 +838,7 @@ class FlowController:
)
self._rewired()
async def recheck(self) -> None:
async def recheck(self, wait: float | None = FLOW_REBUILD_WAIT) -> None:
"""Validate the built graph again without rebuilding anything.
For what validation reads from outside the flows: a device created or
@@ -844,7 +846,7 @@ class FlowController:
"""
if self.pipeline is None:
return
await self._acquire(FLOW_REBUILD_WAIT)
await self._acquire(wait)
try:
self.issues = _collect_issues(
self.loaded, self.pipeline, self._all_flow_inputs(), self.devices
@@ -2124,6 +2126,14 @@ def _collect_issues(
_NUMBERS = frozenset({"int", "float"})
def _ways(said: dict[str, Any]) -> list[str]:
"""The directions a reported port may be wired in, none when it does not
say: a direction this version does not know is no reason to refuse one."""
if said.get("modes"):
return list(said["modes"])
return [said["direction"]] if said.get("direction") in ("in", "out") else []
def _device_issues(
loaded: dict[str, LoadedNode], devices: Any
) -> list[ValidationIssue]:
@@ -2190,9 +2200,7 @@ def _device_issues(
elif said is None:
problem = f"'{device}' does not report a port '{spec.port}'."
advisory = True
elif said.get("direction") and direction not in (
said.get("modes") or [said["direction"]]
):
elif (ways := _ways(said)) and direction not in ways:
problem = (
f"'{spec.port}' of '{device}' is an {said['direction']} "
f"port, wired here as {direction}."