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//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 }