From 700b37890bfda5be810501a78c6e1d54beed99b9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 17:30:17 +0100 Subject: [PATCH] node-uplink links answers each link's mac, master and parent (S1a links-mac, issue 1965) Both holders' uplink-tools copies read address, master, link and link_index/link_netnsid from the existing ip -j address show call. Fields are left out where a link has none (L3); a parent in another namespace is {index, namespace} (L2); the form lives in uplink.go only (L4); only the tool description changes, not seat.json (L5); bridge fdb MACs are not answered (L6); names mac, master, parent (L1). Builds: design 48 10, design 08 code:, issue 1965 note 11 L1-L6 --- .../networkmanager/cmd/uplink-tools/main.go | 2 +- .../networkmanager/cmd/uplink-tools/uplink.go | 25 ++++++++++++++++++- .../cmd/uplink-tools/uplink_test.go | 18 ++++++++++--- .../systemd-networkd/cmd/uplink-tools/main.go | 2 +- .../cmd/uplink-tools/uplink.go | 25 ++++++++++++++++++- .../cmd/uplink-tools/uplink_test.go | 18 ++++++++++--- 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/modules/networkmanager/cmd/uplink-tools/main.go b/modules/networkmanager/cmd/uplink-tools/main.go index d2aed1d5d..116251870 100644 --- a/modules/networkmanager/cmd/uplink-tools/main.go +++ b/modules/networkmanager/cmd/uplink-tools/main.go @@ -19,7 +19,7 @@ func tools(m Machine) []stdio.Tool { "— its header, a backup a VPN client left beside it, a link in its place, a known writer running. (r)", Run: func(map[string]any) (any, error) { return m.ReadResolvers() }}, {Name: seat + ".links", - Description: "Every network link on the machine: its state, its addresses, whether the default route " + + Description: "Every network link on the machine: its state, its addresses, its mac, the bridge or bond it is enslaved to (master), the link it rides on (parent: its name, or an index and namespace when that link is in another network namespace), whether the default route " + "leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's " + "tunnel included. (r)", Run: func(map[string]any) (any, error) { return m.Links(context.Background()) }}, diff --git a/modules/networkmanager/cmd/uplink-tools/uplink.go b/modules/networkmanager/cmd/uplink-tools/uplink.go index d5e85ed5f..25d91e4d4 100644 --- a/modules/networkmanager/cmd/uplink-tools/uplink.go +++ b/modules/networkmanager/cmd/uplink-tools/uplink.go @@ -221,12 +221,24 @@ type Link struct { State string `json:"state"` Kind string `json:"kind,omitempty"` Addresses []string `json:"addresses,omitempty"` + // MAC is the link-layer address as ip says it (lo's all-zero included); left out where the link has + // none. Master is the bridge or bond the link is enslaved to. Parent is the link it rides on: its + // name where that link is in this machine's namespace, else a ParentPeer; both left out where none. + MAC string `json:"mac,omitempty"` + Master string `json:"master,omitempty"` + Parent any `json:"parent,omitempty"` // Default says the default route leaves through it, and Metric that route's metric. Default bool `json:"default_route,omitempty"` Metric *int `json:"metric,omitempty"` DNS *LinkDNS `json:"dns,omitempty"` } +// ParentPeer is a parent in another network namespace, which has no name here: ip's index and namespace id. +type ParentPeer struct { + Index int `json:"index"` + Namespace int `json:"namespace"` +} + // Links answers the links verb: every link from the kernel, its default route, and what the manager // knows of its names. A manager that cannot be asked is said, never read as no resolvers. func (m Machine) Links(ctx context.Context) (map[string]any, error) { @@ -238,6 +250,11 @@ func (m Machine) Links(ctx context.Context) (map[string]any, error) { Name string `json:"ifname"` State string `json:"operstate"` Kind string `json:"link_type"` + MAC string `json:"address"` + Master string `json:"master"` + Link string `json:"link"` + LinkIdx *int `json:"link_index"` + NetNSID *int `json:"link_netnsid"` AddrInfo []struct { Local string `json:"local"` Prefix int `json:"prefixlen"` @@ -276,7 +293,13 @@ func (m Machine) Links(ctx context.Context) (map[string]any, error) { } links := []Link{} for _, a := range addrs { - l := Link{Name: a.Name, State: a.State, Kind: a.Kind} + l := Link{Name: a.Name, State: a.State, Kind: a.Kind, MAC: a.MAC, Master: a.Master} + switch { + case a.Link != "": + l.Parent = a.Link + case a.LinkIdx != nil && a.NetNSID != nil: + l.Parent = ParentPeer{Index: *a.LinkIdx, Namespace: *a.NetNSID} + } for _, ai := range a.AddrInfo { l.Addresses = append(l.Addresses, a2s(ai.Local, ai.Prefix)) } diff --git a/modules/networkmanager/cmd/uplink-tools/uplink_test.go b/modules/networkmanager/cmd/uplink-tools/uplink_test.go index 376e3d874..1f0449800 100644 --- a/modules/networkmanager/cmd/uplink-tools/uplink_test.go +++ b/modules/networkmanager/cmd/uplink-tools/uplink_test.go @@ -103,8 +103,10 @@ func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { joined := strings.Join(args, " ") switch { case joined == "-j address show": - return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, -{"ifname":"wlan0","operstate":"UP","link_type":"ether","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, + return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","address":"00:00:00:00:00:00","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, +{"ifname":"wlan0","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:01","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, +{"ifname":"veth1","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:02","master":"br-1","link":"veth2","addr_info":[]}, +{"ifname":"veth3","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:03","master":"br-1","link_index":7,"link_netnsid":2,"addr_info":[]}, {"ifname":"vpn0","operstate":"UNKNOWN","link_type":"none","addr_info":[{"local":"172.16.9.9","prefixlen":32}]}]`, nil case joined == "-j -4 route show default": return `[{"dst":"default","dev":"wlan0","metric":600}]`, nil @@ -122,10 +124,20 @@ func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { t.Fatal(err) } links := answer["links"].([]Link) - if len(links) != 3 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || + if len(links) != 5 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || links[0].DNS == nil || links[0].DNS.Servers[0] != "192.168.1.1" { t.Fatalf("the links are %+v", links) } + byName := map[string]Link{} + for _, l := range links { + byName[l.Name] = l + } + if byName["wlan0"].MAC != "aa:bb:cc:00:00:01" || byName["lo"].MAC != "00:00:00:00:00:00" || + byName["veth1"].Master != "br-1" || byName["veth1"].Parent != "veth2" || + byName["veth3"].Parent != (ParentPeer{Index: 7, Namespace: 2}) || + byName["vpn0"].MAC != "" || byName["vpn0"].Master != "" || byName["vpn0"].Parent != nil { + t.Fatalf("mac, master and parent are %+v", byName) + } for _, l := range links[1:] { if l.Default || (l.Name == "vpn0" && l.DNS != nil) { t.Fatalf("%+v", l) diff --git a/modules/systemd-networkd/cmd/uplink-tools/main.go b/modules/systemd-networkd/cmd/uplink-tools/main.go index d2aed1d5d..116251870 100644 --- a/modules/systemd-networkd/cmd/uplink-tools/main.go +++ b/modules/systemd-networkd/cmd/uplink-tools/main.go @@ -19,7 +19,7 @@ func tools(m Machine) []stdio.Tool { "— its header, a backup a VPN client left beside it, a link in its place, a known writer running. (r)", Run: func(map[string]any) (any, error) { return m.ReadResolvers() }}, {Name: seat + ".links", - Description: "Every network link on the machine: its state, its addresses, whether the default route " + + Description: "Every network link on the machine: its state, its addresses, its mac, the bridge or bond it is enslaved to (master), the link it rides on (parent: its name, or an index and namespace when that link is in another network namespace), whether the default route " + "leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's " + "tunnel included. (r)", Run: func(map[string]any) (any, error) { return m.Links(context.Background()) }}, diff --git a/modules/systemd-networkd/cmd/uplink-tools/uplink.go b/modules/systemd-networkd/cmd/uplink-tools/uplink.go index d5e85ed5f..25d91e4d4 100644 --- a/modules/systemd-networkd/cmd/uplink-tools/uplink.go +++ b/modules/systemd-networkd/cmd/uplink-tools/uplink.go @@ -221,12 +221,24 @@ type Link struct { State string `json:"state"` Kind string `json:"kind,omitempty"` Addresses []string `json:"addresses,omitempty"` + // MAC is the link-layer address as ip says it (lo's all-zero included); left out where the link has + // none. Master is the bridge or bond the link is enslaved to. Parent is the link it rides on: its + // name where that link is in this machine's namespace, else a ParentPeer; both left out where none. + MAC string `json:"mac,omitempty"` + Master string `json:"master,omitempty"` + Parent any `json:"parent,omitempty"` // Default says the default route leaves through it, and Metric that route's metric. Default bool `json:"default_route,omitempty"` Metric *int `json:"metric,omitempty"` DNS *LinkDNS `json:"dns,omitempty"` } +// ParentPeer is a parent in another network namespace, which has no name here: ip's index and namespace id. +type ParentPeer struct { + Index int `json:"index"` + Namespace int `json:"namespace"` +} + // Links answers the links verb: every link from the kernel, its default route, and what the manager // knows of its names. A manager that cannot be asked is said, never read as no resolvers. func (m Machine) Links(ctx context.Context) (map[string]any, error) { @@ -238,6 +250,11 @@ func (m Machine) Links(ctx context.Context) (map[string]any, error) { Name string `json:"ifname"` State string `json:"operstate"` Kind string `json:"link_type"` + MAC string `json:"address"` + Master string `json:"master"` + Link string `json:"link"` + LinkIdx *int `json:"link_index"` + NetNSID *int `json:"link_netnsid"` AddrInfo []struct { Local string `json:"local"` Prefix int `json:"prefixlen"` @@ -276,7 +293,13 @@ func (m Machine) Links(ctx context.Context) (map[string]any, error) { } links := []Link{} for _, a := range addrs { - l := Link{Name: a.Name, State: a.State, Kind: a.Kind} + l := Link{Name: a.Name, State: a.State, Kind: a.Kind, MAC: a.MAC, Master: a.Master} + switch { + case a.Link != "": + l.Parent = a.Link + case a.LinkIdx != nil && a.NetNSID != nil: + l.Parent = ParentPeer{Index: *a.LinkIdx, Namespace: *a.NetNSID} + } for _, ai := range a.AddrInfo { l.Addresses = append(l.Addresses, a2s(ai.Local, ai.Prefix)) } diff --git a/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go b/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go index 376e3d874..1f0449800 100644 --- a/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go +++ b/modules/systemd-networkd/cmd/uplink-tools/uplink_test.go @@ -103,8 +103,10 @@ func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { joined := strings.Join(args, " ") switch { case joined == "-j address show": - return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, -{"ifname":"wlan0","operstate":"UP","link_type":"ether","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, + return `[{"ifname":"lo","operstate":"UNKNOWN","link_type":"loopback","address":"00:00:00:00:00:00","addr_info":[{"local":"127.0.0.1","prefixlen":8}]}, +{"ifname":"wlan0","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:01","addr_info":[{"local":"192.168.1.20","prefixlen":24}]}, +{"ifname":"veth1","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:02","master":"br-1","link":"veth2","addr_info":[]}, +{"ifname":"veth3","operstate":"UP","link_type":"ether","address":"aa:bb:cc:00:00:03","master":"br-1","link_index":7,"link_netnsid":2,"addr_info":[]}, {"ifname":"vpn0","operstate":"UNKNOWN","link_type":"none","addr_info":[{"local":"172.16.9.9","prefixlen":32}]}]`, nil case joined == "-j -4 route show default": return `[{"dst":"default","dev":"wlan0","metric":600}]`, nil @@ -122,10 +124,20 @@ func TestTheLinksCarryTheirDefaultRouteAndTheirNames(t *testing.T) { t.Fatal(err) } links := answer["links"].([]Link) - if len(links) != 3 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || + if len(links) != 5 || links[0].Name != "wlan0" || !links[0].Default || *links[0].Metric != 600 || links[0].DNS == nil || links[0].DNS.Servers[0] != "192.168.1.1" { t.Fatalf("the links are %+v", links) } + byName := map[string]Link{} + for _, l := range links { + byName[l.Name] = l + } + if byName["wlan0"].MAC != "aa:bb:cc:00:00:01" || byName["lo"].MAC != "00:00:00:00:00:00" || + byName["veth1"].Master != "br-1" || byName["veth1"].Parent != "veth2" || + byName["veth3"].Parent != (ParentPeer{Index: 7, Namespace: 2}) || + byName["vpn0"].MAC != "" || byName["vpn0"].Master != "" || byName["vpn0"].Parent != nil { + t.Fatalf("mac, master and parent are %+v", byName) + } for _, l := range links[1:] { if l.Default || (l.Name == "vpn0" && l.DNS != nil) { t.Fatalf("%+v", l) -- 2.54.0