repeater/internal/app/network.go
Pierre-Olivier Mercier 92b6113d72 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/<iface>/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.
2026-05-02 11:37:28 +08:00

87 lines
2.8 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 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)
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
var addr string
hasInet := false
isDHCP := false
for i, f := range fields {
switch f {
case "inet":
hasInet = true
if i+1 < len(fields) {
addr = fields[i+1]
}
case "dynamic":
isDHCP = true
case "valid_lft":
if i+1 < len(fields) && fields[i+1] != "forever" {
isDHCP = true
}
}
}
if hasInet && isDHCP {
if slash := strings.IndexByte(addr, '/'); slash >= 0 {
addr = addr[:slash]
}
return &models.EthernetStatus{Active: true, Interface: iface, IPv4: addr}, nil
}
}
return &models.EthernetStatus{Active: false, Interface: iface}, nil
}
// ensureUplink returns the current Ethernet status, and starts wpa_supplicant
// when no DHCP-assigned address is present. 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 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)))
}
return eth
}