浏览代码

Add Linux WI-FI state support

Support monitoring WIFI state on Linux through:
- NetworkManager (D-Bus)
- IWD (D-Bus)
- wpa_supplicant (control socket)
- ConnMan (D-Bus)
世界 2 周之前
父节点
当前提交
5ce866cc8a

+ 3 - 1
adapter/network.go

@@ -10,6 +10,7 @@ import (
 
 type NetworkManager interface {
 	Lifecycle
+	Initialize(ruleSets []RuleSet)
 	InterfaceFinder() control.InterfaceFinder
 	UpdateInterfaces() error
 	DefaultNetworkInterface() *NetworkInterface
@@ -24,9 +25,10 @@ type NetworkManager interface {
 	NetworkMonitor() tun.NetworkUpdateMonitor
 	InterfaceMonitor() tun.DefaultInterfaceMonitor
 	PackageManager() tun.PackageManager
+	NeedWIFIState() bool
 	WIFIState() WIFIState
-	ResetNetwork()
 	UpdateWIFIState()
+	ResetNetwork()
 }
 
 type NetworkOptions struct {

+ 0 - 1
adapter/router.go

@@ -24,7 +24,6 @@ type Router interface {
 	PreMatch(metadata InboundContext, context tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error)
 	ConnectionRouterEx
 	RuleSet(tag string) (RuleSet, bool)
-	NeedWIFIState() bool
 	Rules() []Rule
 	AppendTracker(tracker ConnectionTracker)
 	ResetNetwork()

+ 1 - 1
box.go

@@ -184,7 +184,7 @@ func New(options Options) (*Box, error) {
 	service.MustRegister[adapter.ServiceManager](ctx, serviceManager)
 	dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions)
 	service.MustRegister[adapter.DNSRouter](ctx, dnsRouter)
-	networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions)
+	networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions)
 	if err != nil {
 		return nil, E.Cause(err, "initialize network manager")
 	}

+ 9 - 0
common/settings/wifi.go

@@ -0,0 +1,9 @@
+package settings
+
+import "github.com/sagernet/sing-box/adapter"
+
+type WIFIMonitor interface {
+	ReadWIFIState() adapter.WIFIState
+	Start() error
+	Close() error
+}

+ 46 - 0
common/settings/wifi_linux.go

@@ -0,0 +1,46 @@
+package settings
+
+import (
+	"github.com/sagernet/sing-box/adapter"
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+type LinuxWIFIMonitor struct {
+	monitor WIFIMonitor
+}
+
+func NewWIFIMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
+	monitors := []func(func(adapter.WIFIState)) (WIFIMonitor, error){
+		newNetworkManagerMonitor,
+		newIWDMonitor,
+		newWpaSupplicantMonitor,
+		newConnManMonitor,
+	}
+	var errors []error
+	for _, factory := range monitors {
+		monitor, err := factory(callback)
+		if err == nil {
+			return &LinuxWIFIMonitor{monitor: monitor}, nil
+		}
+		errors = append(errors, err)
+	}
+	return nil, E.Cause(E.Errors(errors...), "no supported WIFI manager found")
+}
+
+func (m *LinuxWIFIMonitor) ReadWIFIState() adapter.WIFIState {
+	return m.monitor.ReadWIFIState()
+}
+
+func (m *LinuxWIFIMonitor) Start() error {
+	if m.monitor != nil {
+		return m.monitor.Start()
+	}
+	return nil
+}
+
+func (m *LinuxWIFIMonitor) Close() error {
+	if m.monitor != nil {
+		return m.monitor.Close()
+	}
+	return nil
+}

+ 166 - 0
common/settings/wifi_linux_connman.go

@@ -0,0 +1,166 @@
+package settings
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+
+	"github.com/godbus/dbus/v5"
+)
+
+type connmanMonitor struct {
+	conn       *dbus.Conn
+	callback   func(adapter.WIFIState)
+	cancel     context.CancelFunc
+	signalChan chan *dbus.Signal
+}
+
+func newConnManMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
+	conn, err := dbus.ConnectSystemBus()
+	if err != nil {
+		return nil, err
+	}
+	cmObj := conn.Object("net.connman", "/")
+	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+	defer cancel()
+	call := cmObj.CallWithContext(ctx, "net.connman.Manager.GetServices", 0)
+	if call.Err != nil {
+		conn.Close()
+		return nil, call.Err
+	}
+	return &connmanMonitor{conn: conn, callback: callback}, nil
+}
+
+func (m *connmanMonitor) ReadWIFIState() adapter.WIFIState {
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancel()
+
+	cmObj := m.conn.Object("net.connman", "/")
+	var services []interface{}
+	err := cmObj.CallWithContext(ctx, "net.connman.Manager.GetServices", 0).Store(&services)
+	if err != nil {
+		return adapter.WIFIState{}
+	}
+
+	for _, service := range services {
+		servicePair, ok := service.([]interface{})
+		if !ok || len(servicePair) != 2 {
+			continue
+		}
+
+		serviceProps, ok := servicePair[1].(map[string]dbus.Variant)
+		if !ok {
+			continue
+		}
+
+		typeVariant, hasType := serviceProps["Type"]
+		if !hasType {
+			continue
+		}
+		serviceType, ok := typeVariant.Value().(string)
+		if !ok || serviceType != "wifi" {
+			continue
+		}
+
+		stateVariant, hasState := serviceProps["State"]
+		if !hasState {
+			continue
+		}
+		state, ok := stateVariant.Value().(string)
+		if !ok || (state != "online" && state != "ready") {
+			continue
+		}
+
+		nameVariant, hasName := serviceProps["Name"]
+		if !hasName {
+			continue
+		}
+		ssid, ok := nameVariant.Value().(string)
+		if !ok || ssid == "" {
+			continue
+		}
+
+		bssidVariant, hasBSSID := serviceProps["BSSID"]
+		if !hasBSSID {
+			return adapter.WIFIState{SSID: ssid}
+		}
+		bssid, ok := bssidVariant.Value().(string)
+		if !ok {
+			return adapter.WIFIState{SSID: ssid}
+		}
+
+		return adapter.WIFIState{
+			SSID:  ssid,
+			BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")),
+		}
+	}
+
+	return adapter.WIFIState{}
+}
+
+func (m *connmanMonitor) Start() error {
+	if m.callback == nil {
+		return nil
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	m.cancel = cancel
+
+	m.signalChan = make(chan *dbus.Signal, 10)
+	m.conn.Signal(m.signalChan)
+
+	err := m.conn.AddMatchSignal(
+		dbus.WithMatchInterface("net.connman.Service"),
+		dbus.WithMatchSender("net.connman"),
+	)
+	if err != nil {
+		return err
+	}
+
+	state := m.ReadWIFIState()
+	go m.monitorSignals(ctx, m.signalChan, state)
+	m.callback(state)
+
+	return nil
+}
+
+func (m *connmanMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) {
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case signal, ok := <-signalChan:
+			if !ok {
+				return
+			}
+			// godbus Signal.Name uses "interface.member" format (e.g. "net.connman.Service.PropertyChanged"),
+			// not just the member name. This differs from the D-Bus signal member in the match rule.
+			if signal.Name == "net.connman.Service.PropertyChanged" {
+				state := m.ReadWIFIState()
+				if state != lastState {
+					lastState = state
+					m.callback(state)
+				}
+			}
+		}
+	}
+}
+
+func (m *connmanMonitor) Close() error {
+	if m.cancel != nil {
+		m.cancel()
+	}
+	if m.signalChan != nil {
+		m.conn.RemoveSignal(m.signalChan)
+		close(m.signalChan)
+	}
+	if m.conn != nil {
+		m.conn.RemoveMatchSignal(
+			dbus.WithMatchInterface("net.connman.Service"),
+			dbus.WithMatchSender("net.connman"),
+		)
+		return m.conn.Close()
+	}
+	return nil
+}

