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:
2026-07-15 20:38:07 +03:00
co-authored by Claude Opus 4.8
parent a59d2c0d15
commit 677a1dde37
13 changed files with 985 additions and 12 deletions
+263
View File
@@ -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
}
}
+149
View File
@@ -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)
}
}
+225
View File
@@ -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
}
+226
View File
@@ -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
View File
@@ -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] {
+6
View File
@@ -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
}
+16 -8
View File
@@ -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,
+25
View File
@@ -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
}
+12
View File
@@ -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()
}
+13
View File
@@ -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,
},
},
}
}
+11
View File
@@ -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
+23
View File
@@ -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.
+1
View File
@@ -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) {