Compare commits

...

9 Commits

Author SHA1 Message Date
世界
7df171ff20 Bump version 2026-03-11 21:31:42 +08:00
世界
46eda3e96f cronet-go: Update chromium to 145.0.7632.159 2026-03-11 21:31:42 +08:00
世界
727a9d18d6 documentation: Update descriptions for neighbor rules 2026-03-11 21:31:42 +08:00
世界
20f60b8c7b Add macOS support for MAC and hostname rule items 2026-03-11 21:31:42 +08:00
世界
84b0ddff7f Add Android support for MAC and hostname rule items 2026-03-11 21:31:42 +08:00
世界
811ea13b73 Add MAC and hostname rule items 2026-03-11 21:31:41 +08:00
世界
bdb90f0a01 Bump version 2026-03-11 21:30:11 +08:00
世界
c9ab6458fa tun:Fix auto_redirect dropping SO_BINDTODEVICE traffic 2026-03-11 21:30:11 +08:00
世界
16a249f672 tailscale: Fix system interface rules 2026-03-11 21:30:03 +08:00
49 changed files with 2226 additions and 34 deletions

View File

@@ -1 +1 @@
2fef65f9dba90ddb89a87d00a6eb6165487c10c1
ea7cd33752aed62603775af3df946c1b83f4b0b3

View File

