feat(node): pair onboard computers with the Core fleet through UI
This commit is contained in:
@@ -629,7 +629,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,
|
||||
@@ -802,7 +805,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 +831,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,50 @@
|
||||
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 timer: ReturnType<typeof setTimeout>;
|
||||
async function poll() {
|
||||
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
|
||||
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
|
||||
finally { if (active) timer = setTimeout(poll, 5000); }
|
||||
}
|
||||
void poll();
|
||||
return () => { active = false; clearTimeout(timer); };
|
||||
}, []);
|
||||
return { items, error, refresh };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type WorkspaceKind =
|
||||
| "map"
|
||||
| "timeline"
|
||||
| "missions"
|
||||
| "vehicles"
|
||||
| "catalog"
|
||||
| "contour-health"
|
||||
| "compute-modules"
|
||||
@@ -193,23 +194,12 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
id: "vehicles",
|
||||
root: "fleet",
|
||||
label: "Аппараты",
|
||||
title: "Реестр аппаратов",
|
||||
title: "Аппараты",
|
||||
eyebrow: "ПАРК / РЕЕСТР",
|
||||
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
|
||||
description: "Аппараты и их бортовые компьютеры в частном контуре.",
|
||||
icon: "apps",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Идентичность аппарата",
|
||||
description: "Никакой привязки продуктовой модели к конкретному производителю.",
|
||||
capabilities: [
|
||||
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
|
||||
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
|
||||
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
|
||||
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
|
||||
],
|
||||
},
|
||||
],
|
||||
kind: "vehicles",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "local-device",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import {
|
||||
@@ -1167,6 +1168,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
return <MissionWorkspace {...props} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "contour-health":
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface LaboratoryViewAction {
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
fleetCreateRequest?: number;
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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";
|
||||
|
||||
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 [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)}>К списку аппаратов</Button></div>
|
||||
<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="Устройства аппарата"><p>Устройства к аппарату ещё не подключены.</p></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,5 @@
|
||||
.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; }
|
||||
@@ -69,6 +69,11 @@ func run() error {
|
||||
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
|
||||
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
|
||||
@@ -104,6 +109,7 @@ func run() error {
|
||||
go func() { errs <- private.Serve(unix) }()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go pairing.Run(ctx)
|
||||
log.Print("Mission Core Node " + version + " listening on loopback")
|
||||
select {
|
||||
case err = <-errs:
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
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
|
||||
}
|
||||
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,165 @@
|
||||
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")
|
||||
}
|
||||
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,321 @@
|
||||
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"
|
||||
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, 16384)).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()
|
||||
timer := time.NewTicker(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
defer func() {
|
||||
for _, c := range p.clients {
|
||||
c.CloseIdleConnections()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
p.mu.Lock()
|
||||
var binding *CoreBinding
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil {
|
||||
copy := *p.state.Binding
|
||||
binding = ©
|
||||
}
|
||||
revocations := append([]CoreBinding(nil), p.state.Revocations...)
|
||||
p.mu.Unlock()
|
||||
wanted := make(map[string]bool)
|
||||
if binding != nil {
|
||||
wanted[binding.BindingID+digest(binding.ClientPEM)] = true
|
||||
}
|
||||
for _, b := range revocations {
|
||||
wanted[b.BindingID+digest(b.ClientPEM)] = true
|
||||
}
|
||||
for key, c := range p.clients {
|
||||
if !wanted[key] {
|
||||
c.CloseIdleConnections()
|
||||
delete(p.clients, key)
|
||||
}
|
||||
}
|
||||
if binding != nil {
|
||||
id, name := p.store.Public()
|
||||
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
|
||||
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()
|
||||
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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
type Server struct {
|
||||
Store *Store
|
||||
Pairing *Pairing
|
||||
Assets fs.FS
|
||||
Origin string
|
||||
Version string
|
||||
@@ -77,6 +78,9 @@ func reply(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
if s.Pairing != nil {
|
||||
s.Pairing.localRoutes(mux, s)
|
||||
}
|
||||
if s.Access != nil {
|
||||
s.accessRoutes(mux)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import tarfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.4.2"
|
||||
VERSION = "0.5.0"
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
|
||||
@@ -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.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>;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { SystemAccess } from "./SystemAccess";
|
||||
import { TailnetAccess } from "./TailnetAccess";
|
||||
import { useEnvironment } from "./useEnvironment";
|
||||
import { EnvironmentView } from "./EnvironmentView";
|
||||
import { CoreConnectionView } from "./CoreConnectionView";
|
||||
import "./node.css";
|
||||
|
||||
function App() {
|
||||
@@ -33,6 +34,7 @@ function App() {
|
||||
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 === "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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IconName } from "@nodedc/ui-react";
|
||||
export type RootId = "system" | "devices";
|
||||
export type ViewId = "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh";
|
||||
export type ViewId = "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: null },
|
||||
@@ -10,6 +10,7 @@ export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[
|
||||
{ id: "overview", root: "system", label: "Обзор БК", icon: "activity" },
|
||||
{ id: "network", root: "system", label: "Сеть", icon: "network" },
|
||||
{ id: "usb", root: "system", label: "USB-устройства", icon: "camera" },
|
||||
{ id: "core", root: "system", label: "Mission Core", icon: "network" },
|
||||
{ id: "tailscale", root: "system", label: "Tailscale", icon: "globe" },
|
||||
{ id: "ssh", root: "system", label: "SSH · доверенные устройства", icon: "key" },
|
||||
{ id: "diagnostics", root: "system", label: "Диагностика", icon: "clipboard" },
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Привязка БК к Mission Core — протокол v1
|
||||
|
||||
Дата: 05.09.2026. Инкремент Node 0.5.0. Основание: MISSIONCOR-76 и согласованная
|
||||
поверхность `03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md`. Чистая установка ОС
|
||||
отложена владельцем; она не закрыта текущими проверками и не блокирует этот этап.
|
||||
|
||||
## Работа оператора
|
||||
|
||||
1. В установленном Node: **Система → Mission Core**. Выбрать собственный адрес
|
||||
БК, доступный Core через общую LAN или Tailscale; создать приглашение.
|
||||
2. В Core: **Парк → Аппараты → +**. Способ подключения «С бортовым компьютером»,
|
||||
отдельно класс платформы (UGV, UAV, стационарная, другая), название и код.
|
||||
3. «Проверить БК» подтверждает криптографическую идентичность и показывает
|
||||
сведения машины. «Добавить аппарат» сохраняет ожидающую привязку.
|
||||
4. После подтверждения Node сам соединяется с Core. «В сети» появляется только
|
||||
по полученному через mTLS сообщению этого БК. Глаз открывает конфигурацию.
|
||||
5. Отзыв доступен с обеих сторон с подтверждением. Повторное подключение той же
|
||||
идентичности БК после отзыва сохраняет идентификатор аппарата в этом Core.
|
||||
|
||||
UGV/UAV описывают платформу, а не способность автономного движения. Наличие Node
|
||||
не означает реализованный автопилот. Подключение аппаратов без БК в этой версии
|
||||
не реализовано. USB-инвентаризация системы не создаёт устройства аппарата.
|
||||
|
||||
## Границы компонентов
|
||||
|
||||
- Node: Go-служба без root/capabilities, постоянная Ed25519-идентичность;
|
||||
GTK/WebKit desktop с общей React/DG-поверхностью. Авторизация местного оператора
|
||||
остаётся через OS/Polkit → закрытый Unix socket → одноразовый вход в loopback UI.
|
||||
- UI Node `CoreConnectionView.tsx` использует только локальные `/api/core*`.
|
||||
Она не выдаёт браузеру закрытые ключи, CA-ключ Core или клиентский сертификат.
|
||||
- Core: `k1link.fleet.trust` — PKI и проверка приглашения;
|
||||
`registry` — реестр аппаратов и переходы; `transport` — входящий канал Node;
|
||||
`web.fleet_api` — API локального оператора. `core/fleet/useFleet` и
|
||||
`workspaces/fleet/VehiclesWorkspace` — отдельный UI-модуль, а не логика в App.
|
||||
- Control Station остаётся на `127.0.0.1:8000`; новые API отвергают удалённого
|
||||
клиента, неверный Host и чужой Origin. Авторитет здесь — локальный сеанс Core;
|
||||
многопользовательская удалённая авторизация Core этим инкрементом не вводится.
|
||||
- SDK v0alpha2 `ExecutionBinding` связывает постоянный node_id с новым
|
||||
agent_instance_id процесса и платформой linux. `devices: []` явно означает,
|
||||
что драйверные устройства этим этапом ещё не опубликованы.
|
||||
|
||||
## Частные каналы
|
||||
|
||||
- БК временно слушает выбранный собственный IPv4 на TCP 8781 во время приглашения
|
||||
и ожидающего подтверждения. Wildcard, loopback, публичный адрес, DNS-имя,
|
||||
произвольный порт, URL с credentials/path/query/fragment запрещены.
|
||||
- Допущены RFC1918 и CGNAT 100.64/10 для Tailscale. IPv6 не реализован.
|
||||
- Core слушает TCP 8782 только на конкретном частном обратном адресе, который
|
||||
реально использовало его соединение с БК. Этот сервис не отдаёт UI и не
|
||||
принимает приглашения; только mTLS heartbeat/unpair.
|
||||
- Tailscale — заменяемый IP-транспорт. В протоколе нет его API, идентичности,
|
||||
публичного relay, cloud rendezvous или обязательного внешнего сервера.
|
||||
- Адреса фиксируются при привязке. При смене подсети/IP нужна новая привязка.
|
||||
Автоматический поиск нового адреса Core и маршрутизация между сетями отсутствуют.
|
||||
- Подключение K1 остаётся отдельной задачей: только Wi-Fi Bridge к общей LAN.
|
||||
Старый локальный Quick Connect остаётся лабораторным legacy-сценарием.
|
||||
|
||||
## Приглашение и криптография
|
||||
|
||||
`MCN1.` + base64url JSON со строго заданными полями:
|
||||
`schema`, `node_id`, `id`, `endpoint`, `expires_at`, `secret`.
|
||||
Schema: `missioncore.node-pairing/v1`. Срок — 10 минут; id/secret — случайные
|
||||
256 бит. Код является полномочием доверия: он показывается только при создании,
|
||||
не пишется в URL, localStorage, логи или Ops. В Node сохраняется только SHA-256
|
||||
секрета. Создание нового приглашения отменяет старое.
|
||||
|
||||
Bootstrap использует TLS 1.3 с самоподписанным сертификатом Node. Core сначала
|
||||
проверяет тип Ed25519, SHA-256 публичного ключа из node_id, подпись сертификата и
|
||||
срок действия; только затем отправляет секрет. Системная CA, DNS, прокси и
|
||||
перенаправления не могут заменить этот pin. Это явно реализованная проверка
|
||||
peer certificate, а не отключённая проверка идентичности.
|
||||
|
||||
Core имеет постоянный Ed25519 CA (10 лет) в закрытом локальном хранилище. Node
|
||||
проверяет CA fingerprint core_id, подпись CA и выпущенный Core клиентский
|
||||
сертификат на собственный публичный ключ. Дальше работает обычный TLS 1.3 с
|
||||
RootCAs, проверкой IP SAN сервера и обязательным клиентским сертификатом.
|
||||
Закрытый ключ Node никогда не передаётся Core. Common Name не используется как
|
||||
идентичность: идентичность вычисляется из публичного ключа.
|
||||
|
||||
Клиентский сертификат — 30 дней, продление при остатке менее 7 дней через
|
||||
действующий mTLS-сеанс; прежний serial допускается 1 час для доставки обновления.
|
||||
Серверный leaf — 30 дней, контекст обновляется каждые 7 дней и при запуске.
|
||||
Корневой ключ автоматически не заменяется. Долгий offline сверх срока client
|
||||
certificate требует отзыва и новой привязки через UI. Автоматической ротации
|
||||
постоянных идентичностей/корня в этой версии нет. Часы обоих компьютеров должны
|
||||
быть корректны; ошибки доверия не обходятся отключением TLS.
|
||||
|
||||
## Переходы и восстановление
|
||||
|
||||
Node: `unpaired → inviting → pending → paired`, отдельно `revoked`.
|
||||
|
||||
1. `/v1/pair/inspect`: действующий секрет, проверка состояния inviting,
|
||||
возвращает подтверждённую идентичность и системный инвентарь.
|
||||
2. Core хранит проверку в памяти до 5 минут (максимум 16 проверок). После кнопки
|
||||
добавления он транзакционно создаёт запись pending и сохраняет предложение,
|
||||
приглашение и будущую идентичность привязки для восстановления после сбоя.
|
||||
3. `/v1/pair/offer`: Node проверяет секрет/CA/client certificate и атомарно
|
||||
сохраняет pending с hash предложения и случайной квитанцией. Один Node — один
|
||||
Core. Точный повтор получает ту же квитанцию; иное предложение получает 409.
|
||||
4. Core сохраняет квитанцию ДО `/v1/pair/commit`. Node атомарно сохраняет paired.
|
||||
После этого bootstrap закрывается, начинается исходящее соединение.
|
||||
5. Потерянный ответ commit восстанавливается настоящим mTLS heartbeat. Core
|
||||
перепроверяет binding_id и состояние после каждого сетевого вызова, поэтому
|
||||
завершение старого запроса не восстанавливает уже отозванную привязку.
|
||||
6. Pending повторяется до истечения приглашения. Истёкшее подтверждение остаётся
|
||||
в реестре как failed с причиной; оператор создаёт новый код. На Node pending
|
||||
также истекает. Повторное добавление по той же проверке идемпотентно.
|
||||
|
||||
Core хранит запись аппарата отдельно от его вычислительного корня: vehicle_id,
|
||||
класс, название, node_id, revision, enrollment, connectivity, last_seen, host,
|
||||
execution_binding. UNIQUE(node_id) исключает два аппарата на одном БК в этом Core.
|
||||
Перезапуск Core сбрасывает доказательство текущей доступности до нового heartbeat.
|
||||
Отсутствие сообщений более 20 секунд означает offline, а не удаление записи.
|
||||
|
||||
## Сохранность и отзыв
|
||||
|
||||
- Node: `core-binding.json` в StateDirectory 0700, файл 0600,
|
||||
temp → fsync → rename → fsync каталога; изменение памяти только после записи.
|
||||
- Core: закрытый каталог `MISSIONCORE_DATA_DIR/fleet`, SQLite WAL/FULL, файл 0600,
|
||||
транзакции под единственным writer lock. CA private key — отдельный файл 0600.
|
||||
- Пока подтверждение не завершено, секрет приглашения нужен Core для повтора и
|
||||
хранится в закрытой pending-записи. После подтверждения удаляется из текущей
|
||||
записи; secure_delete включён. Это не обещание физического стирания блоков SSD,
|
||||
WAL, снимков или резервных копий; закрытое хранилище целиком считается секретным.
|
||||
- Отзыв в Core немедленно запрещает следующий запрос старого binding_id, включая
|
||||
существующее TLS-соединение. Node получает 410 и прекращает канал.
|
||||
- Отзыв в Node прекращает локальную привязку сразу и сохраняет durable outbox
|
||||
уведомления Core. При недоступном Core аппарат там может оставаться offline до
|
||||
доставки отзыва. Очередь ограничена 8 элементами; истёкший сертификат больше не
|
||||
даёт полномочий и удаляется из очереди. Отзыв не зависит от доставки для
|
||||
прекращения работы самого Node. Старый binding_id не принимает новый реестр.
|
||||
- Повреждённая идентичность/привязка не заменяется молча. Node отказывает в старте
|
||||
с повреждённым файлом доверия; восстановление такого хранилища из резервной
|
||||
копии пока инженерная операция. Недоступное хранилище Fleet отключает pairing,
|
||||
сохраняя другие разделы Core. GUI-восстановление повреждённой идентичности не
|
||||
входит в текущую приёмку и остаётся отдельной задачей.
|
||||
|
||||
Bootstrap ограничен 16 одновременными соединениями, временем чтения/записи,
|
||||
размером заголовков/JSON и 32 неверными попытками секрета за минуту. Core mTLS
|
||||
ограничен 16 соединениями и телом 64 КиБ. Используется исходящий keepalive HTTP/1.1
|
||||
с heartbeat каждые 5 секунд, таймаутами и автоматическим переподключением.
|
||||
|
||||
## Проверка и следующий этап
|
||||
|
||||
Покрыты тестами сохранение и повтор commit, конфликт владельцев, TTL, отмена,
|
||||
отзыв во время сетевого ответа, потерянный ack, повторная регистрация без дубля,
|
||||
проверка pin до отправки секрета, срок прежнего сертификата, закрытость API и
|
||||
запрет публичных/DNS/посторонних портов. GUI-приёмка реальной пары и точные хеши
|
||||
сборок фиксируются отдельным отчётом в MISSIONCOR-76.
|
||||
|
||||
Этот этап передаёт только сведения БК. Он не объявляет готовыми команды,
|
||||
медиа/WebRTC, запись, управление K1, SDK-драйвер D455, телеметрию движения или
|
||||
автономные миссии. Следующий отдельный инкремент: реальный RealSense D455 за Node;
|
||||
после него K1 через Wi-Fi Bridge. Их полномочия и API добавляются к существующим
|
||||
границам Node/Core, а не к SSH или UI браузера.
|
||||
@@ -12,6 +12,7 @@ license = { text = "Proprietary" }
|
||||
authors = [{ name = "NODE.DC" }]
|
||||
dependencies = [
|
||||
"bleak==3.0.2",
|
||||
"cryptography>=46,<47",
|
||||
"fastapi>=0.116,<1",
|
||||
"foxglove-sdk==0.25.3",
|
||||
"httpx>=0.28,<1",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core-owned vehicle registry and private Node enrollment transport."""
|
||||
@@ -0,0 +1,399 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from missioncore_plugin_sdk.v0alpha2.identity import ExecutionBinding
|
||||
|
||||
from .trust import SCHEMA, CoreTrust, PairingError, node_request, parse_invitation, pem, public_id
|
||||
|
||||
|
||||
class FleetRegistry:
|
||||
"""One writer lock and durable transactions; transport never owns vehicles."""
|
||||
|
||||
def __init__(self, root: Path):
|
||||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if root.is_symlink() or root.stat().st_mode & 0o077:
|
||||
raise PairingError("Хранилище реестра требует закрытый каталог.")
|
||||
self.started_at = time.time()
|
||||
self.root = root
|
||||
self.lock = threading.RLock()
|
||||
self.trust = CoreTrust(root)
|
||||
path = root / "fleet.sqlite3"
|
||||
if path.is_symlink():
|
||||
raise PairingError("Конфликт файла реестра.")
|
||||
self.db = sqlite3.connect(path, check_same_thread=False)
|
||||
path.chmod(0o600)
|
||||
self.db.execute("PRAGMA journal_mode=WAL")
|
||||
self.db.execute("PRAGMA synchronous=FULL")
|
||||
self.db.execute("PRAGMA secure_delete=ON")
|
||||
self.db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS vehicles (id TEXT PRIMARY KEY, "
|
||||
"node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
|
||||
)
|
||||
self.db.commit()
|
||||
self.previews: dict[str, dict] = {}
|
||||
self.listeners: dict[str, object] = {}
|
||||
self.stop = threading.Event()
|
||||
self.worker: threading.Thread | None = None
|
||||
|
||||
def rows(self):
|
||||
return [
|
||||
json.loads(row[0]) for row in self.db.execute("SELECT body FROM vehicles ORDER BY id")
|
||||
]
|
||||
|
||||
def save(self, row):
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
"INSERT INTO vehicles VALUES(?,?,?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET body=excluded.body",
|
||||
(row["id"], row["node_id"], json.dumps(row)),
|
||||
)
|
||||
|
||||
def find(self, identifier):
|
||||
result = self.db.execute("SELECT body FROM vehicles WHERE id=?", (identifier,)).fetchone()
|
||||
if not result:
|
||||
raise PairingError("Аппарат не найден.")
|
||||
return json.loads(result[0])
|
||||
|
||||
def start(self):
|
||||
with self.lock:
|
||||
for row in self.rows():
|
||||
if row["enrollment"] in ("pending", "paired"):
|
||||
# Address absent: keep the binding and show offline.
|
||||
with suppress(OSError):
|
||||
self.listen(row["core_address"])
|
||||
self.worker = threading.Thread(
|
||||
target=self.reconcile, name="mission-core-fleet", daemon=True
|
||||
)
|
||||
self.worker.start()
|
||||
|
||||
def close(self):
|
||||
self.stop.set()
|
||||
if self.worker:
|
||||
self.worker.join(timeout=20)
|
||||
for server in self.listeners.values():
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
with self.lock:
|
||||
self.db.close()
|
||||
|
||||
def listen(self, address):
|
||||
from .transport import NodeChannelServer
|
||||
|
||||
if address not in self.listeners:
|
||||
server = NodeChannelServer(address, self)
|
||||
self.listeners[address] = server
|
||||
threading.Thread(
|
||||
target=server.serve_forever, name="mission-core-node-channel", daemon=True
|
||||
).start()
|
||||
|
||||
def preview(self, code: str) -> dict:
|
||||
invitation = parse_invitation(code.strip())
|
||||
value, public_key, address = node_request(
|
||||
invitation, "/v1/pair/inspect", {"id": invitation["id"], "secret": invitation["secret"]}
|
||||
)
|
||||
if value.get("schema") != SCHEMA or value.get("node_id") != invitation["node_id"]:
|
||||
raise PairingError("БК вернул несовместимое подтверждение.")
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
self.previews = {
|
||||
key: item for key, item in self.previews.items() if item["expires"] > now
|
||||
}
|
||||
if len(self.previews) >= 16:
|
||||
raise PairingError("Завершите или закройте предыдущие приглашения.")
|
||||
identifier = secrets.token_urlsafe(32)
|
||||
self.previews[identifier] = {
|
||||
"invitation": invitation,
|
||||
"expires": min(now + 300, invitation["expires_at"]),
|
||||
"public_key": public_key,
|
||||
"core_address": address,
|
||||
"node": value,
|
||||
}
|
||||
return {
|
||||
"preview_id": identifier,
|
||||
"node_id": invitation["node_id"],
|
||||
"name": value["name"],
|
||||
"host": value["host"],
|
||||
"expires_at": invitation["expires_at"],
|
||||
"endpoint": invitation["endpoint"],
|
||||
}
|
||||
|
||||
def add(self, preview_id: str, name: str, platform: str) -> dict:
|
||||
with self.lock:
|
||||
preview = self.previews.get(preview_id)
|
||||
if preview is None or preview["expires"] <= time.time():
|
||||
raise PairingError("Проверка приглашения истекла. Вставьте код снова.")
|
||||
if preview.get("created_id"):
|
||||
return self.public(self.find(preview["created_id"]))
|
||||
invitation = preview["invitation"]
|
||||
existing = next(
|
||||
(row for row in self.rows() if row["node_id"] == invitation["node_id"]), None
|
||||
)
|
||||
if existing and existing["enrollment"] in ("pending", "paired"):
|
||||
if existing.get("invitation_id") == invitation["id"]:
|
||||
return self.public(existing)
|
||||
raise PairingError("Этот БК уже добавлен. Сначала отзовите прежнюю привязку.")
|
||||
if (
|
||||
not name.strip()
|
||||
or len(name.strip()) > 80
|
||||
or any(ord(c) < 32 for c in name)
|
||||
or platform not in ("ugv", "uav", "stationary", "other")
|
||||
):
|
||||
raise PairingError("Проверьте название и класс аппарата.")
|
||||
try:
|
||||
self.listen(preview["core_address"])
|
||||
except OSError:
|
||||
raise PairingError(
|
||||
"Не удалось открыть частный канал Core. "
|
||||
"Проверьте сеть и доступность порта приложения."
|
||||
) from None
|
||||
binding = {
|
||||
"binding_id": secrets.token_urlsafe(32),
|
||||
"core_id": self.trust.core_id,
|
||||
"core_name": "Mission Core",
|
||||
"endpoint": f"https://{preview['core_address']}:8782",
|
||||
"ca_pem": pem(self.trust.ca),
|
||||
"client_pem": self.trust.leaf(invitation["node_id"], preview["public_key"]),
|
||||
}
|
||||
row = {
|
||||
"id": existing["id"] if existing else secrets.token_urlsafe(16),
|
||||
"node_id": invitation["node_id"],
|
||||
"name": name.strip(),
|
||||
"platform": platform,
|
||||
"enrollment": "pending",
|
||||
"revision": (existing["revision"] + 1) if existing else 1,
|
||||
"binding": binding,
|
||||
"invitation_id": invitation["id"],
|
||||
"invitation": invitation,
|
||||
"receipt": None,
|
||||
"core_address": preview["core_address"],
|
||||
"last_seen": None,
|
||||
"inventory": None,
|
||||
"runtime": None,
|
||||
"certificate_previous": None,
|
||||
"notice": "Подтверждаем привязку с БК",
|
||||
"created_at": time.time(),
|
||||
}
|
||||
self.save(row)
|
||||
self.previews[preview_id] = {"created_id": row["id"], "expires": preview["expires"]}
|
||||
return self.public(row)
|
||||
|
||||
def public(self, row):
|
||||
online = (
|
||||
row["enrollment"] == "paired"
|
||||
and row["last_seen"] is not None
|
||||
and row["last_seen"] >= self.started_at
|
||||
and time.time() - row["last_seen"] < 20
|
||||
)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"node_id": row["node_id"],
|
||||
"name": row["name"],
|
||||
"platform": row["platform"],
|
||||
"enrollment": row["enrollment"],
|
||||
"revision": row["revision"],
|
||||
"connectivity": "online" if online else "offline",
|
||||
"last_seen": row["last_seen"],
|
||||
"host": row["inventory"],
|
||||
"execution_binding": row["runtime"],
|
||||
"devices": [],
|
||||
"notice": row["notice"],
|
||||
"core_endpoint": row["binding"]["endpoint"],
|
||||
}
|
||||
|
||||
def listing(self):
|
||||
with self.lock:
|
||||
return {
|
||||
"schema": "missioncore.fleet-registry/v1",
|
||||
"core_id": self.trust.core_id,
|
||||
"items": [self.public(row) for row in self.rows()],
|
||||
}
|
||||
|
||||
def revoke(self, identifier: str):
|
||||
with self.lock:
|
||||
row = self.find(identifier)
|
||||
if row["enrollment"] == "revoked":
|
||||
return self.public(row)
|
||||
row.update(
|
||||
enrollment="revoked",
|
||||
revision=row["revision"] + 1,
|
||||
invitation=None,
|
||||
receipt=None,
|
||||
notice="Доверие отозвано",
|
||||
)
|
||||
self.save(row)
|
||||
return self.public(row)
|
||||
|
||||
def advance(self, identifier):
|
||||
# Hold no registry lock across network I/O. Every completion rechecks
|
||||
# the exact binding and enrollment to avoid resurrecting a revoked row.
|
||||
with self.lock:
|
||||
row = self.find(identifier)
|
||||
if row["enrollment"] != "pending":
|
||||
return
|
||||
invitation = row["invitation"]
|
||||
if invitation["expires_at"] <= time.time():
|
||||
row.update(
|
||||
enrollment="failed",
|
||||
notice="Приглашение истекло. Создайте новое в Node.",
|
||||
invitation=None,
|
||||
receipt=None,
|
||||
)
|
||||
self.save(row)
|
||||
return
|
||||
if row["receipt"] is None:
|
||||
value, _, _ = node_request(
|
||||
invitation,
|
||||
"/v1/pair/offer",
|
||||
{"id": invitation["id"], "secret": invitation["secret"], "binding": row["binding"]},
|
||||
)
|
||||
receipt = value.get("receipt")
|
||||
if (
|
||||
not isinstance(receipt, str)
|
||||
or len(receipt) != 43
|
||||
or value.get("node_id") != row["node_id"]
|
||||
):
|
||||
raise PairingError("БК не подтвердил ожидаемую привязку.")
|
||||
with self.lock:
|
||||
current = self.find(identifier)
|
||||
if (
|
||||
current["enrollment"] != "pending"
|
||||
or current["binding"]["binding_id"] != row["binding"]["binding_id"]
|
||||
):
|
||||
return
|
||||
current["receipt"] = receipt
|
||||
self.save(current)
|
||||
row = current
|
||||
node_request(
|
||||
invitation,
|
||||
"/v1/pair/commit",
|
||||
{"id": row["binding"]["binding_id"], "receipt": row["receipt"]},
|
||||
)
|
||||
# An ack proves the binding. Online additionally needs the outgoing
|
||||
# authenticated heartbeat; a lost ack is recovered by that heartbeat.
|
||||
with self.lock:
|
||||
current = self.find(identifier)
|
||||
if (
|
||||
current["enrollment"] == "pending"
|
||||
and current["binding"]["binding_id"] == row["binding"]["binding_id"]
|
||||
):
|
||||
current.update(
|
||||
enrollment="paired",
|
||||
invitation=None,
|
||||
receipt=None,
|
||||
notice="Ожидаем исходящее соединение БК",
|
||||
)
|
||||
self.save(current)
|
||||
|
||||
def reconcile(self):
|
||||
while not self.stop.is_set():
|
||||
with self.lock:
|
||||
active = [row for row in self.rows() if row["enrollment"] in ("pending", "paired")]
|
||||
for address in {row["core_address"] for row in active}:
|
||||
try:
|
||||
self.listen(address)
|
||||
server = self.listeners[address]
|
||||
if server.refresh_at <= time.time():
|
||||
server.refresh_context()
|
||||
except OSError:
|
||||
pass
|
||||
identifiers = [row["id"] for row in active if row["enrollment"] == "pending"]
|
||||
self.previews = {
|
||||
k: v for k, v in self.previews.items() if v["expires"] > time.time()
|
||||
}
|
||||
for identifier in identifiers:
|
||||
if self.stop.is_set():
|
||||
break
|
||||
try:
|
||||
self.advance(identifier)
|
||||
except (PairingError, OSError, ValueError):
|
||||
with self.lock:
|
||||
row = self.find(identifier)
|
||||
if row["enrollment"] == "pending":
|
||||
row["notice"] = (
|
||||
"Подтверждение пока не получено. "
|
||||
"Повторяем до истечения приглашения."
|
||||
)
|
||||
self.save(row)
|
||||
self.stop.wait(2)
|
||||
|
||||
def receive(self, certificate: bytes, path: str, value: dict):
|
||||
cert = x509.load_der_x509_certificate(certificate)
|
||||
key = cert.public_key()
|
||||
if not isinstance(key, Ed25519PublicKey):
|
||||
return 403, {"error": "Unauthorized Node"}
|
||||
node_id = public_id("node_", key)
|
||||
with self.lock:
|
||||
row = next((item for item in self.rows() if item["node_id"] == node_id), None)
|
||||
if (
|
||||
row is None
|
||||
or row["enrollment"] not in ("pending", "paired")
|
||||
or value.get("node_id") != node_id
|
||||
or value.get("binding_id") != row["binding"]["binding_id"]
|
||||
):
|
||||
return 410, {"error": "Binding revoked"}
|
||||
allowed = [row["binding"]["client_pem"]]
|
||||
if (
|
||||
row.get("certificate_previous")
|
||||
and row["certificate_previous"]["until"] > time.time()
|
||||
):
|
||||
allowed.append(row["certificate_previous"]["pem"])
|
||||
if cert.serial_number not in [
|
||||
x509.load_pem_x509_certificate(item.encode()).serial_number for item in allowed
|
||||
]:
|
||||
return 403, {"error": "Certificate superseded"}
|
||||
if value.get("schema") != SCHEMA:
|
||||
return 400, {"error": "Incompatible protocol"}
|
||||
if path == "/v1/node/unpair":
|
||||
row.update(
|
||||
enrollment="revoked",
|
||||
revision=row["revision"] + 1,
|
||||
invitation=None,
|
||||
receipt=None,
|
||||
notice="БК отозвал привязку",
|
||||
)
|
||||
self.save(row)
|
||||
return 200, {"ok": True}
|
||||
if path != "/v1/node/heartbeat":
|
||||
return 404, {"error": "Unknown operation"}
|
||||
binding = ExecutionBinding.model_validate(value["execution_binding"])
|
||||
if (
|
||||
binding.node_id != node_id
|
||||
or binding.platform.value != "linux"
|
||||
or value.get("devices") != []
|
||||
):
|
||||
return 400, {"error": "Invalid Node inventory"}
|
||||
host = value.get("host")
|
||||
if (
|
||||
not isinstance(host, dict)
|
||||
or len(host.get("usb", [])) > 256
|
||||
or len(host.get("networks", [])) > 64
|
||||
):
|
||||
return 400, {"error": "Invalid host inventory"}
|
||||
row.update(
|
||||
enrollment="paired",
|
||||
invitation=None,
|
||||
receipt=None,
|
||||
last_seen=time.time(),
|
||||
inventory=host,
|
||||
runtime=binding.model_dump(mode="json"),
|
||||
notice="",
|
||||
)
|
||||
current = x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode())
|
||||
if current.not_valid_after_utc - datetime.now(UTC) < timedelta(days=7):
|
||||
row["certificate_previous"] = {
|
||||
"pem": row["binding"]["client_pem"],
|
||||
"until": time.time() + 3600,
|
||||
}
|
||||
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
|
||||
self.save(row)
|
||||
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"]}
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from .trust import private_address
|
||||
|
||||
|
||||
class NodeChannelServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
block_on_close = False
|
||||
|
||||
def __init__(self, address, registry):
|
||||
self.registry = registry
|
||||
self.address = address
|
||||
self.refresh_context()
|
||||
self.capacity = threading.BoundedSemaphore(16)
|
||||
super().__init__((address, 8782), NodeChannelHandler)
|
||||
|
||||
def refresh_context(self):
|
||||
self.context = self.registry.trust.server_context(self.address)
|
||||
self.refresh_at = time.time() + 7 * 86400
|
||||
|
||||
def process_request(self, request, client_address):
|
||||
if not private_address(client_address[0]) or not self.capacity.acquire(blocking=False):
|
||||
request.close()
|
||||
return
|
||||
super().process_request(request, client_address)
|
||||
|
||||
def process_request_thread(self, request, client_address):
|
||||
try:
|
||||
request.settimeout(8)
|
||||
secured = self.context.wrap_socket(request, server_side=True)
|
||||
super().process_request_thread(secured, client_address)
|
||||
except (OSError, ssl.SSLError):
|
||||
request.close()
|
||||
finally:
|
||||
self.capacity.release()
|
||||
|
||||
def handle_error(self, request, client_address):
|
||||
# No tracebacks containing request fields, keys or local addresses.
|
||||
pass
|
||||
|
||||
|
||||
class NodeChannelHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if (
|
||||
self.headers.get("Content-Type") != "application/json"
|
||||
or self.headers.get("Origin")
|
||||
or not 0 < size <= 65536
|
||||
):
|
||||
raise ValueError
|
||||
value = json.loads(self.rfile.read(size))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError
|
||||
status, data = self.server.registry.receive(
|
||||
self.connection.getpeercert(binary_form=True), self.path, value
|
||||
)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
self.close_connection = True
|
||||
status, data = 400, {"error": "Invalid request"}
|
||||
encoded = json.dumps(data).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import tempfile
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||
|
||||
SCHEMA = "missioncore.node-pairing/v1"
|
||||
TOKEN = re.compile(r"^[A-Za-z0-9_-]{43}$")
|
||||
NODE_ID = re.compile(r"^node_[a-f0-9]{64}$")
|
||||
|
||||
|
||||
class PairingError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def private_address(value: str) -> bool:
|
||||
try:
|
||||
ip = ipaddress.IPv4Address(value)
|
||||
return any(
|
||||
ip in ipaddress.ip_network(cidr)
|
||||
for cidr in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")
|
||||
)
|
||||
except ipaddress.AddressValueError:
|
||||
return False
|
||||
|
||||
|
||||
def endpoint(value: str, port: int) -> tuple[str, int]:
|
||||
try:
|
||||
u = urlsplit(value)
|
||||
if (
|
||||
u.scheme == "https"
|
||||
and u.username is None
|
||||
and not u.path
|
||||
and not u.query
|
||||
and not u.fragment
|
||||
and u.port == port
|
||||
and private_address(u.hostname or "")
|
||||
):
|
||||
return u.hostname, port
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
raise PairingError("В приглашении указан недопустимый адрес частной сети.")
|
||||
|
||||
|
||||
def parse_invitation(code: str) -> dict:
|
||||
try:
|
||||
if not code.startswith("MCN1.") or len(code) > 4096:
|
||||
raise ValueError
|
||||
encoded = code[5:]
|
||||
data = json.loads(
|
||||
base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True)
|
||||
)
|
||||
if set(data) != {"schema", "node_id", "id", "endpoint", "expires_at", "secret"}:
|
||||
raise ValueError
|
||||
if (
|
||||
data["schema"] != SCHEMA
|
||||
or not NODE_ID.fullmatch(data["node_id"])
|
||||
or not TOKEN.fullmatch(data["id"])
|
||||
or not TOKEN.fullmatch(data["secret"])
|
||||
):
|
||||
raise ValueError
|
||||
now = datetime.now(UTC).timestamp()
|
||||
if type(data["expires_at"]) is not int or not now < data["expires_at"] <= now + 630:
|
||||
raise PairingError("Приглашение просрочено. Создайте новое в Node.")
|
||||
endpoint(data["endpoint"], 8781)
|
||||
return data
|
||||
except (ValueError, TypeError, KeyError, UnicodeError) as error:
|
||||
if isinstance(error, PairingError):
|
||||
raise
|
||||
raise PairingError("Код приглашения повреждён или имеет неподдерживаемую версию.") from None
|
||||
|
||||
|
||||
def atomic_private(path: Path, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if path.is_symlink():
|
||||
raise PairingError("Обнаружен конфликт файла доверия.")
|
||||
fd, name = tempfile.mkstemp(prefix=".fleet-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(name, path)
|
||||
directory = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
Path(name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def public_id(prefix: str, key: Ed25519PublicKey) -> str:
|
||||
return prefix + hashlib.sha256(key.public_bytes_raw()).hexdigest()
|
||||
|
||||
|
||||
def pem(cert: x509.Certificate) -> str:
|
||||
return cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
|
||||
|
||||
class CoreTrust:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
path = root / "core-identity.json"
|
||||
if path.exists():
|
||||
if path.is_symlink() or path.stat().st_mode & 0o077:
|
||||
raise PairingError("Права хранилища Core должны быть закрытыми.")
|
||||
value = json.loads(path.read_text())
|
||||
self.key = Ed25519PrivateKey.from_private_bytes(base64.b64decode(value["key"]))
|
||||
self.ca = x509.load_pem_x509_certificate(value["ca"].encode())
|
||||
if self.ca.public_key().public_bytes_raw() != self.key.public_key().public_bytes_raw():
|
||||
raise PairingError("Идентичность Core повреждена; автоматическая замена запрещена.")
|
||||
else:
|
||||
self.key = Ed25519PrivateKey.generate()
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Mission Core authority")])
|
||||
self.ca = (
|
||||
self.builder(name, self.key.public_key(), 3650)
|
||||
.issuer_name(name)
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
||||
.add_extension(
|
||||
x509.KeyUsage(True, False, False, False, False, True, True, None, None),
|
||||
critical=True,
|
||||
)
|
||||
.sign(self.key, None)
|
||||
)
|
||||
atomic_private(
|
||||
path,
|
||||
json.dumps(
|
||||
{
|
||||
"key": base64.b64encode(self.key.private_bytes_raw()).decode(),
|
||||
"ca": pem(self.ca),
|
||||
}
|
||||
).encode(),
|
||||
)
|
||||
self.core_id = public_id("core_", self.key.public_key())
|
||||
|
||||
@staticmethod
|
||||
def builder(subject, public_key, days):
|
||||
now = datetime.now(UTC)
|
||||
return (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.public_key(public_key)
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(days=days))
|
||||
)
|
||||
|
||||
def leaf(self, node_id: str, public_key: Ed25519PublicKey) -> str:
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Mission Core Node")])
|
||||
return pem(
|
||||
self.builder(subject, public_key, 30)
|
||||
.issuer_name(self.ca.subject)
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.add_extension(
|
||||
x509.KeyUsage(True, False, False, False, False, False, False, None, None),
|
||||
critical=True,
|
||||
)
|
||||
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False)
|
||||
.sign(self.key, None)
|
||||
)
|
||||
|
||||
def server_context(self, address: str) -> ssl.SSLContext:
|
||||
if not private_address(address):
|
||||
raise PairingError("Сервер Core должен использовать частный адрес.")
|
||||
cert = (
|
||||
self.builder(self.ca.subject, self.key.public_key(), 30)
|
||||
.issuer_name(self.ca.subject)
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(address))]),
|
||||
critical=False,
|
||||
)
|
||||
.sign(self.key, None)
|
||||
)
|
||||
cert_path, key_path = self.root / f"server-{address}.pem", self.root / "server-key.pem"
|
||||
atomic_private(cert_path, (pem(cert) + pem(self.ca)).encode())
|
||||
atomic_private(
|
||||
key_path,
|
||||
self.key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
),
|
||||
)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
context.load_cert_chain(cert_path, key_path)
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
context.load_verify_locations(cadata=pem(self.ca))
|
||||
return context
|
||||
|
||||
|
||||
def node_request(invitation: dict, path: str, payload: dict) -> tuple[dict, Ed25519PublicKey, str]:
|
||||
address, port = endpoint(invitation["endpoint"], 8781)
|
||||
# The one-use secret is sent only AFTER authenticating the Node identity.
|
||||
# No ambient proxy, DNS lookup, redirect or system CA replaces this pin.
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
connection = http.client.HTTPSConnection(address, port, timeout=5, context=context)
|
||||
try:
|
||||
connection.connect()
|
||||
cert = x509.load_der_x509_certificate(connection.sock.getpeercert(binary_form=True))
|
||||
key = cert.public_key()
|
||||
now = datetime.now(UTC)
|
||||
if (
|
||||
not isinstance(key, Ed25519PublicKey)
|
||||
or public_id("node_", key) != invitation["node_id"]
|
||||
or not cert.not_valid_before_utc <= now < cert.not_valid_after_utc
|
||||
):
|
||||
raise PairingError("Идентичность БК не совпала с приглашением.")
|
||||
key.verify(cert.signature, cert.tbs_certificate_bytes)
|
||||
local = connection.sock.getsockname()[0]
|
||||
if not private_address(local):
|
||||
raise PairingError("Core не получил частный обратный адрес.")
|
||||
connection.request("POST", path, json.dumps(payload), {"Content-Type": "application/json"})
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise PairingError(
|
||||
"Node отклонил запрос: приглашение истекло, отменено или уже использовано."
|
||||
if response.status in (409, 410)
|
||||
else "Node не смог подтвердить привязку."
|
||||
)
|
||||
value = json.loads(response.read(65537))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid response")
|
||||
return value, key, local
|
||||
except PairingError:
|
||||
raise
|
||||
except Exception:
|
||||
# No invitation, peer-provided body, certificate or OS exception in API/logs.
|
||||
raise PairingError(
|
||||
"БК недоступен или не подтвердил защищённое соединение. "
|
||||
"Проверьте частную сеть и повторите."
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
+25
-1
@@ -112,6 +112,7 @@ from k1link.simulation.projects import SimulationProjectService, SimulationProje
|
||||
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
|
||||
from k1link.web.artifact_health_api import build_artifact_health_router
|
||||
from k1link.web.compute_contour_api import build_compute_contour_router
|
||||
from k1link.web.fleet_api import router as fleet_router
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||
@@ -897,7 +898,24 @@ async def _recorded_blueprint_resource_reaper() -> None:
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
async def app_lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from k1link.fleet.registry import FleetRegistry
|
||||
|
||||
fleet = None
|
||||
application.state.fleet = None
|
||||
try:
|
||||
fleet = FleetRegistry(session_store.data_dir / "fleet")
|
||||
fleet.start()
|
||||
application.state.fleet = fleet
|
||||
except (OSError, ValueError, sqlite3.Error):
|
||||
# Fail closed for pairing without taking down unrelated operator work.
|
||||
if fleet is not None:
|
||||
fleet.close()
|
||||
fleet = None
|
||||
logging.getLogger(__name__).error("Fleet trust storage unavailable; pairing disabled")
|
||||
reconciler: asyncio.Task[None] | None = None
|
||||
publication_reconciler: asyncio.Task[None] | None = None
|
||||
blueprint_reaper: asyncio.Task[None] | None = None
|
||||
@@ -920,6 +938,9 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
finally:
|
||||
from k1link.viewer.recorded import recorded_blueprint_sessions
|
||||
|
||||
application.state.fleet = None
|
||||
if fleet is not None:
|
||||
await asyncio.to_thread(fleet.close)
|
||||
if blueprint_reaper is not None:
|
||||
blueprint_reaper.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
@@ -950,6 +971,9 @@ app = FastAPI(
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1_024, compresslevel=5)
|
||||
|
||||
|
||||
app.include_router(fleet_router)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def request_validation_error_handler(
|
||||
_: Request,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Operator-only fleet admission; the separate private mTLS listener is in fleet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.fleet.registry import FleetRegistry
|
||||
from k1link.fleet.trust import PairingError
|
||||
|
||||
|
||||
def local_operator(request: Request) -> FleetRegistry:
|
||||
try:
|
||||
peer = ipaddress.ip_address(request.client.host)
|
||||
host = urlsplit(f"http://{request.headers.get('host', '')}")
|
||||
if not peer.is_loopback or host.hostname not in ("127.0.0.1", "localhost", "::1"):
|
||||
raise ValueError
|
||||
origin = request.headers.get("origin")
|
||||
if origin and origin != f"http://{request.headers['host']}":
|
||||
raise ValueError
|
||||
if request.headers.get("sec-fetch-site") == "cross-site":
|
||||
raise ValueError
|
||||
except (ValueError, AttributeError, KeyError):
|
||||
raise HTTPException(403, "Откройте Mission Core на компьютере оператора.") from None
|
||||
registry = getattr(request.app.state, "fleet", None)
|
||||
if registry is None:
|
||||
raise HTTPException(503, "Реестр аппаратов недоступен. Повторите подключение.")
|
||||
return registry
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
code: str = Field(min_length=1, max_length=4096)
|
||||
|
||||
|
||||
class AddRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
preview_id: str = Field(min_length=43, max_length=43)
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
platform: str = Field(pattern="^(ugv|uav|stationary|other)$")
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def fleet_list(response: Response, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return fleet.listing()
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
def fleet_preview(
|
||||
body: PreviewRequest,
|
||||
response: Response,
|
||||
fleet: Annotated[FleetRegistry, Depends(local_operator)],
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
return fleet.preview(body.code)
|
||||
except PairingError as error:
|
||||
raise HTTPException(409, str(error)) from None
|
||||
|
||||
|
||||
@router.post("")
|
||||
def fleet_add(
|
||||
body: AddRequest, response: Response, fleet: Annotated[FleetRegistry, Depends(local_operator)]
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
return fleet.add(body.preview_id, body.name, body.platform)
|
||||
except PairingError as error:
|
||||
raise HTTPException(409, str(error)) from None
|
||||
|
||||
|
||||
@router.delete("/{vehicle_id}")
|
||||
def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||
try:
|
||||
return fleet.revoke(vehicle_id)
|
||||
except PairingError as error:
|
||||
raise HTTPException(404, str(error)) from None
|
||||
@@ -0,0 +1,301 @@
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.fleet.registry import FleetRegistry
|
||||
from k1link.fleet.trust import SCHEMA, PairingError, parse_invitation, pem, public_id
|
||||
from k1link.web.fleet_api import router
|
||||
|
||||
|
||||
def code(value):
|
||||
return "MCN1." + base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(tmp_path, monkeypatch):
|
||||
fleet = FleetRegistry(tmp_path)
|
||||
key = Ed25519PrivateKey.generate()
|
||||
node_id = public_id("node_", key.public_key())
|
||||
invite = dict(
|
||||
schema=SCHEMA,
|
||||
node_id=node_id,
|
||||
id=secrets.token_urlsafe(32),
|
||||
secret=secrets.token_urlsafe(32),
|
||||
expires_at=int(time.time()) + 600,
|
||||
endpoint="https://192.168.20.4:8781",
|
||||
)
|
||||
calls = []
|
||||
|
||||
def request(invitation, path, payload):
|
||||
calls.append((path, payload))
|
||||
if path.endswith("/inspect"):
|
||||
return (
|
||||
dict(schema=SCHEMA, node_id=node_id, name="Test board", host={"os": "Linux"}),
|
||||
key.public_key(),
|
||||
"192.168.20.5",
|
||||
)
|
||||
if path.endswith("/offer"):
|
||||
return (
|
||||
dict(node_id=node_id, receipt=secrets.token_urlsafe(32)),
|
||||
key.public_key(),
|
||||
"192.168.20.5",
|
||||
)
|
||||
return dict(node_id=node_id), key.public_key(), "192.168.20.5"
|
||||
|
||||
monkeypatch.setattr("k1link.fleet.registry.node_request", request)
|
||||
monkeypatch.setattr(fleet, "listen", lambda address: None)
|
||||
yield fleet, invite, key, calls
|
||||
fleet.close()
|
||||
|
||||
|
||||
def create(setup):
|
||||
fleet, invite, _, _ = setup
|
||||
preview = fleet.preview(code(invite))
|
||||
row = fleet.add(preview["preview_id"], "Test vehicle", "ugv")
|
||||
return row, preview
|
||||
|
||||
|
||||
def heartbeat(row):
|
||||
return dict(
|
||||
schema=SCHEMA,
|
||||
node_id=row["node_id"],
|
||||
binding_id=row["binding"]["binding_id"],
|
||||
execution_binding={
|
||||
"node_id": row["node_id"],
|
||||
"agent_instance_id": "agent_test",
|
||||
"platform": "linux",
|
||||
},
|
||||
host={"hostname": "synthetic", "usb": [], "networks": []},
|
||||
devices=[],
|
||||
)
|
||||
|
||||
|
||||
def cert(row):
|
||||
return x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode()).public_bytes(
|
||||
serialization.Encoding.DER
|
||||
)
|
||||
|
||||
|
||||
def test_preview_add_is_durable_and_idempotent(setup):
|
||||
fleet, invite, _, calls = setup
|
||||
public, preview = create(setup)
|
||||
assert public["enrollment"] == "pending"
|
||||
assert public["connectivity"] == "offline"
|
||||
assert fleet.add(preview["preview_id"], "Test vehicle", "ugv")["id"] == public["id"]
|
||||
fleet.advance(public["id"])
|
||||
assert fleet.listing()["items"][0]["enrollment"] == "paired"
|
||||
assert fleet.listing()["items"][0]["connectivity"] == "offline"
|
||||
row = fleet.find(public["id"])
|
||||
assert row["invitation"] is None and row["receipt"] is None
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
|
||||
listed = fleet.listing()
|
||||
assert listed["items"][0]["connectivity"] == "online"
|
||||
assert invite["secret"] not in json.dumps(listed)
|
||||
assert "client_pem" not in json.dumps(listed)
|
||||
reopened = FleetRegistry(fleet.root)
|
||||
try:
|
||||
assert reopened.trust.core_id == fleet.trust.core_id
|
||||
assert reopened.listing()["items"][0]["id"] == public["id"]
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_lost_commit_ack_recovers_from_authenticated_heartbeat(setup, monkeypatch):
|
||||
fleet, _, _, _ = setup
|
||||
public, _ = create(setup)
|
||||
original = __import__("k1link.fleet.registry", fromlist=["node_request"]).node_request
|
||||
|
||||
def drop_ack(invitation, path, body):
|
||||
if path.endswith("/commit"):
|
||||
raise PairingError("Lost acknowledgement")
|
||||
return original(invitation, path, body)
|
||||
|
||||
monkeypatch.setattr("k1link.fleet.registry.node_request", drop_ack)
|
||||
with pytest.raises(PairingError):
|
||||
fleet.advance(public["id"])
|
||||
row = fleet.find(public["id"])
|
||||
assert row["receipt"] and row["enrollment"] == "pending"
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
|
||||
assert fleet.find(public["id"])["enrollment"] == "paired"
|
||||
|
||||
|
||||
def test_revoke_during_network_completion_cannot_resurrect(setup, monkeypatch):
|
||||
fleet, _, _, _ = setup
|
||||
public, _ = create(setup)
|
||||
original = __import__("k1link.fleet.registry", fromlist=["node_request"]).node_request
|
||||
|
||||
def revoke_first(invitation, path, body):
|
||||
value = original(invitation, path, body)
|
||||
fleet.revoke(public["id"])
|
||||
return value
|
||||
|
||||
monkeypatch.setattr("k1link.fleet.registry.node_request", revoke_first)
|
||||
fleet.advance(public["id"])
|
||||
row = fleet.find(public["id"])
|
||||
assert row["enrollment"] == "revoked"
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 410
|
||||
|
||||
|
||||
def test_repair_retains_vehicle_and_rejects_old_binding(setup):
|
||||
fleet, invite, _, _ = setup
|
||||
public, _ = create(setup)
|
||||
old = fleet.find(public["id"])
|
||||
fleet.revoke(public["id"])
|
||||
invite["id"] = secrets.token_urlsafe(32)
|
||||
updated, _ = create(setup)
|
||||
assert updated["id"] == public["id"] and updated["revision"] == 3
|
||||
assert fleet.receive(cert(old), "/v1/node/heartbeat", heartbeat(old))[0] == 410
|
||||
|
||||
|
||||
def test_rotation_has_bounded_old_certificate_grace(setup):
|
||||
fleet, _, key, _ = setup
|
||||
public, _ = create(setup)
|
||||
row = fleet.find(public["id"])
|
||||
original = x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode())
|
||||
soon = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(original.subject)
|
||||
.issuer_name(original.issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC) - timedelta(hours=1))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=1))
|
||||
.sign(fleet.trust.key, None)
|
||||
)
|
||||
row["binding"]["client_pem"] = pem(soon)
|
||||
fleet.save(row)
|
||||
status, reply = fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))
|
||||
assert status == 200 and reply["client_pem"] != pem(soon)
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200
|
||||
current = fleet.find(public["id"])
|
||||
current["certificate_previous"]["until"] = time.time() - 1
|
||||
fleet.save(current)
|
||||
assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"https://8.8.8.8:8781",
|
||||
"https://127.0.0.1:8781",
|
||||
"https://192.168.1.2:22",
|
||||
"https://user@192.168.1.2:8781",
|
||||
"https://192.168.1.2:8781/api",
|
||||
"http://192.168.1.2:8781",
|
||||
"https://local.test:8781",
|
||||
],
|
||||
)
|
||||
def test_invitation_never_targets_public_loopback_dns_or_other_ports(setup, address):
|
||||
_, invite, _, _ = setup
|
||||
invite["endpoint"] = address
|
||||
with pytest.raises(PairingError):
|
||||
parse_invitation(code(invite))
|
||||
|
||||
|
||||
def test_expiry_and_node_inventory_admission(setup):
|
||||
fleet, invite, _, _ = setup
|
||||
public, _ = create(setup)
|
||||
row = fleet.find(public["id"])
|
||||
row["invitation"]["expires_at"] = 0
|
||||
fleet.save(row)
|
||||
fleet.advance(public["id"])
|
||||
assert fleet.find(public["id"])["enrollment"] == "failed"
|
||||
invite["expires_at"] = 0
|
||||
with pytest.raises(PairingError):
|
||||
parse_invitation(code(invite))
|
||||
|
||||
|
||||
def test_operator_api_rejects_cross_origin_and_remote_peer(setup):
|
||||
fleet, _, _, _ = setup
|
||||
app = FastAPI()
|
||||
app.state.fleet = fleet
|
||||
app.include_router(router)
|
||||
with TestClient(app, base_url="http://127.0.0.1:8000", client=("127.0.0.1", 55555)) as client:
|
||||
assert client.get("/api/v1/fleet").status_code == 200
|
||||
assert (
|
||||
client.get("/api/v1/fleet", headers={"Origin": "https://attacker.test"}).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.get("/api/v1/fleet", headers={"Host": "attacker.test:8000"}).status_code == 403
|
||||
)
|
||||
assert client.post("/api/v1/fleet/preview", json={"code": "bad"}).status_code == 409
|
||||
with TestClient(
|
||||
app, base_url="http://127.0.0.1:8000", client=("192.168.20.6", 55555)
|
||||
) as client:
|
||||
assert client.get("/api/v1/fleet").status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_identity", [False, True])
|
||||
def test_bootstrap_pin_is_verified_before_secret_is_sent(setup, monkeypatch, wrong_identity):
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from k1link.fleet.trust import node_request
|
||||
|
||||
_, invite, key, _ = setup
|
||||
if wrong_identity:
|
||||
key = Ed25519PrivateKey.generate()
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic Node")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC) - timedelta(minutes=1))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(hours=1))
|
||||
.sign(key, None)
|
||||
.public_bytes(serialization.Encoding.DER)
|
||||
)
|
||||
sent = []
|
||||
|
||||
class Socket:
|
||||
def getpeercert(self, **_kwargs):
|
||||
return certificate
|
||||
|
||||
def getsockname(self):
|
||||
return ("192.168.20.5", 41000)
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
|
||||
def read(self, _limit):
|
||||
return b'{"ok":true}'
|
||||
|
||||
class Connection:
|
||||
sock = Socket()
|
||||
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def request(self, *args):
|
||||
sent.append(args)
|
||||
|
||||
def getresponse(self):
|
||||
return Response()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("k1link.fleet.trust.http.client.HTTPSConnection", Connection)
|
||||
if wrong_identity:
|
||||
with pytest.raises(PairingError):
|
||||
node_request(invite, "/v1/pair/inspect", {"secret": invite["secret"]})
|
||||
assert not sent
|
||||
else:
|
||||
assert node_request(invite, "/v1/pair/inspect", {"secret": invite["secret"]})[0] == {
|
||||
"ok": True
|
||||
}
|
||||
assert len(sent) == 1
|
||||
@@ -75,6 +75,29 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.2"
|
||||
@@ -96,6 +119,45 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-fast"
|
||||
version = "5.0.22"
|
||||
@@ -343,6 +405,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "bleak" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "foxglove-sdk" },
|
||||
{ name = "httpx" },
|
||||
@@ -373,6 +436,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "bleak", specifier = "==3.0.2" },
|
||||
{ name = "cryptography", specifier = ">=46,<47" },
|
||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
||||
{ name = "grpcio", marker = "extra == 'perception-stream'", specifier = ">=1.76,<2" },
|
||||
@@ -500,6 +564,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
|
||||
Reference in New Issue
Block a user