From 310cce2a45437f90185cb965bd44f3f5089e4bd4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 5 Jul 2026 09:51:44 +0800 Subject: [PATCH] app: Detect Ethernet uplink via default route, not address flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probeEthernet looked for a "DHCP-assigned" address using the `dynamic` flag or a finite `valid_lft`. udhcpc (BusyBox) installs the leased address with a plain `ip addr add` and no lease lifetime, so the address shows `valid_lft forever` and no `dynamic` flag — indistinguishable from a static LAN/hotspot address on the same interface. The uplink was never recognized and wpa_supplicant was started despite working connectivity. Detect the uplink via the interface's default route instead, which is reliable across udhcpc and full iproute2, and report the uplink source address from `ip route get` so the UI shows the DHCP address rather than a co-located static one. Co-Authored-By: Claude Opus 4.8 --- internal/app/network.go | 81 +++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/internal/app/network.go b/internal/app/network.go index 07502f1..4e93898 100644 --- a/internal/app/network.go +++ b/internal/app/network.go @@ -22,50 +22,67 @@ func hasCarrier(iface string) bool { } // probeEthernet reports the wired uplink state of iface. Active requires both -// a live link (`carrier == 1`) and a DHCP-assigned IPv4 — detected either by -// the `dynamic` flag emitted by full iproute2, or by a finite `valid_lft` -// (which BusyBox's `ip` does emit even when it omits the flag). +// a live link (`carrier == 1`) and a default route via the interface. +// +// The default route is the reliable cross-client signal: udhcpc (BusyBox) +// installs the leased address with a plain `ip addr add`, leaving no lease +// lifetime — the address shows `valid_lft forever` and no `dynamic` flag, so +// address flags can't tell a DHCP uplink apart from a static LAN/hotspot +// address on the same interface. Only the real uplink installs a default route +// through this interface, and that holds for udhcpc and full iproute2 alike. func probeEthernet(iface string) (*models.EthernetStatus, error) { if !hasCarrier(iface) { return &models.EthernetStatus{Active: false, Interface: iface}, nil } - out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output() + out, err := exec.Command("ip", "-4", "route", "show", "default", "dev", iface).Output() if err != nil { - return nil, fmt.Errorf("ip addr show %s: %w", iface, err) + return nil, fmt.Errorf("ip route show default dev %s: %w", iface, err) } - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(line) - var addr string - hasInet := false - isDHCP := false - for i, f := range fields { - switch f { - case "inet": - hasInet = true - if i+1 < len(fields) { - addr = fields[i+1] - } - case "dynamic": - isDHCP = true - case "valid_lft": - if i+1 < len(fields) && fields[i+1] != "forever" { - isDHCP = true - } + if strings.TrimSpace(string(out)) == "" { + return &models.EthernetStatus{Active: false, Interface: iface}, nil + } + + return &models.EthernetStatus{ + Active: true, + Interface: iface, + IPv4: uplinkSourceIP(iface), + }, nil +} + +// uplinkSourceIP returns the source address the kernel uses for off-link +// traffic through iface, so the UI shows the uplink (DHCP) address rather than +// a static LAN/hotspot address that shares the interface. Returns "" when the +// kernel would route off-link traffic through a different interface — the +// caller has already confirmed iface carries a default route, so an empty +// string only affects the displayed IP, not the active decision. +func uplinkSourceIP(iface string) string { + out, err := exec.Command("ip", "-4", "route", "get", "1.1.1.1").Output() + if err != nil { + return "" + } + fields := strings.Fields(string(out)) + var dev, src string + for i, f := range fields { + switch f { + case "dev": + if i+1 < len(fields) { + dev = fields[i+1] + } + case "src": + if i+1 < len(fields) { + src = fields[i+1] } } - if hasInet && isDHCP { - if slash := strings.IndexByte(addr, '/'); slash >= 0 { - addr = addr[:slash] - } - return &models.EthernetStatus{Active: true, Interface: iface, IPv4: addr}, nil - } } - return &models.EthernetStatus{Active: false, Interface: iface}, nil + if dev != iface { + return "" + } + return src } // ensureUplink returns the current Ethernet status, and starts wpa_supplicant -// when no DHCP-assigned address is present. When Ethernet is already +// when the interface carries no default route. When Ethernet is already // providing connectivity the WiFi backend is left alone — and crucially the // caller must avoid any D-Bus call to fi.w1.wpa_supplicant1, which would // otherwise re-activate the daemon via dbus-activation. @@ -79,7 +96,7 @@ func ensureUplink(iface string) *models.EthernetStatus { log.Printf("DHCP address %s on %s, leaving wpa_supplicant alone", eth.IPv4, iface) return eth } - log.Printf("No usable DHCP uplink on %s (carrier or lease missing), starting wpa_supplicant", iface) + log.Printf("No usable uplink on %s (carrier or default route missing), starting wpa_supplicant", iface) if out, err := exec.Command("service", "wpa_supplicant", "start").CombinedOutput(); err != nil { log.Printf("Failed to start wpa_supplicant: %v: %s", err, strings.TrimSpace(string(out))) }