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
|
# 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 «Система».
|
0.3.1 consolidates host inventory, USB, Tailscale and SSH under «Система».
|
||||||
«Обзор БК» contains host facts, the board name and an observed connectivity
|
«Обзор БК» contains host facts, the board name and an observed connectivity
|
||||||
summary. The separate configuration page and redundant Node health badge are
|
summary. The separate configuration page and redundant Node health badge are
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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) }
|
store, err := node.OpenStore(dir); if err != nil { log.Fatal(err) }
|
||||||
assets, _ := fs.Sub(web.Assets, "dist")
|
assets, _ := fs.Sub(web.Assets, "dist")
|
||||||
memory, available := uint64(8388608), uint64(5242880)
|
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 {
|
app := &node.Server{Store: store, Assets: assets, Origin: "http://127.0.0.1:8780", Version: "0.3.2-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,
|
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,
|
||||||
Networks: []node.Network{{Name:"ethernet-qa", Up:true, Addresses:[]string{"192.0.2.10/24"}}},
|
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{}}
|
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 {
|
}, 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{}} }
|
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"}}
|
return node.TailscaleStatus{Installed:true, State:"Running", Online:true, Addresses:[]string{"100.64.0.10"}}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Network struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Up bool `json:"up"`
|
Up bool `json:"up"`
|
||||||
Addresses []string `json:"addresses"`
|
Addresses []string `json:"addresses"`
|
||||||
|
AddressesReadable bool `json:"addresses_readable"`
|
||||||
}
|
}
|
||||||
type USB struct {
|
type USB struct {
|
||||||
Port string `json:"port"`
|
Port string `json:"port"`
|
||||||
@@ -32,6 +33,7 @@ type Inventory struct {
|
|||||||
MemoryKiB *uint64 `json:"memory_kib"`
|
MemoryKiB *uint64 `json:"memory_kib"`
|
||||||
AvailableKiB *uint64 `json:"available_kib"`
|
AvailableKiB *uint64 `json:"available_kib"`
|
||||||
Networks []Network `json:"networks"`
|
Networks []Network `json:"networks"`
|
||||||
|
NetworksReadable bool `json:"networks_readable"`
|
||||||
USB []USB `json:"usb"`
|
USB []USB `json:"usb"`
|
||||||
USBReadable bool `json:"usb_readable"`
|
USBReadable bool `json:"usb_readable"`
|
||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
@@ -70,26 +72,9 @@ func Host(root string) Inventory {
|
|||||||
if v.MemoryKiB == nil {
|
if v.MemoryKiB == nil {
|
||||||
v.Warnings = append(v.Warnings, "Сведения о памяти недоступны")
|
v.Warnings = append(v.Warnings, "Сведения о памяти недоступны")
|
||||||
}
|
}
|
||||||
interfaces, err := net.Interfaces()
|
var warnings []string
|
||||||
if err != nil {
|
v.Networks, v.NetworksReadable, warnings = readNetworks(net.Interfaces, func(it net.Interface) ([]net.Addr, error) { return it.Addrs() })
|
||||||
v.Warnings = append(v.Warnings, "Не удалось прочитать сетевые интерфейсы")
|
v.Warnings = append(v.Warnings, 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 })
|
|
||||||
entries, err := os.ReadDir(filepath.Join(root, "sys/bus/usb/devices"))
|
entries, err := os.ReadDir(filepath.Join(root, "sys/bus/usb/devices"))
|
||||||
v.USBReadable = err == nil
|
v.USBReadable = err == nil
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,3 +90,29 @@ func Host(root string) Inventory {
|
|||||||
}
|
}
|
||||||
return v
|
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]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.3.1"
|
VERSION = "0.3.2"
|
||||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ ProtectKernelTunables=yes
|
|||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=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=
|
CapabilityBoundingSet=
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
LimitNOFILE=1024
|
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 { access, loading } = useAccess(value.host.collected_at, failure);
|
||||||
const tailnet = useTailscaleStatus(failure, value.host.collected_at);
|
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 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="Подключения и доступ к бортовому компьютеру.">
|
return <SettingsCard title="Сводка БК" description="Подключения и доступ к бортовому компьютеру.">
|
||||||
<ResourceList aria-label="Сводка подключений БК">
|
<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={<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={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>
|
<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";
|
import type { Status } from "./api";
|
||||||
export function NetworkView({ value }: { value: Status }) {
|
export function NetworkView({ value }: { value: Status }) {
|
||||||
return <div className="node-content"><SettingsCard title="Сетевые подключения" description="Интерфейсы и адреса бортового компьютера.">
|
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>;
|
</SettingsCard><p className="node-note">Наличие адреса не подтверждает доступность другого компьютера или устройства.</p></div>;
|
||||||
}
|
}
|
||||||
export function USBView({ value }: { value: Status }) {
|
export function USBView({ value }: { value: Status }) {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ export interface Status {
|
|||||||
host: {
|
host: {
|
||||||
collected_at: string; hostname: string; os: string; architecture: string; cpus: number;
|
collected_at: string; hostname: string; os: string; architecture: string; cpus: number;
|
||||||
memory_kib: number | null; available_kib: number | null;
|
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: { port: string; vendor: string; product_id: string; product: string; speed_mbps: string }[];
|
||||||
usb_readable: boolean; warnings: string[];
|
usb_readable: boolean; warnings: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -177,3 +177,18 @@ ActivityIndicator, ToastStack. Новых визуальных примитив
|
|||||||
Typecheck, production build и существующий UI boundary прошли. Это GUI-проверка
|
Typecheck, production build и существующий UI boundary прошли. Это GUI-проверка
|
||||||
перестановки функций, не закрытие приёмки чистой установки или сопряжения.
|
перестановки функций, не закрытие приёмки чистой установки или сопряжения.
|
||||||
Аппаратная установка и её результат фиксируются отдельно в отчёте Ops.
|
Аппаратная установка и её результат фиксируются отдельно в отчёте 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