Pull request 1925: 6006-client-processor

Updates #6006.

Squashed commit of the following:

commit c72d6375e9c472c73b0bb9d025a8e197f404ba38
Merge: 02d64b10e 0cd441f04
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Tue Jul 18 13:56:26 2023 +0300

    Merge branch 'master' into 6006-client-processor

commit 02d64b10e19b2e937e45cab58d2310231a19bfbc
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Mon Jul 17 19:42:07 2023 +0300

    client: imp code, tests

commit b1613463089b4dde97484ff6a44b05888f0c2276
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Mon Jul 17 18:42:19 2023 +0300

    client: imp code, docs, tests

commit f71a17983b70d79839cf35dbe3279f0fdcac2ed7
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Fri Jul 14 21:53:47 2023 +0300

    all: add new client processor; imp code
This commit is contained in:
Ainar Garipov 2023-07-18 14:02:32 +03:00
parent 0cd441f04f
commit dead10e033
10 changed files with 664 additions and 33 deletions

View File

@ -4,9 +4,13 @@ import (
"context"
"io"
"io/fs"
"net/netip"
"github.com/AdguardTeam/AdGuardHome/internal/aghos"
"github.com/AdguardTeam/AdGuardHome/internal/client"
"github.com/AdguardTeam/AdGuardHome/internal/next/agh"
"github.com/AdguardTeam/AdGuardHome/internal/rdns"
"github.com/AdguardTeam/AdGuardHome/internal/whois"
"github.com/AdguardTeam/dnsproxy/upstream"
"github.com/miekg/dns"
)
@ -135,6 +139,59 @@ func (s *ServiceWithConfig[ConfigType]) Config() (c ConfigType) {
return s.OnConfig()
}
// Package client
// AddressProcessor is a fake [client.AddressProcessor] implementation for
// tests.
type AddressProcessor struct {
OnProcess func(ip netip.Addr)
OnClose func() (err error)
}
// type check
var _ client.AddressProcessor = (*AddressProcessor)(nil)
// Process implements the [client.AddressProcessor] interface for
// *AddressProcessor.
func (p *AddressProcessor) Process(ip netip.Addr) {
p.OnProcess(ip)
}
// Close implements the [client.AddressProcessor] interface for
// *AddressProcessor.
func (p *AddressProcessor) Close() (err error) {
return p.OnClose()
}
// AddressUpdater is a fake [client.AddressUpdater] implementation for tests.
type AddressUpdater struct {
OnUpdateAddress func(ip netip.Addr, host string, info *whois.Info)
}
// type check
var _ client.AddressUpdater = (*AddressUpdater)(nil)
// UpdateAddress implements the [client.AddressUpdater] interface for
// *AddressUpdater.
func (p *AddressUpdater) UpdateAddress(ip netip.Addr, host string, info *whois.Info) {
p.OnUpdateAddress(ip, host, info)
}
// Package rdns
// Exchanger is a fake [rdns.Exchanger] implementation for tests.
type Exchanger struct {
OnExchange func(ip netip.Addr) (host string, err error)
}
// type check
var _ rdns.Exchanger = (*Exchanger)(nil)
// Exchange implements [rdns.Exchanger] interface for *Exchanger.
func (e *Exchanger) Exchange(ip netip.Addr) (host string, err error) {
return e.OnExchange(ip)
}
// Module dnsproxy
// Package upstream

293
internal/client/addrproc.go Normal file
View File

