feat(shater): per-device control + device discovery (Phase 6 backend)
Parental/per-device policy: route or block per device by client IP.
- model: Device{Name,MAC,IP,Enabled,Proxy,Target,Block[],Allow[]} +
Model.Devices; uci parse (case device, list block/allow) + render +
round-trip fixture. Identity MAC-first, else IP.
- generate: resolveDevices() maps each enabled device to a source CIDR
(explicit IP, else MAC->IP via /tmp/dhcp.leases; unresolvable skipped).
Routing: device rules spliced after sniff/hijack-dns but BEFORE general
rules (device overrides win first-match) — !Proxy->direct, Proxy+Target->
target, Proxy no-target->global default. DNS: per-device allow-then-block
with source_ip_cidr=<deviceIP> -> predefinedNXDOMAIN (reuses the D15
action); device Block is NXDOMAIN for that device even with the global
filter off; allow overrides.
- shater/devices (new leaf pkg): ParseLeases/MACToIP/Discover — merges
/tmp/dhcp.leases with 'ip neigh' (REACHABLE->online) via the execCommand
seam, cross-refs configured devices (MAC/IP), appends offline configured
rows. panel GET /api/devices (session-gated) serves it.
Verified: round-trip + generate codegen tests (src=IP route rule,
source_ip_cidr NXDOMAIN DNS rule, off-cases emit nothing, allow-before-
block) + box.New; devices merge test; panel endpoint test. VM gate PASSED
with TWO netns clients: deviceA nslookup example.com -> NXDOMAIN while
deviceB resolves (per-device block); engine routes .50->direct vs .51->
block (per-device exit); GET /api/devices lists both online, deviceA
configured:true blockCount:1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// Package devices is the shater v0.2 device-discovery + identity helper (Phase 6).
|
||||
// It is a leaf package (stdlib + model only) so both the generate stage (MAC->IP
|
||||
// resolution at gen time) and the panel API (GET /api/devices) can depend on it
|
||||
// without an import cycle.
|
||||
//
|
||||
// Two on-router signals are merged:
|
||||
//
|
||||
// - the dnsmasq DHCP lease table (/tmp/dhcp.leases), giving ip / mac / hostname
|
||||
// for every DHCP client; and
|
||||
// - `ip neigh show`, giving reachability (REACHABLE -> online, STALE -> idle,
|
||||
// anything else -> offline) and a MAC for non-DHCP / statically-addressed
|
||||
// hosts that never took a lease.
|
||||
//
|
||||
// The merged rows are cross-referenced against the configured model.Devices
|
||||
// (matched by MAC preferentially, else IP) so each row carries whether it is a
|
||||
// configured device and, if so, its friendly name / proxy / target / block-count.
|
||||
//
|
||||
// All shelling-out goes through the execCommand seam so discovery is testable
|
||||
// without a router (mirrors shater/netplane).
|
||||
package devices
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// LeasesPath is the dnsmasq DHCP lease table on OpenWrt. A package var so tests
|
||||
// can point it at a fixture. Each line is:
|
||||
//
|
||||
// <expiry> <mac> <ip> <hostname> <client-id>
|
||||
var LeasesPath = "/tmp/dhcp.leases"
|
||||
|
||||
// execCommand is the exec seam: tests replace it to intercept `ip neigh`.
|
||||
var execCommand = exec.Command
|
||||
|
||||
// Lease is one parsed dnsmasq lease row (the fields shater cares about).
|
||||
type Lease struct {
|
||||
MAC string
|
||||
IP string
|
||||
Hostname string // "" when the client sent none ("*")
|
||||
}
|
||||
|
||||
// ParseLeases reads the dnsmasq lease table into a slice of leases. Any read or
|
||||
// parse failure yields an empty slice (callers fall back to other signals).
|
||||
func ParseLeases(path string) []Lease {
|
||||
var out []Lease
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
mac, ip, host := strings.ToLower(f[1]), f[2], f[3]
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
if host == "*" {
|
||||
host = ""
|
||||
}
|
||||
out = append(out, Lease{MAC: mac, IP: ip, Hostname: host})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MACToIP builds a lower-cased MAC -> IP index from the lease table, used by the
|
||||
// generate stage to resolve a device configured by MAC to its current IP. When a
|
||||
// MAC appears more than once the last (most recent) lease line wins.
|
||||
func MACToIP(path string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, l := range ParseLeases(path) {
|
||||
if l.MAC != "" && l.IP != "" {
|
||||
out[l.MAC] = l.IP
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Discovered is one row of GET /api/devices: a host seen on the LAN (via lease
|
||||
// and/or neighbour table) cross-referenced with the configured devices.
|
||||
type Discovered struct {
|
||||
IP string `json:"ip"`
|
||||
MAC string `json:"mac"`
|
||||
Hostname string `json:"hostname"`
|
||||
Online bool `json:"online"` // reachable or recently-seen (state != offline)
|
||||
State string `json:"state"` // online|idle|offline
|
||||
Configured bool `json:"configured"` // matches a model.Device (by MAC or IP)
|
||||
Name string `json:"name,omitempty"` // configured friendly name
|
||||
Proxy bool `json:"proxy"` // configured: routed via proxy
|
||||
Target string `json:"target,omitempty"`
|
||||
BlockCount int `json:"blockCount"` // number of per-device blocked domains
|
||||
}
|
||||
|
||||
// neigh is one parsed `ip neigh` row.
|
||||
type neigh struct {
|
||||
IP string
|
||||
MAC string
|
||||
State string // online|idle|offline
|
||||
}
|
||||
|
||||
// Discover merges the DHCP lease table with `ip neigh show`, cross-references the
|
||||
// configured devices, and returns one row per discovered host (union of leases +
|
||||
// neighbours), plus any configured device that was not otherwise seen (as an
|
||||
// offline row) so the panel can always render the full parental-control list.
|
||||
// Never returns nil.
|
||||
func Discover(configured []model.Device) []Discovered {
|
||||
// Index by IP, seeding from the lease table (ip/mac/hostname).
|
||||
byIP := map[string]*Discovered{}
|
||||
order := []string{}
|
||||
add := func(ip string) *Discovered {
|
||||
if d, ok := byIP[ip]; ok {
|
||||
return d
|
||||
}
|
||||
d := &Discovered{IP: ip, State: "offline"}
|
||||
byIP[ip] = d
|
||||
order = append(order, ip)
|
||||
return d
|
||||
}
|
||||
for _, l := range ParseLeases(LeasesPath) {
|
||||
d := add(l.IP)
|
||||
if d.MAC == "" {
|
||||
d.MAC = l.MAC
|
||||
}
|
||||
if d.Hostname == "" {
|
||||
d.Hostname = l.Hostname
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay neighbour reachability + MACs for non-DHCP hosts.
|
||||
for _, n := range parseNeigh() {
|
||||
d := add(n.IP)
|
||||
if d.MAC == "" {
|
||||
d.MAC = n.MAC
|
||||
}
|
||||
// Prefer the "most online" state if a host somehow appears twice.
|
||||
if stateRank(n.State) > stateRank(d.State) {
|
||||
d.State = n.State
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-reference the configured devices (match by MAC first, else IP). Track
|
||||
// which configured entries matched so unseen ones can be appended as offline.
|
||||
matched := make([]bool, len(configured))
|
||||
for _, d := range byIP {
|
||||
if i, ok := matchConfigured(configured, d.MAC, d.IP); ok {
|
||||
markConfigured(d, configured[i])
|
||||
matched[i] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Discovered, 0, len(order)+len(configured))
|
||||
for _, ip := range order {
|
||||
d := byIP[ip]
|
||||
d.Online = d.State != "offline"
|
||||
out = append(out, *d)
|
||||
}
|
||||
// Append configured-but-unseen devices as offline rows (identity from config).
|
||||
for i, cd := range configured {
|
||||
if matched[i] {
|
||||
continue
|
||||
}
|
||||
row := Discovered{
|
||||
IP: strings.TrimSpace(cd.IP), MAC: strings.ToLower(strings.TrimSpace(cd.MAC)),
|
||||
State: "offline", Online: false,
|
||||
}
|
||||
markConfigured(&row, cd)
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// markConfigured stamps the configured-device fields onto a discovered row.
|
||||
func markConfigured(d *Discovered, cd model.Device) {
|
||||
d.Configured = true
|
||||
d.Name = cd.Name
|
||||
d.Proxy = cd.Proxy
|
||||
d.Target = cd.Target
|
||||
d.BlockCount = len(cd.Block)
|
||||
}
|
||||
|
||||
// matchConfigured finds the configured device matching mac (preferred) or ip.
|
||||
func matchConfigured(configured []model.Device, mac, ip string) (int, bool) {
|
||||
mac = strings.ToLower(strings.TrimSpace(mac))
|
||||
ip = strings.TrimSpace(ip)
|
||||
if mac != "" {
|
||||
for i, cd := range configured {
|
||||
if strings.EqualFold(strings.TrimSpace(cd.MAC), mac) {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if ip != "" {
|
||||
for i, cd := range configured {
|
||||
if strings.TrimSpace(cd.IP) == ip {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseNeigh runs `ip neigh show` and parses each row into ip/mac/state. A
|
||||
// missing `ip` binary or any error yields an empty slice (discovery degrades to
|
||||
// the lease table alone). Row shapes handled:
|
||||
//
|
||||
// 192.168.1.50 dev br-lan lladdr aa:bb:cc:dd:ee:ff REACHABLE
|
||||
// 192.168.1.9 dev br-lan FAILED
|
||||
// fe80::1 dev br-lan lladdr 00:11:.. router STALE
|
||||
func parseNeigh() []neigh {
|
||||
out, err := execCommand("ip", "neigh", "show").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var rows []neigh
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
r := neigh{IP: f[0], State: "offline"}
|
||||
for i := 1; i < len(f); i++ {
|
||||
if f[i] == "lladdr" && i+1 < len(f) {
|
||||
r.MAC = strings.ToLower(f[i+1])
|
||||
}
|
||||
}
|
||||
// The kernel NUD state is the last uppercase token on the line.
|
||||
last := f[len(f)-1]
|
||||
r.State = nudState(last)
|
||||
rows = append(rows, r)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// nudState maps a kernel neighbour (NUD) state to shater's online|idle|offline.
|
||||
// REACHABLE => online; STALE/DELAY/PROBE/PERMANENT/NOARP => idle (a MAC is known,
|
||||
// just not freshly verified); FAILED/INCOMPLETE/NONE/anything else => offline.
|
||||
func nudState(s string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "REACHABLE":
|
||||
return "online"
|
||||
case "STALE", "DELAY", "PROBE", "PERMANENT", "NOARP":
|
||||
return "idle"
|
||||
default:
|
||||
return "offline"
|
||||
}
|
||||
}
|
||||
|
||||
// stateRank orders states so a "more online" reading wins a merge.
|
||||
func stateRank(s string) int {
|
||||
switch s {
|
||||
case "online":
|
||||
return 2
|
||||
case "idle":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// fakeNeigh replaces execCommand so `ip neigh show` returns a fixed transcript.
|
||||
// It re-execs the test binary with a helper env var (the standard os/exec test
|
||||
// seam), and the helper prints the canned output.
|
||||
func fakeNeigh(t *testing.T, output string) func() {
|
||||
t.Helper()
|
||||
orig := execCommand
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
cs := []string{"-test.run=TestNeighHelper", "--", name}
|
||||
cs = append(cs, args...)
|
||||
cmd := exec.Command(os.Args[0], cs...)
|
||||
cmd.Env = append(os.Environ(), "GO_NEIGH_HELPER=1", "GO_NEIGH_OUTPUT="+output)
|
||||
return cmd
|
||||
}
|
||||
return func() { execCommand = orig }
|
||||
}
|
||||
|
||||
// TestNeighHelper is not a real test: it is the child process fakeNeigh execs.
|
||||
func TestNeighHelper(t *testing.T) {
|
||||
if os.Getenv("GO_NEIGH_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
os.Stdout.WriteString(os.Getenv("GO_NEIGH_OUTPUT"))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func writeLeases(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "dhcp.leases")
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestDiscoverMerge covers the whole merge: a DHCP lease host, a neigh-only
|
||||
// (statically addressed) host, reachability mapping, and the configured-device
|
||||
// cross-reference by MAC and by IP.
|
||||
func TestDiscoverMerge(t *testing.T) {
|
||||
// deviceA is a DHCP client (lease has ip+mac+hostname), REACHABLE.
|
||||
// deviceB has a lease too but is STALE (idle).
|
||||
// 192.168.1.99 is neigh-only (no lease), REACHABLE.
|
||||
leases := "" +
|
||||
"1700000000 aa:bb:cc:dd:ee:ff 192.168.1.50 kidpc 01:aa\n" +
|
||||
"1700000000 11:22:33:44:55:66 192.168.1.51 tv *\n"
|
||||
LeasesPath = writeLeases(t, leases)
|
||||
|
||||
neigh := "" +
|
||||
"192.168.1.50 dev br-lan lladdr aa:bb:cc:dd:ee:ff REACHABLE\n" +
|
||||
"192.168.1.51 dev br-lan lladdr 11:22:33:44:55:66 STALE\n" +
|
||||
"192.168.1.99 dev br-lan lladdr 77:88:99:aa:bb:cc REACHABLE\n"
|
||||
defer fakeNeigh(t, neigh)()
|
||||
|
||||
configured := []model.Device{
|
||||
// matched by MAC (its IP field is stale/empty on purpose)
|
||||
{Name: "Kid PC", MAC: "AA:BB:CC:DD:EE:FF", Enabled: true, Proxy: true, Target: "group:main", Block: []string{"x.example", "y.example"}},
|
||||
// matched by IP
|
||||
{Name: "Living-room TV", IP: "192.168.1.51", Enabled: true},
|
||||
// configured but never seen on the LAN -> appended offline
|
||||
{Name: "Away phone", MAC: "de:ad:be:ef:00:11", Enabled: true},
|
||||
}
|
||||
|
||||
rows := Discover(configured)
|
||||
|
||||
byIP := map[string]Discovered{}
|
||||
for _, r := range rows {
|
||||
if r.IP != "" {
|
||||
byIP[r.IP] = r
|
||||
}
|
||||
}
|
||||
|
||||
a := byIP["192.168.1.50"]
|
||||
if a.MAC != "aa:bb:cc:dd:ee:ff" || a.Hostname != "kidpc" {
|
||||
t.Fatalf("deviceA lease fields wrong: %+v", a)
|
||||
}
|
||||
if a.State != "online" || !a.Online {
|
||||
t.Fatalf("deviceA (REACHABLE) must be online, got %+v", a)
|
||||
}
|
||||
if !a.Configured || a.Name != "Kid PC" || !a.Proxy || a.Target != "group:main" || a.BlockCount != 2 {
|
||||
t.Fatalf("deviceA cross-ref (by MAC) wrong: %+v", a)
|
||||
}
|
||||
|
||||
b := byIP["192.168.1.51"]
|
||||
if b.State != "idle" || !b.Online {
|
||||
t.Fatalf("deviceB (STALE) must be idle+online, got %+v", b)
|
||||
}
|
||||
if !b.Configured || b.Name != "Living-room TV" {
|
||||
t.Fatalf("deviceB cross-ref (by IP) wrong: %+v", b)
|
||||
}
|
||||
|
||||
c := byIP["192.168.1.99"]
|
||||
if c.MAC != "77:88:99:aa:bb:cc" || c.State != "online" || c.Configured {
|
||||
t.Fatalf("neigh-only host wrong: %+v", c)
|
||||
}
|
||||
|
||||
// The away phone (configured, unseen) must appear as an offline row.
|
||||
var away *Discovered
|
||||
for i := range rows {
|
||||
if rows[i].Name == "Away phone" {
|
||||
away = &rows[i]
|
||||
}
|
||||
}
|
||||
if away == nil {
|
||||
t.Fatalf("configured-but-unseen device not appended; rows=%+v", rows)
|
||||
}
|
||||
if away.Online || away.State != "offline" || !away.Configured {
|
||||
t.Fatalf("away phone must be an offline configured row, got %+v", *away)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMACToIP proves the generate-side lease resolver picks the current IP.
|
||||
func TestMACToIP(t *testing.T) {
|
||||
p := writeLeases(t, "1 AA:BB:CC:DD:EE:FF 192.168.1.50 host *\n")
|
||||
got := MACToIP(p)
|
||||
if got["aa:bb:cc:dd:ee:ff"] != "192.168.1.50" {
|
||||
t.Fatalf("MACToIP lower-cased lookup failed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverNoSignals proves discovery degrades to an empty (non-nil) slice
|
||||
// when there is no lease file and `ip neigh` fails.
|
||||
func TestDiscoverNoSignals(t *testing.T) {
|
||||
LeasesPath = filepath.Join(t.TempDir(), "absent.leases")
|
||||
orig := execCommand
|
||||
execCommand = func(string, ...string) *exec.Cmd {
|
||||
return exec.Command(os.Args[0], "-test.run=NoSuchTestXYZ", "nonexistent-binary-xyz")
|
||||
}
|
||||
defer func() { execCommand = orig }()
|
||||
// Force the fake to fail: point at a bogus binary path.
|
||||
execCommand = func(string, ...string) *exec.Cmd { return exec.Command("this-binary-does-not-exist-xyz") }
|
||||
|
||||
rows := Discover(nil)
|
||||
if rows == nil {
|
||||
t.Fatalf("Discover must never return nil")
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("no signals must yield no rows, got %+v", rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package generate
|
||||
|
||||
// Phase 6 — per-device (parental) control. A `config device` resolves to a
|
||||
// CURRENT source IP at generate time (its explicit IP, else a MAC->IP lookup in
|
||||
// the DHCP lease table) and is turned into device-scoped rules that WIN over the
|
||||
// network-wide rules because they carry the device's source IP as the match and
|
||||
// are emitted BEFORE the general rules:
|
||||
//
|
||||
// - routing (buildRoute): src=[deviceIP] -> the device's exit (Target when
|
||||
// Proxy+Target, the global default when Proxy with no Target, direct when
|
||||
// !Proxy). Emitted ahead of the general model rules so a device override
|
||||
// always wins first-match.
|
||||
// - DNS (buildDNS): per-device allow (source_ip_cidr + domain -> default
|
||||
// resolver) FIRST, then per-device block (source_ip_cidr + domain ->
|
||||
// predefined NXDOMAIN). These are independent of the global DNS-filter master
|
||||
// switch, so a device's Block list is NXDOMAIN for that device even with the
|
||||
// network-wide filter off; a device's Allow overrides both device and global
|
||||
// block rules (higher priority, terminal DNS route action).
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/devices"
|
||||
)
|
||||
|
||||
// resolvedDevice pairs an enabled model.Device with its resolved source CIDR.
|
||||
type resolvedDevice struct {
|
||||
name string
|
||||
proxy bool
|
||||
target string
|
||||
block []string
|
||||
allow []string
|
||||
cidr string // e.g. 192.168.1.50/32 (full-length mask)
|
||||
}
|
||||
|
||||
// devicesResolved memoizes the per-device IP resolution so buildRoute and
|
||||
// buildDNS share one DHCP-lease read and any skip-warnings fire only once.
|
||||
func (b *builder) devicesResolved() []resolvedDevice {
|
||||
if !b.resolvedDevsOK {
|
||||
b.resolvedDevs = b.resolveDevices()
|
||||
b.resolvedDevsOK = true
|
||||
}
|
||||
return b.resolvedDevs
|
||||
}
|
||||
|
||||
// resolveDevices resolves each ENABLED device to a current source CIDR: its
|
||||
// explicit IP if set, else a MAC->IP lookup in the DHCP lease table. Devices that
|
||||
// resolve to no IP (no explicit IP and MAC not currently leased) are warned and
|
||||
// skipped. The lease table is read at most once (lazily).
|
||||
func (b *builder) resolveDevices() []resolvedDevice {
|
||||
var macIP map[string]string // lazily loaded lease index
|
||||
var out []resolvedDevice
|
||||
for _, d := range b.m.Devices {
|
||||
if !d.Enabled {
|
||||
continue
|
||||
}
|
||||
ip := strings.TrimSpace(d.IP)
|
||||
if ip == "" && strings.TrimSpace(d.MAC) != "" {
|
||||
if macIP == nil {
|
||||
macIP = devices.MACToIP(devices.LeasesPath)
|
||||
}
|
||||
ip = macIP[strings.ToLower(strings.TrimSpace(d.MAC))]
|
||||
}
|
||||
cidr, ok := cidrForIP(ip)
|
||||
if !ok {
|
||||
b.warnf("device %q: no current IP (ip unset and MAC %q not leased), skipped", d.Name, d.MAC)
|
||||
continue
|
||||
}
|
||||
out = append(out, resolvedDevice{
|
||||
name: d.Name, proxy: d.Proxy, target: d.Target,
|
||||
block: d.Block, allow: d.Allow, cidr: cidr,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cidrForIP normalises a device IP to a full-length source CIDR (/32 or /128) so
|
||||
// it is a valid sing-box source_ip_cidr entry. An already-CIDR value is validated
|
||||
// and passed through. Returns ok=false for empty/unparseable input.
|
||||
func cidrForIP(ip string) (string, bool) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.Contains(ip, "/") {
|
||||
if _, err := netip.ParsePrefix(ip); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return ip, true
|
||||
}
|
||||
a, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return netip.PrefixFrom(a, a.BitLen()).String(), true
|
||||
}
|
||||
|
||||
// deviceRouteRules builds the per-device routing rules, in device order. Each is
|
||||
// src=[deviceCIDR] -> the device's resolved outbound tag. defaultTarget is the
|
||||
// resolved global default (route Final) used for a Proxy device with no Target.
|
||||
// A device whose explicit Target cannot be resolved is warned and skipped.
|
||||
func (b *builder) deviceRouteRules(defaultTarget string) []option.Rule {
|
||||
var rules []option.Rule
|
||||
for _, rd := range b.devicesResolved() {
|
||||
var target string
|
||||
switch {
|
||||
case !rd.proxy:
|
||||
target = tagDirect
|
||||
case strings.TrimSpace(rd.target) != "":
|
||||
t, ok := b.resolveTarget(rd.target)
|
||||
if !ok {
|
||||
b.warnf("device %q: unresolved target %q, routing rule skipped", rd.name, rd.target)
|
||||
continue
|
||||
}
|
||||
target = t
|
||||
default:
|
||||
target = defaultTarget // proxy via the global default
|
||||
}
|
||||
route := option.RouteActionOptions{Outbound: target}
|
||||
b.applyDPI(&route, target, "device:"+rd.name)
|
||||
rules = append(rules, option.Rule{
|
||||
Type: C.RuleTypeDefault,
|
||||
DefaultOptions: option.DefaultRule{
|
||||
RawDefaultRule: option.RawDefaultRule{
|
||||
SourceIPCIDR: badoption.Listable[string]{rd.cidr},
|
||||
},
|
||||
RuleAction: option.RuleAction{
|
||||
Action: C.RuleActionTypeRoute,
|
||||
RouteOptions: route,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// deviceDNSRules builds the per-device DNS rules: for each device with an Allow
|
||||
// and/or Block list, an allow rule FIRST (source_ip_cidr + domain -> the default
|
||||
// resolver `final`, terminal so it overrides every block below) then a block rule
|
||||
// (source_ip_cidr + domain -> predefined NXDOMAIN). Emitted regardless of the
|
||||
// global DNS-filter switch. Nothing is emitted for a device with neither list.
|
||||
func (b *builder) deviceDNSRules(final string) []option.DNSRule {
|
||||
var rules []option.DNSRule
|
||||
for _, rd := range b.devicesResolved() {
|
||||
if raw, ok := deviceDomainMatch(rd.allow); ok {
|
||||
raw.SourceIPCIDR = badoption.Listable[string]{rd.cidr}
|
||||
rules = append(rules, option.DNSRule{
|
||||
Type: C.RuleTypeDefault,
|
||||
DefaultOptions: option.DefaultDNSRule{
|
||||
RawDefaultDNSRule: raw,
|
||||
DNSRuleAction: option.DNSRuleAction{
|
||||
Action: C.RuleActionTypeRoute,
|
||||
RouteOptions: option.DNSRouteActionOptions{Server: final},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if raw, ok := deviceDomainMatch(rd.block); ok {
|
||||
raw.SourceIPCIDR = badoption.Listable[string]{rd.cidr}
|
||||
rules = append(rules, option.DNSRule{
|
||||
Type: C.RuleTypeDefault,
|
||||
DefaultOptions: option.DefaultDNSRule{
|
||||
RawDefaultDNSRule: raw,
|
||||
DNSRuleAction: predefinedNXDOMAIN(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// anyDeviceDNS reports whether any resolved device carries a block/allow list —
|
||||
// used to warn when the DNS plane is unavailable (no resolvers) so per-device
|
||||
// filtering would be inert.
|
||||
func (b *builder) anyDeviceDNS() bool {
|
||||
for _, rd := range b.devicesResolved() {
|
||||
if len(rd.block) > 0 || len(rd.allow) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deviceDomainMatch classifies a device's block/allow entries into a DNS-rule
|
||||
// domain matcher. As with the D15 filter, a BARE entry is a DomainSuffix (covers
|
||||
// subdomains); "full:" forces an exact Domain, "." a suffix, "keyword:" a
|
||||
// substring. Returns ok=false when nothing usable remains.
|
||||
func deviceDomainMatch(entries []string) (option.RawDefaultDNSRule, bool) {
|
||||
var domain, suffix, keyword []string
|
||||
for _, e := range entries {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(e, "keyword:"):
|
||||
keyword = append(keyword, strings.TrimPrefix(e, "keyword:"))
|
||||
case strings.HasPrefix(e, "full:"):
|
||||
domain = append(domain, strings.TrimPrefix(e, "full:"))
|
||||
case strings.HasPrefix(e, "."):
|
||||
suffix = append(suffix, strings.TrimPrefix(e, "."))
|
||||
default:
|
||||
suffix = append(suffix, e) // bare => suffix (covers subdomains)
|
||||
}
|
||||
}
|
||||
if len(domain)+len(suffix)+len(keyword) == 0 {
|
||||
return option.RawDefaultDNSRule{}, false
|
||||
}
|
||||
var raw option.RawDefaultDNSRule
|
||||
if len(domain) > 0 {
|
||||
raw.Domain = badoption.Listable[string](domain)
|
||||
}
|
||||
if len(suffix) > 0 {
|
||||
raw.DomainSuffix = badoption.Listable[string](suffix)
|
||||
}
|
||||
if len(keyword) > 0 {
|
||||
raw.DomainKeyword = badoption.Listable[string](keyword)
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// baseDeviceModel is a tproxy+resolver model with the given devices, enough to
|
||||
// exercise the per-device routing + DNS codegen without any global DNS filter.
|
||||
func baseDeviceModel(devs ...model.Device) *model.Model {
|
||||
g := model.DefaultGlobals()
|
||||
g.ResolverDefault = "cf"
|
||||
return &model.Model{
|
||||
Globals: g,
|
||||
Inbounds: []model.Inbound{
|
||||
{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12395, TCP: true, UDP: true},
|
||||
},
|
||||
Resolvers: []model.Resolver{
|
||||
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
|
||||
},
|
||||
Devices: devs,
|
||||
}
|
||||
}
|
||||
|
||||
// routeRuleForSrc returns the first route rule whose SourceIPCIDR contains cidr.
|
||||
func routeRuleForSrc(opts option.Options, cidr string) (option.DefaultRule, bool) {
|
||||
if opts.Route == nil {
|
||||
return option.DefaultRule{}, false
|
||||
}
|
||||
for _, r := range opts.Route.Rules {
|
||||
for _, c := range r.DefaultOptions.SourceIPCIDR {
|
||||
if c == cidr {
|
||||
return r.DefaultOptions, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return option.DefaultRule{}, false
|
||||
}
|
||||
|
||||
// dnsRuleForSrc returns the first DNS rule whose SourceIPCIDR contains cidr.
|
||||
func dnsRuleForSrc(opts option.Options, cidr string) (option.DefaultDNSRule, bool) {
|
||||
if opts.DNS == nil {
|
||||
return option.DefaultDNSRule{}, false
|
||||
}
|
||||
for _, r := range opts.DNS.Rules {
|
||||
for _, c := range r.DefaultOptions.SourceIPCIDR {
|
||||
if c == cidr {
|
||||
return r.DefaultOptions, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return option.DefaultDNSRule{}, false
|
||||
}
|
||||
|
||||
// TestDevicePerDeviceRoutingAndDNS is the Phase-6 DoD codegen case: a device with
|
||||
// an explicit IP + Block (proxied via direct) produces a device-scoped ROUTE rule
|
||||
// (src=IP -> direct) AND a device-scoped DNS block rule (source_ip_cidr=IP +
|
||||
// domain_suffix -> predefined NXDOMAIN). A second device (different IP, no block)
|
||||
// is device-scoped in routing but carries NO DNS rule. A DISABLED device emits
|
||||
// nothing at all.
|
||||
func TestDevicePerDeviceRoutingAndDNS(t *testing.T) {
|
||||
const ipA = "192.168.1.50/32"
|
||||
const ipB = "192.168.1.51/32"
|
||||
const ipOff = "192.168.1.52/32"
|
||||
|
||||
m := baseDeviceModel(
|
||||
model.Device{
|
||||
Name: "kid", Enabled: true, IP: "192.168.1.50",
|
||||
Proxy: true, Target: "direct",
|
||||
Block: []string{"blocked-for-a.example"},
|
||||
},
|
||||
model.Device{
|
||||
Name: "tv", Enabled: true, IP: "192.168.1.51", Proxy: false,
|
||||
},
|
||||
model.Device{
|
||||
Name: "off", Enabled: false, IP: "192.168.1.52",
|
||||
Block: []string{"nope.example"},
|
||||
},
|
||||
)
|
||||
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warns)
|
||||
}
|
||||
|
||||
// deviceA: routing rule src=.50 -> direct.
|
||||
ra, ok := routeRuleForSrc(opts, ipA)
|
||||
if !ok {
|
||||
t.Fatalf("no route rule for %s; rules=%+v", ipA, opts.Route.Rules)
|
||||
}
|
||||
if ra.RuleAction.Action != C.RuleActionTypeRoute || ra.RuleAction.RouteOptions.Outbound != tagDirect {
|
||||
t.Fatalf("device A route must be route->direct, got %+v", ra.RuleAction)
|
||||
}
|
||||
|
||||
// deviceA: DNS block rule src=.50, domain_suffix blocked-for-a.example, NXDOMAIN.
|
||||
da, ok := dnsRuleForSrc(opts, ipA)
|
||||
if !ok {
|
||||
t.Fatalf("no DNS rule for %s; rules=%+v", ipA, opts.DNS.Rules)
|
||||
}
|
||||
if da.DNSRuleAction.Action != C.RuleActionTypePredefined {
|
||||
t.Fatalf("device A DNS rule must be predefined, got %+v", da.DNSRuleAction)
|
||||
}
|
||||
if da.DNSRuleAction.PredefinedOptions.Rcode == nil ||
|
||||
int(*da.DNSRuleAction.PredefinedOptions.Rcode) != dns.RcodeNameError {
|
||||
t.Fatalf("device A DNS block must be NXDOMAIN(3), got %+v", da.DNSRuleAction.PredefinedOptions)
|
||||
}
|
||||
if len(da.DomainSuffix) != 1 || da.DomainSuffix[0] != "blocked-for-a.example" {
|
||||
t.Fatalf("device A DNS block must match domain_suffix blocked-for-a.example, got %+v", da.DomainSuffix)
|
||||
}
|
||||
|
||||
// deviceB: device-scoped routing (src=.51 -> direct because !proxy) but NO DNS rule.
|
||||
rb, ok := routeRuleForSrc(opts, ipB)
|
||||
if !ok {
|
||||
t.Fatalf("no route rule for %s (deviceB should be device-scoped)", ipB)
|
||||
}
|
||||
if rb.RuleAction.RouteOptions.Outbound != tagDirect {
|
||||
t.Fatalf("device B (!proxy) must route direct, got %+v", rb.RuleAction)
|
||||
}
|
||||
if _, ok := dnsRuleForSrc(opts, ipB); ok {
|
||||
t.Fatalf("device B has no block/allow; must carry no DNS rule")
|
||||
}
|
||||
|
||||
// off device: nothing whatsoever.
|
||||
if _, ok := routeRuleForSrc(opts, ipOff); ok {
|
||||
t.Fatalf("disabled device must emit no route rule")
|
||||
}
|
||||
if _, ok := dnsRuleForSrc(opts, ipOff); ok {
|
||||
t.Fatalf("disabled device must emit no DNS rule")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceProxyNoTargetUsesGlobalDefault proves a Proxy device with no Target
|
||||
// routes to the resolved global default (route Final). With a catch-all default
|
||||
// rule -> group, Final becomes that group and the device rule follows it.
|
||||
func TestDeviceProxyNoTargetUsesGlobalDefault(t *testing.T) {
|
||||
m := baseDeviceModel(
|
||||
model.Device{Name: "kid", Enabled: true, IP: "192.168.1.60", Proxy: true},
|
||||
)
|
||||
// A matcher-less rule -> direct makes the global default "direct".
|
||||
m.Rules = []model.Rule{{Name: "default", Enabled: true, Target: "direct"}}
|
||||
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warns)
|
||||
}
|
||||
if opts.Route.Final != tagDirect {
|
||||
t.Fatalf("Final must be direct (catch-all), got %q", opts.Route.Final)
|
||||
}
|
||||
r, ok := routeRuleForSrc(opts, "192.168.1.60/32")
|
||||
if !ok {
|
||||
t.Fatalf("no route rule for proxy-no-target device")
|
||||
}
|
||||
if r.RuleAction.RouteOptions.Outbound != tagDirect {
|
||||
t.Fatalf("proxy-no-target device must route via global default (direct), got %+v", r.RuleAction)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceAllowOverridesBlock proves the ordering guarantee: a device's Allow
|
||||
// rule is emitted BEFORE its Block rule (allow wins, terminal DNS route action).
|
||||
func TestDeviceAllowOverridesBlock(t *testing.T) {
|
||||
m := baseDeviceModel(model.Device{
|
||||
Name: "kid", Enabled: true, IP: "192.168.1.70",
|
||||
Block: []string{"ads.example"},
|
||||
Allow: []string{"good.example"},
|
||||
})
|
||||
opts, _, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if opts.DNS == nil || len(opts.DNS.Rules) < 2 {
|
||||
t.Fatalf("expected >=2 device DNS rules (allow+block), got %+v", opts.DNS)
|
||||
}
|
||||
allow := opts.DNS.Rules[0].DefaultOptions
|
||||
if allow.DNSRuleAction.Action != C.RuleActionTypeRoute ||
|
||||
len(allow.DomainSuffix) != 1 || allow.DomainSuffix[0] != "good.example" {
|
||||
t.Fatalf("first device DNS rule must be the allow(good.example)->route, got %+v", allow)
|
||||
}
|
||||
block := opts.DNS.Rules[1].DefaultOptions
|
||||
if block.DNSRuleAction.Action != C.RuleActionTypePredefined ||
|
||||
len(block.DomainSuffix) != 1 || block.DomainSuffix[0] != "ads.example" {
|
||||
t.Fatalf("second device DNS rule must be the block(ads.example)->NXDOMAIN, got %+v", block)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceNoneEmitsNothing proves the guard: no devices (or none enabled) adds
|
||||
// no device route rules and no device DNS rules — identical to a pre-Phase-6 build.
|
||||
func TestDeviceNoneEmitsNothing(t *testing.T) {
|
||||
// No devices at all.
|
||||
m := baseDeviceModel()
|
||||
opts, _, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
// Only sniff + hijack-dns route rules remain (no device/general rules).
|
||||
if opts.Route == nil || len(opts.Route.Rules) != 2 {
|
||||
t.Fatalf("no-devices model must emit only sniff+hijackdns route rules, got %+v", opts.Route)
|
||||
}
|
||||
if opts.DNS != nil && len(opts.DNS.Rules) != 0 {
|
||||
t.Fatalf("no-devices model must emit no DNS rules, got %+v", opts.DNS.Rules)
|
||||
}
|
||||
|
||||
// A single DISABLED device: same result.
|
||||
m2 := baseDeviceModel(model.Device{Name: "off", Enabled: false, IP: "192.168.1.80", Block: []string{"x.example"}})
|
||||
opts2, _, err := GenerateWithWarnings(m2)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(opts2.Route.Rules) != 2 {
|
||||
t.Fatalf("disabled-only model must emit only sniff+hijackdns route rules, got %+v", opts2.Route.Rules)
|
||||
}
|
||||
if opts2.DNS != nil && len(opts2.DNS.Rules) != 0 {
|
||||
t.Fatalf("disabled-only model must emit no DNS rules, got %+v", opts2.DNS.Rules)
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -28,6 +28,9 @@ func (b *builder) buildDNS() *option.DNSOptions {
|
||||
if b.dnsFilterActive() {
|
||||
b.warnf("dns_filter enabled but no resolvers configured; filter inert (needs a resolver for the in-engine DNS plane)")
|
||||
}
|
||||
if b.anyDeviceDNS() {
|
||||
b.warnf("per-device DNS block/allow configured but no resolvers; inert (needs a resolver for the in-engine DNS plane)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,6 +48,9 @@ func (b *builder) buildDNS() *option.DNSOptions {
|
||||
if b.dnsFilterActive() {
|
||||
b.warnf("dns_filter enabled but no valid resolvers built; filter inert")
|
||||
}
|
||||
if b.anyDeviceDNS() {
|
||||
b.warnf("per-device DNS block/allow configured but no valid resolvers built; inert")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,10 +59,15 @@ func (b *builder) buildDNS() *option.DNSOptions {
|
||||
final = b.m.Resolvers[0].Name
|
||||
}
|
||||
|
||||
// D15: the allow/block filter rules sit FIRST so filtering happens before the
|
||||
// normal per-resolver DNS rules. Emits nothing when the filter is off or no
|
||||
// list is enabled (behaviour identical to today).
|
||||
rules := b.buildDNSFilterRules(final)
|
||||
// Phase-6 per-device DNS rules sit FIRST (ahead of the network-wide D15 filter)
|
||||
// so a device's allow/block wins for that source IP: allow overrides every
|
||||
// block, and a device block applies even when the global filter is off.
|
||||
rules := b.deviceDNSRules(final)
|
||||
|
||||
// D15: the allow/block filter rules follow so network-wide filtering happens
|
||||
// before the normal per-resolver DNS rules. Emits nothing when the filter is
|
||||
// off or no list is enabled (behaviour identical to today).
|
||||
rules = append(rules, b.buildDNSFilterRules(final)...)
|
||||
|
||||
for _, dr := range b.m.DNSRules {
|
||||
if !serverTags[dr.Resolver] {
|
||||
|
||||
@@ -116,6 +116,12 @@ type builder struct {
|
||||
// into Route.RuleSet (referenced by the reject/allow DNS rules).
|
||||
dnsFilterRuleSets []option.RuleSet
|
||||
|
||||
// resolvedDevs memoizes the Phase-6 per-device resolution (model.Device ->
|
||||
// current source CIDR) so buildRoute and buildDNS share one lease read and the
|
||||
// skip-warnings fire exactly once. Guarded by resolvedDevsOK.
|
||||
resolvedDevs []resolvedDevice
|
||||
resolvedDevsOK bool
|
||||
|
||||
warnings []string
|
||||
}
|
||||
|
||||
|
||||
@@ -22,19 +22,16 @@ import (
|
||||
// open => "direct". A catch-all model rule (no matchers) overrides Final
|
||||
// with its resolved target so a "default -> group/node" egress works.
|
||||
func (b *builder) buildRoute() *option.RouteOptions {
|
||||
// The leading sniff rule labels each connection's protocol; the hijack-dns
|
||||
// rule immediately after it steals every sniffed DNS query into the engine's
|
||||
// internal DNS resolver (D14). LAN :53 is diverted here by the netplane tproxy
|
||||
// catch-all (no nft :53 redirect); this is what actually answers it, routing
|
||||
// each query through its resolver's detour — the anti-leak.
|
||||
rules := []option.Rule{sniffRule(), hijackDNSRule()}
|
||||
|
||||
// Kill-switch default backstop.
|
||||
final := tagBlock
|
||||
if strings.EqualFold(strings.TrimSpace(b.m.Globals.KillSwitch), "open") {
|
||||
final = tagDirect
|
||||
}
|
||||
|
||||
// General model rules are collected separately so the Phase-6 device rules can
|
||||
// be spliced in AHEAD of them (device overrides win first-match) while still
|
||||
// being resolved against the final default target the catch-all loop computes.
|
||||
var general []option.Rule
|
||||
for _, i := range sortedRuleIndices(b.m.Rules) {
|
||||
r := b.m.Rules[i]
|
||||
if !r.Enabled {
|
||||
@@ -75,7 +72,7 @@ func (b *builder) buildRoute() *option.RouteOptions {
|
||||
|
||||
route := option.RouteActionOptions{Outbound: target}
|
||||
b.applyDPI(&route, target, r.Name)
|
||||
rules = append(rules, option.Rule{
|
||||
general = append(general, option.Rule{
|
||||
Type: C.RuleTypeDefault,
|
||||
DefaultOptions: option.DefaultRule{
|
||||
RawDefaultRule: raw,
|
||||
@@ -87,6 +84,17 @@ func (b *builder) buildRoute() *option.RouteOptions {
|
||||
})
|
||||
}
|
||||
|
||||
// Assemble: the leading sniff rule labels each connection's protocol; the
|
||||
// hijack-dns rule immediately after it steals every sniffed DNS query into the
|
||||
// engine's internal DNS resolver (D14). LAN :53 is diverted here by the
|
||||
// netplane tproxy catch-all (no nft :53 redirect); this is what actually
|
||||
// answers it, routing each query through its resolver's detour — the anti-leak.
|
||||
// Phase-6 device rules follow (device overrides win), then the general rules;
|
||||
// device rules resolving a Proxy-with-no-Target use the computed default.
|
||||
rules := []option.Rule{sniffRule(), hijackDNSRule()}
|
||||
rules = append(rules, b.deviceRouteRules(final)...)
|
||||
rules = append(rules, general...)
|
||||
|
||||
return &option.RouteOptions{
|
||||
Rules: rules,
|
||||
Final: final,
|
||||
|
||||
@@ -31,6 +31,7 @@ type Model struct {
|
||||
DNSRules []DNSRule
|
||||
Blocklists []Blocklist
|
||||
Allowlists []Allowlist
|
||||
Devices []Device
|
||||
}
|
||||
|
||||
// Globals is the single `config globals` section.
|
||||
@@ -339,3 +340,27 @@ type Allowlist struct {
|
||||
Path string // file source
|
||||
Entries []string // inline source
|
||||
}
|
||||
|
||||
// Device is a `config device` — a per-device (parental) control entry (Phase 6).
|
||||
// A device is identified by MAC preferentially, else by IP; the generator resolves
|
||||
// it to a CURRENT source IP at gen time (explicit IP, else MAC->IP via the DHCP
|
||||
// lease table) and emits device-scoped routing + DNS rules that win over the
|
||||
// network-wide rules (they carry the device's source IP as the match).
|
||||
//
|
||||
// Proxy — route this device through the proxy (true) vs direct (false).
|
||||
// Target — the exit when Proxy: node:|group:|chain:|egress:|direct|block;
|
||||
// empty = the global default target (the route Final / catch-all).
|
||||
// Block — domains blocked (NXDOMAIN) for THIS device only, regardless of the
|
||||
// global DNS-filter master switch.
|
||||
// Allow — domains allowed for this device, overriding any blocklist (emitted
|
||||
// with higher priority than the block rules, device and global).
|
||||
type Device struct {
|
||||
Name string
|
||||
MAC string // identity (preferred); may be ""
|
||||
IP string // identity / current address; may be ""
|
||||
Enabled bool
|
||||
Proxy bool
|
||||
Target string
|
||||
Block []string
|
||||
Allow []string
|
||||
}
|
||||
|
||||
@@ -252,6 +252,18 @@ func RenderUCIExport(m *Model) string {
|
||||
w.listOpt("entry", al.Entries)
|
||||
}
|
||||
|
||||
for _, d := range m.Devices {
|
||||
w.startAnon("device")
|
||||
w.strOpt("name", d.Name)
|
||||
w.strOpt("mac", d.MAC)
|
||||
w.strOpt("ip", d.IP)
|
||||
w.boolOpt("enabled", d.Enabled)
|
||||
w.boolOpt("proxy", d.Proxy)
|
||||
w.strOpt("target", d.Target)
|
||||
w.listOpt("block", d.Block)
|
||||
w.listOpt("allow", d.Allow)
|
||||
}
|
||||
|
||||
return w.b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,19 @@ func richModel() *Model {
|
||||
Name: "safe", Enabled: true, Source: "inline",
|
||||
Entries: []string{"good.example", "cdn.example.com"},
|
||||
}},
|
||||
Devices: []Device{
|
||||
{
|
||||
// identified by MAC, proxied via a named target, per-device block+allow.
|
||||
Name: "kid-laptop", MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.50",
|
||||
Enabled: true, Proxy: true, Target: "group:main",
|
||||
Block: []string{"blocked.example", ".ads.example"},
|
||||
Allow: []string{"good.example"},
|
||||
},
|
||||
{
|
||||
// direct (bypass proxy), identified by IP only, disabled, embedded quote.
|
||||
Name: "guest'phone", IP: "192.168.1.51", Enabled: false, Proxy: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,17 @@ func ParseUCIExport(text string) (*Model, error) {
|
||||
Path: s.opt("path"),
|
||||
Entries: s.list("entry"),
|
||||
})
|
||||
case "device":
|
||||
m.Devices = append(m.Devices, Device{
|
||||
Name: firstNonEmpty(s.opt("name"), s.Name),
|
||||
MAC: s.opt("mac"),
|
||||
IP: s.opt("ip"),
|
||||
Enabled: s.optBool("enabled", true),
|
||||
Proxy: s.optBool("proxy", false),
|
||||
Target: s.opt("target"),
|
||||
Block: s.list("block"),
|
||||
Allow: s.list("allow"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/shater/apply"
|
||||
"github.com/sagernet/sing-box/shater/devices"
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
"github.com/sagernet/sing-box/shater/stats"
|
||||
)
|
||||
@@ -192,6 +193,28 @@ func (s *Server) handleStatsLog(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// handleDevices → GET /api/devices: the Phase-6 device-discovery list. Merges the
|
||||
// DHCP lease table with `ip neigh` and cross-references the configured
|
||||
// Model.Devices (matched by MAC or IP) so each row carries {ip, mac, hostname,
|
||||
// online, state, configured, name, proxy, target, blockCount}. Reading the
|
||||
// configured devices is best-effort: if the UCI read fails, discovery still
|
||||
// returns the live hosts (all with configured=false). Always a JSON array.
|
||||
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
var configured []model.Device
|
||||
if m, err := model.ReadUCI(); err == nil {
|
||||
configured = m.Devices
|
||||
}
|
||||
rows := devices.Discover(configured)
|
||||
if rows == nil {
|
||||
rows = []devices.Discovered{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// --- mutating endpoints -----------------------------------------------------
|
||||
|
||||
// applyResponse mirrors the control socket's {changed,error} result.
|
||||
|
||||
@@ -173,6 +173,7 @@ func (s *Server) buildRouter() http.Handler {
|
||||
mux.Handle("/api/rollback", s.requireSession(http.HandlerFunc(s.handleRollback)))
|
||||
mux.Handle("/api/stats", s.requireSession(http.HandlerFunc(s.handleStats)))
|
||||
mux.Handle("/api/stats/log", s.requireSession(http.HandlerFunc(s.handleStatsLog)))
|
||||
mux.Handle("/api/devices", s.requireSession(http.HandlerFunc(s.handleDevices)))
|
||||
|
||||
// Any other /api/* path is an unknown endpoint → JSON 404 (NOT the SPA).
|
||||
mux.Handle("/api/", s.requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user