From 92b6113d726c44881c89e0fb0fec54ea735b76a0 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 2 May 2026 11:37:28 +0800 Subject: [PATCH] app: Require link carrier to consider Ethernet uplink active A static IPv4 (e.g. the hotspot gateway address on eth0) was wrongly classified as a DHCP uplink on stacks where ip(8) omits the "dynamic" flag, and a NO-CARRIER interface with any address was reported as up. Gate probeEthernet on /sys/class/net//carrier == 1, and accept either the dynamic flag or a finite valid_lft as the DHCP signal so BusyBox's ip output is handled too. --- internal/app/network.go | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/internal/app/network.go b/internal/app/network.go index 557dffb..07502f1 100644 --- a/internal/app/network.go +++ b/internal/app/network.go @@ -3,18 +3,33 @@ package app import ( "fmt" "log" + "os" "os/exec" "strings" "github.com/nemunaire/repeater/internal/models" ) -// probeEthernet reports the wired uplink state of iface by parsing -// `ip -4 -o addr show dev `. An IPv4 line carrying the "dynamic" flag -// (set by ip(8) on addresses with a finite valid_lft) is treated as a -// DHCP-assigned lease — that's the signal we use to decide whether the box -// already has connectivity through Ethernet. +// hasCarrier reports whether the kernel sees an active link on iface. +// `/sys/class/net//carrier` is "1" when the link layer is up; reading +// it on an admin-down interface yields EINVAL, which we treat the same way. +func hasCarrier(iface string) bool { + data, err := os.ReadFile("/sys/class/net/" + iface + "/carrier") + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == "1" +} + +// 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). 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() if err != nil { return nil, fmt.Errorf("ip addr show %s: %w", iface, err) @@ -23,7 +38,7 @@ func probeEthernet(iface string) (*models.EthernetStatus, error) { fields := strings.Fields(line) var addr string hasInet := false - hasDynamic := false + isDHCP := false for i, f := range fields { switch f { case "inet": @@ -32,10 +47,14 @@ func probeEthernet(iface string) (*models.EthernetStatus, error) { addr = fields[i+1] } case "dynamic": - hasDynamic = true + isDHCP = true + case "valid_lft": + if i+1 < len(fields) && fields[i+1] != "forever" { + isDHCP = true + } } } - if hasInet && hasDynamic { + if hasInet && isDHCP { if slash := strings.IndexByte(addr, '/'); slash >= 0 { addr = addr[:slash] } @@ -60,7 +79,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 DHCP address on %s, starting wpa_supplicant", iface) + log.Printf("No usable DHCP uplink on %s (carrier or lease 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))) }