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>
104 lines
3.6 KiB
Go
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
|
|
}
|