@@ -2,6 +2,7 @@ package adapter
import (
"context"
"net"
"net/netip"
"time"
@@ -82,6 +83,8 @@ type InboundContext struct {
SourceGeoIPCode string
GeoIPCode string
ProcessInfo *ConnectionOwner
SourceMACAddress net.HardwareAddr
SourceHostname string
QueryType uint16
FakeIP bool

23
adapter/neighbor.go Normal file
View File

@@ -0,0 +1,23 @@
package adapter
import (
"net"
"net/netip"
)
type NeighborEntry struct {
Address netip.Addr
MACAddress net.HardwareAddr
Hostname string
}
type NeighborResolver interface {
LookupMAC(address netip.Addr) (net.HardwareAddr, bool)
LookupHostname(address netip.Addr) (string, bool)
Start() error
Close() error
}
type NeighborUpdateListener interface {
UpdateNeighborTable(entries []NeighborEntry)
}

View File

@@ -36,6 +36,10 @@ type PlatformInterface interface {
UsePlatformNotification() bool
SendNotification(notification *Notification) error
UsePlatformNeighborResolver() bool
StartNeighborMonitor(listener NeighborUpdateListener) error
CloseNeighborMonitor(listener NeighborUpdateListener) error
}
type FindConnectionOwnerRequest struct {

View File

@@ -26,6 +26,8 @@ type Router interface {
RuleSet(tag string) (RuleSet, bool)
Rules() []Rule
NeedFindProcess() bool
NeedFindNeighbor() bool
NeighborResolver() NeighborResolver
AppendTracker(tracker ConnectionTracker)
ResetNetwork()
}

View File

@@ -2,6 +2,79 @@
icon: material/alert-decagram
---
#### 1.14.0-alpha.2
* Add OpenWrt and Alpine APK packages to release **1**
* Backport to macOS 10.13 High Sierra **2**
* OCM service: Add WebSocket support for Responses API **3**
* Fixes and improvements
**1**:
Alpine APK files use `linux` in the filename to distinguish from OpenWrt APKs which use the `openwrt` prefix:
- OpenWrt: `sing-box_{version}_openwrt_{architecture}.apk`
- Alpine: `sing-box_{version}_linux_{architecture}.apk`
**2**:
Legacy macOS binaries (with `-legacy-macos-10.13` suffix) now support
macOS 10.13 High Sierra, built using Go 1.25 with patches
from [SagerNet/go](https://github.com/SagerNet/go).
**3**:
See [OCM](/configuration/service/ocm).
#### 1.13.3-beta.1
* Add OpenWrt and Alpine APK packages to release **1**
* Backport to macOS 10.13 High Sierra **2**
* OCM service: Add WebSocket support for Responses API **3**
* Fixes and improvements
**1**:
Alpine APK files use `linux` in the filename to distinguish from OpenWrt APKs which use the `openwrt` prefix:
- OpenWrt: `sing-box_{version}_openwrt_{architecture}.apk`
- Alpine: `sing-box_{version}_linux_{architecture}.apk`
**2**:
Legacy macOS binaries (with `-legacy-macos-10.13` suffix) now support
macOS 10.13 High Sierra, built using Go 1.25 with patches
from [SagerNet/go](https://github.com/SagerNet/go).
**3**:
See [OCM](/configuration/service/ocm).
#### 1.14.0-alpha.1
* Add `source_mac_address` and `source_hostname` rule items **1**
* Add `include_mac_address` and `exclude_mac_address` TUN options **2**
* Update NaiveProxy to 145.0.7632.159 **3**
* Fixes and improvements
**1**:
New rule items for matching LAN devices by MAC address and hostname via neighbor resolution.
Supported on Linux, macOS, or in graphical clients on Android and macOS.
See [Route Rule](/configuration/route/rule/#source_mac_address), [DNS Rule](/configuration/dns/rule/#source_mac_address) and [Neighbor Resolution](/configuration/shared/neighbor/).
**2**:
Limit or exclude devices from TUN routing by MAC address.
Only supported on Linux with `auto_route` and `auto_redirect` enabled.
See [TUN](/configuration/inbound/tun/#include_mac_address).
**3**:
This is not an official update from NaiveProxy. Instead, it's a Chromium codebase update maintained by Project S.
#### 1.13.2
* Fixes and improvements

View File

@@ -2,6 +2,11 @@
icon: material/alert-decagram
---
!!! quote "Changes in sing-box 1.14.0"
:material-plus: [source_mac_address](#source_mac_address)
:material-plus: [source_hostname](#source_hostname)
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [interface_address](#interface_address)
@@ -149,6 +154,12 @@ icon: material/alert-decagram
"default_interface_address": [
"2000::/3"
],
"source_mac_address": [
"00:11:22:33:44:55"
],
"source_hostname": [
"my-device"
],
"wifi_ssid": [
"My WIFI"
],
@@ -408,6 +419,26 @@ Matches network interface (same values as `network_type`) address.
Match default interface address.
#### source_mac_address
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup.
Match source device MAC address.
#### source_hostname
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup.
Match source device hostname from DHCP leases.
#### wifi_ssid
!!! quote ""

View File

@@ -2,6 +2,11 @@
icon: material/alert-decagram
---
!!! quote "sing-box 1.14.0 中的更改"
:material-plus: [source_mac_address](#source_mac_address)
:material-plus: [source_hostname](#source_hostname)
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [interface_address](#interface_address)
@@ -149,6 +154,12 @@ icon: material/alert-decagram
"default_interface_address": [
"2000::/3"
],
"source_mac_address": [
"00:11:22:33:44:55"
],
"source_hostname": [
"my-device"
],
"wifi_ssid": [
"My WIFI"
],
@@ -407,6 +418,26 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`.
匹配默认接口地址。
#### source_mac_address
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux、macOS或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。
匹配源设备 MAC 地址。
#### source_hostname
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux、macOS或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。
匹配源设备从 DHCP 租约获取的主机名。
#### wifi_ssid
!!! quote ""

View File

@@ -2,6 +2,15 @@
icon: material/new-box
---
!!! quote "Changes in sing-box 1.14.0"
:material-plus: [include_mac_address](#include_mac_address)
:material-plus: [exclude_mac_address](#exclude_mac_address)
!!! quote "Changes in sing-box 1.13.3"
:material-alert: [strict_route](#strict_route)
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark)
@@ -125,6 +134,12 @@ icon: material/new-box
"exclude_package": [
"com.android.captiveportallogin"
],
"include_mac_address": [
"00:11:22:33:44:55"
],
"exclude_mac_address": [
"66:77:88:99:aa:bb"
],
"platform": {
"http_proxy": {
"enabled": false,
@@ -348,6 +363,9 @@ Enforce strict routing rules when `auto_route` is enabled:
* Let unsupported network unreachable
* For legacy reasons, when neither `strict_route` nor `auto_redirect` are enabled, all ICMP traffic will not go through TUN.
* When `auto_redirect` is enabled, `strict_route` also affects `SO_BINDTODEVICE` traffic:
* Enabled: `SO_BINDTODEVICE` traffic is redirected through sing-box.
* Disabled: `SO_BINDTODEVICE` traffic bypasses sing-box.
*In Windows*:
@@ -548,6 +566,30 @@ Limit android packages in route.
Exclude android packages in route.
#### include_mac_address
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux with `auto_route` and `auto_redirect` enabled.
Limit MAC addresses in route. Not limited by default.
Conflict with `exclude_mac_address`.
#### exclude_mac_address
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux with `auto_route` and `auto_redirect` enabled.
Exclude MAC addresses in route.
Conflict with `include_mac_address`.
#### platform
Platform-specific settings, provided by client applications.

View File

@@ -2,6 +2,15 @@
icon: material/new-box
---
!!! quote "sing-box 1.14.0 中的更改"
:material-plus: [include_mac_address](#include_mac_address)
:material-plus: [exclude_mac_address](#exclude_mac_address)
!!! quote "sing-box 1.13.3 中的更改"
:material-alert: [strict_route](#strict_route)
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark)
@@ -126,6 +135,12 @@ icon: material/new-box
"exclude_package": [
"com.android.captiveportallogin"
],
"include_mac_address": [
"00:11:22:33:44:55"
],
"exclude_mac_address": [
"66:77:88:99:aa:bb"
],
"platform": {
"http_proxy": {
"enabled": false,
@@ -347,6 +362,9 @@ tun 接口的 IPv6 前缀。
* 使不支持的网络不可达。
* 出于历史遗留原因,当未启用 `strict_route``auto_redirect` 时,所有 ICMP 流量将不会通过 TUN。
* 当启用 `auto_redirect` 时,`strict_route` 也影响 `SO_BINDTODEVICE` 流量:
* 启用:`SO_BINDTODEVICE` 流量被重定向通过 sing-box。
* 禁用:`SO_BINDTODEVICE` 流量绕过 sing-box。
*在 Windows 中*
@@ -536,6 +554,30 @@ TCP/IP 栈。
排除路由的 Android 应用包名。
#### include_mac_address
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux且需要 `auto_route``auto_redirect` 已启用。
限制被路由的 MAC 地址。默认不限制。
`exclude_mac_address` 冲突。
#### exclude_mac_address
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux且需要 `auto_route``auto_redirect` 已启用。
排除路由的 MAC 地址。
`include_mac_address` 冲突。
#### platform
平台特定的设置,由客户端应用提供。

View File

@@ -4,6 +4,11 @@ icon: material/alert-decagram
# Route
!!! quote "Changes in sing-box 1.14.0"
:material-plus: [find_neighbor](#find_neighbor)
:material-plus: [dhcp_lease_files](#dhcp_lease_files)
!!! quote "Changes in sing-box 1.12.0"
:material-plus: [default_domain_resolver](#default_domain_resolver)
@@ -35,6 +40,9 @@ icon: material/alert-decagram
"override_android_vpn": false,
"default_interface": "",
"default_mark": 0,
"find_process": false,
"find_neighbor": false,
"dhcp_lease_files": [],
"default_domain_resolver": "", // or {}
"default_network_strategy": "",
"default_network_type": [],
@@ -107,6 +115,38 @@ Set routing mark by default.
Takes no effect if `outbound.routing_mark` is set.
#### find_process
!!! quote ""
Only supported on Linux, Windows, and macOS.
Enable process search for logging when no `process_name`, `process_path`, `package_name`, `user` or `user_id` rules exist.
#### find_neighbor
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux and macOS.
Enable neighbor resolution for logging when no `source_mac_address` or `source_hostname` rules exist.
See [Neighbor Resolution](/configuration/shared/neighbor/) for setup.
#### dhcp_lease_files
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux and macOS.
Custom DHCP lease file paths for hostname and MAC address resolution.
Automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea) if empty.
#### default_domain_resolver
!!! question "Since sing-box 1.12.0"

View File

@@ -4,6 +4,11 @@ icon: material/alert-decagram
# 路由
!!! quote "sing-box 1.14.0 中的更改"
:material-plus: [find_neighbor](#find_neighbor)
:material-plus: [dhcp_lease_files](#dhcp_lease_files)
!!! quote "sing-box 1.12.0 中的更改"
:material-plus: [default_domain_resolver](#default_domain_resolver)
@@ -37,6 +42,9 @@ icon: material/alert-decagram
"override_android_vpn": false,
"default_interface": "",
"default_mark": 0,
"find_process": false,
"find_neighbor": false,
"dhcp_lease_files": [],
"default_network_strategy": "",
"default_fallback_delay": ""
}
@@ -106,6 +114,38 @@ icon: material/alert-decagram
如果设置了 `outbound.routing_mark` 设置,则不生效。
#### find_process
!!! quote ""
仅支持 Linux、Windows 和 macOS。
在没有 `process_name``process_path``package_name``user``user_id` 规则时启用进程搜索以输出日志。
#### find_neighbor
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux 和 macOS。
在没有 `source_mac_address``source_hostname` 规则时启用邻居解析以输出日志。
参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。
#### dhcp_lease_files
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux 和 macOS。
用于主机名和 MAC 地址解析的自定义 DHCP 租约文件路径。
为空时自动从常见 DHCP 服务器dnsmasq、odhcpd、ISC dhcpd、Kea检测。
#### default_domain_resolver
!!! question "自 sing-box 1.12.0 起"

View File

@@ -2,6 +2,11 @@
icon: material/new-box
---
!!! quote "Changes in sing-box 1.14.0"
:material-plus: [source_mac_address](#source_mac_address)
:material-plus: [source_hostname](#source_hostname)
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [interface_address](#interface_address)
@@ -159,6 +164,12 @@ icon: material/new-box
"tailscale",
"wireguard"
],
"source_mac_address": [
"00:11:22:33:44:55"
],
"source_hostname": [
"my-device"
],
"rule_set": [
"geoip-cn",
"geosite-cn"
@@ -449,6 +460,26 @@ Match specified outbounds' preferred routes.
| `tailscale` | Match MagicDNS domains and peers' allowed IPs |
| `wireguard` | Match peers's allowed IPs |
#### source_mac_address
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup.
Match source device MAC address.
#### source_hostname
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup.
Match source device hostname from DHCP leases.
#### rule_set
!!! question "Since sing-box 1.8.0"

View File

@@ -2,6 +2,11 @@
icon: material/new-box
---
!!! quote "sing-box 1.14.0 中的更改"
:material-plus: [source_mac_address](#source_mac_address)
:material-plus: [source_hostname](#source_hostname)
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [interface_address](#interface_address)
@@ -156,6 +161,12 @@ icon: material/new-box
"tailscale",
"wireguard"
],
"source_mac_address": [
"00:11:22:33:44:55"
],
"source_hostname": [
"my-device"
],
"rule_set": [
"geoip-cn",
"geosite-cn"
@@ -446,6 +457,26 @@ icon: material/new-box
| `tailscale` | 匹配 MagicDNS 域名和对端的 allowed IPs |
| `wireguard` | 匹配对端的 allowed IPs |
#### source_mac_address
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux、macOS或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。
匹配源设备 MAC 地址。
#### source_hostname
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux、macOS或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。
匹配源设备从 DHCP 租约获取的主机名。
#### rule_set
!!! question "自 sing-box 1.8.0 起"

View File

@@ -0,0 +1,49 @@
---
icon: material/lan
---
# Neighbor Resolution
Match LAN devices by MAC address and hostname using
[`source_mac_address`](/configuration/route/rule/#source_mac_address) and
[`source_hostname`](/configuration/route/rule/#source_hostname) rule items.
Neighbor resolution is automatically enabled when these rule items exist.
Use [`route.find_neighbor`](/configuration/route/#find_neighbor) to force enable it for logging without rules.
## Linux
Works natively. No special setup required.
Hostname resolution requires DHCP lease files,
automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea).
Custom paths can be set via [`route.dhcp_lease_files`](/configuration/route/#dhcp_lease_files).
## Android
!!! quote ""
Only supported in graphical clients.
Requires Android 11 or above and ROOT.
Must use [VPNHotspot](https://github.com/Mygod/VPNHotspot) to share the VPN connection.
ROM built-in features like "Use VPN for connected devices" can share VPN
but cannot provide MAC address or hostname information.
Set **IP Masquerade Mode** to **None** in VPNHotspot settings.
Only route/DNS rules are supported. TUN include/exclude routes are not supported.
### Hostname Visibility
Hostname is only visible in sing-box if it is visible in VPNHotspot.
For Apple devices, change **Private Wi-Fi Address** from **Rotating** to **Fixed** in the Wi-Fi settings
of the connected network. Non-Apple devices are always visible.
## macOS
Requires the standalone version (macOS system extension).
The App Store version can share the VPN as a hotspot but does not support MAC address or hostname reading.
See [VPN Hotspot](/manual/misc/vpn-hotspot/#macos) for Internet Sharing setup.

View File

@@ -0,0 +1,49 @@
---
icon: material/lan
---
# 邻居解析
通过
[`source_mac_address`](/configuration/route/rule/#source_mac_address) 和
[`source_hostname`](/configuration/route/rule/#source_hostname) 规则项匹配局域网设备的 MAC 地址和主机名。
当这些规则项存在时,邻居解析自动启用。
使用 [`route.find_neighbor`](/configuration/route/#find_neighbor) 可在没有规则时强制启用以输出日志。
## Linux
原生支持,无需特殊设置。
主机名解析需要 DHCP 租约文件,
自动从常见 DHCP 服务器dnsmasq、odhcpd、ISC dhcpd、Kea检测。
可通过 [`route.dhcp_lease_files`](/configuration/route/#dhcp_lease_files) 设置自定义路径。
## Android
!!! quote ""
仅在图形客户端中支持。
需要 Android 11 或以上版本和 ROOT。
必须使用 [VPNHotspot](https://github.com/Mygod/VPNHotspot) 共享 VPN 连接。
ROM 自带的「通过 VPN 共享连接」等功能可以共享 VPN
但无法提供 MAC 地址或主机名信息。
在 VPNHotspot 设置中将 **IP 遮掩模式** 设为 **无**
仅支持路由/DNS 规则。不支持 TUN 的 include/exclude 路由。
### 设备可见性
MAC 地址和主机名仅在 VPNHotspot 中可见时 sing-box 才能读取。
对于 Apple 设备,需要在所连接网络的 Wi-Fi 设置中将**私有无线局域网地址**从**轮替**改为**固定**。
非 Apple 设备始终可见。
## macOS
需要独立版本macOS 系统扩展)。
App Store 版本可以共享 VPN 热点但不支持 MAC 地址或主机名读取。
参阅 [VPN 热点](/manual/misc/vpn-hotspot/#macos) 了解互联网共享设置。

View File

@@ -144,6 +144,18 @@ func (s *platformInterfaceStub) SendNotification(notification *adapter.Notificat
return nil
}
func (s *platformInterfaceStub) UsePlatformNeighborResolver() bool {
return false
}
func (s *platformInterfaceStub) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error {
return os.ErrInvalid
}
func (s *platformInterfaceStub) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error {
return nil
}
func (s *platformInterfaceStub) UsePlatformLocalDNSTransport() bool {
return false
}

View File

@@ -0,0 +1,53 @@
package libbox
import (
"net"
"net/netip"
)
type NeighborEntry struct {
Address string
MacAddress string
Hostname string
}
type NeighborEntryIterator interface {
Next() *NeighborEntry
HasNext() bool
}
type NeighborSubscription struct {
done chan struct{}
}
func (s *NeighborSubscription) Close() {
close(s.done)
}
func tableToIterator(table map[netip.Addr]net.HardwareAddr) NeighborEntryIterator {
entries := make([]*NeighborEntry, 0, len(table))
for address, mac := range table {
entries = append(entries, &NeighborEntry{
Address: address.String(),
MacAddress: mac.String(),
})
}
return &neighborEntryIterator{entries}
}
type neighborEntryIterator struct {
entries []*NeighborEntry
}
func (i *neighborEntryIterator) HasNext() bool {
return len(i.entries) > 0
}
func (i *neighborEntryIterator) Next() *NeighborEntry {
if len(i.entries) == 0 {
return nil
}
entry := i.entries[0]
i.entries = i.entries[1:]
return entry
}

View File

@@ -0,0 +1,123 @@
//go:build darwin
package libbox
import (
"net"
"net/netip"
"os"
"slices"
"time"
"github.com/sagernet/sing-box/route"
"github.com/sagernet/sing/common/buf"
E "github.com/sagernet/sing/common/exceptions"
xroute "golang.org/x/net/route"
"golang.org/x/sys/unix"
)
func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) {
entries, err := route.ReadNeighborEntries()
if err != nil {
return nil, E.Cause(err, "initial neighbor dump")
}
table := make(map[netip.Addr]net.HardwareAddr)
for _, entry := range entries {
table[entry.Address] = entry.MACAddress
}
listener.UpdateNeighborTable(tableToIterator(table))
routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0)
if err != nil {
return nil, E.Cause(err, "open route socket")
}
err = unix.SetNonblock(routeSocket, true)
if err != nil {
unix.Close(routeSocket)
return nil, E.Cause(err, "set route socket nonblock")
}
subscription := &NeighborSubscription{
done: make(chan struct{}),
}
go subscription.loop(listener, routeSocket, table)
return subscription, nil
}
func (s *NeighborSubscription) loop(listener NeighborUpdateListener, routeSocket int, table map[netip.Addr]net.HardwareAddr) {
routeSocketFile := os.NewFile(uintptr(routeSocket), "route")
defer routeSocketFile.Close()
buffer := buf.NewPacket()
defer buffer.Release()
for {
select {
case <-s.done:
return
default:
}
tv := unix.NsecToTimeval(int64(3 * time.Second))
_ = unix.SetsockoptTimeval(routeSocket, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv)
n, err := routeSocketFile.Read(buffer.FreeBytes())
if err != nil {
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
continue
}
select {
case <-s.done:
return
default:
}
continue
}
messages, err := xroute.ParseRIB(xroute.RIBTypeRoute, buffer.FreeBytes()[:n])
if err != nil {
continue
}
changed := false
for _, message := range messages {
routeMessage, isRouteMessage := message.(*xroute.RouteMessage)
if !isRouteMessage {
continue
}
if routeMessage.Flags&unix.RTF_LLINFO == 0 {
continue
}
address, mac, isDelete, ok := route.ParseRouteNeighborMessage(routeMessage)
if !ok {
continue
}
if isDelete {
if _, exists := table[address]; exists {
delete(table, address)
changed = true
}
} else {
existing, exists := table[address]
if !exists || !slices.Equal(existing, mac) {
table[address] = mac
changed = true
}
}
}
if changed {
listener.UpdateNeighborTable(tableToIterator(table))
}
}
}
func ReadBootpdLeases() NeighborEntryIterator {
leaseIPToMAC, ipToHostname, macToHostname := route.ReloadLeaseFiles([]string{"/var/db/dhcpd_leases"})
entries := make([]*NeighborEntry, 0, len(leaseIPToMAC))
for address, mac := range leaseIPToMAC {
entry := &NeighborEntry{
Address: address.String(),
MacAddress: mac.String(),
}
hostname, found := ipToHostname[address]
if !found {
hostname = macToHostname[mac.String()]
}
entry.Hostname = hostname
entries = append(entries, entry)
}
return &neighborEntryIterator{entries}
}

View File

@@ -0,0 +1,88 @@
//go:build linux
package libbox
import (
"net"
"net/netip"
"slices"
"time"
"github.com/sagernet/sing-box/route"
E "github.com/sagernet/sing/common/exceptions"
"github.com/mdlayher/netlink"
"golang.org/x/sys/unix"
)
func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) {
entries, err := route.ReadNeighborEntries()
if err != nil {
return nil, E.Cause(err, "initial neighbor dump")
}
table := make(map[netip.Addr]net.HardwareAddr)
for _, entry := range entries {
table[entry.Address] = entry.MACAddress
}
listener.UpdateNeighborTable(tableToIterator(table))
connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{
Groups: 1 << (unix.RTNLGRP_NEIGH - 1),
})
if err != nil {
return nil, E.Cause(err, "subscribe neighbor updates")
}
subscription := &NeighborSubscription{
done: make(chan struct{}),
}
go subscription.loop(listener, connection, table)
return subscription, nil
}
func (s *NeighborSubscription) loop(listener NeighborUpdateListener, connection *netlink.Conn, table map[netip.Addr]net.HardwareAddr) {
defer connection.Close()
for {
select {
case <-s.done:
return
default:
}
err := connection.SetReadDeadline(time.Now().Add(3 * time.Second))
if err != nil {
return
}
messages, err := connection.Receive()
if err != nil {
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
continue
}
select {
case <-s.done:
return
default:
}
continue
}
changed := false
for _, message := range messages {
address, mac, isDelete, ok := route.ParseNeighborMessage(message)
if !ok {
continue
}
if isDelete {
if _, exists := table[address]; exists {
delete(table, address)
changed = true
}
} else {
existing, exists := table[address]
if !exists || !slices.Equal(existing, mac) {
table[address] = mac
changed = true
}
}
}
if changed {
listener.UpdateNeighborTable(tableToIterator(table))
}
}
}

View File

@@ -0,0 +1,9 @@
//go:build !linux && !darwin
package libbox
import "os"
func SubscribeNeighborTable(_ NeighborUpdateListener) (*NeighborSubscription, error) {
return nil, os.ErrInvalid
}

View File

@@ -21,6 +21,13 @@ type PlatformInterface interface {
SystemCertificates() StringIterator
ClearDNSCache()
SendNotification(notification *Notification) error
StartNeighborMonitor(listener NeighborUpdateListener) error
CloseNeighborMonitor(listener NeighborUpdateListener) error
RegisterMyInterface(name string)
}
type NeighborUpdateListener interface {
UpdateNeighborTable(entries NeighborEntryIterator)
}
type ConnectionOwner struct {

View File

@@ -78,6 +78,7 @@ func (w *platformInterfaceWrapper) OpenInterface(options *tun.Options, platformO
}
options.FileDescriptor = dupFd
w.myTunName = options.Name
w.iif.RegisterMyInterface(options.Name)
return tun.New(*options)
}
@@ -220,6 +221,46 @@ func (w *platformInterfaceWrapper) SendNotification(notification *adapter.Notifi
return w.iif.SendNotification((*Notification)(notification))
}
func (w *platformInterfaceWrapper) UsePlatformNeighborResolver() bool {
return true
}
func (w *platformInterfaceWrapper) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error {
return w.iif.StartNeighborMonitor(&neighborUpdateListenerWrapper{listener: listener})
}
func (w *platformInterfaceWrapper) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error {
return w.iif.CloseNeighborMonitor(nil)
}
type neighborUpdateListenerWrapper struct {
listener adapter.NeighborUpdateListener
}
func (w *neighborUpdateListenerWrapper) UpdateNeighborTable(entries NeighborEntryIterator) {
var result []adapter.NeighborEntry
for entries.HasNext() {
entry := entries.Next()
if entry == nil {
continue
}
address, err := netip.ParseAddr(entry.Address)
if err != nil {
continue
}
macAddress, err := net.ParseMAC(entry.MacAddress)
if err != nil {
continue
}
result = append(result, adapter.NeighborEntry{
Address: address,
MACAddress: macAddress,
Hostname: entry.Hostname,
})
}
w.listener.UpdateNeighborTable(result)
}
func AvailablePort(startPort int32) (int32, error) {
for port := int(startPort); ; port++ {
if port > 65535 {

12
go.mod
View File

@@ -14,11 +14,13 @@ require (
github.com/godbus/dbus/v5 v5.2.2
github.com/gofrs/uuid/v5 v5.4.0
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91
github.com/jsimonetti/rtnetlink v1.4.0
github.com/keybase/go-keychain v0.0.1
github.com/libdns/acmedns v0.5.0
github.com/libdns/alidns v1.0.6
github.com/libdns/cloudflare v0.2.2
github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/mdlayher/netlink v1.9.0
github.com/metacubex/utls v1.8.4
github.com/mholt/acmez/v3 v3.1.6
github.com/miekg/dns v1.1.72
@@ -27,8 +29,8 @@ require (
github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a
github.com/sagernet/cors v1.2.1
github.com/sagernet/cronet-go v0.0.0-20260309102448-2fef65f9dba9
github.com/sagernet/cronet-go/all v0.0.0-20260309102448-2fef65f9dba9
github.com/sagernet/cronet-go v0.0.0-20260309100020-c128886ff3fc
github.com/sagernet/cronet-go/all v0.0.0-20260309100020-c128886ff3fc
github.com/sagernet/fswatch v0.1.1
github.com/sagernet/gomobile v0.1.12
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1
@@ -39,10 +41,10 @@ require (
github.com/sagernet/sing-shadowsocks v0.2.8
github.com/sagernet/sing-shadowsocks2 v0.2.1
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11
github.com/sagernet/sing-tun v0.8.2
github.com/sagernet/sing-tun v0.8.3-0.20260311132553-5485872f601f
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1
github.com/sagernet/smux v1.5.50-sing-box-mod.1
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260310162543-0c2de366d4de
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260311131347-f88b27eeb76e
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854
github.com/spf13/cobra v1.10.2
@@ -92,11 +94,9 @@ require (
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
github.com/mdlayher/netlink v1.9.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect

16
go.sum
View File

@@ -162,10 +162,10 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM=
github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=
github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI=
github.com/sagernet/cronet-go v0.0.0-20260309102448-2fef65f9dba9 h1:xq5Yr10jXEppD3cnGjE3WENaB6D0YsZu6KptZ8d3054=
github.com/sagernet/cronet-go v0.0.0-20260309102448-2fef65f9dba9/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw=
github.com/sagernet/cronet-go/all v0.0.0-20260309102448-2fef65f9dba9 h1:uxQyy6Y/boOuecVA66tf79JgtoRGfeDJcfYZZLKVA5E=
github.com/sagernet/cronet-go/all v0.0.0-20260309102448-2fef65f9dba9/go.mod h1:Xm6cCvs0/twozC1JYNq0sVlOVmcSGzV7YON1XGcD97w=
github.com/sagernet/cronet-go v0.0.0-20260309100020-c128886ff3fc h1:YK7PwJT0irRAEui9ASdXSxcE2BOVQipWMF/A1Ogt+7c=
github.com/sagernet/cronet-go v0.0.0-20260309100020-c128886ff3fc/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw=
github.com/sagernet/cronet-go/all v0.0.0-20260309100020-c128886ff3fc h1:EJPHOqk23IuBsTjXK9OXqkNxPbKOBWKRmviQoCcriAs=
github.com/sagernet/cronet-go/all v0.0.0-20260309100020-c128886ff3fc/go.mod h1:8aty0RW96DrJSMWXO6bRPMBJEjuqq5JWiOIi4bCRzFA=
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260309101654-0cbdcfddded9 h1:Qi0IKBpoPP3qZqIXuOKMsT2dv+l/MLWMyBHDMLRw2EA=
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260309101654-0cbdcfddded9/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260309101654-0cbdcfddded9 h1:p+wCMjOhj46SpSD/AJeTGgkCcbyA76FyH631XZatyU8=
@@ -248,14 +248,14 @@ github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnq
github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w=
github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
github.com/sagernet/sing-tun v0.8.2 h1:rQr/x3eQCHh3oleIaoJdPdJwqzZp4+QWcJLT0Wz2xKY=
github.com/sagernet/sing-tun v0.8.2/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs=
github.com/sagernet/sing-tun v0.8.3-0.20260311132553-5485872f601f h1:uj3rzedphq1AiL0PpuVoob5RtKsPBcMRd8aqo+q0rqA=
github.com/sagernet/sing-tun v0.8.3-0.20260311132553-5485872f601f/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs=
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o=
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=
github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260310162543-0c2de366d4de h1:wsJ0COxUOIvBE+hUho0C/DbMeUe9jtwfh6dECAiTk94=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260310162543-0c2de366d4de/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260311131347-f88b27eeb76e h1:Sv1qUhJIidjSTc24XEknovDZnbmVSlAXj8wNVgIfgGo=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260311131347-f88b27eeb76e/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c h1:f9cXNB+IOOPnR8DOLMTpr42jf7naxh5Un5Y09BBf5Cg=
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0=
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=

View File

@@ -129,6 +129,7 @@ nav:
- UDP over TCP: configuration/shared/udp-over-tcp.md
- TCP Brutal: configuration/shared/tcp-brutal.md
- Wi-Fi State: configuration/shared/wifi-state.md
- Neighbor Resolution: configuration/shared/neighbor.md
- Endpoint:
- configuration/endpoint/index.md
- WireGuard: configuration/endpoint/wireguard.md

View File

@@ -9,6 +9,8 @@ type RouteOptions struct {
RuleSet []RuleSet `json:"rule_set,omitempty"`
Final string `json:"final,omitempty"`
FindProcess bool `json:"find_process,omitempty"`
FindNeighbor bool `json:"find_neighbor,omitempty"`
DHCPLeaseFiles badoption.Listable[string] `json:"dhcp_lease_files,omitempty"`
AutoDetectInterface bool `json:"auto_detect_interface,omitempty"`
OverrideAndroidVPN bool `json:"override_android_vpn,omitempty"`
DefaultInterface string `json:"default_interface,omitempty"`

View File

@@ -103,6 +103,8 @@ type RawDefaultRule struct {
InterfaceAddress *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]] `json:"interface_address,omitempty"`
NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"`
DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"`
SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"`
SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"`
PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"`
RuleSet badoption.Listable[string] `json:"rule_set,omitempty"`
RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"`

View File

@@ -106,6 +106,8 @@ type RawDefaultDNSRule struct {
InterfaceAddress *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]] `json:"interface_address,omitempty"`
NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"`
DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"`
SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"`
SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"`
RuleSet badoption.Listable[string] `json:"rule_set,omitempty"`
RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"`
RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"`

View File

@@ -39,6 +39,8 @@ type TunInboundOptions struct {
IncludeAndroidUser badoption.Listable[int] `json:"include_android_user,omitempty"`
IncludePackage badoption.Listable[string] `json:"include_package,omitempty"`
ExcludePackage badoption.Listable[string] `json:"exclude_package,omitempty"`
IncludeMACAddress badoption.Listable[string] `json:"include_mac_address,omitempty"`
ExcludeMACAddress badoption.Listable[string] `json:"exclude_mac_address,omitempty"`
UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"`
Stack string `json:"stack,omitempty"`
Platform *TunPlatformOptions `json:"platform,omitempty"`

View File

@@ -333,9 +333,6 @@ func (t *Endpoint) Start(stage adapter.StartStage) error {
t.systemTun = systemTun
t.systemDialer = systemDialer
t.server.TunDevice = wgTunDevice
t.server.RouterWrapper = func(inner router.Router) router.Router {
return &addressOnlyRouter{Router: inner}
}
}
if mark := t.network.AutoRedirectOutputMark(); mark > 0 {
controlFunc := t.network.AutoRedirectOutputMarkFunc()
@@ -480,11 +477,12 @@ func (t *Endpoint) Close() error {
t.fallbackTCPCloser()
t.fallbackTCPCloser = nil
}
err := common.Close(common.PtrOrNil(t.server))
if t.systemTun != nil {
_ = t.systemTun.Close()
t.systemTun.Close()
t.systemTun = nil
}
return common.Close(common.PtrOrNil(t.server))
return err
}
func (t *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
@@ -850,15 +848,3 @@ func (c *dnsConfigurtor) Close() error {
return nil
}
type addressOnlyRouter struct {
router.Router
}
func (r *addressOnlyRouter) Set(config *router.Config) error {
if config != nil {
config = &router.Config{
LocalAddrs: config.LocalAddrs,
}
}
return r.Router.Set(config)
}

View File

@@ -156,6 +156,22 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
if nfQueue == 0 {
nfQueue = tun.DefaultAutoRedirectNFQueue
}
var includeMACAddress []net.HardwareAddr
for i, macString := range options.IncludeMACAddress {
mac, macErr := net.ParseMAC(macString)
if macErr != nil {
return nil, E.Cause(macErr, "parse include_mac_address[", i, "]")
}
includeMACAddress = append(includeMACAddress, mac)
}
var excludeMACAddress []net.HardwareAddr
for i, macString := range options.ExcludeMACAddress {
mac, macErr := net.ParseMAC(macString)
if macErr != nil {
return nil, E.Cause(macErr, "parse exclude_mac_address[", i, "]")
}
excludeMACAddress = append(excludeMACAddress, mac)
}
networkManager := service.FromContext[adapter.NetworkManager](ctx)
multiPendingPackets := C.IsDarwin && ((options.Stack == "gvisor" && tunMTU < 32768) || (options.Stack != "gvisor" && options.MTU <= 9000))
inbound := &Inbound{
@@ -193,6 +209,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
IncludeAndroidUser: options.IncludeAndroidUser,
IncludePackage: options.IncludePackage,
ExcludePackage: options.ExcludePackage,
IncludeMACAddress: includeMACAddress,
ExcludeMACAddress: excludeMACAddress,
InterfaceMonitor: networkManager.InterfaceMonitor(),
EXP_MultiPendingPackets: multiPendingPackets,
},

View File

@@ -0,0 +1,239 @@
//go:build darwin
package route
import (
"net"
"net/netip"
"os"
"sync"
"time"
"github.com/sagernet/fswatch"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/buf"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
"golang.org/x/net/route"
"golang.org/x/sys/unix"
)
var defaultLeaseFiles = []string{
"/var/db/dhcpd_leases",
"/tmp/dhcp.leases",
}
type neighborResolver struct {
logger logger.ContextLogger
leaseFiles []string
access sync.RWMutex
neighborIPToMAC map[netip.Addr]net.HardwareAddr
leaseIPToMAC map[netip.Addr]net.HardwareAddr
ipToHostname map[netip.Addr]string
macToHostname map[string]string
watcher *fswatch.Watcher
done chan struct{}
}
func newNeighborResolver(resolverLogger logger.ContextLogger, leaseFiles []string) (adapter.NeighborResolver, error) {
if len(leaseFiles) == 0 {
for _, path := range defaultLeaseFiles {
info, err := os.Stat(path)
if err == nil && info.Size() > 0 {
leaseFiles = append(leaseFiles, path)
}
}
}
return &neighborResolver{
logger: resolverLogger,
leaseFiles: leaseFiles,
neighborIPToMAC: make(map[netip.Addr]net.HardwareAddr),
leaseIPToMAC: make(map[netip.Addr]net.HardwareAddr),
ipToHostname: make(map[netip.Addr]string),
macToHostname: make(map[string]string),
done: make(chan struct{}),
}, nil
}
func (r *neighborResolver) Start() error {
err := r.loadNeighborTable()
if err != nil {
r.logger.Warn(E.Cause(err, "load neighbor table"))
}
r.doReloadLeaseFiles()
go r.subscribeNeighborUpdates()
if len(r.leaseFiles) > 0 {
watcher, err := fswatch.NewWatcher(fswatch.Options{
Path: r.leaseFiles,
Logger: r.logger,
Callback: func(_ string) {
r.doReloadLeaseFiles()
},
})
if err != nil {
r.logger.Warn(E.Cause(err, "create lease file watcher"))
} else {
r.watcher = watcher
err = watcher.Start()
if err != nil {
r.logger.Warn(E.Cause(err, "start lease file watcher"))
}
}
}
return nil
}
func (r *neighborResolver) Close() error {
close(r.done)
if r.watcher != nil {
return r.watcher.Close()
}
return nil
}
func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) {
r.access.RLock()
defer r.access.RUnlock()
mac, found := r.neighborIPToMAC[address]
if found {
return mac, true
}
mac, found = r.leaseIPToMAC[address]
if found {
return mac, true
}
mac, found = extractMACFromEUI64(address)
if found {
return mac, true
}
return nil, false
}
func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) {
r.access.RLock()
defer r.access.RUnlock()
hostname, found := r.ipToHostname[address]
if found {
return hostname, true
}
mac, macFound := r.neighborIPToMAC[address]
if !macFound {
mac, macFound = r.leaseIPToMAC[address]
}
if !macFound {
mac, macFound = extractMACFromEUI64(address)
}
if macFound {
hostname, found = r.macToHostname[mac.String()]
if found {
return hostname, true
}
}
return "", false
}
func (r *neighborResolver) loadNeighborTable() error {
entries, err := ReadNeighborEntries()
if err != nil {
return err
}
r.access.Lock()
defer r.access.Unlock()
for _, entry := range entries {
r.neighborIPToMAC[entry.Address] = entry.MACAddress
}
return nil
}
func (r *neighborResolver) subscribeNeighborUpdates() {
routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0)
if err != nil {
r.logger.Warn(E.Cause(err, "subscribe neighbor updates"))
return
}
err = unix.SetNonblock(routeSocket, true)
if err != nil {
unix.Close(routeSocket)
r.logger.Warn(E.Cause(err, "set route socket nonblock"))
return
}
routeSocketFile := os.NewFile(uintptr(routeSocket), "route")
defer routeSocketFile.Close()
buffer := buf.NewPacket()
defer buffer.Release()
for {
select {
case <-r.done:
return
default:
}
err = setReadDeadline(routeSocketFile, 3*time.Second)
if err != nil {
r.logger.Warn(E.Cause(err, "set route socket read deadline"))
return
}
n, err := routeSocketFile.Read(buffer.FreeBytes())
if err != nil {
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
continue
}
select {
case <-r.done:
return
default:
}
r.logger.Warn(E.Cause(err, "receive neighbor update"))
continue
}
messages, err := route.ParseRIB(route.RIBTypeRoute, buffer.FreeBytes()[:n])
if err != nil {
continue
}
for _, message := range messages {
routeMessage, isRouteMessage := message.(*route.RouteMessage)
if !isRouteMessage {
continue
}
if routeMessage.Flags&unix.RTF_LLINFO == 0 {
continue
}
address, mac, isDelete, ok := ParseRouteNeighborMessage(routeMessage)
if !ok {
continue
}
r.access.Lock()
if isDelete {
delete(r.neighborIPToMAC, address)
} else {
r.neighborIPToMAC[address] = mac
}
r.access.Unlock()
}
}
}
func (r *neighborResolver) doReloadLeaseFiles() {
leaseIPToMAC, ipToHostname, macToHostname := ReloadLeaseFiles(r.leaseFiles)
r.access.Lock()
r.leaseIPToMAC = leaseIPToMAC
r.ipToHostname = ipToHostname
r.macToHostname = macToHostname
r.access.Unlock()
}
func setReadDeadline(file *os.File, timeout time.Duration) error {
rawConn, err := file.SyscallConn()
if err != nil {
return err
}
var controlErr error
err = rawConn.Control(func(fd uintptr) {
tv := unix.NsecToTimeval(int64(timeout))
controlErr = unix.SetsockoptTimeval(int(fd), unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv)
})
if err != nil {
return err
}
return controlErr
}

View File

@@ -0,0 +1,386 @@
package route
import (
"bufio"
"encoding/hex"
"net"
"net/netip"
"os"
"strconv"
"strings"
"time"
)
func parseLeaseFile(path string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) {
file, err := os.Open(path)
if err != nil {
return
}
defer file.Close()
if strings.HasSuffix(path, "dhcpd_leases") {
parseBootpdLeases(file, ipToMAC, ipToHostname, macToHostname)
return
}
if strings.HasSuffix(path, "kea-leases4.csv") {
parseKeaCSV4(file, ipToMAC, ipToHostname, macToHostname)
return
}
if strings.HasSuffix(path, "kea-leases6.csv") {
parseKeaCSV6(file, ipToMAC, ipToHostname, macToHostname)
return
}
if strings.HasSuffix(path, "dhcpd.leases") {
parseISCDhcpd(file, ipToMAC, ipToHostname, macToHostname)
return
}
parseDnsmasqOdhcpd(file, ipToMAC, ipToHostname, macToHostname)
}
func ReloadLeaseFiles(leaseFiles []string) (leaseIPToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) {
leaseIPToMAC = make(map[netip.Addr]net.HardwareAddr)
ipToHostname = make(map[netip.Addr]string)
macToHostname = make(map[string]string)
for _, path := range leaseFiles {
parseLeaseFile(path, leaseIPToMAC, ipToHostname, macToHostname)
}
return
}
func parseDnsmasqOdhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) {
now := time.Now().Unix()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "duid ") {
continue
}
if strings.HasPrefix(line, "# ") {
parseOdhcpdLine(line[2:], ipToMAC, ipToHostname, macToHostname)
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
expiry, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
continue
}
if expiry != 0 && expiry < now {
continue
}
if strings.Contains(fields[1], ":") {
mac, macErr := net.ParseMAC(fields[1])
if macErr != nil {
continue
}
address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2]))
if !addrOK {
continue
}
address = address.Unmap()
ipToMAC[address] = mac
hostname := fields[3]
if hostname != "*" {
ipToHostname[address] = hostname
macToHostname[mac.String()] = hostname
}
} else {
var mac net.HardwareAddr
if len(fields) >= 5 {
duid, duidErr := parseDUID(fields[4])
if duidErr == nil {
mac, _ = extractMACFromDUID(duid)
}
}
address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2]))
if !addrOK {
continue
}
address = address.Unmap()
if mac != nil {
ipToMAC[address] = mac
}
hostname := fields[3]
if hostname != "*" {
ipToHostname[address] = hostname
if mac != nil {
macToHostname[mac.String()] = hostname
}
}
}
}
}
func parseOdhcpdLine(line string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) {
fields := strings.Fields(line)
if len(fields) < 5 {
return
}
validTime, err := strconv.ParseInt(fields[4], 10, 64)
if err != nil {
return
}
if validTime == 0 {
return
}
if validTime > 0 && validTime < time.Now().Unix() {
return
}
hostname := fields[3]
if hostname == "-" || strings.HasPrefix(hostname, `broken\x20`) {
hostname = ""
}
if len(fields) >= 8 && fields[2] == "ipv4" {
mac, macErr := net.ParseMAC(fields[1])
if macErr != nil {
return
}
addressField := fields[7]
slashIndex := strings.IndexByte(addressField, '/')
if slashIndex >= 0 {
addressField = addressField[:slashIndex]
}
address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField))
if !addrOK {
return
}
address = address.Unmap()
ipToMAC[address] = mac
if hostname != "" {
ipToHostname[address] = hostname
macToHostname[mac.String()] = hostname
}
return
}
var mac net.HardwareAddr
duidHex := fields[1]
duidBytes, hexErr := hex.DecodeString(duidHex)
if hexErr == nil {
mac, _ = extractMACFromDUID(duidBytes)
}
for i := 7; i < len(fields); i++ {
addressField := fields[i]
slashIndex := strings.IndexByte(addressField, '/')
if slashIndex >= 0 {
addressField = addressField[:slashIndex]
}
address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField))
if !addrOK {
continue
}
address = address.Unmap()
if mac != nil {
ipToMAC[address] = mac
}
if hostname != "" {
ipToHostname[address] = hostname
if mac != nil {
macToHostname[mac.String()] = hostname
}
}
}
}
func parseISCDhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) {
scanner := bufio.NewScanner(file)
var currentIP netip.Addr
var currentMAC net.HardwareAddr
var currentHostname string
var currentActive bool
var inLease bool
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") {
ipString := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {")
parsed, addrOK := netip.AddrFromSlice(net.ParseIP(ipString))
if addrOK {
currentIP = parsed.Unmap()
inLease = true
currentMAC = nil
currentHostname = ""
currentActive = false
}
continue
}
if line == "}" && inLease {
if currentActive && currentMAC != nil {
ipToMAC[currentIP] = currentMAC
if currentHostname != "" {
ipToHostname[currentIP] = currentHostname
macToHostname[currentMAC.String()] = currentHostname
}
} else {
delete(ipToMAC, currentIP)
delete(ipToHostname, currentIP)
}
inLease = false
continue
}
if !inLease {
continue
}
if strings.HasPrefix(line, "hardware ethernet ") {
macString := strings.TrimSuffix(strings.TrimPrefix(line, "hardware ethernet "), ";")
parsed, macErr := net.ParseMAC(macString)
if macErr == nil {
currentMAC = parsed
}
} else if strings.HasPrefix(line, "client-hostname ") {
hostname := strings.TrimSuffix(strings.TrimPrefix(line, "client-hostname "), ";")
hostname = strings.Trim(hostname, "\"")
if hostname != "" {
currentHostname = hostname
}
} else if strings.HasPrefix(line, "binding state ") {
state := strings.TrimSuffix(strings.TrimPrefix(line, "binding state "), ";")
currentActive = state == "active"
}
}
}
func parseKeaCSV4(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) {
scanner := bufio.NewScanner(file)
firstLine := true
for scanner.Scan() {
if firstLine {
firstLine = false
continue
}
fields := strings.Split(scanner.Text(), ",")
if len(fields) < 10 {
continue
}
if fields[9] != "0" {
continue
}
address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0]))
if !addrOK {
continue
}
address = address.Unmap()
mac, macErr := net.ParseMAC(fields[1])
if macErr != nil {
continue
}
ipToMAC[address] = mac
hostname := ""
if len(fields) > 8 {
hostname = fields[8]
}
if hostname != "" {
ipToHostname[address] = hostname
macToHostname[mac.String()] = hostname
}
}
}
func parseKeaCSV6(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) {
scanner := bufio.NewScanner(file)
firstLine := true
for scanner.Scan() {
if firstLine {
firstLine = false
continue
}
fields := strings.Split(scanner.Text(), ",")
if len(fields) < 14 {
continue
}
if fields[13] != "0" {
continue
}
address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0]))
if !addrOK {
continue
}
address = address.Unmap()
var mac net.HardwareAddr
if fields[12] != "" {
mac, _ = net.ParseMAC(fields[12])
}
if mac == nil {
duid, duidErr := hex.DecodeString(strings.ReplaceAll(fields[1], ":", ""))
if duidErr == nil {
mac, _ = extractMACFromDUID(duid)
}
}
hostname := ""
if len(fields) > 11 {
hostname = fields[11]
}
if mac != nil {
ipToMAC[address] = mac
}
if hostname != "" {
ipToHostname[address] = hostname
if mac != nil {
macToHostname[mac.String()] = hostname
}
}
}
}
func parseBootpdLeases(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) {
now := time.Now().Unix()
scanner := bufio.NewScanner(file)
var currentName string
var currentIP netip.Addr
var currentMAC net.HardwareAddr
var currentLease int64
var inBlock bool
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "{" {
inBlock = true
currentName = ""
currentIP = netip.Addr{}
currentMAC = nil
currentLease = 0
continue
}
if line == "}" && inBlock {
if currentMAC != nil && currentIP.IsValid() {
if currentLease == 0 || currentLease >= now {
ipToMAC[currentIP] = currentMAC
if currentName != "" {
ipToHostname[currentIP] = currentName
macToHostname[currentMAC.String()] = currentName
}
}
}
inBlock = false
continue
}
if !inBlock {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
switch key {
case "name":
currentName = value
case "ip_address":
parsed, addrOK := netip.AddrFromSlice(net.ParseIP(value))
if addrOK {
currentIP = parsed.Unmap()
}
case "hw_address":
typeAndMAC, hasSep := strings.CutPrefix(value, "1,")
if hasSep {
mac, macErr := net.ParseMAC(typeAndMAC)
if macErr == nil {
currentMAC = mac
}
}
case "lease":
leaseHex := strings.TrimPrefix(value, "0x")
parsed, parseErr := strconv.ParseInt(leaseHex, 16, 64)
if parseErr == nil {
currentLease = parsed
}
}
}
}

