Compare commits

..

48 Commits

Author SHA1 Message Date
Cuong Manh Le
49eebcdcbc .github/workflows: bump go version to 1.21.x 2024-03-04 14:49:52 +07:00
Cuong Manh Le
e89021ec3a cmd/cli: only set DNS for physical interfaces on Windows
By filtering the interfaces by MAC address instead of name.
2024-03-04 14:49:52 +07:00
Cuong Manh Le
73a697b2fa cmd/cli: remove old DNS settings on installing 2024-02-27 23:18:11 +07:00
Yegor Sak
9319d08046 Update file config.md 2024-02-27 23:18:11 +07:00
Cuong Manh Le
7dc5138e91 cmd/cli: watch resolv.conf on all unix platforms 2024-02-22 18:15:36 +07:00
Cuong Manh Le
8f189c919a cmd/cli: skip deactivation check for old socket server
If the server is running old version of ctrld, the deactivation pin
check will return 404 not found, the client should consider this as no
error instead of returning invalid pin code.

This allows v1.3.5 binary `ctrld start` command while the ctrld server
is still running old version. I discover this while testing v1.3.5
binary on a router with old ctrld version running.
2024-02-22 18:14:30 +07:00
Cuong Manh Le
906479a15c cmd/cli: do not save static DNS when ctrld is already installed
If ctrld was installed, the DNS setting was changed, we could not
determine the dynamic or static settings before installing ctrld.
2024-02-21 17:49:19 +07:00
Cuong Manh Le
dabbf2037b cmd/cli: do not allow running start command if pin code set
While at it, also emitting a better error message when pin code was set
but users do not provide --pin flag.
2024-02-20 15:21:00 +07:00
Cuong Manh Le
583718f234 cmd/cli: silent un-necessary error for physical interfaces loop
The loop is run after the main interface DNS was set, thus the error
would make noise to users. This commit removes the noise, by making
currentStaticDNS returns an additional error, so it's up to the caller
to decive whether to emit the error or not.

Further, the physical interface loop will now only log when the callback
function runs successfully. Emitting the callback error can be done in
the future, until we can figure out how to detect physical interfaces in
Go portably.
2024-02-19 18:29:22 +07:00
Cuong Manh Le
fdb82f6ec3 cmd/cli: only emit error for running interfaces
While at it, also ensure setDNS/resetDNS return a wrapped error on
Darwin/Windows, so the caller can decide whether to print the error to
users.
2024-02-19 18:29:22 +07:00
Cuong Manh Le
5145729ab1 cmd/cli: always set/reset DNS regardless of interfaces state
The interface may be down during ctrld uninstall, so the previous set
DNS won't be restored, causing bad state when interface is up again.
2024-02-19 18:29:22 +07:00
Cuong Manh Le
4d810261a4 cmd/cli: only save/restore static DNS
The save/restore DNS functionality always perform its job, even though
the DNS is not static, aka set by DHCP. That may lead to confusion to
users. Since DHCP settings was changed to static settings, even though
the namesers set are the same.

To fix this, ctrld should save/restore only there's actual static DNS
set. For DHCP, thing should work as-is like we are doing.
2024-02-19 18:29:22 +07:00
Cuong Manh Le
18e8616834 cmd/cli: save DNS settings only once
While at it, also fixing a bug in getting saved nameservers.
2024-02-19 18:29:22 +07:00
Cuong Manh Le
d55563cac5 cmd/cli: removing current forwarders during setting DNS
Otherwise, old staled forwarders will be set in Windows DNS each time
the OS restart.
2024-02-19 18:29:22 +07:00
Ginder Singh
bb481d9bcc Added build script for mobile lib. 2024-02-19 18:29:22 +07:00
Cuong Manh Le
a163be3584 cmd/cli: preserve static DNS on Windows/Mac 2024-02-19 18:29:22 +07:00
Cuong Manh Le
891b7cb2c6 cmd/cli: integrating with Windows Server DNS feature
Windows Server which is running Active Directory will have its own DNS
server running. For typical setup, this DNS server will listen on all
interfaces, and receiving queries from others to be able to resolve
computer name in domain.

That would make ctrld default setup never works, since ctrld can listen
on port 53, but requests are never be routed to its listeners.

To integrate ctrld in this case, we need to listen on a local IP
address, then configure this IP as a Forwarder of local DNS server. With
this setup, computer name on domain can still be resolved, and other
queries can still be resolved by ctrld upstream as usual.
2024-02-19 18:29:22 +07:00
Cuong Manh Le
176c22f229 cmd/cli: handle general failure better during self check
After installing as a system service, "ctrld start" does an end-to-end
test for ensuring DNS can be resolved correctly. However, in case the
system is mis-configured (by firewall, other softwares ...) and the test
query could not be sent to ctrld listener, the current error message is
not helpful, causing the confusion from users perspective.

To improve this, selfCheckStatus function now returns the actual status
and error during its process. The caller can now rely on the service
status and the error to produce more useful/friendly message to users.
2024-02-19 18:29:22 +07:00
Ginder Singh
faa0ed06b6 Added pin protection to mobile lib. 2024-02-07 14:58:39 +07:00
Cuong Manh Le
9515db7faf cmd/cli: ensure ctrld was uninstalled before installing
In some old Windows systems, s.Uninstall does not remove the service
completely at the time s.Install was running, prevent ctrld from being
installed again.

Workaround this by attempting to uninstall ctrld several times, re-check
for service status after each attempt to ensure it was uninstalled.
2024-02-07 14:58:39 +07:00
Cuong Manh Le
d822bf4257 all: add pin protected deactivation 2024-02-07 14:58:38 +07:00
Cuong Manh Le
0826671809 cmd/cli: set DNS for all physical interfaces on Windows/Darwin 2024-02-07 14:40:51 +07:00
Cuong Manh Le
67d74774a9 all: include file information in Windows builds 2024-02-07 14:40:18 +07:00
Cuong Manh Le
5d65416227 internal/clientinfo: fill empty hostname based on MAC address
An interface may have multiple MAC addresses, that leads to the problem
when looking up hostname for its multiple <ip, mac> pairs, because the
"ip" map, which storing "mac => ip" mapping can only store 1 entry. It
ends up returns an empty hostname for a known MAC address.

Fixing this by filling empty hostname based on clients which is already
listed, ensuring all clients with the same MAC address will have the
same hostname information.
2024-02-07 14:39:34 +07:00
Yegor Sak
49441f62f3 Update file config.md 2024-02-07 14:39:17 +07:00
Cuong Manh Le
99651f6e5b internal/router: supports UniFi UXG products 2024-02-07 14:38:50 +07:00
Cuong Manh Le
edca1f4f89 Drop quic free build
Since go1.21, Go standard library have added support for QUIC protocol.
The binary size gains between quic and quic-free version is now minimal.
Removing the quic free build, simplify the code and build process.
2024-02-07 14:38:19 +07:00
Yegor S
3d834f00f6 Update README.md 2024-02-02 12:03:29 -05:00
Yegor S
61fb71b1fa Update README.md
bump go min version
2024-01-23 19:57:57 -05:00
Yegor S
f8967c376f Merge pull request #135 from Control-D-Inc/release-branch-v1.3.4
Release branch v1.3.4
2024-01-23 19:44:37 -05:00
Cuong Manh Le
6d3c86c0be internal/clientinfo: add kea-dhcp4 to readLeaseFile
While at it, also removing duplicated characters in cutset of
strings.Trim function.
2024-01-23 01:31:14 +07:00
Cuong Manh Le
e42554f892 internal/router/dnsmasq: always include client's mac/ip
Since ctrld now supports MAC rules, the client's mac and ip must always
be sent to ctrld. Otherwise, the mac policy won't work when ctrld is an
upstream of dnsmasq.
2024-01-22 23:13:31 +07:00
Cuong Manh Le
28984090e5 internal/router: report error if DNS shield is enabled in UniFi OS 2024-01-22 23:13:09 +07:00
Cuong Manh Le
251255c746 all: change bootstrap DNS for ipv4/ipv6 2024-01-22 23:12:55 +07:00
Cuong Manh Le
32709dc64c internal/router: use daemon -r option
So if ctrld is killed unexpectedly, daemon will respawn new ctrld and
keep the system DNS working.
2024-01-22 23:12:39 +07:00
Cuong Manh Le
71f26a6d81 Add prometheus exporter
Updates #6
2024-01-22 23:12:17 +07:00
Cuong Manh Le
44352f8006 all: make discovery refresh interval configurable 2024-01-22 23:10:59 +07:00
Cuong Manh Le
af38623590 internal/clientinfo: read mdns data from avahi-daemon cache
When avahi-daemon is avaibale, reading data from its cache help ctrld
populate the mdns data with already known services within local network,
allowing discover client info more quickly.
2024-01-22 23:10:47 +07:00
Cuong Manh Le
9c1665a759 internal/clientinfo: add kea-dhcp4 parser 2024-01-22 23:10:28 +07:00
Cuong Manh Le
eaad24e5e5 internal/clientinfo: add host_entries.conf parser 2024-01-22 23:10:17 +07:00
Ginder Singh
cfaf32f71a Added upstream proto option to mobile library
Changed android listener IP to 0.0.0.0
2024-01-22 23:10:02 +07:00
Cuong Manh Le
51b235b61a internal/clientinfo: implement ndp listen
So when new clients join the network, ctrld can really the event and
update client information to NDP table quickly.
2024-01-22 23:10:00 +07:00
Cuong Manh Le
0a6d9d4454 internal/clientinfo: add Ubios custom device name 2024-01-22 23:06:52 +07:00
Cuong Manh Le
dc700bbd52 internal/router: use max-cache-ttl=0 on some routers
On some routers, dnsmasq config may change cache-size dynamically after
ctrld starts, causing dnsmasq crashes.

Fixing this by using max-cache-ttl, which have the same effect with
setting cache-size=0 but won't conflict with existing routers config.
2024-01-22 23:05:56 +07:00
Cuong Manh Le
cb445825f4 internal/clientinfo: add NDP discovery 2024-01-22 23:05:44 +07:00
Cuong Manh Le
4d996e317b Fix wrong toml struct tag for arp discovery 2024-01-22 23:04:22 +07:00
Yegor S
30c9012004 Update config.md 2023-12-19 16:58:49 -05:00
Yegor S
2a23feaf4b Merge pull request #113 from Control-D-Inc/release-branch-v1.3.3
Release branch v1.3.3
2023-12-18 22:28:45 -05:00
62 changed files with 2250 additions and 360 deletions

View File