@ -0,0 +1,293 @@
package client
import (
"context"
"net/netip"
"sync"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/rdns"
"github.com/AdguardTeam/AdGuardHome/internal/whois"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/netutil"
)
// ErrClosed is returned from [AddressProcessor.Close] if it's closed more than
// once.
const ErrClosed errors.Error = "use of closed address processor"
// AddressProcessor is the interface for types that can process clients.
type AddressProcessor interface {
Process(ip netip.Addr)
Close() (err error)
}
// EmptyAddrProc is an [AddressProcessor] that does nothing.
type EmptyAddrProc struct{}
// type check
var _ AddressProcessor = EmptyAddrProc{}
// Process implements the [AddressProcessor] interface for EmptyAddrProc.
func (EmptyAddrProc) Process(_ netip.Addr) {}
// Close implements the [AddressProcessor] interface for EmptyAddrProc.
func (EmptyAddrProc) Close() (_ error) { return nil }
// DefaultAddrProcConfig is the configuration structure for address processors.
type DefaultAddrProcConfig struct {
// DialContext is used to create TCP connections to WHOIS servers.
// DialContext must not be nil if [DefaultAddrProcConfig.UseWHOIS] is true.
DialContext whois.DialContextFunc
// Exchanger is used to perform rDNS queries. Exchanger must not be nil if
// [DefaultAddrProcConfig.UseRDNS] is true.
Exchanger rdns.Exchanger
// PrivateSubnets are used to determine if an incoming IP address is
// private. It must not be nil.
PrivateSubnets netutil.SubnetSet
// AddressUpdater is used to update the information about a client's IP
// address. It must not be nil.
AddressUpdater AddressUpdater
// InitialAddresses are the addresses that are queued for processing
// immediately by [NewDefaultAddrProc].
InitialAddresses []netip.Addr
// UseRDNS, if true, enables resolving of client IP addresses using reverse
// DNS.
UseRDNS bool
// UsePrivateRDNS, if true, enables resolving of private client IP addresses
// using reverse DNS. See [DefaultAddrProcConfig.PrivateSubnets].
UsePrivateRDNS bool
// UseWHOIS, if true, enables resolving of client IP addresses using WHOIS.
UseWHOIS bool
}
// AddressUpdater is the interface for storages of DNS clients that can update
// information about them.
//
// TODO(a.garipov): Consider using the actual client storage once it is moved
// into this package.
type AddressUpdater interface {
// UpdateAddress updates information about an IP address, setting host (if
// not empty) and WHOIS information (if not nil).
UpdateAddress(ip netip.Addr, host string, info *whois.Info)
}
// DefaultAddrProc processes incoming client addresses with rDNS and WHOIS, if
// configured, and updates that information in a client storage.
type DefaultAddrProc struct {
// clientIPsMu serializes closure of clientIPs and access to isClosed.
clientIPsMu *sync.Mutex
// clientIPs is the channel queueing client processing tasks.
clientIPs chan netip.Addr
// rdns is used to perform rDNS lookups of clients' IP addresses.
rdns rdns.Interface
// whois is used to perform WHOIS lookups of clients' IP addresses.
whois whois.Interface
// addrUpdater is used to update the information about a client's IP
// address.
addrUpdater AddressUpdater
// privateSubnets are used to determine if an incoming IP address is
// private.
privateSubnets netutil.SubnetSet
// isClosed is set to true once the address processor is closed.
isClosed bool
// usePrivateRDNS, if true, enables resolving of private client IP addresses
// using reverse DNS.
usePrivateRDNS bool
}
const (
// defaultQueueSize is the size of queue of IPs for rDNS and WHOIS
// processing.
defaultQueueSize = 255
// defaultCacheSize is the maximum size of the cache for rDNS and WHOIS
// processing. It must be greater than zero.
defaultCacheSize = 10_000
// defaultIPTTL is the Time to Live duration for IP addresses cached by
// rDNS and WHOIS.
defaultIPTTL = 1 * time.Hour
)
// NewDefaultAddrProc returns a new running client address processor. c must
// not be nil.
func NewDefaultAddrProc(c *DefaultAddrProcConfig) (p *DefaultAddrProc) {
p = &DefaultAddrProc{
clientIPsMu: &sync.Mutex{},
clientIPs: make(chan netip.Addr, defaultQueueSize),
rdns: &rdns.Empty{},
addrUpdater: c.AddressUpdater,
whois: &whois.Empty{},
privateSubnets: c.PrivateSubnets,
usePrivateRDNS: c.UsePrivateRDNS,
}
if c.UseRDNS {
p.rdns = rdns.New(&rdns.Config{
Exchanger: c.Exchanger,
CacheSize: defaultCacheSize,
CacheTTL: defaultIPTTL,
})
}
if c.UseWHOIS {
p.whois = newWHOIS(c.DialContext)
}
go p.process()
for _, ip := range c.InitialAddresses {
p.Process(ip)
}
return p
}
// newWHOIS returns a whois.Interface instance using the given function for
// dialing.
func newWHOIS(dialFunc whois.DialContextFunc) (w whois.Interface) {
// TODO(s.chzhen): Consider making configurable.
const (
// defaultTimeout is the timeout for WHOIS requests.
defaultTimeout = 5 * time.Second
// defaultMaxConnReadSize is an upper limit in bytes for reading from a
// net.Conn.
defaultMaxConnReadSize = 64 * 1024
// defaultMaxRedirects is the maximum redirects count.
defaultMaxRedirects = 5
// defaultMaxInfoLen is the maximum length of whois.Info fields.
defaultMaxInfoLen = 250
)
return whois.New(&whois.Config{
DialContext: dialFunc,
ServerAddr: whois.DefaultServer,
Port: whois.DefaultPort,
Timeout: defaultTimeout,
CacheSize: defaultCacheSize,
MaxConnReadSize: defaultMaxConnReadSize,
MaxRedirects: defaultMaxRedirects,
MaxInfoLen: defaultMaxInfoLen,
CacheTTL: defaultIPTTL,
})
}
// type check
var _ AddressProcessor = (*DefaultAddrProc)(nil)
// Process implements the [AddressProcessor] interface for *DefaultAddrProc.
func (p *DefaultAddrProc) Process(ip netip.Addr) {
p.clientIPsMu.Lock()
defer p.clientIPsMu.Unlock()
if p.isClosed {
return
}
select {
case p.clientIPs <- ip:
// Go on.
default:
log.Debug("clients: ip channel is full; len: %d", len(p.clientIPs))
}
}
// process processes the incoming client IP-address information. It is intended
// to be used as a goroutine. Once clientIPs is closed, process exits.
func (p *DefaultAddrProc) process() {
defer log.OnPanic("addrProcessor.process")
log.Info("clients: processing addresses")
for ip := range p.clientIPs {
host := p.processRDNS(ip)
info := p.processWHOIS(ip)
p.addrUpdater.UpdateAddress(ip, host, info)
}
log.Info("clients: finished processing addresses")
}
// processRDNS resolves the clients' IP addresses using reverse DNS. host is
// empty if there were errors or if the information hasn't changed.
func (p *DefaultAddrProc) processRDNS(ip netip.Addr) (host string) {
start := time.Now()
log.Debug("clients: processing %s with rdns", ip)
defer func() {
log.Debug("clients: finished processing %s with rdns in %s", ip, time.Since(start))
}()
ok := p.shouldResolve(ip)
if !ok {
return
}
host, changed := p.rdns.Process(ip)
if !changed {
host = ""
}
return host
}
// shouldResolve returns false if ip is a loopback address, or ip is private and
// resolving of private addresses is disabled.
func (p *DefaultAddrProc) shouldResolve(ip netip.Addr) (ok bool) {
return !ip.IsLoopback() &&
(p.usePrivateRDNS || !p.privateSubnets.Contains(ip.AsSlice()))
}
// processWHOIS looks up the information about clients' IP addresses in the
// WHOIS databases. info is nil if there were errors or if the information
// hasn't changed.
func (p *DefaultAddrProc) processWHOIS(ip netip.Addr) (info *whois.Info) {
start := time.Now()
log.Debug("clients: processing %s with whois", ip)
defer func() {
log.Debug("clients: finished processing %s with whois in %s", ip, time.Since(start))
}()
// TODO(s.chzhen): Move the timeout logic from WHOIS configuration to the
// context.
info, changed := p.whois.Process(context.Background(), ip)
if !changed {
info = nil
}
return info
}
// Close implements the [AddressProcessor] interface for *DefaultAddrProc.
func (p *DefaultAddrProc) Close() (err error) {
p.clientIPsMu.Lock()
defer p.clientIPsMu.Unlock()
if p.isClosed {
return ErrClosed
}
close(p.clientIPs)
p.isClosed = true
return nil
}

