Author SHA1 Message Date
DCCONSTRUCTIONS 020a878915 docs(node): reconcile release status and build provenance for 0.6.11 2026-09-06 01:39:02 +03:00
DCCONSTRUCTIONS 404285419f fix(sensors): align device lamp with identity and refresh to end 2026-09-06 01:18:47 +03:00
DCCONSTRUCTIONS ba0c948ca1 fix(node): show camera connectivity and remove company header marks 2026-09-06 01:05:19 +03:00
DCCONSTRUCTIONS 429b4681fe fix(node): use compact guideline copy and plain setup checkmarks 2026-09-06 00:44:23 +03:00
DCCONSTRUCTIONS 22d69107ba feat(node): push USB changes and preserve sensor setup across sessions 2026-09-06 00:24:48 +03:00
DCCONSTRUCTIONS a8647c4d87 fix(node): verify D455 access and share sensor progress and recording UI 2026-09-05 23:25:11 +03:00
DCCONSTRUCTIONS b1aaa40508 Add D455 sensor host and shared Node/Core preparation surface 2026-09-05 22:27:07 +03:00
DCCONSTRUCTIONS 3616acc648 fix(node): clear consumed invitations after confirmed pairing 2026-09-05 21:37:56 +03:00
DCCONSTRUCTIONS e82d012907 feat(node): pair onboard computers with the Core fleet through UI 2026-09-05 21:16:13 +03:00
DCCONSTRUCTIONS fc545f8440 docs(node): proceed to pairing with clean-install acceptance deferred 2026-09-05 20:31:43 +03:00
DCCONSTRUCTIONS a98ef339de fix(node): show completed setup checks with a timestamp 2026-09-05 20:11:17 +03:00
DCCONSTRUCTIONS 9062126913 fix(node): make environment progress readable from the desktop 2026-09-05 19:54:08 +03:00
DCCONSTRUCTIONS 59e14c5bc0 feat(node): configure system environment through the desktop workflow 2026-09-05 19:46:52 +03:00
DCCONSTRUCTIONS 15bb793e5e fix(node): restore Linux network inventory and expose read failures 2026-09-05 18:47:12 +03:00
DCCONSTRUCTIONS 79911eb316 feat(node): consolidate board system views and plan fleet pairing 2026-09-05 18:35:57 +03:00
DCCONSTRUCTIONS d696842f5d feat(node): package Ubuntu desktop setup and trusted access 2026-09-05 17:27:59 +03:00
122 changed files with 11549 additions and 21 deletions
+5 -5
View File
@@ -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 };
}
+5 -15
View File
@@ -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'});
});
+4
View File
@@ -0,0 +1,4 @@
/build/
/ui/node_modules/
/ui/dist/
/web/dist/
+222
View File
@@ -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.
+41
View File
@@ -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))
}
+157
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module nodedc.local/mission-core/node-agent
go 1.26.0
+265
View File
@@ -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")
}
}
+118
View File
@@ -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)
}
}
+255
View File
@@ -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")
}
}
+233
View File
@@ -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 = &copy
}
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)
}
}
+493
View File
@@ -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 &copy, 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 &copy, 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 &copy
}
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)
}
}
+250
View File
@@ -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
}
+122
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/lib/mission-core-node/node-agent authorize
+60
View File
@@ -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")
+128
View File
@@ -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
+326
View File
@@ -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
+219
View File
@@ -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>
+20
View File
@@ -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
+7
View File
@@ -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.
+29
View File
@@ -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
+49
View File
@@ -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))
+13
View File
@@ -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()
+607
View File
@@ -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
+132
View File
@@ -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}
+193
View File
@@ -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)
+9
View File
@@ -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"
}
+2
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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

+20
View File
@@ -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>;
}
+18
View File
@@ -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>;
}
+30
View File
@@ -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>;
}
+5
View File
@@ -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}/>;}
+44
View File
@@ -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>;
}
+48
View File
@@ -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>;
}
+45
View File
@@ -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 });
}
+62
View File
@@ -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 />);
+14
View File
@@ -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); }
+18
View File
@@ -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" },
];
+16
View File
@@ -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 };
}
+50
View File
@@ -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"};
}
+28
View File
@@ -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 };
}
+11
View File
@@ -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);
}
});
+31
View 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"
]
}
+9
View File
@@ -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"] },
});
+6
View File
@@ -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 первый пакет остаётся кандидатом для проверки.
+109
View File
@@ -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