feat(node): pair onboard computers with the Core fleet through UI

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 21:16:13 +03:00
parent fc545f8440
commit e82d012907
29 changed files with 2442 additions and 19 deletions
+5 -1
View File
@@ -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 };
}
+5 -15
View File
@@ -16,6 +16,7 @@ export type WorkspaceKind =
| "map"
| "timeline"
| "missions"
| "vehicles"
| "catalog"
| "contour-health"
| "compute-modules"
@@ -193,23 +194,12 @@ export const workspaces: WorkspaceDefinition[] = [
id: "vehicles",
root: "fleet",
label: "Аппараты",
title: "Реестр аппаратов",
title: "Аппараты",
eyebrow: "ПАРК / РЕЕСТР",
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
description: "Аппараты и их бортовые компьютеры в частном контуре.",
icon: "apps",
kind: "catalog",
groups: [
{
title: "Идентичность аппарата",
description: "Никакой привязки продуктовой модели к конкретному производителю.",
capabilities: [
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
],
},
],
kind: "vehicles",
groups: [],
},
{
id: "local-device",
@@ -1,3 +1,4 @@
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import {
@@ -1167,6 +1168,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
return <TimelineWorkspace {...props} />;
case "missions":
return <MissionWorkspace {...props} />;
case "vehicles":
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "contour-health":
@@ -35,6 +35,7 @@ export interface LaboratoryViewAction {
}
export interface WorkspaceRendererProps {
fleetCreateRequest?: number;
definition: WorkspaceDefinition;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
@@ -0,0 +1,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; }
+6
View File
@@ -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:
+223
View File
@@ -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 = &copy
}
revocations := append([]CoreBinding(nil), p.state.Revocations...)
p.mu.Unlock()
wanted := make(map[string]bool)
if binding != nil {
wanted[binding.BindingID+digest(binding.ClientPEM)] = true
}
for _, b := range revocations {
wanted[b.BindingID+digest(b.ClientPEM)] = true
}
for key, c := range p.clients {
if !wanted[key] {
c.CloseIdleConnections()
delete(p.clients, key)
}
}
if binding != nil {
id, name := p.store.Public()
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
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:
}
}
}
+4
View File
@@ -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)
}
+1 -1
View File
@@ -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>;
}
+2
View File
@@ -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;
+2 -1
View File
@@ -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" },
+155
View File
@@ -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 браузера.
+1
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
"""Core-owned vehicle registry and private Node enrollment transport."""
+399
View File
@@ -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"]}
+78
View File
@@ -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)
+254
View File
@@ -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
View File
@@ -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,
+85
View File
@@ -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
+301
View File
@@ -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
Generated
+73
View File
@@ -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"