Jelajahi Sumber

Add Linux WI-FI state support

世界 1 Minggu lalu
induk
melakukan
766972ce52

+ 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 {

+ 2 - 0
adapter/platform.go

@@ -32,6 +32,8 @@ type PlatformInterface interface {
 	UsePlatformConnectionOwnerFinder() bool
 	FindConnectionOwner(request *FindConnectionOwnerRequest) (*ConnectionOwner, error)
 
+	UsePlatformWIFIMonitor() bool
+
 	UsePlatformNotification() bool
 	SendNotification(notification *Notification) error
 }

+ 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

@@ -183,7 +183,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
+}

+ 160 - 0
common/settings/wifi_linux_connman.go

@@ -0,0 +1,160 @@
+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
+			}
+			if signal.Name == "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 {
+		return m.conn.Close()
+	}
+	return nil
+}

+ 184 - 0
common/settings/wifi_linux_iwd.go

@@ -0,0 +1,184 @@
+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 {
+		return m.conn.Close()
+	}
+	return nil
+}

+ 157 - 0
common/settings/wifi_linux_nm.go

@@ -0,0 +1,157 @@
+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 primaryConnectionPath dbus.ObjectPath
+	err := nmObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager", "PrimaryConnection").Store(&primaryConnectionPath)
+	if err != nil || primaryConnectionPath == "/" {
+		return adapter.WIFIState{}
+	}
+
+	connObj := m.conn.Object("org.freedesktop.NetworkManager", primaryConnectionPath)
+
+	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 {
+		return adapter.WIFIState{}
+	}
+
+	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 {
+		return m.conn.Close()
+	}
+	return nil
+}

+ 179 - 0
common/settings/wifi_linux_wpa.go

@@ -0,0 +1,179 @@
+package settings
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+)
+
+type wpaSupplicantMonitor struct {
+	socketPath string
+	callback   func(adapter.WIFIState)
+	cancel     context.CancelFunc
+}
+
+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())
+			localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-%d", os.Getpid()), 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 {
+	localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-%d", os.Getpid()), 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, ":", "")),
+	}
+}
+
+func (m *wpaSupplicantMonitor) sendCommand(conn *net.UnixConn, command string) (string, error) {
+	_, err := conn.Write([]byte(command + "\n"))
+	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
+
+	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()
+
+	_, err = conn.Write([]byte("ATTACH\n"))
+	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():
+			return
+		default:
+		}
+
+		conn.SetReadDeadline(time.Now().Add(30 * time.Second))
+		n, err := conn.Read(buf)
+		if err != nil {
+			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") {
+			state := m.ReadWIFIState()
+			if state != lastState {
+				lastState = state
+				m.callback(state)
+			}
+		}
+	}
+}
+
+func (m *wpaSupplicantMonitor) Close() error {
+	if m.cancel != nil {
+		m.cancel()
+	}
+	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
+}

+ 0 - 1
daemon/started_service.go