+ 188 - 0
common/settings/wifi_linux_iwd.go

@@ -0,0 +1,188 @@
+package settings
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+
+	"github.com/godbus/dbus/v5"
+)
+
+type iwdMonitor struct {
+	conn       *dbus.Conn
+	callback   func(adapter.WIFIState)
+	cancel     context.CancelFunc
+	signalChan chan *dbus.Signal
+}
+
+func newIWDMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
+	conn, err := dbus.ConnectSystemBus()
+	if err != nil {
+		return nil, err
+	}
+	iwdObj := conn.Object("net.connman.iwd", "/")
+	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+	defer cancel()
+	call := iwdObj.CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0)
+	if call.Err != nil {
+		conn.Close()
+		return nil, call.Err
+	}
+	return &iwdMonitor{conn: conn, callback: callback}, nil
+}
+
+func (m *iwdMonitor) ReadWIFIState() adapter.WIFIState {
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancel()
+
+	iwdObj := m.conn.Object("net.connman.iwd", "/")
+	var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+	err := iwdObj.CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&objects)
+	if err != nil {
+		return adapter.WIFIState{}
+	}
+
+	for _, interfaces := range objects {
+		stationProps, hasStation := interfaces["net.connman.iwd.Station"]
+		if !hasStation {
+			continue
+		}
+
+		stateVariant, hasState := stationProps["State"]
+		if !hasState {
+			continue
+		}
+		state, ok := stateVariant.Value().(string)
+		if !ok || state != "connected" {
+			continue
+		}
+
+		connectedNetworkVariant, hasNetwork := stationProps["ConnectedNetwork"]
+		if !hasNetwork {
+			continue
+		}
+		networkPath, ok := connectedNetworkVariant.Value().(dbus.ObjectPath)
+		if !ok || networkPath == "/" {
+			continue
+		}
+
+		networkInterfaces, hasNetworkPath := objects[networkPath]
+		if !hasNetworkPath {
+			continue
+		}
+
+		networkProps, hasNetworkInterface := networkInterfaces["net.connman.iwd.Network"]
+		if !hasNetworkInterface {
+			continue
+		}
+
+		nameVariant, hasName := networkProps["Name"]
+		if !hasName {
+			continue
+		}
+		ssid, ok := nameVariant.Value().(string)
+		if !ok {
+			continue
+		}
+
+		connectedBSSVariant, hasBSS := stationProps["ConnectedAccessPoint"]
+		if !hasBSS {
+			return adapter.WIFIState{SSID: ssid}
+		}
+		bssPath, ok := connectedBSSVariant.Value().(dbus.ObjectPath)
+		if !ok || bssPath == "/" {
+			return adapter.WIFIState{SSID: ssid}
+		}
+
+		bssInterfaces, hasBSSPath := objects[bssPath]
+		if !hasBSSPath {
+			return adapter.WIFIState{SSID: ssid}
+		}
+
+		bssProps, hasBSSInterface := bssInterfaces["net.connman.iwd.BasicServiceSet"]
+		if !hasBSSInterface {
+			return adapter.WIFIState{SSID: ssid}
+		}
+
+		addressVariant, hasAddress := bssProps["Address"]
+		if !hasAddress {
+			return adapter.WIFIState{SSID: ssid}
+		}
+		bssid, ok := addressVariant.Value().(string)
+		if !ok {
+			return adapter.WIFIState{SSID: ssid}
+		}
+
+		return adapter.WIFIState{
+			SSID:  ssid,
+			BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")),
+		}
+	}
+
+	return adapter.WIFIState{}
+}
+
+func (m *iwdMonitor) Start() error {
+	if m.callback == nil {
+		return nil
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	m.cancel = cancel
+
+	m.signalChan = make(chan *dbus.Signal, 10)
+	m.conn.Signal(m.signalChan)
+
+	err := m.conn.AddMatchSignal(
+		dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
+		dbus.WithMatchSender("net.connman.iwd"),
+	)
+	if err != nil {
+		return err
+	}
+
+	state := m.ReadWIFIState()
+	go m.monitorSignals(ctx, m.signalChan, state)
+	m.callback(state)
+
+	return nil
+}
+
+func (m *iwdMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) {
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case signal, ok := <-signalChan:
+			if !ok {
+				return
+			}
+			if signal.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" {
+				state := m.ReadWIFIState()
+				if state != lastState {
+					lastState = state
+					m.callback(state)
+				}
+			}
+		}
+	}
+}
+
+func (m *iwdMonitor) Close() error {
+	if m.cancel != nil {
+		m.cancel()
+	}
+	if m.signalChan != nil {
+		m.conn.RemoveSignal(m.signalChan)
+		close(m.signalChan)
+	}
+	if m.conn != nil {
+		m.conn.RemoveMatchSignal(
+			dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
+			dbus.WithMatchSender("net.connman.iwd"),
+		)
+		return m.conn.Close()
+	}
+	return nil
+}

