From 15bb793e5e67e00262b111143438e10998218452 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 5 Sep 2026 18:47:12 +0300 Subject: [PATCH] fix(node): restore Linux network inventory and expose read failures --- apps/node-agent/README.md | 4 + apps/node-agent/_qa/main.go | 10 ++- apps/node-agent/internal/node/inventory.go | 79 +++++++++++-------- .../internal/node/inventory_test.go | 31 ++++++++ apps/node-agent/packaging/build_deb.py | 2 +- .../packaging/mission-core-node.service | 4 +- apps/node-agent/ui/src/BoardSummary.tsx | 3 +- apps/node-agent/ui/src/InventoryViews.tsx | 2 +- apps/node-agent/ui/src/api.ts | 3 +- .../03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md | 15 ++++ 10 files changed, 110 insertions(+), 43 deletions(-) create mode 100644 apps/node-agent/internal/node/inventory_test.go diff --git a/apps/node-agent/README.md b/apps/node-agent/README.md index 656333c..974a42d 100644 --- a/apps/node-agent/README.md +++ b/apps/node-agent/README.md @@ -1,5 +1,9 @@ # Mission Core Node — Ubuntu system configuration candidate +0.3.2 also repairs Linux interface inventory: the unprivileged service admits +AF_NETLINK for OS metadata reads while retaining an empty capability set. +Inventory and UI distinguish a failed network/address read from an empty list. + 0.3.1 consolidates host inventory, USB, Tailscale and SSH under «Система». «Обзор БК» contains host facts, the board name and an observed connectivity summary. The separate configuration page and redundant Node health badge are diff --git a/apps/node-agent/_qa/main.go b/apps/node-agent/_qa/main.go index 1137ce0..9a7ac03 100644 --- a/apps/node-agent/_qa/main.go +++ b/apps/node-agent/_qa/main.go @@ -15,14 +15,16 @@ import ( ) func main() { - dir := "/private/tmp/mc-node-ui-031-qa" + dir := "/private/tmp/mc-node-ui-032-qa" store, err := node.OpenStore(dir); if err != nil { log.Fatal(err) } assets, _ := fs.Sub(web.Assets, "dist") memory, available := uint64(8388608), uint64(5242880) - app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.3.1-qa", Inventory: func() node.Inventory { - return node.Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname:"qa-board", OS:"Ubuntu 24.04.4 LTS", Architecture:"amd64", CPUs:8, MemoryKiB:&memory, AvailableKiB:&available, - Networks: []node.Network{{Name:"ethernet-qa", Up:true, Addresses:[]string{"192.0.2.10/24"}}}, + app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.3.2-qa", Inventory: func() node.Inventory { + inventory := node.Inventory{CollectedAt: time.Now().UTC().Format(time.RFC3339), Hostname:"qa-board", OS:"Ubuntu 24.04.4 LTS", Architecture:"amd64", CPUs:8, MemoryKiB:&memory, AvailableKiB:&available, + NetworksReadable:true, Networks: []node.Network{{Name:"ethernet-qa", Up:true, AddressesReadable:true, Addresses:[]string{"192.0.2.10/24"}}}, USB:[]node.USB{{Port:"2-1", Vendor:"8086", ProductID:"0b5c", Product:"Intel RealSense D455 · QA", Speed:"5000"}}, USBReadable:true, Warnings:[]string{}} + if _, err := os.Stat(filepath.Join(dir,"network-unavailable")); err == nil { inventory.NetworksReadable=false; inventory.Networks=[]node.Network{}; inventory.Warnings=[]string{"Не удалось прочитать сетевые интерфейсы"} } + return inventory }, Access: &node.AccessStore{Path:filepath.Join(dir,"ssh-keys.json"), Users:func()[]string{return []string{"operator"}}}, Tailscale:func()node.TailscaleStatus { if _, err := os.Stat(filepath.Join(dir,"offline")); err == nil { return node.TailscaleStatus{Installed:true, State:"unavailable", Addresses:[]string{}} } return node.TailscaleStatus{Installed:true, State:"Running", Online:true, Addresses:[]string{"100.64.0.10"}} diff --git a/apps/node-agent/internal/node/inventory.go b/apps/node-agent/internal/node/inventory.go index e8a5cd7..c636dbe 100644 --- a/apps/node-agent/internal/node/inventory.go +++ b/apps/node-agent/internal/node/inventory.go @@ -12,9 +12,10 @@ import ( ) type Network struct { - Name string `json:"name"` - Up bool `json:"up"` - Addresses []string `json:"addresses"` + Name string `json:"name"` + Up bool `json:"up"` + Addresses []string `json:"addresses"` + AddressesReadable bool `json:"addresses_readable"` } type USB struct { Port string `json:"port"` @@ -24,17 +25,18 @@ type USB struct { Speed string `json:"speed_mbps"` } type Inventory struct { - CollectedAt string `json:"collected_at"` - Hostname string `json:"hostname"` - OS string `json:"os"` - Architecture string `json:"architecture"` - CPUs int `json:"cpus"` - MemoryKiB *uint64 `json:"memory_kib"` - AvailableKiB *uint64 `json:"available_kib"` - Networks []Network `json:"networks"` - USB []USB `json:"usb"` - USBReadable bool `json:"usb_readable"` - Warnings []string `json:"warnings"` + CollectedAt string `json:"collected_at"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Architecture string `json:"architecture"` + CPUs int `json:"cpus"` + MemoryKiB *uint64 `json:"memory_kib"` + AvailableKiB *uint64 `json:"available_kib"` + Networks []Network `json:"networks"` + NetworksReadable bool `json:"networks_readable"` + USB []USB `json:"usb"` + USBReadable bool `json:"usb_readable"` + Warnings []string `json:"warnings"` } // Host reads only local kernel/OS metadata. It never probes network devices, @@ -70,26 +72,9 @@ func Host(root string) Inventory { if v.MemoryKiB == nil { v.Warnings = append(v.Warnings, "Сведения о памяти недоступны") } - interfaces, err := net.Interfaces() - if err != nil { - v.Warnings = append(v.Warnings, "Не удалось прочитать сетевые интерфейсы") - } - for _, it := range interfaces { - if it.Flags&net.FlagLoopback != 0 { - continue - } - n := Network{Name: it.Name, Up: it.Flags&net.FlagUp != 0, Addresses: []string{}} - addresses, e := it.Addrs() - if e != nil { - v.Warnings = append(v.Warnings, "Адреса интерфейса "+it.Name+" недоступны") - } - for _, a := range addresses { - n.Addresses = append(n.Addresses, a.String()) - } - sort.Strings(n.Addresses) - v.Networks = append(v.Networks, n) - } - sort.Slice(v.Networks, func(i, j int) bool { return v.Networks[i].Name < v.Networks[j].Name }) + var warnings []string + v.Networks, v.NetworksReadable, warnings = readNetworks(net.Interfaces, func(it net.Interface) ([]net.Addr, error) { return it.Addrs() }) + v.Warnings = append(v.Warnings, warnings...) entries, err := os.ReadDir(filepath.Join(root, "sys/bus/usb/devices")) v.USBReadable = err == nil if err != nil { @@ -105,3 +90,29 @@ func Host(root string) Inventory { } return v } + +func readNetworks(list func() ([]net.Interface, error), addrs func(net.Interface) ([]net.Addr, error)) ([]Network, bool, []string) { + result, warnings := []Network{}, []string{} + interfaces, err := list() + if err != nil { + return result, false, []string{"Не удалось прочитать сетевые интерфейсы"} + } + for _, it := range interfaces { + if it.Flags&net.FlagLoopback != 0 { + continue + } + addresses, err := addrs(it) + n := Network{Name: it.Name, Up: it.Flags&net.FlagUp != 0, Addresses: []string{}, AddressesReadable: err == nil} + if err != nil { + warnings = append(warnings, "Адреса интерфейса "+it.Name+" недоступны") + } else { + for _, a := range addresses { + n.Addresses = append(n.Addresses, a.String()) + } + } + sort.Strings(n.Addresses) + result = append(result, n) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result, true, warnings +} diff --git a/apps/node-agent/internal/node/inventory_test.go b/apps/node-agent/internal/node/inventory_test.go new file mode 100644 index 0000000..31a9e3a --- /dev/null +++ b/apps/node-agent/internal/node/inventory_test.go @@ -0,0 +1,31 @@ +package node + +import ( + "errors" + "net" + "testing" +) + +func TestNetworkInventoryDistinguishesFailureFromEmpty(t *testing.T) { + denied := errors.New("read denied") + for _, failure := range []error{nil, denied} { + rows, readable, warnings := readNetworks(func() ([]net.Interface, error) { return nil, failure }, func(net.Interface) ([]net.Addr, error) { t.Fatal("no interface to read"); return nil, nil }) + if len(rows) != 0 || readable != (failure == nil) || (len(warnings) > 0) != (failure != nil) { + t.Fatal(rows, readable, warnings) + } + } +} + +func TestNetworkAddressFailureKeepsInterfaceWithoutInventingAddresses(t *testing.T) { + rows, readable, warnings := readNetworks(func() ([]net.Interface, error) { + return []net.Interface{{Name: "loop", Flags: net.FlagLoopback}, {Name: "ethernet", Flags: net.FlagUp}}, nil + }, func(it net.Interface) ([]net.Addr, error) { + if it.Name != "ethernet" { + t.Fatal("read loopback") + } + return []net.Addr{&net.IPAddr{IP: net.ParseIP("192.0.2.1")}}, errors.New("partial result") + }) + if !readable || len(rows) != 1 || rows[0].Name != "ethernet" || !rows[0].Up || rows[0].AddressesReadable || len(rows[0].Addresses) != 0 || len(warnings) != 1 { + t.Fatal(rows, readable, warnings) + } +} diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index ee1e630..759c4b1 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.3.1" +VERSION = "0.3.2" BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af" diff --git a/apps/node-agent/packaging/mission-core-node.service b/apps/node-agent/packaging/mission-core-node.service index ab374d2..cbf979a 100644 --- a/apps/node-agent/packaging/mission-core-node.service +++ b/apps/node-agent/packaging/mission-core-node.service @@ -23,7 +23,9 @@ ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes RestrictSUIDSGID=yes -RestrictAddressFamilies=AF_UNIX AF_INET +# Go reads interface/address inventory through route netlink on Linux. +# CAP_NET_ADMIN stays absent; this does not grant network reconfiguration. +RestrictAddressFamilies=AF_UNIX AF_INET AF_NETLINK CapabilityBoundingSet= LockPersonality=yes LimitNOFILE=1024 diff --git a/apps/node-agent/ui/src/BoardSummary.tsx b/apps/node-agent/ui/src/BoardSummary.tsx index 6c5d537..d598dc6 100644 --- a/apps/node-agent/ui/src/BoardSummary.tsx +++ b/apps/node-agent/ui/src/BoardSummary.tsx @@ -8,9 +8,10 @@ export function BoardSummary({ value, failure, openView }: { value: Status; fail const { access, loading } = useAccess(value.host.collected_at, failure); const tailnet = useTailscaleStatus(failure, value.host.collected_at); const networks = value.host.networks.filter(item => item.name !== "lo" && item.name !== "lo0" && item.up && item.addresses.length > 0); + const networkReadable = value.host.networks_readable && value.host.networks.every(item => item.addresses_readable); return -
  • } title="Сеть" description="Включённые интерфейсы с назначенными адресами" status={{networks.length}} actions={} />
  • +
  • } title="Сеть" description="Включённые интерфейсы с назначенными адресами" status={{networkReadable ? networks.length : "Нет сведений"}} actions={} />
  • } title="USB-устройства" description="Обнаружены операционной системой" status={{value.host.usb_readable ? value.host.usb.length : "Нет сведений"}} actions={} />
  • : } title="Tailscale" description="Частная сеть" status={{tailscaleLabel(tailnet.value, tailnet.checked)}} actions={} />
  • : } title="SSH" description="Удалённое обслуживание БК" metadata={access ? `Разрешено ключей: ${access.keys.length}` : undefined} status={{loading ? "Проверяем" : !access ? "Нет сведений" : access.ssh_ready ? "Отвечает локально" : "Не отвечает"}} actions={} />
  • diff --git a/apps/node-agent/ui/src/InventoryViews.tsx b/apps/node-agent/ui/src/InventoryViews.tsx index 1376fef..c6962ca 100644 --- a/apps/node-agent/ui/src/InventoryViews.tsx +++ b/apps/node-agent/ui/src/InventoryViews.tsx @@ -2,7 +2,7 @@ import { Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } fr import type { Status } from "./api"; export function NetworkView({ value }: { value: Status }) { return
    - {value.host.networks.length === 0 ?

    Сетевые интерфейсы не обнаружены.

    : {value.host.networks.map(network =>
  • } title={network.name} description={network.addresses.join(" · ") || "Нет назначенного адреса"} status={{network.up ? "Включён" : "Выключен"}} />
  • )}
    } + {!value.host.networks_readable ?

    Не удалось получить сетевые интерфейсы. Повторите обновление.

    : value.host.networks.length === 0 ?

    Сетевые интерфейсы не обнаружены.

    : {value.host.networks.map(network =>
  • } title={network.name} description={!network.addresses_readable ? "Адреса недоступны" : network.addresses.join(" · ") || "Нет назначенного адреса"} status={{network.up ? "Включён" : "Выключен"}} />
  • )}
    }

    Наличие адреса не подтверждает доступность другого компьютера или устройства.

    ; } export function USBView({ value }: { value: Status }) { diff --git a/apps/node-agent/ui/src/api.ts b/apps/node-agent/ui/src/api.ts index 2eca10b..45c3571 100644 --- a/apps/node-agent/ui/src/api.ts +++ b/apps/node-agent/ui/src/api.ts @@ -3,7 +3,8 @@ export interface Status { host: { collected_at: string; hostname: string; os: string; architecture: string; cpus: number; memory_kib: number | null; available_kib: number | null; - networks: { name: string; up: boolean; addresses: string[] }[]; + networks: { name: string; up: boolean; addresses: string[]; addresses_readable: boolean }[]; + networks_readable: boolean; usb: { port: string; vendor: string; product_id: string; product: string; speed_mbps: string }[]; usb_readable: boolean; warnings: string[]; }; diff --git a/docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md b/docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md index 83f516e..e572494 100644 --- a/docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md +++ b/docs/node/03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md @@ -177,3 +177,18 @@ ActivityIndicator, ToastStack. Новых визуальных примитив Typecheck, production build и существующий UI boundary прошли. Это GUI-проверка перестановки функций, не закрытие приёмки чистой установки или сопряжения. Аппаратная установка и её результат фиксируются отдельно в отчёте Ops. + +### Поправка 0.3.2 по результатам физической проверки + +На Mini обнаружена ошибка прежнего системного профиля: `net.Interfaces()` +не мог читать route netlink при `RestrictAddressFamilies=AF_UNIX AF_INET`. +Приложение при этом показывало пустую сеть. В разрешённые семейства службы +добавлен AF_NETLINK; служба остаётся непривилегированной с пустым +CapabilityBoundingSet, без CAP_NET_ADMIN. Новый контракт отдельно сообщает +`networks_readable` и `addresses_readable`. Ошибка чтения больше не означает +ноль подключений или отсутствие назначенного адреса. + +Go race tests проверяют ошибку чтения отдельно от пустой сети и неполное чтение +адресов без вымышленных данных. Через UI изолированной ноды подтверждены +«Нет сведений» в сводке, сообщение об ошибке на странице сети и появление +интерфейса после восстановления и нажатия «Обновить сведения».