View File

@@ -0,0 +1,224 @@
//go:build linux
package route
import (
"net"
"net/netip"
"os"
"slices"
"sync"
"time"
"github.com/sagernet/fswatch"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
"github.com/jsimonetti/rtnetlink"
"github.com/mdlayher/netlink"
"golang.org/x/sys/unix"
)
var defaultLeaseFiles = []string{
"/tmp/dhcp.leases",
"/var/lib/dhcp/dhcpd.leases",
"/var/lib/dhcpd/dhcpd.leases",
"/var/lib/kea/kea-leases4.csv",
"/var/lib/kea/kea-leases6.csv",
}
type neighborResolver struct {
logger logger.ContextLogger
leaseFiles []string
access sync.RWMutex
neighborIPToMAC map[netip.Addr]net.HardwareAddr
leaseIPToMAC map[netip.Addr]net.HardwareAddr
ipToHostname map[netip.Addr]string
macToHostname map[string]string
watcher *fswatch.Watcher
done chan struct{}
}
func newNeighborResolver(resolverLogger logger.ContextLogger, leaseFiles []string) (adapter.NeighborResolver, error) {
if len(leaseFiles) == 0 {
for _, path := range defaultLeaseFiles {
info, err := os.Stat(path)
if err == nil && info.Size() > 0 {
leaseFiles = append(leaseFiles, path)
}
}
}
return &neighborResolver{
logger: resolverLogger,
leaseFiles: leaseFiles,
neighborIPToMAC: make(map[netip.Addr]net.HardwareAddr),
leaseIPToMAC: make(map[netip.Addr]net.HardwareAddr),
ipToHostname: make(map[netip.Addr]string),
macToHostname: make(map[string]string),
done: make(chan struct{}),
}, nil
}
func (r *neighborResolver) Start() error {
err := r.loadNeighborTable()
if err != nil {
r.logger.Warn(E.Cause(err, "load neighbor table"))
}
r.doReloadLeaseFiles()
go r.subscribeNeighborUpdates()
if len(r.leaseFiles) > 0 {
watcher, err := fswatch.NewWatcher(fswatch.Options{
Path: r.leaseFiles,
Logger: r.logger,
Callback: func(_ string) {
r.doReloadLeaseFiles()
},
})
if err != nil {
r.logger.Warn(E.Cause(err, "create lease file watcher"))
} else {
r.watcher = watcher
err = watcher.Start()
if err != nil {
r.logger.Warn(E.Cause(err, "start lease file watcher"))
}
}
}
return nil
}
func (r *neighborResolver) Close() error {
close(r.done)
if r.watcher != nil {
return r.watcher.Close()
}
return nil
}
func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) {
r.access.RLock()
defer r.access.RUnlock()
mac, found := r.neighborIPToMAC[address]
if found {
return mac, true
}
mac, found = r.leaseIPToMAC[address]
if found {
return mac, true
}
mac, found = extractMACFromEUI64(address)
if found {
return mac, true
}
return nil, false
}
func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) {
r.access.RLock()
defer r.access.RUnlock()
hostname, found := r.ipToHostname[address]
if found {
return hostname, true
}
mac, macFound := r.neighborIPToMAC[address]
if !macFound {
mac, macFound = r.leaseIPToMAC[address]
}
if !macFound {
mac, macFound = extractMACFromEUI64(address)
}
if macFound {
hostname, found = r.macToHostname[mac.String()]
if found {
return hostname, true
}
}
return "", false
}
func (r *neighborResolver) loadNeighborTable() error {
connection, err := rtnetlink.Dial(nil)
if err != nil {
return E.Cause(err, "dial rtnetlink")
}
defer connection.Close()
neighbors, err := connection.Neigh.List()
if err != nil {
return E.Cause(err, "list neighbors")
}
r.access.Lock()
defer r.access.Unlock()
for _, neigh := range neighbors {
if neigh.Attributes == nil {
continue
}
if neigh.Attributes.LLAddress == nil || len(neigh.Attributes.Address) == 0 {
continue
}
address, ok := netip.AddrFromSlice(neigh.Attributes.Address)
if !ok {
continue
}
r.neighborIPToMAC[address] = slices.Clone(neigh.Attributes.LLAddress)
}
return nil
}
func (r *neighborResolver) subscribeNeighborUpdates() {
connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{
Groups: 1 << (unix.RTNLGRP_NEIGH - 1),
})
if err != nil {
r.logger.Warn(E.Cause(err, "subscribe neighbor updates"))
return
}
defer connection.Close()
for {
select {
case <-r.done:
return
default:
}
err = connection.SetReadDeadline(time.Now().Add(3 * time.Second))
if err != nil {
r.logger.Warn(E.Cause(err, "set netlink read deadline"))
return
}
messages, err := connection.Receive()
if err != nil {
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
continue
}
select {
case <-r.done:
return
default:
}
r.logger.Warn(E.Cause(err, "receive neighbor update"))
continue
}
for _, message := range messages {
address, mac, isDelete, ok := ParseNeighborMessage(message)
if !ok {
continue
}
r.access.Lock()
if isDelete {
delete(r.neighborIPToMAC, address)
} else {
r.neighborIPToMAC[address] = mac
}
r.access.Unlock()
}
}
}
func (r *neighborResolver) doReloadLeaseFiles() {
leaseIPToMAC, ipToHostname, macToHostname := ReloadLeaseFiles(r.leaseFiles)
r.access.Lock()
r.leaseIPToMAC = leaseIPToMAC
r.ipToHostname = ipToHostname
r.macToHostname = macToHostname
r.access.Unlock()
}