+ 163 - 0
common/settings/wifi_linux_nm.go

@@ -0,0 +1,163 @@
+package settings
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+
+	"github.com/godbus/dbus/v5"
+)
+
+type networkManagerMonitor struct {
+	conn       *dbus.Conn
+	callback   func(adapter.WIFIState)
+	cancel     context.CancelFunc
+	signalChan chan *dbus.Signal
+}
+
+func newNetworkManagerMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
+	conn, err := dbus.ConnectSystemBus()
+	if err != nil {
+		return nil, err
+	}
+	nmObj := conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager")
+	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+	defer cancel()
+	var state uint32
+	err = nmObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager", "State").Store(&state)
+	if err != nil {
+		conn.Close()
+		return nil, err
+	}
+	return &networkManagerMonitor{conn: conn, callback: callback}, nil
+}
+
+func (m *networkManagerMonitor) ReadWIFIState() adapter.WIFIState {
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancel()
+
+	nmObj := m.conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager")
+
+	var activeConnectionPaths []dbus.ObjectPath
+	err := nmObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager", "ActiveConnections").Store(&activeConnectionPaths)
+	if err != nil || len(activeConnectionPaths) == 0 {
+		return adapter.WIFIState{}
+	}
+
+	for _, connectionPath := range activeConnectionPaths {
+		connObj := m.conn.Object("org.freedesktop.NetworkManager", connectionPath)
+
+		var devicePaths []dbus.ObjectPath
+		err = connObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Connection.Active", "Devices").Store(&devicePaths)
+		if err != nil || len(devicePaths) == 0 {
+			continue
+		}
+
+		for _, devicePath := range devicePaths {
+			deviceObj := m.conn.Object("org.freedesktop.NetworkManager", devicePath)
+
+			var deviceType uint32
+			err = deviceObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Device", "DeviceType").Store(&deviceType)
+			if err != nil || deviceType != 2 {
+				continue
+			}
+
+			var accessPointPath dbus.ObjectPath
+			err = deviceObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Device.Wireless", "ActiveAccessPoint").Store(&accessPointPath)
+			if err != nil || accessPointPath == "/" {
+				continue
+			}
+
+			apObj := m.conn.Object("org.freedesktop.NetworkManager", accessPointPath)
+
+			var ssidBytes []byte
+			err = apObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.AccessPoint", "Ssid").Store(&ssidBytes)
+			if err != nil {
+				continue
+			}
+
+			var hwAddress string
+			err = apObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.AccessPoint", "HwAddress").Store(&hwAddress)
+			if err != nil {
+				continue
+			}
+
+			ssid := strings.TrimSpace(string(ssidBytes))
+			if ssid == "" {
+				continue
+			}
+
+			return adapter.WIFIState{
+				SSID:  ssid,
+				BSSID: strings.ToUpper(strings.ReplaceAll(hwAddress, ":", "")),
+			}
+		}
+	}
+
+	return adapter.WIFIState{}
+}
+
+func (m *networkManagerMonitor) Start() error {
+	if m.callback == nil {
+		return nil
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	m.cancel = cancel
+
+	m.signalChan = make(chan *dbus.Signal, 10)
+	m.conn.Signal(m.signalChan)
+
+	err := m.conn.AddMatchSignal(
+		dbus.WithMatchSender("org.freedesktop.NetworkManager"),
+		dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
+	)
+	if err != nil {
+		return err
+	}
+
+	state := m.ReadWIFIState()
+	go m.monitorSignals(ctx, m.signalChan, state)
+	m.callback(state)
+
+	return nil
+}
+
+func (m *networkManagerMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) {
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case signal, ok := <-signalChan:
+			if !ok {
+				return
+			}
+			if signal.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" {
+				state := m.ReadWIFIState()
+				if state != lastState {
+					lastState = state
+					m.callback(state)
+				}
+			}
+		}
+	}
+}
+
+func (m *networkManagerMonitor) Close() error {
+	if m.cancel != nil {
+		m.cancel()
+	}
+	if m.signalChan != nil {
+		m.conn.RemoveSignal(m.signalChan)
+		close(m.signalChan)
+	}
+	if m.conn != nil {
+		m.conn.RemoveMatchSignal(
+			dbus.WithMatchSender("org.freedesktop.NetworkManager"),
+			dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
+		)
+		return m.conn.Close()
+	}
+	return nil
+}

