diff --git a/core/internal/server/network/backend_networkd.go b/core/internal/server/network/backend_networkd.go index 84369dce..aa74c0e8 100644 --- a/core/internal/server/network/backend_networkd.go +++ b/core/internal/server/network/backend_networkd.go @@ -27,16 +27,19 @@ type linkInfo struct { } func (l *linkInfo) isWired() bool { + if looksVirtual(l.name) { + return false + } 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 + return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp") } func (l *linkInfo) isWireless() bool { + if looksVirtual(l.name) { + return false + } if l.linkType != "" { return l.linkType == "wlan" } @@ -45,7 +48,7 @@ func (l *linkInfo) isWireless() bool { func looksVirtual(name string) bool { virtualPrefixes := []string{ - "lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap", + "lo", "docker", "podman", "veth", "virbr", "br-", "vnet", "tun", "tap", "vboxnet", "vmnet", "kube", "cni", "flannel", "cali", } for _, prefix := range virtualPrefixes { @@ -110,6 +113,12 @@ func (b *SystemdNetworkdBackend) Close() { } } +type enumeratedLink struct { + ifindex int32 + name string + path dbus.ObjectPath +} + func (b *SystemdNetworkdBackend) enumerateLinks() error { obj := b.conn.Object(networkdBusName, b.managerPath) @@ -123,25 +132,48 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error { return fmt.Errorf("ListLinks: %w", err) } + fresh := make([]enumeratedLink, len(links)) + for i, l := range links { + fresh[i] = enumeratedLink{ifindex: l.Ifindex, name: l.Name, path: l.Path} + } + b.linksMutex.Lock() defer b.linksMutex.Unlock() + b.syncLinks(fresh) - for _, l := range links { - if existing, ok := b.links[l.Name]; ok && existing.path == l.Path { - existing.ifindex = l.Ifindex + return nil +} + +// syncLinks reconciles the cached link map against the freshly enumerated set: +// it adds links not seen before (querying their Type once), refreshes the +// ifindex of survivors, and prunes links that no longer appear. Pruning is what +// keeps torn-down container interfaces (podman bridges, veth pairs) from +// lingering as routable and being mistaken for the wired uplink. +// Callers must hold linksMutex. +func (b *SystemdNetworkdBackend) syncLinks(fresh []enumeratedLink) { + present := make(map[string]bool, len(fresh)) + for _, l := range fresh { + present[l.name] = true + if existing, ok := b.links[l.name]; ok && existing.path == l.path { + existing.ifindex = l.ifindex continue } info := &linkInfo{ - ifindex: l.Ifindex, - name: l.Name, - path: l.Path, - linkType: b.fetchLinkType(l.Path), + 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) + 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 + for name := range b.links { + if !present[name] { + log.Debugf("networkd: pruned stale link %s", name) + delete(b.links, name) + } + } } // fetchLinkType queries networkd's Describe method and extracts the link Type diff --git a/core/internal/server/network/backend_networkd_test.go b/core/internal/server/network/backend_networkd_test.go index 2fec55cb..1510dc06 100644 --- a/core/internal/server/network/backend_networkd_test.go +++ b/core/internal/server/network/backend_networkd_test.go @@ -160,6 +160,12 @@ func TestLinkInfo_Classify(t *testing.T) { {"loopback type", "lo", "loopback", false, false}, {"none type (tun overlay)", "nebula.homelab", "none", false, false}, {"none type (wireguard)", "wg0", "none", false, false}, + // Virtual interfaces report Type=ether but must never be mistaken for + // the wired uplink — stale podman/veth links would otherwise poison + // ethernet detection. + {"veth ether excluded", "veth1234", "ether", false, false}, + {"podman bridge ether excluded", "podman3", "ether", false, false}, + {"docker bridge ether excluded", "docker0", "ether", false, false}, // Fallback path: linkType unavailable, name-prefix heuristic applies. {"fallback enp wired", "enp141s0", "", true, false}, {"fallback wlan wireless", "wlan0", "", false, true}, @@ -205,8 +211,46 @@ func TestParseDescribeType(t *testing.T) { } } +func TestSyncLinks_PrunesRemovedLinks(t *testing.T) { + // Stale container interfaces (torn-down podman bridges, veth pairs) must + // not linger in the link map after they disappear from ListLinks — kept as + // routable, they stole the wired-uplink slot from the real ethernet NIC. + backend, _ := NewSystemdNetworkdBackend() + backend.links = map[string]*linkInfo{ + "eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether", opState: "routable"}, + "podman3": {ifindex: 9, name: "podman3", path: "/org/freedesktop/network1/link/_39", linkType: "ether", opState: "routable"}, + "veth0": {ifindex: 10, name: "veth0", path: "/org/freedesktop/network1/link/_310", linkType: "ether", opState: "routable"}, + } + + backend.syncLinks([]enumeratedLink{ + {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32"}, + }) + + assert.Len(t, backend.links, 1) + assert.Contains(t, backend.links, "eno1") + assert.NotContains(t, backend.links, "podman3") + assert.NotContains(t, backend.links, "veth0") +} + +func TestSyncLinks_RefreshesSurvivingLink(t *testing.T) { + // A link that survives keeps its cached Type — Describe is only queried for + // newly seen links — while picking up a refreshed ifindex. + backend, _ := NewSystemdNetworkdBackend() + backend.links = map[string]*linkInfo{ + "eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether"}, + } + + backend.syncLinks([]enumeratedLink{ + {ifindex: 7, name: "eno1", path: "/org/freedesktop/network1/link/_32"}, + }) + + assert.Len(t, backend.links, 1) + assert.Equal(t, int32(7), backend.links["eno1"].ifindex) + assert.Equal(t, "ether", backend.links["eno1"].linkType) +} + 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"} + virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc", "podman0", "podman3"} for _, n := range virtual { assert.True(t, looksVirtual(n), "%s should look virtual", n) }