@@ -9,7 +9,7 @@ jobs:
fail-fast: false
matrix:
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
go: ["1.20.x"]
go: ["1.21.x"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3

9
.gitignore vendored
View File

@@ -3,3 +3,12 @@ gon.hcl
/Build
.DS_Store
# Release folder
dist/
# Binaries
ctrld-*
# generated file
cmd/cli/rsrc_*.syso

View File

@@ -10,7 +10,8 @@ A highly configurable DNS forwarding proxy with support for:
- Multiple network policy driven DNS query steering
- Policy driven domain based "split horizon" DNS with wildcard support
- Integrations with common router vendors and firmware
- LAN client discovery via DHCP, mDNS, and ARP
- LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing
- Prometheus metrics exporter
## TLDR
Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways.
@@ -61,7 +62,7 @@ $ docker pull controldns/ctrld
Alternatively, if you know what you're doing you can download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section for the appropriate platform.
## Build
Lastly, you can build `ctrld` from source which requires `go1.19+`:
Lastly, you can build `ctrld` from source which requires `go1.21+`:
```shell
$ go build ./cmd/ctrld
@@ -232,7 +233,6 @@ See [Contribution Guideline](./docs/contributing.md)
## Roadmap
The following functionality is on the roadmap and will be available in future releases.
- Prometheus metrics exporter
- DNS intercept mode
- Direct listener mode
- Support for more routers (let us know which ones)

View File

@@ -18,4 +18,5 @@ type LeaseFileFormat string
const (
Dnsmasq LeaseFileFormat = "dnsmasq"
IscDhcpd LeaseFileFormat = "isc-dhcpd"
KeaDHCP4 LeaseFileFormat = "kea-dhcp4"
)

View File

@@ -50,9 +50,10 @@ var (
)
var (
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
defaultConfigFile = "ctrld.toml"
rootCertPool *x509.CertPool
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
defaultConfigFile = "ctrld.toml"
rootCertPool *x509.CertPool
errSelfCheckNoAnswer = errors.New("no answer from ctrld listener")
)
var basicModeFlags = []string{"listen", "primary_upstream", "secondary_upstream", "domains"}
@@ -258,6 +259,16 @@ func initCLI() {
return
}
status, err := s.Status()
isCtrldInstalled := !errors.Is(err, service.ErrNotInstalled)
// If pin code was set, do not allow running start command.
if status == service.StatusRunning {
if err := checkDeactivationPin(s); isCheckDeactivationPinErr(err) {
os.Exit(deactivationPinInvalidExitCode)
}
}
if router.Name() != "" && iface != "" {
mainLog.Load().Debug().Msg("cleaning up router before installing")
_ = p.router.Cleanup()
@@ -266,7 +277,22 @@ func initCLI() {
tasks := []task{
{s.Stop, false},
{func() error { return doGenerateNextDNSConfig(nextdns) }, true},
{s.Uninstall, false},
{func() error { return ensureUninstall(s) }, false},
{func() error {
// If ctrld is installed, we should not save current DNS settings, because:
//
// - The DNS settings was being set by ctrld already.
// - We could not determine the state of DNS settings before installing ctrld.
if isCtrldInstalled {
return nil
}
// Save current DNS so we can restore later.
withEachPhysicalInterfaces("", "save DNS settings", func(i *net.Interface) error {
return saveCurrentStaticDNS(i)
})
return nil
}, false},
{s.Install, false},
{s.Start, true},
// Note that startCmd do not actually write ControlD config, but the config file was
@@ -280,17 +306,40 @@ func initCLI() {
return
}
status := selfCheckStatus(s)
switch status {
case service.StatusRunning:
ok, status, err := selfCheckStatus(s)
switch {
case ok && status == service.StatusRunning:
mainLog.Load().Notice().Msg("Service started")
default:
marker := bytes.Repeat([]byte("="), 32)
mainLog.Load().Error().Msg("ctrld service may not have started due to an error or misconfiguration, service log:")
_, _ = mainLog.Load().Write(marker)
for msg := range runCmdLogCh {
_, _ = mainLog.Load().Write([]byte(msg))
// If ctrld service is not running, emitting log obtained from ctrld process.
if status != service.StatusRunning {
mainLog.Load().Error().Msg("ctrld service may not have started due to an error or misconfiguration, service log:")
_, _ = mainLog.Load().Write(marker)
haveLog := false
for msg := range runCmdLogCh {
_, _ = mainLog.Load().Write([]byte(msg))
haveLog = true
}
// If we're unable to get log from "ctrld run", notice users about it.
if !haveLog {
mainLog.Load().Write([]byte(`<no log output is obtained from ctrld process>"`))
}
}
// Report any error if occurred.
if err != nil {
_, _ = mainLog.Load().Write(marker)
msg := fmt.Sprintf("An error occurred while performing test query: %s", err)
mainLog.Load().Write([]byte(msg))
}
// If ctrld service is running but selfCheckStatus failed, it could be related
// to user's system firewall configuration, notice users about it.
if status == service.StatusRunning {
_, _ = mainLog.Load().Write(marker)
mainLog.Load().Write([]byte(`ctrld service was running, but a DNS query could not be sent to its listener`))
mainLog.Load().Write([]byte(`Please check your system firewall if it is configured to block/intercept/redirect DNS queries`))
}
_, _ = mainLog.Load().Write(marker)
uninstall(p, s)
os.Exit(1)
@@ -364,6 +413,9 @@ func initCLI() {
return
}
initLogging()
if err := checkDeactivationPin(s); isCheckDeactivationPinErr(err) {
os.Exit(deactivationPinInvalidExitCode)
}
if doTasks([]task{{s.Stop, true}}) {
p.router.Cleanup()
p.resetDNS()
@@ -372,6 +424,8 @@ func initCLI() {
},
}
stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
stopCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
_ = stopCmd.Flags().MarkHidden("pin")
restartCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
@@ -518,10 +572,15 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
if iface == "" {
iface = "auto"
}
if err := checkDeactivationPin(s); isCheckDeactivationPinErr(err) {
os.Exit(deactivationPinInvalidExitCode)
}
uninstall(p, s)
},
}
uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, use "auto" for the default gateway interface`)
uninstallCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for uninstalling ctrld`)
_ = uninstallCmd.Flags().MarkHidden("pin")
listIfacesCmd := &cobra.Command{
Use: "list",
@@ -719,6 +778,10 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
sort.Strings(s)
return s
}
// If metrics is enabled, server set this for all clients, so we can check only the first one.
// Ideally, we may have a field in response to indicate that query count should be shown, but
// it would break earlier version of ctrld, which only look list of clients in response.
withQueryCount := len(clients) > 0 && clients[0].IncludeQueryCount
data := make([][]string, len(clients))
for i, c := range clients {
row := []string{
@@ -727,10 +790,17 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
c.Mac,
strings.Join(map2Slice(c.Source), ","),
}
if withQueryCount {
row = append(row, strconv.FormatInt(c.QueryCount, 10))
}
data[i] = row
}
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"IP", "Hostname", "Mac", "Discovered"})
headers := []string{"IP", "Hostname", "Mac", "Discovered"}
if withQueryCount {
headers = append(headers, "Queries")
}
table.SetHeader(headers)
table.SetAutoFormatHeaders(false)
table.AppendBulk(data)
table.Render()
@@ -753,6 +823,11 @@ func isMobile() bool {
return runtime.GOOS == "android" || runtime.GOOS == "ios"
}
// isAndroid reports whether the current OS is Android.
func isAndroid() bool {
return runtime.GOOS == "android"
}
// RunCobraCommand runs ctrld cli.
func RunCobraCommand(cmd *cobra.Command) {
noConfigStart = isNoConfigStart(cmd)
@@ -771,11 +846,20 @@ func RunMobile(appConfig *AppConfig, appCallback *AppCallback, stopCh chan struc
homedir = appConfig.HomeDir
verbose = appConfig.Verbose
cdUID = appConfig.CdUID
cdUpstreamProto = ctrld.ResolverTypeDOH
cdUpstreamProto = appConfig.UpstreamProto
logPath = appConfig.LogPath
run(appCallback, stopCh)
}
// CheckDeactivationPin checks if deactivation pin is valid
func CheckDeactivationPin(pin int64) int {
deactivationPin = pin
if err := checkDeactivationPin(nil); isCheckDeactivationPinErr(err) {
return deactivationPinInvalidExitCode
}
return 0
}
// run runs ctrld cli with given app callback and stop channel.
func run(appCallback *AppCallback, stopCh chan struct{}) {
if stopCh == nil {
@@ -1155,6 +1239,18 @@ func processNoConfigFlags(noConfigStart bool) {
v.Set("upstream", upstream)
}
// defaultDeactivationPin is the default value for cdDeactivationPin.
// If cdDeactivationPin equals to this default, it means the pin code is not set from Control D API.
const defaultDeactivationPin = -1
// cdDeactivationPin is used in cd mode to decide whether stop and uninstall commands can be run.
var cdDeactivationPin int64 = defaultDeactivationPin
// deactivationPinNotSet reports whether cdDeactivationPin was not set by processCDFlags.
func deactivationPinNotSet() bool {
return cdDeactivationPin == defaultDeactivationPin
}
func processCDFlags(cfg *ctrld.Config) error {
logger := mainLog.Load().With().Str("mode", "cd").Logger()
logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID)
@@ -1179,6 +1275,11 @@ func processCDFlags(cfg *ctrld.Config) error {
return err
}
if resolverConfig.DeactivationPin != nil {
logger.Debug().Msg("saving deactivation pin")
cdDeactivationPin = *resolverConfig.DeactivationPin
}
logger.Info().Msg("generating ctrld config from Control-D configuration")
*cfg = ctrld.Config{}
@@ -1294,41 +1395,44 @@ func defaultIfaceName() string {
return dri
}
func selfCheckStatus(s service.Service) service.Status {
// selfCheckStatus performs the end-to-end DNS test by sending query to ctrld listener.
// It returns a boolean to indicate whether the check is succeeded, the actual status
// of ctrld service, and an additional error if any.
func selfCheckStatus(s service.Service) (bool, service.Status, error) {
status, err := s.Status()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not get service status")
return status
return false, service.StatusUnknown, err
}
// If ctrld is not running, do nothing, just return the status as-is.
if status != service.StatusRunning {
return status
return false, status, nil
}
dir, err := socketDir()
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to check ctrld listener status: could not get home directory")
return service.StatusUnknown
return false, status, err
}
mainLog.Load().Debug().Msg("waiting for ctrld listener to be ready")
cc := newSocketControlClient(s, dir)
if cc == nil {
return service.StatusUnknown
return false, status, errors.New("could not connect to control server")
}
resp, err := cc.post(startedPath, nil)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to connect to control server")
return service.StatusUnknown
return false, status, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
mainLog.Load().Error().Msg("ctrld listener is not ready")
return service.StatusUnknown
return false, status, errors.New("ctrld listener is not ready")
}
// Not a ctrld upstream, return status as-is.
if cfg.FirstUpstream().VerifyDomain() == "" {
return status
return true, status, nil
}
mainLog.Load().Debug().Msg("ctrld listener is ready")
@@ -1353,12 +1457,12 @@ func selfCheckStatus(s service.Service) service.Status {
domain := cfg.FirstUpstream().VerifyDomain()
if domain == "" {
// Nothing to do, return the status as-is.
return status
return true, status, nil
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
mainLog.Load().Error().Err(err).Msg("could not watch config change")
return service.StatusUnknown
return false, status, err
}
defer watcher.Close()
@@ -1397,14 +1501,18 @@ func selfCheckStatus(s service.Service) service.Status {
m := new(dns.Msg)
m.SetQuestion(domain+".", dns.TypeA)
m.RecursionDesired = true
r, _, err := c.ExchangeContext(ctx, m, net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port)))
r, _, exErr := exchangeContextWithTimeout(c, time.Second, m, net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port)))
if r != nil && r.Rcode == dns.RcodeSuccess && len(r.Answer) > 0 {
mainLog.Load().Debug().Msgf("self-check against %q succeeded", domain)
return status
return true, status, nil
}
// Return early if this is a connection refused.
if errConnectionRefused(exErr) {
return false, status, exErr
}
lastAnswer = r
lastErr = err
bo.BackOff(ctx, fmt.Errorf("ExchangeContext: %w", err))
lastErr = exErr
bo.BackOff(ctx, fmt.Errorf("ExchangeContext: %w", exErr))
}
mainLog.Load().Debug().Msgf("self-check against %q failed", domain)
lc := cfg.FirstListener()
@@ -1419,9 +1527,9 @@ func selfCheckStatus(s service.Service) service.Status {
for _, s := range strings.Split(lastAnswer.String(), "\n") {
mainLog.Load().Debug().Msgf("%s", s)
}
mainLog.Load().Debug().Msg(marker)
return false, status, errSelfCheckNoAnswer
}
return service.StatusUnknown
return false, status, lastErr
}
func userHomeDir() (string, error) {
@@ -1618,10 +1726,18 @@ type listenerConfigCheck struct {
// mobileListenerPort returns hardcoded port for mobile platforms.
func mobileListenerPort() int {
if runtime.GOOS == "ios" {
return 53
if isAndroid() {
return 5354
}
return 5354
return 53
}
// mobileListenerIp returns hardcoded listener ip for mobile platforms
func mobileListenerIp() string {
if isAndroid() {
return "0.0.0.0"
}
return "127.0.0.1"
}
// updateListenerConfig updates the config for listeners if not defined,
@@ -1640,10 +1756,17 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, fata
lcc := make(map[string]*listenerConfigCheck)
cdMode := cdUID != ""
nextdnsMode := nextdns != ""
// For Windows server with local Dns server running, we can only try on random local IP.
hasLocalDnsServer := windowsHasLocalDnsServerRunning()
for n, listener := range cfg.Listener {
lcc[n] = &listenerConfigCheck{}
if listener.IP == "" {
listener.IP = "0.0.0.0"
if hasLocalDnsServer {
// Windows Server lies to us that we could listen on 0.0.0.0:53
// even there's a process already done that, stick to local IP only.
listener.IP = "127.0.0.1"
}
lcc[n].IP = true
}
if listener.Port == 0 {
@@ -1652,9 +1775,15 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, fata
}
// In cd mode, we always try to pick an ip:port pair to work.
// Same if nextdns resolver is used.
//
// Except on Windows Server with local Dns running,
// we could only listen on random local IP port 53.
if cdMode || nextdnsMode {
lcc[n].IP = true
lcc[n].Port = true
if hasLocalDnsServer {
lcc[n].Port = false
}
}
updated = updated || lcc[n].IP || lcc[n].Port
}
@@ -1670,9 +1799,8 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, fata
delete(cfg.Listener, k)
}
}
// In cd mode, always use 127.0.0.1:5354.
if cdMode {
firstLn.IP = "127.0.0.1" // Mobile platforms allows running listener only on loop back address.
firstLn.IP = mobileListenerIp()
firstLn.Port = mobileListenerPort()
// TODO: use clear(lcc) once upgrading to go 1.21
for k := range lcc {
@@ -1741,6 +1869,11 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, fata
tryAllPort53 := true
tryOldIPPort5354 := true
tryPort5354 := true
if hasLocalDnsServer {
tryAllPort53 = false
tryOldIPPort5354 = false
tryPort5354 = false
}
attempts := 0
maxAttempts := 10
for {
@@ -2026,3 +2159,103 @@ func noticeWritingControlDConfig() error {
}
return nil
}
// deactivationPinInvalidExitCode indicates exit code due to invalid pin code.
const deactivationPinInvalidExitCode = 126
// errInvalidDeactivationPin indicates that the deactivation pin is invalid.
var errInvalidDeactivationPin = errors.New("deactivation pin is invalid")
// errRequiredDeactivationPin indicates that the deactivation pin is required but not provided by users.
var errRequiredDeactivationPin = errors.New("deactivation pin is required to stop or uninstall the service")
// checkDeactivationPin validates if the deactivation pin matches one in ControlD config.
func checkDeactivationPin(s service.Service) error {
dir, err := socketDir()
if err != nil {
mainLog.Load().Err(err).Msg("could not check deactivation pin")
return err
}
var cc *controlClient
if s == nil {
cc = newControlClient(filepath.Join(dir, ctrldControlUnixSock))
} else {
cc = newSocketControlClient(s, dir)
}
if cc == nil {
return nil // ctrld is not running.
}
data, _ := json.Marshal(&deactivationRequest{Pin: deactivationPin})
resp, _ := cc.post(deactivationPath, bytes.NewReader(data))
if resp != nil {
switch resp.StatusCode {
case http.StatusBadRequest:
mainLog.Load().Error().Msg(errRequiredDeactivationPin.Error())
return errRequiredDeactivationPin // pin is required
case http.StatusOK:
return nil // valid pin
case http.StatusNotFound:
return nil // the server is running older version of ctrld
}
}
mainLog.Load().Error().Msg(errInvalidDeactivationPin.Error())
return errInvalidDeactivationPin
}
// isCheckDeactivationPinErr reports whether there is an error during check deactivation pin process.
func isCheckDeactivationPinErr(err error) bool {
return errors.Is(err, errInvalidDeactivationPin) || errors.Is(err, errRequiredDeactivationPin)
}
// ensureUninstall ensures that s.Uninstall will remove ctrld service from system completely.
func ensureUninstall(s service.Service) error {
maxAttempts := 10
var err error
for i := 0; i < maxAttempts; i++ {
err = s.Uninstall()
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
return nil
}
time.Sleep(time.Second)
}
return errors.Join(err, errors.New("uninstall failed"))
}
// exchangeContextWithTimeout wraps c.ExchangeContext with the given timeout.
func exchangeContextWithTimeout(c *dns.Client, timeout time.Duration, msg *dns.Msg, addr string) (*dns.Msg, time.Duration, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return c.ExchangeContext(ctx, msg, addr)
}
// powershell runs the given powershell command.
func powershell(cmd string) ([]byte, error) {
out, err := exec.Command("powershell", "-Command", cmd).CombinedOutput()
return bytes.TrimSpace(out), err
}
// windowsHasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
func windowsHasLocalDnsServerRunning() bool {
if runtime.GOOS == "windows" {
out, _ := powershell("Get-WindowsFeature -Name DNS")
if !bytes.Contains(bytes.ToLower(out), []byte("installed")) {
return false
}
_, err := powershell("Get-Process -Name DNS")
return err == nil
}
return false
}
// absHomeDir returns the absolute path to given filename using home directory as root dir.
func absHomeDir(filename string) string {
if homedir != "" {
return filepath.Join(homedir, filename)
}
dir, err := userHomeDir()
if err != nil {
return filename
}
return filepath.Join(dir, filename)
}

View File

@@ -27,3 +27,8 @@ func newControlClient(addr string) *controlClient {
func (c *controlClient) post(path string, data io.Reader) (*http.Response, error) {
return c.c.Post("http://unix"+path, contentTypeJson, data)
}
// deactivationRequest represents request for validating deactivation pin.
type deactivationRequest struct {
Pin int64 `json:"pin"`
}

View File

@@ -10,14 +10,17 @@ import (
"sort"
"time"
dto "github.com/prometheus/client_model/go"
"github.com/Control-D-Inc/ctrld"
)
const (
contentTypeJson = "application/json"
listClientsPath = "/clients"
startedPath = "/started"
reloadPath = "/reload"
contentTypeJson = "application/json"
listClientsPath = "/clients"
startedPath = "/started"
reloadPath = "/reload"
deactivationPath = "/deactivation"
)
type controlServer struct {
@@ -66,6 +69,25 @@ func (p *prog) registerControlServerHandler() {
sort.Slice(clients, func(i, j int) bool {
return clients[i].IP.Less(clients[j].IP)
})
if p.cfg.Service.MetricsQueryStats {
for _, client := range clients {
client.IncludeQueryCount = true
dm := &dto.Metric{}
m, err := statsClientQueriesCount.MetricVec.GetMetricWithLabelValues(
client.IP.String(),
client.Mac,
client.Hostname,
)
if err != nil {
mainLog.Load().Debug().Err(err).Msgf("could not get metrics for client: %v", client)
continue
}
if err := m.Write(dm); err == nil {
client.QueryCount = int64(dm.Counter.GetValue())
}
}
}
if err := json.NewEncoder(w).Encode(&clients); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -125,6 +147,30 @@ func (p *prog) registerControlServerHandler() {
// Otherwise, reload is done.
w.WriteHeader(http.StatusOK)
}))
p.cs.register(deactivationPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
// Non-cd mode or pin code not set, always allowing deactivation.
if cdUID == "" || deactivationPinNotSet() {
w.WriteHeader(http.StatusOK)
return
}
var req deactivationRequest
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusPreconditionFailed)
mainLog.Load().Err(err).Msg("invalid deactivation request")
return
}
code := http.StatusForbidden
switch req.Pin {
case cdDeactivationPin:
code = http.StatusOK
case defaultDeactivationPin:
// If the pin code was set, but users do not provide --pin, return proper code to client.
code = http.StatusBadRequest
}
w.WriteHeader(code)
}))
}
func jsonResponse(next http.Handler) http.Handler {

View File

@@ -54,6 +54,14 @@ type proxyRequest struct {
ufr *upstreamForResult
}
// proxyResponse contains data for proxying a DNS response from upstream.
type proxyResponse struct {
answer *dns.Msg
cached bool
clientInfo bool
upstream string
}
// upstreamForResult represents the result of processing rules for a request.
type upstreamForResult struct {
upstreams []string
@@ -101,26 +109,49 @@ func (p *prog) serveDNS(listenerNum string) error {
fmtSrcToDest := fmtRemoteToLocal(listenerNum, ci.Hostname, remoteAddr.String())
t := time.Now()
ctrld.Log(ctx, mainLog.Load().Info(), "QUERY: %s: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain)
res := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, ci.Mac, domain)
ur := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, ci.Mac, domain)
labelValues := make([]string, 0, len(statsQueriesCountLabels))
labelValues = append(labelValues, net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)))
labelValues = append(labelValues, ci.IP)
labelValues = append(labelValues, ci.Mac)
labelValues = append(labelValues, ci.Hostname)
var answer *dns.Msg
if !res.matched && listenerConfig.Restricted {
if !ur.matched && listenerConfig.Restricted {
ctrld.Log(ctx, mainLog.Load().Info(), "query refused, %s does not match any network policy", remoteAddr.String())
answer = new(dns.Msg)
answer.SetRcode(m, dns.RcodeRefused)
labelValues = append(labelValues, "") // no upstream
} else {
var failoverRcode []int
if listenerConfig.Policy != nil {
failoverRcode = listenerConfig.Policy.FailoverRcodeNumbers
}
answer = p.proxy(ctx, &proxyRequest{
pr := p.proxy(ctx, &proxyRequest{
msg: m,
ci: ci,
failoverRcodes: failoverRcode,
ufr: res,
ufr: ur,
})
answer = pr.answer
rtt := time.Since(t)
ctrld.Log(ctx, mainLog.Load().Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
upstream := pr.upstream
switch {
case pr.cached:
upstream = "cache"
case pr.clientInfo:
upstream = "client_info_table"
}
labelValues = append(labelValues, upstream)
}
labelValues = append(labelValues, dns.TypeToString[q.Qtype])
labelValues = append(labelValues, dns.RcodeToString[answer.Rcode])
go func() {
p.WithLabelValuesInc(statsQueriesCount, labelValues...)
p.WithLabelValuesInc(statsClientQueriesCount, []string{ci.IP, ci.Mac, ci.Hostname}...)
}()
if err := w.WriteMsg(answer); err != nil {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveDNS: failed to send DNS response to client")
}
@@ -360,7 +391,7 @@ func (p *prog) proxyLanHostnameQuery(ctx context.Context, msg *dns.Msg) *dns.Msg
return nil
}
func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
var staleAnswer *dns.Msg
upstreams := req.ufr.upstreams
serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale
@@ -370,6 +401,8 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
upstreams = []string{upstreamOS}
}
res := &proxyResponse{}
// LAN/PTR lookup flow:
//
// 1. If there's matching rule, follow it.
@@ -384,14 +417,18 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
case isPrivatePtrLookup(req.msg):
isLanOrPtrQuery = true
if answer := p.proxyPrivatePtrLookup(ctx, req.msg); answer != nil {
return answer
res.answer = answer
res.clientInfo = true
return res
}
upstreams, upstreamConfigs = p.upstreamsAndUpstreamConfigForLanAndPtr(upstreams, upstreamConfigs)
ctrld.Log(ctx, mainLog.Load().Debug(), "private PTR lookup, using upstreams: %v", upstreams)
case isLanHostnameQuery(req.msg):
isLanOrPtrQuery = true
if answer := p.proxyLanHostnameQuery(ctx, req.msg); answer != nil {
return answer
res.answer = answer
res.clientInfo = true
return res
}
upstreams, upstreamConfigs = p.upstreamsAndUpstreamConfigForLanAndPtr(upstreams, upstreamConfigs)
ctrld.Log(ctx, mainLog.Load().Debug(), "lan hostname lookup, using upstreams: %v", upstreams)
@@ -413,7 +450,9 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
if cachedValue.Expire.After(now) {
ctrld.Log(ctx, mainLog.Load().Debug(), "hit cached response")
setCachedAnswerTTL(answer, now, cachedValue.Expire)
return answer
res.answer = answer
res.cached = true
return res
}
staleAnswer = answer
}
@@ -475,7 +514,9 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
ctrld.Log(ctx, mainLog.Load().Debug(), "serving stale cached response")
now := time.Now()
setCachedAnswerTTL(staleAnswer, now, now.Add(staleTTL))
return staleAnswer
res.answer = staleAnswer
res.cached = true
return res
}
continue
}
@@ -509,12 +550,15 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *dns.Msg {
hostname = req.ci.Hostname
}
ctrld.Log(ctx, mainLog.Load().Info(), "REPLY: %s -> %s (%s): %s", upstreams[n], req.ufr.srcAddr, hostname, dns.RcodeToString[answer.Rcode])
return answer
res.answer = answer
res.upstream = upstreamConfig.Endpoint
return res
}
ctrld.Log(ctx, mainLog.Load().Error(), "all %v endpoints failed", upstreams)
answer := new(dns.Msg)
answer.SetRcode(req.msg, dns.RcodeServerFailure)
return answer
res.answer = answer
return res
}
func (p *prog) upstreamsAndUpstreamConfigForLanAndPtr(upstreams []string, upstreamConfigs []*ctrld.UpstreamConfig) ([]string, []*ctrld.UpstreamConfig) {

View File

@@ -187,8 +187,8 @@ func TestCache(t *testing.T) {
got1 := prog.proxy(context.Background(), req1)
got2 := prog.proxy(context.Background(), req2)
assert.NotSame(t, got1, got2)
assert.Equal(t, answer1.Rcode, got1.Rcode)
assert.Equal(t, answer2.Rcode, got2.Rcode)
assert.Equal(t, answer1.Rcode, got1.answer.Rcode)
assert.Equal(t, answer2.Rcode, got2.answer.Rcode)
}
func Test_ipAndMacFromMsg(t *testing.T) {

View File

@@ -11,8 +11,9 @@ type AppCallback struct {
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
type AppConfig struct {
CdUID string
HomeDir string
Verbose int
LogPath string
CdUID string
HomeDir string
UpstreamProto string
Verbose int
LogPath string
}

View File

@@ -34,6 +34,7 @@ var (
ifaceStartStop string
nextdns string
cdUpstreamProto string
deactivationPin int64
mainLog atomic.Pointer[zerolog.Logger]
consoleWriter zerolog.ConsoleWriter

150
cmd/cli/metrics.go Normal file
View File

@@ -0,0 +1,150 @@
package cli
import (
"context"
"encoding/json"
"net"
"net/http"
"runtime"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/prometheus/prom2json"
)
// metricsServer represents a server to expose Prometheus metrics via HTTP.
type metricsServer struct {
server *http.Server
mux *http.ServeMux
reg *prometheus.Registry
addr string
started bool
}
// newMetricsServer returns new metrics server.
func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, error) {
mux := http.NewServeMux()
ms := &metricsServer{
server: &http.Server{Handler: mux},
mux: mux,
reg: reg,
}
ms.addr = addr
ms.registerMetricsServerHandler()
return ms, nil
}
// register adds handlers for given pattern.
func (ms *metricsServer) register(pattern string, handler http.Handler) {
ms.mux.Handle(pattern, handler)
}
// registerMetricsServerHandler adds handlers for metrics server.
func (ms *metricsServer) registerMetricsServerHandler() {
ms.register("/metrics", promhttp.HandlerFor(
ms.reg,
promhttp.HandlerOpts{
EnableOpenMetrics: true,
Timeout: 10 * time.Second,
},
))
ms.register("/metrics/json", jsonResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
g := prometheus.ToTransactionalGatherer(ms.reg)
mfs, done, err := g.Gather()
defer done()
if err != nil {
msg := "could not gather metrics"
mainLog.Load().Warn().Err(err).Msg(msg)
http.Error(w, msg, http.StatusInternalServerError)
return
}
result := make([]*prom2json.Family, 0, len(mfs))
for _, mf := range mfs {
result = append(result, prom2json.NewFamily(mf))
}
if err := json.NewEncoder(w).Encode(result); err != nil {
msg := "could not marshal metrics result"
mainLog.Load().Warn().Err(err).Msg(msg)
http.Error(w, msg, http.StatusInternalServerError)
return
}
})))
}
// start runs the metricsServer.
func (ms *metricsServer) start() error {
listener, err := net.Listen("tcp", ms.addr)
if err != nil {
return err
}
go ms.server.Serve(listener)
ms.started = true
return nil
}
// stop shutdowns the metricsServer within 2 seconds timeout.
func (ms *metricsServer) stop() error {
if !ms.started {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*1)
defer cancel()
return ms.server.Shutdown(ctx)
}
// runMetricsServer initializes metrics stats and runs the metrics server if enabled.
func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
if !p.metricsEnabled() {
return
}
// Reset all stats.
statsVersion.Reset()
statsQueriesCount.Reset()
statsClientQueriesCount.Reset()
reg := prometheus.NewRegistry()
// Register queries count stats if enabled.
if cfg.Service.MetricsQueryStats {
reg.MustRegister(statsQueriesCount)
reg.MustRegister(statsClientQueriesCount)
}
addr := p.cfg.Service.MetricsListener
ms, err := newMetricsServer(addr, reg)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not create new metrics server")
return
}
// Only start listener address if defined.
if addr != "" {
// Go runtime stats.
reg.MustRegister(collectors.NewBuildInfoCollector())
reg.MustRegister(collectors.NewGoCollector(
collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll),
))
// ctrld stats.
reg.MustRegister(statsVersion)
statsVersion.WithLabelValues(commit, runtime.Version(), curVersion()).Inc()
reg.MustRegister(statsTimeStart)
statsTimeStart.Set(float64(time.Now().Unix()))
mainLog.Load().Debug().Msgf("starting metrics server on: %s", addr)
if err := ms.start(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not start metrics server")
return
}
}
select {
case <-p.stopCh:
case <-ctx.Done():
case <-reloadCh:
}
if err := ms.stop(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not stop metrics server")
return
}
}