+ 225 - 0
common/settings/wifi_linux_wpa.go

@@ -0,0 +1,225 @@
+package settings
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+)
+
+var wpaSocketCounter atomic.Uint64
+
+type wpaSupplicantMonitor struct {
+	socketPath  string
+	callback    func(adapter.WIFIState)
+	cancel      context.CancelFunc
+	monitorConn *net.UnixConn
+	connMutex   sync.Mutex
+}
+
+func newWpaSupplicantMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
+	socketDirs := []string{"/var/run/wpa_supplicant", "/run/wpa_supplicant"}
+	for _, socketDir := range socketDirs {
+		entries, err := os.ReadDir(socketDir)
+		if err != nil {
+			continue
+		}
+		for _, entry := range entries {
+			if entry.IsDir() || entry.Name() == "." || entry.Name() == ".." {
+				continue
+			}
+			socketPath := filepath.Join(socketDir, entry.Name())
+			id := wpaSocketCounter.Add(1)
+			localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-%d-%d", os.Getpid(), id), Net: "unixgram"}
+			remoteAddr := &net.UnixAddr{Name: socketPath, Net: "unixgram"}
+			conn, err := net.DialUnix("unixgram", localAddr, remoteAddr)
+			if err != nil {
+				continue
+			}
+			conn.Close()
+			return &wpaSupplicantMonitor{socketPath: socketPath, callback: callback}, nil
+		}
+	}
+	return nil, os.ErrNotExist
+}
+
+func (m *wpaSupplicantMonitor) ReadWIFIState() adapter.WIFIState {
+	id := wpaSocketCounter.Add(1)
+	localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-%d-%d", os.Getpid(), id), Net: "unixgram"}
+	remoteAddr := &net.UnixAddr{Name: m.socketPath, Net: "unixgram"}
+	conn, err := net.DialUnix("unixgram", localAddr, remoteAddr)
+	if err != nil {
+		return adapter.WIFIState{}
+	}
+	defer conn.Close()
+
+	conn.SetDeadline(time.Now().Add(3 * time.Second))
+
+	status, err := m.sendCommand(conn, "STATUS")
+	if err != nil {
+		return adapter.WIFIState{}
+	}
+
+	var ssid, bssid string
+	var connected bool
+	scanner := bufio.NewScanner(strings.NewReader(status))
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, "wpa_state=") {
+			state := strings.TrimPrefix(line, "wpa_state=")
+			connected = state == "COMPLETED"
+		} else if strings.HasPrefix(line, "ssid=") {
+			ssid = strings.TrimPrefix(line, "ssid=")
+		} else if strings.HasPrefix(line, "bssid=") {
+			bssid = strings.TrimPrefix(line, "bssid=")
+		}
+	}
+
+	if !connected || ssid == "" {
+		return adapter.WIFIState{}
+	}
+
+	return adapter.WIFIState{
+		SSID:  ssid,
+		BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")),
+	}
+}
+
+// sendCommand sends a command to wpa_supplicant and returns the response.
+// Commands are sent without trailing newlines per the wpa_supplicant control
+// interface protocol - the official wpa_ctrl.c sends raw command strings.
+func (m *wpaSupplicantMonitor) sendCommand(conn *net.UnixConn, command string) (string, error) {
+	_, err := conn.Write([]byte(command))
+	if err != nil {
+		return "", err
+	}
+
+	buf := make([]byte, 4096)
+	n, err := conn.Read(buf)
+	if err != nil {
+		return "", err
+	}
+
+	response := string(buf[:n])
+	if strings.HasPrefix(response, "FAIL") {
+		return "", os.ErrInvalid
+	}
+
+	return strings.TrimSpace(response), nil
+}
+
+func (m *wpaSupplicantMonitor) Start() error {
+	if m.callback == nil {
+		return nil
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	m.cancel = cancel
+
+	state := m.ReadWIFIState()
+	go m.monitorEvents(ctx, state)
+	m.callback(state)
+
+	return nil
+}
+
+func (m *wpaSupplicantMonitor) monitorEvents(ctx context.Context, lastState adapter.WIFIState) {
+	var consecutiveErrors int
+	var debounceTimer *time.Timer
+	var debounceMutex sync.Mutex
+
+	localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-mon-%d", os.Getpid()), Net: "unixgram"}
+	remoteAddr := &net.UnixAddr{Name: m.socketPath, Net: "unixgram"}
+	conn, err := net.DialUnix("unixgram", localAddr, remoteAddr)
+	if err != nil {
+		return
+	}
+	defer conn.Close()
+
+	m.connMutex.Lock()
+	m.monitorConn = conn
+	m.connMutex.Unlock()
+
+	// ATTACH/DETACH commands use os_strcmp() for exact matching in wpa_supplicant,
+	// so they must be sent without trailing newlines.
+	// See: https://w1.fi/cgit/hostap/tree/wpa_supplicant/ctrl_iface_unix.c
+	_, err = conn.Write([]byte("ATTACH"))
+	if err != nil {
+		return
+	}
+
+	buf := make([]byte, 4096)
+	n, err := conn.Read(buf)
+	if err != nil || !strings.HasPrefix(string(buf[:n]), "OK") {
+		return
+	}
+
+	for {
+		select {
+		case <-ctx.Done():
+			debounceMutex.Lock()
+			if debounceTimer != nil {
+				debounceTimer.Stop()
+			}
+			debounceMutex.Unlock()
+			conn.Write([]byte("DETACH"))
+			return
+		default:
+		}
+
+		conn.SetReadDeadline(time.Now().Add(30 * time.Second))
+		n, err := conn.Read(buf)
+		if err != nil {
+			if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+				continue
+			}
+			select {
+			case <-ctx.Done():
+				return
+			default:
+			}
+			consecutiveErrors++
+			if consecutiveErrors > 10 {
+				return
+			}
+			time.Sleep(time.Second)
+			continue
+		}
+		consecutiveErrors = 0
+
+		msg := string(buf[:n])
+		if strings.Contains(msg, "CTRL-EVENT-CONNECTED") || strings.Contains(msg, "CTRL-EVENT-DISCONNECTED") {
+			debounceMutex.Lock()
+			if debounceTimer != nil {
+				debounceTimer.Stop()
+			}
+			debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
+				state := m.ReadWIFIState()
+				if state != lastState {
+					lastState = state
+					m.callback(state)
+				}
+			})
+			debounceMutex.Unlock()
+		}
+	}
+}
+
+func (m *wpaSupplicantMonitor) Close() error {
+	if m.cancel != nil {
+		m.cancel()
+	}
+	m.connMutex.Lock()
+	if m.monitorConn != nil {
+		m.monitorConn.Close()
+	}
+	m.connMutex.Unlock()
+	return nil
+}