View File

@@ -0,0 +1,50 @@
package route
import (
"encoding/binary"
"encoding/hex"
"net"
"net/netip"
"slices"
"strings"
)
func extractMACFromDUID(duid []byte) (net.HardwareAddr, bool) {
if len(duid) < 4 {
return nil, false
}
duidType := binary.BigEndian.Uint16(duid[0:2])
hwType := binary.BigEndian.Uint16(duid[2:4])
if hwType != 1 {
return nil, false
}
switch duidType {
case 1:
if len(duid) < 14 {
return nil, false
}
return net.HardwareAddr(slices.Clone(duid[8:14])), true
case 3:
if len(duid) < 10 {
return nil, false
}
return net.HardwareAddr(slices.Clone(duid[4:10])), true
}
return nil, false
}
func extractMACFromEUI64(address netip.Addr) (net.HardwareAddr, bool) {
if !address.Is6() {
return nil, false
}
b := address.As16()
if b[11] != 0xff || b[12] != 0xfe {
return nil, false
}
return net.HardwareAddr{b[8] ^ 0x02, b[9], b[10], b[13], b[14], b[15]}, true
}
func parseDUID(s string) ([]byte, error) {
cleaned := strings.ReplaceAll(s, ":", "")
return hex.DecodeString(cleaned)
}

