Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
020a878915 | ||
|
|
404285419f | ||
|
|
ba0c948ca1 | ||
|
|
429b4681fe | ||
|
|
22d69107ba | ||
|
|
a8647c4d87 | ||
|
|
b1aaa40508 | ||
|
|
3616acc648 | ||
|
|
e82d012907 | ||
|
|
fc545f8440 | ||
|
|
a98ef339de | ||
|
|
9062126913 | ||
|
|
59e14c5bc0 | ||
|
|
15bb793e5e | ||
|
|
79911eb316 | ||
|
|
d696842f5d |
@@ -10,7 +10,6 @@ import {
|
||||
ControlRow,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
@@ -629,7 +628,10 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
|
||||
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
|
||||
const contentActions = useApplicationPanelActions({
|
||||
onAddVehicle,
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||
@@ -645,8 +647,6 @@ export default function App() {
|
||||
brandHref="/"
|
||||
brandLabel="NODEDC MISSION CORE"
|
||||
center={
|
||||
<>
|
||||
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
|
||||
<HeaderNavigation
|
||||
label="Архитектурные блоки пункта управления"
|
||||
value={activeRoot ?? undefined}
|
||||
@@ -656,7 +656,6 @@ export default function App() {
|
||||
}))}
|
||||
onChange={selectRoot}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
@@ -802,7 +801,7 @@ export default function App() {
|
||||
settleRecordedReplaySwitch(outcome)}
|
||||
onDeleteBegin={releaseRecordedReplayForDelete}
|
||||
/>
|
||||
) : activeDefinition.kind === "datasets" ? (
|
||||
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
laboratoryAnnotation.control
|
||||
@@ -828,6 +827,7 @@ export default function App() {
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
fleetCreateRequest={fleetCreateRequest}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkspaceDefinition } from "../productModel";
|
||||
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
onAddVehicle?: () => void;
|
||||
refreshRuntime: () => void;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
connectionScenarioResetting: boolean;
|
||||
@@ -43,6 +44,7 @@ export function deviceRuntimeUtilityAction({
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
@@ -52,6 +54,7 @@ export function useApplicationPanelActions({
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
|
||||
if (definition?.kind === "device") {
|
||||
actions.push(deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
@@ -71,6 +74,7 @@ export function useApplicationPanelActions({
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface BoardHost {
|
||||
hostname: string; os: string; architecture: string; cpus: number;
|
||||
memory_kib: number | null; collected_at: string;
|
||||
networks: { name: string; up: boolean; addresses: string[] }[];
|
||||
usb: { port: string; product: string }[];
|
||||
}
|
||||
export interface Vehicle {
|
||||
id: string; node_id: string; name: string; platform: string;
|
||||
enrollment: "pending" | "paired" | "revoked" | "failed";
|
||||
connectivity: "online" | "offline"; last_seen: number | null;
|
||||
host: BoardHost | null; notice: string; revision: number;
|
||||
}
|
||||
export interface FleetPreview {
|
||||
preview_id: string; node_id: string; name: string; host: BoardHost;
|
||||
endpoint: string; expires_at: number;
|
||||
}
|
||||
export async function fleetRequest<T>(path = "", method = "GET", body?: unknown): Promise<T> {
|
||||
const response = await fetch(`/api/v1/fleet${path}`, {
|
||||
method, credentials: "same-origin", cache: "no-store",
|
||||
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(typeof data.detail === "string" ? data.detail : "Не удалось выполнить действие с аппаратом.");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
export function useFleet() {
|
||||
const [items, setItems] = useState<Vehicle[] | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const refresh = useCallback(async () => {
|
||||
const value = await fleetRequest<{ items: Vehicle[] }>();
|
||||
setItems(value.items); setError(""); return value.items;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let fallback: ReturnType<typeof setInterval> | undefined;
|
||||
const read = async () => {
|
||||
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
|
||||
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
|
||||
};
|
||||
void read();
|
||||
const events = new EventSource("/api/v1/fleet/events");
|
||||
events.onmessage = event => {
|
||||
if (!active) return;
|
||||
try { const value = JSON.parse(event.data); setItems(value.items); setError(""); if (fallback) { clearInterval(fallback); fallback = undefined; } }
|
||||
catch { unavailable(); }
|
||||
};
|
||||
const unavailable = () => {
|
||||
if (!active) return;
|
||||
setError("Связь обновляется. Показаны последние полученные сведения.");
|
||||
if (!fallback) fallback = setInterval(() => void read(), 5000);
|
||||
};
|
||||
events.onerror = unavailable;
|
||||
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
|
||||
}, []);
|
||||
return { items, error, refresh };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type WorkspaceKind =
|
||||
| "map"
|
||||
| "timeline"
|
||||
| "missions"
|
||||
| "vehicles"
|
||||
| "catalog"
|
||||
| "contour-health"
|
||||
| "compute-modules"
|
||||
@@ -193,23 +194,12 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
id: "vehicles",
|
||||
root: "fleet",
|
||||
label: "Аппараты",
|
||||
title: "Реестр аппаратов",
|
||||
title: "Аппараты",
|
||||
eyebrow: "ПАРК / РЕЕСТР",
|
||||
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
|
||||
description: "Аппараты и их бортовые компьютеры в частном контуре.",
|
||||
icon: "apps",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Идентичность аппарата",
|
||||
description: "Никакой привязки продуктовой модели к конкретному производителю.",
|
||||
capabilities: [
|
||||
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
|
||||
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
|
||||
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
|
||||
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
|
||||
],
|
||||
},
|
||||
],
|
||||
kind: "vehicles",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "local-device",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import {
|
||||
@@ -1167,6 +1168,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
return <MissionWorkspace {...props} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "contour-health":
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface LaboratoryViewAction {
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
fleetCreateRequest?: number;
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {useMemo} from 'react';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
|
||||
import "./fleet.css";
|
||||
import { VehicleSensors } from "./VehicleSensors";
|
||||
|
||||
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
|
||||
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
|
||||
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
|
||||
|
||||
export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: number }) {
|
||||
const fleet = useFleet();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [platform, setPlatform] = useState("ugv");
|
||||
const [preview, setPreview] = useState<FleetPreview | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
const lastCreateRequest = useRef(createRequest);
|
||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
||||
function close() { if (pending) return; setAdding(false); setPreview(null); setCode(""); setName(""); setError(""); }
|
||||
async function inspect(event: React.FormEvent) {
|
||||
event.preventDefault(); if (pending) return;
|
||||
setPending(true); setError("");
|
||||
try { const value = await fleetRequest<FleetPreview>("/preview", "POST", { code: code.trim() }); setPreview(value); setName(current => current || value.name); setCode(""); }
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Не удалось проверить приглашение."); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function add() {
|
||||
if (!preview || pending) return;
|
||||
setPending(true); setError("");
|
||||
try {
|
||||
const item = await fleetRequest<Vehicle>("", "POST", { preview_id: preview.preview_id, name: name.trim(), platform });
|
||||
await fleet.refresh(); setSelected(item.id); setAdding(false); setPreview(null); setCode(""); setName("");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "Не удалось добавить аппарат."); void fleet.refresh().catch(() => undefined); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
{!adding && error && <p role="alert">{error}</p>}
|
||||
{detail ? <>
|
||||
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</Button></div>
|
||||
{!sensorOpen && <SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
{detail.notice && <p role="status">{detail.notice}</p>}
|
||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
||||
<TextField label="Название аппарата" value={name} maxLength={80} onChange={event => setName(event.target.value)} disabled={pending} autoComplete="off" />
|
||||
{preview ? <SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={() => setPreview(null)}>Другое приглашение</Button></SettingsCard> : <TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={event => setCode(event.target.value)} />}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</form>
|
||||
</Window>
|
||||
<ConfirmationModal open={revoking !== null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name ?? ""}» останется в реестре, а его БК потеряет доступ к Core. БК получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRevoking(null)} onConfirm={async () => { if (!revoking) return; try { await fleetRequest(`/${encodeURIComponent(revoking.id)}`, "DELETE"); await fleet.refresh(); setRevoking(null); } catch (error) { setError(error instanceof Error ? error.message : "Не удалось отозвать привязку."); throw error; } }} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
|
||||
.fleet-workspace { padding: var(--nodedc-space-4); }
|
||||
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
|
||||
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
|
||||
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.fleet-facts { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.fleet-facts dt { color: var(--nodedc-text-secondary); }
|
||||
.fleet-facts dd { color: var(--nodedc-text-muted); }
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=readFileSync(new URL('../../../packages/sensor-ui/src/sensorStatus.ts',import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {sensorStatus}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const connected={online:true,configured:true,prepared:true,verified:false,snapshot:{acquisition:'idle',enrollment:'enrolled'}};
|
||||
|
||||
test('a configured, reconnected camera is connected before another frame verification',()=>{
|
||||
assert.deepEqual(sensorStatus(connected,true),{label:'Подключено',tone:'success'});
|
||||
});
|
||||
test('stale board data cannot assert camera connectivity',()=>{
|
||||
assert.equal(sensorStatus(connected,false).tone,'neutral');
|
||||
assert.equal(sensorStatus({...connected,online:false},true).label,'Не подключено');
|
||||
});
|
||||
test('a capture failure or unavailable driver is not shown as healthy',()=>{
|
||||
assert.equal(sensorStatus({...connected,snapshot:{acquisition:'failed'}},true).tone,'danger');
|
||||
assert.equal(sensorStatus({...connected,prepared:false},true).tone,'warning');
|
||||
});
|
||||
test('an unconfigured camera still requires preparation',()=>{
|
||||
assert.deepEqual(sensorStatus({...connected,configured:false},true),{label:'Требуется подготовка',tone:'neutral'});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/build/
|
||||
/ui/node_modules/
|
||||
/ui/dist/
|
||||
/web/dist/
|
||||
@@ -0,0 +1,222 @@
|
||||
# Mission Core Node — onboard configuration and sensors
|
||||
|
||||
## Current candidate: 0.6.11
|
||||
|
||||
The installed candidate on the qualification Mini is 0.6.11. It contains the
|
||||
native GTK/WebKit application, seven-step environment configuration, host and
|
||||
USB/network inventory, trusted SSH key management, Tailscale access, explicit
|
||||
Node/Core pairing, and the first RealSense D455 sensor workflow. Supported host:
|
||||
Ubuntu 24.04 LTS Desktop amd64. This is an early candidate, not a completed or
|
||||
fully hardware-qualified Node v1.
|
||||
|
||||
The shared Node/Core sensor UI supports preparation, naming, profiles/options,
|
||||
live RGB/depth/IR/points/motion, and capture/recording commands owned by the
|
||||
board. After initial preparation, the device row keeps one connectivity lamp,
|
||||
settings and viewer actions; driver redeployment moves into device settings.
|
||||
USB events update inventory while persistent identity/name/configuration remain
|
||||
available across sessions. A green connection lamp does not assert that frames
|
||||
are currently being acquired or that every sensor option has been qualified.
|
||||
|
||||
Actual GUI acceptance and its limits are recorded in
|
||||
[the sensor architecture report](../../docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md).
|
||||
Node GUI raw playback has passed. Core replay/API event acceptance still needs
|
||||
the separately approved canonical server restart. Resumable raw transfer/import,
|
||||
continuous unplug/replug without service restart, different USB ports, native
|
||||
WebKit media, recovery/soak and clean-OS qualification remain open. The owner
|
||||
powered the Mini off after the session; no later hardware checks are implied.
|
||||
|
||||
Installed package: `mission-core-node_0.6.11_amd64.deb`, 94,021,846 bytes;
|
||||
SHA-256 `2d593616d64ed9fecb99e5758763ae52610731aaf1467fb23c7d75b19ffef0fa`.
|
||||
Its source/provenance base is `404285419f864da7306eef6ee07a2077ad30e674`.
|
||||
Subsequent documentation commits do not imply another package installation.
|
||||
|
||||
## Bootstrap history: 0.2–0.4
|
||||
|
||||
0.4.0 makes «Настройка окружения» the first system view. A fixed privileged
|
||||
helper starts a durable, versioned systemd workflow for packages, the board
|
||||
service, network/USB inventory, SSH and Tailscale. Operator-controlled SSH keys
|
||||
and Tailscale login are available in the same section. The package bootstraps
|
||||
the GUI/service; operational configuration is performed by its UI button.
|
||||
Operator copy is OS-neutral; actual OS/version appears only in «Обзор БК».
|
||||
Support remains Ubuntu 24.04 LTS Desktop amd64.
|
||||
|
||||
0.3.2 also repairs Linux interface inventory: the unprivileged service admits
|
||||
AF_NETLINK for OS metadata reads while retaining an empty capability set.
|
||||
Inventory and UI distinguish a failed network/address read from an empty list.
|
||||
|
||||
0.3.1 consolidates host inventory, USB, Tailscale and SSH under «Система».
|
||||
«Обзор БК» contains host facts, the board name and an observed connectivity
|
||||
summary. The separate configuration page and redundant Node health badge are
|
||||
removed. «Устройства» is reserved for driver-backed devices and remains disabled
|
||||
until a real device workflow exists. The admitted Node/Core pairing and fleet
|
||||
surface plan is in `docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md`.
|
||||
|
||||
0.3.0 adopts the canonical Mission Core shell, navigation, system views and a
|
||||
GUI list of trusted SSH devices. ResourceRow is shared from Design Guideline;
|
||||
see `docs/node/02_NODE_DESKTOP_SURFACE.md` for composition and acceptance.
|
||||
The Debian package still contains the GTK/WebKit desktop launcher and bundled
|
||||
React UI. Real Mini installation and read-only UI checks passed; physical
|
||||
reboot, bare-Ubuntu installation and full GUI upgrade/removal remain open.
|
||||
|
||||
**Qualification in progress (2026-09-05):** the owner installed 0.2.0 through
|
||||
App Center after closing Synaptic, which had blocked package installation.
|
||||
PackageKit completed successfully and the packaged service is active.
|
||||
The owner subsequently confirmed the corrected icon and Tailscale “online” with
|
||||
the board address under 0.2.2. 0.2.2 corrects the desktop icon's
|
||||
canvas and configures the pinned provider's HTTPS control transport. The earlier
|
||||
0.2.1 icon-only candidate was superseded before installation.
|
||||
Manual SSH bootstrap is authorized for engineering access only.
|
||||
|
||||
0.2.3 also routes expired-session errors from Tailscale polling to the existing
|
||||
application login surface. Browser acceptance confirmed that restarting the
|
||||
temporary test service now shows login instead of a misleading unavailable
|
||||
provider. This does not preserve authentication across a service restart.
|
||||
|
||||
Source of truth: MISSIONCOR-76 and its UI-FIRST / BRIDGE-ONLY / system
|
||||
configuration comments. Product surface and physical acceptance procedure:
|
||||
`docs/node/01_BOOTSTRAP_SURFACE_AND_ACCEPTANCE.md` at the repository root.
|
||||
|
||||
This is an independently built application in the Mission Core monorepo.
|
||||
Version 0.2.0 contains local host/USB/network inventory, persistent Ed25519
|
||||
identity, GUI naming, OS-authenticated local launch, redacted report export,
|
||||
OpenSSH installation/autostart, and GUI enrollment/revocation of Ed25519 public
|
||||
keys for local Ubuntu administrators. The agent and desktop window run
|
||||
unprivileged; fixed polkit helpers admit only local login and the shipped system/network actions. SSH configuration is
|
||||
owned by the versioned environment workflow, with conflict detection and cleanup on removal.
|
||||
|
||||
In 0.2.0, Core pairing/mTLS, sensor plugins, capture, media and recovery were
|
||||
subsequent vertical increments. See the current candidate section above for
|
||||
their present implementation and acceptance boundaries.
|
||||
|
||||
## Operator workflow
|
||||
|
||||
Open the `.deb` in Ubuntu's graphical package installer, install it, then launch
|
||||
Mission Core Node from the applications menu and approve the normal OS dialog.
|
||||
The application opens in its own GTK window with embedded WebKit rendering and
|
||||
native system dialogs for authorization and report saving. No external browser
|
||||
is opened. Closing the window leaves the independent board service running.
|
||||
The system installer resolves dependencies from Ubuntu repositories; internet
|
||||
access is needed when those dependencies are absent. There are no shared
|
||||
credentials in the package. SSH public keys are enrolled explicitly in the UI.
|
||||
No shell, Go, Python environment setup, npm, or source checkout is required from
|
||||
the operator. The included Python launcher uses the system Python dependency.
|
||||
|
||||
The desktop icon contains the unchanged canonical NODE.DC mark from the admitted
|
||||
Design Guideline revision inside a transparent square SVG canvas. This gives
|
||||
desktop loaders square intrinsic dimensions without stretching the mark. After
|
||||
upgrading the package, close and reopen the
|
||||
application window so its native helpers and embedded UI have matching features.
|
||||
|
||||
**Observed installer limitation (2026-09-05):** App Center revision 1270 on the
|
||||
qualification board showed “installed” instead of offering the 0.1.1 → 0.2.0
|
||||
local-file upgrade. Do not claim that update path is accepted. The owner's
|
||||
subsequent GUI removal succeeded; reinstall attempts then failed before dpkg
|
||||
because Synaptic remained open and held `/var/lib/dpkg/lock-frontend`.
|
||||
Exit Synaptic through File → Quit before retrying the `.deb` in App Center.
|
||||
Do not delete package-manager locks or terminate a running transaction. The
|
||||
0.2.0 SHA-256 still matches, and APT simulation resolves its dependencies;
|
||||
neither check alone establishes actual installation. The following GUI retry
|
||||
completed successfully and the installed package is 0.2.0. A complete product installer
|
||||
must still qualify GUI upgrade, removal and actionable lock/error handling.
|
||||
|
||||
## Private network setup
|
||||
|
||||
The optional Tailscale panel has real install, login, waiting-for-approval,
|
||||
stopped, starting, unavailable and connected states. Installation and connection
|
||||
use two fixed root-owned polkit helpers from the desktop window. The web API
|
||||
can only read a reduced local status; it cannot run commands or change network
|
||||
settings. The generic Node identity and capture lifecycle do not depend on
|
||||
Tailscale. Pairing to Mission Core is a separate explicit UI operation; see
|
||||
[the pairing protocol](../../docs/node/04_NODE_CORE_PAIRING_PROTOCOL.md).
|
||||
|
||||
On a new machine, the helper downloads the official amd64 `.deb` pinned in
|
||||
`packaging/tailscale-release.json`, verifies SHA-256 before invoking APT, installs
|
||||
without removing other packages, and enables `tailscaled`. It does not add an
|
||||
APT repository or upgrade an existing Tailscale installation. An existing
|
||||
stopped authenticated configuration is resumed with a bare `tailscale up`;
|
||||
fresh login explicitly disables accepting remote DNS and subnet routes. No
|
||||
exit node, advertised subnet, Tailscale SSH, forced reauthentication or reset
|
||||
is configured. Incompatible existing preferences fail instead of being reset.
|
||||
|
||||
For a new provider install, and when explicitly reconnecting a disconnected
|
||||
provider, a root-owned systemd drop-in selects `TS_FORCE_NOISE_443=true`.
|
||||
The board's port-80 control connection stalled after registration with queued
|
||||
unacknowledged data; the upstream `debug ts2021` handshake succeeded over 443.
|
||||
The helper checks the daemon's effective flag and restarts it only when needed.
|
||||
An already Running/NeedsMachineAuth provider is left untouched. Conflicting
|
||||
custom drop-ins are preserved and reported. Keys, DNS and route preferences
|
||||
are not changed. This drop-in remains with the independent provider on Node
|
||||
removal. The transport setting is specific to pinned Tailscale, not Node identity.
|
||||
|
||||
The provider's validated `https://login.tailscale.com/a/...` URL opens in the
|
||||
user's normal browser only after the explicit login action. Node never collects
|
||||
the account password or exports the login URL to JS, its status API or reports.
|
||||
The status probe requests no peers and returns only installation/state, local
|
||||
Tailscale addresses and the provider's online flag. Closing Node or uninstalling
|
||||
it does not disconnect or remove the independently installed Tailscale service.
|
||||
|
||||
Source contracts: [Tailscale stable packages](https://pkgs.tailscale.com/stable/),
|
||||
[pinned up implementation](https://github.com/tailscale/tailscale/blob/v1.102.3/cmd/tailscale/cli/up.go),
|
||||
[pinned status implementation](https://github.com/tailscale/tailscale/blob/v1.102.3/cmd/tailscale/cli/status.go).
|
||||
HTTPS underlay: [pinned control dialer](https://github.com/tailscale/tailscale/blob/v1.102.3/control/controlhttp/client.go).
|
||||
JSON contracts are version-sensitive; review the adapter when updating the pin.
|
||||
|
||||
Only Ubuntu 24.04 LTS Desktop amd64 is admitted by this first package. No blind
|
||||
upgrade of OS, firmware, network profiles, router settings or camera SDK occurs.
|
||||
Existing Ubuntu SSH authentication is retained. Keys enrolled in Node are
|
||||
limited to private source addresses. Removing Node removes its SSH integration,
|
||||
but leaves the SSH server and persistent Node state available for reinstall.
|
||||
|
||||
## Engineering build (not the operator installation procedure)
|
||||
|
||||
Install UI dependencies with `npm ci --ignore-scripts` in `ui/`. The build
|
||||
requires the sibling Design Guideline repository used by the monorepo, at
|
||||
`999864e5b0a81555823cfa1ea6e8cf8a417c37f1` (the exact `DG_COMMIT` in
|
||||
`packaging/build.py`, not necessarily the latest documentation commit). In that checkout, run
|
||||
`npm ci --ignore-scripts` and `npm run build:packages` before building Node.
|
||||
Its dependencies are bundled into the binary; the board never references that
|
||||
sibling path. The pinned commit includes ResourceRow and the shared shell fixes;
|
||||
source and generated export hashes are also retained in package provenance.
|
||||
|
||||
Use the Go release pinned in `toolchain.json`; download it from the official
|
||||
Go distribution and verify its SHA-256. No global Go install is needed.
|
||||
|
||||
```sh
|
||||
python3 packaging/build.py --go /path/to/verified/go/bin/go
|
||||
```
|
||||
|
||||
This runs the production UI build, replaces generated embedded assets, builds
|
||||
a static Linux amd64 Go binary, records source/build provenance, and packages
|
||||
the `.deb` without executing any installer scripts. `build/` is ignored.
|
||||
|
||||
Validation is sequential: `go test -race ./...`, the Control Station application
|
||||
architecture boundary test, Node UI typecheck/unit tests/build, then desktop GUI QA.
|
||||
Package script syntax/archive checks and a macOS browser run cannot establish
|
||||
Ubuntu systemd/polkit/SSH or clean-install acceptance. Those require the GUI
|
||||
procedure on the actual board. The temporary native QA build must be stopped
|
||||
after inspection; the canonical Mission Core on port 8000 stays running.
|
||||
|
||||
For this board, the owner's engineering checkout is under
|
||||
`Загрузки/NDC/MISSION_CORE` in the operator's home directory. Keep complete Git history separately
|
||||
from generated artifacts; do not copy another worktree's `.git` pointer. The
|
||||
launcher accepts `--development-socket` for an unprivileged development service
|
||||
on the board. This does not grant OS privileges and is not installer acceptance.
|
||||
|
||||
K1 uses wireless Bridge in the common LAN only. D455 is attached by USB; check
|
||||
its actual negotiated speed and SDK operation separately from enumeration.
|
||||
|
||||
## Local authority
|
||||
|
||||
The node service binds only `127.0.0.1:8780`. This is not the remote Node/Core
|
||||
control plane. Its private Unix socket is `0600` in a `0700` directory. The
|
||||
root-owned launcher helper has a fixed executable and socket; no user command,
|
||||
path, URL or environment is executed with elevated privileges. One-use login
|
||||
tokens expire after one minute, authenticated cookies after eight hours, and
|
||||
all sessions expire on service restart. Identity corruption fails closed.
|
||||
|
||||
The D455 worker reads USB/SDK serials internally to match one physical device;
|
||||
the shared sensor API uses a derived stable device ID. Treat identifiers, host
|
||||
inventory and captured data as private operational evidence. Credentials,
|
||||
private keys, real recordings, exported host reports and runtime state stay
|
||||
outside normal Git. See the architecture reports for the exact API/report
|
||||
boundaries rather than treating source-test fixtures as live evidence.
|
||||
@@ -0,0 +1,41 @@
|
||||
// Engineering-only UI fixture: uses the production API and isolated storage.
|
||||
// No fixture is linked into cmd/node-agent or installed by the Debian package.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"nodedc.local/mission-core/node-agent/internal/node"
|
||||
"nodedc.local/mission-core/node-agent/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir := "/private/tmp/mc-node-ui-040-qa"
|
||||
store, err := node.OpenStore(dir); if err != nil { log.Fatal(err) }
|
||||
assets, _ := fs.Sub(web.Assets, "dist")
|
||||
memory, available := uint64(8388608), uint64(5242880)
|
||||
app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.4.0-qa", Inventory: func() node.Inventory {
|
||||
inventory := node.Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname:"qa-board", OS:"Ubuntu 24.04.4 LTS", Architecture:"amd64", CPUs:8, MemoryKiB:&memory, AvailableKiB:&available,
|
||||
NetworksReadable:true, Networks: []node.Network{{Name:"ethernet-qa", Up:true, AddressesReadable:true, Addresses:[]string{"192.0.2.10/24"}}},
|
||||
USB:[]node.USB{{Port:"2-1", Vendor:"8086", ProductID:"0b5c", Product:"Intel RealSense D455 · QA", Speed:"5000"}}, USBReadable:true, Warnings:[]string{}}
|
||||
if _, err := os.Stat(filepath.Join(dir,"network-unavailable")); err == nil { inventory.NetworksReadable=false; inventory.Networks=[]node.Network{}; inventory.Warnings=[]string{"Не удалось прочитать сетевые интерфейсы"} }
|
||||
return inventory
|
||||
}, Access: &node.AccessStore{Path:filepath.Join(dir,"ssh-keys.json"), Users:func()[]string{return []string{"operator"}}}, Tailscale:func()node.TailscaleStatus {
|
||||
if _, err := os.Stat(filepath.Join(dir,"offline")); err == nil { return node.TailscaleStatus{Installed:true, State:"unavailable", Addresses:[]string{}} }
|
||||
return node.TailscaleStatus{Installed:true, State:"Running", Online:true, Addresses:[]string{"100.64.0.10"}}
|
||||
}}
|
||||
if err := os.WriteFile(filepath.Join(dir,"login-url"),[]byte(app.IssueLogin()),0600); err != nil {log.Fatal(err)}
|
||||
handler := app.Handler()
|
||||
http.HandleFunc("/",func(w http.ResponseWriter,r *http.Request){
|
||||
if r.URL.Path == "/qa-bridge.js" { w.Header().Set("Content-Type","application/javascript"); _,_ = w.Write([]byte(`window.missionCoreDesktop={networkSetup:true};`)); return }
|
||||
if r.URL.Path == "/" { b,_:=fs.ReadFile(assets,"index.html"); b=bytes.Replace(b,[]byte("</head>"),[]byte(`<script src="/qa-bridge.js"></script></head>`),1); w.Header().Set("Content-Type","text/html"); _,_=w.Write(b); return }
|
||||
handler.ServeHTTP(w,r)
|
||||
})
|
||||
log.Print("isolated Node UI QA: 127.0.0.1:8780")
|
||||
log.Fatal(http.ListenAndServe("127.0.0.1:8780",nil))
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"nodedc.local/mission-core/node-agent/internal/node"
|
||||
"nodedc.local/mission-core/node-agent/web"
|
||||
)
|
||||
|
||||
var version = "0.2.0"
|
||||
|
||||
const defaultSocket = "/run/mission-core-node/admin.sock"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Print(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
if len(os.Args) > 1 && os.Args[1] == "authorize" {
|
||||
return authorize(defaultSocket)
|
||||
}
|
||||
if len(os.Args) == 3 && os.Args[1] == "ssh-keys" {
|
||||
value, err := node.AuthorizedKeys("/var/lib/mission-core-node/ssh-keys.json", os.Args[2])
|
||||
if err == nil {
|
||||
fmt.Print(value)
|
||||
}
|
||||
return err
|
||||
}
|
||||
flags := flag.NewFlagSet("node-agent", flag.ContinueOnError)
|
||||
dir := flags.String("state", "/var/lib/mission-core-node", "private state directory")
|
||||
socket := flags.String("socket", defaultSocket, "private launcher socket")
|
||||
listen := flags.String("listen", "127.0.0.1:8780", "loopback UI address")
|
||||
if err := flags.Parse(os.Args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
host, _, err := net.SplitHostPort(*listen)
|
||||
if err != nil || host != "127.0.0.1" {
|
||||
return errors.New("local UI must bind 127.0.0.1")
|
||||
}
|
||||
// Bind before touching state/socket; a second instance cannot replace identity or launcher authority.
|
||||
tcp, err := net.Listen("tcp", *listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tcp.Close()
|
||||
store, err := node.OpenStore(*dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assets, err := fs.Sub(web.Assets, "dist")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app := &node.Server{Store: store, Assets: assets, Origin: "http://" + *listen, Version: version, Inventory: func() node.Inventory { return node.Host("/") }}
|
||||
pairing, err := node.OpenPairing(store, *dir, version, app.Inventory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.Pairing = pairing
|
||||
nodeID, _ := store.Public()
|
||||
app.Sensors, err = node.OpenSensors(*dir, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pairing.Sensors = app.Sensors
|
||||
app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }}
|
||||
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, e := os.Lstat(*socket); e == nil {
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return errors.New("launcher path is not a socket")
|
||||
}
|
||||
if err := os.Remove(*socket); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(e) {
|
||||
return e
|
||||
}
|
||||
unix, err := net.Listen("unix", *socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close()
|
||||
defer os.Remove(*socket)
|
||||
if err := os.Chmod(*socket, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
admin := http.NewServeMux()
|
||||
admin.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"url": app.IssueLogin()})
|
||||
})
|
||||
public := &http.Server{Handler: app.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 8192}
|
||||
private := &http.Server{Handler: admin, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 10 * time.Second, MaxHeaderBytes: 8192}
|
||||
errs := make(chan error, 2)
|
||||
go func() { errs <- public.Serve(tcp) }()
|
||||
go func() { errs <- private.Serve(unix) }()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go app.Sensors.WatchUSB(ctx)
|
||||
go pairing.Run(ctx)
|
||||
log.Print("Mission Core Node " + version + " listening on loopback")
|
||||
select {
|
||||
case err = <-errs:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
public.Shutdown(shutdown)
|
||||
private.Shutdown(shutdown)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func authorize(socket string) error {
|
||||
// Called by a fixed, root-owned polkit helper. No user-supplied URL, command,
|
||||
// path, or environment is interpreted by the privileged operation.
|
||||
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
}}}
|
||||
res, err := client.Post("http://local/login", "application/json", nil)
|
||||
if err != nil {
|
||||
return errors.New("Node is unavailable")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
return errors.New("Node rejected local authorization")
|
||||
}
|
||||
var value struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 1024)).Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Print(value.URL)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module nodedc.local/mission-core/node-agent
|
||||
|
||||
go 1.26.0
|
||||
@@ -0,0 +1,265 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type AccessKey struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Label string `json:"label"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
type AccessStore struct {
|
||||
mu sync.Mutex
|
||||
Path string
|
||||
Users func() []string
|
||||
}
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
|
||||
// Only existing local administrative accounts are eligible. Never root,
|
||||
// a supplied home directory, an arbitrary NSS principal, or a generated user.
|
||||
func LocalAdmins(root string) []string {
|
||||
group, _ := os.ReadFile(filepath.Join(root, "etc/group"))
|
||||
admins := map[string]bool{}
|
||||
for _, line := range strings.Split(string(group), "\n") {
|
||||
p := strings.Split(line, ":")
|
||||
if len(p) == 4 && p[0] == "sudo" {
|
||||
for _, u := range strings.Split(p[3], ",") {
|
||||
admins[u] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
passwd, _ := os.ReadFile(filepath.Join(root, "etc/passwd"))
|
||||
result := []string{}
|
||||
for _, line := range strings.Split(string(passwd), "\n") {
|
||||
p := strings.Split(line, ":")
|
||||
if len(p) != 7 {
|
||||
continue
|
||||
}
|
||||
uid, e := strconv.Atoi(p[2])
|
||||
if e == nil && uid >= 1000 && uid < 65534 && admins[p[0]] && usernamePattern.MatchString(p[0]) && !strings.HasSuffix(p[6], "nologin") && !strings.HasSuffix(p[6], "false") {
|
||||
result = append(result, p[0])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canonicalKey(key string) (string, string, error) {
|
||||
parts := strings.Fields(strings.TrimSpace(key))
|
||||
bad := errors.New("Нужен публичный ключ Ed25519, начинающийся с ssh-ed25519; приватный ключ вводить нельзя")
|
||||
if len(parts) < 2 || parts[0] != "ssh-ed25519" || strings.ContainsAny(key, "\r\n") {
|
||||
return "", "", bad
|
||||
}
|
||||
b, e := base64.StdEncoding.DecodeString(parts[1])
|
||||
if e != nil || len(b) != 51 {
|
||||
return "", "", bad
|
||||
}
|
||||
if binary.BigEndian.Uint32(b[:4]) != 11 || string(b[4:15]) != "ssh-ed25519" || binary.BigEndian.Uint32(b[15:19]) != 32 {
|
||||
return "", "", bad
|
||||
}
|
||||
h := sha256.Sum256(b)
|
||||
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(b), "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:]), nil
|
||||
}
|
||||
|
||||
func ReadAccess(path string) ([]AccessKey, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []AccessKey{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []AccessKey
|
||||
if err = json.Unmarshal(b, &keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(keys) > 64 {
|
||||
return nil, errors.New("too many access keys")
|
||||
}
|
||||
for _, k := range keys {
|
||||
key, id, err := canonicalKey(k.PublicKey)
|
||||
if err != nil || key != k.PublicKey || id != k.ID || !usernamePattern.MatchString(k.User) {
|
||||
return nil, errors.New("invalid access store")
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (a *AccessStore) List() ([]AccessKey, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return ReadAccess(a.Path)
|
||||
}
|
||||
func (a *AccessStore) allowed(user string) bool {
|
||||
for _, u := range a.Users() {
|
||||
if user == u {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (a *AccessStore) change(fn func([]AccessKey) ([]AccessKey, error)) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
keys, err := ReadAccess(a.Path)
|
||||
if err != nil {
|
||||
return errors.New("Хранилище SSH недоступно")
|
||||
}
|
||||
keys, err = fn(keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.Marshal(keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(a.Path), ".ssh-keys-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, err = f.Write(b); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), a.Path)
|
||||
}
|
||||
|
||||
func (a *AccessStore) Add(user, label, key string) error {
|
||||
if !a.allowed(user) {
|
||||
return errors.New("Выберите существующую учётную запись администратора системы")
|
||||
}
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" || utf8.RuneCountInString(label) > 64 || strings.ContainsFunc(label, unicode.IsControl) {
|
||||
return errors.New("Название ключа должно содержать от 1 до 64 символов")
|
||||
}
|
||||
key, id, err := canonicalKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.change(func(keys []AccessKey) ([]AccessKey, error) {
|
||||
for _, k := range keys {
|
||||
if k.ID == id && k.User == user {
|
||||
return keys, nil
|
||||
}
|
||||
}
|
||||
if len(keys) >= 64 {
|
||||
return nil, errors.New("Достигнут предел 64 ключа")
|
||||
}
|
||||
return append(keys, AccessKey{ID: id, User: user, Label: label, PublicKey: key}), nil
|
||||
})
|
||||
}
|
||||
func (a *AccessStore) Remove(user, id string) error {
|
||||
return a.change(func(keys []AccessKey) ([]AccessKey, error) {
|
||||
next := []AccessKey{}
|
||||
for _, k := range keys {
|
||||
if k.User != user || k.ID != id {
|
||||
next = append(next, k)
|
||||
}
|
||||
}
|
||||
return next, nil
|
||||
})
|
||||
}
|
||||
|
||||
func SSHReady() bool {
|
||||
c, err := net.DialTimeout("tcp", "127.0.0.1:22", 400*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetReadDeadline(time.Now().Add(400 * time.Millisecond))
|
||||
s := bufio.NewScanner(c)
|
||||
return s.Scan() && strings.HasPrefix(s.Text(), "SSH-2.0-")
|
||||
}
|
||||
|
||||
func (s *Server) accessRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/access", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
keys, err := s.Access.List()
|
||||
if err != nil {
|
||||
reply(w, 503, map[string]string{"error": "Хранилище SSH недоступно"})
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]any{"users": s.Access.Users(), "keys": keys, "ssh_ready": SSHReady()})
|
||||
})
|
||||
mux.HandleFunc("POST /api/access", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var b struct {
|
||||
User string `json:"user"`
|
||||
Label string `json:"label"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if !decode(w, r, &b) {
|
||||
return
|
||||
}
|
||||
if err := s.Access.Add(b.User, b.Label, b.Key); err != nil {
|
||||
reply(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]bool{"ok": true})
|
||||
})
|
||||
mux.HandleFunc("DELETE /api/access", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var b struct {
|
||||
User string `json:"user"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if !decode(w, r, &b) {
|
||||
return
|
||||
}
|
||||
if err := s.Access.Remove(b.User, b.ID); err != nil {
|
||||
reply(w, 503, map[string]string{"error": "Не удалось удалить ключ"})
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]bool{"ok": true})
|
||||
})
|
||||
}
|
||||
|
||||
func AuthorizedKeys(path, user string) (string, error) {
|
||||
a := &AccessStore{Path: path, Users: func() []string { return LocalAdmins("/") }}
|
||||
if !a.allowed(user) {
|
||||
return "", nil
|
||||
}
|
||||
keys, err := a.List()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, k := range keys {
|
||||
if k.User == user {
|
||||
out.WriteString(`from="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,127.0.0.0/8,::1,fc00::/7,fe80::/10" ` + k.PublicKey + "\n")
|
||||
}
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema": "missioncore.node.environment/v1",
|
||||
"revision": "ubuntu-24.04-amd64/2",
|
||||
"steps": [
|
||||
{"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]},
|
||||
{"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]},
|
||||
{"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]},
|
||||
{"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]},
|
||||
{"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]},
|
||||
{"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]},
|
||||
{"id":"tailscale-install","label":"Установка Tailscale","description":"Проверенный пакет и системная служба частной сети","requires":["packages"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed environment-profile.json
|
||||
var environmentProfile []byte
|
||||
|
||||
type EnvironmentRun struct {
|
||||
Schema string `json:"schema"`
|
||||
ProfileRevision string `json:"profile_revision"`
|
||||
RunID string `json:"run_id"`
|
||||
State string `json:"state"`
|
||||
StartedAt float64 `json:"started_at"`
|
||||
UpdatedAt float64 `json:"updated_at"`
|
||||
Steps []EnvironmentStep `json:"steps"`
|
||||
}
|
||||
type EnvironmentStep struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
type EnvironmentStatus struct {
|
||||
Profile json.RawMessage `json:"profile"`
|
||||
Run *EnvironmentRun `json:"run"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
func ReadEnvironment() EnvironmentStatus {
|
||||
result := readEnvironmentFile("/var/lib/mission-core-node-environment/last-run.json")
|
||||
if result.Run != nil && result.Run.State == "running" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
state, err := exec.CommandContext(ctx, "/usr/bin/systemctl", "show", "--property=ActiveState", "--value", "mission-core-node-environment.service").Output()
|
||||
if err != nil || (string(state) != "activating\n" && string(state) != "active\n") {
|
||||
// A stopped job/reboot cannot leave yesterday's spinner running.
|
||||
result.Run.State = "interrupted"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func readEnvironmentFile(path string) EnvironmentStatus {
|
||||
result := EnvironmentStatus{Profile: json.RawMessage(environmentProfile), Available: true}
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return result
|
||||
}
|
||||
if err != nil {
|
||||
result.Available = false
|
||||
return result
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() > 32768 {
|
||||
result.Available = false
|
||||
return result
|
||||
}
|
||||
var run EnvironmentRun
|
||||
decoder := json.NewDecoder(io.LimitReader(f, 32769))
|
||||
if decoder.Decode(&run) != nil || decoder.Decode(new(any)) != io.EOF || !validEnvironmentRun(run) {
|
||||
result.Available = false
|
||||
return result
|
||||
}
|
||||
result.Run = &run
|
||||
return result
|
||||
}
|
||||
|
||||
// Invalid or inconsistent progress cannot turn a failed setup into green UI.
|
||||
func validEnvironmentRun(run EnvironmentRun) bool {
|
||||
if run.Schema != "missioncore.node.environment/v1" || run.RunID == "" || len(run.RunID) > 64 || len(run.Steps) == 0 || len(run.Steps) > 32 || run.ProfileRevision == "" {
|
||||
return false
|
||||
}
|
||||
if run.State != "running" && run.State != "complete" && run.State != "error" {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, step := range run.Steps {
|
||||
if step.ID == "" || len(step.ID) > 64 || seen[step.ID] || len(step.Detail) > 4096 {
|
||||
return false
|
||||
}
|
||||
seen[step.ID] = true
|
||||
switch step.State {
|
||||
case "pending", "running", "complete", "error", "blocked":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if run.State == "complete" && step.State != "complete" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvironmentStatusIsAuthorizedReadOnly(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.Environment = func() EnvironmentStatus { return EnvironmentStatus{Available: true, Profile: environmentProfile} }
|
||||
if got := call(s, "GET", "/api/environment", "", nil); got.Code != 401 {
|
||||
t.Fatal(got.Code)
|
||||
}
|
||||
cookie := login(t, s)
|
||||
if got := call(s, "GET", "/api/environment", "", cookie); got.Code != 200 {
|
||||
t.Fatal(got.Code, got.Body.String())
|
||||
}
|
||||
if got := call(s, "POST", "/api/environment", "{}", cookie); got.Code == 200 {
|
||||
t.Fatal("HTTP can mutate system")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingCorruptAndPartialEnvironmentReports(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "last-run.json")
|
||||
result := readEnvironmentFile(path)
|
||||
if !result.Available || result.Run != nil {
|
||||
t.Fatal("new install not admitted")
|
||||
}
|
||||
samples := []string{
|
||||
`{broken`,
|
||||
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"network-inventory","state":"error"}]}`,
|
||||
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"x","state":"complete"},{"id":"x","state":"complete"}]}`,
|
||||
`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"complete","steps":[{"id":"x","state":"complete"}]} {}`,
|
||||
}
|
||||
for _, sample := range samples {
|
||||
if err := os.WriteFile(path, []byte(sample), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readEnvironmentFile(path); got.Available || got.Run != nil {
|
||||
t.Fatal("invalid report admitted", sample)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"schema":"missioncore.node.environment/v1","profile_revision":"test/1","run_id":"test","state":"error","steps":[{"id":"packages","state":"error"},{"id":"ssh-service","state":"blocked"}]}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = readEnvironmentFile(path)
|
||||
if !result.Available || result.Run == nil || result.Run.State != "error" || result.Run.Steps[1].State != "blocked" {
|
||||
t.Fatal("failure lost")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Network struct {
|
||||
Name string `json:"name"`
|
||||
Up bool `json:"up"`
|
||||
Addresses []string `json:"addresses"`
|
||||
AddressesReadable bool `json:"addresses_readable"`
|
||||
}
|
||||
type USB struct {
|
||||
Port string `json:"port"`
|
||||
Vendor string `json:"vendor"`
|
||||
ProductID string `json:"product_id"`
|
||||
Product string `json:"product"`
|
||||
Speed string `json:"speed_mbps"`
|
||||
}
|
||||
type Inventory struct {
|
||||
CollectedAt string `json:"collected_at"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
CPUs int `json:"cpus"`
|
||||
MemoryKiB *uint64 `json:"memory_kib"`
|
||||
AvailableKiB *uint64 `json:"available_kib"`
|
||||
Networks []Network `json:"networks"`
|
||||
NetworksReadable bool `json:"networks_readable"`
|
||||
USB []USB `json:"usb"`
|
||||
USBReadable bool `json:"usb_readable"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// Host reads only local kernel/OS metadata. It never probes network devices,
|
||||
// opens camera streams, reads device serials, or changes a network interface.
|
||||
func Host(root string) Inventory {
|
||||
read := func(p string) string {
|
||||
b, _ := os.ReadFile(filepath.Join(root, p))
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
v := Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname: host, OS: runtime.GOOS, Architecture: runtime.GOARCH, CPUs: runtime.NumCPU(), Networks: []Network{}, USB: []USB{}, Warnings: []string{}}
|
||||
for _, line := range strings.Split(read("etc/os-release"), "\n") {
|
||||
if x, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
||||
v.OS = strings.Trim(x, "\"")
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(read("proc/meminfo"), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
n, e := strconv.ParseUint(fields[1], 10, 64)
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
if fields[0] == "MemTotal:" {
|
||||
v.MemoryKiB = &n
|
||||
}
|
||||
if fields[0] == "MemAvailable:" {
|
||||
v.AvailableKiB = &n
|
||||
}
|
||||
}
|
||||
if v.MemoryKiB == nil {
|
||||
v.Warnings = append(v.Warnings, "Сведения о памяти недоступны")
|
||||
}
|
||||
var warnings []string
|
||||
v.Networks, v.NetworksReadable, warnings = readNetworks(net.Interfaces, func(it net.Interface) ([]net.Addr, error) { return it.Addrs() })
|
||||
v.Warnings = append(v.Warnings, warnings...)
|
||||
entries, err := os.ReadDir(filepath.Join(root, "sys/bus/usb/devices"))
|
||||
v.USBReadable = err == nil
|
||||
if err != nil {
|
||||
v.Warnings = append(v.Warnings, "Сведения об USB недоступны")
|
||||
}
|
||||
for _, e := range entries {
|
||||
prefix := filepath.Join("sys/bus/usb/devices", e.Name())
|
||||
vendor := read(filepath.Join(prefix, "idVendor"))
|
||||
if vendor == "" {
|
||||
continue
|
||||
}
|
||||
v.USB = append(v.USB, USB{Port: e.Name(), Vendor: vendor, ProductID: read(filepath.Join(prefix, "idProduct")), Product: read(filepath.Join(prefix, "product")), Speed: read(filepath.Join(prefix, "speed"))})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func readNetworks(list func() ([]net.Interface, error), addrs func(net.Interface) ([]net.Addr, error)) ([]Network, bool, []string) {
|
||||
result, warnings := []Network{}, []string{}
|
||||
interfaces, err := list()
|
||||
if err != nil {
|
||||
return result, false, []string{"Не удалось прочитать сетевые интерфейсы"}
|
||||
}
|
||||
for _, it := range interfaces {
|
||||
if it.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addresses, err := addrs(it)
|
||||
n := Network{Name: it.Name, Up: it.Flags&net.FlagUp != 0, Addresses: []string{}, AddressesReadable: err == nil}
|
||||
if err != nil {
|
||||
warnings = append(warnings, "Адреса интерфейса "+it.Name+" недоступны")
|
||||
} else {
|
||||
for _, a := range addresses {
|
||||
n.Addresses = append(n.Addresses, a.String())
|
||||
}
|
||||
}
|
||||
sort.Strings(n.Addresses)
|
||||
result = append(result, n)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
return result, true, warnings
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetworkInventoryDistinguishesFailureFromEmpty(t *testing.T) {
|
||||
denied := errors.New("read denied")
|
||||
for _, failure := range []error{nil, denied} {
|
||||
rows, readable, warnings := readNetworks(func() ([]net.Interface, error) { return nil, failure }, func(net.Interface) ([]net.Addr, error) { t.Fatal("no interface to read"); return nil, nil })
|
||||
if len(rows) != 0 || readable != (failure == nil) || (len(warnings) > 0) != (failure != nil) {
|
||||
t.Fatal(rows, readable, warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAddressFailureKeepsInterfaceWithoutInventingAddresses(t *testing.T) {
|
||||
rows, readable, warnings := readNetworks(func() ([]net.Interface, error) {
|
||||
return []net.Interface{{Name: "loop", Flags: net.FlagLoopback}, {Name: "ethernet", Flags: net.FlagUp}}, nil
|
||||
}, func(it net.Interface) ([]net.Addr, error) {
|
||||
if it.Name != "ethernet" {
|
||||
t.Fatal("read loopback")
|
||||
}
|
||||
return []net.Addr{&net.IPAddr{IP: net.ParseIP("192.0.2.1")}}, errors.New("partial result")
|
||||
})
|
||||
if !readable || len(rows) != 1 || rows[0].Name != "ethernet" || !rows[0].Up || rows[0].AddressesReadable || len(rows[0].Addresses) != 0 || len(warnings) != 1 {
|
||||
t.Fatal(rows, readable, warnings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
state, e := OpenStore(t.TempDir())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return &Server{Store: state, Origin: "http://127.0.0.1:8780", Assets: fstest.MapFS{"index.html": {Data: []byte("test-only asset")}}, Inventory: func() Inventory {
|
||||
return Inventory{Hostname: "private-host", Networks: []Network{{Name: "eth0", Addresses: []string{"192.168.10.4/24"}}}}
|
||||
}}
|
||||
}
|
||||
func call(s *Server, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, s.Origin+path, strings.NewReader(body))
|
||||
r.Header.Set("Origin", s.Origin)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if cookie != nil {
|
||||
r.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
func login(t *testing.T, s *Server) *http.Cookie {
|
||||
t.Helper()
|
||||
v := strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
|
||||
w := call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
return w.Result().Cookies()[0]
|
||||
}
|
||||
|
||||
func TestIdentitySurvivesRenameAndReopen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, e := OpenStore(dir)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
id, _ := s.Public()
|
||||
if e = s.Rename("Борт 1"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e = OpenStore(dir)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
next, name := s.Public()
|
||||
if next != id || name != "Борт 1" {
|
||||
t.Fatal(next, name)
|
||||
}
|
||||
info, _ := os.Stat(filepath.Join(dir, "identity.json"))
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal(info.Mode())
|
||||
}
|
||||
if e = s.Rename("bad\nname"); e == nil {
|
||||
t.Fatal("accepted control character")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorruptIdentityNeverReplaced(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "identity.json")
|
||||
bad := []byte(`{"version":1,"private_key":"bad"}`)
|
||||
os.WriteFile(p, bad, 0600)
|
||||
if _, e := OpenStore(dir); e == nil {
|
||||
t.Fatal("corrupt state accepted")
|
||||
}
|
||||
got, _ := os.ReadFile(p)
|
||||
if !bytes.Equal(got, bad) {
|
||||
t.Fatal("identity replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginOneUseConcurrentAndExpires(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
now := time.Now()
|
||||
s.Now = func() time.Time { return now }
|
||||
v := strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
|
||||
var wg sync.WaitGroup
|
||||
codes := make(chan int, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); codes <- call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil).Code }()
|
||||
}
|
||||
wg.Wait()
|
||||
close(codes)
|
||||
success := 0
|
||||
for c := range codes {
|
||||
if c == 200 {
|
||||
success++
|
||||
} else if c != 401 {
|
||||
t.Fatal(c)
|
||||
}
|
||||
}
|
||||
if success != 1 {
|
||||
t.Fatal(success)
|
||||
}
|
||||
v = strings.TrimPrefix(s.IssueLogin(), s.Origin+"/#login=")
|
||||
now = now.Add(time.Minute)
|
||||
if call(s, "POST", "/api/session", `{"token":"`+v+`"}`, nil).Code != 401 {
|
||||
t.Fatal("expired launch accepted")
|
||||
}
|
||||
c := login(t, s)
|
||||
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatal(c)
|
||||
}
|
||||
now = now.Add(8 * time.Hour)
|
||||
if call(s, "GET", "/api/status", "", c).Code != 401 {
|
||||
t.Fatal("expired session accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedAndCrossSiteRequestsDenied(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
c := login(t, s)
|
||||
for _, path := range []string{"/api/status", "/api/report"} {
|
||||
if call(s, "GET", path, "", nil).Code != 401 {
|
||||
t.Fatal(path)
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"origin", "host", "metadata", "missing-origin"} {
|
||||
r := httptest.NewRequest("PUT", s.Origin+"/api/name", strings.NewReader(`{"name":"attacker"}`))
|
||||
r.AddCookie(c)
|
||||
r.Header.Set("Origin", s.Origin)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
switch kind {
|
||||
case "origin":
|
||||
r.Header.Set("Origin", "https://evil.example")
|
||||
case "host":
|
||||
r.Host = "evil.example"
|
||||
case "metadata":
|
||||
r.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
case "missing-origin":
|
||||
r.Header.Del("Origin")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 403 {
|
||||
t.Fatal(kind, w.Code)
|
||||
}
|
||||
}
|
||||
_, name := s.Store.Public()
|
||||
if name == "attacker" {
|
||||
t.Fatal("cross-site state changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportDoesNotLeakPrivateState(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
c := login(t, s)
|
||||
w := call(s, "GET", "/api/report", "", c)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
id, _ := s.Store.Public()
|
||||
for _, secret := range []string{"private-host", "192.168.10.4", id, "private_key", c.Value} {
|
||||
if strings.Contains(w.Body.String(), secret) {
|
||||
t.Fatal("report leaked", secret)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Disposition"), "attachment") {
|
||||
t.Fatal("not downloadable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutAndStrictJSON(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
c := login(t, s)
|
||||
for _, body := range []string{`{"name":"x"} {}`, `{"name":"x","other":1}`} {
|
||||
if call(s, "PUT", "/api/name", body, c).Code != 400 {
|
||||
t.Fatal("accepted invalid document")
|
||||
}
|
||||
}
|
||||
if call(s, "POST", "/api/logout", `{}`, c).Code != 200 {
|
||||
t.Fatal("logout failed")
|
||||
}
|
||||
if call(s, "GET", "/api/status", "", c).Code != 401 {
|
||||
t.Fatal("session survived logout")
|
||||
}
|
||||
}
|
||||
|
||||
func syntheticKey() string {
|
||||
b := make([]byte, 51)
|
||||
binary.BigEndian.PutUint32(b[:4], 11)
|
||||
copy(b[4:15], "ssh-ed25519")
|
||||
binary.BigEndian.PutUint32(b[15:19], 32)
|
||||
for i := 19; i < len(b); i++ {
|
||||
b[i] = byte(i)
|
||||
}
|
||||
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func TestSSHKeyEnrollmentRejectsCommandsAndRoot(t *testing.T) {
|
||||
a := &AccessStore{Path: filepath.Join(t.TempDir(), "ssh-keys.json"), Users: func() []string { return []string{"operator"} }}
|
||||
key := syntheticKey()
|
||||
for _, bad := range []string{"command=\"sh\" " + key, key + "\n" + key, "-----BEGIN PRIVATE KEY-----", "ssh-ed25519 YQ=="} {
|
||||
if e := a.Add("operator", "laptop", bad); e == nil {
|
||||
t.Fatal("unsafe key accepted")
|
||||
}
|
||||
}
|
||||
if e := a.Add("root", "laptop", key); e == nil {
|
||||
t.Fatal("root accepted")
|
||||
}
|
||||
if e := a.Add("operator", "laptop", key+" private-comment"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := a.Add("operator", "laptop", key); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
keys, e := a.List()
|
||||
if e != nil || len(keys) != 1 || keys[0].PublicKey != key {
|
||||
t.Fatal(keys, e)
|
||||
}
|
||||
if e := a.Remove("operator", keys[0].ID); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
keys, _ = a.List()
|
||||
if len(keys) != 0 {
|
||||
t.Fatal("revocation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxInventoryUsesActualMetadataWithoutSerial(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for p, v := range map[string]string{"etc/os-release": "PRETTY_NAME=\"Synthetic Linux\"", "proc/meminfo": "MemTotal: 8388608 kB\nMemAvailable: 4000000 kB", "sys/bus/usb/devices/1-2/idVendor": "8086", "sys/bus/usb/devices/1-2/idProduct": "0b5c", "sys/bus/usb/devices/1-2/product": "Synthetic camera", "sys/bus/usb/devices/1-2/speed": "5000", "sys/bus/usb/devices/1-2/serial": "do-not-read"} {
|
||||
target := filepath.Join(dir, p)
|
||||
os.MkdirAll(filepath.Dir(target), 0700)
|
||||
os.WriteFile(target, []byte(v), 0600)
|
||||
}
|
||||
v := Host(dir)
|
||||
if v.OS != "Synthetic Linux" || *v.MemoryKiB != 8388608 || !v.USBReadable || len(v.USB) != 1 || v.USB[0].Speed != "5000" {
|
||||
t.Fatal(v)
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
if bytes.Contains(b, []byte("do-not-read")) {
|
||||
t.Fatal("serial leaked")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Invitation struct {
|
||||
ID string `json:"id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
SecretHash string `json:"secret_hash,omitempty"`
|
||||
}
|
||||
type CoreBinding struct {
|
||||
BindingID string `json:"binding_id"`
|
||||
CoreID string `json:"core_id"`
|
||||
CoreName string `json:"core_name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAPEM string `json:"ca_pem"`
|
||||
ClientPEM string `json:"client_pem"`
|
||||
Receipt string `json:"receipt,omitempty"`
|
||||
OfferHash string `json:"offer_hash,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
type PairState struct {
|
||||
Schema string `json:"schema"`
|
||||
Phase string `json:"phase"`
|
||||
Invitation *Invitation `json:"invitation,omitempty"`
|
||||
Binding *CoreBinding `json:"binding,omitempty"`
|
||||
Revocations []CoreBinding `json:"revocations,omitempty"`
|
||||
}
|
||||
type Pairing struct {
|
||||
Sensors *Sensors
|
||||
mu sync.Mutex
|
||||
path string
|
||||
store *Store
|
||||
state PairState
|
||||
now func() time.Time
|
||||
inventory func() Inventory
|
||||
version string
|
||||
lastSeen int64
|
||||
connection string
|
||||
listenError string
|
||||
failureWindow int64
|
||||
failures int
|
||||
clients map[string]*http.Client
|
||||
}
|
||||
|
||||
func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) {
|
||||
p := &Pairing{store: store, path: filepath.Join(dir, "core-binding.json"), now: time.Now, inventory: inventory, version: version, connection: "offline", clients: make(map[string]*http.Client), state: PairState{Schema: PairSchema, Phase: "unpaired"}}
|
||||
data, e := os.ReadFile(p.path)
|
||||
if os.IsNotExist(e) {
|
||||
return p, nil
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
info, e := os.Lstat(p.path)
|
||||
if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || len(data) > 65536 {
|
||||
return nil, errors.New("invalid Core binding permissions or size")
|
||||
}
|
||||
if json.Unmarshal(data, &p.state) != nil || p.state.Schema != PairSchema {
|
||||
return nil, errors.New("invalid Core binding; recovery required")
|
||||
}
|
||||
switch p.state.Phase {
|
||||
case "unpaired", "inviting", "pending", "paired", "revoked":
|
||||
default:
|
||||
return nil, errors.New("unknown Core binding state")
|
||||
}
|
||||
if (p.state.Phase == "pending" || p.state.Phase == "paired") && p.state.Binding == nil {
|
||||
return nil, errors.New("incomplete Core binding")
|
||||
}
|
||||
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation == nil {
|
||||
return nil, errors.New("incomplete invitation")
|
||||
}
|
||||
if (p.state.Phase == "paired" || p.state.Phase == "revoked") && p.state.Invitation != nil {
|
||||
next := p.state
|
||||
next.Invitation = nil
|
||||
if e := p.save(next); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func savePrivateJSON(path string, value any) error {
|
||||
data, e := json.Marshal(value)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
f, e := os.CreateTemp(filepath.Dir(path), ".binding-*")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, e = f.Write(data); e != nil {
|
||||
f.Close()
|
||||
return e
|
||||
}
|
||||
if e = f.Sync(); e != nil {
|
||||
f.Close()
|
||||
return e
|
||||
}
|
||||
if e = f.Close(); e != nil {
|
||||
return e
|
||||
}
|
||||
if e = os.Rename(f.Name(), path); e != nil {
|
||||
return e
|
||||
}
|
||||
dir, e := os.Open(filepath.Dir(path))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer dir.Close()
|
||||
return dir.Sync()
|
||||
}
|
||||
func (p *Pairing) save(next PairState) error {
|
||||
if e := savePrivateJSON(p.path, next); e != nil {
|
||||
return e
|
||||
}
|
||||
p.state = next
|
||||
return nil
|
||||
}
|
||||
func digest(value string) string { h := sha256.Sum256([]byte(value)); return hex.EncodeToString(h[:]) }
|
||||
func (p *Pairing) expire() error {
|
||||
if (p.state.Phase == "inviting" && p.state.Invitation.ExpiresAt <= p.now().Unix()) || (p.state.Phase == "pending" && p.state.Binding.ExpiresAt <= p.now().Unix()) {
|
||||
return p.save(PairState{Schema: PairSchema, Phase: "unpaired", Revocations: p.state.Revocations})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (p *Pairing) status() map[string]any {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.expire()
|
||||
id, _ := p.store.Public()
|
||||
out := map[string]any{"phase": p.state.Phase, "node_id": id, "connection": p.connection, "last_seen": p.lastSeen, "notice": p.listenError, "addresses": p.addresses(), "pending_revocations": len(p.state.Revocations)}
|
||||
if i := p.state.Invitation; i != nil {
|
||||
out["invitation"] = map[string]any{"id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt}
|
||||
}
|
||||
if b := p.state.Binding; b != nil {
|
||||
out["binding"] = map[string]any{"binding_id": b.BindingID, "core_id": b.CoreID, "core_name": b.CoreName, "endpoint": b.Endpoint}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (p *Pairing) addresses() []string {
|
||||
result := []string{}
|
||||
for _, network := range p.inventory().Networks {
|
||||
if !network.Up {
|
||||
continue
|
||||
}
|
||||
for _, alias := range network.Addresses {
|
||||
address := alias
|
||||
for i, c := range address {
|
||||
if c == '/' {
|
||||
address = address[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if PrivateAddress(address) {
|
||||
result = append(result, address)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (p *Pairing) invite(address string) (map[string]any, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if e := p.expire(); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if p.state.Phase == "paired" || p.state.Phase == "pending" {
|
||||
return nil, errors.New("Сначала отмените текущую привязку")
|
||||
}
|
||||
found := false
|
||||
for _, a := range p.addresses() {
|
||||
if a == address {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.New("Выберите доступный частный адрес этого БК")
|
||||
}
|
||||
secret := token()
|
||||
i := &Invitation{ID: token(), Endpoint: "https://" + address + ":" + PairPort, ExpiresAt: p.now().Add(10 * time.Minute).Unix(), SecretHash: digest(secret)}
|
||||
if e := p.save(PairState{Schema: PairSchema, Phase: "inviting", Invitation: i, Revocations: p.state.Revocations}); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
p.connection = "offline"
|
||||
p.lastSeen = 0
|
||||
id, _ := p.store.Public()
|
||||
code, _ := json.Marshal(map[string]any{"schema": PairSchema, "node_id": id, "id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt, "secret": secret})
|
||||
return map[string]any{"code": "MCN1." + base64.RawURLEncoding.EncodeToString(code), "expires_at": i.ExpiresAt}, nil
|
||||
}
|
||||
func (p *Pairing) cancel() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
revocations := append([]CoreBinding(nil), p.state.Revocations...)
|
||||
if p.state.Binding != nil {
|
||||
if len(revocations) >= 8 {
|
||||
return errors.New("Дождитесь доставки предыдущих отзывов доверия")
|
||||
}
|
||||
revocations = append(revocations, *p.state.Binding)
|
||||
}
|
||||
p.connection = "offline"
|
||||
p.listenError = ""
|
||||
p.lastSeen = 0
|
||||
return p.save(PairState{Schema: PairSchema, Phase: "unpaired", Revocations: revocations})
|
||||
}
|
||||
func (p *Pairing) checkInvitation(id, secret string) bool {
|
||||
now := p.now().Unix()
|
||||
if now-p.failureWindow >= 60 {
|
||||
p.failureWindow = now
|
||||
p.failures = 0
|
||||
}
|
||||
if p.failures >= 32 {
|
||||
return false
|
||||
}
|
||||
i := p.state.Invitation
|
||||
if i == nil || i.ID != id || i.ExpiresAt <= now || subtle.ConstantTimeCompare([]byte(i.SecretHash), []byte(digest(secret))) != 1 {
|
||||
p.failures++
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const PairSchema = "missioncore.node-pairing/v1"
|
||||
const PairPort = "8781"
|
||||
const CorePort = "8782"
|
||||
|
||||
func PrivateAddress(value string) bool {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil || ip.To4() == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsPrivate() || (ip.To4()[0] == 100 && ip.To4()[1] >= 64 && ip.To4()[1] <= 127)
|
||||
}
|
||||
func privateEndpoint(value, port string) bool {
|
||||
u, e := url.Parse(value)
|
||||
return e == nil && u.Scheme == "https" && u.User == nil && u.Path == "" && u.RawQuery == "" && u.Fragment == "" && u.Port() == port && PrivateAddress(u.Hostname())
|
||||
}
|
||||
func keyID(prefix string, key ed25519.PublicKey) string {
|
||||
sum := sha256.Sum256(key)
|
||||
return prefix + hex.EncodeToString(sum[:])
|
||||
}
|
||||
func (s *Store) pairingKey() ed25519.PrivateKey {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append(ed25519.PrivateKey(nil), s.state.PrivateKey...)
|
||||
}
|
||||
func bootstrapCertificate(key ed25519.PrivateKey, address string) (tls.Certificate, error) {
|
||||
serial, e := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if e != nil {
|
||||
return tls.Certificate{}, e
|
||||
}
|
||||
spec := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: "Mission Core Node"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(24 * time.Hour), IPAddresses: []net.IP{net.ParseIP(address)}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true}
|
||||
der, e := x509.CreateCertificate(rand.Reader, spec, spec, key.Public(), key)
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, e
|
||||
}
|
||||
func bindingTLS(b CoreBinding, key ed25519.PrivateKey) (*tls.Config, error) {
|
||||
if !privateEndpoint(b.Endpoint, CorePort) {
|
||||
return nil, errors.New("Core address is not private")
|
||||
}
|
||||
block, _ := pem.Decode([]byte(b.CAPEM))
|
||||
if block == nil {
|
||||
return nil, errors.New("missing Core certificate")
|
||||
}
|
||||
ca, e := x509.ParseCertificate(block.Bytes)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
pub, ok := ca.PublicKey.(ed25519.PublicKey)
|
||||
if !ok || keyID("core_", pub) != b.CoreID || !ca.IsCA || ca.CheckSignatureFrom(ca) != nil {
|
||||
return nil, errors.New("Core identity mismatch")
|
||||
}
|
||||
block, _ = pem.Decode([]byte(b.ClientPEM))
|
||||
if block == nil {
|
||||
return nil, errors.New("missing client certificate")
|
||||
}
|
||||
cert, e := x509.ParseCertificate(block.Bytes)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(ca)
|
||||
if _, e = cert.Verify(x509.VerifyOptions{Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
nodePub, ok := cert.PublicKey.(ed25519.PublicKey)
|
||||
if !ok || !nodePub.Equal(key.Public()) {
|
||||
return nil, errors.New("client identity mismatch")
|
||||
}
|
||||
return &tls.Config{MinVersion: tls.VersionTLS13, RootCAs: roots, Certificates: []tls.Certificate{{Certificate: [][]byte{cert.Raw}, PrivateKey: key}}}, nil
|
||||
}
|
||||
|
||||
// Once the issued credential expires, the old Core cannot accept this binding.
|
||||
func clientExpired(b CoreBinding) bool {
|
||||
block, _ := pem.Decode([]byte(b.ClientPEM))
|
||||
if block == nil {
|
||||
return false
|
||||
}
|
||||
cert, e := x509.ParseCertificate(block.Bytes)
|
||||
return e == nil && time.Now().After(cert.NotAfter)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Bound unauthenticated bootstrap sockets before TLS allocates a goroutine.
|
||||
type pairingListener struct {
|
||||
net.Listener
|
||||
slots chan struct{}
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (l *pairingListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case l.slots <- struct{}{}:
|
||||
case <-l.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
c, e := l.Listener.Accept()
|
||||
if e != nil {
|
||||
<-l.slots
|
||||
return nil, e
|
||||
}
|
||||
return &pairingConn{Conn: c, release: func() { <-l.slots }}, nil
|
||||
}
|
||||
func (l *pairingListener) Close() error {
|
||||
l.once.Do(func() { close(l.done) })
|
||||
return l.Listener.Close()
|
||||
}
|
||||
|
||||
type pairingConn struct {
|
||||
net.Conn
|
||||
release func()
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (c *pairingConn) Close() error { e := c.Conn.Close(); c.once.Do(c.release); return e }
|
||||
@@ -0,0 +1,168 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testPairing(t *testing.T) (*Pairing, map[string]any) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store, e := OpenStore(dir)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
p, e := OpenPairing(store, dir, "test", func() Inventory {
|
||||
return Inventory{Networks: []Network{{Name: "test", Up: true, Addresses: []string{"192.168.10.4/24"}}}}
|
||||
})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
out, e := p.invite("192.168.10.4")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
raw, e := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(out["code"].(string), "MCN1."))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var invitation map[string]any
|
||||
if json.Unmarshal(raw, &invitation) != nil {
|
||||
t.Fatal("invitation")
|
||||
}
|
||||
return p, invitation
|
||||
}
|
||||
func testCoreBinding(t *testing.T, p *Pairing) CoreBinding {
|
||||
t.Helper()
|
||||
pub, key, e := ed25519.GenerateKey(rand.Reader)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test Core"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
|
||||
der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
ca, _ = x509.ParseCertificate(der)
|
||||
cert := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}
|
||||
leaf, e := x509.CreateCertificate(rand.Reader, cert, ca, p.store.pairingKey().Public(), key)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return CoreBinding{BindingID: token(), CoreID: keyID("core_", pub), CoreName: "Test", Endpoint: "https://192.168.10.5:8782", CAPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), ClientPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf}))}
|
||||
}
|
||||
func pairCall(p *Pairing, path string, body any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
|
||||
r.RemoteAddr = "192.168.10.5:42000"
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
p.remoteHandler().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
func TestPairingDurableCommitConflictAndReplay(t *testing.T) {
|
||||
p, i := testPairing(t)
|
||||
b := testCoreBinding(t, p)
|
||||
offer := map[string]any{"id": i["id"], "secret": i["secret"], "binding": b}
|
||||
first := pairCall(p, "/v1/pair/offer", offer)
|
||||
if first.Code != 200 {
|
||||
t.Fatal(first.Code, first.Body.String())
|
||||
}
|
||||
second := pairCall(p, "/v1/pair/offer", offer)
|
||||
if second.Code != 200 || first.Body.String() != second.Body.String() {
|
||||
t.Fatal("retry changed receipt")
|
||||
}
|
||||
b.BindingID = token()
|
||||
offer["binding"] = b
|
||||
if pairCall(p, "/v1/pair/offer", offer).Code != 409 {
|
||||
t.Fatal("conflicting owner accepted")
|
||||
}
|
||||
// A process restart retains the pending receipt and finishes the same binding.
|
||||
restored, e := OpenPairing(p.store, strings.TrimSuffix(p.path, "/core-binding.json"), "test", p.inventory)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
commit := map[string]string{"id": restored.state.Binding.BindingID, "receipt": restored.state.Binding.Receipt}
|
||||
if pairCall(restored, "/v1/pair/commit", commit).Code != 200 {
|
||||
t.Fatal("commit failed")
|
||||
}
|
||||
if pairCall(restored, "/v1/pair/inspect", map[string]any{"id": i["id"], "secret": i["secret"]}).Code != 410 {
|
||||
t.Fatal("consumed code admitted")
|
||||
}
|
||||
if _, e = restored.invite("192.168.10.4"); e == nil {
|
||||
t.Fatal("paired Node offered another invitation")
|
||||
}
|
||||
if restored.state.Invitation != nil {
|
||||
t.Fatal("consumed invitation retained")
|
||||
}
|
||||
raw, _ := json.Marshal(restored.status())
|
||||
if strings.Contains(string(raw), i["secret"].(string)) || strings.Contains(string(raw), "client_pem") {
|
||||
t.Fatal("status leaked trust")
|
||||
}
|
||||
if e = restored.cancel(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if pairCall(restored, "/v1/pair/commit", commit).Code == 200 {
|
||||
t.Fatal("cancelled binding resurrected")
|
||||
}
|
||||
if len(restored.state.Revocations) != 1 {
|
||||
t.Fatal("revocation not durable")
|
||||
}
|
||||
}
|
||||
func TestPairingExpiryAndPrivateAddressAdmission(t *testing.T) {
|
||||
p, i := testPairing(t)
|
||||
for _, address := range []string{"127.0.0.1", "0.0.0.0", "8.8.8.8", "192.168.10.99", "::1"} {
|
||||
if _, e := p.invite(address); e == nil {
|
||||
t.Fatal("nonlocal address accepted", address)
|
||||
}
|
||||
}
|
||||
p.now = func() time.Time { return time.Unix(int64(i["expires_at"].(float64))+1, 0) }
|
||||
if pairCall(p, "/v1/pair/inspect", map[string]any{"id": i["id"], "secret": i["secret"]}).Code != 410 {
|
||||
t.Fatal("expired code accepted")
|
||||
}
|
||||
if p.state.Phase != "unpaired" {
|
||||
t.Fatal(p.state.Phase)
|
||||
}
|
||||
}
|
||||
func TestPairingRejectsForeignCertificateAndOpenPermissions(t *testing.T) {
|
||||
p, _ := testPairing(t)
|
||||
b := testCoreBinding(t, p)
|
||||
if _, e := bindingTLS(b, p.store.pairingKey()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
b.CoreID = "core_" + strings.Repeat("0", 64)
|
||||
if _, e := bindingTLS(b, p.store.pairingKey()); e == nil {
|
||||
t.Fatal("unmatched Core pin")
|
||||
}
|
||||
b = testCoreBinding(t, p)
|
||||
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||
if _, e := bindingTLS(b, other); e == nil {
|
||||
t.Fatal("foreign Node certificate")
|
||||
}
|
||||
os.Chmod(p.path, 0644)
|
||||
if _, e := OpenPairing(p.store, strings.TrimSuffix(p.path, "/core-binding.json"), "test", p.inventory); e == nil {
|
||||
t.Fatal("open trust file accepted")
|
||||
}
|
||||
}
|
||||
func TestPairingLocalRoutesRequireOperatorSession(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
p, _ := testPairing(t)
|
||||
s.Pairing = p
|
||||
if call(s, "GET", "/api/core", "", nil).Code != 401 {
|
||||
t.Fatal("unauthenticated access")
|
||||
}
|
||||
if call(s, "GET", "/api/core", "", login(t, s)).Code != 200 {
|
||||
t.Fatal("operator denied")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (p *Pairing) localRoutes(mux *http.ServeMux, s *Server) {
|
||||
mux.HandleFunc("GET /api/core", func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authorized(w, r) {
|
||||
reply(w, 200, p.status())
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("POST /api/core/invitation", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Address string `json:"address"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
out, e := p.invite(body.Address)
|
||||
if e != nil {
|
||||
reply(w, 409, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
reply(w, 200, out)
|
||||
})
|
||||
mux.HandleFunc("DELETE /api/core", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
if e := p.cancel(); e != nil {
|
||||
reply(w, 409, map[string]string{"error": "Не удалось отменить привязку. Повторите действие."})
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]bool{"ok": true})
|
||||
})
|
||||
}
|
||||
func pairDecode(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||
if r.Method != "POST" || r.Header.Get("Content-Type") != "application/json" || r.Header.Get("Origin") != "" {
|
||||
http.Error(w, "Invalid request", 400)
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(value) != nil || decoder.Decode(new(any)) != io.EOF {
|
||||
http.Error(w, "Invalid request", 400)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (p *Pairing) remoteHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
host, _, e := net.SplitHostPort(r.RemoteAddr)
|
||||
if e != nil || !PrivateAddress(host) {
|
||||
http.Error(w, "Private peer required", 403)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Secret string `json:"secret"`
|
||||
Binding *CoreBinding `json:"binding,omitempty"`
|
||||
Receipt string `json:"receipt,omitempty"`
|
||||
}
|
||||
if !pairDecode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.expire() != nil {
|
||||
http.Error(w, "State unavailable", 503)
|
||||
return
|
||||
}
|
||||
id, name := p.store.Public()
|
||||
switch r.URL.Path {
|
||||
case "/v1/pair/inspect":
|
||||
if p.state.Phase != "inviting" || !p.checkInvitation(body.ID, body.Secret) {
|
||||
http.Error(w, "Invitation expired, consumed or cancelled", 410)
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]any{"schema": PairSchema, "node_id": id, "name": name, "version": p.version, "host": p.inventory()})
|
||||
case "/v1/pair/offer":
|
||||
if !p.checkInvitation(body.ID, body.Secret) || body.Binding == nil {
|
||||
http.Error(w, "Invitation expired, consumed or cancelled", 410)
|
||||
return
|
||||
}
|
||||
b := *body.Binding
|
||||
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 {
|
||||
http.Error(w, "Invalid binding", 400)
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(b)
|
||||
hash := digest(string(raw))
|
||||
if p.state.Phase == "pending" || p.state.Phase == "paired" {
|
||||
if p.state.Binding.OfferHash != hash {
|
||||
http.Error(w, "Another Core already claimed this Node", 409)
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]string{"receipt": p.state.Binding.Receipt, "node_id": id})
|
||||
return
|
||||
}
|
||||
if p.state.Phase != "inviting" {
|
||||
http.Error(w, "Invitation consumed", 410)
|
||||
return
|
||||
}
|
||||
if _, e = bindingTLS(b, p.store.pairingKey()); e != nil {
|
||||
http.Error(w, "Invalid Core trust", 400)
|
||||
return
|
||||
}
|
||||
b.Receipt = token()
|
||||
b.OfferHash = hash
|
||||
b.ExpiresAt = p.state.Invitation.ExpiresAt
|
||||
next := p.state
|
||||
next.Phase = "pending"
|
||||
next.Binding = &b
|
||||
if p.save(next) != nil {
|
||||
http.Error(w, "State unavailable", 503)
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]string{"receipt": b.Receipt, "node_id": id})
|
||||
case "/v1/pair/commit":
|
||||
b := p.state.Binding
|
||||
if b == nil || body.ID != b.BindingID || body.Receipt == "" || digest(body.Receipt) != digest(b.Receipt) || (p.state.Phase != "pending" && p.state.Phase != "paired") {
|
||||
http.Error(w, "No matching pending binding", 409)
|
||||
return
|
||||
}
|
||||
next := p.state
|
||||
next.Phase = "paired"
|
||||
next.Invitation = nil
|
||||
if p.save(next) != nil {
|
||||
http.Error(w, "State unavailable", 503)
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]any{"node_id": id, "binding_id": b.BindingID, "phase": "paired"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Pairing) Run(ctx context.Context) {
|
||||
go p.channel(ctx)
|
||||
var server *http.Server
|
||||
endpoint := ""
|
||||
closeServer := func() {
|
||||
if server != nil {
|
||||
timeout, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
_ = server.Shutdown(timeout)
|
||||
cancel()
|
||||
server = nil
|
||||
}
|
||||
endpoint = ""
|
||||
}
|
||||
defer closeServer()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
p.mu.Lock()
|
||||
_ = p.expire()
|
||||
desired := ""
|
||||
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation != nil {
|
||||
desired = p.state.Invitation.Endpoint
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if desired != endpoint {
|
||||
closeServer()
|
||||
if desired != "" {
|
||||
u, _ := url.Parse(desired)
|
||||
cert, e := bootstrapCertificate(p.store.pairingKey(), u.Hostname())
|
||||
var listener net.Listener
|
||||
if e == nil {
|
||||
listener, e = net.Listen("tcp4", u.Host)
|
||||
}
|
||||
p.mu.Lock()
|
||||
if e != nil {
|
||||
p.listenError = "Не удалось открыть частное подключение. Проверьте адрес и создайте приглашение повторно."
|
||||
} else {
|
||||
p.listenError = ""
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if e == nil {
|
||||
server = &http.Server{Handler: p.remoteHandler(), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}}
|
||||
endpoint = desired
|
||||
go func(s *http.Server, l net.Listener) { _ = s.Serve(tls.NewListener(l, s.TLSConfig)) }(server, &pairingListener{Listener: listener, slots: make(chan struct{}, 16), done: make(chan struct{})})
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload any) (map[string]json.RawMessage, int, error) {
|
||||
config, e := bindingTLS(b, p.store.pairingKey())
|
||||
if e != nil {
|
||||
return nil, 0, e
|
||||
}
|
||||
cacheKey := b.BindingID + digest(b.ClientPEM)
|
||||
client := p.clients[cacheKey]
|
||||
if client == nil {
|
||||
transport := &http.Transport{TLSClientConfig: config, Proxy: nil, MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 15 * time.Second, TLSHandshakeTimeout: 4 * time.Second, DialContext: (&net.Dialer{Timeout: 4 * time.Second}).DialContext}
|
||||
client = &http.Client{Transport: transport, Timeout: 8 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirects forbidden") }}
|
||||
p.clients[cacheKey] = client
|
||||
}
|
||||
data, e := json.Marshal(payload)
|
||||
if e != nil {
|
||||
return nil, 0, e
|
||||
}
|
||||
request, e := http.NewRequestWithContext(ctx, "POST", b.Endpoint+path, bytes.NewReader(data))
|
||||
if e != nil {
|
||||
return nil, 0, e
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, e := client.Do(request)
|
||||
if e != nil {
|
||||
return nil, 0, e
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var out map[string]json.RawMessage
|
||||
if json.NewDecoder(io.LimitReader(response.Body, 1048576)).Decode(&out) != nil {
|
||||
return nil, response.StatusCode, errors.New("invalid Core response")
|
||||
}
|
||||
return out, response.StatusCode, nil
|
||||
}
|
||||
func (p *Pairing) channel(ctx context.Context) {
|
||||
instance := "agent_" + token()
|
||||
var changed <-chan struct{}
|
||||
if p.Sensors != nil {
|
||||
var unsubscribe func()
|
||||
changed, unsubscribe = p.Sensors.events.subscribe()
|
||||
defer unsubscribe()
|
||||
}
|
||||
timer := time.NewTicker(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
defer func() {
|
||||
for _, c := range p.clients {
|
||||
c.CloseIdleConnections()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
p.mu.Lock()
|
||||
var binding *CoreBinding
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil {
|
||||
copy := *p.state.Binding
|
||||
binding = ©
|
||||
}
|
||||
revocations := append([]CoreBinding(nil), p.state.Revocations...)
|
||||
p.mu.Unlock()
|
||||
wanted := make(map[string]bool)
|
||||
if binding != nil {
|
||||
wanted[binding.BindingID+digest(binding.ClientPEM)] = true
|
||||
}
|
||||
for _, b := range revocations {
|
||||
wanted[b.BindingID+digest(b.ClientPEM)] = true
|
||||
}
|
||||
for key, c := range p.clients {
|
||||
if !wanted[key] {
|
||||
c.CloseIdleConnections()
|
||||
delete(p.clients, key)
|
||||
}
|
||||
}
|
||||
if binding != nil {
|
||||
id, name := p.store.Public()
|
||||
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
|
||||
if p.Sensors != nil {
|
||||
inv := p.Sensors.Inventory()
|
||||
payload["devices"] = inv["items"]
|
||||
payload["sensor_state"] = inv
|
||||
payload["sensor_results"] = p.Sensors.RemoteResults()
|
||||
}
|
||||
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
|
||||
p.mu.Lock()
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
|
||||
if e == nil && status == 200 {
|
||||
p.connection = "online"
|
||||
p.lastSeen = p.now().Unix()
|
||||
if p.Sensors != nil {
|
||||
var ack []string
|
||||
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
||||
p.Sensors.Acknowledge(ack)
|
||||
}
|
||||
var commands []SensorCommand
|
||||
if json.Unmarshal(result["sensor_commands"], &commands) == nil {
|
||||
for _, c := range commands {
|
||||
_, _ = p.Sensors.Submit(c, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
var cert string
|
||||
if json.Unmarshal(result["client_pem"], &cert) == nil && cert != "" && cert != binding.ClientPEM {
|
||||
next := *binding
|
||||
next.ClientPEM = cert
|
||||
if _, e := bindingTLS(next, p.store.pairingKey()); e == nil {
|
||||
state := p.state
|
||||
state.Binding = &next
|
||||
_ = p.save(state)
|
||||
}
|
||||
}
|
||||
} else if e == nil && status == 410 {
|
||||
next := p.state
|
||||
next.Phase = "revoked"
|
||||
_ = p.save(next)
|
||||
p.connection = "revoked"
|
||||
} else {
|
||||
p.connection = "offline"
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
for _, b := range revocations {
|
||||
id, _ := p.store.Public()
|
||||
_, status, e := p.send(ctx, b, "/v1/node/unpair", map[string]string{"schema": PairSchema, "binding_id": b.BindingID, "node_id": id})
|
||||
if (e == nil && (status == 200 || status == 410)) || clientExpired(b) {
|
||||
p.mu.Lock()
|
||||
next := p.state
|
||||
next.Revocations = nil
|
||||
for _, item := range p.state.Revocations {
|
||||
if item.BindingID != b.BindingID {
|
||||
next.Revocations = append(next.Revocations, item)
|
||||
}
|
||||
}
|
||||
_ = p.save(next)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
case <-changed:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Events are hints to reconcile OS/SDK state, never commands or trusted inventory.
|
||||
// A bounded latest-state signal prevents a slow viewer from blocking discovery.
|
||||
type sensorEvents struct {
|
||||
mu sync.Mutex
|
||||
listeners map[chan struct{}]bool
|
||||
}
|
||||
|
||||
func (e *sensorEvents) subscribe() (<-chan struct{}, func()) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.listeners == nil {
|
||||
e.listeners = map[chan struct{}]bool{}
|
||||
}
|
||||
c := make(chan struct{}, 1)
|
||||
e.listeners[c] = true
|
||||
return c, func() { e.mu.Lock(); delete(e.listeners, c); e.mu.Unlock() }
|
||||
}
|
||||
func (e *sensorEvents) notify() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for c := range e.listeners {
|
||||
select {
|
||||
case c <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func usbEvents(input io.Reader, changed func()) {
|
||||
scanner := bufio.NewScanner(input)
|
||||
fields := map[string]string{}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
if fields["SUBSYSTEM"] == "usb" && fields["DEVTYPE"] == "usb_device" &&
|
||||
(fields["ACTION"] == "add" || fields["ACTION"] == "remove" || fields["ACTION"] == "change") {
|
||||
changed()
|
||||
}
|
||||
fields = map[string]string{}
|
||||
} else if key, value, ok := strings.Cut(line, "="); ok && len(fields) < 128 {
|
||||
fields[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sensors) WatchUSB(ctx context.Context) {
|
||||
for ctx.Err() == nil {
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/udevadm", "monitor", "--udev", "--subsystem-match=usb", "--property")
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C"}
|
||||
pipe, err := cmd.StdoutPipe()
|
||||
if err == nil && cmd.Start() == nil {
|
||||
usbEvents(pipe, s.events.notify)
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
// A failed monitor cannot disable the existing heartbeat reconciliation.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sensors) stream(w http.ResponseWriter, r *http.Request, server *Server) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
if _, ok := w.(http.Flusher); !ok {
|
||||
http.Error(w, "Streaming unavailable", 503)
|
||||
return
|
||||
}
|
||||
changed, unsubscribe := s.events.subscribe()
|
||||
defer unsubscribe()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
controller := http.NewResponseController(w)
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-changed:
|
||||
// USB devices expose several interfaces; coalesce the enumeration burst.
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
value := s.Inventory()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = controller.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if _, err = fmt.Fprintf(w, "retry: 3000\ndata: %s\n\n", data); err != nil {
|
||||
return
|
||||
}
|
||||
if controller.Flush() != nil {
|
||||
return
|
||||
}
|
||||
delay := 15 * time.Second
|
||||
for _, raw := range value["operations"].([]any) {
|
||||
if raw.(map[string]any)["state"] == "running" {
|
||||
delay = 2 * time.Second
|
||||
break
|
||||
}
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(delay)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUSBEventsAreHintsAndDoNotMultiplyInterfaces(t *testing.T) {
|
||||
input := "UDEV [1] add /devices/example (usb)\nACTION=add\nSUBSYSTEM=usb\nDEVTYPE=usb_device\n\n" +
|
||||
"ACTION=add\nSUBSYSTEM=usb\nDEVTYPE=usb_interface\n\n" +
|
||||
"ACTION=remove\nSUBSYSTEM=usb\nDEVTYPE=usb_device\n\n" +
|
||||
"ACTION=add\nSUBSYSTEM=net\nDEVTYPE=usb_device\n\n"
|
||||
e := sensorEvents{}
|
||||
first, closeFirst := e.subscribe()
|
||||
second, closeSecond := e.subscribe()
|
||||
defer closeSecond()
|
||||
count := 0
|
||||
usbEvents(strings.NewReader(input), func() { count++; e.notify() })
|
||||
if count != 2 || len(first) != 1 || len(second) != 1 {
|
||||
t.Fatalf("events=%d first=%d second=%d", count, len(first), len(second))
|
||||
}
|
||||
<-first
|
||||
closeFirst()
|
||||
e.notify()
|
||||
if len(first) != 0 {
|
||||
t.Fatal("closed viewer still receives events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensorEventStreamRequiresLocalSession(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.Sensors, _ = OpenSensors(t.TempDir(), "node_test")
|
||||
if response := call(s, "GET", "/api/devices/events", "", nil); response.Code != 401 {
|
||||
t.Fatal("unauthenticated device stream", response.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SensorSchema = "missioncore.nodedc/plugin-sdk/v0alpha2"
|
||||
|
||||
type SensorSession struct {
|
||||
SessionID string `json:"session_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
type SensorCommand struct {
|
||||
APIVersion string `json:"api_version"`
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"operation_id"`
|
||||
Session SensorSession `json:"session"`
|
||||
Action string `json:"action_id"`
|
||||
Requested string `json:"requested_at"`
|
||||
Deadline string `json:"deadline_at"`
|
||||
Idempotency string `json:"idempotency_key"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
type SensorOperation struct {
|
||||
Command SensorCommand `json:"command"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Remote bool `json:"remote,omitempty"`
|
||||
Updated int64 `json:"updated_at"`
|
||||
}
|
||||
type Sensors struct {
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
prepareMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
}
|
||||
|
||||
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
|
||||
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
|
||||
|
||||
func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
dir := filepath.Join(root, "sensors")
|
||||
if e := os.MkdirAll(dir, 0700); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}, initialized: map[string]bool{}}
|
||||
s.client = &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-sensors/driver.sock")
|
||||
}}}
|
||||
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
|
||||
for _, p := range files {
|
||||
data, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var v SensorOperation
|
||||
if json.Unmarshal(data, &v) != nil {
|
||||
return nil, errors.New("invalid sensor operation journal")
|
||||
}
|
||||
if v.State == "running" {
|
||||
v.State = "unknown"
|
||||
v.Error = "Результат операции неизвестен после перезапуска. Проверьте состояние устройства."
|
||||
}
|
||||
s.operations[v.Command.ID] = &v
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
|
||||
_ = json.Unmarshal(data, &s.names)
|
||||
data, _ = os.ReadFile(filepath.Join(dir, "initialized.json"))
|
||||
_ = json.Unmarshal(data, &s.initialized)
|
||||
if s.initialized == nil {
|
||||
s.initialized = map[string]bool{}
|
||||
}
|
||||
for _, op := range s.operations {
|
||||
if op.Command.Action == "prepare" && op.State == "complete" {
|
||||
s.initialized[op.Command.Session.DeviceID] = true
|
||||
}
|
||||
}
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func (s *Sensors) write(name string, value any) error {
|
||||
data, e := json.Marshal(value)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
f, e := os.CreateTemp(s.root, ".sensor-")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, e = f.Write(data); e != nil {
|
||||
f.Close()
|
||||
return e
|
||||
}
|
||||
if e = f.Sync(); e != nil {
|
||||
f.Close()
|
||||
return e
|
||||
}
|
||||
f.Close()
|
||||
if e = os.Rename(f.Name(), filepath.Join(s.root, name)); e != nil {
|
||||
return e
|
||||
}
|
||||
d, e := os.Open(s.root)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
method := "GET"
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
method = "POST"
|
||||
data, e := json.Marshal(body)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, e := http.NewRequest(method, "http://driver"+path, reader)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
req.Header.Set("X-Node-Id", s.nodeID)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, e := s.client.Do(req)
|
||||
if e != nil {
|
||||
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result map[string]any
|
||||
if json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&result) != nil {
|
||||
return nil, errors.New("Не удалось прочитать результат драйвера.")
|
||||
}
|
||||
if response.StatusCode != 200 {
|
||||
message, _ := result["error"].(string)
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *Sensors) Inventory() map[string]any {
|
||||
items := []any{}
|
||||
seen := map[string]bool{}
|
||||
if result, e := s.driver("/inventory", nil); e == nil {
|
||||
if found, ok := result["items"].([]any); ok {
|
||||
for _, v := range found {
|
||||
item, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
seen[id] = true
|
||||
s.mu.Lock()
|
||||
item["configured"] = s.initialized[id]
|
||||
if n := s.names[id]; n != "" {
|
||||
item["name"] = n
|
||||
}
|
||||
s.mu.Unlock()
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
paths, _ := filepath.Glob("/sys/bus/usb/devices/*")
|
||||
for _, path := range paths {
|
||||
read := func(n string) string {
|
||||
b, _ := os.ReadFile(filepath.Join(path, n))
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
if read("idVendor") != "8086" || read("idProduct") != "0b5c" {
|
||||
continue
|
||||
}
|
||||
serial := read("serial")
|
||||
if serial == "" {
|
||||
continue
|
||||
}
|
||||
h := sha256.Sum256([]byte(serial))
|
||||
id := "rsd455_" + hex.EncodeToString(h[:])[:32]
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
items = append(items, s.discovery(id, read("speed")+" Мбит/с", true))
|
||||
}
|
||||
s.mu.Lock()
|
||||
configured := []string{}
|
||||
for id, ready := range s.initialized {
|
||||
if ready && sensorID.MatchString(id) && !seen[id] {
|
||||
configured = append(configured, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, id := range configured {
|
||||
items = append(items, s.discovery(id, "—", false))
|
||||
}
|
||||
|
||||
var preparation any
|
||||
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
|
||||
_ = json.Unmarshal(data, &preparation)
|
||||
}
|
||||
s.mu.Lock()
|
||||
operations := []any{}
|
||||
for _, v := range s.operations {
|
||||
if time.Now().Unix()-v.Updated < 600 {
|
||||
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "requested_at": v.Command.Requested, "state": v.State, "error": v.Error})
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
|
||||
}
|
||||
func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
s.mu.Lock()
|
||||
name, configured := s.names[id], s.initialized[id]
|
||||
s.mu.Unlock()
|
||||
if name == "" {
|
||||
name = "RealSense D455"
|
||||
}
|
||||
connectivity, enrollment := "offline", "empty"
|
||||
if online {
|
||||
connectivity = "connected"
|
||||
}
|
||||
if configured {
|
||||
enrollment = "enrolled"
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": "RealSense D455", "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.6", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
|
||||
}
|
||||
|
||||
func sensorViewAction(action string) bool {
|
||||
return action == "details" || action == "offer" || action == "close-peer"
|
||||
}
|
||||
|
||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
|
||||
return nil, errors.New("Некорректная команда устройства.")
|
||||
}
|
||||
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "replay": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
|
||||
return nil, errors.New("Операция не поддерживается.")
|
||||
}
|
||||
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
requested, e2 := time.Parse(time.RFC3339Nano, c.Requested)
|
||||
if e != nil || e2 != nil || !deadline.After(requested) || deadline.Sub(requested) > 6*time.Minute {
|
||||
return nil, errors.New("Некорректный срок команды.")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if old := s.operations[c.ID]; old != nil {
|
||||
a, _ := json.Marshal(old.Command)
|
||||
b, _ := json.Marshal(c)
|
||||
if !bytes.Equal(a, b) {
|
||||
return nil, errors.New("Идентификатор операции уже использован.")
|
||||
}
|
||||
copy := *old
|
||||
return ©, nil
|
||||
}
|
||||
if !deadline.After(time.Now()) {
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
for _, v := range s.operations {
|
||||
if v.State == "running" && v.Command.Action == "prepare" {
|
||||
return nil, errors.New("Подготовка модели ещё выполняется.")
|
||||
}
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
return nil, errors.New("Другая операция устройства ещё выполняется.")
|
||||
}
|
||||
}
|
||||
if len(s.operations) > 2000 {
|
||||
for id, v := range s.operations {
|
||||
if v.State != "running" && time.Now().Unix()-v.Updated > 86400 {
|
||||
delete(s.operations, id)
|
||||
os.Remove(filepath.Join(s.root, id+".json"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.operations) > 2000 {
|
||||
return nil, errors.New("Журнал операций заполнен. Повторите позже.")
|
||||
}
|
||||
value := &SensorOperation{Command: c, State: "running", Remote: remote, Updated: time.Now().Unix()}
|
||||
if e = s.write(c.ID+".json", value); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
s.operations[c.ID] = value
|
||||
copy := *value
|
||||
s.events.notify()
|
||||
go s.execute(c)
|
||||
return ©, nil
|
||||
}
|
||||
func (s *Sensors) execute(c SensorCommand) {
|
||||
defer s.events.notify()
|
||||
var result any
|
||||
var err error
|
||||
uncertain := false
|
||||
inv := s.Inventory()
|
||||
var item map[string]any
|
||||
for _, v := range inv["items"].([]any) {
|
||||
i := v.(map[string]any)
|
||||
if i["id"] == c.Session.DeviceID {
|
||||
item = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if item == nil {
|
||||
err = errors.New("Камера не обнаружена. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" {
|
||||
result, err = s.prepare(c)
|
||||
} else if c.Action == "rename" {
|
||||
name, ok := c.Parameters["name"].(string)
|
||||
if !ok || strings.TrimSpace(name) == "" || len([]rune(name)) > 80 || strings.ContainsAny(name, "\n\r\t") {
|
||||
err = errors.New("Введите название до 80 символов.")
|
||||
} else {
|
||||
s.mu.Lock()
|
||||
s.names[c.Session.DeviceID] = strings.TrimSpace(name)
|
||||
err = s.write("names.json", s.names)
|
||||
s.mu.Unlock()
|
||||
result = map[string]bool{"ok": err == nil}
|
||||
}
|
||||
} else {
|
||||
var v map[string]any
|
||||
v, err = s.driver("/operation", c)
|
||||
uncertain = err != nil || v["state"] == "unknown"
|
||||
if err == nil {
|
||||
if v["state"] == "complete" {
|
||||
result = v["result"]
|
||||
} else {
|
||||
message, _ := v["error"].(string)
|
||||
err = errors.New(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err == nil && c.Action == "prepare" {
|
||||
s.initialized[c.Session.DeviceID] = true
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
err = e
|
||||
uncertain = true
|
||||
}
|
||||
}
|
||||
v := s.operations[c.ID]
|
||||
v.Updated = time.Now().Unix()
|
||||
if err != nil {
|
||||
v.State = "error"
|
||||
if uncertain {
|
||||
v.State = "unknown"
|
||||
}
|
||||
v.Error = err.Error()
|
||||
} else {
|
||||
v.State = "complete"
|
||||
v.Result = result
|
||||
}
|
||||
if s.write(c.ID+".json", v) != nil {
|
||||
v.State = "unknown"
|
||||
v.Error = "Не удалось сохранить результат операции. Обновите состояние устройства."
|
||||
}
|
||||
}
|
||||
func (s *Sensors) prepare(c SensorCommand) (any, error) {
|
||||
s.prepareMu.Lock()
|
||||
defer s.prepareMu.Unlock()
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
snap := item["snapshot"].(map[string]any)
|
||||
if state := snap["acquisition"]; state != "idle" && state != "failed" {
|
||||
return nil, errors.New("Остановите захват камер перед подготовкой модели.")
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
|
||||
if cmd.Run() != nil {
|
||||
return nil, errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
|
||||
}
|
||||
for i := 0; i < 12; i++ {
|
||||
inv := s.Inventory()
|
||||
for _, v := range inv["items"].([]any) {
|
||||
item := v.(map[string]any)
|
||||
if item["id"] == c.Session.DeviceID && item["prepared"] == true {
|
||||
snap := item["snapshot"].(map[string]any)
|
||||
sc := snap["context"].(map[string]any)
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.SessionID = sc["session_id"].(string)
|
||||
result, e := s.driver("/operation", verify)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if result["state"] != "complete" {
|
||||
message, _ := result["error"].(string)
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
return result["result"], nil
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
}
|
||||
func (s *Sensors) Get(id string) *SensorOperation {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if v := s.operations[id]; v != nil {
|
||||
copy := *v
|
||||
return ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Sensors) RemoteResults() []any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []any{}
|
||||
for _, v := range s.operations {
|
||||
if v.Remote && time.Now().Unix()-v.Updated < 600 {
|
||||
copy := *v
|
||||
out = append(out, copy)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
|
||||
mux.HandleFunc("GET /api/devices/events", func(w http.ResponseWriter, r *http.Request) { s.stream(w, r, server) })
|
||||
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
if server.authorized(w, r) {
|
||||
reply(w, 200, s.Inventory())
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("POST /api/devices/operations", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var c SensorCommand
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 65536)
|
||||
if r.Header.Get("Content-Type") != "application/json" || json.NewDecoder(r.Body).Decode(&c) != nil {
|
||||
reply(w, 400, map[string]string{"error": "Некорректная команда"})
|
||||
return
|
||||
}
|
||||
v, e := s.Submit(c, false)
|
||||
if e != nil {
|
||||
reply(w, 409, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
reply(w, 202, v)
|
||||
})
|
||||
mux.HandleFunc("GET /api/devices/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
v := s.Get(r.PathValue("id"))
|
||||
if v == nil {
|
||||
reply(w, 404, map[string]string{"error": "Операция не найдена"})
|
||||
return
|
||||
}
|
||||
reply(w, 200, v)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Sensors) Acknowledge(ids []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
if v := s.operations[id]; v != nil && v.State != "running" {
|
||||
v.Remote = false
|
||||
_ = s.write(id+".json", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sensorTestCommand() SensorCommand {
|
||||
now := time.Now()
|
||||
id := "op_01234567890123456789012345678901"
|
||||
return SensorCommand{APIVersion: SensorSchema, Kind: "OperationRequest", ID: id, Idempotency: id, Session: SensorSession{SessionID: "session_test", DeviceID: "rsd455_01234567890123456789012345678901"}, Action: "start", Requested: now.UTC().Format(time.RFC3339Nano), Deadline: now.Add(time.Minute).UTC().Format(time.RFC3339Nano), Parameters: map[string]any{}}
|
||||
}
|
||||
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
|
||||
s, e := OpenSensors(t.TempDir(), "node_test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c := sensorTestCommand()
|
||||
c.Action = "shell"
|
||||
if _, e = s.Submit(c, false); e == nil {
|
||||
t.Fatal("arbitrary action admitted")
|
||||
}
|
||||
c = sensorTestCommand()
|
||||
c.ID = "../../owned"
|
||||
if _, e = s.Submit(c, false); e == nil {
|
||||
t.Fatal("path admitted")
|
||||
}
|
||||
c = sensorTestCommand()
|
||||
c.Requested = time.Now().Add(-2 * time.Minute).Format(time.RFC3339Nano)
|
||||
c.Deadline = time.Now().Add(-time.Minute).Format(time.RFC3339Nano)
|
||||
if _, e = s.Submit(c, false); e == nil {
|
||||
t.Fatal("expired command admitted")
|
||||
}
|
||||
}
|
||||
func TestSensorUncertainCrashDoesNotReplay(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, _ := OpenSensors(root, "node_test")
|
||||
c := sensorTestCommand()
|
||||
old := &SensorOperation{Command: c, State: "running", Updated: time.Now().Unix()}
|
||||
if e := s.write(c.ID+".json", old); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := OpenSensors(root, "node_test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
v, e := s.Submit(c, false)
|
||||
if e != nil || v.State != "unknown" {
|
||||
t.Fatalf("replay: %+v %v", v, e)
|
||||
}
|
||||
c.Parameters = map[string]any{"record": true}
|
||||
if _, e = s.Submit(c, false); e == nil {
|
||||
t.Fatal("id collision did not reject different command")
|
||||
}
|
||||
}
|
||||
|
||||
type sensorRoundTrip func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f sensorRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
|
||||
for _, response := range []string{`{"state":"unknown","error":"uncertain"}`, "transport-failure"} {
|
||||
t.Run(response, func(t *testing.T) {
|
||||
s, _ := OpenSensors(t.TempDir(), "node_test")
|
||||
c := sensorTestCommand()
|
||||
s.operations[c.ID] = &SensorOperation{Command: c, State: "running"}
|
||||
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
body := `{"items":[{"id":"` + c.Session.DeviceID + `"}]}`
|
||||
if r.URL.Path == "/operation" {
|
||||
if response == "transport-failure" {
|
||||
return nil, errors.New("connection lost")
|
||||
}
|
||||
body = response
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
|
||||
})}
|
||||
s.execute(c)
|
||||
if s.Get(c.ID).State != "unknown" {
|
||||
t.Fatal("uncertain hardware effect reported as definite failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensorConfiguredIdentitySurvivesRestartAndDisconnect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, _ := OpenSensors(root, "node_test")
|
||||
c := sensorTestCommand()
|
||||
s.initialized[c.Session.DeviceID] = true
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := OpenSensors(root, "node_test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"items":[]}`)), Header: http.Header{}}, nil
|
||||
})}
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("configured camera disappeared: %d", len(items))
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
if item["id"] != c.Session.DeviceID || item["online"] != false || item["configured"] != true {
|
||||
t.Fatalf("incorrect offline identity: %+v", item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Store *Store
|
||||
Pairing *Pairing
|
||||
Sensors *Sensors
|
||||
Assets fs.FS
|
||||
Origin string
|
||||
Version string
|
||||
Inventory func() Inventory
|
||||
Access *AccessStore
|
||||
Tailscale func() TailscaleStatus
|
||||
Environment func() EnvironmentStatus
|
||||
mu sync.Mutex
|
||||
logins map[string]time.Time
|
||||
sessions map[string]time.Time
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func token() string {
|
||||
b := make([]byte, 32)
|
||||
if _, e := rand.Read(b); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
func (s *Server) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
func prune(m map[string]time.Time, now time.Time) {
|
||||
for k, v := range m {
|
||||
if !v.After(now) {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IssueLogin is reachable through the private Unix socket, never the web API.
|
||||
// OS authentication belongs to the fixed polkit launcher, not a web password.
|
||||
func (s *Server) IssueLogin() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.logins == nil {
|
||||
s.logins = make(map[string]time.Time)
|
||||
}
|
||||
prune(s.logins, s.now())
|
||||
// Cap abandoned desktop launches; newest launches supersede the oldest.
|
||||
if len(s.logins) >= 16 {
|
||||
for k := range s.logins {
|
||||
delete(s.logins, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
t := token()
|
||||
s.logins[t] = s.now().Add(time.Minute)
|
||||
return s.Origin + "/#login=" + t
|
||||
}
|
||||
|
||||
func reply(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
if s.Sensors != nil {
|
||||
s.Sensors.Routes(mux, s)
|
||||
}
|
||||
if s.Pairing != nil {
|
||||
s.Pairing.localRoutes(mux, s)
|
||||
}
|
||||
if s.Access != nil {
|
||||
s.accessRoutes(mux)
|
||||
}
|
||||
mux.HandleFunc("POST /api/session", s.login)
|
||||
mux.HandleFunc("GET /api/environment", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
read := s.Environment
|
||||
if read == nil {
|
||||
read = ReadEnvironment
|
||||
}
|
||||
reply(w, 200, read())
|
||||
})
|
||||
mux.HandleFunc("GET /api/network/tailscale", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
probe := s.Tailscale
|
||||
if probe == nil {
|
||||
probe = ReadTailscale
|
||||
}
|
||||
reply(w, 200, probe())
|
||||
})
|
||||
mux.HandleFunc("POST /api/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
c, _ := r.Cookie("mc_node")
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, c.Value)
|
||||
s.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "mc_node", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
|
||||
reply(w, 200, map[string]bool{"ok": true})
|
||||
})
|
||||
mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
id, name := s.Store.Public()
|
||||
reply(w, 200, map[string]any{"version": s.Version, "node_id": id, "name": name, "host": s.Inventory()})
|
||||
})
|
||||
mux.HandleFunc("PUT /api/name", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := s.Store.Rename(body.Name); err != nil {
|
||||
reply(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]bool{"ok": true})
|
||||
})
|
||||
mux.HandleFunc("GET /api/report", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
v := s.Inventory()
|
||||
// Export is deliberately redacted even though the authenticated UI shows LAN addresses.
|
||||
v.Hostname = "[redacted]"
|
||||
for i := range v.Networks {
|
||||
v.Networks[i].Addresses = []string{}
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="mission-core-node-report.json"`)
|
||||
reply(w, 200, map[string]any{"schema": "missioncore.node.inventory-report/v1", "version": s.Version, "host": v})
|
||||
})
|
||||
mux.Handle("GET /", http.FileServerFS(s.Assets))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
if "http://"+r.Host != s.Origin {
|
||||
http.Error(w, "Invalid host", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if origin := r.Header.Get("Origin"); origin != "" && origin != s.Origin {
|
||||
http.Error(w, "Invalid origin", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
|
||||
http.Error(w, "Cross-site request denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != "GET" && r.Method != "HEAD" && r.Header.Get("Origin") != s.Origin {
|
||||
http.Error(w, "Origin required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
reply(w, 415, map[string]string{"error": "Ожидался JSON"})
|
||||
return false
|
||||
}
|
||||
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(v); err != nil {
|
||||
reply(w, 400, map[string]string{"error": "Некорректный запрос"})
|
||||
return false
|
||||
}
|
||||
if err := d.Decode(new(any)); err != io.EOF {
|
||||
reply(w, 400, map[string]string{"error": "Некорректный запрос"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prune(s.logins, s.now())
|
||||
_, ok := s.logins[body.Token]
|
||||
delete(s.logins, body.Token)
|
||||
if !ok {
|
||||
reply(w, 401, map[string]string{"error": "Повторно откройте приложение через меню приложений"})
|
||||
return
|
||||
}
|
||||
if s.sessions == nil {
|
||||
s.sessions = make(map[string]time.Time)
|
||||
}
|
||||
prune(s.sessions, s.now())
|
||||
if len(s.sessions) >= 32 {
|
||||
for k := range s.sessions {
|
||||
delete(s.sessions, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
t := token()
|
||||
s.sessions[t] = s.now().Add(8 * time.Hour)
|
||||
// Loopback HTTP is intentionally local-only; never expose this cookie on LAN.
|
||||
http.SetCookie(w, &http.Cookie{Name: "mc_node", Value: t, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 28800})
|
||||
reply(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool {
|
||||
c, err := r.Cookie("mc_node")
|
||||
if err != nil || strings.TrimSpace(c.Value) == "" {
|
||||
reply(w, 401, map[string]string{"error": "Откройте Mission Core Node через меню приложений"})
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prune(s.sessions, s.now())
|
||||
if _, ok := s.sessions[c.Value]; !ok {
|
||||
reply(w, 401, map[string]string{"error": "Сеанс завершён. Откройте приложение через меню приложений"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Version int `json:"version"`
|
||||
PrivateKey []byte `json:"private_key"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
state State
|
||||
}
|
||||
|
||||
func OpenStore(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{path: filepath.Join(dir, "identity.json")}
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(b, &s.state); err != nil {
|
||||
return nil, errors.New("invalid identity; recovery required")
|
||||
}
|
||||
if s.state.Version != 1 || len(s.state.PrivateKey) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("unsupported identity; recovery required")
|
||||
}
|
||||
derived := ed25519.NewKeyFromSeed(s.state.PrivateKey[:ed25519.SeedSize])
|
||||
if !equalKey(derived, s.state.PrivateKey) {
|
||||
return nil, errors.New("corrupt identity; recovery required")
|
||||
}
|
||||
if info, e := os.Stat(s.path); e != nil || info.Mode().Perm()&0077 != 0 {
|
||||
return nil, errors.New("identity permissions must be private")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
_, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.state = State{Version: 1, PrivateKey: key, Name: "Моя нода"}
|
||||
if err := s.write(s.state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func equalKey(a, b []byte) bool { return string(a) == string(b) }
|
||||
|
||||
func (s *Store) write(state State) error {
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, err = f.Write(b); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(f.Name(), s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := os.Open(filepath.Dir(s.path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
|
||||
func (s *Store) Public() (string, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
pub := ed25519.PrivateKey(s.state.PrivateKey).Public().(ed25519.PublicKey)
|
||||
hash := sha256.Sum256(pub)
|
||||
return "node_" + hex.EncodeToString(hash[:]), s.state.Name
|
||||
}
|
||||
|
||||
func (s *Store) Rename(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 64 || strings.ContainsFunc(name, unicode.IsControl) {
|
||||
return errors.New("Название должно содержать от 1 до 64 символов без управляющих знаков")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := s.state
|
||||
next.Name = name
|
||||
if err := s.write(next); err != nil {
|
||||
return errors.New("Не удалось сохранить название")
|
||||
}
|
||||
s.state = next
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TailscaleStatus struct {
|
||||
Installed bool `json:"installed"`
|
||||
State string `json:"state"`
|
||||
Online bool `json:"online"`
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
|
||||
type boundedProviderOutput struct{ bytes.Buffer }
|
||||
|
||||
func (b *boundedProviderOutput) Write(data []byte) (int, error) {
|
||||
if b.Len()+len(data) > 1024*1024 {
|
||||
return 0, errors.New("provider status too large")
|
||||
}
|
||||
return b.Buffer.Write(data)
|
||||
}
|
||||
|
||||
func ReadTailscale() TailscaleStatus {
|
||||
value := TailscaleStatus{State: "not_installed", Addresses: []string{}}
|
||||
if _, err := os.Stat("/usr/bin/tailscale"); err != nil {
|
||||
return value
|
||||
}
|
||||
value.Installed = true
|
||||
value.State = "unavailable"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/tailscale", "status", "--json", "--peers=false")
|
||||
// Do not request peer inventory or expose auth URLs, user identities, keys,
|
||||
// provider diagnostics or profile objects in the product API.
|
||||
var output boundedProviderOutput
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return value
|
||||
}
|
||||
return parseTailscale(output.Bytes())
|
||||
}
|
||||
|
||||
func parseTailscale(data []byte) TailscaleStatus {
|
||||
value := TailscaleStatus{Installed: true, State: "unavailable", Addresses: []string{}}
|
||||
if len(data) > 1024*1024 {
|
||||
return value
|
||||
}
|
||||
var raw struct {
|
||||
BackendState string
|
||||
TailscaleIPs []string
|
||||
Self *struct{ Online bool }
|
||||
}
|
||||
if json.Unmarshal(data, &raw) != nil {
|
||||
return value
|
||||
}
|
||||
switch raw.BackendState {
|
||||
case "Running", "Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState":
|
||||
value.State = raw.BackendState
|
||||
default:
|
||||
return value
|
||||
}
|
||||
value.Online = raw.BackendState == "Running" && raw.Self != nil && raw.Self.Online
|
||||
for _, address := range raw.TailscaleIPs {
|
||||
if ip, err := netip.ParseAddr(address); err == nil {
|
||||
value.Addresses = append(value.Addresses, ip.String())
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTailscaleDoesNotExposeProviderCredentialsOrPeers(t *testing.T) {
|
||||
status := parseTailscale([]byte(`{"BackendState":"Running","TailscaleIPs":["100.64.0.10","invalid"],"Self":{"Online":true,"PublicKey":"synthetic-key"},"AuthURL":"https://login.tailscale.com/a/synthetic","User":{"1":{"LoginName":"synthetic@example.test"}},"Peer":{"synthetic":{"HostName":"another-computer"}}}`))
|
||||
if !status.Online || status.State != "Running" || len(status.Addresses) != 1 {
|
||||
t.Fatalf("wrong connection status: %+v", status)
|
||||
}
|
||||
encoded, _ := json.Marshal(status)
|
||||
for _, forbidden := range []string{"synthetic", "AuthURL", "User", "Peer", "PublicKey"} {
|
||||
if strings.Contains(string(encoded), forbidden) {
|
||||
t.Fatalf("provider data leaked: %s", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailscaleDoesNotClaimUnknownOrOfflineConnection(t *testing.T) {
|
||||
for _, input := range []string{`{`, `null`, `{}`, `{"BackendState":"FutureState","Self":{"Online":true}}`} {
|
||||
got := parseTailscale([]byte(input))
|
||||
if got.Online || got.State != "unavailable" {
|
||||
t.Fatalf("unknown state was accepted: %+v", got)
|
||||
}
|
||||
}
|
||||
for _, state := range []string{"Stopped", "NeedsLogin", "NeedsMachineAuth", "Starting", "NoState"} {
|
||||
got := parseTailscale([]byte(`{"BackendState":"` + state + `","Self":{"Online":true}}`))
|
||||
if got.Online || got.State != state {
|
||||
t.Fatalf("not connected: %+v", got)
|
||||
}
|
||||
}
|
||||
if parseTailscale([]byte(`{"BackendState":"Running","Self":{"Online":false}}`)).Online {
|
||||
t.Fatal("offline peer shown as connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderOutputIsBounded(t *testing.T) {
|
||||
var buffer boundedProviderOutput
|
||||
if _, err := buffer.Write(make([]byte, 1024*1024+1)); err == nil || buffer.Len() != 0 {
|
||||
t.Fatal("oversized provider output accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailscaleStatusRequiresLocalLoginBeforeProbe(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
probes := 0
|
||||
s.Tailscale = func() TailscaleStatus {
|
||||
probes++
|
||||
return TailscaleStatus{State: "not_installed", Addresses: []string{}}
|
||||
}
|
||||
if call(s, "GET", "/api/network/tailscale", "", nil).Code != 401 || probes != 0 {
|
||||
t.Fatal("unauthenticated provider probe")
|
||||
}
|
||||
if call(s, "GET", "/api/network/tailscale", "", login(t, s)).Code != 200 || probes != 1 {
|
||||
t.Fatal("authenticated status unavailable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// An authenticated Node action may start only this fixed model job.
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-realsense-prepare.service" && action.lookup("verb") === "start") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
# Managed by Mission Core Node environment profile ubuntu-24.04-amd64/1.
|
||||
[Service]
|
||||
RestrictAddressFamilies=AF_NETLINK
|
||||
@@ -0,0 +1,5 @@
|
||||
# Node-managed public keys supplement existing per-user authorized_keys.
|
||||
# The command can only return GUI-enrolled Ed25519 keys for local sudo users.
|
||||
AuthorizedKeysCommand /usr/lib/mission-core-node/node-agent ssh-keys %u
|
||||
AuthorizedKeysCommandUser mission-core-node
|
||||
PermitEmptyPasswords no
|
||||
@@ -0,0 +1,5 @@
|
||||
# Reviewed D455 only. No firmware/DFU IDs, unrelated cameras or world-writable devices.
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="8086", ATTR{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
|
||||
SUBSYSTEM=="video4linux", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
|
||||
SUBSYSTEM=="iio", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors", RUN+="/usr/bin/python3 -I /usr/lib/mission-core-node/realsense_iio_access.py %p"
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/lib/mission-core-node/node-agent authorize
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Engineering-only, sequential build. Never run by an Ubuntu operator."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from build_deb import build, VERSION, BRAND_SHA256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DG_COMMIT = "999864e5b0a81555823cfa1ea6e8cf8a417c37f1"
|
||||
|
||||
|
||||
def guideline_sources():
|
||||
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
|
||||
paths = list((dg / "packages/ui-react/src").glob("*"))
|
||||
paths += list((dg / "packages/ui-react/dist").glob("*"))
|
||||
paths += [dg / "packages/ui-core/styles.css", dg / "packages/tokens/tokens.css", dg / "packages/tokens/themes.css"]
|
||||
return {str(p.relative_to(dg)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in sorted(paths) if p.is_file()}
|
||||
|
||||
|
||||
def provenance():
|
||||
files = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in sorted(ROOT.rglob("*")) if p.is_file()
|
||||
and not any(x in p.relative_to(ROOT).parts for x in ("node_modules", "build", "__pycache__"))}
|
||||
return {"package": "mission-core-node", "version": VERSION,
|
||||
"brand_mark_sha256": BRAND_SHA256,
|
||||
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
|
||||
"design_guideline_commit": DG_COMMIT,
|
||||
"design_guideline_files": guideline_sources(),
|
||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--go", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
go = args.go.resolve()
|
||||
expected = json.loads((ROOT / "toolchain.json").read_text())["version"]
|
||||
if subprocess.check_output([str(go), "version"], text=True).split()[2] != expected:
|
||||
sys.exit("Go version does not match toolchain.json")
|
||||
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
|
||||
if subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=dg, text=True).strip() != DG_COMMIT:
|
||||
sys.exit("Design Guideline revision does not match the admitted build")
|
||||
subprocess.run(["npm", "run", "build"], cwd=ROOT / "ui", check=True)
|
||||
assets = ROOT / "web/dist"
|
||||
if assets.exists():
|
||||
shutil.rmtree(assets)
|
||||
shutil.copytree(ROOT / "ui/dist", assets)
|
||||
output = ROOT / "build"
|
||||
output.mkdir(exist_ok=True)
|
||||
env = dict(os.environ, GOMAXPROCS="2", CGO_ENABLED="0", GOOS="linux", GOARCH="amd64")
|
||||
subprocess.run([str(go), "build", "-trimpath", f"-ldflags=-s -w -X main.version={VERSION}", "-o",
|
||||
str(output / "node-agent-linux-amd64"), "./cmd/node-agent"], cwd=ROOT, env=env, check=True)
|
||||
(output / "provenance.json").write_text(json.dumps(provenance(), indent=2) + "\n")
|
||||
build(output / "node-agent-linux-amd64", output / f"mission-core-node_{VERSION}_amd64.deb")
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic Debian package on macOS/Linux from reviewed artifacts.
|
||||
|
||||
No install operation, sudo, container, package-manager mutation or network I/O.
|
||||
"""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.6.11"
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
def desktop_icon(brand):
|
||||
"""Give desktop loaders a square canvas without distorting the brand mark.
|
||||
|
||||
The canonical SVG remains an unchanged nested document. Its default
|
||||
xMidYMid meet preserves the mark's aspect ratio inside this square viewport.
|
||||
Explicit intrinsic dimensions also keep GTK's pixbuf square.
|
||||
"""
|
||||
if hashlib.sha256(brand).hexdigest() != BRAND_SHA256:
|
||||
raise ValueError("Brand mark differs from the admitted Design Guideline asset")
|
||||
return (b'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" '
|
||||
b'viewBox="0 0 256 256" preserveAspectRatio="xMidYMid meet">\n'
|
||||
+ brand + b'</svg>\n')
|
||||
|
||||
|
||||
def tarball(files):
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive:
|
||||
directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."}
|
||||
for name in sorted(directories):
|
||||
item = tarfile.TarInfo(name + "/")
|
||||
item.type, item.mode = tarfile.DIRTYPE, 0o755
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item)
|
||||
for name, data, mode in sorted(files):
|
||||
item = tarfile.TarInfo(name)
|
||||
item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item, io.BytesIO(data))
|
||||
return gzip.compress(stream.getvalue(), mtime=0)
|
||||
|
||||
|
||||
def ar_member(name, data):
|
||||
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode()
|
||||
assert len(header) == 60
|
||||
return header + data + (b"\n" if len(data) % 2 else b"")
|
||||
|
||||
|
||||
def build(binary, destination):
|
||||
payload = binary.read_bytes()
|
||||
if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00":
|
||||
raise ValueError("Expected a Linux amd64 ELF binary")
|
||||
p = ROOT / "packaging"
|
||||
control = f"""Package: mission-core-node
|
||||
Version: {VERSION}
|
||||
Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
controls = [("control", control, 0o644)]
|
||||
controls += [(name, (p / name).read_bytes(), 0o755) for name in ["preinst", "postinst", "prerm", "postrm"]]
|
||||
files = [("usr/lib/mission-core-node/node-agent", payload, 0o755)]
|
||||
brand = (ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE/apps/catalog/public/nodedc-mark.svg").read_bytes()
|
||||
files.append(("usr/share/icons/hicolor/scalable/apps/org.nodedc.MissionCoreNode.svg", desktop_icon(brand), 0o644))
|
||||
for source, path, mode in [
|
||||
("launcher.py", "usr/bin/mission-core-node", 0o755),
|
||||
("authorize", "usr/lib/mission-core-node/authorize", 0o755),
|
||||
("mission-core-node.desktop", "usr/share/applications/org.nodedc.MissionCoreNode.desktop", 0o644),
|
||||
("mission-core-node.service", "usr/lib/systemd/system/mission-core-node.service", 0o644),
|
||||
("org.nodedc.mission-core-node.policy", "usr/share/polkit-1/actions/org.nodedc.mission-core-node.policy", 0o644),
|
||||
("60-mission-core-node.conf", "usr/share/mission-core-node/60-mission-core-node.conf", 0o644),
|
||||
("network_helper.py", "usr/lib/mission-core-node/network_helper.py", 0o644),
|
||||
("install-tailscale", "usr/lib/mission-core-node/install-tailscale", 0o755),
|
||||
("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755),
|
||||
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
|
||||
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
|
||||
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
|
||||
("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644),
|
||||
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
|
||||
]:
|
||||
files.append((path, (p / source).read_bytes(), mode))
|
||||
files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644))
|
||||
for name in ("realsense_prepare.py", "realsense_iio_access.py"):
|
||||
files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644))
|
||||
for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"):
|
||||
files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644))
|
||||
files.append(("usr/share/polkit-1/rules.d/50-mission-core-device-prepare.rules", (p / "50-mission-core-device-prepare.rules").read_bytes(), 0o644))
|
||||
files.append(("usr/share/mission-core-node/realsense/70-mission-core-realsense.rules", (p / "70-mission-core-realsense.rules").read_bytes(), 0o644))
|
||||
bundle = json.loads((p / "realsense-bundle.json").read_text())
|
||||
files.append(("usr/share/mission-core-node/realsense/bundle.json", (p / "realsense-bundle.json").read_bytes(), 0o644))
|
||||
for item in bundle["wheels"]:
|
||||
data = (ROOT / "build/realsense-wheels" / item["name"]).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("Driver bundle hash mismatch")
|
||||
files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644))
|
||||
for path in (ROOT / "sensors").glob("*.py"):
|
||||
files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644))
|
||||
sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk"
|
||||
for path in sdk.rglob("*.py"):
|
||||
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(archive)
|
||||
digest = hashlib.sha256(archive).hexdigest()
|
||||
destination.with_suffix(destination.suffix + ".sha256").write_text(f"{digest} {destination.name}\n")
|
||||
print(json.dumps({"file": str(destination), "bytes": len(archive), "sha256": digest}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--binary", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
build(args.binary, args.output)
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py start
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py connect
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/python3
|
||||
"""Fixed, versioned environment workflow. Called only by the installed UI.
|
||||
|
||||
The privileged dispatcher starts a durable systemd job. It accepts no command,
|
||||
path, package name, address, key or other configuration from JavaScript.
|
||||
"""
|
||||
import fcntl
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
|
||||
PROFILE = Path("/usr/share/mission-core-node/environment-profile.json")
|
||||
STATE = Path("/var/lib/mission-core-node-environment")
|
||||
UNIT = "mission-core-node-environment.service"
|
||||
NODE_UNIT = "mission-core-node.service"
|
||||
ORIGIN = "http://127.0.0.1:8780"
|
||||
LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=([A-Za-z0-9_-]{43})")
|
||||
|
||||
|
||||
class SetupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def command(argv, *, timeout=15):
|
||||
result = subprocess.run(argv, env=ENV, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise SetupError("Системное действие не завершено. Повторите настройку; если ошибка сохранится, откройте диагностику.")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def trusted_directory(path, mode=0o755):
|
||||
created = not path.exists() and not path.is_symlink()
|
||||
path.mkdir(mode=mode, parents=True, exist_ok=True)
|
||||
info = path.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise SetupError("Каталог настройки имеет неподходящие права. Переустановите пакет Node через интерфейс системы.")
|
||||
# umask 0077 must protect working files, but this nonsensitive report and
|
||||
# newly created configuration directories must be traversable by readers.
|
||||
# The only existing directory repaired here is our dedicated report store.
|
||||
if created or path == STATE:
|
||||
path.chmod(mode)
|
||||
|
||||
|
||||
def publish(path, data):
|
||||
trusted_directory(path.parent)
|
||||
if path.is_symlink():
|
||||
raise SetupError("Конфликт системного файла: существующая ссылка сохранена.")
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".node-env-", delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
try:
|
||||
output.write(data)
|
||||
output.flush()
|
||||
os.fchmod(output.fileno(), 0o644)
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def owned_config(template, destination):
|
||||
expected = template.read_bytes()
|
||||
trusted_directory(destination.parent)
|
||||
if destination.is_symlink():
|
||||
raise SetupError("Конфликт с существующей настройкой. Она сохранена без изменений.")
|
||||
if destination.exists():
|
||||
info = destination.stat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or destination.read_bytes() != expected:
|
||||
raise SetupError("Конфликт с существующей настройкой. Она сохранена; проверьте конфигурацию перед повтором.")
|
||||
return False
|
||||
publish(destination, expected)
|
||||
return True
|
||||
|
||||
|
||||
def authorize():
|
||||
uri = command(["/usr/lib/mission-core-node/node-agent", "authorize"])
|
||||
if not LOGIN.fullmatch(uri):
|
||||
raise SetupError("Не удалось проверить локальную службу БК.")
|
||||
return uri
|
||||
|
||||
|
||||
def probe_node():
|
||||
# Validate the actual sandboxed service, not the root helper's own access.
|
||||
token = LOGIN.fullmatch(authorize()).group(1)
|
||||
connection = http.client.HTTPConnection("127.0.0.1", 8780, timeout=10)
|
||||
cookie = None
|
||||
try:
|
||||
connection.request("POST", "/api/session", json.dumps({"token": token}), {"Origin": ORIGIN, "Content-Type": "application/json"})
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise SetupError("Служба БК не подтвердила доступ для проверки.")
|
||||
cookie = response.getheader("Set-Cookie", "").split(";", 1)[0]
|
||||
response.read()
|
||||
connection.request("GET", "/api/status", headers={"Cookie": cookie})
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise SetupError("Не удалось получить сведения из службы БК.")
|
||||
data = response.read(2 * 1024 * 1024)
|
||||
return json.loads(data)["host"]
|
||||
finally:
|
||||
if cookie:
|
||||
try:
|
||||
connection.request("POST", "/api/logout", "{}", {"Cookie": cookie, "Origin": ORIGIN, "Content-Type": "application/json"})
|
||||
connection.getresponse().read()
|
||||
except (OSError, http.client.HTTPException):
|
||||
pass
|
||||
connection.close()
|
||||
|
||||
|
||||
def platform():
|
||||
release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines() if "=" in line)
|
||||
if release.get("ID", "").strip('"') != "ubuntu" or release.get("VERSION_ID", "").strip('"') != "24.04" or command(["/usr/bin/dpkg", "--print-architecture"]) != "amd64":
|
||||
raise SetupError("Этот профиль не поддерживает установленную систему или архитектуру. Сведения о системе доступны в обзоре БК.")
|
||||
return "Система и архитектура соответствуют профилю."
|
||||
|
||||
|
||||
def packages():
|
||||
missing = []
|
||||
for name in ["openssh-server", "ca-certificates"]:
|
||||
result = subprocess.run(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}", name], env=ENV, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode or result.stdout.strip() != "installed":
|
||||
missing.append(name)
|
||||
if missing:
|
||||
options = ["-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1", "-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30"]
|
||||
# Never kill APT/dpkg in the middle of a transaction or delete its lock.
|
||||
for argv in [["/usr/bin/apt-get", *options, "update"], ["/usr/bin/apt-get", *options, "--no-remove", "--no-install-recommends", "install", "-y", *missing]]:
|
||||
result = subprocess.run(argv, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode:
|
||||
raise SetupError("Не удалось установить пакеты. Проверьте интернет, закройте другие системные установщики и повторите настройку.")
|
||||
for name in ["openssh-server", "ca-certificates"]:
|
||||
if command(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}", name]) != "installed":
|
||||
raise SetupError("Проверка установленных пакетов не пройдена.")
|
||||
return "OpenSSH Server и системные зависимости установлены."
|
||||
|
||||
|
||||
def node_service():
|
||||
changed = owned_config(Path("/usr/share/mission-core-node/60-environment.conf"), Path("/etc/systemd/system/mission-core-node.service.d/60-environment.conf"))
|
||||
command(["/usr/bin/systemctl", "daemon-reload"])
|
||||
if command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=User", "--value"]) != "mission-core-node" or command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=CapabilityBoundingSet", "--value"]):
|
||||
raise SetupError("Права службы отличаются от профиля. Настройка остановлена без изменения чужих разрешений.")
|
||||
command(["/usr/bin/systemctl", "enable", "--now", NODE_UNIT])
|
||||
needs_restart = changed
|
||||
if not needs_restart:
|
||||
try:
|
||||
needs_restart = not probe_node().get("networks_readable")
|
||||
except (SetupError, OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
needs_restart = True
|
||||
if needs_restart:
|
||||
command(["/usr/bin/systemctl", "restart", NODE_UNIT])
|
||||
families = command(["/usr/bin/systemctl", "show", NODE_UNIT, "--property=RestrictAddressFamilies", "--value"])
|
||||
if "AF_NETLINK" not in families.split():
|
||||
raise SetupError("Существующая настройка службы запрещает получение сетевых данных. Она сохранена; требуется устранить конфликт профиля.")
|
||||
command(["/usr/bin/systemctl", "is-active", NODE_UNIT])
|
||||
wait_for_node()
|
||||
return "Служба БК запущена; автозапуск и системный профиль проверены."
|
||||
|
||||
|
||||
def wait_for_node():
|
||||
# Type=simple starts before the local socket/listener is ready. Retry only
|
||||
# read-only readiness, never package/service changes or user actions.
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
try:
|
||||
probe_node()
|
||||
return
|
||||
except (SetupError, OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
if time.monotonic() >= deadline:
|
||||
raise SetupError("Служба БК не подтвердила готовность после запуска. Повторите настройку.")
|
||||
time.sleep(0.25)
|
||||
|
||||
|
||||
def network_inventory():
|
||||
host = probe_node()
|
||||
if not host.get("networks_readable") or any(not item.get("addresses_readable") for item in host["networks"]):
|
||||
raise SetupError("Служба БК не смогла получить интерфейсы или адреса. Проверьте этап настройки службы и повторите.")
|
||||
return f"Получено сетевых интерфейсов: {len(host['networks'])}."
|
||||
|
||||
|
||||
def usb_inventory():
|
||||
host = probe_node()
|
||||
if not host.get("usb_readable"):
|
||||
raise SetupError("Служба БК не смогла получить USB-устройства. Повторите настройку.")
|
||||
return f"Получено USB-устройств: {len(host['usb'])}. Это системное обнаружение."
|
||||
|
||||
|
||||
def ssh_service():
|
||||
owned_config(Path("/usr/share/mission-core-node/60-mission-core-node.conf"), Path("/etc/ssh/sshd_config.d/60-mission-core-node.conf"))
|
||||
trusted_directory(Path("/run/sshd"))
|
||||
command(["/usr/sbin/sshd", "-t"])
|
||||
config = command(["/usr/sbin/sshd", "-T"]).splitlines()
|
||||
if "authorizedkeyscommand /usr/lib/mission-core-node/node-agent ssh-keys %u" not in config or "authorizedkeyscommanduser mission-core-node" not in config:
|
||||
raise SetupError("Другая конфигурация SSH переопределяет доступ Node. Она сохранена; устраните конфликт и повторите.")
|
||||
command(["/usr/bin/systemctl", "enable", "--now", "ssh.service"])
|
||||
command(["/usr/bin/systemctl", "try-reload-or-restart", "ssh.service"])
|
||||
import socket
|
||||
with socket.create_connection(("127.0.0.1", 22), timeout=3) as connection:
|
||||
if not connection.recv(256).startswith(b"SSH-2.0-"):
|
||||
raise SetupError("SSH запущен, но не подтвердил локальную готовность.")
|
||||
return "SSH отвечает локально; реестр доверенных ключей подключён."
|
||||
|
||||
|
||||
def tailscale_install():
|
||||
# Reuse the existing pinned provider installer, checksum and operation lock.
|
||||
result = subprocess.run(["/usr/lib/mission-core-node/install-tailscale"], env=ENV, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise SetupError("Не удалось запустить установку Tailscale.")
|
||||
value = json.loads(result.stdout)
|
||||
if value.get("ok") is not True:
|
||||
raise SetupError(str(value.get("error", "Установка Tailscale не завершена."))[:1024])
|
||||
command(["/usr/bin/systemctl", "is-active", "tailscaled.service"])
|
||||
return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно."
|
||||
|
||||
|
||||
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install}
|
||||
|
||||
|
||||
def run_steps(profile, operations, save):
|
||||
record = {"schema": profile["schema"], "profile_revision": profile["revision"], "run_id": str(uuid.uuid4()), "state": "running", "started_at": time.time(), "steps": [{"id": step["id"], "state": "pending", "detail": ""} for step in profile["steps"]]}
|
||||
def update():
|
||||
record["updated_at"] = time.time()
|
||||
save(record)
|
||||
update()
|
||||
for specification, step in zip(profile["steps"], record["steps"]):
|
||||
states = {item["id"]: item["state"] for item in record["steps"]}
|
||||
if any(states.get(dependency) != "complete" for dependency in specification["requires"]):
|
||||
step.update(state="blocked", detail="Сначала завершите предыдущие необходимые этапы.")
|
||||
update()
|
||||
continue
|
||||
step.update(state="running", detail="")
|
||||
update()
|
||||
try:
|
||||
step.update(state="complete", detail=operations[step["id"]]())
|
||||
except SetupError as error:
|
||||
step.update(state="error", detail=str(error))
|
||||
except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
step.update(state="error", detail="Не удалось завершить этап. Повторите настройку; сведения о проблеме сохранены в этом списке.")
|
||||
update()
|
||||
record["state"] = "complete" if all(step["state"] == "complete" for step in record["steps"]) else "error"
|
||||
update()
|
||||
return record
|
||||
|
||||
|
||||
def start():
|
||||
trusted_directory(STATE)
|
||||
fd = os.open(STATE / "dispatch.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise SetupError("Настройка уже выполняется. Дождитесь её завершения.")
|
||||
result = subprocess.run(["/usr/bin/systemctl", "start", UNIT], env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode:
|
||||
raise SetupError("Задание настройки не завершилось. Посмотрите этапы и повторите действие.")
|
||||
record = json.loads((STATE / "last-run.json").read_text())
|
||||
# Renew the ordinary local UI session after a service restart. The
|
||||
# capability is returned only to the native launcher, never to reports.
|
||||
return {"ok": record["state"] == "complete", "login_uri": authorize()}
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0 or sys.argv[1:] not in (["start"], ["run"]):
|
||||
raise SystemExit("Use the installed application's environment setup")
|
||||
os.environ.clear()
|
||||
os.environ.update(ENV)
|
||||
os.umask(0o077)
|
||||
try:
|
||||
if sys.argv[1] == "run":
|
||||
profile = json.loads(PROFILE.read_text())
|
||||
if {step["id"] for step in profile["steps"]} != set(OPERATIONS):
|
||||
raise SetupError("Профиль окружения не соответствует установленной версии.")
|
||||
run_steps(profile, OPERATIONS, lambda record: publish(STATE / "last-run.json", (json.dumps(record) + "\n").encode()))
|
||||
return
|
||||
result = start()
|
||||
except SetupError as error:
|
||||
result = {"ok": False, "error": str(error)}
|
||||
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
|
||||
result = {"ok": False, "error": "Не удалось выполнить настройку окружения. Повторите действие."}
|
||||
if sys.argv[1] == "run":
|
||||
raise SystemExit(1)
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Engineering build input, never run on an operator board. Exact PyPI hashes only."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
manifest = json.loads((root / "packaging/realsense-bundle.json").read_text())
|
||||
output = root / "build/realsense-wheels"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
for item in manifest["wheels"]:
|
||||
target = output / item["name"]
|
||||
if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == item["sha256"]:
|
||||
continue
|
||||
package, version = item["name"].split("-")[:2]
|
||||
with urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=30) as response:
|
||||
metadata = json.load(response)
|
||||
source = next(
|
||||
v
|
||||
for v in metadata["urls"]
|
||||
if v["filename"] == item["name"] and v["digests"]["sha256"] == item["sha256"]
|
||||
)
|
||||
if not source["url"].startswith("https://files.pythonhosted.org/"):
|
||||
raise ValueError("Unexpected package origin")
|
||||
with urlopen(source["url"], timeout=120) as response:
|
||||
data = response.read(item["bytes"] + 1)
|
||||
if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("Driver checksum mismatch")
|
||||
target.write_bytes(data)
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py install
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/python3
|
||||
"""Standalone GTK application. Only the fixed polkit helper runs as root."""
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("WebKit2", "4.1")
|
||||
from gi.repository import Gio, GLib, Gtk, WebKit2
|
||||
|
||||
ORIGIN = "http://127.0.0.1:8780"
|
||||
LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=[A-Za-z0-9_-]{43}")
|
||||
|
||||
|
||||
def local_url(uri):
|
||||
try:
|
||||
u = urlsplit(uri)
|
||||
return (u.scheme, u.hostname, u.port) == ("http", "127.0.0.1", 8780) and not u.username and not u.password
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def authorize(development_socket=None):
|
||||
if development_socket:
|
||||
# Engineering-only, unprivileged service. Cannot read the deployed
|
||||
# service's protected Unix socket and never grants OS privileges.
|
||||
connection = http.client.HTTPConnection("local", timeout=5)
|
||||
connection.sock = socket.socket(socket.AF_UNIX)
|
||||
connection.sock.settimeout(5)
|
||||
try:
|
||||
connection.sock.connect(development_socket)
|
||||
connection.request("POST", "/login", headers={"Content-Type": "application/json"})
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise ValueError("Local authorization failed")
|
||||
uri = json.loads(response.read(1024))["url"]
|
||||
finally:
|
||||
connection.close()
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/pkexec", "/usr/lib/mission-core-node/authorize"],
|
||||
check=True, capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
uri = result.stdout.strip()
|
||||
if not LOGIN.fullmatch(uri):
|
||||
raise ValueError("Unexpected launcher response")
|
||||
return uri
|
||||
|
||||
|
||||
class NodeApplication(Gtk.Application):
|
||||
def __init__(self, development_socket=None):
|
||||
super().__init__(application_id="org.nodedc.MissionCoreNode", flags=Gio.ApplicationFlags.FLAGS_NONE)
|
||||
self.development_socket = development_socket
|
||||
self.window = None
|
||||
self.pending = False
|
||||
self.initial_login = False
|
||||
self.cancelled_downloads = set()
|
||||
self.environment_timer = None
|
||||
self.environment_previous = None
|
||||
|
||||
def do_activate(self):
|
||||
if self.window:
|
||||
self.window.present()
|
||||
return
|
||||
self.window = Gtk.ApplicationWindow(application=self)
|
||||
self.window.set_title("Mission Core Node")
|
||||
self.window.set_default_size(1100, 780)
|
||||
self.window.set_icon_name("org.nodedc.MissionCoreNode")
|
||||
context = WebKit2.WebContext.new_ephemeral()
|
||||
context.connect("download-started", self.download_started)
|
||||
self.view = WebKit2.WebView.new_with_context(context)
|
||||
self.view.get_settings().set_enable_developer_extras(False)
|
||||
self.view.connect("context-menu", lambda *_: True)
|
||||
self.view.connect("decide-policy", self.decide_policy)
|
||||
self.view.connect("permission-request", self.deny_permission)
|
||||
self.view.connect("load-failed", self.load_failed)
|
||||
self.view.connect("load-changed", self.loaded)
|
||||
self.view.connect("web-process-terminated", self.process_failed)
|
||||
manager = self.view.get_user_content_manager()
|
||||
manager.add_script(WebKit2.UserScript.new(
|
||||
"Object.defineProperty(window, 'missionCoreDesktop', {value: Object.freeze({networkSetup: true, environmentSetup: true})});",
|
||||
WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None,
|
||||
))
|
||||
manager.register_script_message_handler("node")
|
||||
manager.connect("script-message-received::node", self.message)
|
||||
self.window.add(self.view)
|
||||
self.window.connect("destroy", self.destroyed)
|
||||
self.window.show_all()
|
||||
self.view.load_uri(ORIGIN)
|
||||
|
||||
def loaded(self, _view, event):
|
||||
if event == WebKit2.LoadEvent.FINISHED and not self.initial_login:
|
||||
self.initial_login = True
|
||||
self.login()
|
||||
|
||||
def destroyed(self, *_):
|
||||
self.window = None
|
||||
|
||||
def message(self, _manager, result):
|
||||
if not local_url(self.view.get_uri() or ""):
|
||||
return
|
||||
action = result.get_js_value().to_string()
|
||||
if action == "authorize":
|
||||
self.login()
|
||||
elif action == "configure-system":
|
||||
self.configure_environment()
|
||||
elif action in ("install-tailscale", "connect-tailscale"):
|
||||
self.network_action(action)
|
||||
|
||||
def environment_record(self):
|
||||
try:
|
||||
path = Path("/var/lib/mission-core-node-environment/last-run.json")
|
||||
if path.stat().st_size > 32768:
|
||||
return None
|
||||
value = json.loads(path.read_text())
|
||||
if value.get("schema") == "missioncore.node.environment/v1":
|
||||
return value
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def environment_progress(self):
|
||||
if not self.window or not self.pending:
|
||||
self.environment_timer = None
|
||||
return False
|
||||
record = self.environment_record()
|
||||
if record and record.get("run_id") != self.environment_previous:
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-environment-progress', {detail: " + json.dumps(record) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
return True
|
||||
|
||||
def configure_environment(self):
|
||||
if self.pending:
|
||||
return
|
||||
self.pending = True
|
||||
previous = self.environment_record()
|
||||
self.environment_previous = previous.get("run_id") if previous else None
|
||||
self.environment_timer = GLib.timeout_add(1000, self.environment_progress)
|
||||
def work():
|
||||
value = {"ok": False}
|
||||
try:
|
||||
process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/configure-system"], capture_output=True, text=True)
|
||||
if process.returncode:
|
||||
value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие."
|
||||
else:
|
||||
value = json.loads(process.stdout)
|
||||
if type(value.get("ok")) is not bool:
|
||||
raise ValueError("Unexpected environment result")
|
||||
if value.get("login_uri") and not LOGIN.fullmatch(value["login_uri"]):
|
||||
raise ValueError("Unexpected local login")
|
||||
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
|
||||
value = {"ok": False, "error": "Не удалось завершить настройку окружения. Повторите действие."}
|
||||
GLib.idle_add(self.environment_finished, value)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def environment_finished(self, value):
|
||||
self.pending = False
|
||||
if self.environment_timer:
|
||||
GLib.source_remove(self.environment_timer)
|
||||
self.environment_timer = None
|
||||
uri = value.pop("login_uri", None)
|
||||
value["reloading"] = bool(uri)
|
||||
if self.window:
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-environment-result', {detail: " + json.dumps(value) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
if uri:
|
||||
self.view.load_uri(uri)
|
||||
return False
|
||||
|
||||
def network_action(self, action):
|
||||
if self.pending:
|
||||
self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False)
|
||||
return
|
||||
self.pending = True
|
||||
def work():
|
||||
value = {"action": action, "ok": False}
|
||||
try:
|
||||
# The action is selected from the allowlist above. No command,
|
||||
# URL, credential, path or network option is accepted from JS.
|
||||
process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/" + action],
|
||||
capture_output=True, text=True)
|
||||
if process.returncode:
|
||||
value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие."
|
||||
else:
|
||||
result = json.loads(process.stdout)
|
||||
if not isinstance(result, dict) or type(result.get("ok")) is not bool:
|
||||
raise ValueError("Unexpected helper response")
|
||||
value["ok"] = result["ok"]
|
||||
if result.get("url"):
|
||||
uri = result["url"]
|
||||
if not re.fullmatch(r"https://login\.tailscale\.com/a/[A-Za-z0-9_-]{1,256}", uri):
|
||||
raise ValueError("Unexpected login destination")
|
||||
# Keep the provider credential inside the native process;
|
||||
# no auth URL is persisted or returned to the web API/JS.
|
||||
value["login_uri"] = uri
|
||||
if not value["ok"]:
|
||||
value["error"] = str(result.get("error", "Настройка Tailscale не завершена."))[:1024]
|
||||
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
|
||||
value = {"action": action, "ok": False, "error": "Не удалось выполнить настройку Tailscale. Повторите действие."}
|
||||
GLib.idle_add(self.network_result, value)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def network_result(self, value, completed=True):
|
||||
if completed:
|
||||
self.pending = False
|
||||
uri = value.pop("login_uri", None)
|
||||
if not self.window:
|
||||
return False
|
||||
if uri:
|
||||
try:
|
||||
Gio.AppInfo.launch_default_for_uri(uri, None)
|
||||
value["browser_opened"] = True
|
||||
except GLib.Error:
|
||||
value["ok"] = False
|
||||
value["error"] = "Не удалось открыть браузер. Проверьте системный браузер по умолчанию и повторите вход."
|
||||
script = "window.dispatchEvent(new CustomEvent('mission-core-network-result', {detail: " + json.dumps(value) + "}));"
|
||||
self.view.evaluate_javascript(script, -1, None, None, None, None, None)
|
||||
return False
|
||||
|
||||
def login(self):
|
||||
if self.pending:
|
||||
return
|
||||
self.pending = True
|
||||
def work():
|
||||
try:
|
||||
uri = authorize(self.development_socket)
|
||||
GLib.idle_add(self.login_ready, uri)
|
||||
except (OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError):
|
||||
GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос.")
|
||||
finally:
|
||||
GLib.idle_add(self.login_finished)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def login_ready(self, uri):
|
||||
if self.window:
|
||||
self.view.load_uri(uri)
|
||||
return False
|
||||
|
||||
def login_finished(self):
|
||||
self.pending = False
|
||||
return False
|
||||
|
||||
def problem(self, message):
|
||||
if not self.window:
|
||||
return False
|
||||
dialog = Gtk.MessageDialog(transient_for=self.window, modal=True,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.CLOSE,
|
||||
text="Mission Core Node")
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.connect("response", lambda d, _: d.destroy())
|
||||
dialog.show()
|
||||
return False
|
||||
|
||||
def load_failed(self, _view, _event, uri, _error):
|
||||
if local_url(uri):
|
||||
self.problem("Служба ноды недоступна. Повторно откройте приложение после восстановления службы.")
|
||||
return True
|
||||
|
||||
def process_failed(self, *_):
|
||||
self.problem("Окно приложения остановилось. Закройте и повторно откройте Mission Core Node. Служба борта продолжает работать отдельно.")
|
||||
|
||||
def deny_permission(self, _view, permission):
|
||||
permission.deny()
|
||||
return True
|
||||
|
||||
def decide_policy(self, _view, decision, kind):
|
||||
if kind in (WebKit2.PolicyDecisionType.NAVIGATION_ACTION, WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION):
|
||||
uri = decision.get_navigation_action().get_request().get_uri()
|
||||
if kind == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION or not local_url(uri):
|
||||
decision.ignore()
|
||||
return True
|
||||
elif kind == WebKit2.PolicyDecisionType.RESPONSE:
|
||||
uri = decision.get_request().get_uri()
|
||||
if not local_url(uri):
|
||||
decision.ignore()
|
||||
return True
|
||||
if urlsplit(uri).path == "/api/report" and decision.get_response().get_status_code() == 200:
|
||||
decision.download()
|
||||
return True
|
||||
return False
|
||||
|
||||
def download_started(self, _context, download):
|
||||
uri = download.get_request().get_uri()
|
||||
if not local_url(uri) or urlsplit(uri).path != "/api/report":
|
||||
download.cancel()
|
||||
return
|
||||
download.connect("decide-destination", self.download_destination)
|
||||
download.connect("failed", self.download_failed)
|
||||
|
||||
def download_failed(self, download, _error):
|
||||
if download in self.cancelled_downloads:
|
||||
self.cancelled_downloads.discard(download)
|
||||
return False
|
||||
return self.problem("Не удалось сохранить отчёт.")
|
||||
|
||||
def download_destination(self, download, _suggested):
|
||||
chooser = Gtk.FileChooserNative.new("Сохранить отчёт", self.window,
|
||||
Gtk.FileChooserAction.SAVE, "Сохранить", "Отмена")
|
||||
chooser.set_current_name("mission-core-node-report.json")
|
||||
chooser.set_do_overwrite_confirmation(True)
|
||||
if chooser.run() == Gtk.ResponseType.ACCEPT:
|
||||
download.set_allow_overwrite(True)
|
||||
download.set_destination(Path(chooser.get_filename()).as_uri())
|
||||
else:
|
||||
self.cancelled_downloads.add(download)
|
||||
download.cancel()
|
||||
chooser.destroy()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--development-socket", help="Engineering-only: private socket of an unprivileged development service")
|
||||
arguments = parser.parse_args()
|
||||
if os.geteuid() == 0:
|
||||
raise SystemExit("Run the desktop application as your normal system user")
|
||||
raise SystemExit(NodeApplication(arguments.development_socket).run([]))
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Mission Core Node explicit environment configuration
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/environment_helper.py run
|
||||
Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
Environment=LANG=C.UTF-8
|
||||
Environment=DEBIAN_FRONTEND=noninteractive
|
||||
UMask=0077
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
# A package transaction must finish even if the operator closes the UI.
|
||||
TimeoutStartSec=0
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Mission Core fixed RealSense model preparation
|
||||
After=systemd-udevd.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/realsense_prepare.py
|
||||
TimeoutStartSec=300
|
||||
UMask=0022
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Mission Core Node
|
||||
Comment=Настройка и диагностика бортового компьютера
|
||||
Exec=/usr/bin/mission-core-node
|
||||
Icon=org.nodedc.MissionCoreNode
|
||||
Terminal=false
|
||||
Categories=System;
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=Mission Core Node local device host
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mission-core-node
|
||||
Group=mission-core-node
|
||||
ExecStart=/usr/lib/mission-core-node/node-agent
|
||||
StateDirectory=mission-core-node
|
||||
StateDirectoryMode=0700
|
||||
RuntimeDirectory=mission-core-node
|
||||
RuntimeDirectoryMode=0700
|
||||
UMask=0077
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
# The explicit environment workflow admits read-only route netlink metadata.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=yes
|
||||
LimitNOFILE=1024
|
||||
TasksMax=64
|
||||
MemoryMax=256M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=Mission Core isolated RealSense driver
|
||||
After=network.target
|
||||
ConditionPathExists=/var/lib/mission-core-node-drivers/active.path
|
||||
[Service]
|
||||
User=mission-core-sensors
|
||||
Group=mission-core-node
|
||||
SupplementaryGroups=mission-core-sensors
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/sensors/bootstrap.py
|
||||
StateDirectory=mission-core-sensors
|
||||
StateDirectoryMode=0700
|
||||
RuntimeDirectory=mission-core-sensors
|
||||
RuntimeDirectoryMode=0750
|
||||
UMask=0077
|
||||
Environment=OPENBLAS_NUM_THREADS=1
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=yes
|
||||
TasksMax=128
|
||||
MemoryMax=900M
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/python3
|
||||
"""Fixed polkit operations for the optional Tailscale provider. Never a shell API."""
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import stat
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
TAILSCALE = "/usr/bin/tailscale"
|
||||
RELEASE = Path("/usr/share/mission-core-node/tailscale-release.json")
|
||||
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
|
||||
TRANSPORT_DIRECTORY = Path("/etc/systemd/system/tailscaled.service.d")
|
||||
TRANSPORT_NAME = "60-mission-core-node-https.conf"
|
||||
TRANSPORT_CONFIG = b"# Mission Core Node: provider control transport; preserve on Node removal.\n[Service]\nEnvironment=TS_FORCE_NOISE_443=true\n"
|
||||
|
||||
|
||||
class SetupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def login_url(value):
|
||||
if not isinstance(value, str) or len(value) > 512:
|
||||
return False
|
||||
try:
|
||||
u = urlsplit(value)
|
||||
return (u.scheme == "https" and u.netloc == "login.tailscale.com"
|
||||
and not u.query and not u.fragment
|
||||
and re.fullmatch(r"/a/[A-Za-z0-9_-]+", u.path) is not None)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def status():
|
||||
result = subprocess.run([TAILSCALE, "status", "--json", "--peers=false"],
|
||||
env=ENV, capture_output=True, text=True, timeout=8)
|
||||
if result.returncode or len(result.stdout) > 1024 * 1024:
|
||||
raise SetupError("Служба Tailscale пока не отвечает. Подождите и повторите подключение.")
|
||||
value = json.loads(result.stdout)
|
||||
if not isinstance(value, dict):
|
||||
raise SetupError("Не удалось прочитать состояние Tailscale.")
|
||||
return value
|
||||
|
||||
|
||||
def checked(command):
|
||||
# Do not kill dpkg mid-transaction if the desktop window is closed. APT has
|
||||
# bounded network/lock waits; the fixed root process completes independently.
|
||||
result = subprocess.run(command, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if result.returncode:
|
||||
raise SetupError("Установка не завершена. Проверьте интернет и завершение других системных установок, затем повторите.")
|
||||
|
||||
|
||||
def control_transport():
|
||||
"""Use the pinned provider's HTTPS underlay on networks that stall port 80.
|
||||
|
||||
Only a Node-owned systemd drop-in is written; keys, profiles, DNS, routes
|
||||
and other provider settings are never edited. An active connected provider
|
||||
is left untouched by callers. Never replace a custom file at our path.
|
||||
"""
|
||||
TRANSPORT_DIRECTORY.mkdir(mode=0o755, exist_ok=True)
|
||||
info = TRANSPORT_DIRECTORY.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise SetupError("Небезопасные права каталога службы Tailscale. Требуется проверить настройку системы.")
|
||||
destination = TRANSPORT_DIRECTORY / TRANSPORT_NAME
|
||||
if destination.is_symlink():
|
||||
raise SetupError("Обнаружена другая настройка транспорта Tailscale; она сохранена без изменений.")
|
||||
if destination.exists():
|
||||
info = destination.stat()
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022
|
||||
or destination.read_bytes() != TRANSPORT_CONFIG):
|
||||
raise SetupError("Обнаружена другая настройка транспорта Tailscale; она сохранена без изменений.")
|
||||
return
|
||||
# Root-only operation lock serializes our own setup. Publish a complete
|
||||
# file atomically; systemd must never see a half-written configuration.
|
||||
with tempfile.NamedTemporaryFile(dir=TRANSPORT_DIRECTORY, prefix=".node-https-", delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
try:
|
||||
output.write(TRANSPORT_CONFIG)
|
||||
output.flush()
|
||||
os.fchmod(output.fileno(), 0o644)
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary, destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
checked(["/usr/bin/systemctl", "daemon-reload"])
|
||||
|
||||
|
||||
def https_transport_active():
|
||||
result = subprocess.run(["/usr/bin/systemctl", "show", "--property=MainPID", "--value", "tailscaled.service"],
|
||||
env=ENV, capture_output=True, text=True, timeout=5)
|
||||
pid = result.stdout.strip()
|
||||
if result.returncode or not re.fullmatch(r"[1-9][0-9]{0,9}", pid):
|
||||
return False
|
||||
try:
|
||||
# Read only to check this one nonsensitive flag; never emit the process
|
||||
# environment (which can contain unrelated administrator credentials).
|
||||
return b"TS_FORCE_NOISE_443=true" in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def install():
|
||||
if not Path(TAILSCALE).exists():
|
||||
release = json.loads(RELEASE.read_text())
|
||||
expected = release["sha256"]
|
||||
url = release["url"]
|
||||
if (not isinstance(expected, str) or not isinstance(url, str)
|
||||
or not re.fullmatch(r"[a-f0-9]{64}", expected)
|
||||
or not re.fullmatch(r"https://pkgs\.tailscale\.com/stable/tailscale_[0-9.]+_amd64\.deb", url)):
|
||||
raise SetupError("Повреждены сведения об установочном пакете Tailscale.")
|
||||
with tempfile.TemporaryDirectory(prefix="mission-core-tailscale-", dir="/var/tmp") as directory:
|
||||
package = Path(directory) / "tailscale.deb"
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with urllib.request.urlopen(url, timeout=30) as response, package.open("xb") as output:
|
||||
while chunk := response.read(1024 * 1024):
|
||||
size += len(chunk)
|
||||
if size > 64 * 1024 * 1024:
|
||||
raise SetupError("Размер пакета Tailscale не соответствует ожидаемому.")
|
||||
digest.update(chunk)
|
||||
output.write(chunk)
|
||||
if digest.hexdigest() != expected:
|
||||
raise SetupError("Контрольная сумма Tailscale не совпала. Пакет не установлен; повторите загрузку.")
|
||||
checked(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1",
|
||||
"-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30",
|
||||
"--no-remove", "--no-install-recommends", "install", "-y", str(package)])
|
||||
control_transport()
|
||||
# The vendor package may already have started its daemon during APT.
|
||||
checked(["/usr/bin/systemctl", "restart", "tailscaled.service"])
|
||||
checked(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
|
||||
if not Path(TAILSCALE).is_file():
|
||||
raise SetupError("Установщик завершился, но Tailscale не найден.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def connect_command(state):
|
||||
if state == "Stopped":
|
||||
# Up with absolutely no flags is the upstream preserve-all-preferences
|
||||
# resume operation. Even --json counts as a flag in the pinned CLI.
|
||||
return [TAILSCALE, "up"]
|
||||
if state == "NeedsLogin":
|
||||
# Fresh onboard setup keeps the current LAN DNS/routes. No exit node,
|
||||
# route advertisement, Tailscale SSH, reset, or forced reauthentication.
|
||||
return [TAILSCALE, "up", "--json", "--timeout=12s", "--accept-dns=false", "--accept-routes=false"]
|
||||
raise SetupError("Tailscale ещё запускается. Подождите и повторите подключение.")
|
||||
|
||||
|
||||
def connect():
|
||||
if not Path(TAILSCALE).is_file():
|
||||
raise SetupError("Сначала установите Tailscale через приложение.")
|
||||
checked(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
|
||||
current = status()
|
||||
state = current.get("BackendState")
|
||||
if state in ("Running", "NeedsMachineAuth"):
|
||||
return {"ok": True}
|
||||
control_transport()
|
||||
if not https_transport_active():
|
||||
checked(["/usr/bin/systemctl", "daemon-reload"])
|
||||
checked(["/usr/bin/systemctl", "restart", "tailscaled.service"])
|
||||
if not https_transport_active():
|
||||
raise SetupError("Другие настройки службы мешают восстановить соединение Tailscale. Они сохранены; требуется проверить конфигурацию системы.")
|
||||
current = status()
|
||||
state = current.get("BackendState")
|
||||
if state in ("Running", "NeedsMachineAuth"):
|
||||
return {"ok": True}
|
||||
# A pending provider login is reused; never force a second authentication.
|
||||
if state == "NeedsLogin" and login_url(current.get("AuthURL")):
|
||||
return {"ok": True, "url": current["AuthURL"]}
|
||||
try:
|
||||
result = subprocess.run(connect_command(state), env=ENV, capture_output=True, timeout=18)
|
||||
success = result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
success = False
|
||||
# Read the daemon's actual outcome, not the CLI's progress text. AuthURL and
|
||||
# any other provider credentials are never written to disk or the journal.
|
||||
current = status()
|
||||
if current.get("BackendState") in ("Running", "NeedsMachineAuth"):
|
||||
return {"ok": True}
|
||||
if login_url(current.get("AuthURL")):
|
||||
return {"ok": True, "url": current["AuthURL"]}
|
||||
if success:
|
||||
return {"ok": True}
|
||||
raise SetupError("Tailscale не завершил подключение. Проверьте интернет и повторите; существующие нестандартные настройки требуют отдельной проверки.")
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0 or sys.argv[1:] not in (["install"], ["connect"]):
|
||||
raise SystemExit("Use the installed application and its system authorization dialog")
|
||||
os.environ.clear()
|
||||
os.environ.update(ENV)
|
||||
os.umask(0o077)
|
||||
try:
|
||||
directory = Path("/run/mission-core-node-system")
|
||||
directory.mkdir(mode=0o700, exist_ok=True)
|
||||
info = directory.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
|
||||
raise SetupError("Небезопасные права системного каталога Node. Требуется восстановить установку.")
|
||||
fd = os.open(directory / "tailscale.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise SetupError("Операция с Tailscale уже выполняется. Подождите и обновите состояние.")
|
||||
result = install() if sys.argv[1] == "install" else connect()
|
||||
except SetupError as error:
|
||||
result = {"ok": False, "error": str(error)}
|
||||
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
|
||||
result = {"ok": False, "error": "Не удалось завершить настройку Tailscale. Проверьте подключение к интернету и повторите."}
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>NODE.DC</vendor>
|
||||
<action id="org.nodedc.mission-core-node.configure-system">
|
||||
<description>Configure the Mission Core Node environment</description>
|
||||
<description xml:lang="ru">Настроить окружение Mission Core Node</description>
|
||||
<message>Install required packages, configure Node and SSH services, collect system inventory and install Tailscale.</message>
|
||||
<message xml:lang="ru">Установить зависимости, настроить службы БК и SSH, проверить сеть и USB, установить Tailscale. Вход и ключи подтверждаются отдельно.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/configure-system</annotate>
|
||||
</action>
|
||||
<action id="org.nodedc.mission-core-node.open">
|
||||
<description>Open Mission Core Node</description>
|
||||
<description xml:lang="ru">Открыть Mission Core Node</description>
|
||||
<message>Authenticate to manage this onboard computer.</message>
|
||||
<message xml:lang="ru">Подтвердите доступ к управлению этим бортовым компьютером.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/authorize</annotate>
|
||||
</action>
|
||||
<action id="org.nodedc.mission-core-node.install-tailscale">
|
||||
<description>Install Tailscale for Mission Core Node</description>
|
||||
<description xml:lang="ru">Установить Tailscale для Mission Core Node</description>
|
||||
<message>Install the verified Tailscale package and enable its system service.</message>
|
||||
<message xml:lang="ru">Установить проверенный пакет Tailscale и включить его системную службу.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/install-tailscale</annotate>
|
||||
</action>
|
||||
<action id="org.nodedc.mission-core-node.connect-tailscale">
|
||||
<description>Connect this computer to Tailscale</description>
|
||||
<description xml:lang="ru">Подключить борт к Tailscale</description>
|
||||
<message>Enable Tailscale and open its sign-in page if authentication is required.</message>
|
||||
<message xml:lang="ru">Включить Tailscale и открыть страницу входа, если требуется авторизация.</message>
|
||||
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/connect-tailscale</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$1" in
|
||||
configure)
|
||||
if ! getent passwd mission-core-node >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
|
||||
fi
|
||||
if ! getent passwd mission-core-sensors >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors
|
||||
fi
|
||||
# Only bootstrap required to open the GUI. Operational configuration is a
|
||||
# versioned job started by «Настройка окружения → Сконфигурировать».
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable mission-core-node.service
|
||||
systemctl restart mission-core-node.service
|
||||
systemctl try-restart mission-core-realsense.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
# Preserve identity and ownership on remove/purge. A future explicit UI factory
|
||||
# reset must distinguish local deletion from revoking remote Core authorization.
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
if [ -d /run/systemd/system ]; then
|
||||
if [ -S /run/mission-core-sensors/driver.sock ]; then
|
||||
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("driver",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-sensors/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
|
||||
echo "Mission Core Node: остановите захват или просмотр записи перед обновлением или удалением." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true)
|
||||
case "$mc_node_environment_state" in
|
||||
active|activating)
|
||||
echo "Mission Core Node: дождитесь завершения настройки окружения в приложении." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
. /etc/os-release
|
||||
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
|
||||
echo "Mission Core Node: this package does not support the installed system." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ -d /run/systemd/system ]; then
|
||||
if [ -S /run/mission-core-sensors/driver.sock ]; then
|
||||
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("driver",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-sensors/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
|
||||
echo "Mission Core Node: остановите захват или просмотр записи перед обновлением или удалением." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true)
|
||||
case "$mc_node_environment_state" in
|
||||
active|activating)
|
||||
echo "Mission Core Node: дождитесь завершения настройки окружения в приложении." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
if [ -e "$mc_node_ssh_snippet" ]; then
|
||||
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
|
||||
rm "$mc_node_ssh_snippet"
|
||||
else
|
||||
mc_node_saved_snippet=$(mktemp /etc/ssh/sshd_config.d/mission-core-node-removed.XXXXXX)
|
||||
mv "$mc_node_ssh_snippet" "$mc_node_saved_snippet"
|
||||
fi
|
||||
fi
|
||||
mc_node_environment_snippet=/etc/systemd/system/mission-core-node.service.d/60-environment.conf
|
||||
if [ ! -L "$mc_node_environment_snippet" ] && cmp -s /usr/share/mission-core-node/60-environment.conf "$mc_node_environment_snippet"; then
|
||||
rm "$mc_node_environment_snippet"
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
if [ -x /usr/sbin/sshd ]; then
|
||||
/usr/sbin/sshd -t
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
fi
|
||||
systemctl stop mission-core-realsense.service
|
||||
systemctl disable mission-core-realsense.service || true
|
||||
systemctl stop mission-core-node.service
|
||||
systemctl disable mission-core-node.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"schema": "missioncore.node.driver-bundle/v1",
|
||||
"model_id": "realsense.d455",
|
||||
"revision": "c70509ac57b917dbdd5707a9",
|
||||
"python": "3.12",
|
||||
"platform": "linux-amd64",
|
||||
"wheels": [
|
||||
{
|
||||
"name": "aiohappyeyeballs-2.7.1-py3-none-any.whl",
|
||||
"sha256": "9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472",
|
||||
"bytes": 15038
|
||||
},
|
||||
{
|
||||
"name": "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||
"sha256": "fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545",
|
||||
"bytes": 1719929
|
||||
},
|
||||
{
|
||||
"name": "aioice-0.10.2-py3-none-any.whl",
|
||||
"sha256": "14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf",
|
||||
"bytes": 24875
|
||||
},
|
||||
{
|
||||
"name": "aiortc-1.14.0-py3-none-any.whl",
|
||||
"sha256": "4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e",
|
||||
"bytes": 93183
|
||||
},
|
||||
{
|
||||
"name": "aiosignal-1.4.0-py3-none-any.whl",
|
||||
"sha256": "053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e",
|
||||
"bytes": 7490
|
||||
},
|
||||
{
|
||||
"name": "annotated_types-0.8.0-py3-none-any.whl",
|
||||
"sha256": "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0",
|
||||
"bytes": 13427
|
||||
},
|
||||
{
|
||||
"name": "attrs-26.1.0-py3-none-any.whl",
|
||||
"sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
|
||||
"bytes": 67548
|
||||
},
|
||||
{
|
||||
"name": "av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl",
|
||||
"sha256": "7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2",
|
||||
"bytes": 41174337
|
||||
},
|
||||
{
|
||||
"name": "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
|
||||
"sha256": "c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf",
|
||||
"bytes": 221822
|
||||
},
|
||||
{
|
||||
"name": "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
|
||||
"sha256": "ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef",
|
||||
"bytes": 4712478
|
||||
},
|
||||
{
|
||||
"name": "dnspython-2.8.0-py3-none-any.whl",
|
||||
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af",
|
||||
"bytes": 331094
|
||||
},
|
||||
{
|
||||
"name": "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl",
|
||||
"sha256": "494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383",
|
||||
"bytes": 242411
|
||||
},
|
||||
{
|
||||
"name": "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl",
|
||||
"sha256": "14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411",
|
||||
"bytes": 33364
|
||||
},
|
||||
{
|
||||
"name": "idna-3.19-py3-none-any.whl",
|
||||
"sha256": "815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4",
|
||||
"bytes": 68550
|
||||
},
|
||||
{
|
||||
"name": "ifaddr-0.2.0-py3-none-any.whl",
|
||||
"sha256": "085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748",
|
||||
"bytes": 12314
|
||||
},
|
||||
{
|
||||
"name": "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961",
|
||||
"bytes": 256322
|
||||
},
|
||||
{
|
||||
"name": "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||
"sha256": "fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249",
|
||||
"bytes": 16527618
|
||||
},
|
||||
{
|
||||
"name": "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
|
||||
"sha256": "67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6",
|
||||
"bytes": 7644652
|
||||
},
|
||||
{
|
||||
"name": "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476",
|
||||
"bytes": 61639
|
||||
},
|
||||
{
|
||||
"name": "pycparser-3.0-py3-none-any.whl",
|
||||
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992",
|
||||
"bytes": 48172
|
||||
},
|
||||
{
|
||||
"name": "pydantic-2.11.7-py3-none-any.whl",
|
||||
"sha256": "dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b",
|
||||
"bytes": 444782
|
||||
},
|
||||
{
|
||||
"name": "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||
"sha256": "8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1",
|
||||
"bytes": 2002028
|
||||
},
|
||||
{
|
||||
"name": "pyee-14.0.0-py3-none-any.whl",
|
||||
"sha256": "3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d",
|
||||
"bytes": 15553
|
||||
},
|
||||
{
|
||||
"name": "pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc",
|
||||
"bytes": 2434534
|
||||
},
|
||||
{
|
||||
"name": "pyopenssl-26.4.0-py3-none-any.whl",
|
||||
"sha256": "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c",
|
||||
"bytes": 56026
|
||||
},
|
||||
{
|
||||
"name": "pyrealsense2-2.58.4.10922-cp312-cp312-manylinux1_x86_64.whl",
|
||||
"sha256": "1e83454cbaf9de50962d78ce3addb0b6a6d8d02259c1f0c64d4a0d527a02bf73",
|
||||
"bytes": 13094959
|
||||
},
|
||||
{
|
||||
"name": "typing_extensions-4.16.0-py3-none-any.whl",
|
||||
"sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8",
|
||||
"bytes": 45571
|
||||
},
|
||||
{
|
||||
"name": "typing_inspection-0.4.4-py3-none-any.whl",
|
||||
"sha256": "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147",
|
||||
"bytes": 14750
|
||||
},
|
||||
{
|
||||
"name": "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9",
|
||||
"bytes": 109835
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""udev-owned D455 IMU permission grant, limited to SDK capture controls."""
|
||||
|
||||
import grp
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def allowed_attributes(root):
|
||||
names = [
|
||||
"buffer/enable",
|
||||
"buffer/length",
|
||||
"buffer/watermark",
|
||||
"current_timestamp_clock",
|
||||
"in_accel_sampling_frequency",
|
||||
"in_accel_hysteresis",
|
||||
"in_anglvel_hysteresis",
|
||||
"in_anglvel_sampling_frequency",
|
||||
"scan_elements/in_timestamp_en",
|
||||
]
|
||||
names += [
|
||||
f"scan_elements/in_{kind}_{axis}_en" for kind in ("accel", "anglvel") for axis in "xyz"
|
||||
]
|
||||
for name in names:
|
||||
path = root / name
|
||||
if path.exists() and not path.is_symlink() and path.resolve().is_relative_to(root):
|
||||
yield path
|
||||
|
||||
|
||||
def validate(sys_path, sys_root=Path("/sys")):
|
||||
if not sys_path.startswith("/devices/") or ".." in sys_path.split("/"):
|
||||
raise ValueError("Invalid sysfs path")
|
||||
root = (sys_root / sys_path.lstrip("/")).resolve(strict=True)
|
||||
if not root.is_relative_to(sys_root / "devices") or not re.fullmatch(r"iio:device[0-9]+", root.name):
|
||||
raise ValueError("Not an IIO device")
|
||||
matched = False
|
||||
for parent in root.parents:
|
||||
if (parent / "idVendor").exists() and (parent / "idProduct").exists():
|
||||
matched = (parent / "idVendor").read_text().strip() == "8086" and (
|
||||
parent / "idProduct"
|
||||
).read_text().strip() == "0b5c"
|
||||
break
|
||||
if not matched:
|
||||
raise ValueError("Not an admitted D455")
|
||||
return root
|
||||
|
||||
|
||||
def grant(sys_path):
|
||||
root = validate(sys_path)
|
||||
group = grp.getgrnam("mission-core-sensors").gr_gid
|
||||
for path in allowed_attributes(root):
|
||||
os.chown(path, 0, group, follow_symlinks=False)
|
||||
os.chmod(path, 0o660, follow_symlinks=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.geteuid() != 0 or len(sys.argv) != 2:
|
||||
sys.exit(1)
|
||||
grant(sys.argv[1])
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Fixed model job. No paths, packages, URLs or commands are accepted from clients."""
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
SHARE = Path("/usr/share/mission-core-node/realsense")
|
||||
ROOT = Path("/var/lib/mission-core-node-drivers")
|
||||
REPORT = ROOT / "preparation.json"
|
||||
STEPS = [
|
||||
("platform", "Проверка совместимости системы"),
|
||||
("payload", "Проверка встроенного драйвера"),
|
||||
("runtime", "Развёртывание драйвера"),
|
||||
("access", "Настройка доступа к камере"),
|
||||
("service", "Запуск службы камеры"),
|
||||
]
|
||||
|
||||
|
||||
def publish(value):
|
||||
ROOT.mkdir(mode=0o755, exist_ok=True)
|
||||
if ROOT.is_symlink() or ROOT.stat().st_uid != 0 or ROOT.stat().st_mode & 0o022:
|
||||
raise RuntimeError("Небезопасный каталог драйверов")
|
||||
ROOT.chmod(0o755)
|
||||
handle, name = tempfile.mkstemp(prefix=".preparation-", dir=ROOT)
|
||||
tmp = Path(name)
|
||||
with os.fdopen(handle, "w") as f:
|
||||
os.fchmod(f.fileno(), 0o644)
|
||||
json.dump(value, f, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
tmp.replace(REPORT)
|
||||
|
||||
|
||||
def run(*args):
|
||||
result = subprocess.run(args, capture_output=True, timeout=90)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Системный этап не завершён. Повторите подготовку устройства.")
|
||||
|
||||
|
||||
def safe_members(archive):
|
||||
for info in archive.infolist():
|
||||
path = PurePosixPath(info.filename)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or (info.external_attr >> 16) & 0o170000 == 0o120000
|
||||
):
|
||||
raise RuntimeError("Недопустимое содержимое драйверного пакета")
|
||||
if ".data" in info.filename or info.filename.endswith(".pth"):
|
||||
raise RuntimeError("Пакет требует неподдерживаемый способ установки")
|
||||
yield info
|
||||
|
||||
|
||||
def configure_imu_namespace():
|
||||
# Isolated Python mode deliberately omits the script directory. This fixed,
|
||||
# root-owned module is the same allowlist used by the udev grant.
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"realsense_iio_access", Path(__file__).with_name("realsense_iio_access.py")
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
paths = []
|
||||
for root in Path("/sys/bus/iio/devices").glob("iio:device*"):
|
||||
resolved = root.resolve()
|
||||
try:
|
||||
module.validate(str(resolved)[4:])
|
||||
except ValueError:
|
||||
continue
|
||||
paths.extend(str(p) for p in module.allowed_attributes(resolved))
|
||||
# No subtree write grant. Only concrete allowlisted IIO attribute files
|
||||
# belonging to detected D455s are admitted to the service mount namespace.
|
||||
if any(" " in p or "\n" in p or "%" in p for p in paths):
|
||||
raise RuntimeError("Неподдерживаемый путь IMU")
|
||||
content = (
|
||||
"[Service]\nReadWritePaths=\n"
|
||||
+ "".join("ReadWritePaths=-" + p + "\n" for p in sorted(paths))
|
||||
).encode()
|
||||
folder = Path("/etc/systemd/system/mission-core-realsense.service.d")
|
||||
folder.mkdir(exist_ok=True, mode=0o755)
|
||||
destination = folder / "70-imu-access.conf"
|
||||
fingerprint = ROOT / "imu-config.sha256"
|
||||
previous = fingerprint.read_text() if fingerprint.exists() else None
|
||||
if destination.is_symlink() or fingerprint.is_symlink():
|
||||
raise RuntimeError("Конфликт настроек доступа IMU")
|
||||
if destination.exists():
|
||||
old = destination.read_bytes()
|
||||
if old == content:
|
||||
return False
|
||||
if hashlib.sha256(old).hexdigest() != previous:
|
||||
raise RuntimeError("Настройки IMU изменены в системе. Чужой файл сохранён.")
|
||||
# Read-only preflight; every entry point shares the board's capture owner.
|
||||
sock = Path("/run/mission-core-sensors/driver.sock")
|
||||
if sock.exists():
|
||||
client = http.client.HTTPConnection("driver", timeout=5)
|
||||
client.sock = socket.socket(socket.AF_UNIX)
|
||||
client.sock.settimeout(5)
|
||||
try:
|
||||
client.sock.connect(str(sock))
|
||||
client.request("GET", "/prepare-safe")
|
||||
response = client.getresponse()
|
||||
if response.status != 200 or not json.loads(response.read(1024)).get("safe"):
|
||||
raise RuntimeError("Остановите захват всех камер перед подготовкой драйвера.")
|
||||
finally:
|
||||
client.close()
|
||||
destination.write_bytes(content)
|
||||
destination.chmod(0o644)
|
||||
fingerprint.write_text(hashlib.sha256(content).hexdigest())
|
||||
fingerprint.chmod(0o644)
|
||||
return True
|
||||
|
||||
|
||||
def prepare():
|
||||
manifest = json.loads((SHARE / "bundle.json").read_text())
|
||||
revision = manifest["revision"]
|
||||
if not revision.isalnum():
|
||||
raise RuntimeError("Некорректная версия драйвера")
|
||||
target = ROOT / revision
|
||||
if target.is_symlink() or (ROOT / "active.path").is_symlink():
|
||||
raise RuntimeError("Конфликт установленного драйвера")
|
||||
imu_changed = False
|
||||
state = {
|
||||
"schema": "missioncore.node.device-preparation/v1",
|
||||
"model_id": "realsense.d455",
|
||||
"revision": revision,
|
||||
"run_id": str(uuid.uuid4()),
|
||||
"started_at": time.time(),
|
||||
"state": "running",
|
||||
"steps": [{"id": k, "label": v, "state": "pending"} for k, v in STEPS],
|
||||
}
|
||||
publish(state)
|
||||
try:
|
||||
for step in state["steps"]:
|
||||
step["state"] = "running"
|
||||
state["updated_at"] = time.time()
|
||||
publish(state)
|
||||
if step["id"] == "platform":
|
||||
release = dict(
|
||||
line.split("=", 1)
|
||||
for line in Path("/etc/os-release").read_text().splitlines()
|
||||
if "=" in line
|
||||
)
|
||||
if (
|
||||
(release.get("ID", "").strip('"'), release.get("VERSION_ID", "").strip('"'))
|
||||
!= ("ubuntu", "24.04")
|
||||
or os.uname().machine != "x86_64"
|
||||
or sys.version_info[:2] != (3, 12)
|
||||
):
|
||||
raise RuntimeError("Встроенный драйвер несовместим с этой системой")
|
||||
elif step["id"] == "payload":
|
||||
for entry in manifest["wheels"]:
|
||||
path = SHARE / entry["name"]
|
||||
if (
|
||||
path.name != entry["name"]
|
||||
or path.is_symlink()
|
||||
or hashlib.sha256(path.read_bytes()).hexdigest() != entry["sha256"]
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Контрольная сумма драйвера не совпала. Переустановите пакет Node."
|
||||
)
|
||||
elif step["id"] == "runtime":
|
||||
if not target.exists():
|
||||
stage = ROOT / (revision + ".partial")
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
stage.mkdir(mode=0o755)
|
||||
for entry in manifest["wheels"]:
|
||||
with zipfile.ZipFile(SHARE / entry["name"]) as archive:
|
||||
archive.extractall(stage, members=safe_members(archive))
|
||||
for path in stage.rglob("*"):
|
||||
path.chmod(0o755 if path.is_dir() else 0o644)
|
||||
stage.rename(target)
|
||||
# Wheel RECORD content is checked again against the bundled wheel;
|
||||
# a previous successful report never substitutes for integrity.
|
||||
for entry in manifest["wheels"]:
|
||||
with zipfile.ZipFile(SHARE / entry["name"]) as archive:
|
||||
for info in safe_members(archive):
|
||||
if not info.is_dir() and (
|
||||
target / info.filename
|
||||
).read_bytes() != archive.read(info):
|
||||
raise RuntimeError(
|
||||
"Установленный драйвер изменён. "
|
||||
"Нужна переустановка пакета драйвера."
|
||||
)
|
||||
(ROOT / "active.path").write_text(str(target))
|
||||
(ROOT / "active.path").chmod(0o644)
|
||||
elif step["id"] == "access":
|
||||
imu_changed = configure_imu_namespace()
|
||||
source = SHARE / "70-mission-core-realsense.rules"
|
||||
dest = Path("/etc/udev/rules.d/70-mission-core-realsense.rules")
|
||||
if dest.is_symlink() or (
|
||||
dest.exists()
|
||||
and dest.read_bytes() != source.read_bytes()
|
||||
and hashlib.sha256(dest.read_bytes()).hexdigest()
|
||||
!= "782eba7935400e688a7eaea53fe50d358a046eb6b2c99187a787cc03b0301449"
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Правила доступа к камере изменены в системе. Чужая конфигурация сохранена."
|
||||
)
|
||||
dest.write_bytes(source.read_bytes())
|
||||
dest.chmod(0o644)
|
||||
run("/usr/bin/udevadm", "control", "--reload-rules")
|
||||
# Restrict trigger to the admitted product; no unrelated USB reset.
|
||||
run(
|
||||
"/usr/bin/udevadm",
|
||||
"trigger",
|
||||
"--action=change",
|
||||
"--subsystem-match=usb",
|
||||
"--attr-match=idVendor=8086",
|
||||
"--attr-match=idProduct=0b5c",
|
||||
)
|
||||
run(
|
||||
"/usr/bin/udevadm",
|
||||
"trigger",
|
||||
"--action=change",
|
||||
"--subsystem-match=video4linux",
|
||||
)
|
||||
run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=hidraw")
|
||||
run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=iio")
|
||||
run("/usr/bin/udevadm", "settle", "--timeout=10")
|
||||
elif step["id"] == "service":
|
||||
run("/usr/bin/systemctl", "enable", "mission-core-realsense.service")
|
||||
# The fixed job refuses running capture before changing its namespace.
|
||||
run("/usr/bin/systemctl", "daemon-reload")
|
||||
run(
|
||||
"/usr/bin/systemctl",
|
||||
"restart" if imu_changed else "start",
|
||||
"mission-core-realsense.service",
|
||||
)
|
||||
run("/usr/bin/systemctl", "is-active", "--quiet", "mission-core-realsense.service")
|
||||
step["state"] = "complete"
|
||||
publish(state)
|
||||
state["state"] = "complete"
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
subprocess.SubprocessError,
|
||||
zipfile.BadZipFile,
|
||||
) as error:
|
||||
step["state"] = "error"
|
||||
step["message"] = (
|
||||
str(error)[:300]
|
||||
if isinstance(error, RuntimeError)
|
||||
else "Не удалось подготовить драйвер. Повторите действие."
|
||||
)
|
||||
state["state"] = "error"
|
||||
for item in state["steps"]:
|
||||
if item["state"] == "pending":
|
||||
item["state"] = "blocked"
|
||||
state["updated_at"] = time.time()
|
||||
publish(state)
|
||||
return state["state"] == "complete"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o022)
|
||||
if os.geteuid() != 0 or len(sys.argv) != 1:
|
||||
sys.exit(1)
|
||||
sys.exit(0 if prepare() else 1)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1.102.3",
|
||||
"url": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb",
|
||||
"sha256": "88e1b0319da94a52ea409a1a5935e4e7215065a25cd99bc509b6dcbb73737fae",
|
||||
"checksum_source": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb.sha256"
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Workflow acceptance boundaries without touching the host OS or credentials."""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import environment_helper as helper
|
||||
|
||||
PROFILE = json.loads((Path(__file__).parents[1] / "internal/node/environment-profile.json").read_text())
|
||||
|
||||
|
||||
class EnvironmentWorkflowTests(unittest.TestCase):
|
||||
def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self):
|
||||
saved = []
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['packages'].side_effect = helper.SetupError('Package lock held')
|
||||
first = helper.run_steps(PROFILE, operations, lambda data: saved.append(copy.deepcopy(data)))
|
||||
states = {item['id']: item['state'] for item in first['steps']}
|
||||
self.assertEqual(first['state'], 'error')
|
||||
self.assertEqual(states['packages'], 'error')
|
||||
self.assertEqual(states['ssh-service'], 'blocked')
|
||||
self.assertEqual(states['tailscale-install'], 'blocked')
|
||||
self.assertEqual(states['network-inventory'], 'complete')
|
||||
self.assertEqual(states['usb-inventory'], 'complete')
|
||||
operations['ssh-service'].assert_not_called()
|
||||
operations['tailscale-install'].assert_not_called()
|
||||
self.assertTrue(any(row['state'] == 'running' for state in saved for row in state['steps']))
|
||||
operations['packages'].side_effect = None
|
||||
second = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
self.assertEqual(second['state'], 'complete')
|
||||
self.assertNotEqual(first['run_id'], second['run_id'])
|
||||
self.assertEqual(operations['network-inventory'].call_count, 2)
|
||||
operations['ssh-service'].assert_called_once()
|
||||
|
||||
def test_incompatible_platform_prevents_all_system_mutations(self):
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['platform'].side_effect = helper.SetupError('Unsupported system')
|
||||
result = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
self.assertEqual(result['state'], 'error')
|
||||
for name, operation in operations.items():
|
||||
if name != 'platform':
|
||||
operation.assert_not_called()
|
||||
|
||||
def test_subprocess_exception_does_not_leak_output_or_report_success(self):
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
operations['packages'].side_effect = subprocess.CalledProcessError(1, ['private-command'], output='private-output')
|
||||
result = helper.run_steps(PROFILE, operations, lambda _: None)
|
||||
encoded = json.dumps(result)
|
||||
self.assertNotIn('private-command', encoded)
|
||||
self.assertNotIn('private-output', encoded)
|
||||
self.assertEqual(result['state'], 'error')
|
||||
|
||||
def test_existing_packages_skip_apt_and_no_service_command_is_hidden_here(self):
|
||||
with patch.object(helper.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'installed', '')) as run:
|
||||
helper.packages()
|
||||
self.assertTrue(run.call_args_list)
|
||||
self.assertTrue(all(call.args[0][0] == '/usr/bin/dpkg-query' for call in run.call_args_list))
|
||||
|
||||
def test_missing_package_installed_without_removal_and_without_transaction_timeout(self):
|
||||
def respond(argv, **kwargs):
|
||||
if argv[0] == '/usr/bin/dpkg-query':
|
||||
return subprocess.CompletedProcess(argv, 0, 'installed' if argv[-1] == 'ca-certificates' or installed[0] else 'not-installed', '')
|
||||
if 'install' in argv:
|
||||
installed[0] = True
|
||||
self.assertIn('--no-remove', argv)
|
||||
self.assertNotIn('timeout', kwargs)
|
||||
return subprocess.CompletedProcess(argv, 0, '', '')
|
||||
installed = [False]
|
||||
with patch.object(helper.subprocess, 'run', side_effect=respond) as run:
|
||||
helper.packages()
|
||||
installs = [call.args[0] for call in run.call_args_list if 'install' in call.args[0]]
|
||||
self.assertEqual(len(installs), 1)
|
||||
self.assertEqual(installs[0][-1], 'openssh-server')
|
||||
|
||||
def test_inventory_verifies_board_service_not_root_access(self):
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': False, 'networks': []}):
|
||||
with self.assertRaises(helper.SetupError): helper.network_inventory()
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': True, 'networks': [{'addresses_readable': False}]}):
|
||||
with self.assertRaises(helper.SetupError): helper.network_inventory()
|
||||
with patch.object(helper, 'probe_node', return_value={'networks_readable': True, 'networks': []}):
|
||||
self.assertIn('0', helper.network_inventory())
|
||||
with patch.object(helper, 'probe_node', return_value={'usb_readable': False, 'usb': []}):
|
||||
with self.assertRaises(helper.SetupError): helper.usb_inventory()
|
||||
|
||||
def test_foreign_config_and_symlink_are_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(helper, 'trusted_directory'):
|
||||
template, target = Path(directory)/'template', Path(directory)/'config'
|
||||
template.write_bytes(b'owned')
|
||||
target.write_bytes(b'foreign')
|
||||
with self.assertRaises(helper.SetupError): helper.owned_config(template, target)
|
||||
self.assertEqual(target.read_bytes(), b'foreign')
|
||||
target.unlink()
|
||||
target.symlink_to(template)
|
||||
with self.assertRaises(helper.SetupError): helper.owned_config(template, target)
|
||||
self.assertTrue(target.is_symlink())
|
||||
self.assertEqual(template.read_bytes(), b'owned')
|
||||
|
||||
def test_restrictive_umask_does_not_hide_report_and_existing_report_dir_is_repaired(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = Path(directory)/'report'
|
||||
with patch.object(helper, 'STATE', target), patch.object(Path, 'lstat', autospec=True, side_effect=lambda path: SimpleNamespace(st_uid=0, st_mode=os.stat(path).st_mode)):
|
||||
previous = os.umask(0o077)
|
||||
try:
|
||||
helper.trusted_directory(target)
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o755)
|
||||
target.chmod(0o700)
|
||||
helper.trusted_directory(target)
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o755)
|
||||
foreign = Path(directory)/'foreign'
|
||||
foreign.mkdir(mode=0o700)
|
||||
helper.trusted_directory(foreign)
|
||||
self.assertEqual(foreign.stat().st_mode & 0o777, 0o700)
|
||||
finally: os.umask(previous)
|
||||
|
||||
def test_delayed_service_start_retries_only_readiness(self):
|
||||
with patch.object(helper, 'probe_node', side_effect=[OSError('not listening'), {}]) as read, patch.object(helper.time, 'sleep'), patch.object(helper, 'command') as mutation:
|
||||
helper.wait_for_node()
|
||||
self.assertEqual(read.call_count, 2)
|
||||
mutation.assert_not_called()
|
||||
|
||||
def test_failed_service_rights_never_starts_or_restarts_it(self):
|
||||
def command(argv):
|
||||
if '--property=User' in argv: return 'root'
|
||||
return ''
|
||||
with patch.object(helper, 'owned_config', return_value=False), patch.object(helper, 'command', side_effect=command) as run:
|
||||
with self.assertRaises(helper.SetupError): helper.node_service()
|
||||
self.assertFalse(any('restart' in call.args[0] or 'enable' in call.args[0] for call in run.call_args_list))
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Security/continuity checks. No installation, OS mutation, or real login."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import network_helper as helper
|
||||
|
||||
|
||||
class NetworkHelperTests(unittest.TestCase):
|
||||
def test_only_provider_login_destinations_are_accepted(self):
|
||||
self.assertTrue(helper.login_url("https://login.tailscale.com/a/synthetic-login"))
|
||||
for value in [None, "http://login.tailscale.com/a/x", "https://login.tailscale.com.evil.test/a/x",
|
||||
"https://login.tailscale.com@evil.test/a/x", "https://login.tailscale.com/a/x?q=x",
|
||||
"https://login.tailscale.com/a/../admin", "https://login.tailscale.com/a/x#fragment",
|
||||
"https://login.tailscale.com:443/a/x", "file:///tmp/x"]:
|
||||
self.assertFalse(helper.login_url(value), value)
|
||||
|
||||
def test_resume_keeps_existing_preferences_and_new_login_keeps_lan(self):
|
||||
self.assertEqual(helper.connect_command("Stopped"), [helper.TAILSCALE, "up"])
|
||||
fresh = helper.connect_command("NeedsLogin")
|
||||
self.assertIn("--accept-dns=false", fresh)
|
||||
self.assertIn("--accept-routes=false", fresh)
|
||||
for flag in ("--reset", "--force-reauth", "--ssh", "--advertise-routes", "--exit-node"):
|
||||
self.assertFalse(any(arg.startswith(flag) for arg in fresh))
|
||||
with self.assertRaises(helper.SetupError):
|
||||
helper.connect_command("Unknown")
|
||||
|
||||
def test_checksum_failure_never_reaches_apt_or_service_mutation(self):
|
||||
original_tempdir = tempfile.TemporaryDirectory
|
||||
with original_tempdir() as directory:
|
||||
release = Path(directory) / "release.json"
|
||||
release.write_text(json.dumps({"url": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb",
|
||||
"sha256": hashlib.sha256(b"expected").hexdigest()}))
|
||||
with patch.object(helper, "TAILSCALE", str(Path(directory) / "missing")), \
|
||||
patch.object(helper, "RELEASE", release), \
|
||||
patch.object(helper.tempfile, "TemporaryDirectory", side_effect=lambda **kwargs: original_tempdir(dir=directory)), \
|
||||
patch.object(helper.urllib.request, "urlopen", return_value=io.BytesIO(b"tampered")), \
|
||||
patch.object(helper, "checked") as mutation:
|
||||
with self.assertRaises(helper.SetupError):
|
||||
helper.install()
|
||||
mutation.assert_not_called()
|
||||
|
||||
def test_existing_provider_is_not_reinstalled(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper.urllib.request, "urlopen") as download, \
|
||||
patch.object(helper, "checked") as mutation:
|
||||
self.assertTrue(helper.install()["ok"])
|
||||
download.assert_not_called()
|
||||
mutation.assert_called_once_with(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
|
||||
|
||||
def test_cli_timeout_uses_daemon_outcome_and_does_not_retry_login(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper, "checked"), \
|
||||
patch.object(helper, "control_transport"), \
|
||||
patch.object(helper, "https_transport_active", return_value=True), \
|
||||
patch.object(helper, "status", side_effect=[{"BackendState": "NeedsLogin"}, {"BackendState": "NeedsLogin", "AuthURL": "https://login.tailscale.com/a/synthetic"}]), \
|
||||
patch.object(helper.subprocess, "run", side_effect=helper.subprocess.TimeoutExpired("tailscale", 18)) as run:
|
||||
self.assertEqual(helper.connect(), {"ok": True, "url": "https://login.tailscale.com/a/synthetic"})
|
||||
self.assertEqual(run.call_count, 1)
|
||||
|
||||
def test_connected_provider_is_never_reconfigured(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper, "checked"), \
|
||||
patch.object(helper, "status", return_value={"BackendState": "Running"}), \
|
||||
patch.object(helper, "control_transport") as transport:
|
||||
self.assertEqual(helper.connect(), {"ok": True})
|
||||
transport.assert_not_called()
|
||||
|
||||
def test_transport_recovery_uses_saved_profile_without_login_when_possible(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper, "checked") as system, \
|
||||
patch.object(helper, "control_transport"), \
|
||||
patch.object(helper, "https_transport_active", side_effect=[False, True]), \
|
||||
patch.object(helper, "status", side_effect=[{"BackendState": "NeedsLogin"}, {"BackendState": "Running"}]), \
|
||||
patch.object(helper.subprocess, "run") as cli:
|
||||
self.assertEqual(helper.connect(), {"ok": True})
|
||||
self.assertIn(unittest.mock.call(["/usr/bin/systemctl", "restart", "tailscaled.service"]), system.call_args_list)
|
||||
cli.assert_not_called()
|
||||
|
||||
def test_transport_conflict_does_not_start_another_login(self):
|
||||
with tempfile.NamedTemporaryFile() as existing, \
|
||||
patch.object(helper, "TAILSCALE", existing.name), \
|
||||
patch.object(helper, "checked"), \
|
||||
patch.object(helper, "control_transport"), \
|
||||
patch.object(helper, "https_transport_active", return_value=False), \
|
||||
patch.object(helper, "status", return_value={"BackendState": "NeedsLogin"}), \
|
||||
patch.object(helper.subprocess, "run") as cli:
|
||||
with self.assertRaises(helper.SetupError):
|
||||
helper.connect()
|
||||
cli.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from realsense_iio_access import allowed_attributes, validate
|
||||
|
||||
|
||||
class IMUScopeTests(unittest.TestCase):
|
||||
def test_only_capture_attributes_are_granted(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder).resolve()
|
||||
for name in [
|
||||
"buffer/enable",
|
||||
"in_accel_hysteresis",
|
||||
"scan_elements/in_accel_x_en",
|
||||
"reset",
|
||||
"power/control",
|
||||
]:
|
||||
path = root / name
|
||||
path.parent.mkdir(exist_ok=True, parents=True)
|
||||
path.touch()
|
||||
(root / "buffer/length").symlink_to("/etc/passwd")
|
||||
self.assertEqual(
|
||||
{str(p.relative_to(root)) for p in allowed_attributes(root)},
|
||||
{"buffer/enable", "in_accel_hysteresis", "scan_elements/in_accel_x_en"},
|
||||
)
|
||||
|
||||
def test_foreign_usb_and_path_escape_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
sys = Path(folder).resolve()
|
||||
usb = sys / "devices/usb/device"
|
||||
root = usb / "hid/iio:device0"
|
||||
root.mkdir(parents=True)
|
||||
(usb / "idVendor").write_text("8086")
|
||||
(usb / "idProduct").write_text("0b5c")
|
||||
self.assertEqual(validate("/devices/usb/device/hid/iio:device0", sys), root)
|
||||
(usb / "idProduct").write_text("ffff")
|
||||
with self.assertRaises(ValueError):
|
||||
validate("/devices/usb/device/hid/iio:device0", sys)
|
||||
for path in ["/devices/../etc/passwd", "/class/iio:device0", "/devices/usb/device/hid"]:
|
||||
with self.assertRaises(ValueError):
|
||||
validate(path, sys)
|
||||
@@ -0,0 +1,25 @@
|
||||
import io
|
||||
import unittest
|
||||
import zipfile
|
||||
from realsense_prepare import safe_members
|
||||
|
||||
|
||||
class BundleBoundaryTests(unittest.TestCase):
|
||||
def test_archives_cannot_escape_or_execute_import_hooks(self):
|
||||
for name in ('../../etc/shadow', '/root/escape', 'something.pth', 'sdk.data/purelib/code.py'):
|
||||
value = io.BytesIO()
|
||||
with zipfile.ZipFile(value, 'w') as archive:
|
||||
archive.writestr(name, b'payload')
|
||||
with zipfile.ZipFile(io.BytesIO(value.getvalue())) as archive:
|
||||
with self.assertRaises(RuntimeError):
|
||||
list(safe_members(archive))
|
||||
|
||||
def test_archive_symlink_is_rejected(self):
|
||||
value = io.BytesIO()
|
||||
with zipfile.ZipFile(value, 'w') as archive:
|
||||
info = zipfile.ZipInfo('device.py')
|
||||
info.external_attr = 0o120777 << 16
|
||||
archive.writestr(info, '/etc/shadow')
|
||||
with zipfile.ZipFile(io.BytesIO(value.getvalue())) as archive:
|
||||
with self.assertRaises(RuntimeError):
|
||||
list(safe_members(archive))
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Isolated interpreter; only root-owned pinned runtime and product code on sys.path."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path("/var/lib/mission-core-node-drivers")
|
||||
path = Path((root / "active.path").read_text())
|
||||
if path.parent != root or not path.name.isalnum() or path.is_symlink() or path.stat().st_uid != 0:
|
||||
raise RuntimeError("Invalid driver installation")
|
||||
sys.path[:0] = [str(path), "/usr/lib/mission-core-node/sensors", "/usr/lib/mission-core-node/sdk"]
|
||||
from server import main # noqa: E402 — use only the verified isolated runtime above
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,607 @@
|
||||
"""D455 adapter. Hardware ownership and raw acquisition remain on the board."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import numpy as np
|
||||
import pyrealsense2 as rs
|
||||
from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
|
||||
|
||||
MODEL = {
|
||||
"plugin_id": "missioncore.realsense",
|
||||
"plugin_version": "0.6.6",
|
||||
"model_id": "realsense.d455",
|
||||
}
|
||||
|
||||
|
||||
def utc():
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def device_id(serial):
|
||||
return "rsd455_" + hashlib.sha256(serial.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def atomic(path, value):
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with tmp.open("w") as f:
|
||||
json.dump(value, f, ensure_ascii=False, allow_nan=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def kind(profile):
|
||||
return str(profile.stream_type()).split(".")[-1]
|
||||
|
||||
|
||||
def configure_profiles(config, profiles):
|
||||
for p in profiles:
|
||||
stream, fmt = getattr(rs.stream, p["stream"]), getattr(rs.format, p["format"])
|
||||
if "width" in p:
|
||||
config.enable_stream(stream, p["index"], p["width"], p["height"], fmt, p["fps"])
|
||||
else:
|
||||
config.enable_stream(stream, p["index"], fmt, p["fps"])
|
||||
|
||||
|
||||
class Device:
|
||||
def __init__(self, serial, root, execution, usb_serial=None):
|
||||
self.serial = serial
|
||||
self.id = device_id(usb_serial or serial)
|
||||
self.root = root / self.id
|
||||
self.root.mkdir(exist_ok=True, mode=0o700)
|
||||
self.lock = threading.RLock()
|
||||
self.execution = execution
|
||||
self.session = "sensor_" + uuid.uuid4().hex
|
||||
self.opened = utc()
|
||||
self.revision = 0
|
||||
self.online = True
|
||||
self.acquisition = "idle"
|
||||
self.message = ""
|
||||
self.pipeline = None
|
||||
self.thread = None
|
||||
self.queue = queue.Queue(maxsize=2)
|
||||
self.stop_event = threading.Event()
|
||||
self.images = {}
|
||||
self.motion = {}
|
||||
self.depth = None
|
||||
self.intrinsics = None
|
||||
self.frames = {}
|
||||
self.last_frame = None
|
||||
self.record = None
|
||||
self.playback_id = None
|
||||
self.profiles = []
|
||||
self.options = []
|
||||
self.sdk_device = None
|
||||
self.firmware = ""
|
||||
self.transport = ""
|
||||
self.config = {"name": "RealSense D455", "verified": False}
|
||||
if (self.root / "config.json").exists():
|
||||
self.config.update(json.loads((self.root / "config.json").read_text()))
|
||||
# A previous verification is evidence, not a live readiness assertion.
|
||||
self.verified_this_process = False
|
||||
for path in self.root.glob("recordings/*/manifest.json"):
|
||||
value = json.loads(path.read_text())
|
||||
if value.get("state") in ("recording", "finalizing"):
|
||||
value.update(state="interrupted", recovered_at=utc())
|
||||
atomic(path, value)
|
||||
|
||||
def save(self):
|
||||
atomic(self.root / "config.json", self.config)
|
||||
|
||||
def disconnect(self):
|
||||
with self.lock:
|
||||
if not self.online:
|
||||
return
|
||||
self.online = False
|
||||
self.verified_this_process = False
|
||||
self.sdk_device = None
|
||||
# Stored playback is independent from a physical USB camera.
|
||||
live = self.pipeline is not None and self.playback_id is None
|
||||
if self.playback_id is None:
|
||||
self.images = {}
|
||||
self.motion = {}
|
||||
self.depth = None
|
||||
self.message = "Камера отключена от БК."
|
||||
if live:
|
||||
self.stop(failed=True)
|
||||
|
||||
def refresh(self, dev):
|
||||
with self.lock:
|
||||
self.sdk_device = dev
|
||||
if not self.online:
|
||||
self.verified_this_process = False
|
||||
self.session = "sensor_" + uuid.uuid4().hex
|
||||
self.opened = utc()
|
||||
self.online = True
|
||||
self.firmware = dev.get_info(rs.camera_info.firmware_version)
|
||||
self.transport = dev.get_info(rs.camera_info.usb_type_descriptor)
|
||||
if self.pipeline is not None or self.acquisition == "stopping":
|
||||
return
|
||||
profiles, options = [], []
|
||||
for index, sensor in enumerate(dev.query_sensors()):
|
||||
for profile in sensor.get_stream_profiles():
|
||||
stream = kind(profile)
|
||||
if stream not in ("color", "depth", "infrared", "accel", "gyro"):
|
||||
continue
|
||||
data = {
|
||||
"sensor": index,
|
||||
"stream": stream,
|
||||
"index": profile.stream_index(),
|
||||
"fps": profile.fps(),
|
||||
"format": str(profile.format()).split(".")[-1],
|
||||
}
|
||||
if profile.is_video_stream_profile():
|
||||
video = profile.as_video_stream_profile()
|
||||
data.update(width=video.width(), height=video.height())
|
||||
data["id"] = hashlib.sha256(
|
||||
json.dumps(data, sort_keys=True).encode()
|
||||
).hexdigest()[:16]
|
||||
profiles.append(data)
|
||||
for option in sensor.get_supported_options():
|
||||
try:
|
||||
limits = sensor.get_option_range(option)
|
||||
value = sensor.get_option(option)
|
||||
if not all(
|
||||
math.isfinite(x) for x in (limits.min, limits.max, limits.step, value)
|
||||
):
|
||||
continue
|
||||
options.append(
|
||||
{
|
||||
"id": f"{index}:{int(option)}",
|
||||
"sensor": sensor.get_info(rs.camera_info.name),
|
||||
"label": str(option).split(".")[-1],
|
||||
"value": value,
|
||||
"min": limits.min,
|
||||
"max": limits.max,
|
||||
"step": limits.step,
|
||||
"read_only": sensor.is_option_read_only(option),
|
||||
}
|
||||
)
|
||||
except RuntimeError:
|
||||
continue
|
||||
self.profiles, self.options = profiles, options
|
||||
|
||||
def defaults(self):
|
||||
result = []
|
||||
for stream, index, fmt, fps in [
|
||||
("depth", 0, "z16", 15),
|
||||
("color", 0, "rgb8", 15),
|
||||
("infrared", 1, "y8", 15),
|
||||
("infrared", 2, "y8", 15),
|
||||
("accel", 0, "motion_xyz32f", 63),
|
||||
("gyro", 0, "motion_xyz32f", 200),
|
||||
]:
|
||||
candidates = [
|
||||
p
|
||||
for p in self.profiles
|
||||
if p["stream"] == stream and p["index"] == index and p["format"] == fmt
|
||||
]
|
||||
if candidates:
|
||||
p = min(
|
||||
candidates,
|
||||
key=lambda p: (
|
||||
abs(p.get("width", 640) - 640)
|
||||
+ abs(p.get("height", 480) - 480)
|
||||
+ abs(p["fps"] - fps) * 20
|
||||
),
|
||||
)
|
||||
result.append(p["id"])
|
||||
return result
|
||||
|
||||
def snapshot(self, detailed=True):
|
||||
with self.lock:
|
||||
now = utc()
|
||||
snap = DeviceSessionSnapshot.model_validate(
|
||||
{
|
||||
"context": {
|
||||
"session_id": self.session,
|
||||
"device": {
|
||||
"device_id": self.id,
|
||||
"model": MODEL,
|
||||
"stability": "stable",
|
||||
"basis": "hardware-identifier",
|
||||
},
|
||||
"execution": self.execution,
|
||||
"opened_at": self.opened,
|
||||
},
|
||||
"revision": self.revision,
|
||||
"enrollment": "enrolled" if self.config["verified"] else "empty",
|
||||
"connectivity": "connected" if self.online else "offline",
|
||||
"acquisition": self.acquisition,
|
||||
"observed_at": now,
|
||||
"message": self.message or None,
|
||||
}
|
||||
)
|
||||
value = {
|
||||
"id": self.id,
|
||||
"name": self.config["name"],
|
||||
"model": "RealSense D455",
|
||||
"prepared": True,
|
||||
"verified": self.verified_this_process,
|
||||
"online": self.online,
|
||||
"snapshot": snap.model_dump(mode="json"),
|
||||
"firmware": self.firmware,
|
||||
"usb": self.transport,
|
||||
"frames": dict(self.frames),
|
||||
"last_frame": self.last_frame,
|
||||
"recording": self.record,
|
||||
"playback_id": self.playback_id,
|
||||
"layers": list(self.images)
|
||||
+ (["points"] if self.depth is not None else [])
|
||||
+ (["motion"] if self.motion else []),
|
||||
}
|
||||
if detailed:
|
||||
value.update(
|
||||
profiles=self.profiles,
|
||||
defaults=self.defaults(),
|
||||
options=self.options,
|
||||
recordings=self.recordings(),
|
||||
motion=dict(self.motion),
|
||||
)
|
||||
return value
|
||||
|
||||
def callback(self, frame):
|
||||
if frame.is_motion_frame():
|
||||
v = frame.as_motion_frame().get_motion_data()
|
||||
key = kind(frame.profile)
|
||||
self.motion[key] = {
|
||||
"x": v.x,
|
||||
"y": v.y,
|
||||
"z": v.z,
|
||||
"timestamp_ms": frame.get_timestamp(),
|
||||
"clock": str(frame.get_frame_timestamp_domain()),
|
||||
"observed_at": utc(),
|
||||
}
|
||||
self.frames[key] = self.frames.get(key, 0) + 1
|
||||
elif frame.is_frameset():
|
||||
with suppress(queue.Full):
|
||||
self.queue.put_nowait(frame.as_frameset())
|
||||
|
||||
def start(self, selected=None, record=False):
|
||||
with self.lock:
|
||||
if self.pipeline is not None or self.acquisition == "stopping":
|
||||
raise ValueError("Захват уже запущен. Сначала остановите его.")
|
||||
if not self.online or self.sdk_device is None:
|
||||
raise ValueError("Камера не подключена.")
|
||||
ids = selected if selected is not None else self.defaults()
|
||||
if not isinstance(ids, list) or not 1 <= len(ids) <= 6 or len(set(ids)) != len(ids):
|
||||
raise ValueError("Выберите профили потоков.")
|
||||
profiles = [next((p for p in self.profiles if p["id"] == ident), None) for ident in ids]
|
||||
if any(p is None for p in profiles) or len(
|
||||
{(p["stream"], p["index"]) for p in profiles}
|
||||
) != len(profiles):
|
||||
raise ValueError("Выбраны несовместимые профили потоков.")
|
||||
if not any(p["stream"] in ("depth", "color", "infrared") for p in profiles):
|
||||
raise ValueError("Выберите хотя бы один видеопоток.")
|
||||
config = rs.config()
|
||||
config.enable_device(self.serial)
|
||||
configure_profiles(config, profiles)
|
||||
pipeline = rs.pipeline()
|
||||
if not config.can_resolve(rs.pipeline_wrapper(pipeline)):
|
||||
raise ValueError("Камера не поддерживает эту комбинацию профилей. Выберите другую.")
|
||||
self.playback_id = None
|
||||
self.acquisition = "starting"
|
||||
self.revision += 1
|
||||
self.images, self.motion, self.frames = {}, {}, {}
|
||||
self.depth, self.last_frame = None, None
|
||||
self.queue = queue.Queue(maxsize=2)
|
||||
self.stop_event.clear()
|
||||
record_path = None
|
||||
if record:
|
||||
if shutil.disk_usage(self.root).free < 2 * 1024**3:
|
||||
self.acquisition = "idle"
|
||||
raise ValueError("Для исходной записи нужно не меньше 2 ГиБ свободного места.")
|
||||
ident = "capture_" + uuid.uuid4().hex
|
||||
record_path = self.root / "recordings" / ident
|
||||
record_path.mkdir(parents=True, mode=0o700)
|
||||
config.enable_record_to_file(str(record_path / "source.db3"))
|
||||
self.record = {
|
||||
"id": ident,
|
||||
"state": "recording",
|
||||
"started_at": utc(),
|
||||
"monotonic_ns": time.monotonic_ns(),
|
||||
"device_id": self.id,
|
||||
"session_id": self.session,
|
||||
"profiles": profiles,
|
||||
"firmware": self.firmware,
|
||||
"sdk": "2.58.4.10922",
|
||||
"storage_format": "rosbag2-sqlite3",
|
||||
"options": self.options,
|
||||
}
|
||||
atomic(record_path / "manifest.json", self.record)
|
||||
try:
|
||||
active = pipeline.start(config, self.callback)
|
||||
self.pipeline = pipeline
|
||||
self.acquisition = "streaming"
|
||||
self.message = ""
|
||||
self.depth_scale = active.get_device().first_depth_sensor().get_depth_scale()
|
||||
calibration = []
|
||||
for p in active.get_streams():
|
||||
if p.is_video_stream_profile():
|
||||
v = p.as_video_stream_profile().get_intrinsics()
|
||||
calibration.append(
|
||||
{
|
||||
"stream": kind(p),
|
||||
"index": p.stream_index(),
|
||||
"width": v.width,
|
||||
"height": v.height,
|
||||
"fx": v.fx,
|
||||
"fy": v.fy,
|
||||
"ppx": v.ppx,
|
||||
"ppy": v.ppy,
|
||||
"coeffs": v.coeffs,
|
||||
"model": str(v.model),
|
||||
}
|
||||
)
|
||||
if self.record:
|
||||
self.record["calibration"] = calibration
|
||||
self.record["depth_scale"] = self.depth_scale
|
||||
atomic(record_path / "manifest.json", self.record)
|
||||
self.thread = threading.Thread(target=self.consume, daemon=True)
|
||||
self.thread.start()
|
||||
except Exception as error:
|
||||
logging.error("D455 capture: %s", str(error).replace(self.serial, "[camera]"))
|
||||
with suppress(RuntimeError):
|
||||
pipeline.stop()
|
||||
self.pipeline = None
|
||||
self.acquisition = "failed"
|
||||
self.message = "Не удалось открыть потоки камеры. Проверьте подключение и профили."
|
||||
if self.record:
|
||||
self.record.update(state="failed", ended_at=utc())
|
||||
atomic(record_path / "manifest.json", self.record)
|
||||
self.record = None
|
||||
raise ValueError(self.message) from None
|
||||
|
||||
def replay(self, ident):
|
||||
# Only completed board-owned recordings; UI never supplies a filesystem path.
|
||||
if not isinstance(ident, str) or not re.fullmatch(r"capture_[0-9a-f]{32}", ident):
|
||||
raise ValueError("Некорректная запись.")
|
||||
with self.lock:
|
||||
if self.pipeline is not None or self.acquisition == "stopping":
|
||||
raise ValueError("Сначала остановите текущий захват или просмотр записи.")
|
||||
directory = self.root / "recordings" / ident
|
||||
source, manifest = directory / "source.db3", directory / "manifest.json"
|
||||
if directory.is_symlink() or source.is_symlink() or manifest.is_symlink():
|
||||
raise ValueError("Запись недоступна.")
|
||||
if not source.is_file() or not manifest.is_file():
|
||||
raise ValueError("Запись не найдена.")
|
||||
value = json.loads(manifest.read_text())
|
||||
if value.get("state") != "complete" or source.stat().st_size != value.get("bytes"):
|
||||
raise ValueError("Запись не завершена или повреждена.")
|
||||
config, pipeline = rs.config(), rs.pipeline()
|
||||
config.enable_device_from_file(str(source), repeat_playback=True)
|
||||
configure_profiles(config, value["profiles"])
|
||||
self.images, self.motion, self.frames = {}, {}, {}
|
||||
self.depth, self.last_frame = None, None
|
||||
self.queue = queue.Queue(maxsize=2)
|
||||
self.stop_event.clear()
|
||||
self.acquisition = "starting"
|
||||
try:
|
||||
active = pipeline.start(config, self.callback)
|
||||
self.pipeline = pipeline
|
||||
active.get_device().as_playback().set_real_time(True)
|
||||
self.depth_scale = active.get_device().first_depth_sensor().get_depth_scale()
|
||||
self.playback_id = ident
|
||||
self.acquisition = "streaming"
|
||||
self.message = ""
|
||||
self.thread = threading.Thread(target=self.consume, daemon=True)
|
||||
self.thread.start()
|
||||
except RuntimeError:
|
||||
with suppress(RuntimeError):
|
||||
pipeline.stop()
|
||||
self.pipeline = None
|
||||
self.acquisition = "failed"
|
||||
raise ValueError("Не удалось открыть исходную запись.") from None
|
||||
self.revision += 1
|
||||
return {"ok": True, "playback_id": ident}
|
||||
|
||||
def consume(self):
|
||||
colorizer = rs.colorizer()
|
||||
last_data, last_disk = time.monotonic(), time.monotonic()
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
frames = self.queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
if time.monotonic() - last_data > 8:
|
||||
self.message = "Кадры перестали поступать. Проверьте USB и остановите захват."
|
||||
self.acquisition = "failed"
|
||||
self.online = False
|
||||
break
|
||||
continue
|
||||
last_data = time.monotonic()
|
||||
for frame in frames:
|
||||
key = kind(frame.profile)
|
||||
if key == "infrared":
|
||||
key += str(frame.profile.stream_index())
|
||||
if not frame.is_video_frame():
|
||||
continue
|
||||
data = np.asanyarray(frame.get_data()).copy()
|
||||
if key == "depth":
|
||||
self.depth = data
|
||||
self.intrinsics = frame.profile.as_video_stream_profile().get_intrinsics()
|
||||
data = np.asanyarray(colorizer.colorize(frame).get_data()).copy()
|
||||
elif data.ndim == 2:
|
||||
data = np.repeat(data[:, :, None], 3, axis=2)
|
||||
elif frame.profile.format() == rs.format.bgr8:
|
||||
data = data[:, :, ::-1].copy()
|
||||
elif frame.profile.format() != rs.format.rgb8:
|
||||
continue
|
||||
self.images[key] = data
|
||||
self.frames[key] = self.frames.get(key, 0) + 1
|
||||
self.last_frame = {
|
||||
"observed_at": utc(),
|
||||
"monotonic_ns": time.monotonic_ns(),
|
||||
"device_timestamp_ms": frame.get_timestamp(),
|
||||
"clock": str(frame.get_frame_timestamp_domain()),
|
||||
}
|
||||
if self.record and time.monotonic() - last_disk > 2:
|
||||
last_disk = time.monotonic()
|
||||
if shutil.disk_usage(self.root).free < 512 * 1024**2:
|
||||
self.message = "Запись остановлена: мало свободного места."
|
||||
break
|
||||
if not self.stop_event.is_set():
|
||||
# Explicit device/disk failure closes raw recording; never auto-restart.
|
||||
self.stop(failed=True, from_capture=True)
|
||||
|
||||
def stop(self, failed=False, from_capture=False):
|
||||
with self.lock:
|
||||
if self.acquisition == "stopping":
|
||||
raise ValueError("Исходная запись ещё сохраняется. Дождитесь завершения.")
|
||||
self.stop_event.set()
|
||||
pipeline, self.pipeline = self.pipeline, None
|
||||
if pipeline is not None:
|
||||
self.acquisition = "stopping"
|
||||
try:
|
||||
pipeline.stop()
|
||||
except RuntimeError:
|
||||
# Removal may invalidate the SDK handle before STOP reaches it.
|
||||
failed = True
|
||||
self.message = "Захват прерван. Проверьте подключение камеры."
|
||||
del pipeline
|
||||
if self.thread and not from_capture:
|
||||
self.thread.join(timeout=3)
|
||||
self.playback_id = None
|
||||
value = dict(self.record) if self.record else None
|
||||
self.acquisition = "stopping" if value else "failed" if failed else "idle"
|
||||
self.revision += 1
|
||||
if value:
|
||||
value.update(
|
||||
state="finalizing",
|
||||
ended_at=utc(),
|
||||
ended_monotonic_ns=time.monotonic_ns(),
|
||||
frames=dict(self.frames),
|
||||
)
|
||||
self.record = value
|
||||
path = self.root / "recordings" / value["id"]
|
||||
atomic(path / "manifest.json", value)
|
||||
if value:
|
||||
# Hashing a large source must not lock inventory or heartbeat. The
|
||||
# stopping state still prevents START, preparation and package updates.
|
||||
try:
|
||||
source = path / "source.db3"
|
||||
if source.exists():
|
||||
digest = hashlib.sha256()
|
||||
with source.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
value.update(sha256=digest.hexdigest(), bytes=source.stat().st_size)
|
||||
else:
|
||||
failed = True
|
||||
value["state"] = "failed" if failed else "complete"
|
||||
atomic(path / "manifest.json", value)
|
||||
except OSError:
|
||||
failed = True
|
||||
self.message = "Не удалось завершить сохранение исходной записи. Проверьте диск БК."
|
||||
value["state"] = "failed"
|
||||
atomic(path / "manifest.json", value)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.record = None
|
||||
self.acquisition = "failed" if failed else "idle"
|
||||
self.revision += 1
|
||||
return {"ok": True}
|
||||
|
||||
def verify(self):
|
||||
if self.pipeline is not None or self.acquisition == "stopping":
|
||||
raise ValueError("Остановите захват перед повторной проверкой.")
|
||||
try:
|
||||
self.start()
|
||||
deadline = time.monotonic() + 8
|
||||
expected = {
|
||||
p["stream"] + (str(p["index"]) if p["stream"] == "infrared" else "")
|
||||
for p in self.profiles
|
||||
if p["id"] in self.defaults()
|
||||
}
|
||||
while time.monotonic() < deadline:
|
||||
if all(self.frames.get(key, 0) >= 2 for key in expected):
|
||||
self.config["verified"] = True
|
||||
self.verified_this_process = True
|
||||
self.config["verified_at"] = utc()
|
||||
self.save()
|
||||
return {
|
||||
"ok": True,
|
||||
"frames": dict(self.frames),
|
||||
"verified_at": self.config["verified_at"],
|
||||
}
|
||||
time.sleep(0.1)
|
||||
raise ValueError(
|
||||
"Не получены кадры всех выбранных потоков. Проверьте USB 3 и повторите проверку."
|
||||
)
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def rename(self, name):
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or not name.strip()
|
||||
or len(name) > 80
|
||||
or any(ord(c) < 32 for c in name)
|
||||
):
|
||||
raise ValueError("Введите название до 80 символов.")
|
||||
with self.lock:
|
||||
self.config["name"] = name.strip()
|
||||
self.save()
|
||||
self.revision += 1
|
||||
return {"ok": True}
|
||||
|
||||
def set_option(self, identifier, value):
|
||||
with self.lock:
|
||||
item = next((v for v in self.options if v["id"] == identifier), None)
|
||||
if (
|
||||
not item
|
||||
or item["read_only"]
|
||||
or type(value) not in (int, float)
|
||||
or not math.isfinite(value)
|
||||
or not item["min"] <= value <= item["max"]
|
||||
):
|
||||
raise ValueError("Параметр недоступен или значение вне диапазона.")
|
||||
if self.playback_id:
|
||||
raise ValueError("Остановите просмотр записи перед настройкой камеры.")
|
||||
sensor_index, option_id = map(int, identifier.split(":"))
|
||||
sensor = self.sdk_device.query_sensors()[sensor_index]
|
||||
option = rs.option(option_id)
|
||||
sensor.set_option(option, float(value))
|
||||
item["value"] = sensor.get_option(option)
|
||||
self.revision += 1
|
||||
return {"ok": True, "value": item["value"]}
|
||||
|
||||
def points(self):
|
||||
depth, intrinsics = self.depth, self.intrinsics
|
||||
if depth is None or intrinsics is None:
|
||||
return []
|
||||
# SDK deprojection respects the camera's actual distortion model.
|
||||
h, w = depth.shape
|
||||
stride = max(8, math.ceil(math.sqrt(h * w / 2000)))
|
||||
result = []
|
||||
for y in range(0, h, stride):
|
||||
for x in range(0, w, stride):
|
||||
z = float(depth[y, x]) * self.depth_scale
|
||||
if 0 < z < 15:
|
||||
result.extend(
|
||||
round(v, 4) for v in rs.rs2_deproject_pixel_to_point(intrinsics, [x, y], z)
|
||||
)
|
||||
return result
|
||||
|
||||
def recordings(self):
|
||||
result = []
|
||||
for path in sorted(self.root.glob("recordings/*/manifest.json"), reverse=True)[:100]:
|
||||
value = json.loads(path.read_text())
|
||||
result.append(
|
||||
{
|
||||
k: value.get(k)
|
||||
for k in ("id", "state", "started_at", "ended_at", "bytes", "sha256")
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Private WebRTC preview. No capture ownership, relay, STUN or public candidates."""
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from fractions import Fraction
|
||||
|
||||
import aioice.ice
|
||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
|
||||
from av import VideoFrame
|
||||
|
||||
|
||||
def private(address):
|
||||
try:
|
||||
value = ipaddress.ip_address(address)
|
||||
return value.version == 4 and (
|
||||
value.is_loopback
|
||||
or any(
|
||||
value in ipaddress.ip_network(n)
|
||||
for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
host_addresses = aioice.ice.get_host_addresses
|
||||
aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [
|
||||
v for v in host_addresses(use_ipv4=True, use_ipv6=False) if private(v)
|
||||
]
|
||||
|
||||
|
||||
class CameraTrack(VideoStreamTrack):
|
||||
def __init__(self, device, layer):
|
||||
super().__init__()
|
||||
self.device, self.layer = device, layer
|
||||
self.started = None
|
||||
self.sequence = 0
|
||||
|
||||
async def recv(self):
|
||||
# Fixed 15 Hz preview clock; raw hardware timing is independent.
|
||||
if self.started is None:
|
||||
self.started = time.monotonic()
|
||||
await asyncio.sleep(max(0, self.started + self.sequence / 15 - time.monotonic()))
|
||||
pts, base = self.sequence * 6000, Fraction(1, 90000)
|
||||
self.sequence += 1
|
||||
while self.layer not in self.device.images:
|
||||
await asyncio.sleep(0.1)
|
||||
frame = VideoFrame.from_ndarray(self.device.images[self.layer], format="rgb24")
|
||||
frame.pts, frame.time_base = pts, base
|
||||
return frame
|
||||
|
||||
|
||||
class Peers:
|
||||
def __init__(self):
|
||||
self.items = {}
|
||||
|
||||
async def offer(self, device, params):
|
||||
layer = params.get("layer", "color")
|
||||
if layer not in ("color", "depth", "infrared1", "infrared2", "points", "motion"):
|
||||
raise ValueError("Неизвестный слой камеры.")
|
||||
if len(self.items) >= 4:
|
||||
raise ValueError("Закройте лишние окна просмотра камеры.")
|
||||
sdp = params.get("sdp", "")
|
||||
if not isinstance(sdp, str) or len(sdp) > 32768:
|
||||
raise ValueError("Некорректное приглашение просмотра.")
|
||||
for line in sdp.splitlines():
|
||||
if line.startswith("a=candidate:"):
|
||||
fields = line.split()
|
||||
if len(fields) < 8 or (not private(fields[4]) and not fields[4].endswith(".local")):
|
||||
raise ValueError("Просмотр доступен только в частной сети.")
|
||||
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
||||
ident = "peer_" + uuid.uuid4().hex
|
||||
self.items[ident] = {"pc": pc, "seen": time.monotonic()}
|
||||
|
||||
async def telemetry(channel):
|
||||
try:
|
||||
while pc.connectionState not in ("failed", "closed"):
|
||||
if time.monotonic() - self.items.get(ident, {}).get("seen", 0) > 30:
|
||||
break
|
||||
if channel.readyState == "open" and channel.bufferedAmount < 65536:
|
||||
payload = {
|
||||
"layer": layer,
|
||||
"motion": device.motion,
|
||||
"frame": device.last_frame,
|
||||
"acquisition": device.acquisition,
|
||||
}
|
||||
if layer == "points":
|
||||
payload["points"] = await asyncio.to_thread(device.points)
|
||||
channel.send(json.dumps(payload, allow_nan=False))
|
||||
await asyncio.sleep(0.25)
|
||||
finally:
|
||||
await self.close(ident)
|
||||
|
||||
@pc.on("datachannel")
|
||||
def datachannel(channel):
|
||||
@channel.on("message")
|
||||
def message(value):
|
||||
if value == "keepalive" and ident in self.items:
|
||||
self.items[ident]["seen"] = time.monotonic()
|
||||
|
||||
asyncio.create_task(telemetry(channel))
|
||||
|
||||
@pc.on("connectionstatechange")
|
||||
async def changed():
|
||||
if pc.connectionState in ("failed", "closed"):
|
||||
self.items.pop(ident, None)
|
||||
|
||||
try:
|
||||
await pc.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="offer"))
|
||||
if layer not in ("points", "motion"):
|
||||
pc.addTrack(CameraTrack(device, layer))
|
||||
await pc.setLocalDescription(await pc.createAnswer())
|
||||
|
||||
async def expiry():
|
||||
await asyncio.sleep(30)
|
||||
if ident in self.items and pc.connectionState != "connected":
|
||||
await self.close(ident)
|
||||
|
||||
asyncio.create_task(expiry())
|
||||
return {"peer_id": ident, "sdp": pc.localDescription.sdp, "type": "answer"}
|
||||
except Exception:
|
||||
await self.close(ident)
|
||||
raise
|
||||
|
||||
async def close(self, ident):
|
||||
entry = self.items.pop(ident, None)
|
||||
if entry:
|
||||
await entry["pc"].close()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Private Unix plugin host; the Go broker is the only UI/Core authority."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pyrealsense2 as rs
|
||||
from aiohttp import web
|
||||
from device import Device, atomic, device_id
|
||||
from media import Peers
|
||||
from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest
|
||||
|
||||
ROOT = Path("/var/lib/mission-core-sensors")
|
||||
SOCKET = Path("/run/mission-core-sensors/driver.sock")
|
||||
|
||||
|
||||
class Host:
|
||||
def __init__(self, root=ROOT):
|
||||
self.root = root
|
||||
self.context = rs.context()
|
||||
self.devices = {}
|
||||
self.execution = None
|
||||
self.peers = Peers()
|
||||
self.scan_lock = asyncio.Lock()
|
||||
self.operation_locks = {}
|
||||
|
||||
async def scan(self):
|
||||
if self.execution is None:
|
||||
return
|
||||
async with self.scan_lock:
|
||||
|
||||
def work():
|
||||
found = set()
|
||||
for dev in self.context.query_devices():
|
||||
if dev.is_playback():
|
||||
continue
|
||||
if dev.get_info(rs.camera_info.product_id).lower() != "0b5c":
|
||||
continue
|
||||
serial = dev.get_info(rs.camera_info.serial_number)
|
||||
# SDK module serial and USB serial are distinct on D455.
|
||||
# Resolve the actual transport ancestor, not list order or model count.
|
||||
physical = Path(dev.get_info(rs.camera_info.physical_port)).resolve()
|
||||
usb_serial = None
|
||||
for parent in (physical, *physical.parents):
|
||||
if (
|
||||
(parent / "idVendor").exists()
|
||||
and (parent / "idProduct").exists()
|
||||
and (parent / "idVendor").read_text().strip() == "8086"
|
||||
and (parent / "idProduct").read_text().strip() == "0b5c"
|
||||
):
|
||||
usb_serial = (parent / "serial").read_text().strip()
|
||||
break
|
||||
if not usb_serial:
|
||||
continue
|
||||
ident = device_id(usb_serial)
|
||||
found.add(ident)
|
||||
if ident not in self.devices:
|
||||
self.devices[ident] = Device(serial, self.root, self.execution, usb_serial)
|
||||
self.devices[ident].refresh(dev)
|
||||
for ident, device in self.devices.items():
|
||||
if ident not in found:
|
||||
device.disconnect()
|
||||
|
||||
await asyncio.to_thread(work)
|
||||
|
||||
async def inventory(self, request):
|
||||
node_id = request.headers.get("X-Node-Id", "")
|
||||
if not re.fullmatch(r"node_[0-9a-f]{64}", node_id):
|
||||
raise web.HTTPForbidden()
|
||||
if self.execution is None:
|
||||
self.execution = {
|
||||
"node_id": node_id,
|
||||
"agent_instance_id": "driver_" + uuid.uuid4().hex,
|
||||
"platform": "linux",
|
||||
}
|
||||
if self.execution["node_id"] != node_id:
|
||||
raise web.HTTPForbidden()
|
||||
await self.scan()
|
||||
return web.json_response(
|
||||
{"items": [device.snapshot(False) for device in self.devices.values()]}
|
||||
)
|
||||
|
||||
async def operation(self, request):
|
||||
value = await request.json()
|
||||
command = OperationRequest.model_validate(value)
|
||||
identifier = command.operation_id
|
||||
if not re.fullmatch(r"op_[0-9a-f]{32}", identifier):
|
||||
raise ValueError("Некорректный идентификатор операции.")
|
||||
path = self.root / (identifier + ".json")
|
||||
digest = hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
||||
lock = self.operation_locks.setdefault(identifier, asyncio.Lock())
|
||||
async with lock:
|
||||
if path.exists():
|
||||
previous = json.loads(path.read_text())
|
||||
if previous["digest"] != digest:
|
||||
raise ValueError("Идентификатор операции уже использован.")
|
||||
return web.json_response(previous["result"])
|
||||
if command.deadline_at <= datetime.now(UTC):
|
||||
raise ValueError("Срок команды истёк. Состояние камеры не изменено.")
|
||||
device = self.devices.get(command.session.device_id)
|
||||
if device is None or command.session.session_id != device.session:
|
||||
raise ValueError("Сеанс камеры изменился. Обновите сведения.")
|
||||
# Persist uncertainty before any side effect. A crash must not replay START.
|
||||
receipt = {
|
||||
"digest": digest,
|
||||
"result": {
|
||||
"state": "unknown",
|
||||
"error": "Результат операции пока неизвестен. Обновите состояние устройства.",
|
||||
},
|
||||
}
|
||||
atomic(path, receipt)
|
||||
try:
|
||||
action, params = command.action_id, dict(command.parameters)
|
||||
if action == "details":
|
||||
result = device.snapshot()
|
||||
elif action == "rename":
|
||||
result = device.rename(params.get("name"))
|
||||
elif action == "verify":
|
||||
result = await asyncio.to_thread(device.verify)
|
||||
elif action == "start":
|
||||
if type(params.get("record", False)) is not bool:
|
||||
raise ValueError("Некорректный режим записи.")
|
||||
await asyncio.to_thread(
|
||||
device.start, params.get("profiles"), params.get("record", False)
|
||||
)
|
||||
result = {"ok": True}
|
||||
elif action == "replay":
|
||||
result = await asyncio.to_thread(device.replay, params.get("recording_id"))
|
||||
elif action == "stop":
|
||||
result = await asyncio.to_thread(device.stop)
|
||||
elif action == "option":
|
||||
result = await asyncio.to_thread(
|
||||
device.set_option, params.get("id"), params.get("value")
|
||||
)
|
||||
elif action == "offer":
|
||||
result = await self.peers.offer(device, params)
|
||||
elif action == "close-peer":
|
||||
result = await self.peers.close(params.get("peer_id"))
|
||||
else:
|
||||
raise ValueError("Неподдерживаемая операция устройства.")
|
||||
receipt["result"] = {"state": "complete", "result": result}
|
||||
except (ValueError, RuntimeError) as error:
|
||||
receipt["result"] = {"state": "error", "error": str(error)[:400]}
|
||||
atomic(path, receipt)
|
||||
self.operation_locks.pop(identifier, None)
|
||||
return web.json_response(receipt["result"])
|
||||
|
||||
async def prepare_safe(self, request):
|
||||
return web.json_response(
|
||||
{
|
||||
"safe": all(
|
||||
d.pipeline is None
|
||||
and d.acquisition not in ("preparing", "starting", "stopping")
|
||||
for d in self.devices.values()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
async def cleanup(self, app):
|
||||
for peer in list(self.peers.items):
|
||||
await self.peers.close(peer)
|
||||
for device in self.devices.values():
|
||||
await asyncio.to_thread(device.stop, True)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def errors(request, handler):
|
||||
try:
|
||||
return await handler(request)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return web.json_response({"error": "Некорректная команда устройства."}, status=400)
|
||||
except RuntimeError:
|
||||
return web.json_response(
|
||||
{"error": "Драйвер не смог выполнить запрос. Проверьте подключение камеры."}, status=409
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
os.umask(0o007)
|
||||
host = Host()
|
||||
app = web.Application(client_max_size=65536, middlewares=[errors])
|
||||
app.router.add_get("/inventory", host.inventory)
|
||||
app.router.add_get("/prepare-safe", host.prepare_safe)
|
||||
app.router.add_post("/operation", host.operation)
|
||||
app.on_cleanup.append(host.cleanup)
|
||||
if SOCKET.exists():
|
||||
SOCKET.unlink()
|
||||
web.run_app(app, path=str(SOCKET), print=None, access_log=None, shutdown_timeout=20)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"filename": "go1.26.8.darwin-arm64.tar.gz",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"version": "go1.26.8",
|
||||
"sha256": "a012b25b571bd0138a03dcd25375ceba866fe5ca822f426d2c66a4de56fd3f4b",
|
||||
"size": 64626620,
|
||||
"kind": "archive"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Mission Core Node</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
Generated
+1244
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@nodedc/mission-core-node-ui",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"build": "tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#e2e1e1}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:.75px}</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||
<polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||
<path class="cls-2" d="M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15ZM140.43 18.64c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09ZM188.88 18.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75ZM205.15 9.75c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
|
||||
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||
<polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,20 @@
|
||||
import { ActivityIndicator, Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react";
|
||||
import type { Status } from "./api";
|
||||
import type { ViewId } from "./nodeModel";
|
||||
import { useAccess } from "./useAccess";
|
||||
import { tailscaleLabel, useTailscaleStatus } from "./useTailscaleStatus";
|
||||
|
||||
export function BoardSummary({ value, failure, openView }: { value: Status; failure: (error: unknown) => void; openView: (id: ViewId) => void }) {
|
||||
const { access, loading } = useAccess(value.host.collected_at, failure);
|
||||
const tailnet = useTailscaleStatus(failure, value.host.collected_at);
|
||||
const networks = value.host.networks.filter(item => item.name !== "lo" && item.name !== "lo0" && item.up && item.addresses.length > 0);
|
||||
const networkReadable = value.host.networks_readable && value.host.networks.every(item => item.addresses_readable);
|
||||
return <SettingsCard title="Сводка БК" description="Подключения и доступ к бортовому компьютеру.">
|
||||
<ResourceList aria-label="Сводка подключений БК">
|
||||
<li><ResourceRow icon={<Icon name="network" />} title="Сеть" description="Включённые интерфейсы с назначенными адресами" status={<StatusBadge tone={networkReadable && networks.length ? "neutral" : "warning"}>{networkReadable ? networks.length : "Нет сведений"}</StatusBadge>} actions={<Button onClick={() => openView("network")}>Открыть сеть</Button>} /></li>
|
||||
<li><ResourceRow icon={<Icon name="camera" />} title="USB-устройства" description="Обнаружены операционной системой" status={<StatusBadge>{value.host.usb_readable ? value.host.usb.length : "Нет сведений"}</StatusBadge>} actions={<Button onClick={() => openView("usb")}>Открыть USB</Button>} /></li>
|
||||
<li><ResourceRow icon={tailnet.checked ? <Icon name="globe" /> : <ActivityIndicator size="compact" />} title="Tailscale" description="Частная сеть" status={<StatusBadge tone={tailnet.value?.online ? "success" : "neutral"}>{tailscaleLabel(tailnet.value, tailnet.checked)}</StatusBadge>} actions={<Button onClick={() => openView("tailscale")}>Открыть Tailscale</Button>} /></li>
|
||||
<li><ResourceRow icon={loading ? <ActivityIndicator size="compact" /> : <Icon name="key" />} title="SSH" description="Удалённое обслуживание БК" metadata={access ? `Разрешено ключей: ${access.keys.length}` : undefined} status={<StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем" : !access ? "Нет сведений" : access.ssh_ready ? "Отвечает локально" : "Не отвечает"}</StatusBadge>} actions={<Button onClick={() => openView("ssh")}>Настроить SSH</Button>} /></li>
|
||||
</ResourceList>
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Button, ConfirmationModal, Select, SettingsCard, StatusBadge, TextAreaField } from "@nodedc/ui-react";
|
||||
import { request } from "./api";
|
||||
|
||||
interface CoreState {
|
||||
phase: "unpaired" | "inviting" | "pending" | "paired" | "revoked";
|
||||
connection: string; node_id: string; last_seen: number; notice: string; addresses: string[];
|
||||
invitation?: { id: string; endpoint: string; expires_at: number };
|
||||
binding?: { core_id: string; core_name: string; endpoint: string };
|
||||
}
|
||||
|
||||
export function CoreConnectionView({ failure }: { failure: (error: unknown) => void }) {
|
||||
const [value, setValue] = useState<CoreState | null>(null);
|
||||
const [address, setAddress] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [available, setAvailable] = useState(true);
|
||||
async function refresh() { const next = await request<CoreState>("/api/core"); setValue(next); setAvailable(true); return next; }
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function poll() {
|
||||
try { const next = await request<CoreState>("/api/core"); if (active) { setValue(next); setAvailable(true); } }
|
||||
catch (error) { if (active) { setAvailable(false); failure(error); } }
|
||||
finally { if (active) timer = setTimeout(poll, 3000); }
|
||||
}
|
||||
void poll();
|
||||
return () => { active = false; clearTimeout(timer); };
|
||||
}, [failure]);
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setAddress(current => value.addresses.includes(current) ? current : value.addresses[0] ?? "");
|
||||
if (value.phase !== "inviting") setCode("");
|
||||
}
|
||||
}, [value]);
|
||||
async function create() {
|
||||
if (pending) return;
|
||||
setPending(true); setNotice(""); setCode("");
|
||||
try {
|
||||
const result = await request<{ code: string }>("/api/core/invitation", "POST", { address });
|
||||
await refresh(); setCode(result.code);
|
||||
} catch (error) { failure(error); } finally { setPending(false); }
|
||||
}
|
||||
const paired = value?.phase === "paired";
|
||||
const label = !available ? "Состояние недоступно" : paired ? value.connection === "online" ? "В сети" : "Нет соединения с Core" : value?.phase === "pending" ? "Подтверждаем привязку" : value?.phase === "inviting" ? "Приглашение открыто" : value?.phase === "revoked" ? "Доверие отозвано" : "Не подключён";
|
||||
return <div className="node-content"><SettingsCard title="Mission Core" description="Подключение бортового компьютера к аппарату в парке" actions={<StatusBadge tone={available && paired && value.connection === "online" ? "success" : "neutral"}>{label}</StatusBadge>}>
|
||||
{!value ? <ActivityIndicator label="Получаем состояние подключения" /> : <>
|
||||
<dl className="node-facts"><div><dt>Идентификатор БК</dt><dd>{value.node_id}</dd></div>
|
||||
{value.binding && <><div><dt>Core</dt><dd>{value.binding.core_name}</dd></div><div><dt>Идентификатор Core</dt><dd>{value.binding.core_id}</dd></div><div><dt>Частный адрес Core</dt><dd>{value.binding.endpoint}</dd></div></>}
|
||||
{value.last_seen > 0 && <div><dt>Последняя связь</dt><dd>{new Date(value.last_seen * 1000).toLocaleString("ru-RU")}</dd></div>}
|
||||
</dl>
|
||||
{value.notice && <p className="node-note" role="status">{value.notice}</p>}
|
||||
{paired ? <p className="node-note">БК сохраняет привязку при перезапуске и восстанавливает соединение автоматически. Для подключения к другому Core сначала отзовите текущую привязку.</p> : value.phase === "pending" ? <ActivityIndicator label="Core принимает приглашение. Дождитесь подтверждения связи." /> : <div className="node-form">
|
||||
<p className="node-note">Выберите адрес, доступный компьютеру с Mission Core: в общей локальной сети или Tailscale. Создайте приглашение и вставьте его в Core: «Парк → Аппараты → Добавить аппарат».</p>
|
||||
<Select label="Адрес БК для подключения" value={address} options={value.addresses.map(item => ({ value: item, label: item }))} onChange={setAddress} disabled={pending || !available} />
|
||||
{value.addresses.length === 0 && <p className="node-note">Подключите БК к частной сети. Доступные адреса появятся автоматически.</p>}
|
||||
<Button disabled={pending || !available || !address} onClick={() => void create()}>{pending ? "Создаём…" : value.phase === "inviting" ? "Создать новое приглашение" : "Создать приглашение"}</Button>
|
||||
{value.phase === "inviting" && value.invitation && <p className="node-note">Действует до {new Date(value.invitation.expires_at * 1000).toLocaleTimeString("ru-RU")}. Приглашение позволяет одному Core получить доверие этого БК. Передавайте его только нужному оператору.</p>}
|
||||
{code && <><TextAreaField label="Код приглашения" value={code} readOnly rows={5} spellCheck={false} onFocus={event => event.target.select()} /><Button onClick={async () => { try { await navigator.clipboard.writeText(code); setNotice("Код скопирован"); } catch { setNotice("Выделите код в поле и скопируйте его сочетанием Ctrl+C."); } }}>Скопировать код</Button></>}
|
||||
{value.phase === "inviting" && !code && <p className="node-note">Код показывается только при создании. Создайте новое приглашение, если он не сохранился у вас.</p>}
|
||||
</div>}
|
||||
{value.phase !== "unpaired" && <Button disabled={pending || !available} onClick={() => setRemove(true)}>{value.binding ? "Отозвать привязку" : "Отменить приглашение"}</Button>}
|
||||
{notice && <p role="status" className="node-note">{notice}</p>}
|
||||
</>}
|
||||
</SettingsCard>
|
||||
<ConfirmationModal open={remove} title={value?.binding ? "Отозвать привязку к Core?" : "Отменить приглашение?"} description={value?.binding ? "Этот БК прекратит соединение с Core. Аппарат останется в его реестре; отзыв будет доставлен при доступности сети." : "Этот код больше нельзя будет использовать. Вы сможете создать новый."} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRemove(false)} onConfirm={async () => { try { await request("/api/core", "DELETE"); setCode(""); setNotice(""); await refresh(); setRemove(false); } catch (error) { failure(error); throw error; } }} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react";
|
||||
import { environmentSetupAvailable } from "./api";
|
||||
import type { useEnvironment } from "./useEnvironment";
|
||||
import { TailnetAccess } from "./TailnetAccess";
|
||||
import { SystemAccess } from "./SystemAccess";
|
||||
|
||||
const labels:Record<string,string>={pending:"Ожидает",running:"Выполняется",error:"Ошибка",blocked:"Не выполнено"};
|
||||
export function EnvironmentView({environment,failure,success}: {environment:ReturnType<typeof useEnvironment>;failure:(error:unknown)=>void;success:(text:string)=>void}) {
|
||||
const {value,pending,running,loading,start}=environment;
|
||||
const [adding,setAdding]=useState(false);
|
||||
const revision=String(value?.run?.updated_at??0);
|
||||
const complete=value?.run?.state==="complete"&&value.run.profile_revision===value.profile.revision;
|
||||
return <div className="node-content">
|
||||
<SettingsCard title="Настройка окружения" description="Установка и проверка компонентов для работы с бортовым компьютером.">
|
||||
<p className="node-note">Сконфигурируйте окружение при первом запуске или повторите проверку после изменений. Приложение установит нужные пакеты, настроит службы и получит системные сведения. Вход в частную сеть и доверенный SSH-ключ подтверждаются ниже.</p>
|
||||
<Button onClick={start} disabled={running||!value||!environmentSetupAvailable()}>{running?"Настраиваем…":"Сконфигурировать"}</Button>
|
||||
{!environmentSetupAvailable()&&<p className="node-note">Настройка запускается из установленного приложения на БК.</p>}
|
||||
{pending&&(!value?.run||value.run.state!=="running")&&<ActivityIndicator label="Подтвердите действие в системном окне. Ожидаем начало настройки…" />}
|
||||
{!value?loading?<ActivityIndicator label="Получаем этапы настройки" />:<p className="node-note">Сведения о настройке недоступны. Обновите страницу.</p>:<>
|
||||
{!value.available&&<p className="node-note">Журнал предыдущего запуска недоступен. Нажмите «Сконфигурировать» — приложение восстановит доступ и выполнит этапы заново.</p>}
|
||||
<ResourceList aria-label="Этапы настройки окружения">{value.profile.steps.map(specification=>{
|
||||
const step=value.run?.steps.find(item=>item.id===specification.id);
|
||||
const state=step?.state??"pending";
|
||||
return <li key={specification.id}><ResourceRow icon={state==="running"?<ActivityIndicator size="compact" />:<Icon name={state==="error"?"alert":"circle"} />} title={specification.label} description={specification.description} metadata={step?.detail} status={state==="complete"?<Icon name="check" label="Этап завершён" />:<StatusBadge tone={state==="error"?"warning":"neutral"}>{labels[state]??"Неизвестно"}</StatusBadge>} /></li>;
|
||||
})}</ResourceList>
|
||||
{value.run&&!running&&value.run.updated_at>0&&<p className="node-note">Результат проверки от {new Date(value.run.updated_at*1000).toLocaleString("ru-RU")}.</p>}
|
||||
{complete&&!running&&<p className="node-note">Системные этапы завершены. Ниже проверьте вход в Tailscale и доверенный ключ для SSH.</p>}
|
||||
{value.run?.state==="error"&&!running&&<p className="node-note">Часть этапов не завершена. Исправьте указанные причины и повторите настройку.</p>}
|
||||
{value.run?.state==="interrupted"&&<p className="node-note">Предыдущая настройка прервана. Повторный запуск проверит уже выполненные этапы.</p>}
|
||||
</>}
|
||||
</SettingsCard>
|
||||
{!running&&<>
|
||||
<SettingsCard title="Доверенное устройство для SSH"><SystemAccess revision={revision} failure={failure} success={success} adding={adding} closeAdd={()=>setAdding(false)} startAdd={()=>setAdding(true)} /></SettingsCard>
|
||||
<TailnetAccess failure={failure} revision={revision} />
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } from "@nodedc/ui-react";
|
||||
import type { Status } from "./api";
|
||||
export function NetworkView({ value }: { value: Status }) {
|
||||
return <div className="node-content"><SettingsCard title="Сетевые подключения" description="Интерфейсы и адреса бортового компьютера.">
|
||||
{!value.host.networks_readable ? <p className="node-note" role="status">Не удалось получить сетевые интерфейсы. Повторите обновление.</p> : value.host.networks.length === 0 ? <p className="node-note">Сетевые интерфейсы не обнаружены.</p> : <ResourceList aria-label="Сетевые интерфейсы">{value.host.networks.map(network => <li key={network.name}><ResourceRow icon={<Icon name="network" />} title={network.name} description={!network.addresses_readable ? "Адреса недоступны" : network.addresses.join(" · ") || "Нет назначенного адреса"} status={<StatusBadge tone={network.up ? "neutral" : "warning"}>{network.up ? "Включён" : "Выключен"}</StatusBadge>} /></li>)}</ResourceList>}
|
||||
</SettingsCard><p className="node-note">Наличие адреса не подтверждает доступность другого компьютера или устройства.</p></div>;
|
||||
}
|
||||
export function USBView({ value }: { value: Status }) {
|
||||
return <div className="node-content"><SettingsCard title="Подключённые по USB" description="Устройства, которые обнаружила операционная система." actions={<StatusBadge>{value.host.usb.length}</StatusBadge>}>
|
||||
{!value.host.usb_readable ? <p className="node-note" role="status">Не удалось получить список устройств. Повторите обновление.</p> : value.host.usb.length === 0 ? <p className="node-note">Подключите устройство к USB, затем обновите список.</p> : <ResourceList aria-label="USB-устройства">{value.host.usb.map(device => <li key={device.port}><ResourceRow icon={<Icon name="camera" />} title={device.product || `USB ${device.vendor}:${device.product_id}`} description={`Порт ${device.port} · ${device.speed_mbps ? `${device.speed_mbps} Мбит/с` : "Скорость недоступна"}`} metadata={`${device.vendor}:${device.product_id}`} status={<StatusBadge>Обнаружено</StatusBadge>} /></li>)}</ResourceList>}
|
||||
</SettingsCard><p className="node-note">Обнаружение USB ещё не означает готовность к съёмке.</p></div>;
|
||||
}
|
||||
export function DiagnosticsView({ value }: { value: Status }) {
|
||||
return <div className="node-content"><SettingsCard title="Диагностический отчёт" description="Сведения о системе и доступности компонентов без имени компьютера, сетевых адресов и ID ноды.">
|
||||
<Button icon={<Icon name="download" />} onClick={() => location.assign("/api/report")}>Скачать отчёт</Button>
|
||||
<p className="node-note">Сведения получены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
|
||||
</SettingsCard><SettingsCard title="Замечания системы">{value.host.warnings.length ? value.host.warnings.map(warning => <p className="node-note" key={warning}><StatusBadge tone="warning">{warning}</StatusBadge></p>) : <p className="node-note">При последнем сборе сведений замечаний нет.</p>}</SettingsCard></div>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, SettingsCard, TextField } from "@nodedc/ui-react";
|
||||
import { request, type Status } from "./api";
|
||||
import type { ViewId } from "./nodeModel";
|
||||
import { BoardSummary } from "./BoardSummary";
|
||||
|
||||
const memory = (value: number | null) => value === null ? "Недоступно" : `${(value / 1048576).toFixed(1)} ГиБ`;
|
||||
export function NodeOverview({ value, refresh, failure, openView }: { value: Status; refresh: () => Promise<void>; failure: (error: unknown) => void; openView: (id: ViewId) => void }) {
|
||||
const [name, setName] = useState(value.name);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => setName(value.name), [value.name]);
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault(); if (saving) return; setSaving(true);
|
||||
try { await request("/api/name", "PUT", { name }); await refresh(); } catch (error) { failure(error); } finally { setSaving(false); }
|
||||
}
|
||||
return <div className="node-content">
|
||||
<SettingsCard title={value.name} eyebrow="БОРТОВОЙ КОМПЬЮТЕР" description={value.host.hostname}>
|
||||
<dl className="node-facts"><div><dt>Операционная система</dt><dd>{value.host.os}</dd></div><div><dt>Архитектура</dt><dd>{value.host.architecture}</dd></div><div><dt>Логических процессоров</dt><dd>{value.host.cpus}</dd></div><div><dt>Оперативная память</dt><dd>{memory(value.host.memory_kib)}</dd></div><div><dt>Доступно памяти</dt><dd>{memory(value.host.available_kib)}</dd></div><div><dt>Mission Core Node</dt><dd>{value.version}</dd></div></dl>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Название БК" description="Название бортового компьютера.">
|
||||
<form onSubmit={save} className="node-form" aria-busy={saving}>
|
||||
<TextField label="Название БК" value={name} maxLength={64} disabled={saving} onChange={event => setName(event.target.value)} autoComplete="off" />
|
||||
<Button type="submit" disabled={saving || !name.trim() || name.trim() === value.name}>{saving ? "Сохраняем…" : "Сохранить"}</Button>
|
||||
</form>
|
||||
<dl className="node-facts"><div><dt>ID БК</dt><dd>{value.node_id}</dd></div></dl>
|
||||
</SettingsCard>
|
||||
<BoardSummary value={value} failure={failure} openView={openView} />
|
||||
<p className="node-note">Сведения обновлены {new Date(value.host.collected_at).toLocaleString("ru-RU")}.</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {request} from './api';
|
||||
const transport:SensorTransport={subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace transport={transport}/>;}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { request } from "./api";
|
||||
import { useAccess, type AccessKey } from "./useAccess";
|
||||
|
||||
export function SystemAccess({ revision, failure, success, adding, closeAdd, startAdd }: { revision: string; failure: (error: unknown) => void; success: (message: string) => void; adding: boolean; closeAdd: () => void; startAdd?: () => void }) {
|
||||
const { access, loading, refresh } = useAccess(revision, failure);
|
||||
const [user, setUser] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [remove, setRemove] = useState<AccessKey | null>(null);
|
||||
const [detail, setDetail] = useState<AccessKey | null>(null);
|
||||
useEffect(() => { if (access) setUser(current => access.users.includes(current) ? current : access.users[0] ?? ""); }, [access]);
|
||||
useEffect(() => { if (!adding) { setLabel(""); setKey(""); } }, [adding]);
|
||||
async function add(event: React.FormEvent) {
|
||||
event.preventDefault(); if (pending) return; setPending(true);
|
||||
try { await request("/api/access", "POST", { user, label, key: key.trim() }); await refresh(); closeAdd(); success("Доступ устройства добавлен"); }
|
||||
catch (error) { failure(error); } finally { setPending(false); }
|
||||
}
|
||||
return <div className="node-content">
|
||||
<div className="node-section-heading"><p className="node-note">Компьютеры, которым разрешён вход по SSH через Node.</p><StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем SSH" : !access ? "Нет сведений" : access.ssh_ready ? "SSH отвечает локально" : "SSH не отвечает"}</StatusBadge></div>
|
||||
{loading && !access ? <ActivityIndicator label="Получение доверенных устройств" /> : !access ? <p className="node-note">Не удалось загрузить список. Повторите обновление.</p> : <>
|
||||
{access.keys.length === 0 ? <p className="node-note">Доверенных устройств пока нет. Добавьте компьютер, которому нужен доступ.</p> : <ResourceList aria-label="Доверенные SSH-устройства">{access.keys.map(item => <li key={`${item.user}:${item.id}`}><ResourceRow icon={<Icon name="key" />} title={item.label} description={`Пользователь системы: ${item.user}`} metadata={<span title={item.id}>{item.id}</span>} status={<StatusBadge>Доступ разрешён</StatusBadge>} actions={<><IconButton label={`Сведения: ${item.label}`} onClick={() => setDetail(item)}><Icon name="eye" /></IconButton><IconButton label={`Отозвать доступ: ${item.label}`} onClick={() => setRemove(item)}><Icon name="trash" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<p className="node-note">Разрешённый ключ не означает, что компьютер сейчас подключён. Отзыв закрывает новые подключения через Node; открытые сеансы и отдельно настроенные способы входа в систему сохраняются.</p>
|
||||
</>}
|
||||
{startAdd && <Button onClick={startAdd} disabled={!access}>Добавить доверенное устройство</Button>}
|
||||
<Window open={adding} title="Добавить доверенное устройство" subtitle="Разрешить компьютеру подключаться к этому борту по SSH" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={() => { if (!pending) closeAdd(); }} footer={<WindowFooterActions><Button disabled={pending} onClick={closeAdd}>Отмена</Button><Button type="submit" form="node-add-ssh" disabled={pending || !access || !key.trim() || !label.trim() || !user}>{pending ? "Добавляем…" : "Разрешить доступ"}</Button></WindowFooterActions>}>
|
||||
{!access ? <p className="node-note">{loading ? "Получаем пользователей системы…" : "Не удалось получить пользователей. Закройте окно и обновите список."}</p> : access.users.length === 0 ? <p className="node-note">Не найдены администраторы системы. Добавьте пользователя в настройках системы.</p> : <form id="node-add-ssh" className="node-form" onSubmit={add} aria-busy={pending}>
|
||||
<TextField label="Название устройства" placeholder="Например, ноутбук оператора" value={label} maxLength={64} disabled={pending} onChange={event => setLabel(event.target.value)} autoComplete="off" />
|
||||
<Select label="Пользователь системы" value={user} options={access.users.map(value => ({ value, label: value }))} onChange={setUser} disabled={pending} />
|
||||
<TextAreaField label="Публичный SSH-ключ Ed25519" placeholder="ssh-ed25519 …" value={key} rows={4} disabled={pending} onChange={event => setKey(event.target.value)} autoComplete="off" spellCheck={false} />
|
||||
<p className="node-note">Вставьте содержимое публичного файла .pub с доверенного компьютера. Приватный ключ остаётся на том компьютере. Этот доступ действует в частной сети.</p>
|
||||
</form>}
|
||||
</Window>
|
||||
<Window open={detail !== null} title={detail?.label ?? "Доверенное устройство"} subtitle="SSH-доступ к этому борту" onClose={() => setDetail(null)}>
|
||||
{detail && <dl className="node-facts"><div><dt>Пользователь системы</dt><dd>{detail.user}</dd></div><div><dt>Отпечаток</dt><dd>{detail.id}</dd></div><div><dt>Публичный ключ</dt><dd>{detail.public_key}</dd></div></dl>}
|
||||
</Window>
|
||||
<ConfirmationModal open={remove !== null} title="Отозвать доступ устройства?" description={`Новые подключения через Node с устройства «${remove?.label ?? ""}» станут недоступны. Открытые сеансы продолжат работать.`} confirmLabel="Отозвать" cancelLabel="Отмена" pendingLabel="Отзываем…" danger onClose={() => setRemove(null)} onConfirm={async () => {
|
||||
if (!remove) return;
|
||||
try { await request("/api/access", "DELETE", { user: remove.user, id: remove.id }); await refresh(); setRemove(null); success("Доступ устройства отозван"); } catch (error) { failure(error); throw error; }
|
||||
}} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Button, SettingsCard, StatusBadge } from "@nodedc/ui-react";
|
||||
import { desktopAction, networkSetupAvailable } from "./api";
|
||||
import { tailscaleLabel, useTailscaleStatus } from "./useTailscaleStatus";
|
||||
|
||||
interface NetworkResult { action: string; ok: boolean; error?: string; browser_opened?: boolean }
|
||||
|
||||
export function TailnetAccess({ failure, revision: hostRevision }: { failure: (error: unknown) => void; revision: string }) {
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const { value, checked } = useTailscaleStatus(failure, hostRevision, revision);
|
||||
useEffect(() => { if (value?.online) setNotice(""); }, [value]);
|
||||
useEffect(() => {
|
||||
function completed(event: Event) {
|
||||
const result = (event as CustomEvent<NetworkResult>).detail;
|
||||
if (!result || !["install-tailscale", "connect-tailscale"].includes(result.action)) return;
|
||||
setPending(null);
|
||||
if (!result.ok) failure(new Error(result.error ?? "Настройка не завершена. Повторите действие."));
|
||||
setNotice(result.browser_opened ? "Завершите вход в открывшемся браузере. Состояние здесь обновится автоматически." : "");
|
||||
setRevision(value => value + 1);
|
||||
}
|
||||
window.addEventListener("mission-core-network-result", completed);
|
||||
return () => window.removeEventListener("mission-core-network-result", completed);
|
||||
}, [failure]);
|
||||
function perform(action: "install-tailscale" | "connect-tailscale") {
|
||||
if (pending) return;
|
||||
setNotice(""); setPending(action);
|
||||
if (!desktopAction(action)) {
|
||||
setPending(null); failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений."));
|
||||
}
|
||||
}
|
||||
const label = tailscaleLabel(value, checked);
|
||||
const canConnect = value?.installed && ["NeedsLogin", "Stopped", "unavailable"].includes(value.state);
|
||||
const supported = networkSetupAvailable();
|
||||
return <div className="node-content"><SettingsCard title="Tailscale" description="Частная сеть для удалённого доступа к борту" actions={<StatusBadge tone={value?.online ? "success" : "neutral"}>{label}</StatusBadge>}>
|
||||
<p className="node-note">Частная сеть для доступа к борту с другого компьютера. Войдите в ту же сеть Tailscale, что и на компьютере оператора. При первом подключении настройки локальной сети сохраняются.</p>
|
||||
{!checked ? <ActivityIndicator label="Проверяем подключение Tailscale" /> : !value ? <p className="node-note">Не удалось получить состояние. Повторная проверка выполняется автоматически.</p> : <>
|
||||
{!value.installed && <><p className="node-note">Приложение загрузит проверенный пакет Tailscale и включит его службу. Понадобятся интернет и системное подтверждение.</p><Button disabled={pending !== null || !supported} onClick={() => perform("install-tailscale")}>Установить Tailscale</Button></>}
|
||||
{canConnect && <Button disabled={pending !== null || !supported} onClick={() => perform("connect-tailscale")}>{value.state === "NeedsLogin" ? "Войти в Tailscale" : "Подключить Tailscale"}</Button>}
|
||||
{value.state === "NeedsMachineAuth" && <p className="node-note">Администратор вашей сети должен разрешить подключение этого компьютера в Tailscale.</p>}
|
||||
{value.addresses.length > 0 && <dl className="node-facts"><div><dt>Адреса в Tailscale</dt><dd>{value.addresses.join(" · ")}</dd></div></dl>}
|
||||
</>}
|
||||
{!supported && <p className="node-note">Для настройки сети закройте окно и заново откройте установленное приложение из меню приложений. После обновления пакета требуется перезапуск окна.</p>}
|
||||
{pending && <ActivityIndicator label={pending === "install-tailscale" ? "Подтвердите установку в системном окне. Загружаем и устанавливаем компонент…" : "Подтвердите действие в системном окне. Проверяем подключение…"} />}
|
||||
{notice && <p className="node-note" role="status">{notice}</p>}
|
||||
</SettingsCard></div>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface Status {
|
||||
version: string; node_id: string; name: string;
|
||||
host: {
|
||||
collected_at: string; hostname: string; os: string; architecture: string; cpus: number;
|
||||
memory_kib: number | null; available_kib: number | null;
|
||||
networks: { name: string; up: boolean; addresses: string[]; addresses_readable: boolean }[];
|
||||
networks_readable: boolean;
|
||||
usb: { port: string; vendor: string; product_id: string; product: string; speed_mbps: string }[];
|
||||
usb_readable: boolean; warnings: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export class APIError extends Error { constructor(message: string, public status: number) { super(message); } }
|
||||
export type DesktopAction = "authorize" | "install-tailscale" | "connect-tailscale" | "configure-system";
|
||||
export function environmentSetupAvailable(): boolean {
|
||||
return (window as Window & { missionCoreDesktop?: { environmentSetup?: boolean } }).missionCoreDesktop?.environmentSetup === true;
|
||||
}
|
||||
export function networkSetupAvailable(): boolean {
|
||||
return (window as Window & { missionCoreDesktop?: { networkSetup?: boolean } }).missionCoreDesktop?.networkSetup === true;
|
||||
}
|
||||
export function desktopAction(action: DesktopAction): boolean {
|
||||
if (action === "configure-system" ? !environmentSetupAvailable() : action !== "authorize" && !networkSetupAvailable()) return false;
|
||||
const host = window as Window & { webkit?: { messageHandlers?: { node?: { postMessage: (message: string) => void } } } };
|
||||
const channel = host.webkit?.messageHandlers?.node;
|
||||
if (!channel) return false;
|
||||
channel.postMessage(action);
|
||||
return true;
|
||||
}
|
||||
export function desktopLogin(): boolean { return desktopAction("authorize"); }
|
||||
export async function request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, { method, credentials: "same-origin", cache: "no-store", headers: body === undefined ? {} : {"Content-Type": "application/json"}, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10000) }).catch(() => { throw new Error("БК не ответил вовремя. Обновите сведения; действие могло продолжиться на борту."); });
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new APIError(error.error ?? "Не удалось выполнить запрос к ноде", res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function loginFromLaunch(): Promise<void> {
|
||||
const params = new URLSearchParams(location.hash.slice(1));
|
||||
const token = params.get("login");
|
||||
// Remove the one-use credential from history before any asynchronous work.
|
||||
if (location.hash) history.replaceState(null, "", location.pathname);
|
||||
if (token) await request("/api/session", "POST", { token });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ActivityIndicator, AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, HeaderNavigation, HeaderProfile, Icon, SettingsCard, ToastStack, UserProfileMenu, useApplicationWorkspace } from "@nodedc/ui-react";
|
||||
import "@nodedc/tokens/tokens.css";
|
||||
import "@nodedc/tokens/themes.css";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
import { desktopLogin } from "./api";
|
||||
import { useNode } from "./useNode";
|
||||
import { roots, views, type RootId, type ViewId } from "./nodeModel";
|
||||
import { NodeOverview } from "./NodeOverview";
|
||||
import { USBView, DiagnosticsView, NetworkView } from "./InventoryViews";
|
||||
import { SystemAccess } from "./SystemAccess";
|
||||
import { TailnetAccess } from "./TailnetAccess";
|
||||
import { useEnvironment } from "./useEnvironment";
|
||||
import { EnvironmentView } from "./EnvironmentView";
|
||||
import { CoreConnectionView } from "./CoreConnectionView";
|
||||
import "./node.css";
|
||||
import { NodeSensors } from "./NodeSensors";
|
||||
|
||||
function App() {
|
||||
const node = useNode();
|
||||
const { value, pending, locked, refresh, failure } = node;
|
||||
const environment = useEnvironment(!!value, failure);
|
||||
const refreshAll = () => {if(!environment.running) {void refresh();void environment.refresh();}};
|
||||
const [root, setRoot] = useState<RootId>("system");
|
||||
const workspace = useApplicationWorkspace<ViewId>({ activeView: "environment" });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem("node-theme") === "light" ? "light" : "dark");
|
||||
// Theme applies to body portals as well as the application shell.
|
||||
useEffect(() => { document.documentElement.dataset.nodedcTheme = theme; }, [theme]);
|
||||
const currentRoot = roots.find(item => item.id === root)!;
|
||||
const currentView = views.find(item => item.id === workspace.activeView);
|
||||
function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
|
||||
function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
|
||||
const content = !value ? null : workspace.activeView === "environment" ? <EnvironmentView environment={environment} failure={failure} success={node.success} /> : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
|
||||
: workspace.activeView === "sensors" ? <NodeSensors />
|
||||
: workspace.activeView === "network" ? <NetworkView value={value} />
|
||||
: workspace.activeView === "usb" ? <USBView value={value} />
|
||||
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />
|
||||
: workspace.activeView === "diagnostics" ? <DiagnosticsView value={value} />
|
||||
: workspace.activeView === "tailscale" ? <TailnetAccess failure={failure} revision={value.host.collected_at} />
|
||||
: workspace.activeView === "ssh" ? <SystemAccess revision={value.host.collected_at} failure={failure} success={node.success} adding={adding} closeAdd={() => setAdding(false)} /> : null;
|
||||
return <>
|
||||
<ApplicationShell data-nodedc-ui className="node-app" header={<AppHeader brandMonochrome brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />} brandLabel="Mission Core Node"
|
||||
center={<HeaderNavigation label="Разделы бортового компьютера" value={root} items={roots.map(item => ({ value: item.id, label: item.label, disabled: !value || !item.first || environment.running }))} onChange={selectRoot} />}
|
||||
right={<HeaderProfile><UserProfileMenu displayName="Node" subtitle={value?.name ?? "Mission Core Node"} triggerLabel={null} actions={[{ id: "refresh", label: "Обновить сведения", icon: "refresh", onSelect: refreshAll }, { id: "theme", label: theme === "dark" ? "Светлая тема" : "Тёмная тема", icon: "eye", onSelect: () => { const next = theme === "dark" ? "light" : "dark"; setTheme(next); localStorage.setItem("node-theme", next); } }]} /></HeaderProfile>} />}
|
||||
navigationOpen={!!value && workspace.navigationOpen} contentOpen={!!value && workspace.contentOpen} contentExpanded={workspace.contentExpanded}
|
||||
navigation={<AdminNavigationPanel eyebrow="MISSION CORE NODE" title={currentRoot.label} onClose={workspace.closeNavigation} closeLabel="Закрыть навигацию" navigationLabel="Разделы выбранной вкладки"
|
||||
contexts={value ? [{ id: "board", label: value.name, description: value.host.hostname, icon: <Icon name="activity" />, onSelect: () => openView("overview") }] : []}
|
||||
items={views.filter(item => item.root === root).map(item => ({ id: item.id, label: item.label, icon: <Icon name={item.icon} /> }))} activeId={workspace.activeView ?? undefined} onItemChange={id => openView(id as ViewId)} footer={<span>Mission Core Node · {value?.version}</span>} />}
|
||||
content={currentView && <ApplicationPanel title={currentView.label} eyebrow={currentRoot.label} expanded={workspace.contentExpanded} onExpandedChange={workspace.setContentExpanded} onClose={workspace.closeView}
|
||||
utilityActions={[...(workspace.activeView === "ssh" ? [{ label: "Добавить доверенное устройство", icon: "plus" as const, onClick: () => setAdding(true) }] : []), { label: "Обновить сведения", icon: "refresh", disabled: pending || environment.running, onClick: refreshAll }]}>{content}</ApplicationPanel>}
|
||||
stage={<div className="node-stage" aria-busy={pending}>
|
||||
{value ? <SettingsCard title={value.name} eyebrow="MISSION CORE NODE" description={`Бортовой компьютер · ${value.host.architecture}`}><div className="node-home-actions">{roots.map(item => <Button key={item.id} disabled={!item.first} onClick={() => selectRoot(item.id)}>{item.label}</Button>)}</div></SettingsCard> : <SettingsCard className="node-entry" title={pending ? "Подключаемся к ноде" : locked ? "Вход в Mission Core Node" : "Нода недоступна"}>
|
||||
{pending ? <ActivityIndicator label="Получение сведений о ноде" /> : <p className="node-note">{locked ? "Подтвердите доступ в системном окне." : "Не удалось связаться со службой. Повторите подключение."}</p>}
|
||||
<Button disabled={pending} onClick={() => { if (locked) { if (!desktopLogin()) failure(new Error("Откройте установленное приложение Mission Core Node из меню приложений.")); } else void refresh(); }}>{locked ? "Войти" : "Повторить подключение"}</Button>
|
||||
</SettingsCard>}
|
||||
</div>} />
|
||||
<ToastStack items={node.toasts} onDismiss={node.dismiss} />
|
||||
</>;
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,14 @@
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--nodedc-canvas); color: var(--nodedc-text-primary); font-family: var(--nodedc-font-family); font-size: var(--nodedc-font-size-md); }
|
||||
.node-stage { height: 100%; overflow: auto; padding: var(--nodedc-space-5); }
|
||||
.node-content { display: grid; align-content: start; gap: var(--nodedc-space-5); min-width: 0; }
|
||||
.node-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: var(--nodedc-space-5); margin: 0; font-size: var(--nodedc-font-size-sm); }
|
||||
.node-facts > div { min-width: 0; }
|
||||
.node-facts dt { color: var(--nodedc-text-muted); margin-bottom: var(--nodedc-space-2); }
|
||||
.node-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.node-section-heading { display: flex; flex-wrap: wrap; align-items: center; gap: var(--nodedc-space-3); justify-content: space-between; }
|
||||
.node-form { display: grid; gap: var(--nodedc-space-4); }
|
||||
.node-form > button { justify-self: start; }
|
||||
.node-note { margin: 0; color: var(--nodedc-text-secondary); font-size: var(--nodedc-font-size-sm); line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.node-entry { max-width: 640px; margin: var(--nodedc-space-8) auto; }
|
||||
.node-home-actions { display: flex; flex-wrap: wrap; gap: var(--nodedc-space-3); }
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IconName } from "@nodedc/ui-react";
|
||||
export type RootId = "system" | "devices";
|
||||
export type ViewId = "sensors" | "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh" | "core";
|
||||
export const roots: { id: RootId; label: string; first: ViewId | null }[] = [
|
||||
{ id: "system", label: "Система", first: "environment" },
|
||||
{ id: "devices", label: "Устройства", first: "sensors" },
|
||||
];
|
||||
export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [
|
||||
{ id: "sensors", root: "devices", label: "Подключённые устройства", icon: "camera" },
|
||||
{ id: "environment", root: "system", label: "Настройка окружения", icon: "settings" },
|
||||
{ id: "overview", root: "system", label: "Обзор БК", icon: "activity" },
|
||||
{ id: "network", root: "system", label: "Сеть", icon: "network" },
|
||||
{ id: "usb", root: "system", label: "USB-устройства", icon: "camera" },
|
||||
{ id: "core", root: "system", label: "Mission Core", icon: "network" },
|
||||
{ id: "tailscale", root: "system", label: "Tailscale", icon: "globe" },
|
||||
{ id: "ssh", root: "system", label: "SSH · доверенные устройства", icon: "key" },
|
||||
{ id: "diagnostics", root: "system", label: "Диагностика", icon: "clipboard" },
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { request } from "./api";
|
||||
export interface AccessKey { id: string; user: string; label: string; public_key: string }
|
||||
export interface Access { users: string[]; keys: AccessKey[]; ssh_ready: boolean }
|
||||
export function useAccess(revision: string, failure: (error: unknown) => void) {
|
||||
const [access, setAccess] = useState<Access | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setAccess(await request<Access>("/api/access")); }
|
||||
catch (error) { setAccess(null); failure(error); throw error; }
|
||||
finally { setLoading(false); }
|
||||
}, [failure]);
|
||||
useEffect(() => { void refresh().catch(() => {}); }, [revision, refresh]);
|
||||
return { access, loading, refresh };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { desktopAction, request } from "./api";
|
||||
|
||||
export interface EnvironmentStep { id: string; state: string; detail: string }
|
||||
export interface EnvironmentRun { schema: string; profile_revision: string; run_id: string; state: string; updated_at: number; steps: EnvironmentStep[] }
|
||||
export interface EnvironmentStatus { available: boolean; profile: {revision: string; steps:{id:string;label:string;description:string}[]}; run: EnvironmentRun | null }
|
||||
|
||||
export function useEnvironment(authorized: boolean, failure: (error: unknown) => void) {
|
||||
const [value, setValue] = useState<EnvironmentStatus | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {setValue(await request<EnvironmentStatus>("/api/environment"));}
|
||||
catch(error) {failure(error);}
|
||||
finally {setLoading(false);}
|
||||
},[failure]);
|
||||
// Native progress survives a service restart and never depends on a stale
|
||||
// HTTP session. Reopening an existing run uses the read-only status API.
|
||||
useEffect(() => {
|
||||
function progress(event:Event) {
|
||||
const run=(event as CustomEvent<EnvironmentRun>).detail;
|
||||
if(run?.schema==="missioncore.node.environment/v1") setValue(current=>current?{...current,available:true,run}:current);
|
||||
}
|
||||
function complete(event:Event) {
|
||||
const result=(event as CustomEvent<{ok:boolean;error?:string;reloading?:boolean}>).detail;
|
||||
if(!result?.reloading) setPending(false);
|
||||
if(result?.error) failure(new Error(result.error));
|
||||
// The native launcher renews login after service changes. No immediate
|
||||
// request with the expired cookie is made here.
|
||||
}
|
||||
const sessionReady=()=>setPending(false);
|
||||
window.addEventListener("mission-core-session-ready",sessionReady);
|
||||
window.addEventListener("mission-core-environment-progress",progress);
|
||||
window.addEventListener("mission-core-environment-result",complete);
|
||||
return()=>{window.removeEventListener("mission-core-session-ready",sessionReady);window.removeEventListener("mission-core-environment-progress",progress);window.removeEventListener("mission-core-environment-result",complete);};
|
||||
},[failure]);
|
||||
useEffect(()=>{if(authorized&&!pending) void refresh();},[authorized,pending,refresh]);
|
||||
useEffect(()=>{
|
||||
if(!authorized||pending||value?.run?.state!=="running") return;
|
||||
const timer=setTimeout(()=>void refresh(),2000);return()=>clearTimeout(timer);
|
||||
},[authorized,pending,value,refresh]);
|
||||
function start() {
|
||||
if(pending||value?.run?.state==="running") return;
|
||||
if(!desktopAction("configure-system")) {failure(new Error("Откройте установленное приложение Node для настройки окружения."));return;}
|
||||
setValue(current=>current?{...current,run:null}:current);
|
||||
setPending(true);
|
||||
}
|
||||
return {value,pending,loading,refresh,start,running:pending||value?.run?.state==="running"};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { type ToastItem } from "@nodedc/ui-react";
|
||||
import { APIError, loginFromLaunch, request, type Status } from "./api";
|
||||
|
||||
export function useNode() {
|
||||
const [value, setValue] = useState<Status | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const failure = useCallback((error: unknown) => {
|
||||
if (error instanceof APIError && error.status === 401) { setValue(null); setLocked(true); }
|
||||
setToasts([{ id: "request", tone: "error", title: error instanceof Error ? error.message : "Нода недоступна", durationMs: null }]);
|
||||
}, []);
|
||||
const refresh = useCallback(async () => {
|
||||
setPending(true);
|
||||
try { const result = await request<Status>("/api/status"); setValue(result); setLocked(false); setToasts([]); }
|
||||
catch (error) { failure(error); }
|
||||
finally { setPending(false); }
|
||||
}, [failure]);
|
||||
useEffect(() => {
|
||||
const launch = () => { void loginFromLaunch().then(refresh).then(()=>window.dispatchEvent(new Event("mission-core-session-ready"))).catch(error => { failure(error); setPending(false); }); };
|
||||
launch(); window.addEventListener("hashchange", launch);
|
||||
return () => window.removeEventListener("hashchange", launch);
|
||||
}, [refresh, failure]);
|
||||
const success = useCallback((title: string) => setToasts([{ id: "request", tone: "success", title }]), []);
|
||||
return { value, pending, locked, refresh, failure, success, toasts,
|
||||
dismiss: (id: string) => setToasts(items => items.filter(item => item.id !== id)) };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { APIError, request } from "./api";
|
||||
|
||||
export interface NetworkStatus { installed: boolean; state: string; online: boolean; addresses: string[] }
|
||||
const states: Record<string, string> = {
|
||||
not_installed: "Не установлен", unavailable: "Служба недоступна", NeedsLogin: "Требуется вход",
|
||||
NeedsMachineAuth: "Ожидаем разрешения администратора сети", Stopped: "Отключён", Starting: "Подключаемся", NoState: "Запускается",
|
||||
};
|
||||
export function tailscaleLabel(value: NetworkStatus | null, checked: boolean): string {
|
||||
return !checked ? "Проверяем подключение" : !value ? "Состояние недоступно" : value.state === "Running" ? value.online ? "В сети" : "Нет связи с координатором" : states[value.state] ?? "Состояние неизвестно";
|
||||
}
|
||||
|
||||
// Shared by the overview and provider page so session expiry and network states
|
||||
// retain the same meaning. Only the mounted view owns a polling loop.
|
||||
export function useTailscaleStatus(failure: (error: unknown) => void, hostRevision: string, revision = 0) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [value, setValue] = useState<NetworkStatus | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function update() {
|
||||
try {
|
||||
const next = await request<NetworkStatus>("/api/network/tailscale");
|
||||
if (active) setValue(next);
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setValue(null);
|
||||
if (error instanceof APIError && error.status === 401) failure(error);
|
||||
}
|
||||
} finally { if (active) { setChecked(true); timer = setTimeout(() => void update(), 5000); } }
|
||||
}
|
||||
void update();
|
||||
return () => { active = false; clearTimeout(timer); };
|
||||
}, [revision, hostRevision, failure]);
|
||||
return { value, checked };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
|
||||
test("Node UI uses canonical controls and never exposes vendor commands", () => {
|
||||
for (const file of readdirSync(new URL("../src/", import.meta.url)).filter(name => name.endsWith(".tsx"))) {
|
||||
const source = readFileSync(new URL(`../src/${file}`, import.meta.url), "utf8");
|
||||
assert.doesNotMatch(source, /<(button|input|select|textarea)\b/, file);
|
||||
assert.doesNotMatch(source, /mqtt|Quick Connect|BLE_UUID|openapi_key|192\.168\.68\.50|dcsudo/, file);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"react": [
|
||||
"node_modules/@types/react/index.d.ts"
|
||||
],
|
||||
"react/jsx-runtime": [
|
||||
"node_modules/@types/react/jsx-runtime.d.ts"
|
||||
],
|
||||
"@nodedc/ui-react": [
|
||||
"node_modules/@nodedc/ui-react/dist/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
// Shared sensor TSX lives outside this app tsconfig; use the same JSX runtime.
|
||||
esbuild: { jsx: "automatic" },
|
||||
// Design Guideline packages are linked during development. Their own React
|
||||
// must never become a second hook dispatcher in the portable production bundle.
|
||||
resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] },
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed dist
|
||||
var Assets embed.FS
|
||||
@@ -0,0 +1,300 @@
|
||||
# Mission Core Node: первый запуск борта
|
||||
|
||||
Основание: MISSIONCOR-76 и согласованные комментарии UI-FIRST / BRIDGE-ONLY.
|
||||
Документ описывает реализацию первого приращения; тело архитектурной карточки
|
||||
остаётся исходной точкой и не изменяется этим документом.
|
||||
|
||||
Ниже сохранена история bootstrap 0.1–0.2.3. Текущая композиция, результаты
|
||||
проверки 0.3.0 и актуальные ограничения описаны в
|
||||
[02_NODE_DESKTOP_SURFACE.md](02_NODE_DESKTOP_SURFACE.md).
|
||||
|
||||
## Пользовательская задача и размещение
|
||||
|
||||
Оператор устанавливает Node на Ubuntu и открывает приложение, чтобы убедиться,
|
||||
что служба запущена, назвать борт и проверить фактические сетевые/USB подключения.
|
||||
Первичная сущность — этот бортовой компьютер; его ID переживает смену имени,
|
||||
IP и перезагрузку. Сетевой интерфейс и обнаруженный USB не означают готовность
|
||||
сенсора к съёмке. Обновление списка ничего не отправляет устройствам.
|
||||
|
||||
Выбрана самостоятельная локальная поверхность Node, уже запрошенная владельцем:
|
||||
ApplicationShell с предметным обзором в stage. Альтернатива — только инспектор
|
||||
в разделе «Парк» Control Station — не решает первый запуск без сопряжённого Core.
|
||||
Навигация Core и его существующие рабочие пространства не меняются. Это доменная
|
||||
композиция из существующих ApplicationShell, AppHeader, HeaderWorkspace,
|
||||
GlassSurface, TextField, Button, StatusBadge, ActivityIndicator и ToastStack;
|
||||
новые общие компоненты или визуальные примитивы не вводятся. Лабораторная
|
||||
композиция не используется. Отдельных окон/expanded modes у этого обзора нет.
|
||||
|
||||
Действия: запуск через меню ОС, подтверждение штатного диалога администратора,
|
||||
чтение состояния, переименование с сохранением, обновление, выгрузка очищенного
|
||||
отчёта, ввод и отзыв публичного SSH-ключа для локального администратора.
|
||||
Состояния: ожидание, доступ разрешён, сеанс отсутствует/истёк, служба
|
||||
недоступна, частично недоступная инвентаризация, сохранение, ошибка записи.
|
||||
Ошибки запросов принадлежат ToastStack; фиктивной готовности и кнопок будущих
|
||||
подключений нет. Обновление не показывает устаревшие данные как новые.
|
||||
|
||||
## Граница первого пакета
|
||||
|
||||
`apps/node-agent` — отдельное собираемое приложение внутри текущего монорепозитория.
|
||||
Go-служба работает от непривилегированного системного пользователя. systemd
|
||||
запускает её после перезагрузки; состояние в `/var/lib/mission-core-node`.
|
||||
Установленное приложение открывается в собственном окне GTK со встроенным
|
||||
WebKit, без внешней вкладки браузера и адресной строки. Общие React-компоненты
|
||||
дизайн-системы отображаются внутри этого окна. Внутренний UI-служебный канал
|
||||
доступен только через `127.0.0.1:8780` на борту. Это локальный UI
|
||||
Node, а не второй экземпляр Control Station на 8000 и не endpoint сопряжения.
|
||||
Адреса удалённых Node не принимаются этим локальным API.
|
||||
|
||||
Установка — `.deb` через графический установщик пакетов Ubuntu. При сборке
|
||||
в бинарник включены JS/CSS, включая зависимости дизайн-системы; на борту не
|
||||
нужны Node.js, npm, Go, исходники Core или соседний checkout дизайн-системы.
|
||||
Системный установщик разрешает зависимости и запрашивает права через штатный UI.
|
||||
По уточнению владельца «Конфигурация системы» включает установку OpenSSH Server
|
||||
как обязательной зависимости, проверку `sshd -t`, включение `ssh.service` и
|
||||
автозапуска. Интерфейс проверяет ответ SSH-2.0 на loopback: это проверка процесса,
|
||||
не доказательство входа с другого компьютера. Публичные ключи Ed25519 вводятся
|
||||
через TextAreaField, пользователь выбирается через Select, отзыв подтверждается
|
||||
ConfirmationModal. Эти существующие общие компоненты дополняют список выше.
|
||||
|
||||
Ключи Node дополняют обычные Ubuntu authorized_keys через фиксированный,
|
||||
принадлежащий root AuthorizedKeysCommand. Он читает только реестр публичных
|
||||
ключей; отдельные процессы запускаются от пользователя службы, не root.
|
||||
Регистрация ограничена существующими локальными пользователями группы sudo,
|
||||
root исключён. Строки authorized_keys с command/options и приватные ключи не
|
||||
принимаются. Выданные строки ограничены частными LAN/Tailscale адресами через
|
||||
`from=`. Отзыв в Node не закрывает существующие SSH-сессии и не удаляет отдельные
|
||||
ключи/методы входа Ubuntu. SSH не становится транспортом управления сенсорами.
|
||||
Владелец открыл файл `.deb` версии 0.1.1, установил его через GUI и нашёл Node
|
||||
в меню приложений. На Mini подтверждены статус installed, запуск окна из
|
||||
`/usr/bin/mission-core-node`, активные и включённые службы Node/SSH. Это ещё не
|
||||
полная приёмка на чистой Ubuntu: SSH ранее подготовлен для инженерного доступа,
|
||||
проверка перезагрузки и отзыва отдельного SSH-ключа через Node ещё не пройдена.
|
||||
|
||||
Ярлык запускает непривилегированное окно приложения. Оно вызывает фиксированный
|
||||
polkit helper, который только запрашивает одноразовый минутный допуск у службы
|
||||
по Unix-сокету с правами 0600. URL с допуском загружается внутри окна приложения.
|
||||
WebKit использует временный профиль; переходы на другие адреса и дополнительные
|
||||
окна запрещены. Сохранение отчёта открывает системный диалог выбора файла.
|
||||
Закрытие окна не завершает службу борта. Токен передаётся во fragment и
|
||||
удаляется из истории до запроса. API обменивает его один раз на HttpOnly,
|
||||
SameSite=Strict cookie на 8 часов. После перезапуска сеансы прекращаются.
|
||||
Host/Origin/fetch-metadata проверки препятствуют управлению через чужой сайт.
|
||||
Никаких ручных ключей, паролей в конфигурации или SSH для оператора.
|
||||
|
||||
Ed25519 identity создаётся при первом запуске и хранится с правами 0600. При
|
||||
повреждённой identity запуск останавливается, другая identity молча не создаётся.
|
||||
ID — полный SHA-256 публичного ключа. Это идентичность Node, не новый контракт
|
||||
идентичности устройств: Plugin SDK v0alpha2 остаётся источником device contract.
|
||||
Настройка имени не меняет ключ. Экспорт отчёта не содержит ключей, сессий, ID ноды,
|
||||
hostname и сетевых адресов. Инвентаризация не читает USB serial.
|
||||
|
||||
## Что остаётся реализовать по базовой карточке
|
||||
|
||||
Этот пакет не является завершённым Node v1. Отдельными вертикальными сценариями
|
||||
идут сопряжение Core с одноразовым приглашением и mTLS, Plugin SDK transport, D455, Linux K1 Bridge, управление
|
||||
секретами устройств через UI, запись/импорт, WebRTC и восстановление.
|
||||
Не добавлять их как пустые маршруты, готовые галочки или mock-устройства.
|
||||
K1 Quick остаётся в прежнем лабораторном Mac-пути, не в Node.
|
||||
K1 подключается только по Wi-Fi в режиме Bridge к общей локальной сети;
|
||||
проводное подключение K1 не входит в план. D455 подключается к борту по USB.
|
||||
|
||||
## Инженерный доступ к текущему Mini
|
||||
|
||||
Владелец разрешил ручной SSH bootstrap для текущей разработки и размещение
|
||||
полного репозитория в `Загрузки/NDC/MISSION_CORE` на Mini. Этот путь относится
|
||||
к рабочему checkout, а не к обязательным путям устанавливаемого продукта.
|
||||
Передача исходников и пакетов идёт по SSH/SFTP. Приватный ключ оператора остаётся
|
||||
на его компьютере. Доступ обычного пользователя по SSH и административное
|
||||
повышение прав проверяются отдельно: наличие группы sudo не означает, что
|
||||
`sudo` доступен без подтверждения. Инженерный launcher может использовать
|
||||
приватный сокет пользовательской службы; это не проверка установки с нуля.
|
||||
|
||||
Первый вход установил фактические Ubuntu 24.04.4 LTS amd64 и ядро
|
||||
7.0.0-31-generic. Совместимость D455 следует проверять на этом ядре, не выводить
|
||||
её из номера LTS. На Mini подтверждена D455 (8086:0b5c), подключение 5000 Мбит/с,
|
||||
интерфейсы uvcvideo и usbhid. Владелец подтвердил отдельное окно приложения и
|
||||
RealSense в его списке USB. Это приёмка отображения инвентаризации, не SDK/потоков.
|
||||
После установки ярлык `org.nodedc.MissionCoreNode.desktop` совпадает с GTK
|
||||
application ID для корректной регистрации окна в меню и панели Ubuntu.
|
||||
Инженерный запуск из checkout не считается системной установкой приложения.
|
||||
|
||||
## Приращение 0.2.0: частная сеть и значок приложения
|
||||
|
||||
Владелец запросил чистый фирменный знак для значка Ubuntu. Пакет потребляет
|
||||
`nodedc-mark.svg` из закреплённого Design Guideline; значок, desktop
|
||||
entry и GTK application ID согласованы. Новая визуальная сущность не вводится.
|
||||
|
||||
В существующий обзор добавлена предметная секция «Удалённый доступ · Tailscale»
|
||||
из GlassSurface, StatusBadge, Button и ActivityIndicator; ошибки операций
|
||||
передаются в общий ToastStack. Новый экран или навигационный раздел не создаётся.
|
||||
Оператор устанавливает компонент, подтверждает системное действие Ubuntu и
|
||||
входит в свою сеть на странице провайдера в браузере. Состояние возвращается
|
||||
в окно Node автоматически. Вход в Tailscale не означает сопряжения с Core.
|
||||
|
||||
Служба Node имеет только GET адаптер состояния Tailscale: без peer inventory,
|
||||
учётных записей, AuthURL, ключей и необработанных сообщений провайдера. Изменения
|
||||
выполняются двумя фиксированными root-owned polkit helpers, вызываемыми только
|
||||
нативным мостом окна: установить и подключить. Команды, пути, сетевые параметры
|
||||
и секреты из JavaScript не принимаются. Root helper запускает Python в isolated
|
||||
mode, использует фиксированные команды и отдельную блокировку операций в
|
||||
root-owned каталоге. Установка APT не прерывается закрытием окна посреди транзакции.
|
||||
|
||||
Для нового борта загружается официальный Tailscale 1.102.3 amd64 с закреплённой
|
||||
SHA-256; несовпадение блокирует запуск APT. Пакет устанавливается без удаления
|
||||
других пакетов, служба включается. Существующий установленный Tailscale не
|
||||
переустанавливается; новый APT-репозиторий не добавляется. Первое подключение
|
||||
не принимает DNS и subnet routes из tailnet; выход через exit node, публикация
|
||||
подсетей, Tailscale SSH и принудительная повторная авторизация не включаются.
|
||||
Остановленная существующая сеть возобновляется без флагов изменения настроек.
|
||||
Нестандартная конфликтующая конфигурация не сбрасывается автоматически.
|
||||
|
||||
Допущен только URL входа HTTPS на login.tailscale.com с формой /a/…; он живёт
|
||||
в памяти нативного процесса и открывается обычным браузером пользователя.
|
||||
Node не собирает пароль провайдера и не журналирует его URL входа. API состояния
|
||||
показывает только локальные адреса, состояние провайдера и Online. При закрытии
|
||||
или удалении Node отдельная служба Tailscale сохраняется. Замена провайдера
|
||||
сети не должна менять Node identity, Plugin SDK или локальную жизнь устройств.
|
||||
|
||||
При обновлении 0.1.1 → 0.2.0 требуется повторное открытие окна. Возможность
|
||||
сетевой настройки объявляется нативной оболочкой; старое окно после обновления
|
||||
показывает понятную подсказку вместо неподдерживаемых активных кнопок.
|
||||
|
||||
Приёмка этого приращения пока ожидается на Mini: установить новый файл .deb
|
||||
через GUI, запустить из меню и проверить логотип, выполнить установку
|
||||
Tailscale кнопкой, пройти вход в браузере и увидеть фактическое «В сети» в Node.
|
||||
Отдельно проверить отмену OS-auth, повторное открытие окна и сохранность доступа
|
||||
к прежней LAN. Дальше — перезагрузка и UI-сценарий SSH enrollment/revoke.
|
||||
|
||||
## Выявленные ограничения установки 0.2.0 (2026-09-05)
|
||||
|
||||
Владелец проверил обновление поверх 0.1.1: установленный на Mini App Center
|
||||
revision 1270 показал «установлено» и не предложил обновление локальным файлом.
|
||||
Предыдущая рекомендация просто открыть новый .deb для обновления не прошла
|
||||
пользовательскую проверку. Этот путь нельзя считать готовым; требуется отдельно
|
||||
довести и принять обновление через интерфейс без ручного удаления приложения.
|
||||
|
||||
После успешного удаления через Synaptic повторная установка 0.2.0 останавливалась
|
||||
до распаковки. В журнале App Center зафиксирован PackageKit.cannotGetLock:
|
||||
`/var/lib/dpkg/lock-frontend` занят оставшимся открытым Synaptic. Каждая попытка
|
||||
завершалась отказом примерно через 10 секунд, интерфейс возвращал «Установить».
|
||||
Проверка хеша файла успешна; APT simulation планирует только установку Node 0.2.0,
|
||||
без удаления других пакетов. Это диагностика, не доказательство установки.
|
||||
|
||||
Временная процедура повторного прогона: выйти из Synaptic через «Файл → Выход»,
|
||||
повторить «Установить» в App Center, затем проверить пакет, службу и запуск окна.
|
||||
Не удалять lock-файлы и не прерывать выполняющуюся пакетную транзакцию.
|
||||
В требования пользовательской приёмки входят понятное сообщение о занятом
|
||||
менеджере пакетов, повтор после освобождения блокировки, обновление и удаление
|
||||
через UI. Удаление Node сохраняет identity; переустановка не равна чистой Ubuntu.
|
||||
|
||||
Повтор после закрытия Synaptic успешен: владелец подтвердил установку,
|
||||
PackageKit завершил install-files успешно, dpkg сообщает install ok installed
|
||||
0.2.0, служба Node active. Пакет не требовал пересборки для устранения блокировки.
|
||||
|
||||
## Исправление значка 0.2.1
|
||||
|
||||
Владелец сообщил об искажённых пропорциях иконки после установки 0.2.0.
|
||||
Штатный Gtk.IconTheme на Mini загружал исходный SVG как прямоугольный pixbuf
|
||||
64×38 даже с FORCE_SIZE. В 0.2.1 build_deb.py оборачивает неизменённый брендовый
|
||||
SVG квадратным прозрачным SVG 256×256 с сохранением пропорций. SHA-256 исходного
|
||||
знака по-прежнему проверяется; его геометрия и цвет не меняются.
|
||||
|
||||
Проверка тем же GTK на Mini даёт квадратные pixbuf 32, 48, 64, 128 и 256 пикселей;
|
||||
видимая область на 256×256 — 256×150, что соответствует исходному отношению
|
||||
93.22:54.55 с округлением растеризации. PNG 256×256 визуально проверен.
|
||||
Встроенный UI переиспользуется из проверенной сборки 0.2.0. Бинарник Go собирается
|
||||
с номером текущего пакета через linker flag, чтобы версия в интерфейсе не отставала.
|
||||
Обновление через системное окно авторизации в инженерном
|
||||
сеансе не заменяет ещё не принятую установку/обновление одним файлом для оператора.
|
||||
|
||||
## Исправление транспорта Tailscale 0.2.2
|
||||
|
||||
Две авторизации через браузер регистрировали Mini на стороне Tailscale, но
|
||||
локальная служба оставалась NeedsLogin без адресов и сохранённого профиля.
|
||||
После успешной регистрации machineAuthorized=true следовали повторные тайм-ауты
|
||||
PollNetMap. TCP 80 показывал 1713 байт в Send-Q, повторные передачи и отсутствие
|
||||
подтверждений. Это фактическое незавершённое подключение, а не устаревший статус
|
||||
Node. UDP/IPv4 netcheck успешен. Причина потери пакетов внутри внешней сети
|
||||
не установлена; утверждать конкретного провайдера/фильтр по этим данным нельзя.
|
||||
|
||||
Встроенная диагностика Tailscale debug ts2021 с TS_FORCE_NOISE_443=true успешно
|
||||
прошла TLS, Noise handshake и whoami через TCP 443. Возможность проверена по
|
||||
исходникам установленного Tailscale v1.102.3 control/controlhttp/client.go.
|
||||
|
||||
0.2.2 объединяет квадратный значок и применение HTTPS-транспорта провайдера
|
||||
через /etc/systemd/system/tailscaled.service.d/60-mission-core-node-https.conf.
|
||||
Новая установка компонента применяет настройку перед входом. Явное действие
|
||||
подключения восстанавливает ещё не подключённую службу с этим транспортом;
|
||||
уже работающая или ожидающая одобрения служба не перенастраивается. Реальное
|
||||
окружение daemon проверяется только на нужный несекретный флаг, без вывода
|
||||
окружения. Чужой файл/симлинк/небезопасные права приводят к отказу с сохранением
|
||||
существующих настроек. Удаление Node сохраняет самостоятельную службу Tailscale
|
||||
и её транспорт; ключи, профили, DNS, exit node и маршруты helper не редактирует.
|
||||
|
||||
8 Python-проверок прошли, включая сохранение работающего провайдера,
|
||||
восстановление без нового входа при наличии профиля и отказ при конфликте
|
||||
эффективных настроек. Владелец затем подтвердил реальный вход через UI:
|
||||
«В сети» и адрес Tailscale. Независимый status на Mini — Running, Online=true,
|
||||
адреса назначены; TSMP ping до компьютера оператора идёт напрямую по LAN за 5 мс.
|
||||
Это ещё не приёмка TCP/SSH через tailnet: отдельная попытка SSH не завершилась.
|
||||
Промежуточный пакет 0.2.1 со значком заменён этим кандидатом до установки.
|
||||
|
||||
## Восстановление UI-сеанса 0.2.3 и настройка владельца
|
||||
|
||||
При обновлении службы ранее открытое окно сохраняло старый сеанс. Polling в
|
||||
TailnetAccess поглощал HTTP 401 как общее «Состояние недоступно», хотя причина
|
||||
относилась к доступу в Node. Теперь 401 передаётся существующему обработчику
|
||||
приложения: сведения убираются, показывается штатная поверхность входа.
|
||||
Это не изменение сетевого статуса Tailscale и не обход авторизации.
|
||||
|
||||
Проверка через интерфейс: временный Node 0.2.3-qa на Mac, вход, перезапуск только
|
||||
тестовой службы с сохранением identity и новым набором сеансов, автоматическое
|
||||
появление поверхности входа на очередном polling. Тестовый процесс и вкладка
|
||||
закрыты. TypeScript, Node UI boundary, 4 архитектурные проверки и production build
|
||||
успешны. Пакет 0.2.3 передан на Mini; его подтверждение после обновления и
|
||||
переоткрытия окна фиксируется отдельно от лабораторной проверки.
|
||||
|
||||
Владелец отдельно и явно выбрал отключение запросов административного пароля
|
||||
для своего активного локального сеанса во всей Ubuntu. Подготовлено персональное
|
||||
правило Polkit: конкретный пользователь владельца, local=true, active=true,
|
||||
членство sudo, результат YES. Оно относится к графическим действиям через Polkit;
|
||||
sudoers, SSH-аутентификация, вход в ОС и блокировка экрана не изменяются.
|
||||
Это персональная настройка текущего Mini, не значение по умолчанию пакета Node
|
||||
для остальных операторов. Правило расширяет права приложений локального сеанса;
|
||||
владелец выбрал этот режим после уточнения области действия.
|
||||
|
||||
Сценарий применения находится в Загрузки/NDC/.setup/mc-node-owner-setup.py на Mini:
|
||||
проверяет SHA-256 конкретного .deb, обновляет Node до 0.2.3, проверяет установленную
|
||||
версию и только после этого атомарно публикует root-owned правило Polkit.
|
||||
Чужой файл в целевом пути сохраняется с отказом. После ввода пароля в системном
|
||||
окне и переоткрытия Node владелец 2026-09-05 явно подтвердил через интерфейс:
|
||||
«0.2.3, в сети, без пароля». Это подтверждает версию окна, статус Tailscale и
|
||||
открытие Node без повторного запроса пароля. Все остальные административные
|
||||
действия Ubuntu этим сценарием не проверены. Независимая окончательная проверка
|
||||
установленного пакета и файла правила ещё не выполнена: SSH к Mini обрывается
|
||||
до авторизации. Восстановление инженерного SSH остаётся открытым пунктом.
|
||||
|
||||
## Приёмка с нуля
|
||||
|
||||
Инженерные тесты проверяют защиту API, сохранность identity и очистку отчёта.
|
||||
Они не заменяют следующую пользовательскую приёмку на реальной Ubuntu:
|
||||
|
||||
1. На чистой согласованной Ubuntu без подготовленных SSH/ключей открыть пакет.
|
||||
2. Установить через GUI и найти Mission Core Node в меню приложений.
|
||||
3. Открыть, подтвердить системный запрос, получить фактические данные именно Mini.
|
||||
4. Изменить название; закрыть окно приложения, открыть снова, проверить сохранение и ID.
|
||||
5. Перезагрузить Mini; повторить запуск, проверить автозапуск и неизменный ID.
|
||||
6. Подключить/отключить D455 через USB и обновить список; не выдавать enumeration
|
||||
за рабочий поток и не считать USB 2 достаточным для D455.
|
||||
7. Сохранить и проверить очищенный отчёт через системный диалог; проверить
|
||||
закрытие/повторное открытие окна, повторную авторизацию и истечение сеанса.
|
||||
Отдельно проверить отсутствие доступа к внутреннему адресу через браузер
|
||||
без авторизации и невозможность входа повторным URL.
|
||||
8. Проверить клавиатуру, отмену системного запроса, ошибку службы и повторный запуск.
|
||||
9. Проверить SSH на чистой Ubuntu: установка зависимости, перезагрузка, живой
|
||||
ответ, ввод публичного ключа через UI, реальный вход с частного адреса,
|
||||
отзыв через UI и отказ в новом соединении. Отдельно проверить несовместимую
|
||||
существующую конфигурацию sshd: не подменять её молча и не заявлять доступ
|
||||
только на основании сохранённого ключа.
|
||||
|
||||
До выполнения этих пунктов на Mini первый пакет остаётся кандидатом для проверки.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Node 0.3 — структура настольного приложения
|
||||
|
||||
История композиции 0.3.0. Последующее уточнение владельца, перенос системных
|
||||
функций в 0.3.1 и план сопряжения: [03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md](03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md).
|
||||
|
||||
Решение владельца от 2026-09-05: привести доступные функции Node к визуальной
|
||||
системе действующего Mission Core, повторно использовать шапку, навигацию и
|
||||
длинные строки AI Inference. Это согласование состава вкладок и композиции.
|
||||
Базовые архитектурные требования карточки MISSIONCOR-76 не изменяются.
|
||||
|
||||
## Задача оператора
|
||||
|
||||
На бортовом компьютере проверить систему и оборудование, подключить частную
|
||||
сеть и разрешить обслуживание с нескольких доверенных компьютеров. Сценарий
|
||||
начинается с запуска установленного приложения, используется при первичной
|
||||
настройке и обслуживании. Объекты: этот борт, его интерфейсы/USB и список
|
||||
разрешённых публичных SSH-ключей. Управление съёмкой не добавляется.
|
||||
|
||||
## Размещение
|
||||
|
||||
Выбрана предложенная владельцем структура:
|
||||
|
||||
- Состояние системы: обзор компьютера, сеть, конфигурация системы, диагностика.
|
||||
- Устройства: существующее обнаружение USB.
|
||||
- Удалённый контроль: Tailscale и SSH / доверенные устройства.
|
||||
|
||||
Верхние вкладки — HeaderNavigation. Подразделы — AdminNavigationPanel.
|
||||
Контент — ApplicationPanel в ApplicationShell. Открытие, закрытие, разворот
|
||||
и узкий экран принадлежат useApplicationWorkspace. Общая сетка всех функций
|
||||
отклонена: смешивала инвентаризацию, настройку транспорта и выдачу доступа.
|
||||
Лабораторный отчёт как шаблон Node также отклонён: здесь изменяемые объекты и
|
||||
действия оператора. Из LAB используется только уже существующий список.
|
||||
VPN-профили не появляются пустым пунктом: их провайдер пока не реализован.
|
||||
|
||||
## Повторное использование
|
||||
|
||||
Node уже использовал React и пакеты Design Guideline. Теперь подключён полный
|
||||
канонический shell без конвертации изображения или копии CSS шапки. GTK/WebKit
|
||||
остаётся установленным настольным контейнером; React-сборка входит в .deb.
|
||||
Сервер слушает только loopback. Браузерная QA не заменяет настольную приёмку.
|
||||
|
||||
Компоненты: AppHeader, HeaderWorkspace, HeaderNavigation, HeaderProfile,
|
||||
UserProfileMenu, ApplicationShell, ApplicationPanel, AdminNavigationPanel,
|
||||
SettingsCard, Icon/IconButton/Button, Window/ConfirmationModal, Select,
|
||||
TextField/TextAreaField, ActivityIndicator, StatusBadge, ToastStack.
|
||||
Семантические иконки взяты из registry/icons.json.
|
||||
|
||||
ResourceRow/ResourceList сначала выделены в Design Guideline из существующей
|
||||
геометрии observatory-evidence-card. Добавлены export, registry, документация
|
||||
и пример каталога. Состояния и действия остаются у потребителя. Старые строки
|
||||
AI Inference не меняются этим приращением; их миграция на общий export —
|
||||
отдельное изменение Core. Точные исходники и dist DG хэшируются в provenance,
|
||||
поскольку локальный общий компонент ещё не является опубликованным релизом.
|
||||
|
||||
## Действия и состояния
|
||||
|
||||
SSH: плюс в шапке открывает добавление, выбор существующего администратора,
|
||||
название устройства и вставка публичного Ed25519. Разрешено несколько ключей;
|
||||
приватный ключ остаётся на компьютере оператора. Подробности показывают полный
|
||||
отпечаток и публичный ключ. Отзыв требует ConfirmationModal и запрещает только
|
||||
новые подключения через Node. Статус ключа не означает online компьютера.
|
||||
Локальный ответ sshd не означает успешный удалённый вход.
|
||||
|
||||
Обзор сохраняет имя и показывает системные сведения с временем получения.
|
||||
USB показывает обнаружение, без обещания готовности к съёмке. Tailscale
|
||||
сохраняет действующий поллинг и обработку истёкшего сеанса. Все списки имеют
|
||||
loading, empty, unavailable и recovery через обновление; ошибки — ToastStack.
|
||||
Добавление и удаление блокируются на время запроса. Нет консольных команд и
|
||||
отладочных механизмов в операторском интерфейсе.
|
||||
|
||||
## Приёмка
|
||||
|
||||
Проверить собранный React-интерфейс через UI: каждую вкладку и подраздел,
|
||||
разворот/возврат, добавление/подробности/отмену/отзыв SSH на изолированной QA-ноде,
|
||||
rename, ошибки и повторное открытие, Tailscale online/expired session.
|
||||
Проверить темы и узкий экран; затем новый .deb на Mini, сохранение identity,
|
||||
списка доверенных ключей и текущего входа Tailscale. Реальный удалённый SSH и
|
||||
чистая Ubuntu — самостоятельные пункты приёмки.
|
||||
|
||||
## Результат проверки 2026-09-05
|
||||
|
||||
Пройдены UI-сценарии на изолированной QA-ноде с настоящим API Node и отдельным
|
||||
хранилищем: обзор/сеть/конфигурация/диагностика/USB/Tailscale/SSH, сохранение имени,
|
||||
неверный ключ с сохранением draft, добавление синтетического публичного ключа,
|
||||
полные сведения, Escape с возвратом фокуса, отмена и подтверждение отзыва.
|
||||
Успешная операция заменяет предыдущее сообщение об ошибке. Проверены dark/light,
|
||||
узкое окно 720×800, сворачивание и разворот. После рестарта тестовой службы
|
||||
поллинг Tailscale переводит приложение на вход; ошибочный сетевой статус не
|
||||
подставляется. Первый запрос Tailscale теперь имеет отдельное состояние загрузки.
|
||||
|
||||
При QA исправлены два общих дефекта Design Guideline: на узком экране скрывается
|
||||
expand по типу действия, а не первая кнопка шапки; одноцветные светлые знаки
|
||||
могут явно включить контраст для light theme. Цветные изображения по умолчанию
|
||||
не меняются. Геометрия ResourceRow проверена в обоих режимах.
|
||||
|
||||
Typecheck всех пакетов DG и каталога, registry validation, production build
|
||||
каталога и Node, 4 архитектурных теста Core и Node UI boundary прошли.
|
||||
Готовый .deb установлен на Mini до 0.3.0. После обновления независимо проверены
|
||||
работающий Tailscale, D455 USB3 и запуск нового настольного процесса. Пользовательская
|
||||
визуальная приёмка именно окна Ubuntu запрошена отдельно. Через временный
|
||||
loopback SSH-туннель дополнительно проверен UI именно установленного Mini:
|
||||
версия 0.3.0, реальные сведения о системе, Tailscale «В сети», SSH отвечает
|
||||
локально, реальная D455 со скоростью 5000 Мбит/с. Туннель и QA-процессы закрыты. Все синтетические
|
||||
SSH-ключи находятся только в QA-хранилище и не дают доступа к настоящему Mini.
|
||||
|
||||
Инженерный SSH к Mini снова работает по Tailscale с проверкой прежнего ключа
|
||||
хоста. Прямой LAN-путь не квалифицирован; причина прежнего обрыва не доказана.
|
||||
Чистая установка Ubuntu, перезагрузка борта, GUI updater/uninstaller и реальное
|
||||
зачисление/отзыв ключа оператора остаются отдельными пунктами общей приёмки.
|
||||
@@ -0,0 +1,318 @@
|
||||
# Система БК и добавление аппарата в Mission Core
|
||||
|
||||
Уточнение владельца от 2026-09-05 к MISSIONCOR-76. Базовое тело Ops не
|
||||
переписывается. Этот документ фиксирует согласованную композицию следующего
|
||||
приращения и порядок реализации; наличие описанного действия не означает,
|
||||
что его backend уже реализован. История 0.3.0 остаётся в документе 02.
|
||||
|
||||
## Задача и границы сущностей
|
||||
|
||||
Оператор настраивает бортовой компьютер (БК), видит фактическое состояние его
|
||||
системы и связывает его с аппаратом в Core. Это настройка при первом запуске
|
||||
и обслуживание, а не лабораторный отчёт. После сопряжения оператор открывает
|
||||
аппарат в парке и работает с его реально зарегистрированными устройствами.
|
||||
|
||||
БК — вычислительный корень подключений этого аппарата. USB-контроллер, hub,
|
||||
клавиатура и найденная камера относятся к системной инвентаризации. Запись USB
|
||||
не является регистрацией сенсора, доказательством его identity, набором
|
||||
capabilities или готовностью к съёмке. Устройства — объекты драйверов,
|
||||
подключённые и подтверждённые через принятый контракт Plugin SDK v0alpha2.
|
||||
|
||||
## Node: согласованное размещение
|
||||
|
||||
- Верхний раздел «Система» вместо «Состояние системы».
|
||||
- Первый пункт слева — «Настройка окружения»: кнопка «Сконфигурировать»
|
||||
запускает общий версионный профиль системных действий с проверяемыми этапами.
|
||||
- «Обзор БК»: ОС, архитектура, CPU/RAM, имя, сводка фактических подключений.
|
||||
Название редактируется в блоке «Название БК». Самоподтверждающий зелёный
|
||||
статус «Node работает» удаляется. Сведения имеют время получения.
|
||||
- «Сеть»: интерфейсы и адреса БК.
|
||||
- «USB-устройства»: вся системная USB-инвентаризация перенесена сюда.
|
||||
- «Tailscale» и «SSH · доверенные устройства» принадлежат «Системе».
|
||||
Отдельного верхнего «Удалённого контроля» больше нет.
|
||||
- «Mission Core»: выбранное место для создания приглашения и обслуживания
|
||||
привязки к Core. Подключение Core отделено от настройки сетевого транспорта.
|
||||
- «Диагностика»: существующий очищенный отчёт и замечания инвентаризации.
|
||||
Это ограниченная диагностика, не полный support bundle всех будущих модулей.
|
||||
- Прежняя пассивная «Конфигурация системы» заменена рабочей «Настройкой
|
||||
окружения» по последующему уточнению владельца. Состояния входят в обзор;
|
||||
полный сценарий подготовки доступен в первом пункте «Системы».
|
||||
- Верхние «Устройства» предназначены для драйверных K1, D455 и следующих
|
||||
сенсоров. Системный USB-список там не дублируется. В этом приращении
|
||||
вкладка недоступна до появления рабочего подключения через драйвер;
|
||||
пустой экран и фиктивные зарегистрированные устройства не создаются.
|
||||
|
||||
Отдельный раздел телеметрии и VPN-профили сейчас не вводятся: владелец оставил
|
||||
их дальнейшую композицию на следующий этап. Текущий обзор показывает
|
||||
измеренные сведения, не имитирует будущую телеметрию аппарата.
|
||||
|
||||
Выбрана системная композиция вместо трёх прежних верхних разделов: установка
|
||||
сети и доступ относятся к одному БК, а сенсоры имеют отдельный жизненный цикл.
|
||||
Альтернатива «всё внутри Сети» отклонена для сопряжения: частная достижимость
|
||||
и доверенная привязка к Core являются разными фактами. Альтернатива переноса
|
||||
всех операций в обзор также отклонена: обзор остаётся компактной сводкой и
|
||||
ведёт к соответствующей настройке. Это изменение композиции класса B,
|
||||
прямо согласованное владельцем в текущем сообщении, без нового общего контрола.
|
||||
|
||||
## Обзор БК: состояния и доказательства
|
||||
|
||||
Обзор сохраняет сведения об ОС, архитектуре, CPU/RAM и отдельную форму имени.
|
||||
Сводка содержит сеть (включённые интерфейсы с адресами, без loopback), число
|
||||
обнаруженных USB-устройств, фактическое состояние Tailscale, локальный ответ
|
||||
SSH и количество разрешённых ключей. Действия открывают нужный подраздел.
|
||||
Количество ключей не является числом online операторов. Ответ локального
|
||||
SSH не обещает удалённый вход. Наличие сетевого адреса не обещает связь с Core.
|
||||
|
||||
Во время запроса — загрузка, при ошибке — отсутствие актуальных сведений,
|
||||
а не зелёная готовность. Tailscale использует один общий hook статуса с той же
|
||||
проверкой истёкшего сеанса и polling, что и его страница. Старый сеанс
|
||||
возвращает на штатный вход Node. Диагностические сведения остаются snapshot
|
||||
с временем получения и ручным обновлением; новая телеметрия не объявляется.
|
||||
|
||||
## Core: Парк → Аппараты
|
||||
|
||||
Название «Аппараты» сохраняется. Текущий catalog в productModel.ts с текстами
|
||||
о готовности/контрактах заменяется настоящим реестром вместе с backend.
|
||||
«Состояние контура» не поглощает реестр. Прежнее «Подключение» K1 остаётся
|
||||
отдельным legacy-путём и не используется скрыто для устройств удалённого БК.
|
||||
|
||||
В шапке реестра плюс «Добавить аппарат». Канонический modal Window содержит
|
||||
выбор варианта, от которого зависит состав формы:
|
||||
|
||||
1. С бортовым компьютером — название аппарата и код приглашения, созданный
|
||||
в Node. Успешное подтверждение связывает аппарат с устойчивой identity БК.
|
||||
2. Прямое подключение / управление с пульта — регистрация через отдельный
|
||||
реально поддерживаемый адаптер и его поля. Приглашение Node для этого
|
||||
варианта не требуется; вымышленный БК не создаётся. Телеметрия и позиция
|
||||
появляются только если их предоставляет данный адаптер.
|
||||
|
||||
Упомянутые владельцем «автономный» и «с пульта» фиксируют разные сценарии
|
||||
подключения. Само наличие БК не доказывает автономное движение: аппарат с БК
|
||||
может управляться дистанционно. Наземный беспилотный аппарат (UGV), воздушный
|
||||
аппарат и другие классы описывают платформу; способ подключения и возможности
|
||||
автономности не подменяют этот класс. Первая реализуемая форма — «С бортовым
|
||||
компьютером»; неподдерживаемая прямая интеграция не выдаётся за рабочую.
|
||||
|
||||
После успешного сопряжения аппарат появляется длинной строкой ResourceRow.
|
||||
Глазик с подписью «Открыть аппарат» открывает постоянную конфигурацию выбранного
|
||||
аппарата в ApplicationPanel, а не длинную модальную форму. Детали показывают
|
||||
название/класс, привязанный БК, актуальность связи, состав устройств и доступные
|
||||
действия. При потере сети строка не исчезает: конфигурация сохранена,
|
||||
последние данные помечены временем, недоступные команды заблокированы.
|
||||
|
||||
Дерево первой интеграции: аппарат → БК → реально зарегистрированные D455/K1.
|
||||
Конкретные устройства отображаются по факту появления и регистрации, а не
|
||||
заранее как два обязательных online-объекта. Открытие сенсора ведёт в
|
||||
предметную конфигурацию и принятые слои данных, не выполняет команду съёмки.
|
||||
|
||||
## Сопряжение: обязательный полный сценарий P1
|
||||
|
||||
Node: Система → Mission Core → создать приглашение → скопировать код через UI.
|
||||
Показываются срок действия и возможность отменить приглашение. Уже привязанный
|
||||
БК показывает соответствующий Core и состояние связи; повторная привязка
|
||||
проходит явный сценарий. Секрет не сохраняется в истории URL, логах и отчёте.
|
||||
|
||||
Core: Парк → Аппараты → плюс → с БК → вставить код → подтвердить сведения
|
||||
выбранного БК → связать. Запись в реестре и подтверждение доверия должны
|
||||
согласованно переживать обрыв, повторный запрос, рестарт и отмену.
|
||||
|
||||
Сохраняются базовые одноразовость, expiry, pin публичной identity, проверка
|
||||
версии, ограничение окна сопряжения, один владелец и подтверждённая привязка.
|
||||
Ни IP/hostname, ни членство в Tailscale не являются identity или authority.
|
||||
Код не является адресом для произвольного сетевого запроса: private endpoint
|
||||
и перенаправления проверяются до соединения. После bootstrap Node использует
|
||||
исходящий аутентифицированный канал с mTLS; UI не передаёт команды через SSH.
|
||||
|
||||
Обязательны replay/expiry/conflict, отмена, partial binding recovery,
|
||||
revoke/re-pair, ротация сертификатов и запрет потери identity при ремонте.
|
||||
Жизненные циклы enrollment/connectivity/acquisition, revisions, operations,
|
||||
capabilities, streams и evidence используют семантику Plugin SDK v0alpha2;
|
||||
параллельная runtime-онтология для нового экрана не вводится.
|
||||
|
||||
## Устройства и authority
|
||||
|
||||
Управление K1 из Core идёт через runtime выбранного БК, его Linux BLE/network
|
||||
adapter и K1 Bridge в общей с БК локальной сети. Близость к операторскому Mac
|
||||
не нужна. Включённый K1 должен быть достижим для БК и подтвердить identity и
|
||||
совместимый профиль. Состояние «рядом и включён» само по себе не даёт права
|
||||
команды. Не происходит скрытого fallback в legacy runtime Mac.
|
||||
|
||||
Локальный драйвер владеет command session и сырой записью. Открытие глазика
|
||||
или reconnect не повторяют START/STOP. Unknown result сохраняется как
|
||||
неизвестный результат до разрешённого восстановления. Локальная запись
|
||||
продолжается независимо от окна, Core и WAN. Preview/WebRTC не заменяет raw.
|
||||
|
||||
## Порядок реализации и приёмка
|
||||
|
||||
1. Текущее приращение: перенос существующих страниц в «Систему», обзор БК,
|
||||
переименование полей, устранение self-status и единая настройка окружения.
|
||||
Сборка .deb и GUI-проверка на физическом Mini.
|
||||
2. Закрыть оставшиеся системные проверки P1 по предыдущему отчёту:
|
||||
чистая установка, GUI upgrade/repair, reboot и SSH lifecycle без консоли.
|
||||
3. Реализовать backend сопряжения и реестр Core; одновременно выпустить
|
||||
рабочие Node «Mission Core», форму добавления и детали аппарата. Проверить
|
||||
позитивные и негативные сценарии через два интерфейса.
|
||||
4. P2: драйвер D455, фактические profiles/options/streams, raw recording,
|
||||
live-слои и открываемая в Core запись. Затем P3: K1 Linux Bridge.
|
||||
5. Далее совместный P4 и полный P5 восстановления. Вариант прямого аппарата
|
||||
включается после появления конкретного адаптера и собственной приёмки.
|
||||
|
||||
Используемые exports: AppHeader/HeaderNavigation, ApplicationShell/Panel,
|
||||
AdminNavigationPanel, SettingsCard, ResourceRow/List, Button/IconButton, Icon,
|
||||
Select/TextField/TextAreaField, Window/ConfirmationModal, StatusBadge,
|
||||
ActivityIndicator, ToastStack. Новых визуальных примитивов и копий DG нет.
|
||||
|
||||
Для текущей приёмки: USB доступен из «Системы»; прежних remote/setup маршрутов
|
||||
нет (новая настройка — environment); имя БК сохраняется; сводка не объявляет подключение камеры по USB готовым
|
||||
драйвером; Tailscale/SSH открываются из сводки; плюс SSH работает после переноса;
|
||||
темы/узкое окно и recovery сеанса не нарушены. Для будущего сопряжения:
|
||||
добавленный аппарат сохраняется после reload/restart и открывает именно
|
||||
свой БК/устройства, без дубликатов и зависимости от legacy-коннектора.
|
||||
|
||||
## Проверка приращения 0.3.1
|
||||
|
||||
Через интерфейс изолированной QA-ноды с production API проверены сохранение
|
||||
нового имени БК и его отображение в shell; USB в «Системе»; переходы из сводки
|
||||
в USB/Tailscale; недоступность Tailscale и восстановление «В сети»; SSH и его
|
||||
плюс; открытие формы и Escape с возвратом фокуса. Проверены светлая/тёмная темы,
|
||||
сводка и её действия в окне 720×800. После рестарта QA-службы именно из обзора
|
||||
приложение возвращается на штатный вход, без ложного статуса Tailscale.
|
||||
Typecheck, production build и существующий UI boundary прошли. Это GUI-проверка
|
||||
перестановки функций, не закрытие приёмки чистой установки или сопряжения.
|
||||
Аппаратная установка и её результат фиксируются отдельно в отчёте Ops.
|
||||
|
||||
### Поправка 0.3.2 по результатам физической проверки
|
||||
|
||||
На Mini обнаружена ошибка прежнего системного профиля: `net.Interfaces()`
|
||||
не мог читать route netlink при `RestrictAddressFamilies=AF_UNIX AF_INET`.
|
||||
Приложение при этом показывало пустую сеть. В разрешённые семейства службы
|
||||
добавлен AF_NETLINK; служба остаётся непривилегированной с пустым
|
||||
CapabilityBoundingSet, без CAP_NET_ADMIN. Новый контракт отдельно сообщает
|
||||
`networks_readable` и `addresses_readable`. Ошибка чтения больше не означает
|
||||
ноль подключений или отсутствие назначенного адреса.
|
||||
|
||||
Go race tests проверяют ошибку чтения отдельно от пустой сети и неполное чтение
|
||||
адресов без вымышленных данных. Через UI изолированной ноды подтверждены
|
||||
«Нет сведений» в сводке, сообщение об ошибке на странице сети и появление
|
||||
интерфейса после восстановления и нажатия «Обновить сведения».
|
||||
|
||||
|
||||
## Уточнение владельца: единая настройка окружения (0.4.0)
|
||||
|
||||
Эта поправка заменяет раннее решение об удалении страницы конфигурации.
|
||||
Оператор получает чистую поддерживаемую систему и .deb. Пакет устанавливает
|
||||
минимум для открытия приложения и непривилегированной службы. После запуска
|
||||
первым слева открывается «Настройка окружения». Кнопка «Сконфигурировать»
|
||||
выполняет весь текущий системный профиль; ручные действия инженера по SSH
|
||||
не считаются продуктовой реализацией или приёмкой.
|
||||
|
||||
Единый источник этапов — `internal/node/environment-profile.json`. Он входит
|
||||
и в API/UI, и в установленный пакет. Текущие автоматические этапы:
|
||||
|
||||
1. Проверка совместимости системы и архитектуры.
|
||||
2. Установка недостающих OpenSSH Server и системных зависимостей.
|
||||
3. Настройка службы БК и автозапуска, включая разрешение чтения netlink.
|
||||
4. Получение интерфейсов и адресов именно из непривилегированной службы БК.
|
||||
5. Получение USB-инвентаризации из той же службы.
|
||||
6. Подключение реестра доверенных ключей, проверка конфигурации и запуск SSH.
|
||||
7. Установка Tailscale и проверка его системной службы.
|
||||
|
||||
В этом же разделе доступны реестр доверенных SSH-устройств с формой добавления
|
||||
публичного ключа и блок фактического входа в Tailscale. Установленная служба
|
||||
не означает зарегистрированный ключ, удалённый SSH-вход или авторизованную
|
||||
частную сеть. Эти состояния показываются отдельно по наблюдаемому результату.
|
||||
Ключ и вход требуют действия оператора через UI; помощник не подставляет
|
||||
инженерные ключи и не использует общую учётную запись из пакета.
|
||||
|
||||
Каждая строка проходит ожидание → выполнение → готово/ошибка. Зависимые от
|
||||
неуспешного этапа строки блокируются, независимые проверки продолжаются.
|
||||
Повторный запуск заново проверяет результат, сохраняет существующие identity,
|
||||
ключи и сетевые предпочтения; завершённая ранее строка не даёт автоматическую
|
||||
галочку новому запуску. Закрытие окна не прерывает системное задание или dpkg.
|
||||
Последний результат сохраняется атомарно; повторное открытие показывает его,
|
||||
прерванное заданием/перезагрузкой выполнение не выдаётся за успех.
|
||||
|
||||
Фиксированный polkit-helper запускает отдельное root-owned systemd-задание.
|
||||
Из UI не принимаются команды, пути, имена пакетов или произвольные аргументы.
|
||||
Web API читает состояние, но не запускает привилегированные действия.
|
||||
После изменения службы native launcher обновляет локальный сеанс. Повторная
|
||||
установка/удаление пакета во время задания отклоняется с понятной причиной;
|
||||
блокировки APT не удаляются и активная транзакция не завершается принудительно.
|
||||
|
||||
Исправление netlink из 0.3.2 теперь принадлежит этому сценарию: managed drop-in
|
||||
устанавливается кнопкой настройки. Проверяется фактический доступ службы к
|
||||
сети без CAP_NET_ADMIN. На этом этапе пустые/недоступные сведения различаются.
|
||||
Чужие конфигурации в управляемых путях сохраняются и дают конфликт, который
|
||||
нельзя скрывать зелёной галочкой или ручным исправлением только на Mini.
|
||||
Любое следующее необходимое изменение ОС должно расширять версионный профиль,
|
||||
его скрипт, проверку результата и сценарий GUI-приёмки на чистой системе.
|
||||
|
||||
Во всех текстах операторского интерфейса используется «система». Название и
|
||||
версия ОС выводятся один раз в «Обзор БК» из фактической инвентаризации.
|
||||
Название дистрибутива не является частью навигации, форм SSH, системных
|
||||
подтверждений или ошибок. Это не расширяет совместимость: профиль пакета
|
||||
по-прежнему допускает только Ubuntu 24.04 LTS Desktop amd64.
|
||||
|
||||
Приёмка этого приращения требует запуска именно штатной кнопкой на Mini,
|
||||
наблюдения этапов, повторной настройки без потери доступа/identity, проверки
|
||||
SSH-формы и состояния Tailscale. Чистая установка, перезагрузка и полный
|
||||
GUI upgrade/remove остаются отдельными проверками P1; отладочный вызов
|
||||
helper по SSH не заменяет ни одну из них.
|
||||
|
||||
|
||||
### Исправление отчёта настройки 0.4.1
|
||||
|
||||
Первый запуск штатной кнопкой на физическом БК выполнил семь системных этапов:
|
||||
сеть (три интерфейса), USB (13 системных объектов), SSH и Tailscale проверены.
|
||||
В UI обнаружен дефект 0.4.0: umask 0077 делал каталог несекретного журнала 0700,
|
||||
поэтому непривилегированные служба и окно не могли читать результат.
|
||||
В профиль /2 включено явное создание каталога отчёта с правами 0755 и ремонт
|
||||
его прежнего режима; файлы с ключами и чужие каталоги этим не затрагиваются.
|
||||
Повторный запуск той же кнопкой выполняет ремонт. Сообщение теперь называет
|
||||
точную кнопку и ожидаемое действие. Тест проверяет restrictive umask, повторный
|
||||
ремонт и сохранение прав чужого каталога. Физическая GUI-проверка исправленной
|
||||
версии фиксируется по наблюдаемому результату в отчёте Ops.
|
||||
|
||||
|
||||
### Подтверждённый GUI-проход профиля /2 и границы доверия
|
||||
|
||||
Владелец подтвердил успешный повторный запуск через кнопку в 0.4.1. Журнал
|
||||
профиля /2 доступен обычному приложению (каталог 0755); семь этапов complete.
|
||||
Получены три сетевых интерфейса и 13 системных USB-объектов. Отдельная проверка
|
||||
службы подтверждает active, непривилегированного пользователя, пустой набор
|
||||
capabilities и AF_NETLINK. SSH и Tailscale активны. Ручная команда ремонта прав
|
||||
в обход кнопки не выполнялась. В 0.4.2 надпись «Готово» заменена канонической
|
||||
галочкой со смысловой подписью для средств доступности. Рядом с результатом
|
||||
указано время проверки, чтобы прошедший запуск не воспринимался как live-status.
|
||||
Профиль /2 не изменён.
|
||||
|
||||
Это положительная приёмка текущего подготовленного Mini, а не доказательство
|
||||
развёртывания с чистой системой. Ранее инженерный SSH и личная политика
|
||||
локального подтверждения администратора уже настраивались. Количество USB
|
||||
не доказывает работу SDK; локальный ответ SSH не доказывает новый удалённый
|
||||
вход; служба Tailscale не доказывает авторизацию; прошедший запуск является
|
||||
снимком результата, а не непрерывным монитором всех компонентов.
|
||||
|
||||
Ближайший критерий доверия к поставке: отдельная чистая согласованная система,
|
||||
.deb → запуск → «Сконфигурировать» → вход в частную сеть → доверенный ключ
|
||||
через UI → проверяемый вход и отзыв → перезагрузка → повторная проверка.
|
||||
Первичный прогон выполняется без предварительного SSH-bootstrap, команд
|
||||
инженера, предустановленного OpenSSH/Tailscale и личных polkit-исключений.
|
||||
Текущий рабочий Mini для этого не стирается. Виртуальная проверка не заменяет
|
||||
физическую квалификацию USB/BLE/Wi-Fi. После этого следующий функциональный
|
||||
срез P1 — приглашение Node и сопряжение с реестром аппаратов Core, затем D455.
|
||||
|
||||
|
||||
### Решение владельца: переход к сопряжению без ожидания чистой установки
|
||||
|
||||
05.09.2026 владелец явно отложил контрольный проход чистой установки: он
|
||||
остаётся незакрытым критерием приёмки, но больше не блокирует разработку
|
||||
сопряжения. Текущая точка отсчёта — принятый GUI-проход профиля /2 на Mini
|
||||
и установленный Node 0.4.2. Первым выполняется полный сценарий приглашения
|
||||
Node → добавление аппарата в Core → сохранённая привязка и конфигурация БК.
|
||||
Далее — реальные устройства, начиная с D455 и затем K1 Wi-Fi Bridge.
|
||||
Базовые требования и честные ограничения предыдущих отчётов не отменены.
|
||||
|
||||
До начала реализации сопряжения текущий код и это изменение порядка
|
||||
фиксируются коммитом и отдельным целостным комментарием в MISSIONCOR-76.
|
||||
Статус архитектурной задачи остаётся «В работе», P1 не объявляется завершённым.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user