1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-07 19:59:14 -04:00

networkd: classify links by Type instead of name prefix (#2447)

* networkd: classify links by Type instead of name prefix

The systemd-networkd backend decided wifi-vs-ethernet by checking
whether the interface name started with "wlan" or "wlp". Anything
else (that was not on a small virtual-prefix denylist) was treated
as wired ethernet. That misclassified two common cases:

* Nebula tunnels (kernel name like "nebula.homelab", Type=none,
  Kind=tun) showed up as a wired ethernet device — DMS rendered an
  "Ethernet connected" indicator whenever the overlay was up, even
  with no physical NIC plugged in.
* Renamed wifi interfaces (e.g. systemd link files that rename
  wlan0 to a friendlier name like "wifi") were also miscategorised
  as ethernet, because they no longer matched wlan*/wlp*.

networkd already publishes the real link kind in the JSON returned
by the per-link Describe method ("ether", "wlan", "loopback",
"none"). Fetch it during enumerateLinks, cache it on linkInfo, and
classify against that. The old prefix logic is kept as a fallback
for the case where Describe ever fails to populate Type.

The package-level looksVirtual() helper replaces the unexported
isVirtualInterface method so the new classification helpers and
their tests can use it without needing a live backend.

Tests cover both the Type-based and fallback paths, including the
Nebula-shaped Type=none/tun case that motivated this change.

* networkd: cache linkType across signal ticks and unit-test Describe fallback

enumerateLinks runs on every PropertiesChanged signal under
/org/freedesktop/network1, which fires on carrier flap, DHCP renew, and
each address change. The previous version rebuilt every linkInfo from
scratch on each tick, including a synchronous Describe D-Bus round-trip
per link, despite the link Type being fixed at netlink creation. Preserve
existing entries when the D-Bus path matches, refreshing only ifindex,
and only call fetchLinkType on a genuinely new entry. A link torn down
and re-created at a different path still triggers a refetch.

Extract parseDescribeType from fetchLinkType so the JSON failure path —
malformed payload, missing Type field, wrong type for Type — can be
exercised without a live D-Bus connection. The classifier's fallback to
name-prefix heuristics already had coverage; this locks in that the seam
between Describe and the classifier surfaces an empty string on every
failure mode rather than misclassifying a link.
This commit is contained in:
Graeme Foster
2026-05-20 16:43:50 +01:00
committed by GitHub
parent 0990b43a43
commit a923308c09
3 changed files with 152 additions and 30 deletions
@@ -1,6 +1,7 @@
package network
import (
"encoding/json"
"fmt"
"net"
"strings"
@@ -18,10 +19,41 @@ const (
)
type linkInfo struct {
ifindex int32
name string
path dbus.ObjectPath
opState string
ifindex int32
name string
path dbus.ObjectPath
opState string
linkType string
}
func (l *linkInfo) isWired() bool {
if l.linkType != "" {
return l.linkType == "ether"
}
if looksVirtual(l.name) || strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp") {
return false
}
return true
}
func (l *linkInfo) isWireless() bool {
if l.linkType != "" {
return l.linkType == "wlan"
}
return strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp")
}
func looksVirtual(name string) bool {
virtualPrefixes := []string{
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
}
for _, prefix := range virtualPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
type SystemdNetworkdBackend struct {
@@ -95,17 +127,50 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error {
defer b.linksMutex.Unlock()
for _, l := range links {
b.links[l.Name] = &linkInfo{
ifindex: l.Ifindex,
name: l.Name,
path: l.Path,
if existing, ok := b.links[l.Name]; ok && existing.path == l.Path {
existing.ifindex = l.Ifindex
continue
}
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s)", l.Name, l.Ifindex, l.Path)
info := &linkInfo{
ifindex: l.Ifindex,
name: l.Name,
path: l.Path,
linkType: b.fetchLinkType(l.Path),
}
b.links[l.Name] = info
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.Name, l.Ifindex, l.Path, info.linkType)
}
return nil
}
// fetchLinkType queries networkd's Describe method and extracts the link Type
// (e.g. "ether", "wlan", "loopback", "none"). Returns empty on failure; callers
// fall back to name-prefix heuristics in that case. The Type is fixed at link
// creation by the kernel, so callers cache the result for the lifetime of the
// linkInfo and only refetch when a link is re-created at a new D-Bus path.
func (b *SystemdNetworkdBackend) fetchLinkType(path dbus.ObjectPath) string {
linkObj := b.conn.Object(networkdBusName, path)
var describeJSON string
if err := linkObj.Call(networkdLinkIface+".Describe", 0).Store(&describeJSON); err != nil {
return ""
}
return parseDescribeType(describeJSON)
}
// parseDescribeType extracts the top-level "Type" field from a networkd
// Describe payload. Returns empty when the JSON is malformed or the field is
// absent, signalling callers to fall back to name-prefix heuristics.
func parseDescribeType(describeJSON string) string {
var parsed struct {
Type string `json:"Type"`
}
if err := json.Unmarshal([]byte(describeJSON), &parsed); err != nil {
return ""
}
return parsed.Type
}
func (b *SystemdNetworkdBackend) updateState() error {
b.linksMutex.RLock()
defer b.linksMutex.RUnlock()
@@ -113,8 +178,8 @@ func (b *SystemdNetworkdBackend) updateState() error {
var wiredIface *linkInfo
var wifiIface *linkInfo
for name, link := range b.links {
if b.isVirtualInterface(name) {
for _, link := range b.links {
if !link.isWired() && !link.isWireless() {
continue
}
@@ -126,11 +191,11 @@ func (b *SystemdNetworkdBackend) updateState() error {
}
}
if strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
if link.isWireless() {
if wifiIface == nil || link.opState == "routable" || link.opState == "carrier" {
wifiIface = link
}
} else if !b.isVirtualInterface(name) {
} else if link.isWired() {
if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" {
wiredIface = link
}
@@ -140,7 +205,7 @@ func (b *SystemdNetworkdBackend) updateState() error {
var wiredConns []WiredConnection
var ethernetDevices []EthernetDevice
for name, link := range b.links {
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
if !link.isWired() {
continue
}
@@ -229,19 +294,6 @@ func (b *SystemdNetworkdBackend) updateState() error {
return nil
}
func (b *SystemdNetworkdBackend) isVirtualInterface(name string) bool {
virtualPrefixes := []string{
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
}
for _, prefix := range virtualPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
func (b *SystemdNetworkdBackend) getAddresses(ifname string) []string {
iface, err := net.InterfaceByName(ifname)
if err != nil {
@@ -12,7 +12,7 @@ func (b *SystemdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error
var conns []WiredConnection
for name, link := range b.links {
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
if !link.isWired() {
continue
}
@@ -73,8 +73,8 @@ func (b *SystemdNetworkdBackend) GetWiredNetworkDetails(id string) (*WiredNetwor
func (b *SystemdNetworkdBackend) ConnectEthernet() error {
b.linksMutex.RLock()
var primaryWired *linkInfo
for name, l := range b.links {
if strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
for _, l := range b.links {
if !l.isWired() {
continue
}
primaryWired = l
@@ -145,3 +145,73 @@ func TestSystemdNetworkdBackend_DisconnectEthernetDevice(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "not supported")
}
func TestLinkInfo_Classify(t *testing.T) {
// When networkd reports a Type via Describe, classification is exact.
cases := []struct {
name string
ifname string
linkType string
wantWired bool
wantWifi bool
}{
{"ether type", "dock", "ether", true, false},
{"wlan type", "wifi", "wlan", false, true},
{"loopback type", "lo", "loopback", false, false},
{"none type (tun overlay)", "nebula.homelab", "none", false, false},
{"none type (wireguard)", "wg0", "none", false, false},
// Fallback path: linkType unavailable, name-prefix heuristic applies.
{"fallback enp wired", "enp141s0", "", true, false},
{"fallback wlan wireless", "wlan0", "", false, true},
{"fallback wlp wireless", "wlp3s0", "", false, true},
{"fallback lo skipped", "lo", "", false, false},
{"fallback docker skipped", "docker0", "", false, false},
{"fallback tun skipped", "tun0", "", false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := &linkInfo{name: tc.ifname, linkType: tc.linkType}
assert.Equal(t, tc.wantWired, l.isWired(), "isWired")
assert.Equal(t, tc.wantWifi, l.isWireless(), "isWireless")
})
}
}
func TestParseDescribeType(t *testing.T) {
// parseDescribeType is the seam between networkd's Describe RPC and the
// classifier. On any failure path it must return "" so callers fall back
// to name-prefix heuristics rather than misclassifying the link.
cases := []struct {
name string
in string
want string
}{
{"ether", `{"Type":"ether","Name":"enp141s0"}`, "ether"},
{"wlan", `{"Type":"wlan","Name":"wlan0"}`, "wlan"},
{"loopback", `{"Type":"loopback","Name":"lo"}`, "loopback"},
{"none with kind", `{"Type":"none","Kind":"tun","Name":"nebula.homelab"}`, "none"},
{"empty payload", ``, ""},
{"empty object", `{}`, ""},
{"missing Type field", `{"Name":"wlan0","Kind":""}`, ""},
{"explicit empty Type", `{"Type":"","Name":"wlan0"}`, ""},
{"malformed json", `{"Type":"ether"`, ""},
{"non-string Type", `{"Type":42}`, ""},
{"unrelated payload", `"just a string"`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, parseDescribeType(tc.in))
})
}
}
func TestLooksVirtual(t *testing.T) {
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc"}
for _, n := range virtual {
assert.True(t, looksVirtual(n), "%s should look virtual", n)
}
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0"}
for _, n := range real {
assert.False(t, looksVirtual(n), "%s should not look virtual", n)
}
}