Add MAC and hostname rule items

This commit is contained in:
世界
2026-03-03 20:59:13 +08:00
parent d3768cca36
commit 4d217b7481
27 changed files with 1089 additions and 5 deletions

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

13
adapter/neighbor.go Normal file
View File

@@ -0,0 +1,13 @@
package adapter
import (
"net"
"net/netip"
)
type NeighborResolver interface {
LookupMAC(address netip.Addr) (net.HardwareAddr, bool)
LookupHostname(address netip.Addr) (string, bool)
Start() error
Close() error
}

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,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 with `route.find_neighbor` enabled.
Match source device MAC address.
#### source_hostname
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux with `route.find_neighbor` enabled.
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且需要 `route.find_neighbor` 已启用。
匹配源设备 MAC 地址。
#### source_hostname
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux且需要 `route.find_neighbor` 已启用。
匹配源设备从 DHCP 租约获取的主机名。
#### wifi_ssid
!!! quote ""

View File

@@ -2,6 +2,11 @@
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)
@@ -129,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,
@@ -555,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,11 @@
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)
@@ -130,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,
@@ -543,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,8 @@ icon: material/alert-decagram
"override_android_vpn": false,
"default_interface": "",
"default_mark": 0,
"find_neighbor": false,
"dhcp_lease_files": [],
"default_domain_resolver": "", // or {}
"default_network_strategy": "",
"default_network_type": [],
@@ -107,6 +114,30 @@ Set routing mark by default.
Takes no effect if `outbound.routing_mark` is set.
#### find_neighbor
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux.
Enable neighbor resolution for source MAC address and hostname lookup.
Required for `source_mac_address` and `source_hostname` rule items.
#### dhcp_lease_files
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux.
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,8 @@ icon: material/alert-decagram
"override_android_vpn": false,
"default_interface": "",
"default_mark": 0,
"find_neighbor": false,
"dhcp_lease_files": [],
"default_network_strategy": "",
"default_fallback_delay": ""
}
@@ -106,6 +113,30 @@ icon: material/alert-decagram
如果设置了 `outbound.routing_mark` 设置,则不生效。
#### find_neighbor
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux。
启用邻居解析以查找源 MAC 地址和主机名。
`source_mac_address``source_hostname` 规则项需要此选项。
#### dhcp_lease_files
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux。
用于主机名和 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 with `route.find_neighbor` enabled.
Match source device MAC address.
#### source_hostname
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux with `route.find_neighbor` enabled.
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且需要 `route.find_neighbor` 已启用。
匹配源设备 MAC 地址。
#### source_hostname
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux且需要 `route.find_neighbor` 已启用。
匹配源设备从 DHCP 租约获取的主机名。
#### rule_set
!!! question "自 sing-box 1.8.0 起"

6
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
@@ -39,7 +41,7 @@ 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.3
github.com/sagernet/sing-tun v0.8.4-0.20260315091454-bbe21100c226
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.20260311131347-f88b27eeb76e
@@ -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

4
go.sum
View File

@@ -248,8 +248,8 @@ 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.3 h1:mozxmuIoRhFdVHnheenLpBaammVj7bZPcnkApaYKDPY=
github.com/sagernet/sing-tun v0.8.3/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs=
github.com/sagernet/sing-tun v0.8.4-0.20260315091454-bbe21100c226 h1:Shy/fsm+pqVq6OkBAWPaOmOiPT/AwoRxQLiV1357Y0Y=
github.com/sagernet/sing-tun v0.8.4-0.20260315091454-bbe21100c226/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=

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

@@ -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,596 @@
//go:build linux
package route
import (
"bufio"
"encoding/binary"
"encoding/hex"
"net"
"net/netip"
"os"
"slices"
"strconv"
"strings"
"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.reloadLeaseFiles()
go r.subscribeNeighborUpdates()
if len(r.leaseFiles) > 0 {
watcher, err := fswatch.NewWatcher(fswatch.Options{
Path: r.leaseFiles,
Logger: r.logger,
Callback: func(_ string) {
r.reloadLeaseFiles()
},
})
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 {
switch message.Header.Type {
case unix.RTM_NEWNEIGH:
var neighMessage rtnetlink.NeighMessage
unmarshalErr := neighMessage.UnmarshalBinary(message.Data)
if unmarshalErr != nil {
continue
}
if neighMessage.Attributes == nil {
continue
}
if neighMessage.Attributes.LLAddress == nil || len(neighMessage.Attributes.Address) == 0 {
continue
}
address, ok := netip.AddrFromSlice(neighMessage.Attributes.Address)
if !ok {
continue
}
r.access.Lock()
r.neighborIPToMAC[address] = slices.Clone(neighMessage.Attributes.LLAddress)
r.access.Unlock()
case unix.RTM_DELNEIGH:
var neighMessage rtnetlink.NeighMessage
unmarshalErr := neighMessage.UnmarshalBinary(message.Data)
if unmarshalErr != nil {
continue
}
if neighMessage.Attributes == nil || len(neighMessage.Attributes.Address) == 0 {
continue
}
address, ok := netip.AddrFromSlice(neighMessage.Attributes.Address)
if !ok {
continue
}
r.access.Lock()
delete(r.neighborIPToMAC, address)
r.access.Unlock()
}
}
}
}
func (r *neighborResolver) reloadLeaseFiles() {
leaseIPToMAC := make(map[netip.Addr]net.HardwareAddr)
ipToHostname := make(map[netip.Addr]string)
macToHostname := make(map[string]string)
for _, path := range r.leaseFiles {
r.parseLeaseFile(path, leaseIPToMAC, ipToHostname, macToHostname)
}
r.access.Lock()
r.leaseIPToMAC = leaseIPToMAC
r.ipToHostname = ipToHostname
r.macToHostname = macToHostname
r.access.Unlock()
}
func (r *neighborResolver) 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, "kea-leases4.csv") {
r.parseKeaCSV4(file, ipToMAC, ipToHostname, macToHostname)
return
}
if strings.HasSuffix(path, "kea-leases6.csv") {
r.parseKeaCSV6(file, ipToMAC, ipToHostname, macToHostname)
return
}
if strings.HasSuffix(path, "dhcpd.leases") {
r.parseISCDhcpd(file, ipToMAC, ipToHostname, macToHostname)
return
}
r.parseDnsmasqOdhcpd(file, ipToMAC, ipToHostname, macToHostname)
}
func (r *neighborResolver) 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, "# ") {
r.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 (r *neighborResolver) 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 (r *neighborResolver) 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 (r *neighborResolver) 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 (r *neighborResolver) 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 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,14 @@
//go:build !linux
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

@@ -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,24 @@ func (r *Router) Start(stage adapter.StartStage) error {
}
}
}
r.needFindNeighbor = needFindNeighbor
if needFindNeighbor {
monitor.Start("initialize neighbor resolver")
resolver, err := newNeighborResolver(r.logger, r.leaseFiles)
monitor.Finish()
if err != nil {
if err != os.ErrInvalid {
r.logger.Warn(E.Cause(err, "create neighbor resolver"))
}
} else {
err = resolver.Start()
if err != nil {
r.logger.Warn(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 +196,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 +237,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
}