diff --git a/CHANGELOG.md b/CHANGELOG.md index a5cca836576..48aa24d7eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to ### Changed +- Improved detection of runtime clients through more resilient ARP processing + ([#3597]). - The TTL of responses served from the optimistic cache is now lowered to 10 seconds. - Domain-specific private reverse DNS upstream servers are now validated to @@ -89,6 +91,7 @@ In this release, the schema version has changed from 12 to 13. [#3367]: https://github.com/AdguardTeam/AdGuardHome/issues/3367 [#3381]: https://github.com/AdguardTeam/AdGuardHome/issues/3381 [#3503]: https://github.com/AdguardTeam/AdGuardHome/issues/3503 +[#3597]: https://github.com/AdguardTeam/AdGuardHome/issues/3597 [#3978]: https://github.com/AdguardTeam/AdGuardHome/issues/3978 [#4166]: https://github.com/AdguardTeam/AdGuardHome/issues/4166 [#4213]: https://github.com/AdguardTeam/AdGuardHome/issues/4213 diff --git a/internal/aghnet/arpdb.go b/internal/aghnet/arpdb.go new file mode 100644 index 00000000000..759a688afc9 --- /dev/null +++ b/internal/aghnet/arpdb.go @@ -0,0 +1,239 @@ +package aghnet + +import ( + "bufio" + "fmt" + "io" + "net" + "strings" + "sync" + + "github.com/AdguardTeam/AdGuardHome/internal/aghos" + "github.com/AdguardTeam/golibs/errors" + "github.com/AdguardTeam/golibs/netutil" +) + +// ARPDB: The Network Neighborhood Database + +// ARPDB stores and refreshes the network neighborhood reported by ARP (Address +// Resolution Protocol). +type ARPDB interface { + // Refresh updates the stored data. It must be safe for concurrent use. + Refresh() (err error) + + // Neighbors returnes the last set of data reported by ARP. Both the method + // and it's result must be safe for concurrent use. + Neighbors() (ns []Neighbor) +} + +// NewARPDB returns the ARPDB properly initialized for the OS. +func NewARPDB() (arp ARPDB, err error) { + arp = newARPDB() + + err = arp.Refresh() + if err != nil { + return nil, fmt.Errorf("arpdb initial refresh: %w", err) + } + + return arp, nil +} + +// Empty ARPDB implementation + +// EmptyARPDB is the ARPDB implementation that does nothing. +type EmptyARPDB struct{} + +// type check +var _ ARPDB = EmptyARPDB{} + +// Refresh implements the ARPDB interface for EmptyARPContainer. It does +// nothing and always returns nil error. +func (EmptyARPDB) Refresh() (err error) { return nil } + +// Neighbors implements the ARPDB interface for EmptyARPContainer. It always +// returns nil. +func (EmptyARPDB) Neighbors() (ns []Neighbor) { return nil } + +// ARPDB Helper Types + +// Neighbor is the pair of IP address and MAC address reported by ARP. +type Neighbor struct { + // Name is the hostname of the neighbor. Empty name is valid since not each + // implementation of ARP is able to retrieve that. + Name string + + // IP contains either IPv4 or IPv6. + IP net.IP + + // MAC contains the hardware address. + MAC net.HardwareAddr +} + +// Clone returns the deep copy of n. +func (n Neighbor) Clone() (clone Neighbor) { + return Neighbor{ + Name: n.Name, + IP: netutil.CloneIP(n.IP), + MAC: netutil.CloneMAC(n.MAC), + } +} + +// neighs is the helper type that stores neighbors to avoid copying its methods +// among all the ARPDB implementations. +type neighs struct { + mu *sync.RWMutex + ns []Neighbor +} + +// len returns the length of the neighbors slice. It's safe for concurrent use. +func (ns *neighs) len() (l int) { + ns.mu.RLock() + defer ns.mu.RUnlock() + + return len(ns.ns) +} + +// clone returns a deep copy of the underlying neighbors slice. It's safe for +// concurrent use. +func (ns *neighs) clone() (cloned []Neighbor) { + ns.mu.RLock() + defer ns.mu.RUnlock() + + cloned = make([]Neighbor, len(ns.ns)) + for i, n := range ns.ns { + cloned[i] = n.Clone() + } + + return cloned +} + +// reset replaces the underlying slice with the new one. It's safe for +// concurrent use. +func (ns *neighs) reset(with []Neighbor) { + ns.mu.Lock() + defer ns.mu.Unlock() + + ns.ns = with +} + +// Command ARPDB + +// parseNeighsFunc parses the text from sc as if it'd be an output of some +// ARP-related command. lenHint is a hint for the size of the allocated slice +// of Neighbors. +type parseNeighsFunc func(sc *bufio.Scanner, lenHint int) (ns []Neighbor) + +// runCmdFunc is the function that runs some command and returns its output +// wrapped to be a io.Reader. +type runCmdFunc func() (r io.Reader, err error) + +// cmdARPDB is the implementation of the ARPDB that uses command line to +// retrieve data. +type cmdARPDB struct { + parse parseNeighsFunc + runcmd runCmdFunc + ns *neighs +} + +// type check +var _ ARPDB = (*cmdARPDB)(nil) + +// runCmd runs the cmd with it's args and returns the result wrapped to be an +// io.Reader. The error is returned either if the exit code retured by command +// not equals 0 or the execution itself failed. +func runCmd(cmd string, args ...string) (r io.Reader, err error) { + var code int + var out string + code, out, err = aghos.RunCommand(cmd, args...) + if err != nil { + return nil, err + } else if code != 0 { + return nil, fmt.Errorf("unexpected exit code %d", code) + } + + return strings.NewReader(out), nil +} + +// Refresh implements the ARPDB interface for *cmdARPDB. +func (arp *cmdARPDB) Refresh() (err error) { + defer func() { err = errors.Annotate(err, "cmd arpdb: %w") }() + + var r io.Reader + r, err = arp.runcmd() + if err != nil { + return fmt.Errorf("running command: %w", err) + } + + sc := bufio.NewScanner(r) + ns := arp.parse(sc, arp.ns.len()) + if err = sc.Err(); err != nil { + return fmt.Errorf("scanning the output: %w", err) + } + + arp.ns.reset(ns) + + return nil +} + +// Neighbors implements the ARPDB interface for *cmdARPDB. +func (arp *cmdARPDB) Neighbors() (ns []Neighbor) { + return arp.ns.clone() +} + +// Composite ARPDB + +// arpdbs is the ARPDB that combines several ARPDB implementations and +// consequently switches between those. +type arpdbs struct { + // arps is the set of ARPDB implementations to range through. + arps []ARPDB + // last is the last succeeded ARPDB index. + last int +} + +// newARPDBs returns a properly initialized *arpdbs. It begins refreshing from +// the first of arps. +func newARPDBs(arps ...ARPDB) (arp *arpdbs) { + return &arpdbs{ + arps: arps, + last: 0, + } +} + +// type check +var _ ARPDB = (*arpdbs)(nil) + +// Refresh implements the ARPDB interface for *arpdbs. +func (arp *arpdbs) Refresh() (err error) { + var errs []error + l := len(arp.arps) + // Start from the last succeeded implementation. + for i := 0; i < l; i++ { + cur := (arp.last + i) % l + err = arp.arps[cur].Refresh() + if err == nil { + // The succeeded implementation found so update the last succeeded + // index. + arp.last = cur + + return nil + } + + errs = append(errs, err) + } + + if len(errs) > 0 { + err = errors.List("each arpdb failed", errs...) + } + + return err +} + +// Neighbors implements the ARPDB interface for *arpdbs. +func (arp *arpdbs) Neighbors() (ns []Neighbor) { + if l := len(arp.arps); l > 0 && arp.last < l { + return arp.arps[arp.last].Neighbors() + } + + return nil +} diff --git a/internal/aghnet/arpdb_bsd.go b/internal/aghnet/arpdb_bsd.go new file mode 100644 index 00000000000..fe00418a63d --- /dev/null +++ b/internal/aghnet/arpdb_bsd.go @@ -0,0 +1,70 @@ +//go:build darwin || freebsd +// +build darwin freebsd + +package aghnet + +import ( + "bufio" + "net" + "strings" + "sync" + + "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/golibs/netutil" +) + +func newARPDB() *cmdARPDB { + return &cmdARPDB{ + parse: parseArpA, + runcmd: rcArpA, + ns: &neighs{ + mu: &sync.RWMutex{}, + ns: make([]Neighbor, 0), + }, + } +} + +// parseArpA parses the output of the "arp -a" command on macOS and FreeBSD. +// The expected input format: +// +// host.name (192.168.0.1) at ff:ff:ff:ff:ff:ff on en0 ifscope [ethernet] +// +func parseArpA(sc *bufio.Scanner, lenHint int) (ns []Neighbor) { + ns = make([]Neighbor, 0, lenHint) + for sc.Scan() { + ln := sc.Text() + + fields := strings.Fields(ln) + if len(fields) < 4 { + continue + } + + n := Neighbor{} + + if ipStr := fields[1]; len(ipStr) < 2 { + continue + } else if ip := net.ParseIP(ipStr[1 : len(ipStr)-1]); ip == nil { + continue + } else { + n.IP = ip + } + + hwStr := fields[3] + if mac, err := net.ParseMAC(hwStr); err != nil { + continue + } else { + n.MAC = mac + } + + host := fields[0] + if err := netutil.ValidateDomainName(host); err != nil { + log.Debug("parsing arp output: %s", err) + } else { + n.Name = host + } + + ns = append(ns, n) + } + + return ns +} diff --git a/internal/aghnet/arpdb_bsd_test.go b/internal/aghnet/arpdb_bsd_test.go new file mode 100644 index 00000000000..bbadc6006b2 --- /dev/null +++ b/internal/aghnet/arpdb_bsd_test.go @@ -0,0 +1,23 @@ +//go:build darwin || freebsd +// +build darwin freebsd + +package aghnet + +import ( + "net" +) + +const arpAOutput = ` +hostname.one (192.168.1.2) at ab:cd:ef:ab:cd:ef on en0 ifscope [ethernet] +hostname.two (::ffff:ffff) at ef:cd:ab:ef:cd:ab on em0 expires in 1198 seconds [ethernet] +` + +var wantNeighs = []Neighbor{{ + Name: "hostname.one", + IP: net.IPv4(192, 168, 1, 2), + MAC: net.HardwareAddr{0xAB, 0xCD, 0xEF, 0xAB, 0xCD, 0xEF}, +}, { + Name: "hostname.two", + IP: net.ParseIP("::ffff:ffff"), + MAC: net.HardwareAddr{0xEF, 0xCD, 0xAB, 0xEF, 0xCD, 0xAB}, +}} diff --git a/internal/aghnet/arpdb_linux.go b/internal/aghnet/arpdb_linux.go new file mode 100644 index 00000000000..976d8b7ae56 --- /dev/null +++ b/internal/aghnet/arpdb_linux.go @@ -0,0 +1,230 @@ +//go:build linux +// +build linux + +package aghnet + +import ( + "bufio" + "fmt" + "io" + "io/fs" + "net" + "strings" + "sync" + + "github.com/AdguardTeam/AdGuardHome/internal/aghos" + "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/golibs/netutil" + "github.com/AdguardTeam/golibs/stringutil" +) + +func newARPDB() (arp *arpdbs) { + // Use the common storage among the implementations. + ns := &neighs{ + mu: &sync.RWMutex{}, + ns: make([]Neighbor, 0), + } + + var parseF parseNeighsFunc + if aghos.IsOpenWrt() { + parseF = parseArpAWrt + } else { + parseF = parseArpA + } + + return newARPDBs( + // Try /proc/net/arp first. + &fsysARPDB{ns: ns, fsys: aghos.RootDirFS(), filename: "proc/net/arp"}, + // Try "arp -a" then. + &cmdARPDB{parse: parseF, runcmd: rcArpA, ns: ns}, + // Try "ip neigh" finally. + &cmdARPDB{parse: parseIPNeigh, runcmd: rcIPNeigh, ns: ns}, + ) +} + +// fsysARPDB accesses the ARP cache file to update the database. +type fsysARPDB struct { + ns *neighs + fsys fs.FS + filename string +} + +// type check +var _ ARPDB = (*fsysARPDB)(nil) + +// Refresh implements the ARPDB interface for *fsysARPDB. +func (arp *fsysARPDB) Refresh() (err error) { + var f fs.File + f, err = arp.fsys.Open(arp.filename) + if err != nil { + return fmt.Errorf("opening %q: %w", arp.filename, err) + } + + sc := bufio.NewScanner(f) + // Skip the header. + if !sc.Scan() { + return nil + } else if err = sc.Err(); err != nil { + return err + } + + ns := make([]Neighbor, 0, arp.ns.len()) + for sc.Scan() { + ln := sc.Text() + fields := stringutil.SplitTrimmed(ln, " ") + if len(fields) != 6 { + continue + } + + n := Neighbor{} + if n.IP = net.ParseIP(fields[0]); n.IP == nil || n.IP.IsUnspecified() { + continue + } else if n.MAC, err = net.ParseMAC(fields[3]); err != nil { + continue + } + + ns = append(ns, n) + } + + arp.ns.reset(ns) + + return nil +} + +// Neighbors implements the ARPDB interface for *fsysARPDB. +func (arp *fsysARPDB) Neighbors() (ns []Neighbor) { + return arp.ns.clone() +} + +// parseArpAWrt parses the output of the "arp -a" command on OpenWrt. The +// expected input format: +// +// IP address HW type Flags HW address Mask Device +// 192.168.11.98 0x1 0x2 5a:92:df:a9:7e:28 * wan +// +func parseArpAWrt(sc *bufio.Scanner, lenHint int) (ns []Neighbor) { + if !sc.Scan() { + // Skip the header. + return + } + + ns = make([]Neighbor, 0, lenHint) + for sc.Scan() { + ln := sc.Text() + + fields := strings.Fields(ln) + if len(fields) < 4 { + continue + } + + n := Neighbor{} + + if ip := net.ParseIP(fields[0]); ip == nil || n.IP.IsUnspecified() { + continue + } else { + n.IP = ip + } + + hwStr := fields[3] + if mac, err := net.ParseMAC(hwStr); err != nil { + log.Debug("parsing arp output: %s", err) + + continue + } else { + n.MAC = mac + } + + ns = append(ns, n) + } + + return ns +} + +// parseArpA parses the output of the "arp -a" command on Linux. The expected +// input format: +// +// hostname (192.168.1.1) at ab:cd:ef:ab:cd:ef [ether] on enp0s3 +// +func parseArpA(sc *bufio.Scanner, lenHint int) (ns []Neighbor) { + ns = make([]Neighbor, 0, lenHint) + for sc.Scan() { + ln := sc.Text() + + fields := strings.Fields(ln) + if len(fields) < 4 { + continue + } + + n := Neighbor{} + + if ipStr := fields[1]; len(ipStr) < 2 { + continue + } else if ip := net.ParseIP(ipStr[1 : len(ipStr)-1]); ip == nil { + continue + } else { + n.IP = ip + } + + hwStr := fields[3] + if mac, err := net.ParseMAC(hwStr); err != nil { + log.Debug("parsing arp output: %s", err) + + continue + } else { + n.MAC = mac + } + + host := fields[0] + if verr := netutil.ValidateDomainName(host); verr != nil { + log.Debug("parsing arp output: %s", verr) + } else { + n.Name = host + } + + ns = append(ns, n) + } + + return ns +} + +// rcIPNeigh runs "ip neigh". +func rcIPNeigh() (r io.Reader, err error) { + return runCmd("ip", "neigh") +} + +// parseIPNeigh parses the output of the "ip neigh" command on Linux. The +// expected input format: +// +// 192.168.1.1 dev enp0s3 lladdr ab:cd:ef:ab:cd:ef REACHABLE +// +func parseIPNeigh(sc *bufio.Scanner, lenHint int) (ns []Neighbor) { + ns = make([]Neighbor, 0, lenHint) + for sc.Scan() { + ln := sc.Text() + + fields := strings.Fields(ln) + if len(fields) < 5 { + continue + } + + n := Neighbor{} + + if ip := net.ParseIP(fields[0]); ip == nil { + continue + } else { + n.IP = ip + } + + if mac, err := net.ParseMAC(fields[4]); err != nil { + log.Debug("parsing arp output: %s", err) + + continue + } else { + n.MAC = mac + } + + ns = append(ns, n) + } + + return ns +} diff --git a/internal/aghnet/arpdb_linux_test.go b/internal/aghnet/arpdb_linux_test.go new file mode 100644 index 00000000000..0439c9b5423 --- /dev/null +++ b/internal/aghnet/arpdb_linux_test.go @@ -0,0 +1,84 @@ +//go:build linux +// +build linux + +package aghnet + +import ( + "io" + "net" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const arpAOutputWrt = ` +IP address HW type Flags HW address Mask Device +192.168.1.2 0x1 0x2 ab:cd:ef:ab:cd:ef * wan +::ffff:ffff 0x1 0x2 ef:cd:ab:ef:cd:ab * wan` + +const arpAOutput = ` +? (192.168.1.2) at ab:cd:ef:ab:cd:ef on en0 ifscope [ethernet] +? (::ffff:ffff) at ef:cd:ab:ef:cd:ab on em0 expires in 100 seconds [ethernet]` + +const ipNeighOutput = ` +192.168.1.2 dev enp0s3 lladdr ab:cd:ef:ab:cd:ef DELAY +::ffff:ffff dev enp0s3 lladdr ef:cd:ab:ef:cd:ab router STALE` + +var wantNeighs = []Neighbor{{ + IP: net.IPv4(192, 168, 1, 2), + MAC: net.HardwareAddr{0xAB, 0xCD, 0xEF, 0xAB, 0xCD, 0xEF}, +}, { + IP: net.ParseIP("::ffff:ffff"), + MAC: net.HardwareAddr{0xEF, 0xCD, 0xAB, 0xEF, 0xCD, 0xAB}, +}} + +func TestFSysARPDB(t *testing.T) { + a := &fsysARPDB{ + ns: &neighs{ + mu: &sync.RWMutex{}, + ns: make([]Neighbor, 0), + }, + fsys: testdata, + filename: "proc_net_arp", + } + + err := a.Refresh() + require.NoError(t, err) + + ns := a.Neighbors() + assert.Equal(t, wantNeighs, ns) +} + +func TestCmdARPDB_arpawrt(t *testing.T) { + a := &cmdARPDB{ + parse: parseArpAWrt, + runcmd: func() (r io.Reader, err error) { return strings.NewReader(arpAOutputWrt), nil }, + ns: &neighs{ + mu: &sync.RWMutex{}, + ns: make([]Neighbor, 0), + }, + } + + err := a.Refresh() + require.NoError(t, err) + + assert.Equal(t, wantNeighs, a.Neighbors()) +} + +func TestCmdARPDB_ipneigh(t *testing.T) { + a := &cmdARPDB{ + parse: parseIPNeigh, + runcmd: func() (r io.Reader, err error) { return strings.NewReader(ipNeighOutput), nil }, + ns: &neighs{ + mu: &sync.RWMutex{}, + ns: make([]Neighbor, 0), + }, + } + err := a.Refresh() + require.NoError(t, err) + + assert.Equal(t, wantNeighs, a.Neighbors()) +} diff --git a/internal/aghnet/arpdb_openbsd.go b/internal/aghnet/arpdb_openbsd.go new file mode 100644 index 00000000000..805cb459ae7 --- /dev/null +++ b/internal/aghnet/arpdb_openbsd.go @@ -0,0 +1,67 @@ +//go:build openbsd +// +build openbsd + +package aghnet + +import ( + "bufio" + "net" + "strings" + "sync" + + "github.com/AdguardTeam/golibs/log" +) + +func newARPDB() *cmdARPDB { + return &cmdARPDB{ + runcmd: rcArpA, + parse: parseArpA, + ns: &neighs{ + mu: &sync.RWMutex{}, + ns: make([]Neighbor, 0), + }, + } +} + +// parseArpA parses the output of the "arp -a" command on OpenBSD. The expected +// input format: +// +// Host Ethernet Address Netif Expire Flags +// 192.168.1.1 ab:cd:ef:ab:cd:ef em0 19m59s +// +func parseArpA(sc *bufio.Scanner, lenHint int) (ns []Neighbor) { + // Skip the header. + if !sc.Scan() { + return nil + } + + ns = make([]Neighbor, 0, lenHint) + for sc.Scan() { + ln := sc.Text() + + fields := strings.Fields(ln) + if len(fields) < 2 { + continue + } + + n := Neighbor{} + + if ip := net.ParseIP(fields[0]); ip == nil { + continue + } else { + n.IP = ip + } + + if mac, err := net.ParseMAC(fields[1]); err != nil { + log.Debug("parsing arp output: %s", err) + + continue + } else { + n.MAC = mac + } + + ns = append(ns, n) + } + + return ns +} diff --git a/internal/aghnet/arpdb_openbsd_test.go b/internal/aghnet/arpdb_openbsd_test.go new file mode 100644 index 00000000000..b1021513a38 --- /dev/null +++ b/internal/aghnet/arpdb_openbsd_test.go @@ -0,0 +1,22 @@ +//go:build openbsd +// +build openbsd + +package aghnet + +import ( + "net" +) + +const arpAOutput = ` +Host Ethernet Address Netif Expire Flags +192.168.1.2 ab:cd:ef:ab:cd:ef em0 19m56s +::ffff:ffff ef:cd:ab:ef:cd:ab em0 permanent l +` + +var wantNeighs = []Neighbor{{ + IP: net.IPv4(192, 168, 1, 2), + MAC: net.HardwareAddr{0xAB, 0xCD, 0xEF, 0xAB, 0xCD, 0xEF}, +}, { + IP: net.ParseIP("::ffff:ffff"), + MAC: net.HardwareAddr{0xEF, 0xCD, 0xAB, 0xEF, 0xCD, 0xAB}, +}} diff --git a/internal/aghnet/arpdb_test.go b/internal/aghnet/arpdb_test.go new file mode 100644 index 00000000000..ce3da4fb6ac --- /dev/null +++ b/internal/aghnet/arpdb_test.go @@ -0,0 +1,168 @@ +package aghnet + +import ( + "io" + "net" + "strings" + "sync" + "testing" + + "github.com/AdguardTeam/golibs/errors" + "github.com/AdguardTeam/golibs/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestARPDB is the mock implementation of ARPDB to use in tests. +type TestARPDB struct { + OnRefresh func() (err error) + OnNeighbors func() (ns []Neighbor) +} + +// Refresh implements the ARPDB interface for *TestARPDB. +func (arp *TestARPDB) Refresh() (err error) { + return arp.OnRefresh() +} + +// Neighbors implements the ARPDB interface for *TestARPDB. +func (arp *TestARPDB) Neighbors() (ns []Neighbor) { + return arp.OnNeighbors() +} + +func TestARPDBS(t *testing.T) { + knownIP := net.IP{1, 2, 3, 4} + knownMAC := net.HardwareAddr{0xAB, 0xCD, 0xEF, 0xAB, 0xCD, 0xEF} + + succRefrCount, failRefrCount := 0, 0 + clnp := func() { + succRefrCount, failRefrCount = 0, 0 + } + + succDB := &TestARPDB{ + OnRefresh: func() (err error) { succRefrCount++; return nil }, + OnNeighbors: func() (ns []Neighbor) { + return []Neighbor{{Name: "abc", IP: knownIP, MAC: knownMAC}} + }, + } + failDB := &TestARPDB{ + OnRefresh: func() (err error) { failRefrCount++; return errors.Error("refresh failed") }, + OnNeighbors: func() (ns []Neighbor) { return nil }, + } + + t.Run("begin_with_success", func(t *testing.T) { + t.Cleanup(clnp) + + a := newARPDBs(succDB, failDB) + err := a.Refresh() + require.NoError(t, err) + + assert.Equal(t, 1, succRefrCount) + assert.Zero(t, failRefrCount) + assert.NotEmpty(t, a.Neighbors()) + }) + + t.Run("begin_with_fail", func(t *testing.T) { + t.Cleanup(clnp) + + a := newARPDBs(failDB, succDB) + err := a.Refresh() + require.NoError(t, err) + + assert.Equal(t, 1, succRefrCount) + assert.Equal(t, 1, failRefrCount) + assert.NotEmpty(t, a.Neighbors()) + }) + + t.Run("fail_only", func(t *testing.T) { + t.Cleanup(clnp) + + wantMsg := `each arpdb failed: 2 errors: "refresh failed", "refresh failed"` + + a := newARPDBs(failDB, failDB) + err := a.Refresh() + require.Error(t, err) + + testutil.AssertErrorMsg(t, wantMsg, err) + + assert.Equal(t, 2, failRefrCount) + assert.Empty(t, a.Neighbors()) + }) + + t.Run("fail_after_success", func(t *testing.T) { + t.Cleanup(clnp) + + shouldFail := false + unstableDB := &TestARPDB{ + OnRefresh: func() (err error) { + if shouldFail { + err = errors.Error("unstable failed") + } + shouldFail = !shouldFail + + return err + }, + OnNeighbors: func() (ns []Neighbor) { + if !shouldFail { + return failDB.OnNeighbors() + } + + return succDB.OnNeighbors() + }, + } + a := newARPDBs(unstableDB, succDB) + + // Unstable ARPDB should refresh successfully. + err := a.Refresh() + require.NoError(t, err) + + assert.Zero(t, succRefrCount) + assert.NotEmpty(t, a.Neighbors()) + + // Unstable ARPDB should fail and the succDB should be used. + err = a.Refresh() + require.NoError(t, err) + + assert.Equal(t, 1, succRefrCount) + assert.NotEmpty(t, a.Neighbors()) + + // Only the last succeeded ARPDB should be used. + err = a.Refresh() + require.NoError(t, err) + + assert.Equal(t, 2, succRefrCount) + assert.NotEmpty(t, a.Neighbors()) + }) + + t.Run("empty", func(t *testing.T) { + a := newARPDBs() + require.NoError(t, a.Refresh()) + + assert.Empty(t, a.Neighbors()) + }) +} + +func TestCmdARPDB_arpa(t *testing.T) { + a := &cmdARPDB{ + parse: parseArpA, + ns: &neighs{ + mu: &sync.RWMutex{}, + ns: make([]Neighbor, 0), + }, + } + + t.Run("arp_a", func(t *testing.T) { + a.runcmd = func() (r io.Reader, err error) { return strings.NewReader(arpAOutput), nil } + + err := a.Refresh() + require.NoError(t, err) + + assert.Equal(t, wantNeighs, a.Neighbors()) + }) + + t.Run("runcmd_error", func(t *testing.T) { + a.runcmd = func() (r io.Reader, err error) { return nil, errors.Error("can't run") } + + err := a.Refresh() + testutil.AssertErrorMsg(t, "cmd arpdb: running command: can't run", err) + }) +} diff --git a/internal/aghnet/arpdb_unix.go b/internal/aghnet/arpdb_unix.go new file mode 100644 index 00000000000..50346f92504 --- /dev/null +++ b/internal/aghnet/arpdb_unix.go @@ -0,0 +1,13 @@ +//go:build !windows +// +build !windows + +package aghnet + +import ( + "io" +) + +// rcArpA runs "arp -a". +func rcArpA() (r io.Reader, err error) { + return runCmd("arp", "-a") +} diff --git a/internal/aghnet/arpdb_windows.go b/internal/aghnet/arpdb_windows.go new file mode 100644 index 00000000000..5156330b17d --- /dev/null +++ b/internal/aghnet/arpdb_windows.go @@ -0,0 +1,70 @@ +//go:build windows +// +build windows + +package aghnet + +import ( + "bufio" + "io" + "net" + "strings" + "sync" +) + +func newARPDB() *cmdARPDB { + return &cmdARPDB{ + runcmd: rcArpA, + ns: &neighs{ + mu: &sync.RWMutex{}, + ns: make([]Neighbor, 0), + }, + parse: parseArpA, + } +} + +// rcArpA runs "arp /a". +func rcArpA() (r io.Reader, err error) { + return runCmd("arp", "/a") +} + +// parseArpA parses the output of the "arp /a" command on Windows. The expected +// input format (the first line is empty): +// +// +// Interface: 192.168.56.16 --- 0x7 +// Internet Address Physical Address Type +// 192.168.56.1 0a-00-27-00-00-00 dynamic +// 192.168.56.255 ff-ff-ff-ff-ff-ff static +// +func parseArpA(sc *bufio.Scanner, lenHint int) (ns []Neighbor) { + ns = make([]Neighbor, 0, lenHint) + for sc.Scan() { + ln := sc.Text() + if ln == "" { + continue + } + + fields := strings.Fields(ln) + if len(fields) != 3 { + continue + } + + n := Neighbor{} + + if ip := net.ParseIP(fields[0]); ip == nil { + continue + } else { + n.IP = ip + } + + if mac, err := net.ParseMAC(fields[1]); err != nil { + continue + } else { + n.MAC = mac + } + + ns = append(ns, n) + } + + return ns +} diff --git a/internal/aghnet/arpdb_windows_test.go b/internal/aghnet/arpdb_windows_test.go new file mode 100644 index 00000000000..ad88ff8eabd --- /dev/null +++ b/internal/aghnet/arpdb_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows +// +build windows + +package aghnet + +import ( + "net" +) + +const arpAOutput = ` + +Interface: 192.168.1.1 --- 0x7 + Internet Address Physical Address Type + 192.168.1.2 ab-cd-ef-ab-cd-ef dynamic + ::ffff:ffff ef-cd-ab-ef-cd-ab static` + +var wantNeighs = []Neighbor{{ + IP: net.IPv4(192, 168, 1, 2), + MAC: net.HardwareAddr{0xAB, 0xCD, 0xEF, 0xAB, 0xCD, 0xEF}, +}, { + IP: net.ParseIP("::ffff:ffff"), + MAC: net.HardwareAddr{0xEF, 0xCD, 0xAB, 0xEF, 0xCD, 0xAB}, +}} diff --git a/internal/aghnet/hostscontainer_test.go b/internal/aghnet/hostscontainer_test.go index a141ce00bca..40bfb34c9b0 100644 --- a/internal/aghnet/hostscontainer_test.go +++ b/internal/aghnet/hostscontainer_test.go @@ -3,7 +3,6 @@ package aghnet import ( "io/fs" "net" - "os" "path" "strings" "sync/atomic" @@ -281,7 +280,6 @@ func TestHostsContainer_PathsToPatterns(t *testing.T) { } func TestHostsContainer_Translate(t *testing.T) { - testdata := os.DirFS("./testdata") stubWatcher := aghtest.FSWatcher{ OnEvents: func() (e <-chan struct{}) { return nil }, OnAdd: func(name string) (err error) { return nil }, @@ -360,8 +358,6 @@ func TestHostsContainer_Translate(t *testing.T) { func TestHostsContainer(t *testing.T) { const listID = 1234 - testdata := os.DirFS("./testdata") - testCases := []struct { want []*rules.DNSRewrite name string diff --git a/internal/aghnet/net_test.go b/internal/aghnet/net_test.go index b5bf2297e2e..34e99faaddf 100644 --- a/internal/aghnet/net_test.go +++ b/internal/aghnet/net_test.go @@ -1,7 +1,9 @@ package aghnet import ( + "io/fs" "net" + "os" "testing" "github.com/AdguardTeam/AdGuardHome/internal/aghtest" @@ -11,6 +13,9 @@ import ( "github.com/stretchr/testify/require" ) +// testdata is the filesystem containing data for testing the package. +var testdata fs.FS = os.DirFS("./testdata") + func TestMain(m *testing.M) { aghtest.DiscardLogOutput(m) } diff --git a/internal/aghnet/testdata/proc_net_arp b/internal/aghnet/testdata/proc_net_arp new file mode 100644 index 00000000000..07d214e1331 --- /dev/null +++ b/internal/aghnet/testdata/proc_net_arp @@ -0,0 +1,4 @@ +IP address HW type Flags HW address Mask Device +192.168.1.2 0x1 0x2 ab:cd:ef:ab:cd:ef * wan +::ffff:ffff 0x1 0x0 ef:cd:ab:ef:cd:ab * br-lan +0.0.0.0 0x0 0x0 00:00:00:00:00:00 * unspec \ No newline at end of file diff --git a/internal/aghos/os.go b/internal/aghos/os.go index 29eb1afc0dd..8ac189a1c14 100644 --- a/internal/aghos/os.go +++ b/internal/aghos/os.go @@ -52,11 +52,12 @@ func HaveAdminRights() (bool, error) { return haveAdminRights() } -// MaxCmdOutputSize is the maximum length of performed shell command output. -const MaxCmdOutputSize = 2 * 1024 +// MaxCmdOutputSize is the maximum length of performed shell command output in +// bytes. +const MaxCmdOutputSize = 64 * 1024 // RunCommand runs shell command. -func RunCommand(command string, arguments ...string) (int, string, error) { +func RunCommand(command string, arguments ...string) (code int, output string, err error) { cmd := exec.Command(command, arguments...) out, err := cmd.Output() if len(out) > MaxCmdOutputSize { @@ -66,7 +67,7 @@ func RunCommand(command string, arguments ...string) (int, string, error) { if errors.As(err, new(*exec.ExitError)) { return cmd.ProcessState.ExitCode(), string(out), nil } else if err != nil { - return 1, "", fmt.Errorf("exec.Command(%s) failed: %w: %s", command, err, string(out)) + return 1, "", fmt.Errorf("command %q failed: %w: %s", command, err, out) } return cmd.ProcessState.ExitCode(), string(out), nil diff --git a/internal/home/clients.go b/internal/home/clients.go index f539adbeb66..d9aad40ec08 100644 --- a/internal/home/clients.go +++ b/internal/home/clients.go @@ -4,10 +4,7 @@ import ( "bytes" "fmt" "net" - "os/exec" - "runtime" "sort" - "strings" "sync" "time" @@ -99,6 +96,9 @@ type clientsContainer struct { // hosts database. etcHosts *aghnet.HostsContainer + // arpdb stores the neighbors retrieved from ARP. + arpdb aghnet.ARPDB + testing bool // if TRUE, this object is used for internal tests } @@ -109,6 +109,7 @@ func (clients *clientsContainer) Init( objects []*clientObject, dhcpServer *dhcpd.Server, etcHosts *aghnet.HostsContainer, + arpdb aghnet.ARPDB, ) { if clients.list != nil { log.Fatal("clients.list != nil") @@ -121,6 +122,7 @@ func (clients *clientsContainer) Init( clients.dhcpServer = dhcpServer clients.etcHosts = etcHosts + clients.arpdb = arpdb clients.addFromConfig(objects) if clients.testing { @@ -807,16 +809,18 @@ func (clients *clientsContainer) addFromHostsFile(hosts *netutil.IPMap) { // addFromSystemARP adds the IP-hostname pairings from the output of the arp -a // command. func (clients *clientsContainer) addFromSystemARP() { - if runtime.GOOS == "windows" { + if err := clients.arpdb.Refresh(); err != nil { + log.Error("refreshing arp container: %s", err) + + clients.arpdb = aghnet.EmptyARPDB{} + return } - cmd := exec.Command("arp", "-a") - log.Tracef("executing %q %q", cmd.Path, cmd.Args) - data, err := cmd.Output() - if err != nil || cmd.ProcessState.ExitCode() != 0 { - log.Debug("command %q has failed: %q code:%d", - cmd.Path, err, cmd.ProcessState.ExitCode()) + ns := clients.arpdb.Neighbors() + if len(ns) == 0 { + log.Debug("refreshing arp container: the update is empty") + return } @@ -825,30 +829,14 @@ func (clients *clientsContainer) addFromSystemARP() { clients.rmHostsBySrc(ClientSourceARP) - n := 0 - // TODO(a.garipov): Rewrite to use bufio.Scanner. - lines := strings.Split(string(data), "\n") - for _, ln := range lines { - lparen := strings.Index(ln, " (") - rparen := strings.Index(ln, ") ") - if lparen == -1 || rparen == -1 || lparen >= rparen { - continue - } - - host := ln[:lparen] - ipStr := ln[lparen+2 : rparen] - ip := net.ParseIP(ipStr) - if netutil.ValidateDomainName(host) != nil || ip == nil { - continue - } - - ok := clients.addHostLocked(ip, host, ClientSourceARP) - if ok { - n++ + added := 0 + for _, n := range ns { + if clients.addHostLocked(n.IP, "", ClientSourceARP) { + added++ } } - log.Debug("clients: added %d client aliases from 'arp -a' command output", n) + log.Debug("clients: added %d client aliases from arp neighborhood", added) } // updateFromDHCP adds the clients that have a non-empty hostname from the DHCP diff --git a/internal/home/clients_test.go b/internal/home/clients_test.go index 6cc5d46fc8c..8afd56216ba 100644 --- a/internal/home/clients_test.go +++ b/internal/home/clients_test.go @@ -18,7 +18,7 @@ func TestClients(t *testing.T) { clients := clientsContainer{} clients.testing = true - clients.Init(nil, nil, nil) + clients.Init(nil, nil, nil, nil) t.Run("add_success", func(t *testing.T) { c := &Client{ @@ -194,7 +194,7 @@ func TestClientsWHOIS(t *testing.T) { clients := clientsContainer{ testing: true, } - clients.Init(nil, nil, nil) + clients.Init(nil, nil, nil, nil) whois := &RuntimeClientWHOISInfo{ Country: "AU", Orgname: "Example Org", @@ -253,7 +253,7 @@ func TestClientsAddExisting(t *testing.T) { clients := clientsContainer{ testing: true, } - clients.Init(nil, nil, nil) + clients.Init(nil, nil, nil, nil) t.Run("simple", func(t *testing.T) { ip := net.IP{1, 1, 1, 1} @@ -332,7 +332,7 @@ func TestClientsCustomUpstream(t *testing.T) { clients := clientsContainer{ testing: true, } - clients.Init(nil, nil, nil) + clients.Init(nil, nil, nil, nil) // Add client with upstreams. ok, err := clients.Add(&Client{ diff --git a/internal/home/home.go b/internal/home/home.go index 021751e835b..114ba4b6069 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -293,7 +293,15 @@ func setupConfig(args options) (err error) { } } - Context.clients.Init(config.Clients, Context.dhcpServer, Context.etcHosts) + var arpdb aghnet.ARPDB + arpdb, err = aghnet.NewARPDB() + if err != nil { + log.Info("warning: creating arpdb: %s; using stub", err) + + arpdb = aghnet.EmptyARPDB{} + } + + Context.clients.Init(config.Clients, Context.dhcpServer, Context.etcHosts, arpdb) if args.bindPort != 0 { uc := aghalg.UniqChecker{}