+ 27 - 0
common/settings/wifi_stub.go

@@ -0,0 +1,27 @@
+//go:build !linux
+
+package settings
+
+import (
+	"os"
+
+	"github.com/sagernet/sing-box/adapter"
+)
+
+type stubWIFIMonitor struct{}
+
+func NewWIFIMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
+	return nil, os.ErrInvalid
+}
+
+func (m *stubWIFIMonitor) ReadWIFIState() adapter.WIFIState {
+	return adapter.WIFIState{}
+}
+
+func (m *stubWIFIMonitor) Start() error {
+	return nil
+}
+
+func (m *stubWIFIMonitor) Close() error {
+	return nil
+}

+ 2 - 2
docs/configuration/dns/rule.md

@@ -412,7 +412,7 @@ Match default interface address.
 
 !!! quote ""
 
-    Only supported in graphical clients on Android and Apple platforms.
+    Only supported in graphical clients on Android and Apple platforms, or on Linux.
 
 Match WiFi SSID.
 
@@ -420,7 +420,7 @@ Match WiFi SSID.
 
 !!! quote ""
 
-    Only supported in graphical clients on Android and Apple platforms.
+    Only supported in graphical clients on Android and Apple platforms, or on Linux.
 
 Match WiFi BSSID.
 

+ 2 - 2
docs/configuration/dns/rule.zh.md