@@ -18,7 +18,6 @@ import (
 	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common/batch"
 	E "github.com/sagernet/sing/common/exceptions"
-	F "github.com/sagernet/sing/common/format"
 	"github.com/sagernet/sing/common/memory"
 	"github.com/sagernet/sing/common/observable"
 	"github.com/sagernet/sing/common/x/list"

+ 1 - 1
experimental/libbox/command_server.go

@@ -151,7 +151,7 @@ func (s *CommandServer) NeedWIFIState() bool {
 	if instance == nil || instance.Box() == nil {
 		return false
 	}
-	return instance.Box().Router().NeedWIFIState()
+	return instance.Box().Network().NeedWIFIState()
 }
 
 func (s *CommandServer) Pause() {

+ 4 - 0
experimental/libbox/config.go

@@ -116,6 +116,10 @@ func (s *platformInterfaceStub) RequestPermissionForWIFIState() error {
 	return nil
 }
 
+func (s *platformInterfaceStub) UsePlatformWIFIMonitor() bool {
+	return false
+}
+
 func (s *platformInterfaceStub) ReadWIFIState() adapter.WIFIState {
 	return adapter.WIFIState{}
 }

+ 4 - 0
experimental/libbox/service.go

@@ -144,6 +144,10 @@ func (w *platformInterfaceWrapper) RequestPermissionForWIFIState() error {
 	return nil
 }
 
+func (w *platformInterfaceWrapper) UsePlatformWIFIMonitor() bool {
+	return true
+}
+
 func (w *platformInterfaceWrapper) ReadWIFIState() adapter.WIFIState {
 	wifiState := w.iif.ReadWIFIState()
 	if wifiState == nil {

+ 84 - 26
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/option"
@@ -49,28 +51,31 @@ 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) {
-	defaultDomainResolver := common.PtrValueOrDefault(routeOptions.DefaultDomainResolver)
-	if routeOptions.AutoDetectInterface && !(C.IsLinux || C.IsDarwin || C.IsWindows) {
+func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, options option.RouteOptions, dnsOptions option.DNSOptions) (*NetworkManager, error) {
+	defaultDomainResolver := common.PtrValueOrDefault(options.DefaultDomainResolver)
+	if options.AutoDetectInterface && !(C.IsLinux || C.IsDarwin || C.IsWindows) {
 		return nil, E.New("`auto_detect_interface` is only supported on Linux, Windows and macOS")
-	} else if routeOptions.OverrideAndroidVPN && !C.IsAndroid {
+	} else if options.OverrideAndroidVPN && !C.IsAndroid {
 		return nil, E.New("`override_android_vpn` is only supported on Android")
-	} else if routeOptions.DefaultInterface != "" && !(C.IsLinux || C.IsDarwin || C.IsWindows) {
+	} else if options.DefaultInterface != "" && !(C.IsLinux || C.IsDarwin || C.IsWindows) {
 		return nil, E.New("`default_interface` is only supported on Linux, Windows and macOS")
-	} else if routeOptions.DefaultMark != 0 && !C.IsLinux {
+	} else if options.DefaultMark != 0 && !C.IsLinux {
 		return nil, E.New("`default_mark` is only supported on linux")
 	}
 	nm := &NetworkManager{
 		logger:              logger,
 		interfaceFinder:     control.NewDefaultInterfaceFinder(),
-		autoDetectInterface: routeOptions.AutoDetectInterface,
+		autoDetectInterface: options.AutoDetectInterface,
 		defaultOptions: adapter.NetworkOptions{
-			BindInterface:  routeOptions.DefaultInterface,
-			RoutingMark:    uint32(routeOptions.DefaultMark),
+			BindInterface:  options.DefaultInterface,
+			RoutingMark:    uint32(options.DefaultMark),
 			DomainResolver: defaultDomainResolver.Server,
 			DomainResolveOptions: adapter.DNSQueryOptions{
 				Strategy:     C.DomainStrategy(defaultDomainResolver.Strategy),
@@ -78,27 +83,28 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOp
 				RewriteTTL:   defaultDomainResolver.RewriteTTL,
 				ClientSubnet: defaultDomainResolver.ClientSubnet.Build(netip.Prefix{}),
 			},
-			NetworkStrategy:     (*C.NetworkStrategy)(routeOptions.DefaultNetworkStrategy),
-			NetworkType:         common.Map(routeOptions.DefaultNetworkType, option.InterfaceType.Build),
-			FallbackNetworkType: common.Map(routeOptions.DefaultFallbackNetworkType, option.InterfaceType.Build),
-			FallbackDelay:       time.Duration(routeOptions.DefaultFallbackDelay),
+			NetworkStrategy:     (*C.NetworkStrategy)(options.DefaultNetworkStrategy),
+			NetworkType:         common.Map(options.DefaultNetworkType, option.InterfaceType.Build),
+			FallbackNetworkType: common.Map(options.DefaultFallbackNetworkType, option.InterfaceType.Build),
+			FallbackDelay:       time.Duration(options.DefaultFallbackDelay),
 		},
 		pauseManager:      service.FromContext[pause.Manager](ctx),
 		platformInterface: service.FromContext[adapter.PlatformInterface](ctx),
 		endpoint:          service.FromContext[adapter.EndpointManager](ctx),
 		inbound:           service.FromContext[adapter.InboundManager](ctx),
 		outbound:          service.FromContext[adapter.OutboundManager](ctx),
+		needWIFIState:     hasRule(options.Rules, isWIFIRule) || hasDNSRule(dnsOptions.Rules, isWIFIDNSRule),
 	}
-	if routeOptions.DefaultNetworkStrategy != nil {
-		if routeOptions.DefaultInterface != "" {
+	if options.DefaultNetworkStrategy != nil {
+		if options.DefaultInterface != "" {
 			return nil, E.New("`default_network_strategy` is conflict with `default_interface`")
 		}
-		if !routeOptions.AutoDetectInterface {
+		if !options.AutoDetectInterface {
 			return nil, E.New("`auto_detect_interface` is required by `default_network_strategy`")
 		}
 	}
 	usePlatformDefaultInterfaceMonitor := nm.platformInterface != nil
-	enforceInterfaceMonitor := routeOptions.AutoDetectInterface
+	enforceInterfaceMonitor := options.AutoDetectInterface
 	if !usePlatformDefaultInterfaceMonitor {
 		networkMonitor, err := tun.NewNetworkUpdateMonitor(logger)
 		if !((err != nil && !enforceInterfaceMonitor) || errors.Is(err, os.ErrInvalid)) {
@@ -108,7 +114,7 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOp
 			nm.networkMonitor = networkMonitor
 			interfaceMonitor, err := tun.NewDefaultInterfaceMonitor(nm.networkMonitor, logger, tun.DefaultInterfaceMonitorOptions{
 				InterfaceFinder:       nm.interfaceFinder,
-				OverrideAndroidVPN:    routeOptions.OverrideAndroidVPN,
+				OverrideAndroidVPN:    options.OverrideAndroidVPN,
 				UnderNetworkExtension: nm.platformInterface != nil && nm.platformInterface.UnderNetworkExtension(),
 			})
 			if err != nil {
@@ -182,11 +188,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
@@ -218,6 +248,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
 }
 
@@ -375,20 +412,41 @@ 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) 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)
-			}
+func (r *NetworkManager) onWIFIStateChanged(state adapter.WIFIState) {
+	r.wifiStateMutex.Lock()
+	if state != r.wifiState {
+		r.wifiState = state
+		r.wifiStateMutex.Unlock()
+		if state.SSID != "" {
+			r.logger.Info("WIFI state changed: SSID=", state.SSID, ", BSSID=", state.BSSID)
+		} else {
+			r.logger.Info("WIFI disconnected")
 		}
+	} else {
+		r.wifiStateMutex.Unlock()
+	}
+}
+
+func (r *NetworkManager) UpdateWIFIState() {
+	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 - 8
route/router.go

@@ -56,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[adapter.PlatformInterface](ctx),
-		needWIFIState:     hasRule(options.Rules, isWIFIRule) || hasDNSRule(dnsOptions.Rules, isWIFIDNSRule),
 	}
 }
 
@@ -118,10 +117,8 @@ func (r *Router) Start(stage adapter.StartStage) error {
 			if metadata.ContainsProcessRule {
 				needFindProcess = true
 			}
-			if metadata.ContainsWIFIRule {
-				r.needWIFIState = true
-			}
 		}
+		r.network.Initialize(r.ruleSets)
 		if needFindProcess {
 			if r.platformInterface != nil && r.platformInterface.UsePlatformConnectionOwnerFinder() {
 				r.processSearcher = newPlatformSearcher(r.platformInterface)
@@ -194,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
 }