View File

@@ -0,0 +1,84 @@
package route
import (
"net"
"net/netip"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/logger"
)
type platformNeighborResolver struct {
logger logger.ContextLogger
platform adapter.PlatformInterface
access sync.RWMutex
ipToMAC map[netip.Addr]net.HardwareAddr
ipToHostname map[netip.Addr]string
macToHostname map[string]string
}
func newPlatformNeighborResolver(resolverLogger logger.ContextLogger, platform adapter.PlatformInterface) adapter.NeighborResolver {
return &platformNeighborResolver{
logger: resolverLogger,
platform: platform,
ipToMAC: make(map[netip.Addr]net.HardwareAddr),
ipToHostname: make(map[netip.Addr]string),
macToHostname: make(map[string]string),
}
}
func (r *platformNeighborResolver) Start() error {
return r.platform.StartNeighborMonitor(r)
}
func (r *platformNeighborResolver) Close() error {
return r.platform.CloseNeighborMonitor(r)
}
func (r *platformNeighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) {
r.access.RLock()
defer r.access.RUnlock()
mac, found := r.ipToMAC[address]
if found {
return mac, true
}
return extractMACFromEUI64(address)
}
func (r *platformNeighborResolver) LookupHostname(address netip.Addr) (string, bool) {
r.access.RLock()
defer r.access.RUnlock()
hostname, found := r.ipToHostname[address]
if found {
return hostname, true
}
mac, found := r.ipToMAC[address]
if !found {
mac, found = extractMACFromEUI64(address)
}
if !found {
return "", false
}
hostname, found = r.macToHostname[mac.String()]
return hostname, found
}
func (r *platformNeighborResolver) UpdateNeighborTable(entries []adapter.NeighborEntry) {
ipToMAC := make(map[netip.Addr]net.HardwareAddr)
ipToHostname := make(map[netip.Addr]string)
macToHostname := make(map[string]string)
for _, entry := range entries {
ipToMAC[entry.Address] = entry.MACAddress
if entry.Hostname != "" {
ipToHostname[entry.Address] = entry.Hostname
macToHostname[entry.MACAddress.String()] = entry.Hostname
}
}
r.access.Lock()
r.ipToMAC = ipToMAC
r.ipToHostname = ipToHostname
r.macToHostname = macToHostname
r.access.Unlock()
r.logger.Info("updated neighbor table: ", len(entries), " entries")
}

