Compare commits

...

2 commits

Author SHA1 Message Date
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
11ae69de56 wpasupplicant: Disable D-Bus auto-activation on all calls
Every method call and property access to fi.w1.wpa_supplicant1 used flag
0, leaving D-Bus service activation enabled. Any stray request — a startup
race before `service wpa_supplicant start` takes effect, or a leaked call
while the Ethernet uplink is the chosen path — would make the bus daemon
spawn `wpa_supplicant -u` from its .service file, bypassing repeater's own
launch of the daemon.

Route every interaction through callNoAutoStart/getPropNoAutoStart/
setPropNoAutoStart, which set dbus.FlagNoAutoStart. A leaked call now fails
cleanly with ServiceUnknown instead of resurrecting the daemon, leaving
repeater as the sole launcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:44:59 +08:00
6 changed files with 133 additions and 56 deletions

View file

@ -22,50 +22,67 @@ func hasCarrier(iface string) bool {
} }
// probeEthernet reports the wired uplink state of iface. Active requires both // probeEthernet reports the wired uplink state of iface. Active requires both
// a live link (`carrier == 1`) and a DHCP-assigned IPv4 — detected either by // a live link (`carrier == 1`) and a default route via the interface.
// 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). // 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) { func probeEthernet(iface string) (*models.EthernetStatus, error) {
if !hasCarrier(iface) { if !hasCarrier(iface) {
return &models.EthernetStatus{Active: false, Interface: iface}, nil return &models.EthernetStatus{Active: false, Interface: iface}, nil
} }
out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output() out, err := exec.Command("ip", "-4", "route", "show", "default", "dev", iface).Output()
if err != nil { if err != nil {
return nil, fmt.Errorf("ip addr show %s: %w", iface, err) return nil, fmt.Errorf("ip route show default dev %s: %w", iface, err)
} }
for _, line := range strings.Split(string(out), "\n") { if strings.TrimSpace(string(out)) == "" {
fields := strings.Fields(line) return &models.EthernetStatus{Active: false, Interface: iface}, nil
var addr string }
hasInet := false
isDHCP := false return &models.EthernetStatus{
for i, f := range fields { Active: true,
switch f { Interface: iface,
case "inet": IPv4: uplinkSourceIP(iface),
hasInet = true }, nil
if i+1 < len(fields) { }
addr = fields[i+1]
} // uplinkSourceIP returns the source address the kernel uses for off-link
case "dynamic": // traffic through iface, so the UI shows the uplink (DHCP) address rather than
isDHCP = true // a static LAN/hotspot address that shares the interface. Returns "" when the
case "valid_lft": // kernel would route off-link traffic through a different interface — the
if i+1 < len(fields) && fields[i+1] != "forever" { // caller has already confirmed iface carries a default route, so an empty
isDHCP = true // 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 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 if dev != iface {
return ""
}
return src
} }
// ensureUplink returns the current Ethernet status, and starts wpa_supplicant // ensureUplink returns the current Ethernet status, and starts wpa_supplicant
// when no DHCP-assigned address is present. When Ethernet is already // when the interface carries no default route. When Ethernet is already
// providing connectivity the WiFi backend is left alone — and crucially the // 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 // caller must avoid any D-Bus call to fi.w1.wpa_supplicant1, which would
// otherwise re-activate the daemon via dbus-activation. // otherwise re-activate the daemon via dbus-activation.
@ -79,7 +96,7 @@ func ensureUplink(iface string) *models.EthernetStatus {
log.Printf("DHCP address %s on %s, leaving wpa_supplicant alone", eth.IPv4, iface) log.Printf("DHCP address %s on %s, leaving wpa_supplicant alone", eth.IPv4, iface)
return eth return eth
} }
log.Printf("No usable DHCP uplink on %s (carrier or lease missing), starting wpa_supplicant", iface) 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 { 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))) log.Printf("Failed to start wpa_supplicant: %v: %s", err, strings.TrimSpace(string(out)))
} }

View file

