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
@@ -477,11 +477,29 @@ function Pairing({
? "Waiting to be paired"
: "No device is waiting"}
</span>
{pending?.some((entry) => !entry.needs_code) ? (
<p className="text-xs text-muted-foreground">
Compare the check beside each with the one the device shows next
to its code before choosing Pair this one.
</p>
) : null}
{(pending ?? []).map((entry) => (
<div
key={entry.request}
className="flex items-center gap-2 rounded-sm text-sm"
>
{entry.check ? (
<span
className="shrink-0 rounded-sm border border-border px-2 py-1 font-mono text-base font-medium tracking-[0.2em]"
title="The check this device shows next to its code"
data-testid="pending-check"
>
<span aria-hidden="true">{entry.check}</span>
<span className="sr-only">
Check {entry.check.split("").join(" ")}
</span>
</span>
) : null}
<span className="grid min-w-0 flex-1">
<span className="truncate">
{entry.name || entry.board || "Unnamed device"}
@@ -546,8 +564,7 @@ function Pairing({
</Button>
<InfoTip label="Unpair screen">
Sends the screen back to a pairing code and keeps the device and
its dashboards. A screen paired through the portal is revoked
there instead.
its dashboards, also for a screen paired through the portal.
</InfoTip>
</div>
</>
+1 -1
View File
@@ -25,7 +25,7 @@ export type DeviceReport = {
display?: {
w: number
h: number
shape?: "rect" | "round"
shape?: "rect" | "round" | "unknown"
touch?: boolean
} | null
camera?: { sensor?: string; w?: number; h?: number; fps?: number } | null
+34 -5
View File
@@ -1,4 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
type QueryClient,
useMutation,
useQueryClient,
} from "@tanstack/react-query"
import {
type DeviceDef,
@@ -11,6 +15,20 @@ export const deviceKeys = {
all: ["devices"] as const,
detail: (id: string) => ["devices", id] as const,
pending: ["devices-pending"] as const,
/** Every save of a device, whichever one. */
save: ["devices", "save"] as const,
}
/**
* Read the devices again, unless a save is under way.
*
* A refetch started during a save can land before the save does, which shows
* the change undone and hands the next save an older version, which the
* server refuses with 409. The last save to settle reads the list instead.
*/
export function refetchDevices(client: QueryClient, exact = true) {
if (client.isMutating({ mutationKey: deviceKeys.save })) return
client.invalidateQueries({ queryKey: deviceKeys.all, exact })
}
/** Every device, what it shows and whether it is connected. */
@@ -43,8 +61,10 @@ export const pendingQueryOptions = () => ({
* A change is shown at once, and the saves of one device go one at a time,
* each laid over the device as the cache holds it when it is sent and
* against the version the save before it left. So a title and a switch
* changed in one gesture neither refuse nor undo each other. Any failure, a
* 409 from someone else's save included, reads the list again.
* changed in one gesture neither refuse nor undo each other. A refetch the
* socket asks for meanwhile waits (`refetchDevices`), and the last save to
* settle reads the list again, after a failure too, a 409 from someone else's
* save included.
*/
export function useSaveDevice(id: string) {
const queryClient = useQueryClient()
@@ -64,8 +84,13 @@ export function useSaveDevice(id: string) {
: all,
)
return useMutation({
mutationKey: deviceKeys.save,
scope: { id: `device:${id}` },
onMutate: (changes: Partial<DeviceDef>) => patch(changes),
onMutate: async (changes: Partial<DeviceDef>) => {
// One already on its way would land over this change.
await queryClient.cancelQueries({ queryKey: deviceKeys.all, exact: true })
patch(changes)
},
mutationFn: (changes: Partial<DeviceDef>) => {
const current = stored()
if (!current) throw new Error(`No device named ${id}`)
@@ -87,7 +112,11 @@ export function useSaveDevice(id: string) {
// The version alone: the cache already shows this change, and a save
// queued behind it may have shown another since.
onSuccess: (saved) => patch({ version: saved.version }),
onError: () => queryClient.invalidateQueries({ queryKey: deviceKeys.all }),
// Still counted while it settles, so 1 is this one alone.
onSettled: () => {
if (queryClient.isMutating({ mutationKey: deviceKeys.save }) === 1)
queryClient.invalidateQueries({ queryKey: deviceKeys.all, exact: true })
},
})
}
@@ -4,7 +4,7 @@ import { useEffect } from "react"
import { ApiError, FlowsService, OpenAPI } from "@/client"
import { dashboardKeys } from "@/components/Dashboard/queries"
import { deviceKeys } from "@/components/Devices/queries"
import { deviceKeys, refetchDevices } from "@/components/Devices/queries"
import { healthKeys } from "@/components/Health/queries"
import { runKeys } from "@/components/Runs/queries"
import { connectionStore } from "@/lib/connectionStore"
@@ -418,16 +418,16 @@ function open(url: string) {
// `exact` matters — the list key is a prefix of every detail key,
// including the draft an editor may have open.
client?.invalidateQueries({ queryKey: dashboardKeys.all, exact: true })
client?.invalidateQueries({
queryKey: message.dashboard
? dashboardKeys.detail(message.dashboard)
: deviceKeys.all,
})
if (message.dashboard)
client?.invalidateQueries({
queryKey: dashboardKeys.detail(message.dashboard),
})
else if (client) refetchDevices(client, false)
break
case "device_status":
// A device's socket opened or closed: the Devices tab reads presence
// off the list.
client?.invalidateQueries({ queryKey: deviceKeys.all, exact: true })
if (client) refetchDevices(client)
break
case "run_started":
case "run_finished":