@@ -411,7 +411,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`.
 
 !!! quote ""
 
-    仅在 Android 与 Apple 平台图形客户端中支持。
+    仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。
 
 匹配 WiFi SSID。
 
@@ -419,7 +419,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`.
 
 !!! quote ""
 
-    仅在 Android 与 Apple 平台图形客户端中支持。
+    仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。
 
 匹配 WiFi BSSID。
 

+ 2 - 2
docs/configuration/route/rule.md

@@ -430,7 +430,7 @@ Match default interface address.
 
 !!! quote ""
 
-    Only supported in graphical clients on Android and Apple platforms.
+    Only supported in graphical clients on Android and Apple platforms, or on Linux.
 
 Match WiFi SSID.
 
@@ -438,7 +438,7 @@ Match WiFi SSID.
 
 !!! quote ""
 
-    Only supported in graphical clients on Android and Apple platforms.
+    Only supported in graphical clients on Android and Apple platforms, or on Linux.
 
 Match WiFi BSSID.
 

+ 2 - 2
docs/configuration/route/rule.zh.md

@@ -427,7 +427,7 @@ icon: material/new-box
 
 !!! quote ""
 
-    仅在 Android 与 Apple 平台图形客户端中支持。
+    仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。
 
 匹配 WiFi SSID。
 