View File

@ -0,0 +1,259 @@
package client_test
import (
"context"
"io"
"net"
"net/netip"
"testing"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/AdGuardHome/internal/client"
"github.com/AdguardTeam/AdGuardHome/internal/whois"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/netutil"
"github.com/AdguardTeam/golibs/testutil"
"github.com/AdguardTeam/golibs/testutil/fakenet"
"github.com/stretchr/testify/assert"
)
func TestEmptyAddrProc(t *testing.T) {
t.Parallel()
p := client.EmptyAddrProc{}
assert.NotPanics(t, func() {
p.Process(testIP)
})
assert.NotPanics(t, func() {
err := p.Close()
assert.NoError(t, err)
})
}
func TestDefaultAddrProc_Process_rDNS(t *testing.T) {
t.Parallel()
privateIP := netip.MustParseAddr("192.168.0.1")
testCases := []struct {
rdnsErr error
ip netip.Addr
name string
host string
usePrivate bool
wantUpd bool
}{{
rdnsErr: nil,
ip: testIP,
name: "success",
host: testHost,
usePrivate: false,
wantUpd: true,
}, {
rdnsErr: nil,
ip: testIP,
name: "no_host",
host: "",
usePrivate: false,
wantUpd: false,
}, {
rdnsErr: nil,
ip: netip.MustParseAddr("127.0.0.1"),
name: "localhost",
host: "",
usePrivate: false,
wantUpd: false,
}, {
rdnsErr: nil,
ip: privateIP,
name: "private_ignored",
host: "",
usePrivate: false,
wantUpd: false,
}, {
rdnsErr: nil,
ip: privateIP,
name: "private_processed",
host: "private.example",
usePrivate: true,
wantUpd: true,
}, {
rdnsErr: errors.Error("rdns error"),
ip: testIP,
name: "rdns_error",
host: "",
usePrivate: false,
wantUpd: false,
}}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
updIPCh := make(chan netip.Addr, 1)
updHostCh := make(chan string, 1)
updInfoCh := make(chan *whois.Info, 1)
p := client.NewDefaultAddrProc(&client.DefaultAddrProcConfig{
DialContext: func(_ context.Context, _, _ string) (conn net.Conn, err error) {
panic("not implemented")
},
Exchanger: &aghtest.Exchanger{
OnExchange: func(ip netip.Addr) (host string, err error) {
return tc.host, tc.rdnsErr
},
},
PrivateSubnets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
AddressUpdater: &aghtest.AddressUpdater{
OnUpdateAddress: newOnUpdateAddress(tc.wantUpd, updIPCh, updHostCh, updInfoCh),
},
UseRDNS: true,
UsePrivateRDNS: tc.usePrivate,
UseWHOIS: false,
})
testutil.CleanupAndRequireSuccess(t, p.Close)
p.Process(tc.ip)
if !tc.wantUpd {
return
}
gotIP, _ := testutil.RequireReceive(t, updIPCh, testTimeout)
assert.Equal(t, tc.ip, gotIP)
gotHost, _ := testutil.RequireReceive(t, updHostCh, testTimeout)
assert.Equal(t, tc.host, gotHost)
gotInfo, _ := testutil.RequireReceive(t, updInfoCh, testTimeout)
assert.Nil(t, gotInfo)
})
}
}
// newOnUpdateAddress is a test helper that returns a new OnUpdateAddress
// callback using the provided channels if an update is expected and panicking
// otherwise.
func newOnUpdateAddress(
want bool,
ips chan<- netip.Addr,
hosts chan<- string,
infos chan<- *whois.Info,
) (f func(ip netip.Addr, host string, info *whois.Info)) {
return func(ip netip.Addr, host string, info *whois.Info) {
if !want {
panic("got unexpected update")
}
ips <- ip
hosts <- host
infos <- info
}
}
func TestDefaultAddrProc_Process_WHOIS(t *testing.T) {
t.Parallel()
testCases := []struct {
wantInfo *whois.Info
exchErr error
name string
wantUpd bool
}{{
wantInfo: &whois.Info{
City: testWHOISCity,
},
exchErr: nil,
name: "success",
wantUpd: true,
}, {
wantInfo: nil,
exchErr: nil,
name: "no_info",
wantUpd: false,
}, {
wantInfo: nil,
exchErr: errors.Error("whois error"),
name: "whois_error",
wantUpd: false,
}}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
whoisConn := &fakenet.Conn{
OnClose: func() (err error) { return nil },
OnRead: func(b []byte) (n int, err error) {
if tc.wantInfo == nil {
return 0, tc.exchErr
}
data := "city: " + tc.wantInfo.City + "\n"
copy(b, data)
return len(data), io.EOF
},
OnSetDeadline: func(_ time.Time) (err error) { return nil },
OnWrite: func(b []byte) (n int, err error) { return len(b), nil },
}
updIPCh := make(chan netip.Addr, 1)
updHostCh := make(chan string, 1)
updInfoCh := make(chan *whois.Info, 1)
p := client.NewDefaultAddrProc(&client.DefaultAddrProcConfig{
DialContext: func(_ context.Context, _, _ string) (conn net.Conn, err error) {
return whoisConn, nil
},
Exchanger: &aghtest.Exchanger{
OnExchange: func(_ netip.Addr) (host string, err error) {
panic("not implemented")
},
},
PrivateSubnets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
AddressUpdater: &aghtest.AddressUpdater{
OnUpdateAddress: newOnUpdateAddress(tc.wantUpd, updIPCh, updHostCh, updInfoCh),
},
UseRDNS: false,
UsePrivateRDNS: false,
UseWHOIS: true,
})
testutil.CleanupAndRequireSuccess(t, p.Close)
p.Process(testIP)
if !tc.wantUpd {
return
}
gotIP, _ := testutil.RequireReceive(t, updIPCh, testTimeout)
assert.Equal(t, testIP, gotIP)
gotHost, _ := testutil.RequireReceive(t, updHostCh, testTimeout)
assert.Empty(t, gotHost)
gotInfo, _ := testutil.RequireReceive(t, updInfoCh, testTimeout)
assert.Equal(t, tc.wantInfo, gotInfo)
})
}
}
func TestDefaultAddrProc_Close(t *testing.T) {
t.Parallel()
p := client.NewDefaultAddrProc(&client.DefaultAddrProcConfig{})
err := p.Close()
assert.NoError(t, err)
err = p.Close()
assert.ErrorIs(t, err, client.ErrClosed)
}

