fix(node): restore Linux network inventory and expose read failures
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 изолированной ноды подтверждены
|
||||
«Нет сведений» в сводке, сообщение об ошибке на странице сети и появление
|
||||
интерфейса после восстановления и нажатия «Обновить сведения».
|
||||
|
||||
Reference in New Issue
Block a user