diff --git a/core/internal/server/network/backend_networkd.go b/core/internal/server/network/backend_networkd.go index 802cbf02..84369dce 100644 --- a/core/internal/server/network/backend_networkd.go +++ b/core/internal/server/network/backend_networkd.go @@ -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 { diff --git a/core/internal/server/network/backend_networkd_ethernet.go b/core/internal/server/network/backend_networkd_ethernet.go index 8f9b225f..c304e630 100644 --- a/core/internal/server/network/backend_networkd_ethernet.go +++ b/core/internal/server/network/backend_networkd_ethernet.go @@ -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 diff --git a/core/internal/server/network/backend_networkd_test.go b/core/internal/server/network/backend_networkd_test.go index 18c5281c..2fec55cb 100644 --- a/core/internal/server/network/backend_networkd_test.go +++ b/core/internal/server/network/backend_networkd_test.go @@ -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) + } +}