View File

@@ -0,0 +1,14 @@
//go:build !linux && !darwin
package route
import (
"os"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/logger"
)
func newNeighborResolver(_ logger.ContextLogger, _ []string) (adapter.NeighborResolver, error) {
return nil, os.ErrInvalid
}

View File

@@ -0,0 +1,104 @@
//go:build darwin
package route
import (
"net"
"net/netip"
"syscall"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/net/route"
"golang.org/x/sys/unix"
)
func ReadNeighborEntries() ([]adapter.NeighborEntry, error) {
var entries []adapter.NeighborEntry
ipv4Entries, err := readNeighborEntriesAF(syscall.AF_INET)
if err != nil {
return nil, E.Cause(err, "read IPv4 neighbors")
}
entries = append(entries, ipv4Entries...)
ipv6Entries, err := readNeighborEntriesAF(syscall.AF_INET6)
if err != nil {
return nil, E.Cause(err, "read IPv6 neighbors")
}
entries = append(entries, ipv6Entries...)
return entries, nil
}
func readNeighborEntriesAF(addressFamily int) ([]adapter.NeighborEntry, error) {
rib, err := route.FetchRIB(addressFamily, route.RIBType(syscall.NET_RT_FLAGS), syscall.RTF_LLINFO)
if err != nil {
return nil, err
}
messages, err := route.ParseRIB(route.RIBType(syscall.NET_RT_FLAGS), rib)
if err != nil {
return nil, err
}
var entries []adapter.NeighborEntry
for _, message := range messages {
routeMessage, isRouteMessage := message.(*route.RouteMessage)
if !isRouteMessage {
continue
}
address, macAddress, ok := parseRouteNeighborEntry(routeMessage)
if !ok {
continue
}
entries = append(entries, adapter.NeighborEntry{
Address: address,
MACAddress: macAddress,
})
}
return entries, nil
}
func parseRouteNeighborEntry(message *route.RouteMessage) (address netip.Addr, macAddress net.HardwareAddr, ok bool) {
if len(message.Addrs) <= unix.RTAX_GATEWAY {
return
}
gateway, isLinkAddr := message.Addrs[unix.RTAX_GATEWAY].(*route.LinkAddr)
if !isLinkAddr || len(gateway.Addr) < 6 {
return
}
switch destination := message.Addrs[unix.RTAX_DST].(type) {
case *route.Inet4Addr:
address = netip.AddrFrom4(destination.IP)
case *route.Inet6Addr:
address = netip.AddrFrom16(destination.IP)
default:
return
}
macAddress = net.HardwareAddr(make([]byte, len(gateway.Addr)))
copy(macAddress, gateway.Addr)
ok = true
return
}
func ParseRouteNeighborMessage(message *route.RouteMessage) (address netip.Addr, macAddress net.HardwareAddr, isDelete bool, ok bool) {
isDelete = message.Type == unix.RTM_DELETE
if len(message.Addrs) <= unix.RTAX_GATEWAY {
return
}
switch destination := message.Addrs[unix.RTAX_DST].(type) {
case *route.Inet4Addr:
address = netip.AddrFrom4(destination.IP)
case *route.Inet6Addr:
address = netip.AddrFrom16(destination.IP)
default:
return
}
if !isDelete {
gateway, isLinkAddr := message.Addrs[unix.RTAX_GATEWAY].(*route.LinkAddr)
if !isLinkAddr || len(gateway.Addr) < 6 {
return
}
macAddress = net.HardwareAddr(make([]byte, len(gateway.Addr)))
copy(macAddress, gateway.Addr)
}
ok = true
return
}

