diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 9c39cff..c5e817c 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -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" ? ( Offline evaluation ) : activeDefinition.kind === "lab-archive" ? ( laboratoryAnnotation.control @@ -828,6 +831,7 @@ export default function App() { ) : ( void; refreshRuntime: () => void; resetConnectionScenario?: () => Promise; 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, diff --git a/apps/control-station/src/core/fleet/useFleet.ts b/apps/control-station/src/core/fleet/useFleet.ts new file mode 100644 index 0000000..18cc2c2 --- /dev/null +++ b/apps/control-station/src/core/fleet/useFleet.ts @@ -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(path = "", method = "GET", body?: unknown): Promise { + 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(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; + 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 }; +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 8020c2d..5e6872c 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -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", diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index e2fdfd9..adba290 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -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 ; case "missions": return ; + case "vehicles": + return ; case "catalog": return ; case "contour-health": diff --git a/apps/control-station/src/workspaces/contracts.ts b/apps/control-station/src/workspaces/contracts.ts index 70ad2b5..ee22b50 100644 --- a/apps/control-station/src/workspaces/contracts.ts +++ b/apps/control-station/src/workspaces/contracts.ts @@ -35,6 +35,7 @@ export interface LaboratoryViewAction { } export interface WorkspaceRendererProps { + fleetCreateRequest?: number; definition: WorkspaceDefinition; state: MissionRuntimeState | null; backendStatus: BackendStatus; diff --git a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx new file mode 100644 index 0000000..2b028ac --- /dev/null +++ b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx @@ -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(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [selected, setSelected] = useState(null); + const [revoking, setRevoking] = useState(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("/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("", "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
+ {fleet.error &&

{fleet.error}

} + {!adding && error &&

{error}

} + {detail ? <> +
+ {fleet.error ? "Нет свежих данных" : statusLabel(detail)}}> + {detail.notice &&

{detail.notice}

} +
Бортовой компьютер
{detail.node_id}
+
Последняя связь
{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}
+ {detail.host && <>
Имя БК в системе
{detail.host.hostname}
Операционная система
{detail.host.os}
Архитектура
{detail.host.architecture}
Процессоры
{detail.host.cpus}
Память
{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}
} +
+ {detail.enrollment !== "revoked" && } +
+

Устройства к аппарату ещё не подключены.

+ : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={ setSelected(item.id)}>} />
  • )}
    } + {preview ? : }}> +
    + + setName(event.target.value)} disabled={pending} autoComplete="off" /> + {preview ?
    Идентификатор БК
    {preview.node_id}
    Система
    {preview.host.os} · {preview.host.architecture}
    Адрес БК
    {preview.endpoint}
    : setCode(event.target.value)} />} + {error &&

    {error}

    } + +
    + 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; } }} /> +
    ; +} diff --git a/apps/control-station/src/workspaces/fleet/fleet.css b/apps/control-station/src/workspaces/fleet/fleet.css new file mode 100644 index 0000000..c47eb40 --- /dev/null +++ b/apps/control-station/src/workspaces/fleet/fleet.css @@ -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; } diff --git a/apps/node-agent/cmd/node-agent/main.go b/apps/node-agent/cmd/node-agent/main.go index 30b9248..a078276 100644 --- a/apps/node-agent/cmd/node-agent/main.go +++ b/apps/node-agent/cmd/node-agent/main.go @@ -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: diff --git a/apps/node-agent/internal/node/pairing.go b/apps/node-agent/internal/node/pairing.go new file mode 100644 index 0000000..024f7ae --- /dev/null +++ b/apps/node-agent/internal/node/pairing.go @@ -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 +} diff --git a/apps/node-agent/internal/node/pairing_crypto.go b/apps/node-agent/internal/node/pairing_crypto.go new file mode 100644 index 0000000..2479002 --- /dev/null +++ b/apps/node-agent/internal/node/pairing_crypto.go @@ -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) +} diff --git a/apps/node-agent/internal/node/pairing_listener.go b/apps/node-agent/internal/node/pairing_listener.go new file mode 100644 index 0000000..cd99870 --- /dev/null +++ b/apps/node-agent/internal/node/pairing_listener.go @@ -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 } diff --git a/apps/node-agent/internal/node/pairing_test.go b/apps/node-agent/internal/node/pairing_test.go new file mode 100644 index 0000000..fbc74b3 --- /dev/null +++ b/apps/node-agent/internal/node/pairing_test.go @@ -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") + } +} diff --git a/apps/node-agent/internal/node/pairing_transport.go b/apps/node-agent/internal/node/pairing_transport.go new file mode 100644 index 0000000..6c4ac3a --- /dev/null +++ b/apps/node-agent/internal/node/pairing_transport.go @@ -0,0 +1,321 @@ +package node + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "time" +) + +func (p *Pairing) localRoutes(mux *http.ServeMux, s *Server) { + mux.HandleFunc("GET /api/core", func(w http.ResponseWriter, r *http.Request) { + if s.authorized(w, r) { + reply(w, 200, p.status()) + } + }) + mux.HandleFunc("POST /api/core/invitation", func(w http.ResponseWriter, r *http.Request) { + if !s.authorized(w, r) { + return + } + var body struct { + Address string `json:"address"` + } + if !decode(w, r, &body) { + return + } + out, e := p.invite(body.Address) + if e != nil { + reply(w, 409, map[string]string{"error": e.Error()}) + return + } + reply(w, 200, out) + }) + mux.HandleFunc("DELETE /api/core", func(w http.ResponseWriter, r *http.Request) { + if !s.authorized(w, r) { + return + } + if e := p.cancel(); e != nil { + reply(w, 409, map[string]string{"error": "Не удалось отменить привязку. Повторите действие."}) + return + } + reply(w, 200, map[string]bool{"ok": true}) + }) +} +func pairDecode(w http.ResponseWriter, r *http.Request, value any) bool { + if r.Method != "POST" || r.Header.Get("Content-Type") != "application/json" || r.Header.Get("Origin") != "" { + http.Error(w, "Invalid request", 400) + return false + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384)) + decoder.DisallowUnknownFields() + if decoder.Decode(value) != nil || decoder.Decode(new(any)) != io.EOF { + http.Error(w, "Invalid request", 400) + return false + } + return true +} +func (p *Pairing) remoteHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + host, _, e := net.SplitHostPort(r.RemoteAddr) + if e != nil || !PrivateAddress(host) { + http.Error(w, "Private peer required", 403) + return + } + var body struct { + ID string `json:"id"` + Secret string `json:"secret"` + Binding *CoreBinding `json:"binding,omitempty"` + Receipt string `json:"receipt,omitempty"` + } + if !pairDecode(w, r, &body) { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if p.expire() != nil { + http.Error(w, "State unavailable", 503) + return + } + id, name := p.store.Public() + switch r.URL.Path { + case "/v1/pair/inspect": + if p.state.Phase != "inviting" || !p.checkInvitation(body.ID, body.Secret) { + http.Error(w, "Invitation expired, consumed or cancelled", 410) + return + } + reply(w, 200, map[string]any{"schema": PairSchema, "node_id": id, "name": name, "version": p.version, "host": p.inventory()}) + case "/v1/pair/offer": + if !p.checkInvitation(body.ID, body.Secret) || body.Binding == nil { + http.Error(w, "Invitation expired, consumed or cancelled", 410) + return + } + b := *body.Binding + if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 { + http.Error(w, "Invalid binding", 400) + return + } + raw, _ := json.Marshal(b) + hash := digest(string(raw)) + if p.state.Phase == "pending" || p.state.Phase == "paired" { + if p.state.Binding.OfferHash != hash { + http.Error(w, "Another Core already claimed this Node", 409) + return + } + reply(w, 200, map[string]string{"receipt": p.state.Binding.Receipt, "node_id": id}) + return + } + if p.state.Phase != "inviting" { + http.Error(w, "Invitation consumed", 410) + return + } + if _, e = bindingTLS(b, p.store.pairingKey()); e != nil { + http.Error(w, "Invalid Core trust", 400) + return + } + b.Receipt = token() + b.OfferHash = hash + b.ExpiresAt = p.state.Invitation.ExpiresAt + next := p.state + next.Phase = "pending" + next.Binding = &b + if p.save(next) != nil { + http.Error(w, "State unavailable", 503) + return + } + reply(w, 200, map[string]string{"receipt": b.Receipt, "node_id": id}) + case "/v1/pair/commit": + b := p.state.Binding + if b == nil || body.ID != b.BindingID || body.Receipt == "" || digest(body.Receipt) != digest(b.Receipt) || (p.state.Phase != "pending" && p.state.Phase != "paired") { + http.Error(w, "No matching pending binding", 409) + return + } + next := p.state + next.Phase = "paired" + if p.save(next) != nil { + http.Error(w, "State unavailable", 503) + return + } + reply(w, 200, map[string]any{"node_id": id, "binding_id": b.BindingID, "phase": "paired"}) + default: + http.NotFound(w, r) + } + }) +} + +func (p *Pairing) Run(ctx context.Context) { + go p.channel(ctx) + var server *http.Server + endpoint := "" + closeServer := func() { + if server != nil { + timeout, cancel := context.WithTimeout(context.Background(), time.Second) + _ = server.Shutdown(timeout) + cancel() + server = nil + } + endpoint = "" + } + defer closeServer() + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + p.mu.Lock() + _ = p.expire() + desired := "" + if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation != nil { + desired = p.state.Invitation.Endpoint + } + p.mu.Unlock() + if desired != endpoint { + closeServer() + if desired != "" { + u, _ := url.Parse(desired) + cert, e := bootstrapCertificate(p.store.pairingKey(), u.Hostname()) + var listener net.Listener + if e == nil { + listener, e = net.Listen("tcp4", u.Host) + } + p.mu.Lock() + if e != nil { + p.listenError = "Не удалось открыть частное подключение. Проверьте адрес и создайте приглашение повторно." + } else { + p.listenError = "" + } + p.mu.Unlock() + if e == nil { + server = &http.Server{Handler: p.remoteHandler(), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}} + endpoint = desired + go func(s *http.Server, l net.Listener) { _ = s.Serve(tls.NewListener(l, s.TLSConfig)) }(server, &pairingListener{Listener: listener, slots: make(chan struct{}, 16), done: make(chan struct{})}) + } + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} +func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload any) (map[string]json.RawMessage, int, error) { + config, e := bindingTLS(b, p.store.pairingKey()) + if e != nil { + return nil, 0, e + } + cacheKey := b.BindingID + digest(b.ClientPEM) + client := p.clients[cacheKey] + if client == nil { + transport := &http.Transport{TLSClientConfig: config, Proxy: nil, MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 15 * time.Second, TLSHandshakeTimeout: 4 * time.Second, DialContext: (&net.Dialer{Timeout: 4 * time.Second}).DialContext} + client = &http.Client{Transport: transport, Timeout: 8 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirects forbidden") }} + p.clients[cacheKey] = client + } + data, e := json.Marshal(payload) + if e != nil { + return nil, 0, e + } + request, e := http.NewRequestWithContext(ctx, "POST", b.Endpoint+path, bytes.NewReader(data)) + if e != nil { + return nil, 0, e + } + request.Header.Set("Content-Type", "application/json") + response, e := client.Do(request) + if e != nil { + return nil, 0, e + } + defer response.Body.Close() + var out map[string]json.RawMessage + if json.NewDecoder(io.LimitReader(response.Body, 16384)).Decode(&out) != nil { + return nil, response.StatusCode, errors.New("invalid Core response") + } + return out, response.StatusCode, nil +} +func (p *Pairing) channel(ctx context.Context) { + instance := "agent_" + token() + timer := time.NewTicker(5 * time.Second) + defer timer.Stop() + defer func() { + for _, c := range p.clients { + c.CloseIdleConnections() + } + }() + for { + p.mu.Lock() + var binding *CoreBinding + if p.state.Phase == "paired" && p.state.Binding != nil { + copy := *p.state.Binding + binding = © + } + revocations := append([]CoreBinding(nil), p.state.Revocations...) + p.mu.Unlock() + wanted := make(map[string]bool) + if binding != nil { + wanted[binding.BindingID+digest(binding.ClientPEM)] = true + } + for _, b := range revocations { + wanted[b.BindingID+digest(b.ClientPEM)] = true + } + for key, c := range p.clients { + if !wanted[key] { + c.CloseIdleConnections() + delete(p.clients, key) + } + } + if binding != nil { + id, name := p.store.Public() + payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}} + result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload) + p.mu.Lock() + if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID { + if e == nil && status == 200 { + p.connection = "online" + p.lastSeen = p.now().Unix() + var cert string + if json.Unmarshal(result["client_pem"], &cert) == nil && cert != "" && cert != binding.ClientPEM { + next := *binding + next.ClientPEM = cert + if _, e := bindingTLS(next, p.store.pairingKey()); e == nil { + state := p.state + state.Binding = &next + _ = p.save(state) + } + } + } else if e == nil && status == 410 { + next := p.state + next.Phase = "revoked" + _ = p.save(next) + p.connection = "revoked" + } else { + p.connection = "offline" + } + } + p.mu.Unlock() + } + for _, b := range revocations { + id, _ := p.store.Public() + _, status, e := p.send(ctx, b, "/v1/node/unpair", map[string]string{"schema": PairSchema, "binding_id": b.BindingID, "node_id": id}) + if (e == nil && (status == 200 || status == 410)) || clientExpired(b) { + p.mu.Lock() + next := p.state + next.Revocations = nil + for _, item := range p.state.Revocations { + if item.BindingID != b.BindingID { + next.Revocations = append(next.Revocations, item) + } + } + _ = p.save(next) + p.mu.Unlock() + } + } + select { + case <-ctx.Done(): + return + case <-timer.C: + } + } +} diff --git a/apps/node-agent/internal/node/server.go b/apps/node-agent/internal/node/server.go index 75a492f..bb7e9c1 100644 --- a/apps/node-agent/internal/node/server.go +++ b/apps/node-agent/internal/node/server.go @@ -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) } diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 2bcf656..c9e5949 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -13,7 +13,7 @@ import tarfile ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.4.2" +VERSION = "0.5.0" BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af" diff --git a/apps/node-agent/ui/src/CoreConnectionView.tsx b/apps/node-agent/ui/src/CoreConnectionView.tsx new file mode 100644 index 0000000..52c1c48 --- /dev/null +++ b/apps/node-agent/ui/src/CoreConnectionView.tsx @@ -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(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("/api/core"); setValue(next); setAvailable(true); return next; } + useEffect(() => { + let active = true; + let timer: ReturnType; + async function poll() { + try { const next = await request("/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
    {label}}> + {!value ? : <> +
    Идентификатор БК
    {value.node_id}
    + {value.binding && <>
    Core
    {value.binding.core_name}
    Идентификатор Core
    {value.binding.core_id}
    Частный адрес Core
    {value.binding.endpoint}
    } + {value.last_seen > 0 &&
    Последняя связь
    {new Date(value.last_seen * 1000).toLocaleString("ru-RU")}
    } +
    + {value.notice &&

    {value.notice}

    } + {paired ?

    БК сохраняет привязку при перезапуске и восстанавливает соединение автоматически. Для подключения к другому Core сначала отзовите текущую привязку.

    : value.phase === "pending" ? :
    +

    Выберите адрес, доступный компьютеру с Mission Core: в общей локальной сети или Tailscale. Создайте приглашение и вставьте его в Core: «Парк → Аппараты → Добавить аппарат».

    +