View File

@ -0,0 +1,5 @@
// Package client contains types and logic dealing with AdGuard Home's DNS
// clients.
//
// TODO(a.garipov): Expand.
package client

View File

@ -0,0 +1,25 @@
package client_test
import (
"net/netip"
"testing"
"time"
"github.com/AdguardTeam/golibs/testutil"
)
func TestMain(m *testing.M) {
testutil.DiscardLogOutput(m)
}
// testHost is the common hostname for tests.
const testHost = "client.example"
// testTimeout is the common timeout for tests.
const testTimeout = 1 * time.Second
// testWHOISCity is the common city for tests.
const testWHOISCity = "Brussels"
// testIP is the common IP address for tests.
var testIP = netip.MustParseAddr("1.2.3.4")

View File

@ -17,7 +17,7 @@ type Interface interface {
Process(ip netip.Addr) (host string, changed bool)
}
// Empty is an empty [Inteface] implementation which does nothing.
// Empty is an empty [Interface] implementation which does nothing.
type Empty struct{}
// type check

View File

@ -5,25 +5,13 @@ import (
"testing"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/AdGuardHome/internal/rdns"
"github.com/AdguardTeam/golibs/netutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeRDNSExchanger is a mock [rdns.Exchanger] implementation for tests.
type fakeRDNSExchanger struct {
OnExchange func(ip netip.Addr) (host string, err error)
}
// type check
var _ rdns.Exchanger = (*fakeRDNSExchanger)(nil)
// Exchange implements [rdns.Exchanger] interface for *fakeRDNSExchanger.
func (e *fakeRDNSExchanger) Exchange(ip netip.Addr) (host string, err error) {
return e.OnExchange(ip)
}
func TestDefault_Process(t *testing.T) {
ip1 := netip.MustParseAddr("1.2.3.4")
revAddr1, err := netutil.IPToReversedAddr(ip1.AsSlice())
@ -81,7 +69,7 @@ func TestDefault_Process(t *testing.T) {
return "", nil
}
}
exchanger := &fakeRDNSExchanger{
exchanger := &aghtest.Exchanger{
OnExchange: onExchange,
}

View File

@ -48,9 +48,8 @@ func (Empty) Process(_ context.Context, _ netip.Addr) (info *Info, changed bool)
// Config is the configuration structure for Default.
type Config struct {
// DialContext specifies the dial function for creating unencrypted TCP
// connections.
DialContext func(ctx context.Context, network, addr string) (conn net.Conn, err error)
// DialContext is used to create TCP connections to WHOIS servers.
DialContext DialContextFunc
// ServerAddr is the address of the WHOIS server.
ServerAddr string
@ -78,6 +77,13 @@ type Config struct {
Port uint16
}
// DialContextFunc is the semantic alias for dialing functions, such as
// [http.Transport.DialContext].
//
// TODO(a.garipov): Move to aghnet once it stops importing aghtest, because
// otherwise there is an import cycle.
type DialContextFunc = func(ctx context.Context, network, addr string) (conn net.Conn, err error)
// Default is the default WHOIS information processor.
type Default struct {
// cache is the cache containing IP addresses of clients. An active IP
@ -86,9 +92,8 @@ type Default struct {
// resolve the same IP.
cache gcache.Cache
// dialContext connects to a remote server resolving hostname using our own
// DNS server and unecrypted TCP connection.
dialContext func(ctx context.Context, network, addr string) (conn net.Conn, err error)
// dialContext is used to create TCP connections to WHOIS servers.
dialContext DialContextFunc
// serverAddr is the address of the WHOIS server.
serverAddr string
@ -215,7 +220,7 @@ func (w *Default) query(ctx context.Context, target, serverAddr string) (data []
return nil, err
}
_ = conn.SetReadDeadline(time.Now().Add(w.timeout))
_ = conn.SetDeadline(time.Now().Add(w.timeout))
_, err = io.WriteString(conn, target+"\r\n")
if err != nil {
// Don't wrap the error since it's informative enough as is.
@ -310,7 +315,7 @@ func (w *Default) requestInfo(
kv, err := w.queryAll(ctx, ip.String())
if err != nil {
log.Debug("whois: quering about %q: %s", ip, err)
log.Debug("whois: querying %q: %s", ip, err)
return nil, true
}

View File

@ -113,20 +113,14 @@ func TestDefault_Process(t *testing.T) {
return copy(b, tc.data), io.EOF
},
OnWrite: func(b []byte) (n int, err error) {
return len(b), nil
},
OnClose: func() (err error) {
return nil
},
OnSetReadDeadline: func(t time.Time) (err error) {
return nil
},
OnWrite: func(b []byte) (n int, err error) { return len(b), nil },
OnClose: func() (err error) { return nil },
OnSetDeadline: func(t time.Time) (err error) { return nil },
}
w := whois.New(&whois.Config{
Timeout: 5 * time.Second,
DialContext: func(_ context.Context, _, addr string) (_ net.Conn, _ error) {
DialContext: func(_ context.Context, _, _ string) (_ net.Conn, _ error) {
hit = 0
return fakeConn, nil

View File

@ -176,10 +176,13 @@ run_linter gocognit --over 10\
./internal/aghchan/\
./internal/aghhttp/\
./internal/aghio/\
./internal/client/\
./internal/dhcpsvc\
./internal/filtering/hashprefix/\
./internal/filtering/rulelist/\
./internal/next/\
./internal/rdns/\
./internal/schedule/\
./internal/tools/\
./internal/version/\
./internal/whois/\
@ -211,12 +214,14 @@ run_linter gosec --quiet\
./internal/aghnet\
./internal/aghos\
./internal/aghtest\
./internal/client\
./internal/dhcpd\
./internal/dhcpsvc\
./internal/dnsforward\
./internal/filtering/hashprefix/\
./internal/filtering/rulelist/\
./internal/next\
./internal/rdns\
./internal/schedule\
./internal/stats\
./internal/tools\