@ -54,7 +54,7 @@ func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, er
var interfacePath dbus.ObjectPath var interfacePath dbus.ObjectPath
// Try to get existing interface // Try to get existing interface
err := b.wpasupplicant.Call(Service+".GetInterface", 0, interfaceName).Store(&interfacePath) err := callNoAutoStart(b.wpasupplicant, Service+".GetInterface", interfaceName).Store(&interfacePath)
if err == nil { if err == nil {
return interfacePath, nil return interfacePath, nil
} }
@ -64,7 +64,7 @@ func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, er
"Ifname": dbus.MakeVariant(interfaceName), "Ifname": dbus.MakeVariant(interfaceName),
} }
err = b.wpasupplicant.Call(Service+".CreateInterface", 0, args).Store(&interfacePath) err = callNoAutoStart(b.wpasupplicant, Service+".CreateInterface", args).Store(&interfacePath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create interface: %v", err) return "", fmt.Errorf("failed to create interface: %v", err)
} }

View file

@ -38,49 +38,49 @@ func (b *BSS) GetProperties() (*BSSProperties, error) {
props := &BSSProperties{} props := &BSSProperties{}
// Get SSID // Get SSID
if ssidProp, err := b.obj.GetProperty(BSSInterface + ".SSID"); err == nil { if ssidProp, err := getPropNoAutoStart(b.obj, BSSInterface+".SSID"); err == nil {
if ssid, ok := ssidProp.Value().([]byte); ok { if ssid, ok := ssidProp.Value().([]byte); ok {
props.SSID = ssid props.SSID = ssid
} }
} }
// Get BSSID // Get BSSID
if bssidProp, err := b.obj.GetProperty(BSSInterface + ".BSSID"); err == nil { if bssidProp, err := getPropNoAutoStart(b.obj, BSSInterface+".BSSID"); err == nil {
if bssid, ok := bssidProp.Value().([]byte); ok { if bssid, ok := bssidProp.Value().([]byte); ok {
props.BSSID = bssid props.BSSID = bssid
} }
} }
// Get Signal // Get Signal
if signalProp, err := b.obj.GetProperty(BSSInterface + ".Signal"); err == nil { if signalProp, err := getPropNoAutoStart(b.obj, BSSInterface+".Signal"); err == nil {
if signal, ok := signalProp.Value().(int16); ok { if signal, ok := signalProp.Value().(int16); ok {
props.Signal = signal props.Signal = signal
} }
} }
// Get Frequency // Get Frequency
if freqProp, err := b.obj.GetProperty(BSSInterface + ".Frequency"); err == nil { if freqProp, err := getPropNoAutoStart(b.obj, BSSInterface+".Frequency"); err == nil {
if freq, ok := freqProp.Value().(uint16); ok { if freq, ok := freqProp.Value().(uint16); ok {
props.Frequency = uint32(freq) props.Frequency = uint32(freq)
} }
} }
// Get Privacy // Get Privacy
if privacyProp, err := b.obj.GetProperty(BSSInterface + ".Privacy"); err == nil { if privacyProp, err := getPropNoAutoStart(b.obj, BSSInterface+".Privacy"); err == nil {
if privacy, ok := privacyProp.Value().(bool); ok { if privacy, ok := privacyProp.Value().(bool); ok {
props.Privacy = privacy props.Privacy = privacy
} }
} }
// Get RSN (WPA2) information // Get RSN (WPA2) information
if rsnProp, err := b.obj.GetProperty(BSSInterface + ".RSN"); err == nil { if rsnProp, err := getPropNoAutoStart(b.obj, BSSInterface+".RSN"); err == nil {
if rsn, ok := rsnProp.Value().(map[string]dbus.Variant); ok { if rsn, ok := rsnProp.Value().(map[string]dbus.Variant); ok {
props.RSN = rsn props.RSN = rsn
} }
} }
// Get WPA information // Get WPA information
if wpaProp, err := b.obj.GetProperty(BSSInterface + ".WPA"); err == nil { if wpaProp, err := getPropNoAutoStart(b.obj, BSSInterface+".WPA"); err == nil {
if wpa, ok := wpaProp.Value().(map[string]dbus.Variant); ok { if wpa, ok := wpaProp.Value().(map[string]dbus.Variant); ok {
props.WPA = wpa props.WPA = wpa
} }
@ -91,7 +91,7 @@ func (b *BSS) GetProperties() (*BSSProperties, error) {
// GetSSIDString returns the SSID as a string // GetSSIDString returns the SSID as a string
func (b *BSS) GetSSIDString() (string, error) { func (b *BSS) GetSSIDString() (string, error) {
prop, err := b.obj.GetProperty(BSSInterface + ".SSID") prop, err := getPropNoAutoStart(b.obj, BSSInterface+".SSID")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get SSID property: %v", err) return "", fmt.Errorf("failed to get SSID property: %v", err)
} }
@ -106,7 +106,7 @@ func (b *BSS) GetSSIDString() (string, error) {
// GetBSSIDString returns the BSSID as a formatted MAC address string // GetBSSIDString returns the BSSID as a formatted MAC address string
func (b *BSS) GetBSSIDString() (string, error) { func (b *BSS) GetBSSIDString() (string, error) {
prop, err := b.obj.GetProperty(BSSInterface + ".BSSID") prop, err := getPropNoAutoStart(b.obj, BSSInterface+".BSSID")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get BSSID property: %v", err) return "", fmt.Errorf("failed to get BSSID property: %v", err)
} }
@ -122,7 +122,7 @@ func (b *BSS) GetBSSIDString() (string, error) {
// GetSignal returns the signal strength in dBm // GetSignal returns the signal strength in dBm
func (b *BSS) GetSignal() (int16, error) { func (b *BSS) GetSignal() (int16, error) {
prop, err := b.obj.GetProperty(BSSInterface + ".Signal") prop, err := getPropNoAutoStart(b.obj, BSSInterface+".Signal")
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to get Signal property: %v", err) return 0, fmt.Errorf("failed to get Signal property: %v", err)
} }

View file

@ -0,0 +1,60 @@
package wpasupplicant
import (
"fmt"
"strings"
"github.com/godbus/dbus/v5"
)
// D-Bus interactions with the fi.w1.wpa_supplicant1 name must never trigger
// service activation. repeater is the sole launcher of the daemon (via
// `service wpa_supplicant start` in the app layer); if a call reached the bus
// name while the daemon was down — a startup race, or a stray call in
// Ethernet-uplink mode — the bus would auto-activate it from its .service
// file, spawning `wpa_supplicant -u` and bypassing repeater's own launch.
//
// godbus only suppresses activation when FlagNoAutoStart is set on the message
// (msg.Flags = flags & (FlagNoAutoStart | FlagNoReplyExpected)); the default
// flag value of 0 leaves activation enabled. The helpers below set the flag on
// every method call and property access. A leaked call then fails cleanly with
// a ServiceUnknown error instead of resurrecting the daemon.
// callNoAutoStart performs a method call with D-Bus auto-activation disabled.
func callNoAutoStart(obj dbus.BusObject, method string, args ...interface{}) *dbus.Call {
return obj.Call(method, dbus.FlagNoAutoStart, args...)
}
// getPropNoAutoStart reads a property (interface.member notation) with
// auto-activation disabled. It mirrors dbus.Object.GetProperty, which
// otherwise issues Properties.Get with the activation-enabling flag 0.
func getPropNoAutoStart(obj dbus.BusObject, p string) (dbus.Variant, error) {
var result dbus.Variant
iface, prop, err := splitProperty(p)
if err != nil {
return result, err
}
err = obj.Call("org.freedesktop.DBus.Properties.Get", dbus.FlagNoAutoStart, iface, prop).Store(&result)
return result, err
}
// setPropNoAutoStart writes a property with auto-activation disabled. The value
// is passed through as-is, so callers wrap it in a dbus.Variant just as they
// would for dbus.Object.SetProperty.
func setPropNoAutoStart(obj dbus.BusObject, p string, v interface{}) error {
iface, prop, err := splitProperty(p)
if err != nil {
return err
}
return obj.Call("org.freedesktop.DBus.Properties.Set", dbus.FlagNoAutoStart, iface, prop, v).Err
}
// splitProperty splits an "interface.member" property name into its interface
// and member parts, matching godbus's own parsing.
func splitProperty(p string) (iface, prop string, err error) {
idx := strings.LastIndex(p, ".")
if idx == -1 || idx+1 == len(p) {
return "", "", fmt.Errorf("dbus: invalid property %s", p)
}
return p[:idx], p[idx+1:], nil
}

View file

@ -28,7 +28,7 @@ func (i *WPAInterface) Scan(scanType string) error {
"Type": scanType, // "active" or "passive" "Type": scanType, // "active" or "passive"
} }
err := i.obj.Call(InterfaceInterface+".Scan", 0, args).Err err := callNoAutoStart(i.obj, InterfaceInterface+".Scan", args).Err
if err != nil { if err != nil {
return fmt.Errorf("scan failed: %v", err) return fmt.Errorf("scan failed: %v", err)
} }
@ -37,7 +37,7 @@ func (i *WPAInterface) Scan(scanType string) error {
// GetBSSs returns a list of BSS (Basic Service Set) object paths // GetBSSs returns a list of BSS (Basic Service Set) object paths
func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) { func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".BSSs") prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".BSSs")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get BSSs property: %v", err) return nil, fmt.Errorf("failed to get BSSs property: %v", err)
} }
@ -52,7 +52,7 @@ func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) {
// GetState returns the current connection state // GetState returns the current connection state
func (i *WPAInterface) GetState() (WPAState, error) { func (i *WPAInterface) GetState() (WPAState, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".State") prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".State")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get State property: %v", err) return "", fmt.Errorf("failed to get State property: %v", err)
} }
@ -67,7 +67,7 @@ func (i *WPAInterface) GetState() (WPAState, error) {
// GetCurrentBSS returns the currently connected BSS object path // GetCurrentBSS returns the currently connected BSS object path
func (i *WPAInterface) GetCurrentBSS() (dbus.ObjectPath, error) { func (i *WPAInterface) GetCurrentBSS() (dbus.ObjectPath, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".CurrentBSS") prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".CurrentBSS")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get CurrentBSS property: %v", err) return "", fmt.Errorf("failed to get CurrentBSS property: %v", err)
} }
@ -90,7 +90,7 @@ func (i *WPAInterface) AddNetwork(config map[string]interface{}) (dbus.ObjectPat
dbusConfig[key] = dbus.MakeVariant(value) dbusConfig[key] = dbus.MakeVariant(value)
} }
err := i.obj.Call(InterfaceInterface+".AddNetwork", 0, dbusConfig).Store(&networkPath) err := callNoAutoStart(i.obj, InterfaceInterface+".AddNetwork", dbusConfig).Store(&networkPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to add network: %v", err) return "", fmt.Errorf("failed to add network: %v", err)
} }
@ -100,7 +100,7 @@ func (i *WPAInterface) AddNetwork(config map[string]interface{}) (dbus.ObjectPat
// SelectNetwork connects to a network // SelectNetwork connects to a network
func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error { func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error {
err := i.obj.Call(InterfaceInterface+".SelectNetwork", 0, networkPath).Err err := callNoAutoStart(i.obj, InterfaceInterface+".SelectNetwork", networkPath).Err
if err != nil { if err != nil {
return fmt.Errorf("failed to select network: %v", err) return fmt.Errorf("failed to select network: %v", err)
} }
@ -110,7 +110,7 @@ func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error {
// EnableNetwork marks a network configuration as enabled (eligible for auto-connect) // EnableNetwork marks a network configuration as enabled (eligible for auto-connect)
func (i *WPAInterface) EnableNetwork(networkPath dbus.ObjectPath) error { func (i *WPAInterface) EnableNetwork(networkPath dbus.ObjectPath) error {
netObj := i.conn.Object(Service, networkPath) netObj := i.conn.Object(Service, networkPath)
err := netObj.SetProperty(NetworkInterface+".Enabled", dbus.MakeVariant(true)) err := setPropNoAutoStart(netObj, NetworkInterface+".Enabled", dbus.MakeVariant(true))
if err != nil { if err != nil {
return fmt.Errorf("failed to enable network: %v", err) return fmt.Errorf("failed to enable network: %v", err)
} }
@ -119,7 +119,7 @@ func (i *WPAInterface) EnableNetwork(networkPath dbus.ObjectPath) error {
// RemoveNetwork removes a network configuration // RemoveNetwork removes a network configuration
func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error { func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error {
err := i.obj.Call(InterfaceInterface+".RemoveNetwork", 0, networkPath).Err err := callNoAutoStart(i.obj, InterfaceInterface+".RemoveNetwork", networkPath).Err
if err != nil { if err != nil {
return fmt.Errorf("failed to remove network: %v", err) return fmt.Errorf("failed to remove network: %v", err)
} }
@ -128,7 +128,7 @@ func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error {
// GetNetworks returns the object paths of all configured networks // GetNetworks returns the object paths of all configured networks
func (i *WPAInterface) GetNetworks() ([]dbus.ObjectPath, error) { func (i *WPAInterface) GetNetworks() ([]dbus.ObjectPath, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".Networks") prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".Networks")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get Networks property: %v", err) return nil, fmt.Errorf("failed to get Networks property: %v", err)
} }
@ -143,7 +143,7 @@ func (i *WPAInterface) GetNetworks() ([]dbus.ObjectPath, error) {
// Disconnect disconnects from the current network // Disconnect disconnects from the current network
func (i *WPAInterface) Disconnect() error { func (i *WPAInterface) Disconnect() error {
err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err err := callNoAutoStart(i.obj, InterfaceInterface+".Disconnect").Err
if err != nil { if err != nil {
return fmt.Errorf("disconnect failed: %v", err) return fmt.Errorf("disconnect failed: %v", err)
} }
@ -152,7 +152,7 @@ func (i *WPAInterface) Disconnect() error {
// SaveConfig saves the current configuration to the wpa_supplicant config file // SaveConfig saves the current configuration to the wpa_supplicant config file
func (i *WPAInterface) SaveConfig() error { func (i *WPAInterface) SaveConfig() error {
err := i.obj.Call(InterfaceInterface+".SaveConfig", 0).Err err := callNoAutoStart(i.obj, InterfaceInterface+".SaveConfig").Err
if err != nil { if err != nil {
return fmt.Errorf("save config failed: %v", err) return fmt.Errorf("save config failed: %v", err)
} }
@ -166,7 +166,7 @@ func (i *WPAInterface) GetPath() dbus.ObjectPath {
// GetScanning returns whether a scan is currently in progress // GetScanning returns whether a scan is currently in progress
func (i *WPAInterface) GetScanning() (bool, error) { func (i *WPAInterface) GetScanning() (bool, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".Scanning") prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".Scanning")
if err != nil { if err != nil {
return false, fmt.Errorf("failed to get Scanning property: %v", err) return false, fmt.Errorf("failed to get Scanning property: %v", err)
} }

View file

@ -29,7 +29,7 @@ func (n *Network) GetPath() dbus.ObjectPath {
// GetProperties returns properties of the network configuration // GetProperties returns properties of the network configuration
func (n *Network) GetProperties() (map[string]dbus.Variant, error) { func (n *Network) GetProperties() (map[string]dbus.Variant, error) {
prop, err := n.obj.GetProperty(NetworkInterface + ".Properties") prop, err := getPropNoAutoStart(n.obj, NetworkInterface+".Properties")
if err != nil { if err != nil {
return nil, err return nil, err
} }