34
cmd/cli/net.go Normal file
View File

@@ -0,0 +1,34 @@
package cli
import "strings"
// Copied from https://gist.github.com/Ultraporing/fe52981f678be6831f747c206a4861cb
// Mac Address parts to look for, and identify non-physical devices. There may be more, update me!
var macAddrPartsToFilter = []string{
"00:03:FF", // Microsoft Hyper-V, Virtual Server, Virtual PC
"0A:00:27", // VirtualBox
"00:00:00:00:00", // Teredo Tunneling Pseudo-Interface
"00:50:56", // VMware ESX 3, Server, Workstation, Player
"00:1C:14", // VMware ESX 3, Server, Workstation, Player
"00:0C:29", // VMware ESX 3, Server, Workstation, Player
"00:05:69", // VMware ESX 3, Server, Workstation, Player
"00:1C:42", // Microsoft Hyper-V, Virtual Server, Virtual PC
"00:0F:4B", // Virtual Iron 4
"00:16:3E", // Red Hat Xen, Oracle VM, XenSource, Novell Xen
"08:00:27", // Sun xVM VirtualBox
"7A:79", // Hamachi
}
// Filters the possible physical interface address by comparing it to known popular VM Software addresses
// and Teredo Tunneling Pseudo-Interface.
//
//lint:ignore U1000 use in net_windows.go
func isPhysicalInterface(addr string) bool {
for _, macPart := range macAddrPartsToFilter {
if strings.HasPrefix(strings.ToLower(addr), strings.ToLower(macPart)) {
return false
}
}
return true
}

View File

@@ -42,3 +42,21 @@ func networkServiceName(ifaceName string, r io.Reader) string {
}
return ""
}
// validInterface reports whether the *net.Interface is a valid one, which includes:
//
// - en0: physical wireless
// - en1: Thunderbolt 1
// - en2: Thunderbolt 2
// - en3: Thunderbolt 3
// - en4: Thunderbolt 4
//
// For full list, see: https://unix.stackexchange.com/questions/603506/what-are-these-ifconfig-interfaces-on-macos
func validInterface(iface *net.Interface) bool {
switch iface.Name {
case "en0", "en1", "en2", "en3", "en4":
return true
default:
return false
}
}

View File

@@ -1,7 +1,9 @@
//go:build !darwin
//go:build !darwin && !windows
package cli
import "net"
func patchNetIfaceName(iface *net.Interface) error { return nil }
func validInterface(iface *net.Interface) bool { return true }

21
cmd/cli/net_windows.go Normal file
View File

@@ -0,0 +1,21 @@
package cli
import (
"net"
)
func patchNetIfaceName(iface *net.Interface) error {
return nil
}
// validInterface reports whether the *net.Interface is a valid one.
// On Windows, only physical interfaces are considered valid.
func validInterface(iface *net.Interface) bool {
if iface == nil {
return false
}
if isPhysicalInterface(iface.HardwareAddr.String()) {
return true
}
return false
}

View File

@@ -1,6 +1,9 @@
package cli
import (
"bufio"
"bytes"
"fmt"
"net"
"os/exec"
@@ -34,22 +37,23 @@ func setDNS(iface *net.Interface, nameservers []string) error {
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name}
args = append(args, nameservers...)
if err := exec.Command(cmd, args...).Run(); err != nil {
mainLog.Load().Error().Err(err).Msgf("setDNS failed, ips = %q", nameservers)
return err
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
return fmt.Errorf("%v: %w", string(out), err)
}
return nil
}
// TODO(cuonglm): use system API
func resetDNS(iface *net.Interface) error {
if ns := savedStaticNameservers(iface); len(ns) > 0 {
if err := setDNS(iface, ns); err == nil {
return nil
}
}
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name, "empty"}
if err := exec.Command(cmd, args...).Run(); err != nil {
mainLog.Load().Error().Err(err).Msgf("resetDNS failed")
return err
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
return fmt.Errorf("%v: %w", string(out), err)
}
return nil
}
@@ -57,3 +61,22 @@ func resetDNS(iface *net.Interface) error {
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers("")
}
// currentStaticDNS returns the current static DNS settings of given interface.
func currentStaticDNS(iface *net.Interface) ([]string, error) {
cmd := "networksetup"
args := []string{"-getdnsservers", iface.Name}
out, err := exec.Command(cmd, args...).Output()
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(bytes.NewReader(out))
var ns []string
for scanner.Scan() {
line := scanner.Text()
if ip := net.ParseIP(line); ip != nil {
ns = append(ns, ip.String())
}
}
return ns, nil
}

View File

@@ -66,3 +66,8 @@ func resetDNS(iface *net.Interface) error {
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers("")
}
// currentStaticDNS returns the current static DNS settings of given interface.
func currentStaticDNS(iface *net.Interface) ([]string, error) {
return currentDNS(iface), nil
}

View File

