repeater/internal/app/network.go
Pierre-Olivier Mercier 310cce2a45 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>
2026-07-05 09:51:44 +08:00

104 lines
3.6 KiB
Go

package app
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
"github.com/nemunaire/repeater/internal/models"
)
// hasCarrier reports whether the kernel sees an active link on iface.
// `/sys/class/net/<iface>/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 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", "route", "show", "default", "dev", iface).Output()
if err != nil {
return nil, fmt.Errorf("ip route show default dev %s: %w", iface, err)
}
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 dev != iface {
return ""
}
return src
}
// ensureUplink returns the current Ethernet status, and starts wpa_supplicant
// 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.
func ensureUplink(iface string) *models.EthernetStatus {
eth, err := probeEthernet(iface)
if err != nil {
log.Printf("Could not probe %s (%v); starting wpa_supplicant as fallback", iface, err)
eth = &models.EthernetStatus{Active: false, Interface: iface}
}
if eth.Active {
log.Printf("DHCP address %s on %s, leaving wpa_supplicant alone", eth.IPv4, iface)
return eth
}
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)))
}
return eth
}