fix(node): restore Linux network inventory and expose read failures

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 18:47:12 +03:00
parent 79911eb316
commit 15bb793e5e
10 changed files with 110 additions and 43 deletions
+4
View File
@@ -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
+6 -4
View File
@@ -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"}}
+45 -34
View File
@@ -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
}
@@ -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)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.3.1"
VERSION = "0.3.2"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
@@ -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
+2 -1
View File
@@ -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 <SettingsCard title="Сводка БК" description="Подключения и доступ к бортовому компьютеру.">
<ResourceList aria-label="Сводка подключений БК">
<li><ResourceRow icon={<Icon name="network" />} title="Сеть" description="Включённые интерфейсы с назначенными адресами" status={<StatusBadge tone={networks.length ? "neutral" : "warning"}>{networks.length}</StatusBadge>} actions={<Button onClick={() => openView("network")}>Открыть сеть</Button>} /></li>
<li><ResourceRow icon={<Icon name="network" />} title="Сеть" description="Включённые интерфейсы с назначенными адресами" status={<StatusBadge tone={networkReadable && networks.length ? "neutral" : "warning"}>{networkReadable ? networks.length : "Нет сведений"}</StatusBadge>} actions={<Button onClick={() => openView("network")}>Открыть сеть</Button>} /></li>
<li><ResourceRow icon={<Icon name="camera" />} title="USB-устройства" description="Обнаружены операционной системой" status={<StatusBadge>{value.host.usb_readable ? value.host.usb.length : "Нет сведений"}</StatusBadge>} actions={<Button onClick={() => openView("usb")}>Открыть USB</Button>} /></li>
<li><ResourceRow icon={tailnet.checked ? <Icon name="globe" /> : <ActivityIndicator size="compact" />} title="Tailscale" description="Частная сеть" status={<StatusBadge tone={tailnet.value?.online ? "success" : "neutral"}>{tailscaleLabel(tailnet.value, tailnet.checked)}</StatusBadge>} actions={<Button onClick={() => openView("tailscale")}>Открыть Tailscale</Button>} /></li>
<li><ResourceRow icon={loading ? <ActivityIndicator size="compact" /> : <Icon name="key" />} title="SSH" description="Удалённое обслуживание БК" metadata={access ? `Разрешено ключей: ${access.keys.length}` : undefined} status={<StatusBadge tone={access?.ssh_ready ? "success" : "neutral"}>{loading ? "Проверяем" : !access ? "Нет сведений" : access.ssh_ready ? "Отвечает локально" : "Не отвечает"}</StatusBadge>} actions={<Button onClick={() => openView("ssh")}>Настроить SSH</Button>} /></li>
+1 -1
View File
@@ -2,7 +2,7 @@ import { Button, Icon, ResourceList, ResourceRow, SettingsCard, StatusBadge } fr
import type { Status } from "./api";
export function NetworkView({ value }: { value: Status }) {
return <div className="node-content"><SettingsCard title="Сетевые подключения" description="Интерфейсы и адреса бортового компьютера.">
{value.host.networks.length === 0 ? <p className="node-note">Сетевые интерфейсы не обнаружены.</p> : <ResourceList aria-label="Сетевые интерфейсы">{value.host.networks.map(network => <li key={network.name}><ResourceRow icon={<Icon name="network" />} title={network.name} description={network.addresses.join(" · ") || "Нет назначенного адреса"} status={<StatusBadge tone={network.up ? "neutral" : "warning"}>{network.up ? "Включён" : "Выключен"}</StatusBadge>} /></li>)}</ResourceList>}
{!value.host.networks_readable ? <p className="node-note" role="status">Не удалось получить сетевые интерфейсы. Повторите обновление.</p> : value.host.networks.length === 0 ? <p className="node-note">Сетевые интерфейсы не обнаружены.</p> : <ResourceList aria-label="Сетевые интерфейсы">{value.host.networks.map(network => <li key={network.name}><ResourceRow icon={<Icon name="network" />} title={network.name} description={!network.addresses_readable ? "Адреса недоступны" : network.addresses.join(" · ") || "Нет назначенного адреса"} status={<StatusBadge tone={network.up ? "neutral" : "warning"}>{network.up ? "Включён" : "Выключен"}</StatusBadge>} /></li>)}</ResourceList>}
</SettingsCard><p className="node-note">Наличие адреса не подтверждает доступность другого компьютера или устройства.</p></div>;
}
export function USBView({ value }: { value: Status }) {
+2 -1
View File
@@ -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[];
};
@@ -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 изолированной ноды подтверждены
«Нет сведений» в сводке, сообщение об ошибке на странице сети и появление
интерфейса после восстановления и нажатия «Обновить сведения».