@@ -9,12 +9,10 @@ import (
"net"
"net/netip"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/client6"
@@ -25,11 +23,6 @@ import (
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
const (
resolvConfPath = "/etc/resolv.conf"
resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
)
// allocate loopback ip
// sudo ip a add 127.0.0.2/24 dev lo
func allocateIP(ip string) error {
@@ -69,12 +62,6 @@ func setDNS(iface *net.Interface, nameservers []string) error {
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
defer func() {
if r.Mode() == "direct" {
go watchResolveConf(osConfig)
}
}()
trySystemdResolve := false
for i := 0; i < maxSetDNSAttempts; i++ {
if err := r.SetDNS(osConfig); err != nil {
@@ -203,6 +190,11 @@ func currentDNS(iface *net.Interface) []string {
return nil
}
// currentStaticDNS returns the current static DNS settings of given interface.
func currentStaticDNS(iface *net.Interface) ([]string, error) {
return currentDNS(iface), nil
}
func getDNSByResolvectl(iface string) []string {
b, err := exec.Command("resolvectl", "dns", "-i", iface).Output()
if err != nil {
@@ -309,59 +301,3 @@ func sliceIndex[S ~[]E, E comparable](s S, v E) int {
}
return -1
}
// watchResolveConf watches any changes to /etc/resolv.conf file,
// and reverting to the original config set by ctrld.
func watchResolveConf(oc dns.OSConfig) {
mainLog.Load().Debug().Msg("start watching /etc/resolv.conf file")
watcher, err := fsnotify.NewWatcher()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
return
}
// We watch /etc instead of /etc/resolv.conf directly,
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
watchDir := filepath.Dir(resolvConfPath)
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not add /etc/resolv.conf to watcher list")
return
}
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo") // interface name does not matter.
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Name != resolvConfPath { // skip if not /etc/resolv.conf changes.
continue
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
mainLog.Load().Debug().Msg("/etc/resolv.conf changes detected, reverting to ctrld setting")
if err := watcher.Remove(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
continue
}
if err := r.SetDNS(oc); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
}
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
return
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
}
}
}

View File

@@ -2,21 +2,56 @@ package cli
import (
"errors"
"fmt"
"net"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
const (
forwardersFilename = ".forwarders.txt"
v4InterfaceKeyPathFormat = `HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`
v6InterfaceKeyPathFormat = `HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
)
var (
setDNSOnce sync.Once
resetDNSOnce sync.Once
)
func setDNS(iface *net.Interface, nameservers []string) error {
if len(nameservers) == 0 {
return errors.New("empty DNS nameservers")
}
setDNSOnce.Do(func() {
// If there's a Dns server running, that means we are on AD with Dns feature enabled.
// Configuring the Dns server to forward queries to ctrld instead.
if windowsHasLocalDnsServerRunning() {
file := absHomeDir(forwardersFilename)
if data, _ := os.ReadFile(file); len(data) > 0 {
if err := removeDnsServerForwarders(strings.Split(string(data), ",")); err != nil {
mainLog.Load().Error().Err(err).Msg("could not remove current forwarders settings")
} else {
mainLog.Load().Debug().Msg("removed current forwarders settings.")
}
}
if err := os.WriteFile(file, []byte(strings.Join(nameservers, ",")), 0600); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
}
if err := addDnsServerForwarders(nameservers); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
}
}
})
primaryDNS := nameservers[0]
if err := setPrimaryDNS(iface, primaryDNS); err != nil {
if err := setPrimaryDNS(iface, primaryDNS, true); err != nil {
return err
}
if len(nameservers) > 1 {
@@ -28,20 +63,64 @@ func setDNS(iface *net.Interface, nameservers []string) error {
// TODO(cuonglm): should we use system API?
func resetDNS(iface *net.Interface) error {
resetDNSOnce.Do(func() {
// See corresponding comment in setDNS.
if windowsHasLocalDnsServerRunning() {
file := absHomeDir(forwardersFilename)
content, err := os.ReadFile(file)
if err != nil {
mainLog.Load().Error().Err(err).Msg("could not read forwarders settings")
return
}
nameservers := strings.Split(string(content), ",")
if err := removeDnsServerForwarders(nameservers); err != nil {
mainLog.Load().Error().Err(err).Msg("could not remove forwarders settings")
return
}
}
})
// Restoring ipv6 first.
if ctrldnet.SupportsIPv6ListenLocal() {
if output, err := netsh("interface", "ipv6", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp"); err != nil {
mainLog.Load().Warn().Err(err).Msgf("failed to reset ipv6 DNS: %s", string(output))
}
}
// Restoring ipv4 DHCP.
output, err := netsh("interface", "ipv4", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp")
if err != nil {
mainLog.Load().Error().Err(err).Msgf("failed to reset ipv4 DNS: %s", string(output))
return err
return fmt.Errorf("%s: %w", string(output), err)
}
// If there's static DNS saved, restoring it.
if nss := savedStaticNameservers(iface); len(nss) > 0 {
v4ns := make([]string, 0, 2)
v6ns := make([]string, 0, 2)
for _, ns := range nss {
if ctrldnet.IsIPv6(ns) {
v6ns = append(v6ns, ns)
} else {
v4ns = append(v4ns, ns)
}
}
for _, ns := range [][]string{v4ns, v6ns} {
if len(ns) == 0 {
continue
}
primaryDNS := ns[0]
if err := setPrimaryDNS(iface, primaryDNS, false); err != nil {
return err
}
if len(ns) > 1 {
secondaryDNS := ns[1]
_ = addSecondaryDNS(iface, secondaryDNS)
}
}
}
return nil
}
func setPrimaryDNS(iface *net.Interface, dns string) error {
func setPrimaryDNS(iface *net.Interface, dns string, disablev6 bool) error {
ipVer := "ipv4"
if ctrldnet.IsIPv6(dns) {
ipVer = "ipv6"
@@ -52,7 +131,7 @@ func setPrimaryDNS(iface *net.Interface, dns string) error {
mainLog.Load().Error().Err(err).Msgf("failed to set primary DNS: %s", string(output))
return err
}
if ipVer == "ipv4" && ctrldnet.SupportsIPv6ListenLocal() {
if disablev6 && ipVer == "ipv4" && ctrldnet.SupportsIPv6ListenLocal() {
// Disable IPv6 DNS, so the query will be fallback to IPv4.
_, _ = netsh("interface", "ipv6", "set", "dnsserver", idx, "static", "::1", "primary")
}
@@ -93,3 +172,54 @@ func currentDNS(iface *net.Interface) []string {
}
return ns
}
// currentStaticDNS returns the current static DNS settings of given interface.
func currentStaticDNS(iface *net.Interface) ([]string, error) {
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return nil, err
}
guid, err := luid.GUID()
if err != nil {
return nil, err
}
var ns []string
for _, path := range []string{v4InterfaceKeyPathFormat, v6InterfaceKeyPathFormat} {
interfaceKeyPath := path + guid.String()
found := false
for _, key := range []string{"NameServer", "ProfileNameServer"} {
if found {
continue
}
cmd := fmt.Sprintf(`Get-ItemPropertyValue -Path "%s" -Name "%s"`, interfaceKeyPath, key)
out, err := powershell(cmd)
if err == nil && len(out) > 0 {
found = true
ns = append(ns, strings.Split(string(out), ",")...)
}
}
}
return ns, nil
}
// addDnsServerForwarders adds given nameservers to DNS server forwarders list.
func addDnsServerForwarders(nameservers []string) error {
for _, ns := range nameservers {
cmd := fmt.Sprintf("Add-DnsServerForwarder -IPAddress %s", ns)
if out, err := powershell(cmd); err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
}
return nil
}
// removeDnsServerForwarders removes given nameservers from DNS server forwarders list.
func removeDnsServerForwarders(nameservers []string) error {
for _, ns := range nameservers {
cmd := fmt.Sprintf("Remove-DnsServerForwarder -IPAddress %s -Force", ns)
if out, err := powershell(cmd); err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"math/rand"
"net"
"net/netip"
@@ -13,6 +14,7 @@ import (
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
@@ -348,6 +350,13 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
p.checkDnsLoopTicker(ctx)
}()
wg.Add(1)
// Prometheus exporter goroutine.
go func() {
defer wg.Done()
p.runMetricsServer(ctx, reloadCh)
}()
if !reload {
// Stop writing log to unix socket.
consoleWriter.Out = os.Stdout
@@ -365,6 +374,11 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
wg.Wait()
}
// metricsEnabled reports whether prometheus exporter is enabled/disabled.
func (p *prog) metricsEnabled() bool {
return p.cfg.Service.MetricsQueryStats || p.cfg.Service.MetricsListener != ""
}
func (p *prog) Stop(s service.Service) error {
mainLog.Load().Info().Msg("Service stopped")
close(p.stopCh)
@@ -405,8 +419,14 @@ func (p *prog) setDNS() {
if iface == "" {
return
}
// allIfaces tracks whether we should set DNS for all physical interfaces.
allIfaces := false
if iface == "auto" {
iface = defaultIfaceName()
// If iface is "auto", it means user does not specify "--iface" flag.
// In this case, ctrld has to set DNS for all physical interfaces, so
// thing will still work when user switch from one to the other.
allIfaces = requiredMultiNICsConfig()
}
lc := cfg.FirstListener()
if lc == nil {
@@ -448,14 +468,29 @@ func (p *prog) setDNS() {
return
}
logger.Debug().Msg("setting DNS successfully")
if shouldWatchResolvconf() {
servers := make([]netip.Addr, len(nameservers))
for i := range nameservers {
servers[i] = netip.MustParseAddr(nameservers[i])
}
go watchResolvConf(netIface, servers, setResolvConf)
}
if allIfaces {
withEachPhysicalInterfaces(netIface.Name, "set DNS", func(i *net.Interface) error {
return setDNS(i, nameservers)
})
}
}
func (p *prog) resetDNS() {
if iface == "" {
return
}
allIfaces := false
if iface == "auto" {
iface = defaultIfaceName()
// See corresponding comments in (*prog).setDNS function.
allIfaces = requiredMultiNICsConfig()
}
logger := mainLog.Load().With().Str("iface", iface).Logger()
netIface, err := netInterface(iface)
@@ -473,6 +508,9 @@ func (p *prog) resetDNS() {
return
}
logger.Debug().Msg("Restoring DNS successfully")
if allIfaces {
withEachPhysicalInterfaces(netIface.Name, "reset DNS", resetDNS)
}
}
func randomLocalIP() string {
@@ -556,6 +594,15 @@ func errNetworkError(err error) bool {
return false
}
// errConnectionRefused reports whether err is connection refused.
func errConnectionRefused(err error) bool {
var opErr *net.OpError
if !errors.As(err, &opErr) {
return false
}
return errors.Is(opErr.Err, syscall.ECONNREFUSED) || errors.Is(opErr.Err, windowsECONNREFUSED)
}
func ifaceFirstPrivateIP(iface *net.Interface) string {
if iface == nil {
return ""
@@ -637,3 +684,87 @@ func canBeLocalUpstream(addr string) bool {
}
return false
}
// withEachPhysicalInterfaces runs the function f with each physical interfaces, excluding
// the interface that matches excludeIfaceName. The context is used to clarify the
// log message when error happens.
func withEachPhysicalInterfaces(excludeIfaceName, context string, f func(i *net.Interface) error) {
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
// Skip loopback/virtual interface.
if i.IsLoopback() || len(i.HardwareAddr) == 0 {
return
}
// Skip invalid interface.
if !validInterface(i.Interface) {
return
}
netIface := i.Interface
if err := patchNetIfaceName(netIface); err != nil {
mainLog.Load().Debug().Err(err).Msg("failed to patch net interface name")
return
}
// Skip excluded interface.
if netIface.Name == excludeIfaceName {
return
}
// TODO: investigate whether we should report this error?
if err := f(netIface); err == nil {
mainLog.Load().Debug().Msgf("%s for interface %q successfully", context, i.Name)
} else if !errors.Is(err, errSaveCurrentStaticDNSNotSupported) {
mainLog.Load().Err(err).Msgf("%s for interface %q failed", context, i.Name)
}
})
}
// requiredMultiNicConfig reports whether ctrld needs to set/reset DNS for multiple NICs.
func requiredMultiNICsConfig() bool {
switch runtime.GOOS {
case "windows", "darwin":
return true
default:
return false
}
}
var errSaveCurrentStaticDNSNotSupported = errors.New("saving current DNS is not supported on this platform")
// saveCurrentStaticDNS saves the current static DNS settings for restoring later.
// Only works on Windows and Mac.
func saveCurrentStaticDNS(iface *net.Interface) error {
switch runtime.GOOS {
case "windows", "darwin":
default:
return errSaveCurrentStaticDNSNotSupported
}
file := savedStaticDnsSettingsFilePath(iface)
ns, _ := currentStaticDNS(iface)
if len(ns) == 0 {
_ = os.Remove(file) // removing old static DNS settings
return nil
}
if err := os.Remove(file); err != nil && !errors.Is(err, fs.ErrNotExist) {
mainLog.Load().Warn().Err(err).Msg("could not remove old static DNS settings file")
}
mainLog.Load().Debug().Msgf("DNS settings for %s is static, saving ...", iface.Name)
if err := os.WriteFile(file, []byte(strings.Join(ns, ",")), 0600); err != nil {
mainLog.Load().Err(err).Msgf("could not save DNS settings for iface: %s", iface.Name)
return err
}
return nil
}
// savedStaticDnsSettingsFilePath returns the path to saved DNS settings of the given interface.
func savedStaticDnsSettingsFilePath(iface *net.Interface) string {
return absHomeDir(".dns_" + iface.Name)
}
// savedStaticNameservers returns the static DNS nameservers of the given interface.
//
//lint:ignore U1000 use in os_windows.go and os_darwin.go
func savedStaticNameservers(iface *net.Interface) []string {
file := savedStaticDnsSettingsFilePath(iface)
if data, _ := os.ReadFile(file); len(data) > 0 {
return strings.Split(string(data), ",")
}
return nil
}

57
cmd/cli/prometheus.go Normal file
View File

@@ -0,0 +1,57 @@
package cli
import "github.com/prometheus/client_golang/prometheus"
const (
metricsLabelListener = "listener"
metricsLabelClientSourceIP = "client_source_ip"
metricsLabelClientMac = "client_mac"
metricsLabelClientHostname = "client_hostname"
metricsLabelUpstream = "upstream"
metricsLabelRRType = "rr_type"
metricsLabelRCode = "rcode"
)
// statsVersion represent ctrld version.
var statsVersion = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ctrld_build_info",
Help: "Version of ctrld process.",
}, []string{"gitref", "goversion", "version"})
// statsTimeStart represents start time of ctrld service.
var statsTimeStart = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ctrld_time_seconds",
Help: "Start time of the ctrld process since unix epoch in seconds.",
})
var statsQueriesCountLabels = []string{
metricsLabelListener,
metricsLabelClientSourceIP,
metricsLabelClientMac,
metricsLabelClientHostname,
metricsLabelUpstream,
metricsLabelRRType,
metricsLabelRCode,
}
// statsQueriesCount counts total number of queries.
var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ctrld_queries_count",
Help: "Total number of queries.",
}, statsQueriesCountLabels)
// statsClientQueriesCount counts total number of queries of a client.
//
// The labels "client_source_ip", "client_mac", "client_hostname" are unbounded,
// thus this stat is highly inefficient if there are many devices.
var statsClientQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ctrld_client_queries_count",
Help: "Total number queries of a client.",
}, []string{metricsLabelClientSourceIP, metricsLabelClientMac, metricsLabelClientHostname})
// WithLabelValuesInc increases prometheus counter by 1 if query stats is enabled.
func (p *prog) WithLabelValuesInc(c *prometheus.CounterVec, lvs ...string) {
if p.cfg.Service.MetricsQueryStats {
c.WithLabelValues(lvs...).Inc()
}
}

65
cmd/cli/resolvconf.go Normal file
View File

@@ -0,0 +1,65 @@
package cli
import (
"net"
"net/netip"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
const (
resolvConfPath = "/etc/resolv.conf"
resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
)
// watchResolvConf watches any changes to /etc/resolv.conf file,
// and reverting to the original config set by ctrld.
func watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
mainLog.Load().Debug().Msg("start watching /etc/resolv.conf file")
watcher, err := fsnotify.NewWatcher()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
return
}
defer watcher.Close()
// We watch /etc instead of /etc/resolv.conf directly,
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
watchDir := filepath.Dir(resolvConfPath)
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not add /etc/resolv.conf to watcher list")
return
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Name != resolvConfPath { // skip if not /etc/resolv.conf changes.
continue
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
mainLog.Load().Debug().Msg("/etc/resolv.conf changes detected, reverting to ctrld setting")
if err := watcher.Remove(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
continue
}
if err := setDnsFn(iface, ns); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
}
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
return
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
}
}
}

View File

@@ -0,0 +1,20 @@
package cli
import (
"net"
"net/netip"
)
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
servers := make([]string, len(ns))
for i := range ns {
servers[i] = ns[i].String()
}
return setDNS(iface, servers)
}
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
func shouldWatchResolvconf() bool {
return true
}

View File

@@ -0,0 +1,40 @@
//go:build unix && !darwin
package cli
import (
"net"
"net/netip"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/dns"
)
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo") // interface name does not matter.
if err != nil {
return err
}
oc := dns.OSConfig{
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
return r.SetDNS(oc)
}
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
func shouldWatchResolvconf() bool {
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo") // interface name does not matter.
if err != nil {
return false
}
switch r.Mode() {
case "direct", "resolvconf":
return true
default:
return false
}
}

View File

@@ -0,0 +1,16 @@
package cli
import (
"net"
"net/netip"
)
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
func setResolvConf(_ *net.Interface, _ []netip.Addr) error {
return nil
}
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
func shouldWatchResolvconf() bool {
return false
}

View File

@@ -0,0 +1,20 @@
{
"RT_VERSION": {
"#1": {
"0000": {
"fixed": {
"file_version": "0.0.0.1"
},
"info": {
"0409": {
"CompanyName": "ControlD Inc",
"FileDescription": "Control D DNS daemon",
"ProductName": "ctrld",
"InternalName": "ctrld",
"LegalCopyright": "ControlD Inc 2024"
}
}
}
}
}
}

View File

@@ -0,0 +1,4 @@
//go:generate go-winres make --product-version=git-tag --file-version=git-tag
package cli
// Placeholder file for windows builds.

View File

@@ -28,14 +28,15 @@ type AppCallback interface {
// Start configures utility with config.toml from provided directory.
// This function will block until Stop is called
// Check port availability prior to calling it.
func (c *Controller) Start(CdUID string, HomeDir string, logLevel int, logPath string) {
func (c *Controller) Start(CdUID string, HomeDir string, UpstreamProto string, logLevel int, logPath string) {
if c.stopCh == nil {
c.stopCh = make(chan struct{})
c.Config = cli.AppConfig{
CdUID: CdUID,
HomeDir: HomeDir,
Verbose: logLevel,
LogPath: logPath,
CdUID: CdUID,
HomeDir: HomeDir,
UpstreamProto: UpstreamProto,
Verbose: logLevel,
LogPath: logPath,
}
appCallback := mapCallback(c.AppCallback)
cli.RunMobile(&c.Config, &appCallback, c.stopCh)
@@ -60,13 +61,13 @@ func mapCallback(callback AppCallback) cli.AppCallback {
}
}
func (c *Controller) Stop() bool {
if c.stopCh != nil {
func (c *Controller) Stop(Pin int64) int {
errorCode := cli.CheckDeactivationPin(Pin)
if errorCode == 0 && c.stopCh != nil {
close(c.stopCh)
c.stopCh = nil
return true
}
return false
return errorCode
}
func (c *Controller) IsRunning() bool {

View File

@@ -179,23 +179,26 @@ func (c *Config) FirstUpstream() *UpstreamConfig {
// ServiceConfig specifies the general ctrld config.
type ServiceConfig struct {
LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"`
LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"`
CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"`
CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"`
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"`
MaxConcurrentRequests *int `mapstructure:"max_concurrent_requests" toml:"max_concurrent_requests,omitempty" validate:"omitempty,gte=0"`
DHCPLeaseFile string `mapstructure:"dhcp_lease_file_path" toml:"dhcp_lease_file_path" validate:"omitempty,file"`
DHCPLeaseFileFormat string `mapstructure:"dhcp_lease_file_format" toml:"dhcp_lease_file_format" validate:"required_unless=DHCPLeaseFile '',omitempty,oneof=dnsmasq isc-dhcp"`
DiscoverMDNS *bool `mapstructure:"discover_mdns" toml:"discover_mdns,omitempty"`
DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_dhcp,omitempty"`
DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"`
DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"`
DiscoverHosts *bool `mapstructure:"discover_hosts" toml:"discover_hosts,omitempty"`
ClientIDPref string `mapstructure:"client_id_preference" toml:"client_id_preference,omitempty" validate:"omitempty,oneof=host mac"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"`
LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"`
CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"`
CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"`
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"`
MaxConcurrentRequests *int `mapstructure:"max_concurrent_requests" toml:"max_concurrent_requests,omitempty" validate:"omitempty,gte=0"`
DHCPLeaseFile string `mapstructure:"dhcp_lease_file_path" toml:"dhcp_lease_file_path" validate:"omitempty,file"`
DHCPLeaseFileFormat string `mapstructure:"dhcp_lease_file_format" toml:"dhcp_lease_file_format" validate:"required_unless=DHCPLeaseFile '',omitempty,oneof=dnsmasq isc-dhcp"`
DiscoverMDNS *bool `mapstructure:"discover_mdns" toml:"discover_mdns,omitempty"`
DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_arp,omitempty"`
DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"`
DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"`
DiscoverHosts *bool `mapstructure:"discover_hosts" toml:"discover_hosts,omitempty"`
DiscoverRefreshInterval int `mapstructure:"discover_refresh_interval" toml:"discover_refresh_interval,omitempty"`
ClientIDPref string `mapstructure:"client_id_preference" toml:"client_id_preference,omitempty" validate:"omitempty,oneof=host mac"`
MetricsQueryStats bool `mapstructure:"metrics_query_stats" toml:"metrics_query_stats,omitempty"`
MetricsListener string `mapstructure:"metrics_listener" toml:"metrics_listener,omitempty"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
}
// NetworkConfig specifies configuration for networks where ctrld will handle requests.

View File

@@ -1,5 +1,3 @@
//go:build !qf
package ctrld
import (

View File

@@ -1,9 +0,0 @@
//go:build qf
package ctrld
import "net/http"
func (uc *UpstreamConfig) setupDOH3Transport() {}
func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper { return nil }

View File

@@ -121,6 +121,29 @@ func TestConfigValidation(t *testing.T) {
}
}
func TestConfigDiscoverOverride(t *testing.T) {
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "test_config_discover_override")
v.SetConfigType("toml")
configStr := `
[service]
discover_arp = false
discover_dhcp = false
discover_hosts = false
discover_mdns = false
discover_ptr = false
`
require.NoError(t, v.ReadConfig(strings.NewReader(configStr)))
cfg := ctrld.Config{}
require.NoError(t, v.Unmarshal(&cfg))
require.False(t, *cfg.Service.DiscoverARP)
require.False(t, *cfg.Service.DiscoverDHCP)
require.False(t, *cfg.Service.DiscoverHosts)
require.False(t, *cfg.Service.DiscoverMDNS)
require.False(t, *cfg.Service.DiscoverPtr)
}
func defaultConfig(t *testing.T) *ctrld.Config {
v := viper.New()
ctrld.InitConfig(v, "test_load_default_config")

View File

@@ -200,6 +200,14 @@ Perform LAN client discovery using hosts file.
- Required: no
- Default: true
### discover_refresh_interval
Time in seconds between each discovery refresh loop to update new client information data.
The default value is 120 seconds, lower this value to make the discovery process run more aggressively.
- Type: integer
- Required: no
- Default: 120
### dhcp_lease_file_path
Relative or absolute path to a custom DHCP leases file location.
@@ -216,16 +224,27 @@ DHCP leases file format.
- Default: ""
### client_id_preference
Decide how the client ID is generated
Decide how the client ID is generated. By default client ID will use both MAC address and Hostname i.e. `hash(mac + host)`. To override this behavior, select one of the 2 allowed values to scope client ID to just MAC address OR Hostname.
If `host` -> client id will only use the hostname i.e.`hash(hostname)`.
If `mac` -> client id will only use the MAC address `hash(mac)`.
Else -> client ID will use both Mac and Hostname i.e. `hash(mac + host)
- Type: string
- Required: no
- Valid values: `mac`, `host`
- Default: ""
### metrics_query_stats
If set to `true`, collect and export the query counters, and show them in `clients list` command.
- Type: boolean
- Required: no
- Default: false
### metrics_listener
Specifying the `ip` and `port` of the Prometheus metrics server. The Prometheus metrics will be available on: `http://ip:port/metrics`. You can also append `/metrics/json` to get the same data in json format.
- Type: string
- Required: no
- Default: ""
## Upstream
The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to.
@@ -331,7 +350,7 @@ If `ip_stack` is empty, or undefined:
- Default value is `split` for Control D resolvers.
### send_client_info
Specifying whether to include client info when sending query to upstream.
Specifying whether to include client info when sending query to upstream. **This will only work with `doh` or `doh3` type upstreams.**
- Type: boolean
- Required: no

20
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/Control-D-Inc/ctrld
go 1.20
go 1.21
require (
github.com/coreos/go-systemd/v22 v22.5.0
@@ -15,10 +15,14 @@ require (
github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86
github.com/kardianos/service v1.2.1
github.com/mdlayher/ndp v1.0.1
github.com/miekg/dns v1.1.55
github.com/olekukonko/tablewriter v0.0.5
github.com/pelletier/go-toml/v2 v2.0.8
github.com/quic-go/quic-go v0.38.0
github.com/prometheus/client_golang v1.15.1
github.com/prometheus/client_model v0.4.0
github.com/prometheus/prom2json v1.3.3
github.com/quic-go/quic-go v0.41.0
github.com/rs/zerolog v1.28.0
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
@@ -34,11 +38,13 @@ require (
require (
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
@@ -51,6 +57,7 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect
@@ -59,8 +66,9 @@ require (
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/quic-go/qpack v0.4.0 // indirect
github.com/quic-go/qtls-go1-20 v0.3.2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
@@ -69,12 +77,14 @@ require (
github.com/subosito/gotenv v1.4.2 // indirect
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
go.uber.org/mock v0.3.0 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/mod v0.11.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.9.1 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

49
go.sum
View File

@@ -42,11 +42,16 @@ github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be h1:qBKVRi7Mom5h
github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cilium/ebpf v0.10.0 h1:nk5HPMeoBXtOzbkZBWym+ZWq1GIiHUsBFXxwewXAHLQ=
github.com/cilium/ebpf v0.10.0/go.mod h1:DPiVdY/kT534dgc9ERmvP8mWA+9gvwgKfRvk4nNWnoE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@@ -74,6 +79,7 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
@@ -98,8 +104,6 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -114,7 +118,9 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -126,6 +132,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -155,6 +162,7 @@ github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio=
@@ -199,8 +207,12 @@ github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 h1:lez6TS6aAau+8wXUP3G9I3TGlmPFEq2CTxBaRqY6AGE=
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y=
github.com/mdlayher/ndp v1.0.1 h1:+yAD79/BWyFlvAoeG5ncPS0ItlHP/eVbH7bQ6/+LVA4=
github.com/mdlayher/ndp v1.0.1/go.mod h1:rf3wKaWhAYJEXFKpgF8kQ2AxypxVbfNcZbqoAo6fVzk=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg=
@@ -217,6 +229,7 @@ github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
@@ -227,13 +240,21 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/prometheus/prom2json v1.3.3 h1:IYfSMiZ7sSOfliBoo89PcufjWO4eAR0gznGcETyaUgo=
github.com/prometheus/prom2json v1.3.3/go.mod h1:Pv4yIPktEkK7btWsrUTWDDDrnpUrAELaOCj+oFwlgmc=
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/quic-go/qtls-go1-20 v0.3.2 h1:rRgN3WfnKbyik4dBV8A6girlJVxGand/d+jVKbQq5GI=
github.com/quic-go/qtls-go1-20 v0.3.2/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
github.com/quic-go/quic-go v0.38.0 h1:T45lASr5q/TrVwt+jrVccmqHhPL2XuSyoCLVCpfOSLc=
github.com/quic-go/quic-go v0.38.0/go.mod h1:MPCuRq7KBK2hNcfKj/1iD1BGuN3eAYMeNxp3T42LRUg=
github.com/quic-go/quic-go v0.41.0 h1:aD8MmHfgqTURWNJy48IYFg2OnxwHT3JL7ahGs73lb4k=
github.com/quic-go/quic-go v0.41.0/go.mod h1:qCkNjqczPEvgsOnxZ0eCD14lv+B2LHlFAB++CNOh9hA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -283,13 +304,14 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo=
go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8=
go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -337,9 +359,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -372,7 +393,6 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
@@ -395,7 +415,6 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -435,10 +454,8 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -511,7 +528,6 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -608,7 +624,10 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View File

@@ -58,10 +58,12 @@ type ipLister interface {
}
type Client struct {
IP netip.Addr
Mac string
Hostname string
Source map[string]struct{}
IP netip.Addr
Mac string
Hostname string
Source map[string]struct{}
QueryCount int64
IncludeQueryCount bool
}
type Table struct {
@@ -70,10 +72,13 @@ type Table struct {
hostnameResolvers []HostnameResolver
refreshers []refresher
initOnce sync.Once
refreshInterval int
dhcp *dhcp
merlin *merlinDiscover
ubios *ubiosDiscover
arp *arpDiscover
ndp *ndpDiscover
ptr *ptrDiscover
mdns *mdns
hf *hostsFile
@@ -86,12 +91,17 @@ type Table struct {
}
func NewTable(cfg *ctrld.Config, selfIP, cdUID string, ns []string) *Table {
refreshInterval := cfg.Service.DiscoverRefreshInterval
if refreshInterval <= 0 {
refreshInterval = 2 * 60 // 2 minutes
}
return &Table{
svcCfg: cfg.Service,
quitCh: make(chan struct{}),
selfIP: selfIP,
cdUID: cdUID,
ptrNameservers: ns,
svcCfg: cfg.Service,
quitCh: make(chan struct{}),
selfIP: selfIP,
cdUID: cdUID,
ptrNameservers: ns,
refreshInterval: refreshInterval,
}
}
@@ -102,8 +112,9 @@ func (t *Table) AddLeaseFile(name string, format ctrld.LeaseFileFormat) {
clientInfoFiles[name] = format
}
// RefreshLoop runs all the refresher to update new client info data.
func (t *Table) RefreshLoop(ctx context.Context) {
timer := time.NewTicker(time.Minute * 5)
timer := time.NewTicker(time.Second * time.Duration(t.refreshInterval))
defer timer.Stop()
for {
select {
@@ -137,14 +148,26 @@ func (t *Table) init() {
// Otherwise, process all possible sources in order, that means
// the first result of IP/MAC/Hostname lookup will be used.
//
// Merlin custom clients.
// Routers custom clients:
// - Merlin
// - Ubios
if t.discoverDHCP() || t.discoverARP() {
t.merlin = &merlinDiscover{}
if err := t.merlin.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init Merlin discover")
} else {
t.hostnameResolvers = append(t.hostnameResolvers, t.merlin)
t.refreshers = append(t.refreshers, t.merlin)
t.ubios = &ubiosDiscover{}
discovers := map[string]interface {
refresher
HostnameResolver
}{
"Merlin": t.merlin,
"Ubios": t.ubios,
}
for platform, discover := range discovers {
if err := discover.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msgf("could not init %s discover", platform)
} else {
t.hostnameResolvers = append(t.hostnameResolvers, discover)
t.refreshers = append(t.refreshers, discover)
}
}
}
// Hosts file mapping.
@@ -172,17 +195,35 @@ func (t *Table) init() {
}
go t.dhcp.watchChanges()
}
// ARP table.
// ARP/NDP table.
if t.discoverARP() {
t.arp = &arpDiscover{}
t.ndp = &ndpDiscover{}
ctrld.ProxyLogger.Load().Debug().Msg("start arp discovery")
if err := t.arp.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init ARP discover")
} else {
t.ipResolvers = append(t.ipResolvers, t.arp)
t.macResolvers = append(t.macResolvers, t.arp)
t.refreshers = append(t.refreshers, t.arp)
discovers := map[string]interface {
refresher
IpResolver
MacResolver
}{
"ARP": t.arp,
"NDP": t.ndp,
}
for protocol, discover := range discovers {
if err := discover.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msgf("could not init %s discover", protocol)
} else {
t.ipResolvers = append(t.ipResolvers, discover)
t.macResolvers = append(t.macResolvers, discover)
t.refreshers = append(t.refreshers, discover)
}
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-t.quitCh
cancel()
}()
go t.ndp.listen(ctx)
}
// PTR lookup.
if t.discoverPTR() {
@@ -328,7 +369,7 @@ func (t *Table) ListClients() []*Client {
_ = r.refresh()
}
ipMap := make(map[string]*Client)
il := []ipLister{t.dhcp, t.arp, t.ptr, t.mdns, t.vni}
il := []ipLister{t.dhcp, t.arp, t.ndp, t.ptr, t.mdns, t.vni}
for _, ir := range il {
for _, ip := range ir.List() {
c, ok := ipMap[ip]
@@ -343,6 +384,7 @@ func (t *Table) ListClients() []*Client {
}
}
}
clientsByMAC := make(map[string]*Client)
for ip := range ipMap {
c := ipMap[ip]
for _, e := range t.lookupMacAll(ip) {
@@ -356,6 +398,7 @@ func (t *Table) ListClients() []*Client {
for _, e := range t.lookupHostnameAll(ip, c.Mac) {
if c.Hostname == "" && e.name != "" {
c.Hostname = e.name
clientsByMAC[c.Mac] = c
}
if e.name != "" {
c.Source[e.src] = struct{}{}
@@ -364,6 +407,11 @@ func (t *Table) ListClients() []*Client {
}
clients := make([]*Client, 0, len(ipMap))
for _, c := range ipMap {
// If we found a client with empty hostname, use hostname from
// an existed client which has the same MAC address.
if cFromMac := clientsByMAC[c.Mac]; cFromMac != nil && c.Hostname == "" {
c.Hostname = cFromMac.Hostname
}
clients = append(clients, c)
}
return clients

View File

@@ -44,3 +44,31 @@ func TestTable_LookupRFC1918IPv4(t *testing.T) {
t.Fatalf("unexpected result, want: %s, got: %s", rfc1918IPv4, got)
}
}
func TestTable_ListClients(t *testing.T) {
mac := "74:56:3c:44:eb:5e"
ipv6_1 := "2405:4803:a04b:4190:fbe9:cd14:d522:bbae"
ipv6_2 := "2405:4803:a04b:4190:fbe9:cd14:d522:bbab"
table := &Table{}
// NDP init.
table.ndp = &ndpDiscover{}
table.ndp.mac.Store(ipv6_1, mac)
table.ndp.mac.Store(ipv6_2, mac)
table.ndp.ip.Store(mac, ipv6_1)
table.ndp.ip.Store(mac, ipv6_2)
table.ipResolvers = append(table.ipResolvers, table.ndp)
table.macResolvers = append(table.macResolvers, table.ndp)
hostname := "foo"
// mdns init.
table.mdns = &mdns{}
table.mdns.name.Store(ipv6_2, hostname)
table.hostnameResolvers = append(table.hostnameResolvers, table.mdns)
for _, c := range table.ListClients() {
if c.Hostname != hostname {
t.Fatalf("missing hostname for client: %v", c)
}
}
}

View File

@@ -3,6 +3,7 @@ package clientinfo
import (
"bufio"
"bytes"
"encoding/csv"
"fmt"
"io"
"net"
@@ -187,6 +188,8 @@ func (d *dhcp) readLeaseFile(name string, format ctrld.LeaseFileFormat) error {
return d.dnsmasqReadClientInfoFile(name)
case ctrld.IscDhcpd:
return d.iscDHCPReadClientInfoFile(name)
case ctrld.KeaDHCP4:
return d.keaDhcp4ReadClientInfoFile(name)
}
return fmt.Errorf("unsupported format: %s, file: %s", format, name)
}
@@ -202,7 +205,8 @@ func (d *dhcp) dnsmasqReadClientInfoFile(name string) error {
}
// dnsmasqReadClientInfoReader likes ctrld.Dnsmasq, but reading from an io.Reader instead of file.
// dnsmasqReadClientInfoReader performs the same task as dnsmasqReadClientInfoFile,
// but by reading from an io.Reader instead of file.
func (d *dhcp) dnsmasqReadClientInfoReader(reader io.Reader) error {
return lineread.Reader(reader, func(line []byte) error {
fields := bytes.Fields(line)
@@ -244,7 +248,8 @@ func (d *dhcp) iscDHCPReadClientInfoFile(name string) error {
return d.iscDHCPReadClientInfoReader(f)
}
// iscDHCPReadClientInfoReader likes ctrld.IscDhcpd, but reading from an io.Reader instead of file.
// iscDHCPReadClientInfoReader performs the same task as iscDHCPReadClientInfoFile,
// but by reading from an io.Reader instead of file.
func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error {
s := bufio.NewScanner(reader)
var ip, mac, hostname string
@@ -287,6 +292,58 @@ func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error {
return nil
}
// keaDhcp4ReadClientInfoFile populates dhcp table with client info reading from kea dhcp4 lease file.
func (d *dhcp) keaDhcp4ReadClientInfoFile(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
return d.keaDhcp4ReadClientInfoReader(bufio.NewReader(f))
}
// keaDhcp4ReadClientInfoReader performs the same task as keaDhcp4ReadClientInfoFile,
// but by reading from an io.Reader instead of file.
func (d *dhcp) keaDhcp4ReadClientInfoReader(r io.Reader) error {
cr := csv.NewReader(r)
for {
record, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return err
}
if len(record) < 9 {
continue // hostname is at 9th field, so skipping record with not enough fields.
}
if record[0] == "address" {
continue // skip header.
}
mac := record[1]
if _, err := net.ParseMAC(mac); err != nil { // skip invalid MAC
continue
}
ip := normalizeIP(record[0])
if net.ParseIP(ip) == nil {
ctrld.ProxyLogger.Load().Warn().Msgf("invalid ip address entry: %q", ip)
ip = ""
}
d.mac.Store(ip, mac)
d.ip.Store(mac, ip)
hostname := record[8]
if hostname == "*" {
continue
}
name := normalizeHostname(hostname)
d.mac2name.Store(mac, name)
d.ip2name.Store(ip, name)
}
return nil
}
// addSelf populates current host info to dhcp, so queries from
// the host itself can be attached with proper client info.
func (d *dhcp) addSelf() {

View File

@@ -15,4 +15,5 @@ var clientInfoFiles = map[string]ctrld.LeaseFileFormat{
"/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS
"/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense
"/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla
"/var/lib/kea/dhcp4.leases": ctrld.KeaDHCP4, // Pfsense
}

View File

@@ -67,6 +67,41 @@ lease 192.168.1.2 {
"00:00:00:00:00:04",
"example",
},
{
"kea-dhcp4 good",
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
`,
d.keaDhcp4ReadClientInfoReader,
"00:00:00:00:00:05",
"foo",
},
{
"kea-dhcp4 no-header",
`192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0`,
d.keaDhcp4ReadClientInfoReader,
"00:00:00:00:00:05",
"foo",
},
{
"kea-dhcp4 hostname *",
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,*,0,,0
`,
d.keaDhcp4ReadClientInfoReader,
"00:00:00:00:00:05",
"*",
},
{
"kea-dhcp4 bad",
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
192.168.0.124,invalid_MAC,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
`,
d.keaDhcp4ReadClientInfoReader,
"00:00:00:00:00:05",
"foo",
},
}
for _, tc := range tests {
@@ -76,6 +111,12 @@ lease 192.168.1.2 {
t.Errorf("readClientInfoReader() error = %v", err)
}
val, existed := d.mac2name.Load(tc.mac)
if tc.hostname == "*" {
if existed {
t.Errorf("* hostname must be skipped")
}
return
}
if !existed {
t.Error("client info missing")
}

View File

@@ -1,8 +1,12 @@
package clientinfo
import (
"bufio"
"bytes"
"io"
"net/netip"
"os"
"strings"
"sync"
"github.com/fsnotify/fsnotify"
@@ -12,9 +16,10 @@ import (
)
const (
ipv4LocalhostName = "localhost"
ipv6LocalhostName = "ip6-localhost"
ipv6LoopbackName = "ip6-loopback"
ipv4LocalhostName = "localhost"
ipv6LocalhostName = "ip6-localhost"
ipv6LoopbackName = "ip6-loopback"
hostEntriesConfPath = "/var/unbound/host_entries.conf"
)
// hostsFile provides client discovery functionality using system hosts file.
@@ -34,14 +39,9 @@ func (hf *hostsFile) init() error {
if err := hf.watcher.Add(hostsfile.HostsPath); err != nil {
return err
}
m, err := hostsfile.ParseHosts(hostsfile.ReadHostsFile())
if err != nil {
return err
}
hf.mu.Lock()
hf.m = m
hf.mu.Unlock()
return nil
// Conservatively adding hostEntriesConfPath, since it is not available everywhere.
_ = hf.watcher.Add(hostEntriesConfPath)
return hf.refresh()
}
// refresh reloads hosts file entries.
@@ -52,6 +52,14 @@ func (hf *hostsFile) refresh() error {
}
hf.mu.Lock()
hf.m = m
// override hosts file with host_entries.conf content if present.
hem, err := parseHostEntriesConf(hostEntriesConfPath)
if err != nil && !os.IsNotExist(err) {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not read host_entries.conf file")
}
for k, v := range hem {
hf.m[k] = v
}
hf.mu.Unlock()
return nil
}
@@ -137,3 +145,46 @@ func isLocalhostName(hostname string) bool {
return false
}
}
// parseHostEntriesConf parses host_entries.conf file and returns parsed result.
func parseHostEntriesConf(path string) (map[string][]string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return parseHostEntriesConfFromReader(bytes.NewReader(b)), nil
}
// parseHostEntriesConfFromReader is like parseHostEntriesConf, but read from an io.Reader instead of file.
func parseHostEntriesConfFromReader(r io.Reader) map[string][]string {
hostsMap := map[string][]string{}
scanner := bufio.NewScanner(r)
localZone := ""
for scanner.Scan() {
line := scanner.Text()
if after, found := strings.CutPrefix(line, "local-zone:"); found {
after = strings.TrimSpace(after)
fields := strings.Fields(after)
if len(fields) > 1 {
localZone = strings.Trim(fields[0], `"`)
}
continue
}
// Only read "local-data-ptr: ..." line, it has all necessary information.
after, found := strings.CutPrefix(line, "local-data-ptr:")
if !found {
continue
}
after = strings.TrimSpace(after)
after = strings.Trim(after, `"`)
fields := strings.Fields(after)
if len(fields) != 2 {
continue
}
ip := fields[0]
name := strings.TrimSuffix(fields[1], "."+localZone)
hostsMap[ip] = append(hostsMap[ip], name)
}
return hostsMap
}

View File

@@ -1,6 +1,7 @@
package clientinfo
import (
"strings"
"testing"
)
@@ -31,3 +32,46 @@ func Test_hostsFile_LookupHostnameByIP(t *testing.T) {
})
}
}
func Test_parseHostEntriesConfFromReader(t *testing.T) {
const content = `local-zone: "localdomain" transparent
local-data-ptr: "127.0.0.1 localhost"
local-data: "localhost A 127.0.0.1"
local-data: "localhost.localdomain A 127.0.0.1"
local-data-ptr: "::1 localhost"
local-data: "localhost AAAA ::1"
local-data: "localhost.localdomain AAAA ::1"
local-data-ptr: "10.0.10.227 OPNsense.localdomain"
local-data: "OPNsense.localdomain A 10.0.10.227"
local-data: "OPNsense A 10.0.10.227"
local-data-ptr: "fe80::5a78:4e29:caa3:f9f7 OPNsense.localdomain"
local-data: "OPNsense.localdomain AAAA fe80::5a78:4e29:caa3:f9f7"
local-data: "OPNsense AAAA fe80::5a78:4e29:caa3:f9f7"
local-data-ptr: "1.1.1.1 banana-party.local.com"
local-data: "banana-party.local.com IN A 1.1.1.1"
local-data-ptr: "1.1.1.1 cheese-land.lan"
local-data: "cheese-land.lan IN A 1.1.1.1"
`
r := strings.NewReader(content)
hostsMap := parseHostEntriesConfFromReader(r)
if len(hostsMap) != 5 {
t.Fatalf("unexpected number of entries, want 5, got: %d", len(hostsMap))
}
for ip, names := range hostsMap {
switch ip {
case "1.1.1.1":
for _, name := range names {
if name != "banana-party.local.com" && name != "cheese-land.lan" {
t.Fatalf("unexpected names for 1.1.1.1: %v", names)
}
}
case "10.0.10.227":
if len(names) != 1 {
t.Fatalf("unexpected names for 10.0.10.227: %v", names)
}
if names[0] != "OPNsense" {
t.Fatalf("unexpected name: %s", names[0])
}
}
}
}

View File

@@ -1,11 +1,16 @@
package clientinfo
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net"
"net/netip"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -107,6 +112,7 @@ func (m *mdns) init(quitCh chan struct{}) error {
go m.probeLoop(v4ConnList, mdnsV4Addr, quitCh)
go m.probeLoop(v6ConnList, mdnsV6Addr, quitCh)
go m.getDataFromAvahiDaemonCache()
return nil
}
@@ -212,6 +218,44 @@ func (m *mdns) probe(conns []*net.UDPConn, remoteAddr net.Addr) error {
return err
}
// getDataFromAvahiDaemonCache reads entries from avahi-daemon cache to update mdns data.
func (m *mdns) getDataFromAvahiDaemonCache() {
if _, err := exec.LookPath("avahi-browse"); err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not find avahi-browse binary, skipping.")
return
}
// Run avahi-browse to discover services from cache:
// - "-a" -> all services.
// - "-r" -> resolve found services.
// - "-p" -> parseable format.
// - "-c" -> read from cache.
out, err := exec.Command("avahi-browse", "-a", "-r", "-p", "-c").Output()
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not browse services from avahi cache")
return
}
m.storeDataFromAvahiBrowseOutput(bytes.NewReader(out))
}
// storeDataFromAvahiBrowseOutput parses avahi-browse output from reader, then updating found data to mdns table.
func (m *mdns) storeDataFromAvahiBrowseOutput(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.FieldsFunc(scanner.Text(), func(r rune) bool {
return r == ';'
})
if len(fields) < 8 || fields[0] != "=" {
continue
}
ip := fields[7]
name := normalizeHostname(fields[6])
// Only using cache value if we don't have existed one.
if _, loaded := m.name.LoadOrStore(ip, name); !loaded {
ctrld.ProxyLogger.Load().Debug().Msgf("found hostname: %q, ip: %q via avahi cache", name, ip)
}
}
}
func multicastInterfaces() ([]net.Interface, error) {
ifaces, err := net.Interfaces()
if err != nil {

View File

@@ -0,0 +1,27 @@
package clientinfo
import (
"strings"
"testing"
)
func Test_mdns_storeDataFromAvahiBrowseOutput(t *testing.T) {
const content = `+;wlp0s20f3;IPv6;Foo\032\0402\041;_companion-link._tcp;local
+;wlp0s20f3;IPv4;Foo\032\0402\041;_companion-link._tcp;local
=;wlp0s20f3;IPv6;Foo\032\0402\041;_companion-link._tcp;local;Foo-2.local;192.168.1.123;64842;"rpBA=00:00:00:00:00:01" "rpHI=e6ae2cbbca0e" "rpAD=36566f4d850f" "rpVr=510.71.1" "rpHA=0ddc20fdddc8" "rpFl=0x30000" "rpHN=1d4a03afdefa" "rpMac=0"
=;wlp0s20f3;IPv4;Foo\032\0402\041;_companion-link._tcp;local;Foo-2.local;192.168.1.123;64842;"rpBA=00:00:00:00:00:01" "rpHI=e6ae2cbbca0e" "rpAD=36566f4d850f" "rpVr=510.71.1" "rpHA=0ddc20fdddc8" "rpFl=0x30000" "rpHN=1d4a03afdefa" "rpMac=0"
`
m := &mdns{}
m.storeDataFromAvahiBrowseOutput(strings.NewReader(content))
ip := "192.168.1.123"
val, loaded := m.name.LoadOrStore(ip, "")
if !loaded {
t.Fatal("missing Foo-2 data from mdns table")
}
wantHostname := "Foo-2"
hostname := val.(string)
if hostname != wantHostname {
t.Fatalf("unexpected hostname, want: %q, got: %q", wantHostname, hostname)
}
}

219
internal/clientinfo/ndp.go Normal file
View File

@@ -0,0 +1,219 @@
package clientinfo
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/netip"
"strings"
"sync"
"time"
"github.com/mdlayher/ndp"
"github.com/Control-D-Inc/ctrld"
)
// ndpDiscover provides client discovery functionality using NDP protocol.
type ndpDiscover struct {
mac sync.Map // ip => mac
ip sync.Map // mac => ip
}
// refresh re-scans the NDP table.
func (nd *ndpDiscover) refresh() error {
nd.scan()
return nil
}
// LookupIP returns the ipv6 associated with the input MAC address.
func (nd *ndpDiscover) LookupIP(mac string) string {
val, ok := nd.ip.Load(mac)
if !ok {
return ""
}
return val.(string)
}
// LookupMac returns the MAC address of the given IP address.
func (nd *ndpDiscover) LookupMac(ip string) string {
val, ok := nd.mac.Load(ip)
if !ok {
return ""
}
return val.(string)
}
// String returns human-readable format of ndpDiscover.
func (nd *ndpDiscover) String() string {
return "ndp"
}
// List returns all known IP addresses.
func (nd *ndpDiscover) List() []string {
if nd == nil {
return nil
}
var ips []string
nd.ip.Range(func(key, value any) bool {
ips = append(ips, value.(string))
return true
})
nd.mac.Range(func(key, value any) bool {
ips = append(ips, key.(string))
return true
})
return ips
}
// listen listens on ipv6 link local for Neighbor Solicitation message
// to update new neighbors information to ndp table.
func (nd *ndpDiscover) listen(ctx context.Context) {
ifi, err := firstInterfaceWithV6LinkLocal()
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("failed to find valid ipv6")
return
}
c, ip, err := ndp.Listen(ifi, ndp.LinkLocal)
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("ndp listen failed")
return
}
defer c.Close()
ctrld.ProxyLogger.Load().Debug().Msgf("listening ndp on: %s", ip.String())
for {
select {
case <-ctx.Done():
return
default:
}
_ = c.SetReadDeadline(time.Now().Add(30 * time.Second))
msg, _, from, readErr := c.ReadFrom()
if readErr != nil {
var opErr *net.OpError
if errors.As(readErr, &opErr) && (opErr.Timeout() || opErr.Temporary()) {
continue
}
ctrld.ProxyLogger.Load().Debug().Err(readErr).Msg("ndp read loop error")
return
}
// Only looks for neighbor solicitation message, since new clients
// which join network will broadcast this message to us.
am, ok := msg.(*ndp.NeighborSolicitation)
if !ok {
continue
}
fromIP := from.String()
for _, opt := range am.Options {
if lla, ok := opt.(*ndp.LinkLayerAddress); ok {
mac := lla.Addr.String()
nd.mac.Store(fromIP, mac)
nd.ip.Store(mac, fromIP)
}
}
}
}
// scanWindows populates NDP table using information from "netsh" command.
func (nd *ndpDiscover) scanWindows(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
if mac := parseMAC(fields[1]); mac != "" {
nd.mac.Store(fields[0], mac)
nd.ip.Store(mac, fields[0])
}
}
}
// scanUnix populates NDP table using information from "ndp" command.
func (nd *ndpDiscover) scanUnix(r io.Reader) {
scanner := bufio.NewScanner(r)
scanner.Scan() // skip header
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
if mac := parseMAC(fields[1]); mac != "" {
ip := fields[0]
if idx := strings.IndexByte(ip, '%'); idx != -1 {
ip = ip[:idx]
}
nd.mac.Store(ip, mac)
nd.ip.Store(mac, ip)
}
}
}
// normalizeMac ensure the given MAC address have the proper format
// before being parsed.
//
// Example, changing "00:0:00:0:00:01" to "00:00:00:00:00:01", which
// can be seen on Darwin.
func normalizeMac(mac string) string {
if len(mac) == 17 {
return mac
}
// Windows use "-" instead of ":" as separator.
mac = strings.ReplaceAll(mac, "-", ":")
parts := strings.Split(mac, ":")
if len(parts) != 6 {
return ""
}
for i, c := range parts {
if len(c) == 1 {
parts[i] = "0" + c
}
}
return strings.Join(parts, ":")
}
// parseMAC parses the input MAC, doing normalization,
// and return the result after calling net.ParseMac function.
func parseMAC(mac string) string {
hw, _ := net.ParseMAC(normalizeMac(mac))
return hw.String()
}
// firstInterfaceWithV6LinkLocal returns the first interface which is capable of using NDP.
func firstInterfaceWithV6LinkLocal() (*net.Interface, error) {
ifis, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, ifi := range ifis {
// Skip if iface is down/loopback/non-multicast.
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 || ifi.Flags&net.FlagMulticast == 0 {
continue
}
addrs, err := ifi.Addrs()
if err != nil {
return nil, err
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip, ok := netip.AddrFromSlice(ipNet.IP)
if !ok {
return nil, fmt.Errorf("invalid ip address: %s", ipNet.String())
}
if ip.Is6() && !ip.Is4In6() {
return &ifi, nil
}
}
}
return nil, errors.New("no interface can be used")
}

View File

@@ -0,0 +1,24 @@
package clientinfo
import (
"github.com/vishvananda/netlink"
"github.com/Control-D-Inc/ctrld"
)
// scan populates NDP table using information from system mappings.
func (nd *ndpDiscover) scan() {
neighs, err := netlink.NeighList(0, netlink.FAMILY_V6)
if err != nil {
ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not get neigh list")
return
}
for _, n := range neighs {
ip := n.IP.String()
mac := n.HardwareAddr.String()
nd.mac.Store(ip, mac)
nd.ip.Store(mac, ip)
}
}

View File

@@ -0,0 +1,31 @@
//go:build !linux
package clientinfo
import (
"bytes"
"os/exec"
"runtime"
"github.com/Control-D-Inc/ctrld"
)
// scan populates NDP table using information from system mappings.
func (nd *ndpDiscover) scan() {
switch runtime.GOOS {
case "windows":
data, err := exec.Command("netsh", "interface", "ipv6", "show", "neighbors").Output()
if err != nil {
ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not query ndp table")
return
}
nd.scanWindows(bytes.NewReader(data))
default:
data, err := exec.Command("ndp", "-an").Output()
if err != nil {
ctrld.ProxyLogger.Load().Warn().Err(err).Msg("could not query ndp table")
return
}
nd.scanUnix(bytes.NewReader(data))
}
}

View File

@@ -0,0 +1,64 @@
package clientinfo
import (
"strings"
"sync"
"testing"
)
func Test_ndpDiscover_scanUnix(t *testing.T) {
r := strings.NewReader(`Neighbor Linklayer Address Netif Expire St Flgs Prbs
2405:4802:1f90:fda0:1459:ec89:523d:3583 00:0:00:0:00:01 en0 permanent R
2405:4802:1f90:fda0:186b:c54a:1370:c196 (incomplete) en0 expired N
2405:4802:1f90:fda0:88de:14ef:6a8c:579a 00:0:00:0:00:02 en0 permanent R
fe80::1%lo0 (incomplete) lo0 permanent R
`)
nd := &ndpDiscover{}
nd.scanUnix(r)
for _, m := range []*sync.Map{&nd.mac, &nd.ip} {
count := 0
m.Range(func(key, value any) bool {
count++
return true
})
if count != 2 {
t.Errorf("unexpected count, want 2, got: %d", count)
}
}
}
func Test_ndpDiscover_scanWindows(t *testing.T) {
r := strings.NewReader(`Interface 14: Wi-Fi
Internet Address Physical Address Type
-------------------------------------------- ----------------- -----------
2405:4802:1f90:fda0:ffff:ffff:ffff:ff88 00-00-00-00-00-00 Unreachable
fe80::1 60-57-47-21-dd-00 Reachable (Router)
fe80::6257:47ff:fe21:dd00 60-57-47-21-dd-00 Reachable (Router)
ff02::1 33-33-00-00-00-01 Permanent
ff02::2 33-33-00-00-00-02 Permanent
ff02::c 33-33-00-00-00-0c Permanent
`)
nd := &ndpDiscover{}
nd.scanWindows(r)
count := 0
nd.mac.Range(func(key, value any) bool {
count++
return true
})
if count != 6 {
t.Errorf("unexpected count, want 6, got: %d", count)
}
count = 0
nd.ip.Range(func(key, value any) bool {
count++
return true
})
if count != 5 {
t.Errorf("unexpected count, want 5, got: %d", count)
}
}

View File

@@ -0,0 +1,78 @@
package clientinfo
import (
"bytes"
"encoding/json"
"io"
"os/exec"
"strings"
"sync"
"github.com/Control-D-Inc/ctrld/internal/router"
"github.com/Control-D-Inc/ctrld/internal/router/ubios"
)
// ubiosDiscover provides client discovery functionality on Ubios routers.
type ubiosDiscover struct {
hostname sync.Map // mac => hostname
}
// refresh reloads unifi devices from database.
func (u *ubiosDiscover) refresh() error {
if router.Name() != ubios.Name {
return nil
}
return u.refreshDevices()
}
// LookupHostnameByIP returns hostname for given IP.
func (u *ubiosDiscover) LookupHostnameByIP(ip string) string {
return ""
}
// LookupHostnameByMac returns unifi device custom hostname for the given MAC address.
func (u *ubiosDiscover) LookupHostnameByMac(mac string) string {
val, ok := u.hostname.Load(mac)
if !ok {
return ""
}
return val.(string)
}
// refreshDevices updates unifi devices name from local mongodb.
func (u *ubiosDiscover) refreshDevices() error {
cmd := exec.Command("/usr/bin/mongo", "localhost:27117/ace", "--quiet", "--eval", `
DBQuery.shellBatchSize = 256;
db.user.find({name: {$exists: true, $ne: ""}}, {_id:0, mac:1, name:1});`)
b, err := cmd.Output()
if err != nil {
return err
}
return u.storeDevices(bytes.NewReader(b))
}
// storeDevices saves unifi devices name for caching.
func (u *ubiosDiscover) storeDevices(r io.Reader) error {
decoder := json.NewDecoder(r)
device := struct {
MAC string
Name string
}{}
for {
err := decoder.Decode(&device)
if err == io.EOF {
break
}
if err != nil {
return err
}
mac := strings.ToLower(device.MAC)
u.hostname.Store(mac, normalizeHostname(device.Name))
}
return nil
}
// String returns human-readable format of ubiosDiscover.
func (u *ubiosDiscover) String() string {
return "ubios"
}

View File

@@ -0,0 +1,43 @@
package clientinfo
import (
"strings"
"testing"
)
func Test_ubiosDiscover_storeDevices(t *testing.T) {
ud := &ubiosDiscover{}
r := strings.NewReader(`{ "mac": "00:00:00:00:00:01", "name": "device 1" }
{ "mac": "00:00:00:00:00:02", "name": "device 2" }
`)
if err := ud.storeDevices(r); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
mac string
hostname string
}{
{"device 1", "00:00:00:00:00:01", "device 1"},
{"device 2", "00:00:00:00:00:02", "device 2"},
{"non-existed", "00:00:00:00:00:03", ""},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := ud.LookupHostnameByMac(tc.mac); got != tc.hostname {
t.Errorf("hostname mismatched, want: %q, got: %q", tc.hostname, got)
}
})
}
// Test for invalid input.
r = strings.NewReader(`{ "mac": "00:00:00:00:00:01", "name": "device 1"`)
if err := ud.storeDevices(r); err == nil {
t.Fatal("expected error, got nil")
} else {
t.Log(err)
}
}

View File

@@ -35,8 +35,9 @@ type ResolverConfig struct {
Ctrld struct {
CustomConfig string `json:"custom_config"`
} `json:"ctrld"`
Exclude []string `json:"exclude"`
UID string `json:"uid"`
Exclude []string `json:"exclude"`
UID string `json:"uid"`
DeactivationPin *int64 `json:"deactivation_pin,omitempty"`
}
type utilityResponse struct {

View File

@@ -16,8 +16,8 @@ import (
const (
controldIPv6Test = "ipv6.controld.io"
v4BootstrapDNS = "76.76.2.0:53"
v6BootstrapDNS = "[2606:1a40::]:53"
v4BootstrapDNS = "76.76.2.22:53"
v6BootstrapDNS = "[2606:1a40::22]:53"
)
var Dialer = &net.Dialer{

View File

@@ -1,12 +1,9 @@
package dnsmasq
import (
"bytes"
"errors"
"fmt"
"html/template"
"net"
"os"
"path/filepath"
"strings"
@@ -18,12 +15,12 @@ no-resolv
{{- range .Upstreams}}
server={{ .IP }}#{{ .Port }}
{{- end}}
{{- if .SendClientInfo}}
add-mac
add-subnet=32,128
{{- end}}
{{- if .CacheDisabled}}
cache-size=0
{{- else}}
max-cache-ttl=0
{{- end}}
`
@@ -45,12 +42,10 @@ if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then
{{- range .Upstreams}}
pc_append "server={{ .IP }}#{{ .Port }}" "$config_file"
{{- end}}
{{- if .SendClientInfo}}
pc_delete "add-mac" "$config_file"
pc_delete "add-subnet" "$config_file"
pc_append "add-mac" "$config_file" # add client mac
pc_append "add-subnet=32,128" "$config_file" # add client ip
{{- end}}
pc_delete "dnssec" "$config_file" # disable DNSSEC
pc_delete "trust-anchor=" "$config_file" # disable DNSSEC
pc_delete "cache-size=" "$config_file"
@@ -72,11 +67,18 @@ type Upstream struct {
Port int
}
// ConfTmpl generates dnsmasq configuration from ctrld config.
func ConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) {
return ConfTmplWitchCacheDisabled(tmplText, cfg, true)
return ConfTmplWithCacheDisabled(tmplText, cfg, true)
}
func ConfTmplWitchCacheDisabled(tmplText string, cfg *ctrld.Config, cacheDisabled bool) (string, error) {
// ConfTmplWithCacheDisabled is like ConfTmpl, but the caller can control whether
// dnsmasq cache is disabled using cacheDisabled parameter.
//
// Generally, the caller should use ConfTmpl, but on some routers which dnsmasq config may be changed
// after ctrld started (like EdgeOS/Ubios, Firewalla ...), dnsmasq cache should not be disabled because
// the cache-size=0 generated by ctrld will conflict with router's generated config.
func ConfTmplWithCacheDisabled(tmplText string, cfg *ctrld.Config, cacheDisabled bool) (string, error) {
listener := cfg.FirstListener()
if listener == nil {
return "", errors.New("missing listener")
@@ -86,26 +88,27 @@ func ConfTmplWitchCacheDisabled(tmplText string, cfg *ctrld.Config, cacheDisable
ip = "127.0.0.1"
}
upstreams := []Upstream{{IP: ip, Port: listener.Port}}
return confTmpl(tmplText, upstreams, cfg.HasUpstreamSendClientInfo(), cacheDisabled)
return confTmpl(tmplText, upstreams, cacheDisabled)
}
// FirewallaConfTmpl generates dnsmasq config for Firewalla routers.
func FirewallaConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) {
// If ctrld listen on all interfaces, generating config for all of them.
if lc := cfg.FirstListener(); lc != nil && (lc.IP == "0.0.0.0" || lc.IP == "") {
return confTmpl(tmplText, firewallaUpstreams(lc.Port), cfg.HasUpstreamSendClientInfo(), true)
return confTmpl(tmplText, firewallaUpstreams(lc.Port), false)
}
return ConfTmpl(tmplText, cfg)
// Otherwise, generating config for the specific listener from ctrld's config.
return ConfTmplWithCacheDisabled(tmplText, cfg, false)
}
func confTmpl(tmplText string, upstreams []Upstream, sendClientInfo, cacheDisabled bool) (string, error) {
func confTmpl(tmplText string, upstreams []Upstream, cacheDisabled bool) (string, error) {
tmpl := template.Must(template.New("").Parse(tmplText))
var to = &struct {
SendClientInfo bool
Upstreams []Upstream
CacheDisabled bool
Upstreams []Upstream
CacheDisabled bool
}{
SendClientInfo: sendClientInfo,
Upstreams: upstreams,
CacheDisabled: cacheDisabled,
Upstreams: upstreams,
CacheDisabled: cacheDisabled,
}
var sb strings.Builder
if err := tmpl.Execute(&sb, to); err != nil {
@@ -136,20 +139,6 @@ func firewallaDnsmasqConfFiles() ([]string, error) {
return filepath.Glob("/home/pi/firerouter/etc/dnsmasq.dns.*.conf")
}
// firewallUpdateConf updates all firewall config files using given function.
func firewallUpdateConf(update func(conf string) error) error {
confFiles, err := firewallaDnsmasqConfFiles()
if err != nil {
return err
}
for _, conf := range confFiles {
if err := update(conf); err != nil {
return fmt.Errorf("%s: %w", conf, err)
}
}
return nil
}
// FirewallaSelfInterfaces returns list of interfaces that will be configured with default dnsmasq setup on Firewalla.
func FirewallaSelfInterfaces() []*net.Interface {
matches, err := firewallaDnsmasqConfFiles()
@@ -166,32 +155,3 @@ func FirewallaSelfInterfaces() []*net.Interface {
}
return ifaces
}
// FirewallaDisableCache comments out "cache-size" line in all firewalla dnsmasq config files.
func FirewallaDisableCache() error {
return firewallUpdateConf(DisableCache)
}
// FirewallaEnableCache un-comments out "cache-size" line in all firewalla dnsmasq config files.
func FirewallaEnableCache() error {
return firewallUpdateConf(EnableCache)
}
// DisableCache comments out "cache-size" line in dnsmasq config file.
func DisableCache(conf string) error {
return replaceFileContent(conf, "\ncache-size=", "\n#cache-size=")
}
// EnableCache un-comments "cache-size" line in dnsmasq config file.
func EnableCache(conf string) error {
return replaceFileContent(conf, "\n#cache-size=", "\ncache-size=")
}
func replaceFileContent(filename, old, new string) error {
content, err := os.ReadFile(filename)
if err != nil {
return err
}
content = bytes.ReplaceAll(content, []byte(old), []byte(new))
return os.WriteFile(filename, content, 0644)
}

View File

@@ -20,11 +20,15 @@ const (
usgDNSMasqConfigPath = "/etc/dnsmasq.conf"
usgDNSMasqBackupConfigPath = "/etc/dnsmasq.conf.bak"
toggleContentFilteringLink = "https://community.ui.com/questions/UDM-Pro-disable-enable-DNS-filtering/e2cc4060-e56a-4139-b200-62d7f773ff8f"
toggleDnsShieldLink = "https://community.ui.com/questions/UniFi-OS-3-2-7-DNS-Shield-Missing/d3a85905-4ce0-4fe4-8bf0-6cb04f21371d"
)
var ErrContentFilteringEnabled = fmt.Errorf(`the "Content Filtering" feature" is enabled, which is conflicted with ctrld.\n
To disable it, folowing instruction here: %s`, toggleContentFilteringLink)
var ErrDnsShieldEnabled = fmt.Errorf(`the "DNS Shield" feature" is enabled, which is conflicted with ctrld.\n
To disable it, folowing screenshot here: %s`, toggleDnsShieldLink)
type EdgeOS struct {
cfg *ctrld.Config
isUSG bool
@@ -50,6 +54,11 @@ func (e *EdgeOS) Install(_ *service.Config) error {
if ContentFilteringEnabled() {
return ErrContentFilteringEnabled
}
// If "DNS Shield" is enabled, UniFi OS will spawn dnscrypt-proxy process, and route all DNS queries to it. So
// reporting an error and guiding users to disable the feature using UniFi OS web UI.
if DnsShieldEnabled() {
return ErrDnsShieldEnabled
}
return nil
}
@@ -109,7 +118,7 @@ func (e *EdgeOS) setupUSG() error {
sb.WriteString(line)
}
data, err := dnsmasq.ConfTmplWitchCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
if err != nil {
return err
}
@@ -127,7 +136,7 @@ func (e *EdgeOS) setupUSG() error {
}
func (e *EdgeOS) setupUDM() error {
data, err := dnsmasq.ConfTmplWitchCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
if err != nil {
return err
}
@@ -169,6 +178,16 @@ func ContentFilteringEnabled() bool {
return err == nil && !st.IsDir()
}
// DnsShieldEnabled reports whether DNS Shield is enabled.
// See: https://community.ui.com/releases/UniFi-OS-Dream-Machines-3-2-7/251dfc1e-f4dd-4264-a080-3be9d8b9e02b
func DnsShieldEnabled() bool {
buf, err := os.ReadFile("/var/run/dnsmasq.conf.d/dns.conf")
if err != nil {
return false
}
return bytes.Contains(buf, []byte("server=127.0.0.1#5053"))
}
func LeaseFileDir() string {
if checkUSG() {
return ""

View File

@@ -65,11 +65,6 @@ func (f *Firewalla) Setup() error {
return fmt.Errorf("writing ctrld config: %w", err)
}
// Disable dnsmasq cache.
if err := dnsmasq.FirewallaDisableCache(); err != nil {
return err
}
// Restart dnsmasq service.
if err := restartDNSMasq(); err != nil {
return fmt.Errorf("restartDNSMasq: %w", err)
@@ -87,11 +82,6 @@ func (f *Firewalla) Cleanup() error {
return fmt.Errorf("removing ctrld config: %w", err)
}
// Enable dnsmasq cache.
if err := dnsmasq.FirewallaEnableCache(); err != nil {
return err
}
// Restart dnsmasq service.
if err := restartDNSMasq(); err != nil {
return fmt.Errorf("restartDNSMasq: %w", err)

View File

@@ -137,20 +137,9 @@ rcvar="${name}_enable"
pidfile="/var/run/${name}.pid"
child_pidfile="/var/run/${name}_child.pid"
command="/usr/sbin/daemon"
daemon_args="-P ${pidfile} -p ${child_pidfile} -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}"
daemon_args="-r -P ${pidfile} -p ${child_pidfile} -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}"
command_args="${daemon_args} {{.Path}}{{range .Arguments}} {{.}}{{end}}"
stop_cmd="ctrld_stop"
ctrld_stop() {
pid=$(cat ${pidfile})
child_pid=$(cat ${child_pidfile})
if [ -e "${child_pidfile}" ]; then
kill -s TERM "${child_pid}"
wait_for_pids "${child_pid}" "${pidfile}"
fi
}
load_rc_config "${name}"
run_rc_command "$1"
`

View File

@@ -199,7 +199,7 @@ func distroName() string {
return merlin.Name
case haveFile("/etc/openwrt_version"):
return openwrt.Name
case haveDir("/data/unifi"):
case isUbios():
return ubios.Name
case bytes.HasPrefix(unameU(), []byte("synology")):
return synology.Name
@@ -234,3 +234,14 @@ func unameU() []byte {
out, _ := exec.Command("uname", "-u").Output()
return out
}
// isUbios reports whether the current machine is running on Ubios.
func isUbios() bool {
if haveDir("/data/unifi") {
return true
}
if err := exec.Command("ubnt-device-info", "firmware").Run(); err == nil {
return true
}
return false
}

View File

@@ -36,6 +36,10 @@ func (u *Ubios) Install(config *service.Config) error {
if edgeos.ContentFilteringEnabled() {
return edgeos.ErrContentFilteringEnabled
}
// See comment in (*edgeos.EdgeOS).Install method.
if edgeos.DnsShieldEnabled() {
return edgeos.ErrDnsShieldEnabled
}
return nil
}
@@ -51,17 +55,13 @@ func (u *Ubios) Setup() error {
if u.cfg.FirstListener().IsDirectDnsListener() {
return nil
}
data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, u.cfg)
data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, u.cfg, false)
if err != nil {
return err
}
if err := os.WriteFile(ubiosDNSMasqConfigPath, []byte(data), 0600); err != nil {
return err
}
// Disable dnsmasq cache.
if err := dnsmasq.DisableCache(ubiosDNSMasqDnsConfigPath); err != nil {
return err
}
// Restart dnsmasq service.
if err := restartDNSMasq(); err != nil {
return err
@@ -77,10 +77,6 @@ func (u *Ubios) Cleanup() error {
if err := os.Remove(ubiosDNSMasqConfigPath); err != nil {
return err
}
// Enable dnsmasq cache.
if err := dnsmasq.EnableCache(ubiosDNSMasqDnsConfigPath); err != nil {
return err
}
// Restart dnsmasq service.
if err := restartDNSMasq(); err != nil {
return err

View File

@@ -30,7 +30,7 @@ const (
ResolverTypePrivate = "private"
)
var bootstrapDNS = "76.76.2.0"
const bootstrapDNS = "76.76.2.22"
// or is the Resolver used for ResolverTypeOS.
var or = &osResolver{nameservers: defaultNameservers()}

View File

@@ -72,17 +72,9 @@ build() {
if [ "$CGO_ENABLED" = "0" ]; then
binary=${binary}-nocgo
fi
GOOS=${goos} GOARCH=${goarch} GOARM=${3} "$go" generate ./...
GOOS=${goos} GOARCH=${goarch} GOARM=${3} "$go" build -ldflags="$ldflags" -o "$binary" ./cmd/ctrld
compress "$binary"
if [ -z "${CTRLD_NO_QF}" ]; then
binary_qf=${executable_name}-qf-${goos}-${goarch}v${3}
if [ "$CGO_ENABLED" = "0" ]; then
binary_qf=${binary_qf}-nocgo
fi
GOOS=${goos} GOARCH=${goarch} GOARM=${3} "$go" build -ldflags="$ldflags" -tags=qf -o "$binary_qf" ./cmd/ctrld
compress "$binary_qf"
fi
;;
*)
# GOMIPS is required for linux/mips: https://nileshgr.com/2020/02/16/golang-on-openwrt-mips/
@@ -90,17 +82,9 @@ build() {
if [ "$CGO_ENABLED" = "0" ]; then
binary=${binary}-nocgo
fi
GOOS=${goos} GOARCH=${goarch} GOMIPS=softfloat "$go" generate ./...
GOOS=${goos} GOARCH=${goarch} GOMIPS=softfloat "$go" build -ldflags="$ldflags" -o "$binary" ./cmd/ctrld
compress "$binary"
if [ -z "${CTRLD_NO_QF}" ]; then
binary_qf=${executable_name}-qf-${goos}-${goarch}
if [ "$CGO_ENABLED" = "0" ]; then
binary_qf=${binary_qf}-nocgo
fi
GOOS=${goos} GOARCH=${goarch} GOMIPS=softfloat "$go" build -ldflags="$ldflags" -tags=qf -o "$binary_qf" ./cmd/ctrld
compress "$binary_qf"
fi
;;
esac
}

64
scripts/build_lib.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
# This script is used to locally build Android .aar library and iOS .xcframework from ctrld source using go mobile tool.
# Requirements:
# - Android NDK (version 23+)
# - Android SDK (version 33+)
# - Xcode 15 + Build tools
# - Go 1.21
# - Git
# usage: $ ./build_lib.sh v1.3.4
TAG="$1"
# Hacky way to replace version info.
update_versionInfo() {
local file="$1/ctrld/cmd/cli/cli.go"
local tag="$2"
local commit="$3"
awk -v tag="$tag" -v commit="$commit" '
BEGIN { version_updated = 0; commit_updated = 0 }
/^\tversion/ {
sub(/= ".+"/, "= \"" tag "\"");
version_updated = 1;
}
/^\tcommit/ {
sub(/= ".+"/, "= \"" commit "\"");
commit_updated = 1;
}
{ print }
END {
if (version_updated == 0) {
print "\tversion = \"" tag "\"";
}
if (commit_updated == 0) {
print "\tcommit = \"" commit "\"";
}
}
' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
}
export ANDROID_NDK_HOME=~/Library/Android/sdk/ndk/23.0.7599858
mkdir bin
cd bin || exit
root=$(pwd)
# Get source from github and switch to tag
git clone --depth 1 --branch "$TAG" https://github.com/Control-D-Inc/ctrld.git
# Prepare gomobile tool
sourcePath=./ctrld/cmd/ctrld_library
cd $sourcePath || exit
go mod tidy
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
# Prepare build info
buildDir=$root/../build
mkdir -p "$buildDir"
COMMIT=$(git rev-parse HEAD)
update_versionInfo "$root" "$TAG" "$COMMIT"
ldflags="-s -w"
# Build
gomobile bind -target ios/arm64 -ldflags="$ldflags" -o "$buildDir"/ctrld-"$TAG".xcframework || exit
gomobile bind -ldflags="$ldflags" -o "$buildDir"/ctrld-"$TAG".aar || exit
# Clean up
rm -r "$root"
echo "Successfully built Ctrld library $TAG($COMMIT)."