Accept systemd credential delivery and clarify manual K1 control

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 14:44:46 +03:00
parent 102e93796b
commit a93f1f1b8b
19 changed files with 402 additions and 31 deletions
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
let server,Detail,presentation,enrolledDevice;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({K1Detail:Detail}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/K1Detail.tsx'));
presentation=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/presentation.ts');
({enrolledDevice}=await server.ssrLoadModule('../../packages/sensor-ui/src/contracts.ts'));
});
after(async()=>{await server?.close();});
const device={id:'synthetic',kind:'k1',name:'K1 test',online:true,verified:true,prepared:true,
control:{can_start:true,can_stop:false,can_verify:true,generation:1,revision:1,acquisition_id:null},
snapshot:{context:{session_id:'exact-session',execution:{node_id:'test'}},acquisition:'idle'}};
const render=(value,enabled=true)=>renderToStaticMarkup(createElement(Detail,{device:value,enabled,
transport:{},back:()=>{},refresh:async()=>{},failure:()=>{},createRerunHost:()=>assert.fail('SSR must not start a viewer or hardware')}));
test('manual K1 surface admits one START only after current control proof',()=>{
const markup=render(device);
assert.match(markup,/Инициировать запуск/);
assert.match(markup,/Настройки устройства/);
assert.doesNotMatch(markup,/Остановить устройство|Обновить просмотр|sensor-live-layout/);
const waiting={...device,online:false,verified:false,control:{...device.control,can_start:false,network_applied:true,reason_code:'application_authority_unavailable'}};
const pending=render(waiting);
assert.doesNotMatch(pending,/Инициировать запуск|Настройки устройства|sensor-live-layout/);
assert.match(pending,/Проверить состояние K1/);
assert.match(pending,/авторизовать/);
assert.equal(presentation.k1Status(waiting,true).label,'Wi-Fi настроен · нет управления');
assert.equal(presentation.k1ManualState(device,false).canStart,false);
});
test('active acquisition exposes STOP and Rerun without another START',()=>{
const active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}};
const markup=render(active);
assert.match(markup,/Остановить устройство/);
assert.match(markup,/sensor-live-layout/);
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
assert.equal(presentation.k1ManualState(active,false).canStart,false);
assert.equal(presentation.k1ManualState(active,false).showStop,true);
});
test('enrollment handoff opens only the verified current session',()=>{
const inventory={fresh:true,items:[device],operations:[]};
assert.equal(enrolledDevice(inventory,'exact-session'),device);
assert.equal(enrolledDevice(inventory,'other-session'),null);
assert.equal(enrolledDevice({...inventory,fresh:false},'exact-session'),null);
assert.equal(enrolledDevice({...inventory,items:[{...device,verified:false}]},'exact-session'),null);
assert.equal(enrolledDevice({...inventory,items:[device,device]},'exact-session'),null);
});
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.6"
VERSION = "0.8.7"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package
@@ -0,0 +1,76 @@
# Systemd authority and manual K1 control R9
The owner's new attempt on installed Node 0.8.6 / K1 0.1.5+private.1 passed
Bluetooth provisioning and reported network_applied. DeviceInfo then failed
with application_authority_unavailable, before the MQTT transport opened.
The inventory row represents the admitted device session and known address;
it is not proof of a healthy control channel or permission to issue START.
Private screenshots, exact operation and a timestamped/hash manifest are in
private/acceptance/k1-node086-20260907-manual-control. Cache clearing for the
owner attempt has not been independently confirmed. No agent hardware writes
or CLI Bluetooth/MQTT tests were performed.
## Credential delivery contract
The installed systemd 255 credential directory is root-owned, mode 0550,
with named service-user rx ACL, group permissions empty and other permissions
empty. The ordinary SSH user cannot traverse it to inspect the credential
leaf, and no permission bypass was attempted.
[systemd v255 write_credential](https://github.com/systemd/systemd/blob/v255/src/core/exec-credential.c#L150)
creates root-owned files and preferentially grants the service UID a read ACL;
ownership transfer is a fallback. The ACL mask appears as group-read in stat,
as also documented in [systemd issue 29435](https://github.com/systemd/systemd/issues/29435).
The previous Linux loader required service ownership and rejected every group
mode bit, so it rejected this valid delivery model. A local regression with
real bounded file reads and synthetic kernel ownership/mode reproduces that
rejection before the fix. The observed directory matches the systemd scheme;
successful loading on the actual installed service remains an acceptance gate.
The loader now opens a non-symlink credential directory and opens the fixed
leaf relative to its pinned directory descriptor. Root-owned systemd delivery
may use the read ACL mask; service-owned private fallback remains supported.
Untrusted ownership, group-write, world permissions, execute bits, symlinks,
nonregular files and oversized input are rejected. No chmod, chown, ACL change,
new key, network action or alternate secret source is used. The existing key
is retained. A cached startup availability boolean reports whether the
immutable service credential could be loaded; it grants no device authority.
## Manual operation surface
The approved device-detail composition now has one Initiate start action when
current control permits it. Settings uses the canonical SettingsCard actions
slot at the right. STOP is presented for an active acquisition; initial idle
STOP and Refresh viewer buttons are absent. A pending launch indicates progress,
and the existing live Rerun view mounts once the acquisition is streaming. If
the preview transport fails, its local recovery action is available in that
failure state and does not repeat physical START.
The enrollment completion action is Configure device. The generic host fetches
fresh inventory and opens only the uniquely matching verified session; stale,
ambiguous and disconnected sessions cannot complete this handoff. The K1
contribution supplies its own network/control status and disables the generic
rename affordance, which this driver does not implement. A pending Wi-Fi
session is labelled Wi-Fi configured / no control and explains authorization
failure. Controls remain unavailable before connection proof. A read-only
Check K1 state action is available where the backend permits recovery.
No Rerun profile, live blueprint, playback or LAB configuration changed.
The existing plugin control-operation budget, journal identities, physical
acceptance and restart/link-recovery fences remain in place. Sensor-operation
failures now use the same secret-free causal logger as enrollment failures.
## Validation
36 Python checks cover the credential contract, startup availability without
control authority, NodeBridge projection/actions and package lifecycle.
26 focused UI/architecture checks and all 797 Core frontend tests passed;
Core TypeScript and production build passed. The new UI tests exercise the
actual detail renderer for waiting, ready and streaming states, and the exact
session handoff. Scoped Ruff and diff whitespace checks passed.
Node 0.8.7 / optional K1 0.1.6 are reserved for this source. Packaging, installed
credential loading, DeviceInfo, physical START/STOP and live stream verification
must be recorded separately as observed. The connected browser tooling exposes
only an empty in-app browser, not the owner's Chrome or its cache controls;
fresh-cache physical UI acceptance remains an owner step.
+12 -4
View File
@@ -1,6 +1,6 @@
import {useCallback,useEffect,useState} from 'react';
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
import {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
import {SensorDetail} from './SensorDetail';
import {sensorStatus} from './sensorStatus';
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
@@ -22,6 +22,14 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
},[transport,enabled,refresh]);
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
async function completeEnrollment(sessionId:string,signal?:AbortSignal){
if(!enabled)throw new Error('БК недоступен. Обновите состояние устройства.');
const value=await transport.inventory();
if(signal?.aborted)return;
const target=enrolledDevice(value,sessionId);
if(!target)throw new Error('Связь с устройством пока не подтверждена. Проверьте его состояние.');
setInventory(value);setFresh(true);setSelected(target.id);setAdding(false);
}
const device=inventory?.items.find(v=>v.id===selected);
const connected=inventory?.items.filter(v=>v.online||sensorContribution(contributions,v)?.retainOffline)??[];
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
@@ -33,12 +41,12 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
const configured=item.configured??item.snapshot.enrollment==='enrolled';
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
const status=sensorStatus(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
</>}
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}}/>}
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
</div>;
}
@@ -2,9 +2,10 @@ import {useState} from 'react';
import {Select,Window,WindowFooterActions} from '@nodedc/ui-react';
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange}:{
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete}:{
contributions:readonly SensorUiContribution[];
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
onComplete:SensorEnrollmentProps['onComplete'];
}) {
const [selected,setSelected]=useState('');
const supported=wirelessContributions(contributions);
@@ -21,5 +22,5 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
</div>
</Window>
);
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} renderWindow={renderWindow}/>:renderWindow({content:null});
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} onComplete={onComplete} renderWindow={renderWindow}/>:renderWindow({content:null});
}
+7 -1
View File
@@ -1,7 +1,7 @@
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
export interface Sensor {
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;acquisition_id:string|null};
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null};
live_settings?: Record<string,unknown>;
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
@@ -13,6 +13,12 @@ export interface SensorInventory {
fresh?: boolean; items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
}
export function enrolledDevice(inventory:SensorInventory,sessionId:string):Sensor|null {
if(inventory.fresh===false||!sessionId)return null;
const matches=inventory.items.filter(item=>item.snapshot.context.session_id===sessionId);
const item=matches.length===1?matches[0]:null;
return item?.online&&item.verified&&item.prepared?item:null;
}
export interface SensorCommand {
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
session: {session_id:string;device_id:string}; action_id:string; requested_at:string; deadline_at:string;
+3
View File
@@ -11,6 +11,7 @@ export interface SensorDetailProps {
}
export interface SensorEnrollmentProps {
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void;
onComplete:(sessionId:string,signal?:AbortSignal)=>Promise<void>;
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
}
export interface SensorUiContribution {
@@ -19,6 +20,8 @@ export interface SensorUiContribution {
icon:IconName;
retainOffline:boolean;
supportsPreparation:boolean;
supportsRenaming?:boolean;
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
}
@@ -4,7 +4,7 @@ import type {SensorEnrollmentProps} from '@mission-core/sensor-sdk';
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,enrollmentBluetoothFailure,mergeEnrollmentState,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment';
import {revealEnrollment} from './revealEnrollment';
export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}:SensorEnrollmentProps){
export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWindow}:SensorEnrollmentProps){
const [state,setState]=useState<EnrollmentState|null>(null);
const [device,setDevice]=useState('');
const [ssid,setSSID]=useState('');
@@ -106,9 +106,19 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
}
}
async function configure(){
if(pending||!enrollmentProofCurrent(verified,state,device)||!verified)return;
setBusy('opening');setError('');
const signal=lifetime.current?.signal;
try{await onComplete(verified.session,signal);}
catch(cause){if(!signal?.aborted)setError(cause instanceof Error?cause.message:'Не удалось открыть устройство.');}
finally{if(!signal?.aborted)setBusy('');}
}
return renderWindow({busy:pending,
actions:confirmed?<Button variant="primary" disabled={pending}
onClick={()=>{if(enrollmentProofCurrent(verified,state,device)){onChange();onClose();}}}>Подключить</Button>:undefined,
aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
content:<div className="sensor-content" ref={content}>
<SettingsCard title={state?.name||'Бортовой компьютер'}
description="K1 подключится к выбранной сети Wi-Fi рядом с БК. БК может работать через Ethernet или Wi-Fi; сеть компьютера оператора может отличаться.">
@@ -1,25 +1,37 @@
import {useEffect,useState} from 'react';
import {Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import {useEffect,useRef,useState} from 'react';
import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './runtime';
import {K1LiveView} from './K1LiveView';
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
import {K1LiveSettings} from './K1LiveSettings';
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation';
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
const [busy,setBusy]=useState(false),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false);
const [busy,setBusy]=useState(''),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false);
const running=useRef(false);
const streaming=device.snapshot.acquisition==='streaming';
const manual=k1ManualState(device,enabled),status=k1Status(device,enabled);
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
async function act(action:'start'|'stop'|'verify'){
if(busy)return;setBusy(true);failure(null);
if(running.current||!enabled)return;
running.current=true;setBusy(action);failure(null);
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
catch(e){failure(e);}finally{setBusy(false);}
catch(e){failure(e);}finally{running.current=false;setBusy('');}
}
return <div className={expanded?'sensor-content sensor-viewer-expanded':'sensor-content'}>
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><div className="sensor-actions"><StatusBadge tone={streaming&&enabled?'success':'neutral'}>{streaming?'Живой просмотр':'K1 · Bridge'}</StatusBadge><IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></div>
<SettingsCard title={device.name} description="Камера и лидар работают на бортовом компьютере. Исходные данные записи сохраняются на БК.">
<div className="sensor-actions"><Button disabled={!enabled||busy||streaming||!device.control?.can_start} onClick={()=>void act('start')}>Начать просмотр</Button><Button disabled={!enabled||busy||(!streaming&&!device.control?.can_stop)} onClick={()=>void act('stop')}>Остановить</Button><Button disabled={!enabled||busy} onClick={()=>{void refresh();setGeneration(v=>v+1);}}>Обновить просмотр</Button><IconButton label="Настройки живого просмотра" onClick={()=>setSettings(v=>!v)}><Icon name="settings"/></IconButton></div>
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><div className="sensor-actions"><StatusBadge tone={status.tone}>{status.label}</StatusBadge>{streaming&&<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}</div></div>
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!busy} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
{manual.connected&&!manual.showStop&&<Button variant="primary" disabled={!!busy||!manual.canStart} aria-busy={busy==='start'}
icon={busy==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{busy==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
{manual.showStop&&<Button disabled={!manual.connected||!!busy||(!streaming&&!device.control?.can_stop)} aria-busy={busy==='stop'}
icon={busy==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{busy==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
{!manual.connected&&<SettingsCard align="center" title={k1ConnectionNotice(device,enabled)}>
{device.control?.can_verify&&<Button disabled={!enabled||!!busy} aria-busy={busy==='verify'} icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
</SettingsCard>}
</SettingsCard>
{settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost}/>}
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!busy} refresh={refresh} failure={failure}/>}
{!streaming&&(busy==='start'||manual.active)&&<ActivityIndicator label="Запускаем K1 и ожидаем живые данные"/>}
{streaming&&createRerunHost&&<K1LiveView key={generation} device={device} transport={transport} createRerunHost={createRerunHost} onReconnect={()=>setGeneration(value=>value+1)}/>}
</div>;
}
@@ -1,5 +1,5 @@
import {useEffect,useRef,useState} from 'react';
import {ActivityIndicator,SettingsCard} from '@nodedc/ui-react';
import {ActivityIndicator,Button,SettingsCard} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './runtime';
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
@@ -12,7 +12,7 @@ function privateCandidate(sdp:string):string{
}).join('\r\n');
}
export function K1LiveView({device,transport,createRerunHost}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory}){
export function K1LiveView({device,transport,createRerunHost,onReconnect}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onReconnect:()=>void}){
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
const [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false);
useEffect(()=>{
@@ -59,5 +59,5 @@ export function K1LiveView({device,transport,createRerunHost}:{device:Sensor;tra
void run().catch(()=>fail('Не удалось открыть живой просмотр K1. Обновите состояние устройства.'));
return()=>{active=false;clearInterval(keepalive);clearInterval(follow);rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.close();try{closeChannel?.();}finally{host.dispose();}if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});if(mediaURL)URL.revokeObjectURL(mediaURL);};
},[device.snapshot.context.session_id,transport,createRerunHost]);
return <div className="sensor-content">{error?<SettingsCard title={error}/>:!ready&&<ActivityIndicator label="Получаем живые данные K1"/>}<div className="sensor-live-layout"><div className="sensor-live-spatial" ref={spatial}/><div className="sensor-content">{cameraError&&<SettingsCard title={cameraError}/>}<video className="sensor-media" ref={video} muted autoPlay playsInline aria-label="Камера K1"/></div></div></div>;
return <div className="sensor-content">{error?<SettingsCard title={error}/>:!ready&&<ActivityIndicator label="Получаем живые данные K1"/>}{(error||cameraError)&&<Button onClick={onReconnect}>Восстановить просмотр</Button>}<div className="sensor-live-layout"><div className="sensor-live-spatial" ref={spatial}/><div className="sensor-content">{cameraError&&<SettingsCard title={cameraError}/>}<video className="sensor-media" ref={video} muted autoPlay playsInline aria-label="Камера K1"/></div></div></div>;
}
@@ -119,6 +119,7 @@ export function enrollmentNotice(state:EnrollmentState):string {
if(enrollmentBluetoothFailure(state))return 'Не удалось установить связь с K1 по Bluetooth. Настройки Wi-Fi не были отправлены. Найдите K1 ещё раз и выберите его из списка.';
if(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.';
if(code&&STATION_WIFI_FAILURE_MESSAGES[code])return STATION_WIFI_FAILURE_MESSAGES[code];
if(code==='application_authority_unavailable')return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
if(state.connected)return 'K1 подключён к БК.';
if(attempt?.phase==='network_applied')return 'K1 подключился к Wi-Fi. Связь с БК пока не подтверждена. Нажмите «Проверить состояние K1»; повторно вводить сеть и пароль не нужно.';
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
@@ -1,8 +1,10 @@
import type {SensorUiContribution} from '@mission-core/sensor-sdk';
import {K1Detail} from './K1Detail';
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
import {k1Status} from './presentation';
export const xgridsK1SensorUi:SensorUiContribution={
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
supportsRenaming:false,status:k1Status,
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
};
@@ -0,0 +1,28 @@
import type {Sensor} from './runtime';
export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'};
if(!device.online||!device.verified)return device.control?.network_applied
?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'};
if(device.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'};
if(device.snapshot.acquisition==='streaming')return {label:'Идёт захват',tone:'success'};
if(['preparing','starting'].includes(device.snapshot.acquisition))return {label:'Запускается',tone:'neutral'};
if(device.snapshot.acquisition==='stopping')return {label:'Останавливается',tone:'neutral'};
return {label:device.control?.can_start?'Готов к запуску':'Подключён',tone:'success'};
}
export function k1ConnectionNotice(device:Sensor,fresh:boolean):string {
if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.';
if(device.control?.reason_code==='application_authority_unavailable')
return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
return device.control?.network_applied
?'Wi-Fi настроен. Канал управления K1 пока не подтверждён. Проверьте состояние устройства.'
:'Связь с K1 пока не подтверждена. Проверьте питание устройства и подключение к сети.';
}
export function k1ManualState(device:Sensor,fresh:boolean) {
const active=['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition);
const connected=fresh&&device.online&&device.verified;
return {active,connected,canStart:connected&&!active&&device.control?.can_start===true,
showStop:active||device.control?.can_stop===true};
}
+1 -1
View File
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
from debian import package # noqa: E402
from runtime_payload import files as runtime_files # noqa: E402
VERSION = "0.1.5"
VERSION = "0.1.6"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",
@@ -191,14 +191,31 @@ class LinuxApplicationAuthorityLoader:
os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials/mission-core-k1.service")
)
buffer = bytearray()
directory_fd = None
try:
fd = os.open(directory / "k1-application", os.O_RDONLY | os.O_NOFOLLOW)
directory_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
directory_info = os.fstat(directory_fd)
if (
directory_info.st_uid not in {0, os.geteuid()}
or directory_info.st_mode & 0o027
):
raise ValueError("Invalid credential directory")
fd = os.open(
"k1-application", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
dir_fd=directory_fd,
)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
# systemd prefers root ownership + a service-UID read ACL.
# Its ACL mask appears as group-read in st_mode (0440).
# The trusted root-owned directory/file and kernel access check
# own that authorization; do not mistake the mask for exposure.
acl_delivered = info.st_uid == 0 and directory_info.st_uid == 0
if (
not stat.S_ISREG(info.st_mode)
or info.st_uid != os.geteuid()
or info.st_mode & 0o077
or info.st_uid not in {0, os.geteuid()}
or info.st_mode & (0o037 if acl_delivered else 0o077)
or info.st_mode & 0o111
):
raise ValueError("Invalid credential permissions")
buffer.extend(stream.read(1025))
@@ -207,8 +224,10 @@ class LinuxApplicationAuthorityLoader:
return ApplicationControlAuthority(openapi_key=buffer.decode("ascii").strip())
except (OSError, ValueError, UnicodeError):
raise ApplicationAuthorityLoadError(
"Служебный ключ K1 не установлен на БК.",
"Службе K1 на БК не удалось загрузить служебный ключ.",
reason_code="application_authority_unavailable",
) from None
finally:
if directory_fd is not None:
os.close(directory_fd)
buffer[:] = b"\0" * len(buffer)
@@ -29,6 +29,7 @@ from .facade import (
_validate_installed_compatibility_profile,
)
from .linux_host import LinuxApplicationAuthorityLoader, LinuxWifiAssociationProbe, wifi_networks
from .protocol.application_authority import ApplicationAuthorityLoadError
ATTESTATION = {
"firmware_version": "3.0.2",
@@ -94,11 +95,20 @@ def node_operation_id(plugin_id: str | None) -> str | None:
class NodeBridge:
def __init__(self, repository_root: Path, *, service=None):
self.rerun = NodeRerunHub()
self.application_authority_available = None
if service is None:
_validate_installed_compatibility_profile(repository_root)
loader = LinuxApplicationAuthorityLoader()
# systemd's credential mount is immutable for this service lifetime.
# This startup check proves loading, not K1 authentication or control.
try:
loader.load()
self.application_authority_available = True
except ApplicationAuthorityLoadError:
self.application_authority_available = False
service = XgridsK1CompatibilityService(
repository_root,
application_authority_loader=LinuxApplicationAuthorityLoader(),
application_authority_loader=loader,
host_wifi_association_probe=LinuxWifiAssociationProbe(),
visualization_bridge_factory=self.rerun.create,
)
@@ -108,7 +118,10 @@ class NodeBridge:
async def state(self) -> dict:
snapshot = await self.invoke("state.read", {}, "state-read")
return self.project(snapshot)
result = self.project(snapshot)
if self.application_authority_available is not None:
result["application_authority_available"] = self.application_authority_available
return result
@staticmethod
def project(snapshot: dict) -> dict:
@@ -343,7 +356,11 @@ def create_app(repository_root: Path):
raise ValueError("Command expired")
result = await sensor.execute(command, request.headers["X-Node-Id"])
return {"state": "complete", "result": result}
except Exception:
except Exception as error:
logging.getLogger(__name__).warning(
"K1 sensor operation failed: exception=%s chain=%s",
type(error).__name__, failure_locations(error),
)
return {
"state": "unknown",
"error": "Действие K1 не подтверждено. Обновите состояние устройства.",
@@ -64,6 +64,7 @@ def project_sensor(snapshot, node_id):
"opened_at": session["opened_at"],
}
control = snapshot.get("application_control_session") or {}
attempt = snapshot.get("connection_attempt") or {}
return {
"id": identifier,
"name": "XGRIDS K1",
@@ -90,6 +91,12 @@ def project_sensor(snapshot, node_id):
"phase": control.get("state"),
"can_start": lifecycle.get("ready_to_start", False),
"can_stop": control.get("state") in {"start-requested", "initializing", "scanning"},
"can_verify": any(action in lifecycle.get("allowed_actions", []) for action in (
"verify-control-read-only", "observe-current-device-network",
"observe-configured-device-network", "observe-fresh-device-network",
)),
"network_applied": connected or attempt.get("phase") == "network_applied",
"reason_code": None if connected else attempt.get("public_error_code"),
"acquisition_id": acquisition.get("acquisition_id"),
},
"live_settings": snapshot.get("viewer_settings", {}),
+77
View File
@@ -0,0 +1,77 @@
import os
import secrets
import stat
import pytest
from k1link.device_plugins.xgrids_k1.linux_host import LinuxApplicationAuthorityLoader
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
ApplicationAuthorityLoadError,
)
@pytest.mark.parametrize("owner,mode,allowed", [
("root", 0o440, True), # systemd v255: root-owned file and service UID read ACL
("root", 0o400, True),
("service", 0o400, True),
("service", 0o600, True),
("root", 0o444, False),
("root", 0o460, False),
("service", 0o440, False),
("other", 0o400, False),
])
def test_systemd_credential_ownership_contract(tmp_path, monkeypatch, owner, mode, allowed):
secret = secrets.token_hex(18)
path = tmp_path / "k1-application"
path.write_text(secret)
path.chmod(0o600)
file_inode, directory_inode = path.stat().st_ino, tmp_path.stat().st_ino
original = os.fstat
def delivered_metadata(fd):
result = original(fd)
fields = list(result)
if result.st_ino == file_inode:
fields[0] = stat.S_IFREG | mode
fields[4] = {"root": 0, "service": os.geteuid(), "other": os.geteuid()+1}[owner]
elif result.st_ino == directory_inode:
fields[0] = stat.S_IFDIR | 0o550
fields[4] = 0
return os.stat_result(fields)
# Read actual bounded file bytes; replace only kernel metadata unavailable
# on macOS. This is the documented systemd credential ACL representation.
monkeypatch.setattr(os, "fstat", delivered_metadata)
loader = LinuxApplicationAuthorityLoader(tmp_path)
if allowed:
assert loader.load().openapi_key == secret
else:
with pytest.raises(ApplicationAuthorityLoadError) as failure:
loader.load()
assert secret not in str(failure.value)
@pytest.mark.parametrize(
"kind", ["missing", "file-symlink", "directory-symlink", "fifo", "oversized"],
)
def test_credential_loader_rejects_nonregular_or_unbounded_input(tmp_path, kind):
directory = tmp_path / "credentials"
directory.mkdir(mode=0o700)
path = directory / "k1-application"
if kind == "file-symlink":
target = tmp_path / "target"
target.write_text(secrets.token_hex(18))
path.symlink_to(target)
elif kind == "directory-symlink":
path.write_text(secrets.token_hex(18))
path.chmod(0o600)
link = tmp_path / "link"
link.symlink_to(directory, target_is_directory=True)
directory = link
elif kind == "fifo":
os.mkfifo(path, 0o600)
elif kind == "oversized":
path.write_text(secrets.token_hex(1025))
path.chmod(0o600)
with pytest.raises(ApplicationAuthorityLoadError):
LinuxApplicationAuthorityLoader(directory).load()
+54
View File
@@ -70,6 +70,39 @@ def bridge():
return result
@pytest.mark.parametrize("available", [False, True])
def test_startup_credential_check_does_not_authorize_control(monkeypatch, available):
from k1link.device_plugins.xgrids_k1 import node_bridge
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
ApplicationAuthorityLoadError,
)
calls = []
class Loader:
def load(self):
calls.append("load")
if not available:
raise ApplicationAuthorityLoadError("Unavailable")
return object()
monkeypatch.setattr(node_bridge, "_validate_installed_compatibility_profile", lambda _: None)
monkeypatch.setattr(node_bridge, "LinuxApplicationAuthorityLoader", Loader)
monkeypatch.setattr(node_bridge, "XgridsK1CompatibilityService", lambda *a, **kw: object())
device = NodeBridge(Path.cwd())
device.facade = Facade()
device.facade.current["connection_lifecycle"] = {"connection_ready": False}
async def read():
for _ in range(2):
snapshot = await device.state()
assert snapshot["application_authority_available"] is available
assert snapshot["connected"] is False
asyncio.run(read())
assert calls == ["load"]
def test_journalled_failure_logs_source_without_secret_and_does_not_repeat(caplog):
from bleak.exc import BleakError
@@ -352,6 +385,27 @@ def test_sensor_projection_binds_native_sdk_to_board():
assert value["kind"] == "k1"
def test_applied_wifi_does_not_grant_control_or_start_authority():
current = state()
current["connection_lifecycle"] = {
"connection_ready": False, "ready_to_start": False,
"allowed_actions": ["verify-control-read-only"],
}
current["connection_attempt"] = {
"phase": "network_applied", "public_error_code": "application_authority_unavailable",
}
item = project_sensor(current, "node-test")
assert not item["online"] and not item["verified"]
assert item["control"]["network_applied"]
assert item["control"]["reason_code"] == "application_authority_unavailable"
assert not item["control"]["can_start"]
assert item["control"]["can_verify"]
current["connection_lifecycle"].update(connection_ready=True, ready_to_start=True)
item = project_sensor(current, "node-test")
assert item["online"] and item["verified"] and item["control"]["can_start"]
assert item["control"]["reason_code"] is None
def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
async def run():
device = bridge()