diff --git a/apps/control-station/test/k1ManualControl.test.mjs b/apps/control-station/test/k1ManualControl.test.mjs new file mode 100644 index 0000000..121bc3e --- /dev/null +++ b/apps/control-station/test/k1ManualControl.test.mjs @@ -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); +}); diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 92f05de..ac02df2 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -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 diff --git a/docs/audits/2026-09-07-k1-systemd-authority-and-manual-control-r9.md b/docs/audits/2026-09-07-k1-systemd-authority-and-manual-control-r9.md new file mode 100644 index 0000000..5cd7f14 --- /dev/null +++ b/docs/audits/2026-09-07-k1-systemd-authority-and-manual-control-r9.md @@ -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. diff --git a/packages/sensor-ui/src/SensorWorkspace.tsx b/packages/sensor-ui/src/SensorWorkspace.tsx index 50c6070..2e8d69a 100644 --- a/packages/sensor-ui/src/SensorWorkspace.tsx +++ b/packages/sensor-ui/src/SensorWorkspace.tsx @@ -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={}){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
  • } title={item.name} description={item.model} metadata={{item.connection_label||`USB ${item.usb}`}} 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={{configured&&item.online?null:label}} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&void action(item,'prepare')}>}{setEditing(item);setName(item.name);}}>setSelected(item.id)}>}/>
  • ;})}} + const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label; + return
  • } title={item.name} description={item.model} metadata={{item.connection_label||`USB ${item.usb}`}} 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={{configured&&item.online?null:label}} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&void action(item,'prepare')}>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&{setEditing(item);setName(item.name);}}>}setSelected(item.id)}>}/>
  • ;})}} {(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&&{inventory.preparation.steps.map(step=>:step.state==='running'?:{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}}/>) }:Ожидает}/>} } setEditing(null)} footer={}>
    setName(e.target.value)} disabled={!!localBusy}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить}/>}
    - {adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(false)} onChange={()=>{void refresh();}}/>} + {adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>} setError('')}/> ; } diff --git a/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx b/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx index e2936e8..61a12d4 100644 --- a/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx +++ b/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx @@ -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 ); - return Enrollment?:renderWindow({content:null}); + return Enrollment?:renderWindow({content:null}); } diff --git a/packages/sensor-ui/src/contracts.ts b/packages/sensor-ui/src/contracts.ts index dbb0d72..a314640 100644 --- a/packages/sensor-ui/src/contracts.ts +++ b/packages/sensor-ui/src/contracts.ts @@ -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; 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; diff --git a/packages/sensor-ui/src/extensions.ts b/packages/sensor-ui/src/extensions.ts index d5c170a..991a37b 100644 --- a/packages/sensor-ui/src/extensions.ts +++ b/packages/sensor-ui/src/extensions.ts @@ -11,6 +11,7 @@ export interface SensorDetailProps { } export interface SensorEnrollmentProps { transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void; + onComplete:(sessionId:string,signal?:AbortSignal)=>Promise; 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}; } diff --git a/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx b/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx index 36165a7..a4e360f 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx +++ b/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx @@ -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(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?:undefined, + aria-busy={busy==='opening'} icon={busy==='opening'?:undefined} + onClick={()=>void configure()}>Настроить устройство:undefined, content:
    diff --git a/plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx b/plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx index 36ea207..9e1d41f 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx +++ b/plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx @@ -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;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
    -
    {streaming?'Живой просмотр':'K1 · Bridge'}setExpanded(value=>!value)}>
    - -
    setSettings(v=>!v)}>
    +
    {status.label}{streaming&&setExpanded(value=>!value)}>}
    + setSettings(value=>!value)}>}> + {manual.connected&&!manual.showStop&&} + {manual.showStop&&} + {!manual.connected&& + {device.control?.can_verify&&} + } - {settings&&} - {streaming&&createRerunHost&&} + {manual.connected&&settings&&} + {!streaming&&(busy==='start'||manual.active)&&} + {streaming&&createRerunHost&&setGeneration(value=>value+1)}/>}
    ; } diff --git a/plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx b/plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx index 33547cb..2b68a2a 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx +++ b/plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx @@ -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(null),video=useRef(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
    {error?:!ready&&}
    {cameraError&&}
    ; + return
    {error?:!ready&&}{(error||cameraError)&&}
    {cameraError&&}
    ; } diff --git a/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts b/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts index 5a838f2..7e8d123 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts +++ b/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts @@ -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; diff --git a/plugins/xgrids-k1/frontend/src/sensors/plugin.ts b/plugins/xgrids-k1/frontend/src/sensors/plugin.ts index 5720fac..da67d80 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/plugin.ts +++ b/plugins/xgrids-k1/frontend/src/sensors/plugin.ts @@ -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}, }; diff --git a/plugins/xgrids-k1/frontend/src/sensors/presentation.ts b/plugins/xgrids-k1/frontend/src/sensors/presentation.ts new file mode 100644 index 0000000..f109932 --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/sensors/presentation.ts @@ -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}; +} diff --git a/plugins/xgrids-k1/packaging/build_deb.py b/plugins/xgrids-k1/packaging/build_deb.py index d7ba96d..fcde295 100644 --- a/plugins/xgrids-k1/packaging/build_deb.py +++ b/plugins/xgrids-k1/packaging/build_deb.py @@ -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", diff --git a/src/k1link/device_plugins/xgrids_k1/linux_host.py b/src/k1link/device_plugins/xgrids_k1/linux_host.py index 73184f9..a1f48f1 100644 --- a/src/k1link/device_plugins/xgrids_k1/linux_host.py +++ b/src/k1link/device_plugins/xgrids_k1/linux_host.py @@ -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) diff --git a/src/k1link/device_plugins/xgrids_k1/node_bridge.py b/src/k1link/device_plugins/xgrids_k1/node_bridge.py index 9fc1a24..6848eb1 100644 --- a/src/k1link/device_plugins/xgrids_k1/node_bridge.py +++ b/src/k1link/device_plugins/xgrids_k1/node_bridge.py @@ -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 не подтверждено. Обновите состояние устройства.", diff --git a/src/k1link/device_plugins/xgrids_k1/node_sensor.py b/src/k1link/device_plugins/xgrids_k1/node_sensor.py index 7523030..1f78572 100644 --- a/src/k1link/device_plugins/xgrids_k1/node_sensor.py +++ b/src/k1link/device_plugins/xgrids_k1/node_sensor.py @@ -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", {}), diff --git a/tests/test_linux_application_authority.py b/tests/test_linux_application_authority.py new file mode 100644 index 0000000..c0c251a --- /dev/null +++ b/tests/test_linux_application_authority.py @@ -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() diff --git a/tests/test_node_k1_bridge.py b/tests/test_node_k1_bridge.py index 6c12144..82c41cb 100644 --- a/tests/test_node_k1_bridge.py +++ b/tests/test_node_k1_bridge.py @@ -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()