app: Detect Ethernet uplink via default route, not address flags

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 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-07-05 09:51:44 +08:00
commit 310cce2a45

View file

@ -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
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 "inet":
hasInet = true
case "dev":
if i+1 < len(fields) {
addr = fields[i+1]
dev = fields[i+1]
}
case "dynamic":
isDHCP = true
case "valid_lft":
if i+1 < len(fields) && fields[i+1] != "forever" {
isDHCP = true
case "src":
if i+1 < len(fields) {
src = fields[i+1]
}
}
}
if hasInet && isDHCP {
if slash := strings.IndexByte(addr, '/'); slash >= 0 {
addr = addr[:slash]
if dev != iface {
return ""
}
return &models.EthernetStatus{Active: true, Interface: iface, IPv4: addr}, nil
}
}
return &models.EthernetStatus{Active: false, Interface: iface}, nil
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)))
}