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
+10
View File
@@ -3257,6 +3257,11 @@ export const PairStartedSchema = {
type: 'string',
title: 'Secret'
},
check: {
type: 'string',
title: 'Check',
default: ''
},
expires_in: {
type: 'integer',
title: 'Expires In',
@@ -3317,6 +3322,11 @@ export const PendingDeviceSchema = {
title: 'Request',
default: ''
},
check: {
type: 'string',
title: 'Check',
default: ''
},
device: {
type: 'string',
title: 'Device'
+8 -6
View File
@@ -812,8 +812,9 @@ export class DevicesService {
* All this hands out is a code that means nothing until somebody with an
* account approves it, so the worst an unwelcome caller achieves is an entry
* that expires ten minutes later. Reachable from the internet when this
* instance is enrolled with a portal, which is what the cap and the portal's
* own per-address limits are between.
* instance is enrolled with a portal, which is what the per-address limit,
* the cap and the portal's own limits are between. At the cap the oldest
* request makes room, so a flood delays a device rather than locking it out.
* @param data The data for the request.
* @param data.requestBody
* @returns PairStarted Successful Response
@@ -1016,7 +1017,8 @@ export class DevicesService {
*
* The first report of a touch screen makes the device touch friendly, as
* the switch would have been set by hand. Later ones leave the setting to
* whoever changed it since.
* whoever changed it since, and so does the first report of a device paired
* before pairings were noted: its switch was set by hand already.
* @param data The data for the request.
* @param data.deviceId
* @returns Message Successful Response
@@ -1065,9 +1067,9 @@ export class DevicesService {
*
* The screen hanging there is refused on its next request and goes back to
* showing a pairing code, while its dashboards and their arrangement stay.
*
* A credential the portal minted for a remote screen carries no nonce, so
* this does not reach it. That one is revoked at the hub.
* The same holds for one the portal minted. An older portal minted without
* the nonce, and such a credential stands only while the nonce is still 0,
* so this refuses it too.
* @param data The data for the request.
* @param data.deviceId
* @returns Message Successful Response
+2
View File
@@ -1093,6 +1093,7 @@ export type PairRequest = {
export type PairStarted = {
code: string;
secret: string;
check?: string;
expires_in?: number;
};
@@ -1110,6 +1111,7 @@ export type PairStatus = {
*/
export type PendingDevice = {
request?: string;
check?: string;
device: string;
name?: string;
board?: string;
@@ -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":
+15
View File
@@ -170,6 +170,21 @@ function PairPanel() {
) : null}
</div>
{/* Through the portal only the code pairs, so there is no list to
pick this screen from. */}
{started?.check && !isPortal() ? (
<p className="text-sm text-muted-foreground">
Picked from the waiting list instead? Its check is{" "}
<span
className="font-mono font-medium text-foreground"
data-testid="pairing-check"
>
{started.check}
</span>
.
</p>
) : null}
<p className="text-sm text-muted-foreground">
The code lasts ten minutes; this screen replaces it when it runs out.
</p>