View File

@@ -0,0 +1,68 @@
//go:build linux
package route
import (
"net"
"net/netip"
"slices"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
"github.com/jsimonetti/rtnetlink"
"github.com/mdlayher/netlink"
"golang.org/x/sys/unix"
)
func ReadNeighborEntries() ([]adapter.NeighborEntry, error) {
connection, err := rtnetlink.Dial(nil)
if err != nil {
return nil, E.Cause(err, "dial rtnetlink")
}
defer connection.Close()
neighbors, err := connection.Neigh.List()
if err != nil {
return nil, E.Cause(err, "list neighbors")
}
var entries []adapter.NeighborEntry
for _, neighbor := range neighbors {
if neighbor.Attributes == nil {
continue
}
if neighbor.Attributes.LLAddress == nil || len(neighbor.Attributes.Address) == 0 {
continue
}
address, ok := netip.AddrFromSlice(neighbor.Attributes.Address)
if !ok {
continue
}
entries = append(entries, adapter.NeighborEntry{
Address: address,
MACAddress: slices.Clone(neighbor.Attributes.LLAddress),
})
}
return entries, nil
}
func ParseNeighborMessage(message netlink.Message) (address netip.Addr, macAddress net.HardwareAddr, isDelete bool, ok bool) {
var neighMessage rtnetlink.NeighMessage
err := neighMessage.UnmarshalBinary(message.Data)
if err != nil {
return
}
if neighMessage.Attributes == nil || len(neighMessage.Attributes.Address) == 0 {
return
}
address, ok = netip.AddrFromSlice(neighMessage.Attributes.Address)
if !ok {
return
}
isDelete = message.Header.Type == unix.RTM_DELNEIGH
if !isDelete && neighMessage.Attributes.LLAddress == nil {
ok = false
return
}
macAddress = slices.Clone(neighMessage.Attributes.LLAddress)
return
}