@@ -435,7 +435,7 @@ icon: material/new-box
 
 !!! quote ""
 
-    仅在 Android 与 Apple 平台图形客户端中支持。
+    仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。
 
 匹配 WiFi BSSID。
 

+ 4 - 0
experimental/libbox/config.go

@@ -107,6 +107,10 @@ func (s *platformInterfaceStub) IncludeAllNetworks() bool {
 func (s *platformInterfaceStub) ClearDNSCache() {
 }
 
+func (s *platformInterfaceStub) UsePlatformWIFIMonitor() bool {
+	return false
+}
+
 func (s *platformInterfaceStub) ReadWIFIState() adapter.WIFIState {
 	return adapter.WIFIState{}
 }

+ 1 - 0
experimental/libbox/platform/interface.go

@@ -18,6 +18,7 @@ type Interface interface {
 	UnderNetworkExtension() bool
 	IncludeAllNetworks() bool
 	ClearDNSCache()
+	UsePlatformWIFIMonitor() bool
 	ReadWIFIState() adapter.WIFIState
 	SystemCertificates() []string
 	process.Searcher

+ 5 - 1
experimental/libbox/service.go

@@ -111,7 +111,7 @@ func (s *BoxService) Close() error {
 }
 
 func (s *BoxService) NeedWIFIState() bool {
-	return s.instance.Router().NeedWIFIState()
+	return s.instance.Network().NeedWIFIState()
 }
 
 var (
@@ -224,6 +224,10 @@ func (w *platformInterfaceWrapper) ClearDNSCache() {
 	w.iif.ClearDNSCache()
 }
 
+func (w *platformInterfaceWrapper) UsePlatformWIFIMonitor() bool {
+	return true
+}
+
 func (w *platformInterfaceWrapper) ReadWIFIState() adapter.WIFIState {
 	wifiState := w.iif.ReadWIFIState()
 	if wifiState == nil {

+ 65 - 9
route/network.go

@@ -8,11 +8,13 @@ import (
 	"os"
 	"runtime"
 	"strings"
+	"sync"
 	"syscall"
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/conntrack"
+	"github.com/sagernet/sing-box/common/settings"
 	"github.com/sagernet/sing-box/common/taskmonitor"
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/experimental/libbox/platform"
@@ -50,11 +52,14 @@ type NetworkManager struct {
 	endpoint               adapter.EndpointManager
 	inbound                adapter.InboundManager
 	outbound               adapter.OutboundManager
+	needWIFIState          bool
+	wifiMonitor            settings.WIFIMonitor
 	wifiState              adapter.WIFIState
+	wifiStateMutex         sync.RWMutex
 	started                bool
 }
 
-func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOptions option.RouteOptions) (*NetworkManager, error) {
+func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOptions option.RouteOptions, dnsOptions option.DNSOptions) (*NetworkManager, error) {
 	defaultDomainResolver := common.PtrValueOrDefault(routeOptions.DefaultDomainResolver)
 	if routeOptions.AutoDetectInterface && !(C.IsLinux || C.IsDarwin || C.IsWindows) {
 		return nil, E.New("`auto_detect_interface` is only supported on Linux, Windows and macOS")
@@ -89,6 +94,7 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOp
 		endpoint:          service.FromContext[adapter.EndpointManager](ctx),
 		inbound:           service.FromContext[adapter.InboundManager](ctx),
 		outbound:          service.FromContext[adapter.OutboundManager](ctx),
+		needWIFIState:     hasRule(routeOptions.Rules, isWIFIRule) || hasDNSRule(dnsOptions.Rules, isWIFIDNSRule),
 	}
 	if routeOptions.DefaultNetworkStrategy != nil {
 		if routeOptions.DefaultInterface != "" {
@@ -183,11 +189,35 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error {
 			}
 		}
 	case adapter.StartStatePostStart:
+		if r.needWIFIState && !(r.platformInterface != nil && r.platformInterface.UsePlatformWIFIMonitor()) {
+			wifiMonitor, err := settings.NewWIFIMonitor(r.onWIFIStateChanged)
+			if err != nil {
+				if err != os.ErrInvalid {
+					r.logger.Warn(E.Cause(err, "create WIFI monitor"))
+				}
+			} else {
+				r.wifiMonitor = wifiMonitor
+				err = r.wifiMonitor.Start()
+				if err != nil {
+					r.logger.Warn(E.Cause(err, "start WIFI monitor"))
+				}
+			}
+		}
 		r.started = true
 	}
 	return nil
 }
 
+func (r *NetworkManager) Initialize(ruleSets []adapter.RuleSet) {
+	for _, ruleSet := range ruleSets {
+		metadata := ruleSet.Metadata()
+		if metadata.ContainsWIFIRule {
+			r.needWIFIState = true
+			break
+		}
+	}
+}
+
 func (r *NetworkManager) Close() error {
 	monitor := taskmonitor.New(r.logger, C.StopTimeout)
 	var err error
@@ -219,6 +249,13 @@ func (r *NetworkManager) Close() error {
 		})
 		monitor.Finish()
 	}
+	if r.wifiMonitor != nil {
+		monitor.Start("close WIFI monitor")
+		err = E.Append(err, r.wifiMonitor.Close(), func(err error) error {
+			return E.Cause(err, "close WIFI monitor")
+		})
+		monitor.Finish()
+	}
 	return err
 }
 
@@ -376,20 +413,39 @@ func (r *NetworkManager) PackageManager() tun.PackageManager {
 	return r.packageManager
 }
 
+func (r *NetworkManager) NeedWIFIState() bool {
+	return r.needWIFIState
+}
+
 func (r *NetworkManager) WIFIState() adapter.WIFIState {
+	r.wifiStateMutex.RLock()
+	defer r.wifiStateMutex.RUnlock()
 	return r.wifiState
 }
 
+func (r *NetworkManager) onWIFIStateChanged(state adapter.WIFIState) {
+	r.wifiStateMutex.Lock()
+	if state == r.wifiState {
+		r.wifiStateMutex.Unlock()
+		return
+	}
+	r.wifiState = state
+	r.wifiStateMutex.Unlock()
+	if state.SSID != "" {
+		r.logger.Info("updated WIFI state: SSID=", state.SSID, ", BSSID=", state.BSSID)
+	}
+}
+
 func (r *NetworkManager) UpdateWIFIState() {
-	if r.platformInterface != nil {
-		state := r.platformInterface.ReadWIFIState()
-		if state != r.wifiState {
-			r.wifiState = state
-			if state.SSID != "" {
-				r.logger.Info("updated WIFI state: SSID=", state.SSID, ", BSSID=", state.BSSID)
-			}
-		}
+	var state adapter.WIFIState
+	if r.wifiMonitor != nil {
+		state = r.wifiMonitor.ReadWIFIState()
+	} else if r.platformInterface != nil && r.platformInterface.UsePlatformWIFIMonitor() {
+		state = r.platformInterface.ReadWIFIState()
+	} else {
+		return
 	}
+	r.onWIFIStateChanged(state)
 }
 
 func (r *NetworkManager) ResetNetwork() {

+ 1 - 9
route/router.go

@@ -38,7 +38,6 @@ type Router struct {
 	pauseManager      pause.Manager
 	trackers          []adapter.ConnectionTracker
 	platformInterface platform.Interface
-	needWIFIState     bool
 	started           bool
 }
 
@@ -57,7 +56,6 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route
 		needFindProcess:   hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess,
 		pauseManager:      service.FromContext[pause.Manager](ctx),
 		platformInterface: service.FromContext[platform.Interface](ctx),
-		needWIFIState:     hasRule(options.Rules, isWIFIRule) || hasDNSRule(dnsOptions.Rules, isWIFIDNSRule),
 	}
 }
 
@@ -113,15 +111,13 @@ func (r *Router) Start(stage adapter.StartStage) error {
 		if cacheContext != nil {
 			cacheContext.Close()
 		}
+		r.network.Initialize(r.ruleSets)
 		needFindProcess := r.needFindProcess
 		for _, ruleSet := range r.ruleSets {
 			metadata := ruleSet.Metadata()
 			if metadata.ContainsProcessRule {
 				needFindProcess = true
 			}
-			if metadata.ContainsWIFIRule {
-				r.needWIFIState = true
-			}
 		}
 		if needFindProcess {
 			if r.platformInterface != nil {
@@ -195,10 +191,6 @@ func (r *Router) RuleSet(tag string) (adapter.RuleSet, bool) {
 	return ruleSet, loaded
 }
 
-func (r *Router) NeedWIFIState() bool {
-	return r.needWIFIState
-}
-
 func (r *Router) Rules() []adapter.Rule {
 	return r.rules
 }