From 11ae69de561c75b65f16c6195c95d7d1acf7c913 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 2 Jul 2026 11:44:59 +0800 Subject: [PATCH 1/2] wpasupplicant: Disable D-Bus auto-activation on all calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/wifi/wpasupplicant/backend.go | 4 +- internal/wifi/wpasupplicant/bss.go | 20 ++++---- internal/wifi/wpasupplicant/dbusutil.go | 60 ++++++++++++++++++++++++ internal/wifi/wpasupplicant/interface.go | 24 +++++----- internal/wifi/wpasupplicant/network.go | 2 +- 5 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 internal/wifi/wpasupplicant/dbusutil.go diff --git a/internal/wifi/wpasupplicant/backend.go b/internal/wifi/wpasupplicant/backend.go index 1516330..7a0dec3 100644 --- a/internal/wifi/wpasupplicant/backend.go +++ b/internal/wifi/wpasupplicant/backend.go @@ -54,7 +54,7 @@ func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, er var interfacePath dbus.ObjectPath // 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 { return interfacePath, nil } @@ -64,7 +64,7 @@ func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, er "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 { return "", fmt.Errorf("failed to create interface: %v", err) } diff --git a/internal/wifi/wpasupplicant/bss.go b/internal/wifi/wpasupplicant/bss.go index ab1b5b7..e161eea 100644 --- a/internal/wifi/wpasupplicant/bss.go +++ b/internal/wifi/wpasupplicant/bss.go @@ -38,49 +38,49 @@ func (b *BSS) GetProperties() (*BSSProperties, error) { props := &BSSProperties{} // 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 { props.SSID = ssid } } // 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 { props.BSSID = bssid } } // 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 { props.Signal = signal } } // 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 { props.Frequency = uint32(freq) } } // 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 { props.Privacy = privacy } } // 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 { props.RSN = rsn } } // 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 { props.WPA = wpa } @@ -91,7 +91,7 @@ func (b *BSS) GetProperties() (*BSSProperties, error) { // GetSSIDString returns the SSID as a string func (b *BSS) GetSSIDString() (string, error) { - prop, err := b.obj.GetProperty(BSSInterface + ".SSID") + prop, err := getPropNoAutoStart(b.obj, BSSInterface+".SSID") if err != nil { 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 func (b *BSS) GetBSSIDString() (string, error) { - prop, err := b.obj.GetProperty(BSSInterface + ".BSSID") + prop, err := getPropNoAutoStart(b.obj, BSSInterface+".BSSID") if err != nil { 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 func (b *BSS) GetSignal() (int16, error) { - prop, err := b.obj.GetProperty(BSSInterface + ".Signal") + prop, err := getPropNoAutoStart(b.obj, BSSInterface+".Signal") if err != nil { return 0, fmt.Errorf("failed to get Signal property: %v", err) } diff --git a/internal/wifi/wpasupplicant/dbusutil.go b/internal/wifi/wpasupplicant/dbusutil.go new file mode 100644 index 0000000..217089f --- /dev/null +++ b/internal/wifi/wpasupplicant/dbusutil.go @@ -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 +} diff --git a/internal/wifi/wpasupplicant/interface.go b/internal/wifi/wpasupplicant/interface.go index e27d1a0..4484605 100644 --- a/internal/wifi/wpasupplicant/interface.go +++ b/internal/wifi/wpasupplicant/interface.go @@ -28,7 +28,7 @@ func (i *WPAInterface) Scan(scanType string) error { "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 { 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 func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) { - prop, err := i.obj.GetProperty(InterfaceInterface + ".BSSs") + prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".BSSs") if err != nil { 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 func (i *WPAInterface) GetState() (WPAState, error) { - prop, err := i.obj.GetProperty(InterfaceInterface + ".State") + prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".State") if err != nil { 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 func (i *WPAInterface) GetCurrentBSS() (dbus.ObjectPath, error) { - prop, err := i.obj.GetProperty(InterfaceInterface + ".CurrentBSS") + prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".CurrentBSS") if err != nil { 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) } - err := i.obj.Call(InterfaceInterface+".AddNetwork", 0, dbusConfig).Store(&networkPath) + err := callNoAutoStart(i.obj, InterfaceInterface+".AddNetwork", dbusConfig).Store(&networkPath) if err != nil { 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 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 { 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) func (i *WPAInterface) EnableNetwork(networkPath dbus.ObjectPath) error { 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 { 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 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 { 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 func (i *WPAInterface) GetNetworks() ([]dbus.ObjectPath, error) { - prop, err := i.obj.GetProperty(InterfaceInterface + ".Networks") + prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".Networks") if err != nil { 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 func (i *WPAInterface) Disconnect() error { - err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err + err := callNoAutoStart(i.obj, InterfaceInterface+".Disconnect").Err if err != nil { 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 func (i *WPAInterface) SaveConfig() error { - err := i.obj.Call(InterfaceInterface+".SaveConfig", 0).Err + err := callNoAutoStart(i.obj, InterfaceInterface+".SaveConfig").Err if err != nil { 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 func (i *WPAInterface) GetScanning() (bool, error) { - prop, err := i.obj.GetProperty(InterfaceInterface + ".Scanning") + prop, err := getPropNoAutoStart(i.obj, InterfaceInterface+".Scanning") if err != nil { return false, fmt.Errorf("failed to get Scanning property: %v", err) } diff --git a/internal/wifi/wpasupplicant/network.go b/internal/wifi/wpasupplicant/network.go index c018e3f..39ce080 100644 --- a/internal/wifi/wpasupplicant/network.go +++ b/internal/wifi/wpasupplicant/network.go @@ -29,7 +29,7 @@ func (n *Network) GetPath() dbus.ObjectPath { // GetProperties returns properties of the network configuration 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 { return nil, err } From 310cce2a45437f90185cb965bd44f3f5089e4bd4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 5 Jul 2026 09:51:44 +0800 Subject: [PATCH 2/2] app: Detect Ethernet uplink via default route, not address flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/app/network.go | 81 +++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/internal/app/network.go b/internal/app/network.go index 07502f1..4e93898 100644 --- a/internal/app/network.go +++ b/internal/app/network.go @@ -22,50 +22,67 @@ func hasCarrier(iface string) bool { } // 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). +// 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", "-o", "addr", "show", "dev", iface).Output() + out, err := exec.Command("ip", "-4", "route", "show", "default", "dev", iface).Output() 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") { - 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 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 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 -// 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 // caller must avoid any D-Bus call to fi.w1.wpa_supplicant1, which would // 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) 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 { log.Printf("Failed to start wpa_supplicant: %v: %s", err, strings.TrimSpace(string(out))) }