View File

@@ -439,6 +439,23 @@ func (r *Router) matchRule(
metadata.ProcessInfo = processInfo
}
}
if r.neighborResolver != nil && metadata.SourceMACAddress == nil && metadata.Source.Addr.IsValid() {
mac, macFound := r.neighborResolver.LookupMAC(metadata.Source.Addr)
if macFound {
metadata.SourceMACAddress = mac
}
hostname, hostnameFound := r.neighborResolver.LookupHostname(metadata.Source.Addr)
if hostnameFound {
metadata.SourceHostname = hostname
if macFound {
r.logger.InfoContext(ctx, "found neighbor: ", mac, ", hostname: ", hostname)
} else {
r.logger.InfoContext(ctx, "found neighbor hostname: ", hostname)
}
} else if macFound {
r.logger.InfoContext(ctx, "found neighbor: ", mac)
}
}
if metadata.Destination.Addr.IsValid() && r.dnsTransport.FakeIP() != nil && r.dnsTransport.FakeIP().Store().Contains(metadata.Destination.Addr) {
domain, loaded := r.dnsTransport.FakeIP().Store().Lookup(metadata.Destination.Addr)
if !loaded {

View File

@@ -31,9 +31,12 @@ type Router struct {
network adapter.NetworkManager
rules []adapter.Rule
needFindProcess bool
needFindNeighbor bool
leaseFiles []string
ruleSets []adapter.RuleSet
ruleSetMap map[string]adapter.RuleSet
processSearcher process.Searcher
neighborResolver adapter.NeighborResolver
pauseManager pause.Manager
trackers []adapter.ConnectionTracker
platformInterface adapter.PlatformInterface
@@ -53,6 +56,8 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route
rules: make([]adapter.Rule, 0, len(options.Rules)),
ruleSetMap: make(map[string]adapter.RuleSet),
needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess,
needFindNeighbor: hasRule(options.Rules, isNeighborRule) || hasDNSRule(dnsOptions.Rules, isNeighborDNSRule) || options.FindNeighbor,
leaseFiles: options.DHCPLeaseFiles,
pauseManager: service.FromContext[pause.Manager](ctx),
platformInterface: service.FromContext[adapter.PlatformInterface](ctx),
}
@@ -112,6 +117,7 @@ func (r *Router) Start(stage adapter.StartStage) error {
}
r.network.Initialize(r.ruleSets)
needFindProcess := r.needFindProcess
needFindNeighbor := r.needFindNeighbor
for _, ruleSet := range r.ruleSets {
metadata := ruleSet.Metadata()
if metadata.ContainsProcessRule {
@@ -141,6 +147,36 @@ func (r *Router) Start(stage adapter.StartStage) error {
}
}
}
r.needFindNeighbor = needFindNeighbor
if needFindNeighbor {
if r.platformInterface != nil && r.platformInterface.UsePlatformNeighborResolver() {
monitor.Start("initialize neighbor resolver")
resolver := newPlatformNeighborResolver(r.logger, r.platformInterface)
err := resolver.Start()
monitor.Finish()
if err != nil {
r.logger.Error(E.Cause(err, "start neighbor resolver"))
} else {
r.neighborResolver = resolver
}
} else {
monitor.Start("initialize neighbor resolver")
resolver, err := newNeighborResolver(r.logger, r.leaseFiles)
monitor.Finish()
if err != nil {
if err != os.ErrInvalid {
r.logger.Error(E.Cause(err, "create neighbor resolver"))
}
} else {
err = resolver.Start()
if err != nil {
r.logger.Error(E.Cause(err, "start neighbor resolver"))
} else {
r.neighborResolver = resolver
}
}
}
}
case adapter.StartStatePostStart:
for i, rule := range r.rules {
monitor.Start("initialize rule[", i, "]")
@@ -172,6 +208,13 @@ func (r *Router) Start(stage adapter.StartStage) error {
func (r *Router) Close() error {
monitor := taskmonitor.New(r.logger, C.StopTimeout)
var err error
if r.neighborResolver != nil {
monitor.Start("close neighbor resolver")
err = E.Append(err, r.neighborResolver.Close(), func(closeErr error) error {
return E.Cause(closeErr, "close neighbor resolver")
})
monitor.Finish()
}
for i, rule := range r.rules {
monitor.Start("close rule[", i, "]")
err = E.Append(err, rule.Close(), func(err error) error {
@@ -206,6 +249,14 @@ func (r *Router) NeedFindProcess() bool {
return r.needFindProcess
}
func (r *Router) NeedFindNeighbor() bool {
return r.needFindNeighbor
}
func (r *Router) NeighborResolver() adapter.NeighborResolver {
return r.neighborResolver
}
func (r *Router) ResetNetwork() {
r.network.ResetNetwork()
r.dns.ResetNetwork()

View File

@@ -260,6 +260,16 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.SourceMACAddress) > 0 {
item := NewSourceMACAddressItem(options.SourceMACAddress)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.SourceHostname) > 0 {
item := NewSourceHostnameItem(options.SourceHostname)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.PreferredBy) > 0 {
item := NewPreferredByItem(ctx, options.PreferredBy)
rule.items = append(rule.items, item)

View File

@@ -261,6 +261,16 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.SourceMACAddress) > 0 {
item := NewSourceMACAddressItem(options.SourceMACAddress)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.SourceHostname) > 0 {
item := NewSourceHostnameItem(options.SourceHostname)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.RuleSet) > 0 {
//nolint:staticcheck
if options.Deprecated_RulesetIPCIDRMatchSource {

View File

@@ -0,0 +1,42 @@
package rule
import (
"strings"
"github.com/sagernet/sing-box/adapter"
)
var _ RuleItem = (*SourceHostnameItem)(nil)
type SourceHostnameItem struct {
hostnames []string
hostnameMap map[string]bool
}
func NewSourceHostnameItem(hostnameList []string) *SourceHostnameItem {
rule := &SourceHostnameItem{
hostnames: hostnameList,
hostnameMap: make(map[string]bool),
}
for _, hostname := range hostnameList {
rule.hostnameMap[hostname] = true
}
return rule
}
func (r *SourceHostnameItem) Match(metadata *adapter.InboundContext) bool {
if metadata.SourceHostname == "" {
return false
}
return r.hostnameMap[metadata.SourceHostname]
}
func (r *SourceHostnameItem) String() string {
var description string
if len(r.hostnames) == 1 {
description = "source_hostname=" + r.hostnames[0]
} else {
description = "source_hostname=[" + strings.Join(r.hostnames, " ") + "]"
}
return description
}

View File

@@ -0,0 +1,48 @@
package rule
import (
"net"
"strings"
"github.com/sagernet/sing-box/adapter"
)
var _ RuleItem = (*SourceMACAddressItem)(nil)
type SourceMACAddressItem struct {
addresses []string
addressMap map[string]bool
}
func NewSourceMACAddressItem(addressList []string) *SourceMACAddressItem {
rule := &SourceMACAddressItem{
addresses: addressList,
addressMap: make(map[string]bool),
}
for _, address := range addressList {
parsed, err := net.ParseMAC(address)
if err == nil {
rule.addressMap[parsed.String()] = true
} else {
rule.addressMap[address] = true
}
}
return rule
}
func (r *SourceMACAddressItem) Match(metadata *adapter.InboundContext) bool {
if metadata.SourceMACAddress == nil {
return false
}
return r.addressMap[metadata.SourceMACAddress.String()]
}
func (r *SourceMACAddressItem) String() string {
var description string
if len(r.addresses) == 1 {
description = "source_mac_address=" + r.addresses[0]
} else {
description = "source_mac_address=[" + strings.Join(r.addresses, " ") + "]"
}
return description
}

View File

@@ -45,6 +45,14 @@ func isProcessDNSRule(rule option.DefaultDNSRule) bool {
return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0
}
func isNeighborRule(rule option.DefaultRule) bool {
return len(rule.SourceMACAddress) > 0 || len(rule.SourceHostname) > 0
}
func isNeighborDNSRule(rule option.DefaultDNSRule) bool {
return len(rule.SourceMACAddress) > 0 || len(rule.SourceHostname) > 0
}
func isWIFIRule(rule option.DefaultRule) bool {
return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0
}