Compare commits

..

330 Commits

Author SHA1 Message Date
Cuong Manh Le
e0d35d8ba2 Merge pull request #218 from Control-D-Inc/release-branch-v1.4.1
Release branch v1.4.1
2025-03-07 08:25:38 +07:00
Cuong Manh Le
3b2e48761e Upgrade dominikh/staticcheck-action to v1.3.1
To upgrade actions/cache dependency, since v1-v2 was deprecated.
2025-03-06 18:42:06 +07:00
Cuong Manh Le
b27064008e cmd/cli: do not validate if custom config is empty
Avoiding useless warnings when doing rules validation.
2025-03-06 18:17:48 +07:00
Cuong Manh Le
1ad63827e1 cmd/cli: do not validate invalid syntax config
If the remote custom config is an invalid syntax config, we should not
do rules validation, prevent unnecessary error messages printed.
2025-03-01 00:24:59 +07:00
Cuong Manh Le
20e61550c2 cmd/cli: set default value for remote config before validating
Since empty network will now have a default value, we need to set it
after sytax validation, prevent false positive when validating rules.
2025-03-01 00:24:55 +07:00
Cuong Manh Le
020b814402 cmd/cli: fix validating remote custom config
Currently, custom config is only validated against invalid syntax, not
the validating rules for each configuration value. It causes ctrld
process fatal instead of disregarding as expected.

To fix this, force the validating rule after fetching remote config.
While at it, also add the default network value if non-existed.
2025-02-28 20:08:26 +07:00
Cuong Manh Le
e578867118 internal/router: fix fresh tomato config path
When ctrld performs upgrading tasks, the current binary would be moved
to different file, thus the executable will return this new file name,
instead of the old "/path/to/ctrld".

The config path on FreshTomato is located in the same directory with
ctrld binary, with ".startup" suffix. So when the binary was moved
during upgrading, the config path is located wrongly.

To fix it, read the binary path from service config first, then only
fallback to the current executable if the path is empty (this is the
same way ctrld is doing for other router platforms).
2025-02-27 23:47:46 +07:00
Alex Paguis
46a1039f21 guard against nil interface 2025-02-27 18:53:10 +07:00
Cuong Manh Le
cc9e27de5f Add some more mDNS services
Import from https://github.com/Control-D-Inc/ctrld/pull/145

Thanks @jaydeethree for contributing.
2025-02-27 18:52:50 +07:00
Cuong Manh Le
6ab3ab9faf cmd/cli: add DNS as ctrld service dependency
So on Windows system where there's local DNS running, ctrld could set
DNS forwarders correctly after DNS service started.
2025-02-26 00:44:13 +07:00
Alex Paguis
e68bfa795a add a small delay service start self check 2025-02-25 20:07:57 +07:00
Cuong Manh Le
e60a92e93e cmd/cli: improving IPC when try listening failed
So the "ctrld start" should know earlier that "ctrld run" failed to
listen on certain port, and terminate earlier instead of waiting for
timeout happened.
2025-02-25 03:33:00 +07:00
Alex
62fe14f76b prevent running on custom ports for clients 2025-02-24 18:36:18 +07:00
Alex Paguis
a0c5062e3a Resolve "OS upstream failure / wrong default route" 2025-02-24 18:36:08 +07:00
Alex
49eb152d02 transport should try ipv4 then ipv6 explicitly
client list panic guards and debug logging
2025-02-21 20:44:34 +07:00
Cuong Manh Le
b05056423a docs: add documentation for LAN queries 2025-02-21 20:44:34 +07:00
Cuong Manh Le
c7168739c7 cmd/cli: use OS resolver as default upstream for SRV lan hostname
Since application may need SRV record for public domains, which could be
blocked by OS resolver, but not with remote upstreams.

This was reported by a Minecraft user, who seeing thing is broken after
upgrading to v1.4.0 release.
2025-02-21 20:44:34 +07:00
Alex
5b1faf1ce3 dont allow positional args in start commands 2025-02-21 20:44:34 +07:00
Cuong Manh Le
513a6f9ec7 cmd/cli: guarding against nil log ipc connection
The log ip connection may be nil, since it was not created if blocked by
firewall/VPN apps.

While at it, also add warning when the ipc connection could not be created.
2025-02-21 20:44:34 +07:00
Cuong Manh Le
8db6fa4232 cmd/cli: remove un-used functions 2025-02-21 20:44:34 +07:00
Cuong Manh Le
5036de2602 cmd/cli: add support for no default route systems
Currently, ctrld requires the default route interface existed to be
functional correctly.

However, on systems where default route is non existed, or point to a
virtual interface (like ipsec based VPN), the fact that the OS is using
this interface as default gateway and doesn't actually send things to
127.0.0.1 is not ctrld's problem.

In this case, ctrld should just start normally, without worrying about
the no default route interface problem.
2025-02-21 20:44:34 +07:00
Alex
332f8ccc37 debugging save/restore staticinterface settings
postRun should not restore static settings

put back validInterface check

better debug logs for os resolver init, use mutex to prevent duplicate initializations

use WMI instead of registry keys for static DNS data on Windows

use WMI instead of registry keys for static DNS data on Windows

use winipcfg DNS method

use WMI with registry fallback

go back to registry method

restore saved static configs on stop and uninstall

restore ipv6 DHCP if no saved static ipv6 addresses

do not save loopback IPs for static configs

handle watchdog interface changed for new interfaces

dont overwrite static file on start when staticdns is set to loopback

dont overwrite static file on start when staticdns is set to loopback

dont overwrite static file on start when staticdns is set to loopback

no need to resetDNS on start, uninstall already takes care of this
2025-02-21 20:44:34 +07:00
Cuong Manh Le
a582195cec internal/controld: bump default http client timeout
While at it, also converting them to global constants.
2025-02-21 20:44:34 +07:00
Cuong Manh Le
9fe36ae984 Removing unnecessary ProxyLogger nil check
By ensuring it is initialized before codes that access it.
2025-02-21 20:44:34 +07:00
Cuong Manh Le
54cb455522 Fix staticcheck linter warnings
By moving darwin specific codes to darwin file.
2025-02-21 20:44:34 +07:00
Cuong Manh Le
8bd3b9e474 cmd/cli: fix missing runtime log for startup
The runtime internal log should be initialized right after normal log
from configuration, prevent missing log from any actions that could be
happened between two initializations.
2025-02-21 20:44:27 +07:00
Alex
eff5ff580b use saved static nameservers stored for the default router interface when doing nameserver discovery
fix bad logger usages

patch darwin interface name

patch darwin interface name, debugging

make resetDNS check for static config on startup, optionally restoring static confiration as needed

fix netmon logging
2025-02-21 20:33:04 +07:00
Cuong Manh Le
c45f863ed8 cmd/cli: workaround status command with new Openwrt
New Openwrt returns a non-success code even when status command run
successfully, causing wrong status returned.
2025-02-18 20:31:56 +07:00
Alex Paguis
414d4e356d dont repeat ipv6availablity for each interface, increase self check timeout but reduce max attempts 2025-02-18 20:31:56 +07:00
Yegor Sak
ef697eb781 add better explaination
"code quality"
2025-02-18 20:31:51 +07:00
Cuong Manh Le
0631ffe831 all: allow verbose log when connecting to ControlD API
So troubleshooting will be easier in case of errors happened.
2025-02-18 20:31:08 +07:00
Cuong Manh Le
7444d8517a cmd/cli: fix log init end marker with partial data
For partial init log data (does not end with a newline), the log writer
discard data after the last newline to make the log prettier, then write
the init end marker. This causes the marker could be written more than
once, since the second overflows will preserve the data which does
include the marker from the first write.

To fix this, ensure that the init end marker is only written once, and
the second overflows will preserve data until the marker instead of the
fixed initial size like the first one.
2025-02-18 20:31:08 +07:00
Alex
3480043e40 handle default route changes
remove old os resolver IPs on interface down

better debugging for os resolver
2025-02-18 20:30:54 +07:00
Yegor Sak
619b6e7516 Update file config.md
update bad grammar, describe things better
2025-02-18 20:30:47 +07:00
Alex
0123ca44fb ignore ipv6 addresses from defaultRouteIP, guard against using ipv6 address as v4 default 2025-02-18 20:25:35 +07:00
Alex
7929aafe2a OS resolver retry should respect the leak_on_upstream_failure config option 2025-02-18 20:25:26 +07:00
Cuong Manh Le
dc433f8dc9 cmd/cli: support nocgo version for upgrade command
linux/amd64 have the nocgo binary to support system where standard libc
missing.

If the current binary is a nocgo version, "ctrld upgrade" command must
honor the nocgo setting and download the right binary.
2025-02-18 20:25:13 +07:00
Cuong Manh Le
8ccaeeab60 internal/router: support openwrt 24.10
openwrt 24.10 changes the dnsmasq default config path, causing breaking
changes to softwares which depends on old behavior.

This commit adds a workaround for the issue, by querying the actual
config directory from ubus service list, instead of relying on the
default hardcode one.
2025-02-18 20:24:57 +07:00
Cuong Manh Le
043a28eb33 internal/clientinfo: allow router discovers initialization to be failed
Currently, the router discovers initialization are done during startup.
If it were failed, the discovers are skipped. This is too strict, since
the initialization could be failed due to some requires services are not
ready when ctrld started, or router specific requirements for services
management during startup (like UnifiOS v4.0.20).

To fix this, ctrld should relax the initialization checking, allow it to
be failed, and still use the discovers later.
2025-02-18 20:24:47 +07:00
Alex
c329402f5d remove DNS lookups from IPv6 check, close the connection
log ipv6 availability logic

more debugging for ipv6 availability checks

more debugging for ipv6 availability checks
2025-02-18 20:24:25 +07:00
Alex
23e6ad6e1f use first public os reolver response when no LAN servers exist
os resolver debugging improvement

use first public non success answer when no LAN nameservers exist

use first public non success answer when no LAN nameservers exist

fix the os resolver test
2025-02-18 20:23:36 +07:00
Alex
e6de78c1fa fix leak_on_upstream_failure config param 2025-02-18 20:22:33 +07:00
Alex
a670708f93 do not exclude public nameservers from OS resolver queries
remove controld nameservers from public list if thsi is a LAN query

fixed comment

simpler index check

debugging and error for actually no nameservers
2025-02-18 20:21:36 +07:00
Cuong Manh Le
4ebe2fb5f4 all: ensure ctrld started after mongodb on Ubios
Because ctrld needs to query custom client mapping from it.

While at it, also make the error message clearer when initializing ubios
discover failed, by attaching the command output to returned error.
2025-02-18 20:20:04 +07:00
Cuong Manh Le
3403b2039d cmd/cli: remove workaround for systemd-resolved
With new version of tailscale fork library, the DNS could now be set
correctly with systemd-resolved, instead of retrying multiple times.
2025-02-18 20:19:04 +07:00
Cuong Manh Le
e30ad31e0f Merge pull request #209 from Control-D-Inc/release-branch-v1.4.0
Release branch v1.4.0
2025-02-12 14:55:47 +07:00
Alex
81e0bad739 increase failure count for all queries with no answer 2025-02-11 19:29:48 +07:00
Alex
7d07d738dc fix failure count on OS retry 2025-02-11 19:28:55 +07:00
Alex
0fae584e65 OS resolver retry catch all 2025-02-11 19:27:50 +07:00
Alex
9e83085f2a handle old state missing interface crash 2025-02-11 19:27:46 +07:00
Alex
41a00c68ac fix down state handling 2025-02-11 19:27:41 +07:00
Alex
e3b99bf339 mark upstream as down after 10s of no successful queries 2025-02-11 19:27:36 +07:00
Cuong Manh Le
5007a87d3a cmd/cli: better error message when doing restart
In case of remote config validation error during start, it's likely that
there's problem with connecting to ControlD API. The ctrld daemon was
restarted in this case, but may not ready to receive requests yet.

This commit changes the error message to explicitly state that instead
of a mis-leading "could not complete service restart".
2025-02-11 19:27:25 +07:00
Alex
60e65a37a6 do the reset after recovery finished 2025-02-10 18:56:09 +07:00
Alex
d37d0e942c fix countHealthy locking 2025-02-10 18:55:48 +07:00
Alex
98042d8dbd remove leaking logic in favor of recovery logic. 2025-02-10 18:55:36 +07:00
Cuong Manh Le
af4b826b68 cmd/cli: implement valid interfaces map for all systems
Previously, a valid interfaces map is only meaningful on Windows and
Darwin, where ctrld needs to set DNS for all physical interfaces.

With new network monitor, the valid interfaces is used for checking new
changes, thus we have to implement the valid interfaces map for all
systems.

 - On Linux, just retrieving all non-virtual interfaces.
 - On others, fallback to use default route interface only.
2025-02-10 18:45:17 +07:00
Cuong Manh Le
253a57ca01 cmd/cli: make validating remote config non-fatal during restart
Since we already have a config on disk, it's better to enforce what we
have instead of fatal.
2025-02-10 18:45:07 +07:00
Cuong Manh Le
caf98b4dfe cmd/cli: ignore log file config for interactive logging
Otherwise, the interactive commands may clobber the existed log file of
ctrld daemon, causing it stops writing log until restarted.
2025-02-10 18:44:58 +07:00
Alex
398f71fd00 fix leakingQueryReset usages 2025-02-10 18:44:52 +07:00
Alex
e1301ade96 remove context timeout 2025-02-10 18:44:46 +07:00
Alex
7a23f82192 set leakingQueryReset to prevent watchdogs from resetting dns 2025-02-10 18:44:40 +07:00
Cuong Manh Le
715bcc4aa1 internal/clientinfo: make SetSelfIP to update new data
So after network changes, the new data will be used instead of the stale
old one.
2025-02-10 18:44:32 +07:00
Alex
0c74838740 init os resolver after upstream recovers 2025-02-10 18:44:23 +07:00
Alex
4b05b6da7b fix missing unlock 2025-02-10 18:43:03 +07:00
Alex
375844ff1a remove handler log line 2025-02-10 18:42:59 +07:00
Alex
1d207379cb wait for healthy upstream before accepting queries on network change 2025-02-10 18:42:53 +07:00
Alex
fb49cb71e3 debounce upstream failure checking and failure counts 2025-02-10 18:41:48 +07:00
Alex
9618efbcde improve network change ip filtering logic 2025-02-10 18:41:43 +07:00
Alex
bb2210b06a ip detection debugging 2025-02-10 18:41:39 +07:00
Alex
917052723d don't overwrite OS resolver nameservers if there arent any 2025-02-10 18:41:34 +07:00
Alex
fef85cadeb filter non usabel IPs from state changes 2025-02-10 18:41:30 +07:00
Alex
4a05fb6b28 use the changed iface if no default route is set yet 2025-02-10 18:41:25 +07:00
Alex
6644ce53f2 fix interface IP CIDR parsing 2025-02-10 18:41:20 +07:00
Alex
72f0b89fdc remove redundant return 2025-02-10 18:41:15 +07:00
Alex
41a97a6609 clean up network change state logic 2025-02-10 18:41:05 +07:00
Alex
38064d6ad5 parse InterfaceIPs for network delta, not just ifs block 2025-02-10 18:40:52 +07:00
Cuong Manh Le
ae6945cedf cmd/cli: fix missing wg.Done call 2025-02-10 18:40:42 +07:00
Cuong Manh Le
3132d1b032 Remove debug dialer
Since its puporse is solely for debugging, it could be one now.
2025-02-10 18:40:30 +07:00
Cuong Manh Le
2716ae29bd cmd/cli: remove unnecessary prog wait group
Since the client info is now only run once, we don't need to propagate
the wait group to other places for controlling new run.
2025-02-10 18:40:15 +07:00
Cuong Manh Le
1c50c2b6af Set deadline for custom UDP/TCP conn
Otherwise, OS resolver may hang forever if the server does not reply.

While at it, also removing unused method stopClientInfoDiscover.

Updates #344
2025-02-06 15:40:48 +07:00
Alex
cf6d16b439 set new dialer on every request
debugging

debugging

debugging

debugging

use default route interface IP for OS resolver queries

remove retries

fix resolv.conf clobbering on MacOS, set custom local addr for os resolver queries

remove the client info discovery logic on network change, this was overkill just for the IP, and was causing service failure after switching networks many times rapidly

handle ipv6 local addresses

guard ciTable from nil pointer

debugging failure count
2025-02-06 15:40:41 +07:00
Cuong Manh Le
60686f55ff cmd/cli: set ProxyLogger correctly for interactive commands
The ProxyLogger must only be set after mainLog is fully initialized.
However, it's being set before the final initialization of mainlog,
causing it still refers to stale old pointer.

To fix this, introduce a new function to discard ProxyLogger explicitly,
and use this function to init logging for all interactive commands.
2025-02-05 23:39:49 +07:00
Cuong Manh Le
47d7ace3a7 Simplify dnsFromResolvConf
By using existed package instead of hand written one.

While at it, also simplifying the logger getter, since the ProxyLogger
is guaranted to be non-nil.
2025-02-05 18:57:49 +07:00
Alex
2d3779ec27 fix MacOS nameserver detection, fix not installed errors for commands
copy

fix get valid ifaces in nameservers_bsd

nameservers on MacOS can be found in resolv.conf reliably

nameservers on MacOS can be found in resolv.conf reliably

exclude local IPs from MacOS resolve conf check

use scutil for MacOS, simplify reinit logic to prevent duplicate calls

add more dns server fetching options

never skip OS resolver in IsDown check

split dsb and darwin nameserver methods, add delay for setting DNS on interface on network change.

increase delay to 5s but only on MacOS
2025-02-05 13:18:06 +07:00
Cuong Manh Le
595071b608 all: update client info table on network changes
So the client metadata will be updated correctly when the device roaming
between networks.
2025-02-05 13:15:01 +07:00
Cuong Manh Le
57ef717080 cmd/cli: improve error message returned by FlushDNSCache
By recording both the error and output of external commands.

While at it:

 - Removing un-necessary usages of sudo, since ctrld already
   running with root privilege.
 - Removing un-used function triggerCaptiveCheck.
2025-02-05 13:14:52 +07:00
Cuong Manh Le
eb27d1482b cmd/cli: use warn level for network changes logging
So these events will be recorded separately from normal runtime log,
making troubleshooting later more easily.

While at it, only update ctrld.ProxyLogger for runCmd, it's the only one
which needs to log the query when proxying requests.
2025-02-05 13:14:39 +07:00
Cuong Manh Le
f57972ead7 cmd/cli: make runtime log format better
By using more friendly markers to indicate the end of each log section,
so it's easier to read/parse for both human and machine.
2025-02-05 13:14:31 +07:00
Alex
168eaf538b increase OSresolver timeout, fix debug log statements
flush dns cache, manually hit captive portal on MacOS

fix real ip in debug log

treat all upstreams as down upon network change

delay upstream checks when leaking queries on network changes
2025-02-04 18:03:41 +07:00
Cuong Manh Le
1560455ca3 Use all available nameservers in lookupIP
Some systems may be configured with public DNS only, so relying solely
on LAN servers could make the lookup process failed unexpectedly.
2025-02-02 11:48:25 +07:00
Alex
028475a193 fix os.Resolve method to prefer LAN answers
fix os.Resolve method to prefer LAN answers

early return for stop cmd when not installed or stopped

increase service restart delay to 5s
2025-02-02 11:21:39 +07:00
Alex
f7a6dbe39b fix upgrade flow
set service on new run, fix duplicate args

set service on new run, fix duplicate args

revert startCmd in upgrade flow due to pin compat issues

make restart reset DNS like upgrade, add debugging to uninstall method

debugging

debugging

debugging

debugging

debugging WMI

remove stackexchange lib, use ms wmi pkg

debugging

debugging

set correct class

fix os reolver init issues

fix netadapter class

use os resolver instead of fetching default nameservers while already running

remove debug lines

fix lookup IP

fix lookup IP

fix lookup IP

fix lookup IP

fix dns namserver retries when not needed
2025-01-31 20:04:03 +07:00
Alex
e573a490c9 ignore non physical ifaces in validInterfaces method on Windows
debugging

skip type 24 in nameserver detection

skip type 24 in nameserver detection

remove interface type check from valid interfaces for now

skip non hardware interfaces in DNS nameserver lookup

ignore win api log output

set retries to 5 and 1s backoff

reset DNS when upgrading to make sure we get the proper OS nameservers on start

init running iface for upgrade

update windows service options for auto restarts on failure

make upgrade use the actual stop and start commands

fix the windows service retry logic

fix the windows service retry logic

task debugging

more task debugging

windows service name fix

windows service name fix

fix start command args

fix restart delay

dont recover from non crash failures

fix upgrade flow
2025-01-30 17:06:43 +07:00
Alex
ce3281e70d much more debugging, improved nameserver detection, no more testing nameservers
fix logging

fix logging

try to enable nameserver logs

try to enable nameserver logs

handle flags in interface state changes

debugging

debugging

debugging

fix state detection, AD status fix

fix debugging line

more dc info

always log state changes

remove unused method

windows AD IP discovery

windows AD IP discovery

windows AD IP discovery
2025-01-29 12:28:49 +07:00
Cuong Manh Le
0fbfd160c9 cmd/cli: log interfaces state after dns set
The data will be useful for troubleshooting later.
2025-01-24 14:54:28 +07:00
Cuong Manh Le
20759017e6 all: use local resolver for ADDC
For normal OS resolver, ctrld does not use local addresses as nameserver
to avoid possible looping. However, on AD environment with local DNS
running, AD queries must be sent to the local DNS server for proper
resolving.
2025-01-24 14:54:20 +07:00
Cuong Manh Le
69e0aab73e cmd/cli: use wmi to get AD domain
Since using syscall.NetGetJoinInformation won't return the full domain
name.

Discovered while investigating issue with SRV ldap check.
2025-01-24 14:54:10 +07:00
Cuong Manh Le
7ed6733fb7 cmd/cli: better error if internal log is not available 2025-01-24 14:54:01 +07:00
Cuong Manh Le
9718ab8579 cmd/cli: fix getting interface name when disabled on Windows
By getting the name property directly from adapter instance, instead of
using net.InterfaceByIndex function, which could return an error when
the adapter is disabled.
2025-01-20 15:03:40 +07:00
Alex
2687a4a018 remove leaking timeout, fix blocking upstreams checks, leaking is per listener, OS resolvers are tested in parallel, reset is only done is os is down
fix test

use upstreamIS var

init map, fix watcher flag

attempt to detect network changes

attempt to detect network changes

cancel and rerun reinitializeOSResolver

cancel and rerun reinitializeOSResolver

cancel and rerun reinitializeOSResolver

ignore invalid inferaces

ignore invalid inferaces

allow OS resolver upstream to fail

dont wait for dnsWait group on reinit, check for active interfaces to trigger reinit

fix unused var

simpler active iface check, debug logs

dont spam network service name patching on Mac

dont wait for os resolver nameserver testing

remove test for osresovlers for now

async nameserver testing

remove unused test
2025-01-20 15:03:27 +07:00
Cuong Manh Le
2d9c60dea1 cmd/cli: log that multiple interfaces DNS set 2025-01-20 15:00:23 +07:00
Cuong Manh Le
841be069b7 cmd/cli: only list physical interfaces when listing
Since these are the interfaces that ctrld will manipulate anyway.

While at it, also skipping non-working devices on MacOS, by checking
if the device is present in network service order
2025-01-20 15:00:08 +07:00
Alex Paguis
7833132917 Don't automatically restore saved DNS settings when switching networks
smol tweaks to nameserver test queries

fix restoreDNS errors

add some debugging information

fix wront type in log msg

set send logs command timeout to 5 mins

when the runningIface is no longer up, attempt to find a new interface

prefer default route, ignore non physical interfaces

prefer default route, ignore non physical interfaces

add max context timeout on performLeakingQuery with more debug logs
2025-01-20 14:59:31 +07:00
Cuong Manh Le
e9e63b0983 cmd/cli: check root privilege for log commands 2025-01-20 14:57:45 +07:00
Cuong Manh Le
4df470b869 cmd/cli: ensure all ifaces operation is set correctly
Since ctrld process does not rely on the global variable iface anymore
during runtime, ctrld client's operations must be updated to reflect
this change, too.
2025-01-20 14:57:34 +07:00
Cuong Manh Le
89600f6091 cmd/cli: new flow for leaking queries to OS resolver
The current flow involves marking OS resolver as down, which is not
right at all, since ctrld depends on it for leaking queries.

This commits implements new flow, which ctrld will restore DNS settings
once leaking marked, allowing queries go to OS resolver until the
internet connection is established.
2025-01-20 14:57:23 +07:00
Cuong Manh Le
f986a575e8 cmd/cli: log upstream name if endpoint is empty 2025-01-20 14:57:09 +07:00
Cuong Manh Le
9c2fe8d21f cmd/cli: set running iface for stop/uninstall commands 2025-01-20 14:56:53 +07:00
Cuong Manh Le
8bcbb9249e cmd/cli: add an internal warn level log writer
So important events like upstream online/offline/failed will be
preserved, and submitted to the server as necessary.
2025-01-14 14:33:27 +07:00
Cuong Manh Le
a95d50c0af cmd/cli: ensure set/reset DNS is done before checking OS resolver
Otherwise, new DNS settings could be reverted by dns watchers, causing
the checking will be always false.
2025-01-14 14:33:15 +07:00
Cuong Manh Le
5db7d3577b cmd/cli: handle . domain query
By returning FormErr response, the same behavior with ControlD.
2025-01-14 14:33:05 +07:00
Cuong Manh Le
c53a0ca1c4 cmd/cli: close log reader after reading 2025-01-14 14:32:54 +07:00
Cuong Manh Le
6fd3d1788a cmd/cli: fix memory leaked when querying wmi instance
By ensuring the instance is closed when query finished.
2025-01-14 14:32:44 +07:00
Cuong Manh Le
087c1975e5 internal/controld: bump send log timeout to 300s 2025-01-14 14:32:35 +07:00
Cuong Manh Le
3713cbecc3 cmd/cli: correct log writer initial size 2025-01-14 14:32:26 +07:00
Cuong Manh Le
6046789fa4 cmd/cli: re-initializing OS resolver before doing check upstream
Otherwise, the check will be done for old stale nameservers, causing it
never succeed.
2025-01-14 14:32:15 +07:00
Cuong Manh Le
3ea69b180c cmd/cli: use config timeout when checking upstream
Otherwise, for slow network connection (like plane wifi), the check may
fail even though the internet is available.
2025-01-14 14:32:01 +07:00
Cuong Manh Le
db6e977e3a Only used saved LAN servers if available 2025-01-14 14:31:48 +07:00
Cuong Manh Le
a5c776c846 all: change send log to use x-www-form-urlencoded 2025-01-14 14:31:37 +07:00
Cuong Manh Le
5a566c028a cmd/cli: better error message when log file is empty
While at it, also record the size of logs being sent in debug/error
message.
2025-01-14 14:31:24 +07:00
Cuong Manh Le
ff43c74d8d Bump golang.org/x/net to v0.33.0
Fix CVE-2024-45338
2025-01-14 14:31:13 +07:00
Yegor S
3c7255569c Update config.md 2025-01-06 18:40:44 -05:00
Cuong Manh Le
4a92ec4d2d cmd/cli: fix race in Test_addSplitDnsRule 2024-12-19 22:10:34 +07:00
Cuong Manh Le
9bbccb4082 cmd/cli: get default interface once 2024-12-19 21:50:00 +07:00
Cuong Manh Le
4f62314646 cmd/cli: do API reloading if exlcude list changed 2024-12-19 21:50:00 +07:00
Cuong Manh Le
cb49d0d947 cmd/cli: perform leaking queries in non-cd mode 2024-12-19 21:50:00 +07:00
Cuong Manh Le
89f7874fc6 cmd/cli: normalize log path when sending log
So the correct log file that "ctrld run" process is writing logs to will
be sent to server correctly.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
221917e80b Bump golang.org/x/crypto to v0.31.0
To fix CVE-2024-45337 (even though ctrld do not use SSH)
2024-12-19 21:50:00 +07:00
Cuong Manh Le
37d41bd215 Skip public DNS for LAN query
So we don't blindly send requests to public DNS even though they can not
handle these queries.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
8a96b8bec4 cmd/cli: adopt FilteredLevelWriter when doing internal logging
Without verbose log, we use internal log writer with log level set to
debug. However, this will affect other writers, like console log, since
they are default to notice level.

By adopting FilteredLevelWriter, we can make internal log writer run in
debug level, but all others will run in default level instead.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
02ee113b95 Add missing kea dhcp4 format when validating config
Thanks Discord user cosmoxl for reporting this.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
f71dd78915 cmd/cli: move cobra commands to separated file
So each command initialization/logic can be read/update more easily.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
cd5619a05b cmd/cli: add internal logging
So in case of no logging enabled, useful data could be sent to ControlD
server for further troubleshooting.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
a63a30c76b all: add sending logs to ControlD API 2024-12-19 21:50:00 +07:00
Cuong Manh Le
f5ba8be182 Use ControlD Public DNS when non-available
This logic was missed when new initializing OS resolver logic was
implemented. While at it, also adding this test case to prevent
regression.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
a9f76322bd Bump quic-go to v0.48.2
For fixing GO-2024-3302 (CVE-2024-53259)
2024-12-19 21:50:00 +07:00
Cuong Manh Le
ed39269c80 Implementing new initializing OS resolver logic
Since the nameservers that we got during startup are the good ones that
work, saving it for later usage if we could not find available ones.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
09426dcd36 cmd/cli: new flow for LAN hostname query
If there is no explicit rules for LAN hostname queries, using OS
resolver instead of forwarding requests to remote upstreams.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
17941882a9 cmd/cli: split-route SRV record to OS resolver
Since SRV record is mostly useful in AD environment. Even in non-AD one,
the OS resolver could still resolve the query for external services.

Users who want special treatment can still specify domain rules to
forward requests to ControlD upstreams explicitly.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
70ab8032a0 cmd/cli: silent WMI query
The log is being printed by the wmi library, which may cause confusion.
2024-12-19 21:50:00 +07:00
Cuong Manh Le
8360bdc50a cmd/cli: add split route AD top level domain on Windows
The sub-domains are matched using wildcard domain rule, but this rule
won't match top level domain, causing requests are forwarded to ControlD
upstreams.

To fix this, add the split route for top level domain explicitly.
2024-12-19 21:49:57 +07:00
Cuong Manh Le
6837176ec7 cmd/cli: get static DNS using syscall 2024-12-19 21:34:37 +07:00
Cuong Manh Le
5e9b4244e7 cmd/cli: get physical interfaces using Windows WMI 2024-12-19 21:34:26 +07:00
Cuong Manh Le
9b6a308958 cmd/cli: get AD domain using Windows API 2024-12-19 21:34:26 +07:00
Cuong Manh Le
71e327653a cmd/cli: check local DNS using Windows API 2024-12-19 21:34:21 +07:00
Cuong Manh Le
a56711796f cmd/cli: set DNS using Windows API 2024-12-19 21:32:49 +07:00
Cuong Manh Le
09495f2a7c Merge pull request #194 from Control-D-Inc/release-branch-v1.3.11
Release branch v1.3.11
2024-11-20 12:54:22 +07:00
Cuong Manh Le
484643e114 cmd/cli: lowercase AD domain to be consistent with network rules
While at it, also add a note that the domain comparison are done in
case-insensitive manner.
2024-11-13 15:03:38 +07:00
Cuong Manh Le
da91aabc35 cmd/cli: ensure extra split rule is always written
Otherwise, the rule may not be added if ctrld does not run in cd mode.
2024-11-13 15:03:27 +07:00
Cuong Manh Le
c654398981 cmd/cli: make widcard rules match case-insensitively
Domain name comparisons are done in case-insensitive manner.

See: https://datatracker.ietf.org/doc/html/rfc1034#section-3.1
2024-11-13 15:03:17 +07:00
Cuong Manh Le
47a90ec2a1 cmd/cli: re-fetch pin code during deactivation checking
So if the pin code was updated/removed, it will be checked correctly by
ctrld during stop/uninstall commands.
2024-11-13 15:02:52 +07:00
Cuong Manh Le
2875e22d0b cmd/cli: re-fetch deactivation pin code when reloading API config 2024-11-13 15:01:44 +07:00
Cuong Manh Le
c5d14e0075 cmd/cli: only cleanup log file if set
Otherwise, normalizeLogFilePath may return incorrect log file path,
causing invalid log file/backup initialization. Thus "--cleanup" will
complain about invalid files.
2024-11-13 15:01:27 +07:00
Cuong Manh Le
84e06c363c Avoid tailscale.com/tsd dependency
Since it brings gvisor.dev/gvisor to the dependency graph, causing the
binary size bloating on *nix (except darwin).
2024-11-13 15:00:41 +07:00
Cuong Manh Le
5b9ccc5065 Merge pull request #182 from Control-D-Inc/release-branch-v1.3.10
[WIP] Release branch v1.3.10
2024-10-29 14:56:32 +07:00
Cuong Manh Le
6ca1a7ccc7 .github/workflows: use go1.23.x
And also upgrade staticcheck version to 2024.1.1
2024-10-24 13:05:48 +07:00
Cuong Manh Le
9d666be5d4 all: add custom hostname support for provisoning 2024-10-24 13:05:48 +07:00
Cuong Manh Le
65de7edcde Only store last LAN server if available
Otherwise, queries may still be forwarded to this un-available LAN
server, causing slow query time.
2024-10-22 22:01:37 +07:00
Cuong Manh Le
0cdff0d368 Prefer LAN server answer over public one
While at it, also implementing new OS resolver chosing logic, keeping
only 2 LAN servers at any time, 1 for current one, and 1 for last used
one.
2024-10-22 00:14:32 +07:00
Cuong Manh Le
f87220a908 Avoid data race when initializing OS resolver
With new leaking queries features, the initialization of OS resolver can
now lead to data race if queries are resolving while re-initialization
happens.

To fix it, using an atomic pointer to store list of nameservers which
were initialized, making read/write to the list concurrently safe.
2024-10-17 23:41:12 +07:00
Cuong Manh Le
30ea0c6499 Log nameserver in OS resolver response 2024-10-17 23:41:12 +07:00
Cuong Manh Le
9501e35c60 Skip virtual interfaces when parsing route table
Since routing through virtual interfaces may trigger DNS loop in VPN
like observing in UnifiOS Site Magic VPN.
2024-10-12 00:12:46 +07:00
Cuong Manh Le
5ac9d17bdf cmd/cli: simplify queryFromSelf
By using netmon.LocalAddresses instead of looping through interfaces
list manually.
2024-10-08 22:08:48 +07:00
Cuong Manh Le
cb14992ddc Ignore local addresses for OS resolver
Otherwise, DNS loop may be triggered if requests are forwarded from
ctrld to OS resolver.
2024-10-08 22:08:48 +07:00
Cuong Manh Le
e88372fc8c cmd/cli: log request id when leaking 2024-09-30 18:21:30 +07:00
Cuong Manh Le
b320662d67 cmd/cli: emit warning for MacOS 15.0 in case of timeout error 2024-09-30 18:21:22 +07:00
Cuong Manh Le
ce353cd4d9 cmd/cli: write auto split rule for AD to config file 2024-09-30 18:21:11 +07:00
Cuong Manh Le
4befd33866 cmd/cli: notify log server before ctrld process exit
So if ctrld process terminated for any reason, other processes will get
the signal immediately instead of waiting for timeout to report error.
2024-09-30 18:20:56 +07:00
Cuong Manh Le
4b36e3ac44 Change test query to use controld.com
Since some Active Directory could blocks clients to query for "."
2024-09-30 18:20:39 +07:00
Cuong Manh Le
f507bc8f9e cmd/cli: cache query from self result
So we don't waste time to compute a result which is not likely to be
changed.
2024-09-30 18:20:39 +07:00
Cuong Manh Le
14c88f4a6d all: allow empty type for h3 and sdns 2024-09-30 18:20:39 +07:00
Cuong Manh Le
3e388c2857 all: leaking queries to OS resolver instead of SRVFAIL
So it would work in more general case than just captive portal network,
which ctrld have supported recently.

Uses who may want no leaking behavior can use a config to turn off this
feature.
2024-09-30 18:20:27 +07:00
Cuong Manh Le
cfe1209d61 cmd/cli: use powershell to get physical interfaces 2024-09-30 18:17:41 +07:00
Cuong Manh Le
5a88a7c22c cmd/cli: decouple reset DNS task from ctrld status
So it can be run regardless of ctrld current status. This prevents a
racy behavior when reset DNS task restores DNS settings of the system,
but current running ctrld process may revert it immediately.
2024-09-30 18:17:31 +07:00
Cuong Manh Le
8c661c4401 cmd/cli: fix typo in powershell command to get domain 2024-09-30 18:17:12 +07:00
Cuong Manh Le
e6f256d640 all: add pull API config based on special DNS query
For query domain that matches "uid.verify.controld.com" in cd mode, and
the uid has the same value with "--cd" flag, ctrld will fetch uid config
from ControlD API, using this config if valid.

This is useful for force syncing API without waiting until the API
reload ticker fire.
2024-09-30 18:17:00 +07:00
Cuong Manh Le
ede354166b cmd/cli: add split route AD domain on Windows 2024-09-30 18:16:47 +07:00
Cuong Manh Le
282a8ce78e all: add DNS Stamps support
See: https://dnscrypt.info/stamps-specifications
2024-09-30 18:15:16 +07:00
Cuong Manh Le
08fe04f1ee all: support h3:// protocol prefix 2024-09-30 18:15:01 +07:00
Cuong Manh Le
082d14a9ba cmd/cli: implement auto captive portal detection
ControlD have global list of known captive portals that user can augment
with proper setup. However, this requires manual actions, and involving
restart ctrld for taking effects.

By allowing ctrld "leaks" DNS queries to OS resolver, this process
becomes automatically, the captive portal could intercept these queries,
and as long as it was passed, ctrld will resume normal operation.
2024-09-30 18:14:46 +07:00
Cuong Manh Le
617674ce43 all: update tailscale.com to v1.74.0 2024-09-30 18:14:30 +07:00
Cuong Manh Le
7088df58dd Merge pull request #179 from Control-D-Inc/release-branch-v1.3.9
Release branch v1.3.9
2024-09-18 23:50:57 +07:00
Cuong Manh Le
9cbd9b3e44 cmd/cli: use powershell to set/reset DNS on Windows
Using netsh command will emit unexpected SOA queries, do not use it.

While at it, also ensure that local ipv6 will be added to nameservers
list on systems that require ipv6 local listener.
2024-09-18 22:49:52 +07:00
Cuong Manh Le
e6586fd360 Merge pull request #169 from Control-D-Inc/release-branch-v1.3.8
Release branch v1.3.8
2024-09-14 22:07:22 +07:00
Cuong Manh Le
33a6db2599 Configure timeout for HTTP2 transport
Otherwise, a stale TCP connection may still alive for too long, causing
unexpected failed to connect upstream error when network changed.
2024-09-14 21:59:33 +07:00
Cuong Manh Le
70b0c4f7b9 cmd/cli: honoring "iface" value in resetDnsTask
Otherwise, ctrld service command will always do reset DNS while it
should not.
2024-08-26 22:06:55 +07:00
Cuong Manh Le
5af3ec4f7b cmd/cli: ensure DNS goroutines terminated before self-uninstall
Otherwise, these goroutines could mess up with what resetDNS function
do, reverting DHCP DNS settings to ctrld listeners.
2024-08-16 13:50:11 +07:00
Cuong Manh Le
79476add12 Testing nameserver when initializing OS resolver
There are several issues with OS resolver right now:

 - The list of nameservers are obtained un-conditionally from all
   running interfaces.

 - ControlD public DNS query is always be used if response ok.

This could lead to slow query time, and also incorrect result if a
domain is resolved differently between internal DNS and ControlD public
DNS.

To fix these problems:

 - While initializing OS resolver, sending a test query to the
   nameserver to ensure it will response. Unreachable nameserver will
   not be used.

 - Only use ControlD public DNS success response as last one, preferring
   ok response from internal DNS servers.

While at it, also using standard package slices, since ctrld now
requires go1.21 as the minimum version.
2024-08-12 14:16:02 +07:00
Cuong Manh Le
1634a06330 all: change refresh_time -> refetch_time
The custom config is refetched from API, not refresh.
2024-08-12 14:15:49 +07:00
Cuong Manh Le
a007394f60 cmd/cli: ensure goroutines that check DNS terminated
So changes to DNS after ctrld stopped won't be reverted by the goroutine
itself. The problem happens rarely on darwin, because networksetup
command won't propagate config to /etc/resolv.conf if there is no
changes between multiple running.
2024-08-08 01:25:49 +07:00
Cuong Manh Le
62a0ba8731 cmd/cli: fix staticcheck linting 2024-08-08 01:25:22 +07:00
Cuong Manh Le
e8d3ed1acd cmd/cli: use currentStaticDNS when checking DNS changed
The dns watchdog is spawned *after* DNS was set by ctrld, thus it should
use the currentStaticDNS for getting the static DNS, instead of relying
on currentDNS, which could be system wide instead of per interfaces.
2024-08-07 15:54:22 +07:00
Cuong Manh Le
8b98faa441 cmd/cli: do not mask err argument of selfUninstall
The err should be preserved, so if we passed the error around, other
functions could still check for utility error code correctly.
2024-08-07 15:54:22 +07:00
Cuong Manh Le
30320ec9c7 cmd/cli: fix issue with editing /etc/resolv.conf directly on Darwin
On Darwin, modifying /etc/resolv.conf directly does not change interface
network settings. Thus the networksetup command uses to set DNS does not
do anything.

To fix this, after setting DNS using networksetup, re-check the content
of /etc/resolv.conf file to see if the nameservers are what we expected.
Otherwise, re-generate the file with proper nameservers.
2024-08-07 15:54:20 +07:00
Cuong Manh Le
5f4a399850 cmd/cli: extend list of valid interfaces for MacOS
By parsing "networksetup -listallhardwareports" output to get list of
available hardware ports.
2024-08-07 15:51:11 +07:00
Cuong Manh Le
82e0d4b0c4 all: add api driven config reload at runtime 2024-08-07 15:51:11 +07:00
Cuong Manh Le
95a9df826d cmd/cli: extend list of valid interfaces for MacOS 2024-08-07 15:51:11 +07:00
Cuong Manh Le
3b71d26cf3 cmd/cli: change "ctrld start" behavior
Without reading the documentation, users may think that "ctrld start"
will just start ctrld service. However, this is not the case, and may
lead to unexpected result from user's point of view.

This commit changes "ctrld start" to just start already installed ctrld
service, so users won't lost what they did installed before. If there
are any arguments specified, performing the current behavior.
2024-08-07 15:51:11 +07:00
Cuong Manh Le
c233ad9b1b cmd/cli: write new config file on reload 2024-08-07 15:51:11 +07:00
Cuong Manh Le
12d6484b1c Remove quic free file
The quic free build was gone long time ago.
2024-08-07 15:51:11 +07:00
Cuong Manh Le
bc7b1cc6d8 cmd/cli: fix wrong config file reading during self-check
At the time self-check process running, we have already known the exact
config file being used by ctrld service. Thus, we should just re-read
this config file directly instead of guessing the config file.
2024-08-07 15:51:11 +07:00
Cuong Manh Le
ec684348ed cmd/cli: add config to control DNS watchdog 2024-08-07 15:51:11 +07:00
Cuong Manh Le
18a19a3aa2 cmd/cli: cleanup more ctrld generated files
While at it, implement function to open log file on Windows for sharing
delete. So the log file could be backup correctly.

This may fix #303
2024-08-07 15:51:11 +07:00
Cuong Manh Le
905f2d08c5 cmd/cli: fix reset DNS when doing self-uninstall
While at it, also using "ctrld uninstall" on unix platform, ensuring
everything is cleanup properly.
2024-08-07 15:51:11 +07:00
Cuong Manh Le
04947b4d87 cmd/cli: make --cleanup removing more files
While at it, also implementing self-delete function for Windows.
2024-08-07 15:51:11 +07:00
Cuong Manh Le
72bf80533e cmd/cli: always run dns watchdog on Darwin/Windows 2024-08-07 15:51:11 +07:00
Cuong Manh Le
9ddedf926e cmd/cli: fix watching symlink /etc/resolv.conf
Currently, ctrld watches changes to /etc/resolv.conf file, then
reverting to the expected settings. However, if /etc/resolv.conf is a
symlink, changes made to the target file maynot be seen if it's not
under /etc directory.

To fix this, just evaluate the /etc/resolv.conf file before watching it.
2024-08-07 15:51:11 +07:00
Cuong Manh Le
139dd62ff3 cmd/cli: Capitalizing launchd status error message 2024-08-07 15:51:11 +07:00
Cuong Manh Le
50ef00526e cmd/cli: add "--cleanup" flag to remove ctrld's files 2024-08-07 15:51:11 +07:00
Cuong Manh Le
80cf79b9cb all: implement self-uninstall ctrld based on REFUSED queries 2024-08-07 15:51:11 +07:00
Cuong Manh Le
e6ad39b070 cmd/cli: add DNS watchdog on Darwin/Windows
Once per minute, ctrld will check if DNS settings was changed or not. If
yes, re-applying the proper settings for system interfaces.

For now, this is only applied when deactivation_pin was set.
2024-08-07 15:51:11 +07:00
Cuong Manh Le
56f9c72569 Add ControlD public DNS to OS resolver
Since the OS resolver only returns response with NOERROR first, it's
safe to use ControlD public DNS in parallel with system DNS. Local
domains would resolve only though local resolvers, because public ones
will return NXDOMAIN response.
2024-08-07 15:51:09 +07:00
Cuong Manh Le
dc48c908b8 cmd/cli: log validate remote config during "ctrld restart"
The same manner with what ctrld is doing for "ctrld start" command.
2024-08-07 15:28:00 +07:00
Cuong Manh Le
9b0f0e792a cmd/cli: workaround incorrect status data when not root 2024-08-07 15:27:46 +07:00
Cuong Manh Le
b3eebb19b6 internal/router: change default config directory on EdgeOS
So ctrld's own files will survive firmware upgrades.
2024-08-07 15:27:18 +07:00
Cuong Manh Le
c24589a5be internal/clientinfo: avoid heap alloc with mdns read loop
Once resource record (RR)  was used to extract necessary information, it
should be freed in memory. However, the current way that ctrld declare
the RRs causing the slices to be heap allocated, and stay in memory
longer than necessary. On system with low capacity, or firmware that GC
does not run agressively, it may causes the system memory exhausted.

To fix it, prevent RRs to be heap allocated, so they could be freed
immediately after each iterations.
2024-08-07 15:27:07 +07:00
Cuong Manh Le
1e1c5a4dc8 internal/clientinfo: tighten condition to stop probing mdns
If we see permission denied error when probing dns, that mean the
current ctrld process won't be able to do that anyway. So the probing
loop must be terminated to prevent waste of resources, or false positive
from system firewall because of too many failed attempts.
2024-08-07 15:27:02 +07:00
Cuong Manh Le
339023421a docker: bump go version for Dockerfile.debug 2024-08-07 15:26:25 +07:00
Cuong Manh Le
a00d2a431a Merge pull request #155 from Control-D-Inc/release-branch-v1.3.7
Release branch v1.3.7
2024-05-31 15:04:47 +07:00
Cuong Manh Le
5aca118dbb all: always reset DNS before initializing OS resolver
So ctrld could always get the correct nameservers used by system to be
used for its OS resolver.
2024-05-27 22:50:37 +07:00
Cuong Manh Le
411f7434f4 cmd/cli: unify reset DNS task
The task is used in multiple places, easy to be missed and cause problem
if modifying in one place but not the others.
2024-05-27 15:16:17 +07:00
Cuong Manh Le
34801382f5 cmd/cli: always reset DNS before installing ctrld
So ctrld could always gather the correct nameservers for OS resolver.
2024-05-24 18:21:26 +07:00
Cuong Manh Le
b9f2259ae4 cmd/cli: do not check DNS loop for upstream which is being down 2024-05-24 18:21:07 +07:00
Cuong Manh Le
19020a96bf all: fix OS resolver looping issue on Windows
By making dnsFromAdapter ignores DNS server which is the same IP address
of the adapter.

While at it, also changes OS resolver to use ctrld bootstrap DNS only if
there's no available nameservers.
2024-05-24 18:20:49 +07:00
Cuong Manh Le
96085147ff all: preserve DNS settings when running "ctrld restart"
By attempting to reset DNS before starting new ctrld process. This way,
ctrld will read the correct system DNS settings before changing itself.

While at it, some optimizations are made:

 - "ctrld start" won't set DNS anymore, since "ctrld run" has already did
   this, start command could just query socket control server and emittin
   proper message to users.

 - The gateway won't be included as nameservers on Windows anymore,
   since the GetAdaptersAddresses Windows API always returns the correct
   DNS servers of the interfaces.

 - The nameservers list that OS resolver is using will be shown during
   ctrld startup, making it easier for debugging.
2024-05-24 18:20:30 +07:00
Cuong Manh Le
f3dd344026 all: make procd "ctrld stop" blocks until process exited
Since procd does not block when init scripts execute stop operation, it
causes ctrld command callers (the installer, users ...) thought that
ctrld process was exited, while it does not.

See: https://forum.openwrt.org/t/procd-shutdown-issues-questions/33759
2024-05-16 14:35:42 +07:00
Cuong Manh Le
486096416f all: use correct binary path when running upgrade
For safety reason, ctrld will create a backup of the current binary when
running upgrade command.

However, on systems where ctrld status is got by parsing ps command
output, the current binary path is important and must be the same with
the original binary. Depends on kernel version, using os.Executable may
return new backup binary path, aka "ctrld_previous", not the original
"ctrld" binary. This causes upgrade command see ctrld as not running
after restart -> upgrade failed.

Fixing this by recording the binary path before creating new service, so
the ctrld service status can be checked correctly.
2024-05-16 14:35:31 +07:00
Cuong Manh Le
5710f2e984 cmd/cli: correct upgrade url for arm platforms
For arm platforms, the download url must include arm version, since the
ControlD server requires the version in download path.
2024-05-14 13:54:03 +07:00
Cuong Manh Le
09936f1f07 cmd/cli: allow running upgrade while ctrld not installed 2024-05-10 23:21:28 +07:00
Cuong Manh Le
0d6ca57536 cmd/cli: remove old forwarder after adding new one on Windows Server
Otherwise, the forwarders will keep piling up.
2024-05-10 13:53:10 +07:00
Cuong Manh Le
3ddcb84db8 cmd/cli: do not watch for config change during self-check
Once the listener is ready, the config was generated correctly on disk,
so we should just re-read the content instead of watching for changes.
2024-05-09 18:40:07 +07:00
Cuong Manh Le
1012bf063f cmd/cli: do not remove forwarders when set DNS on Windows
It seems to be a Windows bug when removing a forwarder and adding a new
one immediately then causing both of them to be added to forwarders
list. This could be verified easily using powershell commands.

Since the forwarder will be removed when ctrld stop/uninstall, ctrld run
could avoid that action, not only help mitigate above bug, but also not
waste host resources.
2024-05-09 18:39:57 +07:00
Cuong Manh Le
b8155e6182 cmd/cli: set DNS last when running ctrld service
On low resources Windows Server VM, profiling shows the bottle neck when
interacting with Windows DNS server to add/remove forwarders using by
calling external powershell commands. This happens because ctrld try
setting DNS before it runs.

However, it would be better if ctrld only sets DNS after all its
listeners ready. So it won't block ctrld from receiving requests.

With this change, self-check process on dual Core Windows server VM now
runs constantly fast, ~2-4 seconds when running multiple times in a row.
2024-05-09 18:39:47 +07:00
Cuong Manh Le
9a34df61bb docs: remove "os" from upstream type valid values
It is an "magic" internal thing, should not be documented as its just
confusing.

See: https://docs.controld.com/discuss/663aac4f8c775a0011e6b418
2024-05-09 18:39:30 +07:00
Yegor Sak
fbb879edf9 Add README.md image 2024-05-09 18:39:30 +07:00
Cuong Manh Le
ac97c88876 cmd/cli: do not get windows feature for checking DNS installed
"Get-WindowsFeature -Name DNS" is slow to run, and seems to make low
resources Windows VM slow down so much.
2024-05-09 18:39:30 +07:00
Cuong Manh Le
a1fda2c0de cmd/cli: make self-check process faster
The "ctrld start" command is running slow, and using much CPU than
necessary. The problem was made because of several things:

1. ctrld process is waiting for 5 seconds before marking listeners up.
   That ends up adding those seconds to the self-check process, even
   though the listeners may have been already available.

2. While creating socket control client, "s.Status()" is called to
   obtain ctrld service status, so we could terminate early if the
   service failed to run. However, that would make a lot of syscall in a
   hot loop, eating the CPU constantly while the command is running. On
   Windows, that call would become slower after each calls. The same
   effect could be seen using Windows services manager GUI, by pressing
   start/stop/restart button fast enough, we could see a timeout raised.

3. The socket control server is started lately, after all the listeners
   up. That would make the loop for creating socket control client run
   longer and use much resources than necessary.

Fixes for these problems are quite obvious:

1. Removing hard code 5 seconds waiting. NotifyStartedFunc is enough to
   ensure that listeners are ready for accepting requests.

2. Check "s.Status()" only once before the loop. There has been already
   30 seconds timeout, so if anything went wrong, the self-check process
   could be terminated, and won't hang forever.

3. Starting socket control server earlier, so newSocketControlClient can
   connect to server with fewest attempts, then querying "/started"
   endpoint to ensure the listeners have been ready.

With these fixes, "ctrld start" now run much faster on modern machines,
taking ~1-2 seconds (previously ~5-8 seconds) to finish. On dual cores
VM, it takes ~5-8 seconds (previously a few dozen seconds or timeout).

---

While at it, there are two refactoring for making the code easier to
read/maintain:

- PersistentPreRun is now used in root command to init console logging,
  so we don't have to initialize them in sub-commands.

- NotifyStartedFunc now use channel for synchronization, instead of a
  mutex, making the ugly asymetric calls to lock goes away, making the
  code more idiom, and theoretically have better performance.
2024-05-09 18:39:30 +07:00
Cuong Manh Le
f499770d45 cmd/cli: use channel instead of mutex in runDNSServer
So the code is easier to read/follow, and possible reduce the overhead
of using mutex in low resources system.
2024-05-09 18:39:30 +07:00
Cuong Manh Le
4769da4ef4 cmd/cli: simplifying console logging initialization
By using PersistentPreRun with root command, so we don't have to write
the same code for each child commands.
2024-05-09 18:39:30 +07:00
Cuong Manh Le
c2556a8e39 cmd/cli: add skipping self checks flag 2024-05-09 18:39:30 +07:00
Cuong Manh Le
29bf329f6a cmd/cli: fix systemd-networkd-wait-online blocks ctrld starts
The systemd-networkd-wait-online is only required if systemd-networkd
is managing any interfaces. Otherwise, it will hang and block ctrld from
starting.

See: https://github.com/systemd/systemd/issues/23304
2024-05-09 18:39:30 +07:00
Cuong Manh Le
1dee4305bc cmd/cli: refactoring self-check process
Make the code cleaner and easier to maintain.
2024-05-09 18:39:30 +07:00
Cuong Manh Le
429a98b690 Merge pull request #144 from Control-D-Inc/release-branch-v1.3.6
Release branch v1.3.6
2024-04-20 00:01:23 +07:00
Cuong Manh Le
da01a146d2 internal/clientinfo: check hostname mapping for both ipv4/ipv6 2024-04-19 14:32:21 +07:00
Cuong Manh Le
dd9f2465be internal/clientinfo: map ::1 to the right host MAC address
So queries originating from host using ::1 as source will be recognized
properly, and treated the same as other queries from host itself.
2024-04-19 14:32:09 +07:00
Cuong Manh Le
b5cf0e2b31 cmd/cli: allow chosing dev/prod with upgrade command 2024-04-16 00:16:11 +07:00
Cuong Manh Le
1db159ad34 cmd/cli: move pin check before any API calls
So ctrld won't perform unnecessary API calls if pin code is set.
2024-04-16 00:16:00 +07:00
Ginder Singh
6604f973ac Disconnect from Control D without checking pin for app restarts 2024-04-11 00:22:38 +07:00
Cuong Manh Le
69ee6582e2 Bump quic-go to v0.42.0
Fixes https://pkg.go.dev/vuln/GO-2024-2682
2024-04-11 00:19:36 +07:00
Cuong Manh Le
6f12667e8c Only set OS header value for query from router itself
So queries from clients won't be mis-recognized as query from router in
case of client metadata is in progress of collecting.
2024-04-06 00:41:23 +07:00
Cuong Manh Le
b002dff624 internal: only delete old ipv6 if it is non-link local
So the client is removed from table only when it's global ipv6 changed.
2024-04-06 00:41:04 +07:00
Cuong Manh Le
affef963c1 cmd/cli: log new version when upgrading successfully 2024-04-04 22:44:29 +07:00
Cuong Manh Le
56b2056190 Bump golang.org/x/net to v0.23.0
Fix https://pkg.go.dev/vuln/GO-2024-2687
2024-04-04 22:44:29 +07:00
Cuong Manh Le
c1e6f5126a internal/clientinfo: watch NDP table changes on Linux
So with clients which only use SLAAC, ctrld could see client's new ip as
soon as its state changes to REACHABLE.

Moreover, the NDP listener is also changed to listen on all possible
ipv6 link local interfaces. That would allow ctrld to get all NDP events
happening in local network.

SLAAC RFC: https://datatracker.ietf.org/doc/html/rfc4862
2024-04-04 22:44:25 +07:00
Cuong Manh Le
1a8c1ec73d Provide better error message when self-check failed
By connecting to all upstreams when self-check failed, so it's clearer
to users what causes self-check failed.
2024-04-01 14:14:57 +07:00
Cuong Manh Le
52954b8ceb Set bootstrap ip for ControlD upstream in cd mode 2024-04-01 14:14:44 +07:00
Cuong Manh Le
a5025e35ea cmd/cli: add internal domain test query during self-check
So it's clear that client could be reached ctrld's listener or not.
2024-04-01 14:14:32 +07:00
Cuong Manh Le
07f80c9ebf cmd/cli: disable quic-go's ECN support by default
It may cause issues on some OS-es.

See: https://github.com/quic-go/quic-go/issues/3911
2024-03-25 18:25:07 +07:00
Cuong Manh Le
13db23553d Upgrade protobuf to v1.33.0
Fixing CVE-2024-24786.
2024-03-22 22:36:12 +07:00
Cuong Manh Le
3963fce43b Use sync.OnceValue 2024-03-22 16:29:54 +07:00
Cuong Manh Le
ea4e5147bd cmd/cli: use slices.Contains 2024-03-22 16:29:47 +07:00
Cuong Manh Le
7a491a4cc5 cmd/cli: use clear builtin 2024-03-22 16:29:38 +07:00
Cuong Manh Le
5ba90748f6 internal/clientinfo: skipping non-reachable neighbor
Otherwise, failed or stale ipv6 will be used if it appeared last in the
table, instaed of the current one.
2024-03-22 16:11:47 +07:00
Cuong Manh Le
20f8f22bae all: add support to Netgear Orbi Voxel
While at it, also ensure checking the service is installed or not before
executing uninstall function, so we won't emit un-necessary errors.
2024-03-22 16:11:25 +07:00
Cuong Manh Le
b50cccac85 all: add flush cache domains config 2024-03-22 16:09:06 +07:00
Cuong Manh Le
34ebe9b054 cmd/cli: allow MAC wildcard matching 2024-03-22 16:08:53 +07:00
Cuong Manh Le
43d82cf1a7 cmd/cli,internal/router: detect unbound/dnsmasq status correctly on *BSD
Also detect cd mode for stop/uninstall command correctly, too.
2024-03-22 16:08:40 +07:00
Cuong Manh Le
ab88174091 docs: add missing supported lease file type
Discover while supporting user in Discord.
2024-03-22 16:08:26 +07:00
Cuong Manh Le
ebcbf85373 cmd/cli: add upgrade command
This commit implements upgrade command which will:

 - Download latest version for current running arch.
 - Replacing the binary on disk.
 - Self-restart ctrld service.

If the service does not start with new binary, old binary will be
restored and self-restart again.
2024-03-22 16:08:14 +07:00
Cuong Manh Le
87513cba6d cmd/cli: ignore un-usable interfaces on darwin when resetDNS 2024-03-22 16:08:01 +07:00
Cuong Manh Le
64bcd2f00d cmd/cli: validate remote config during "ctrld start"
On BSD, the service is made un-killable since v1.3.4 by using daemon
command "-r" option. However, when reading remote config, the ctrld will
fatally exit if the config is malformed. This causes daemon respawn new
ctrld process immediately, causing the "ctrld start" command hang
forever because of restart loop.

Since "ctrld start" already fetch the resolver config for validating
uid, it should validate the remote config, too. This allows better error
message printed to users, let them know that the config is invalid.

Further, if the remote config was invalid, we should disregard it and
generating the default working one in cd mode.
2024-03-22 16:07:45 +07:00
Cuong Manh Le
cc6ae290f8 internal/clientinfo: use last seen IP for NDP discovery 2024-03-22 16:07:29 +07:00
Cuong Manh Le
3e62bd3dbd internal/router: use same dir with executable as home dir on Firewalla
Since when /etc is not persisted after rebooting.
2024-03-22 16:07:19 +07:00
Ginder Singh
8491f9c455 Deactivation pin fixes
- short control socket name.(in IOS max length is 11)
- wait for control server to reply before checking for deactivation pin.
- Added separate name for control socket for mobile.
- Added stop channel reference to Control client constructor.
2024-03-22 16:05:49 +07:00
Cuong Manh Le
3ca754b438 cmd/cli: use loopback mapping for query from self
So queries from host will always use the same hostname consistently.
2024-03-22 15:58:31 +07:00
Cuong Manh Le
8c7c3901e8 cmd/cli: ignore un-usable interfaces on darwin
So multi interfaces config won't emit un-necessary errors if the network
cable adapters are not being used on MacOS.
2024-03-22 15:58:17 +07:00
Cuong Manh Le
a9672dfff5 Allow DoH/DoH3 endpoint without scheme 2024-03-22 15:58:00 +07:00
Cuong Manh Le
203a2ec8b8 cmd/cli: add timeout for newSocketControlClient
On BSD platform, using "daemon -r" may fool the status check that ctrld
is still running while it was terminated unexpectedly. This may cause
the check in newSocketControlClient hangs forever.

Using a sane timeout value of 30 seconds, which should be enough for the
ctrld service started in normal condition.
2024-03-22 15:57:42 +07:00
Yegor S
810cbd1f4f Merge pull request #138 from Control-D-Inc/release-branch-v1.3.5
Release branch v1.3.5
2024-03-04 12:40:40 -05:00
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
Yegor S
b496147ce7 Merge pull request #137 from Control-D-Inc/fix-doc-links
docs: fix reference links in config.md
2024-02-19 17:02:29 -05: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
Cuong Manh Le
6bb9e7a766 docs: fix reference links in config.md 2024-02-01 14:37:28 +07: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
139 changed files with 11506 additions and 2159 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.23.x"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
@@ -19,8 +19,8 @@ jobs:
with:
go-version: ${{ matrix.go }}
- run: "go test -race ./..."
- uses: dominikh/staticcheck-action@v1.2.0
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2023.1.2"
version: "2024.1.1"
install-go: false
cache-key: ${{ matrix.go }}

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

@@ -4,13 +4,16 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/Control-D-Inc/ctrld.svg)](https://pkg.go.dev/github.com/Control-D-Inc/ctrld)
[![Go Report Card](https://goreportcard.com/badge/github.com/Control-D-Inc/ctrld)](https://goreportcard.com/report/github.com/Control-D-Inc/ctrld)
![ctrld spash image](/docs/ctrldsplash.png)
A highly configurable DNS forwarding proxy with support for:
- Multiple listeners for incoming queries
- Multiple upstreams with fallbacks
- 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 +64,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
@@ -102,9 +105,11 @@ Available Commands:
start Quick start service and configure DNS on interface
stop Quick stop service and remove DNS from interface
restart Restart the ctrld service
reload Reload the ctrld service
status Show status of the ctrld service
uninstall Stop and uninstall the ctrld service
clients Manage clients
upgrade Upgrading ctrld to latest version
Flags:
-h, --help help for ctrld
@@ -232,7 +237,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"
)

15
cmd/cli/ad_others.go Normal file
View File

@@ -0,0 +1,15 @@
//go:build !windows
package cli
import (
"github.com/Control-D-Inc/ctrld"
)
// addExtraSplitDnsRule adds split DNS rule if present.
func addExtraSplitDnsRule(_ *ctrld.Config) bool { return false }
// getActiveDirectoryDomain returns AD domain name of this computer.
func getActiveDirectoryDomain() (string, error) {
return "", nil
}

73
cmd/cli/ad_windows.go Normal file
View File

@@ -0,0 +1,73 @@
package cli
import (
"io"
"log"
"os"
"strings"
"github.com/microsoft/wmi/pkg/base/host"
hh "github.com/microsoft/wmi/pkg/hardware/host"
"github.com/Control-D-Inc/ctrld"
)
// addExtraSplitDnsRule adds split DNS rule for domain if it's part of active directory.
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
domain, err := getActiveDirectoryDomain()
if err != nil {
mainLog.Load().Debug().Msgf("unable to get active directory domain: %v", err)
return false
}
if domain == "" {
mainLog.Load().Debug().Msg("no active directory domain found")
return false
}
// Network rules are lowercase during toml config marshaling,
// lowercase the domain here too for consistency.
domain = strings.ToLower(domain)
domainRuleAdded := addSplitDnsRule(cfg, domain)
wildcardDomainRuleRuleAdded := addSplitDnsRule(cfg, "*."+strings.TrimPrefix(domain, "."))
return domainRuleAdded || wildcardDomainRuleRuleAdded
}
// addSplitDnsRule adds split-rule for given domain if there's no existed rule.
// The return value indicates whether the split-rule was added or not.
func addSplitDnsRule(cfg *ctrld.Config, domain string) bool {
for n, lc := range cfg.Listener {
if lc.Policy == nil {
lc.Policy = &ctrld.ListenerPolicyConfig{}
}
for _, rule := range lc.Policy.Rules {
if _, ok := rule[domain]; ok {
mainLog.Load().Debug().Msgf("split-rule %q already existed for listener.%s", domain, n)
return false
}
}
mainLog.Load().Debug().Msgf("adding split-rule %q for listener.%s", domain, n)
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{domain: []string{}})
}
return true
}
// getActiveDirectoryDomain returns AD domain name of this computer.
func getActiveDirectoryDomain() (string, error) {
log.SetOutput(io.Discard)
defer log.SetOutput(os.Stderr)
whost := host.NewWmiLocalHost()
cs, err := hh.GetComputerSystem(whost)
if cs != nil {
defer cs.Close()
}
if err != nil {
return "", err
}
pod, err := cs.GetPropertyPartOfDomain()
if err != nil {
return "", err
}
if pod {
return cs.GetPropertyDomain()
}
return "", nil
}

View File

@@ -0,0 +1,71 @@
package cli
import (
"fmt"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/testhelper"
"github.com/stretchr/testify/assert"
)
func Test_getActiveDirectoryDomain(t *testing.T) {
start := time.Now()
domain, err := getActiveDirectoryDomain()
if err != nil {
t.Fatal(err)
}
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
start = time.Now()
domainPowershell, err := getActiveDirectoryDomainPowershell()
if err != nil {
t.Fatal(err)
}
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
if domain != domainPowershell {
t.Fatalf("result mismatch, want: %v, got: %v", domainPowershell, domain)
}
}
func getActiveDirectoryDomainPowershell() (string, error) {
cmd := "$obj = Get-WmiObject Win32_ComputerSystem; if ($obj.PartOfDomain) { $obj.Domain }"
output, err := powershell(cmd)
if err != nil {
return "", fmt.Errorf("failed to get domain name: %w, output:\n\n%s", err, string(output))
}
return string(output), nil
}
func Test_addSplitDnsRule(t *testing.T) {
newCfg := func(domains ...string) *ctrld.Config {
cfg := testhelper.SampleConfig(t)
lc := cfg.Listener["0"]
for _, domain := range domains {
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{domain: []string{}})
}
return cfg
}
tests := []struct {
name string
cfg *ctrld.Config
domain string
added bool
}{
{"added", newCfg(), "example.com", true},
{"TLD existed", newCfg("example.com"), "*.example.com", true},
{"wildcard existed", newCfg("*.example.com"), "example.com", true},
{"not added TLD", newCfg("example.com", "*.example.com"), "example.com", false},
{"not added wildcard", newCfg("example.com", "*.example.com"), "*.example.com", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
added := addSplitDnsRule(tc.cfg, tc.domain)
assert.Equal(t, tc.added, added)
})
}
}

5
cmd/cli/cgo.go Normal file
View File

@@ -0,0 +1,5 @@
//go:build cgo
package cli
const cgoEnabled = true

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,31 @@ func Test_writeConfigFile(t *testing.T) {
_, err := os.Stat(configPath)
assert.True(t, os.IsNotExist(err))
assert.NoError(t, writeConfigFile())
assert.NoError(t, writeConfigFile(&cfg))
_, err = os.Stat(configPath)
require.NoError(t, err)
}
func Test_isStableVersion(t *testing.T) {
tests := []struct {
name string
ver string
isStable bool
}{
{"stable", "v1.3.5", true},
{"pre", "v1.3.5-next", false},
{"pre with commit hash", "v1.3.5-next-asdf", false},
{"dev", "dev", false},
{"empty", "dev", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isStableVersion(tc.ver); got != tc.isStable {
t.Errorf("unexpected result for %s, want: %v, got: %v", tc.ver, tc.isStable, got)
}
})
}
}

1398
cmd/cli/commands.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,5 +25,14 @@ func newControlClient(addr string) *controlClient {
}
func (c *controlClient) post(path string, data io.Reader) (*http.Response, error) {
// for log/send, set the timeout to 5 minutes
if path == sendLogsPath {
c.c.Timeout = time.Minute * 5
}
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

@@ -3,6 +3,8 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
@@ -10,16 +12,31 @@ import (
"sort"
"time"
"github.com/kardianos/service"
dto "github.com/prometheus/client_model/go"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
const (
contentTypeJson = "application/json"
listClientsPath = "/clients"
startedPath = "/started"
reloadPath = "/reload"
contentTypeJson = "application/json"
listClientsPath = "/clients"
startedPath = "/started"
reloadPath = "/reload"
deactivationPath = "/deactivation"
cdPath = "/cd"
ifacePath = "/iface"
viewLogsPath = "/log/view"
sendLogsPath = "/log/send"
)
type ifaceResponse struct {
Name string `json:"name"`
All bool `json:"all"`
OK bool `json:"ok"`
}
type controlServer struct {
server *http.Server
mux *http.ServeMux
@@ -62,14 +79,81 @@ func (s *controlServer) register(pattern string, handler http.Handler) {
func (p *prog) registerControlServerHandler() {
p.cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
mainLog.Load().Debug().Msg("handling list clients request")
clients := p.ciTable.ListClients()
mainLog.Load().Debug().Int("client_count", len(clients)).Msg("retrieved clients list")
sort.Slice(clients, func(i, j int) bool {
return clients[i].IP.Less(clients[j].IP)
})
mainLog.Load().Debug().Msg("sorted clients by IP address")
if p.metricsQueryStats.Load() {
mainLog.Load().Debug().Msg("metrics query stats enabled, collecting query counts")
for idx, client := range clients {
mainLog.Load().Debug().
Int("index", idx).
Str("ip", client.IP.String()).
Str("mac", client.Mac).
Str("hostname", client.Hostname).
Msg("processing client metrics")
client.IncludeQueryCount = true
dm := &dto.Metric{}
if statsClientQueriesCount.MetricVec == nil {
mainLog.Load().Debug().
Str("client_ip", client.IP.String()).
Msg("skipping metrics collection: MetricVec is nil")
continue
}
m, err := statsClientQueriesCount.MetricVec.GetMetricWithLabelValues(
client.IP.String(),
client.Mac,
client.Hostname,
)
if err != nil {
mainLog.Load().Debug().
Err(err).
Str("client_ip", client.IP.String()).
Str("mac", client.Mac).
Str("hostname", client.Hostname).
Msg("failed to get metrics for client")
continue
}
if err := m.Write(dm); err == nil && dm.Counter != nil {
client.QueryCount = int64(dm.Counter.GetValue())
mainLog.Load().Debug().
Str("client_ip", client.IP.String()).
Int64("query_count", client.QueryCount).
Msg("successfully collected query count")
} else if err != nil {
mainLog.Load().Debug().
Err(err).
Str("client_ip", client.IP.String()).
Msg("failed to write metric")
}
}
} else {
mainLog.Load().Debug().Msg("metrics query stats disabled, skipping query counts")
}
if err := json.NewEncoder(w).Encode(&clients); err != nil {
mainLog.Load().Error().
Err(err).
Int("client_count", len(clients)).
Msg("failed to encode clients response")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
mainLog.Load().Debug().
Int("client_count", len(clients)).
Msg("successfully sent clients list response")
}))
p.cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
select {
@@ -125,6 +209,127 @@ 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 always allowing deactivation.
if cdUID == "" {
w.WriteHeader(http.StatusOK)
return
}
// Re-fetch pin code from API.
if rc, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev); rc != nil {
if rc.DeactivationPin != nil {
cdDeactivationPin.Store(*rc.DeactivationPin)
} else {
cdDeactivationPin.Store(defaultDeactivationPin)
}
} else {
mainLog.Load().Warn().Err(err).Msg("could not re-fetch deactivation pin code")
}
// If pin code not set, allowing deactivation.
if 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.Load():
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)
}))
p.cs.register(cdPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if cdUID != "" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(cdUID))
return
}
w.WriteHeader(http.StatusBadRequest)
}))
p.cs.register(ifacePath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
res := &ifaceResponse{Name: iface}
// p.setDNS is only called when running as a service
if !service.Interactive() {
<-p.csSetDnsDone
if p.csSetDnsOk {
res.Name = p.runningIface
res.All = p.requiredMultiNICsConfig
res.OK = true
}
}
if err := json.NewEncoder(w).Encode(res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("could not marshal iface data: %v", err), http.StatusInternalServerError)
return
}
}))
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
lr, err := p.logReader()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer lr.r.Close()
if lr.size == 0 {
w.WriteHeader(http.StatusMovedPermanently)
return
}
data, err := io.ReadAll(lr.r)
if err != nil {
http.Error(w, fmt.Sprintf("could not read log: %v", err), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(&logViewResponse{Data: string(data)}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("could not marshal log data: %v", err), http.StatusInternalServerError)
return
}
}))
p.cs.register(sendLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if time.Since(p.internalLogSent) < logWriterSentInterval {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
r, err := p.logReader()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if r.size == 0 {
w.WriteHeader(http.StatusMovedPermanently)
return
}
req := &controld.LogsRequest{
UID: cdUID,
Data: r.r,
}
mainLog.Load().Debug().Msg("sending log file to ControlD server")
resp := logSentResponse{Size: r.size}
if err := controld.SendLogs(req, cdDev); err != nil {
mainLog.Load().Error().Msgf("could not send log file to ControlD server: %v", err)
resp.Error = err.Error()
w.WriteHeader(http.StatusInternalServerError)
} else {
mainLog.Load().Debug().Msg("sending log file successfully")
w.WriteHeader(http.StatusOK)
}
if err := json.NewEncoder(w).Encode(&resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
p.internalLogSent = time.Now()
}))
}
func jsonResponse(next http.Handler) http.Handler {

File diff suppressed because it is too large Load Diff

View File

@@ -22,14 +22,22 @@ func Test_wildcardMatches(t *testing.T) {
domain string
match bool
}{
{"prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
{"prefix", "*.windscribe.com", "anything.windscribe.com", true},
{"prefix not match other domain", "*.windscribe.com", "example.com", false},
{"prefix not match domain in name", "*.windscribe.com", "wwindscribe.com", false},
{"suffix", "suffix.*", "suffix.windscribe.com", true},
{"suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
{"both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
{"both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
{"domain - prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
{"domain - prefix", "*.windscribe.com", "anything.windscribe.com", true},
{"domain - prefix not match other s", "*.windscribe.com", "example.com", false},
{"domain - prefix not match s in name", "*.windscribe.com", "wwindscribe.com", false},
{"domain - suffix", "suffix.*", "suffix.windscribe.com", true},
{"domain - suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
{"domain - both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
{"domain - both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
{"domain - case-insensitive", "*.WINDSCRIBE.com", "anything.windscribe.com", true},
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
{"mac - suffix", "d4:67:98:*", "d4:67:98:05:b4:2b", true},
{"mac - suffix not match other", "d4:67:98:*", "d4:67:97:15:b4:2b", false},
{"mac - both", "d4:67:98:*:b4:2b", "d4:67:98:05:b4:2b", true},
{"mac - both not match", "d4:67:98:*:b4:2b", "d4:67:97:05:c4:2b", false},
}
for _, tc := range tests {
@@ -67,6 +75,7 @@ func Test_canonicalName(t *testing.T) {
func Test_prog_upstreamFor(t *testing.T) {
cfg := testhelper.SampleConfig(t)
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
p := &prog{cfg: cfg}
p.um = newUpstreamMonitor(p.cfg)
p.lanLoopGuard = newLoopGuard()
@@ -187,8 +196,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) {
@@ -357,6 +366,9 @@ func Test_isLanHostnameQuery(t *testing.T) {
{"A not LAN", newDnsMsgWithHostname("example.com", dns.TypeA), false},
{"AAAA not LAN", newDnsMsgWithHostname("example.com", dns.TypeAAAA), false},
{"Not A or AAAA", newDnsMsgWithHostname("foo", dns.TypeTXT), false},
{".domain", newDnsMsgWithHostname("foo.domain", dns.TypeA), true},
{".lan", newDnsMsgWithHostname("foo.lan", dns.TypeA), true},
{".local", newDnsMsgWithHostname("foo.local", dns.TypeA), true},
}
for _, tc := range tests {
tc := tc
@@ -406,6 +418,27 @@ func Test_isPrivatePtrLookup(t *testing.T) {
}
}
func Test_isSrvLanLookup(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isSrvLookup bool
}{
{"SRV LAN", newDnsMsgWithHostname("foo", dns.TypeSRV), true},
{"Not SRV", newDnsMsgWithHostname("foo", dns.TypeNone), false},
{"Not SRV LAN", newDnsMsgWithHostname("controld.com", dns.TypeSRV), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isSrvLanLookup(tc.msg); tc.isSrvLookup != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isSrvLookup, got)
}
})
}
}
func Test_isWanClient(t *testing.T) {
tests := []struct {
name string

14
cmd/cli/hostname.go Normal file
View File

@@ -0,0 +1,14 @@
package cli
import "regexp"
// validHostname reports whether hostname is a valid hostname.
// A valid hostname contains 3 -> 64 characters and conform to RFC1123.
func validHostname(hostname string) bool {
hostnameLen := len(hostname)
if hostnameLen < 3 || hostnameLen > 64 {
return false
}
validHostnameRfc1123 := regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
return validHostnameRfc1123.MatchString(hostname)
}

35
cmd/cli/hostname_test.go Normal file
View File

@@ -0,0 +1,35 @@
package cli
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_validHostname(t *testing.T) {
tests := []struct {
name string
hostname string
valid bool
}{
{"localhost", "localhost", true},
{"localdomain", "localhost.localdomain", true},
{"localhost6", "localhost6.localdomain6", true},
{"ip6", "ip6-localhost", true},
{"non-domain", "controld", true},
{"domain", "controld.com", true},
{"empty", "", false},
{"min length", "fo", false},
{"max length", strings.Repeat("a", 65), false},
{"special char", "foo!", false},
{"non-ascii", "fooΩ", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.hostname, func(t *testing.T) {
t.Parallel()
assert.True(t, validHostname(tc.hostname) == tc.valid)
})
}
}

View File

@@ -1,5 +1,12 @@
package cli
import (
"fmt"
"net"
"net/http"
"time"
)
// AppCallback provides hooks for injecting certain functionalities
// from mobile platforms to main ctrld cli.
type AppCallback struct {
@@ -11,8 +18,61 @@ 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
}
const (
defaultHTTPTimeout = 30 * time.Second
defaultMaxRetries = 3
)
// httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback
func httpClientWithFallback(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
// Prefer IPv4 over IPv6
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
FallbackDelay: 1 * time.Millisecond, // Very small delay to prefer IPv4
}).DialContext,
},
}
}
// doWithRetry performs an HTTP request with retries
func doWithRetry(req *http.Request, maxRetries int) (*http.Response, error) {
var lastErr error
client := httpClientWithFallback(defaultHTTPTimeout)
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(time.Second * time.Duration(attempt+1)) // Exponential backoff
}
resp, err := client.Do(req)
if err == nil {
return resp, nil
}
lastErr = err
mainLog.Load().Debug().Err(err).
Str("method", req.Method).
Str("url", req.URL.String()).
Msgf("HTTP request attempt %d/%d failed", attempt+1, maxRetries)
}
return nil, fmt.Errorf("failed after %d attempts to %s %s: %v", maxRetries, req.Method, req.URL, lastErr)
}
// Helper for making GET requests with retries
func getWithRetry(url string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return doWithRetry(req, defaultMaxRetries)
}

204
cmd/cli/log_writer.go Normal file
View File

@@ -0,0 +1,204 @@
package cli
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
const (
logWriterSize = 1024 * 1024 * 5 // 5 MB
logWriterSmallSize = 1024 * 1024 * 1 // 1 MB
logWriterInitialSize = 32 * 1024 // 32 KB
logWriterSentInterval = time.Minute
logWriterInitEndMarker = "\n\n=== INIT_END ===\n\n"
logWriterLogEndMarker = "\n\n=== LOG_END ===\n\n"
)
type logViewResponse struct {
Data string `json:"data"`
}
type logSentResponse struct {
Size int64 `json:"size"`
Error string `json:"error"`
}
type logReader struct {
r io.ReadCloser
size int64
}
// logWriter is an internal buffer to keep track of runtime log when no logging is enabled.
type logWriter struct {
mu sync.Mutex
buf bytes.Buffer
size int
}
// newLogWriter creates an internal log writer.
func newLogWriter() *logWriter {
return newLogWriterWithSize(logWriterSize)
}
// newSmallLogWriter creates an internal log writer with small buffer size.
func newSmallLogWriter() *logWriter {
return newLogWriterWithSize(logWriterSmallSize)
}
// newLogWriterWithSize creates an internal log writer with a given buffer size.
func newLogWriterWithSize(size int) *logWriter {
lw := &logWriter{size: size}
return lw
}
func (lw *logWriter) Write(p []byte) (int, error) {
lw.mu.Lock()
defer lw.mu.Unlock()
// If writing p causes overflows, discard old data.
if lw.buf.Len()+len(p) > lw.size {
buf := lw.buf.Bytes()
haveEndMarker := false
// If there's init end marker already, preserve the data til the marker.
if idx := bytes.LastIndex(buf, []byte(logWriterInitEndMarker)); idx >= 0 {
buf = buf[:idx+len(logWriterInitEndMarker)]
haveEndMarker = true
} else {
// Otherwise, preserve the initial size data.
buf = buf[:logWriterInitialSize]
if idx := bytes.LastIndex(buf, []byte("\n")); idx != -1 {
buf = buf[:idx]
}
}
lw.buf.Reset()
lw.buf.Write(buf)
if !haveEndMarker {
lw.buf.WriteString(logWriterInitEndMarker) // indicate that the log was truncated.
}
}
// If p is bigger than buffer size, truncate p by half until its size is smaller.
for len(p)+lw.buf.Len() > lw.size {
p = p[len(p)/2:]
}
return lw.buf.Write(p)
}
// initLogging initializes global logging setup.
func (p *prog) initLogging(backup bool) {
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
logWriters := initLoggingWithBackup(backup)
// Initializing internal logging after global logging.
p.initInternalLogging(logWriters)
}
// initInternalLogging performs internal logging if there's no log enabled.
func (p *prog) initInternalLogging(writers []io.Writer) {
if !p.needInternalLogging() {
return
}
p.initInternalLogWriterOnce.Do(func() {
mainLog.Load().Notice().Msg("internal logging enabled")
p.internalLogWriter = newLogWriter()
p.internalLogSent = time.Now().Add(-logWriterSentInterval)
p.internalWarnLogWriter = newSmallLogWriter()
})
p.mu.Lock()
lw := p.internalLogWriter
wlw := p.internalWarnLogWriter
p.mu.Unlock()
// If ctrld was run without explicit verbose level,
// run the internal logging at debug level, so we could
// have enough information for troubleshooting.
if verbose == 0 {
for i := range writers {
w := &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{Writer: writers[i]},
Level: zerolog.NoticeLevel,
}
writers[i] = w
}
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
writers = append(writers, lw)
writers = append(writers, &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{Writer: wlw},
Level: zerolog.WarnLevel,
})
multi := zerolog.MultiLevelWriter(writers...)
l := mainLog.Load().Output(multi).With().Logger()
mainLog.Store(&l)
ctrld.ProxyLogger.Store(&l)
}
// needInternalLogging reports whether prog needs to run internal logging.
func (p *prog) needInternalLogging() bool {
// Do not run in non-cd mode.
if cdUID == "" {
return false
}
// Do not run if there's already log file.
if p.cfg.Service.LogPath != "" {
return false
}
return true
}
func (p *prog) logReader() (*logReader, error) {
if p.needInternalLogging() {
p.mu.Lock()
lw := p.internalLogWriter
wlw := p.internalWarnLogWriter
p.mu.Unlock()
if lw == nil {
return nil, errors.New("nil internal log writer")
}
if wlw == nil {
return nil, errors.New("nil internal warn log writer")
}
// Normal log content.
lw.mu.Lock()
lwReader := bytes.NewReader(lw.buf.Bytes())
lwSize := lw.buf.Len()
lw.mu.Unlock()
// Warn log content.
wlw.mu.Lock()
wlwReader := bytes.NewReader(wlw.buf.Bytes())
wlwSize := wlw.buf.Len()
wlw.mu.Unlock()
reader := io.MultiReader(lwReader, bytes.NewReader([]byte(logWriterLogEndMarker)), wlwReader)
lr := &logReader{r: io.NopCloser(reader)}
lr.size = int64(lwSize + wlwSize)
if lr.size == 0 {
return nil, errors.New("internal log is empty")
}
return lr, nil
}
if p.cfg.Service.LogPath == "" {
return &logReader{r: io.NopCloser(strings.NewReader(""))}, nil
}
f, err := os.Open(normalizeLogFilePath(p.cfg.Service.LogPath))
if err != nil {
return nil, err
}
lr := &logReader{r: f}
if st, err := f.Stat(); err == nil {
lr.size = st.Size()
} else {
return nil, fmt.Errorf("f.Stat: %w", err)
}
if lr.size == 0 {
return nil, errors.New("log file is empty")
}
return lr, nil
}

View File

@@ -0,0 +1,85 @@
package cli
import (
"strings"
"sync"
"testing"
)
func Test_logWriter_Write(t *testing.T) {
size := 64 * 1024
lw := &logWriter{size: size}
lw.buf.Grow(lw.size)
data := strings.Repeat("A", size)
lw.Write([]byte(data))
if lw.buf.String() != data {
t.Fatalf("unexpected buf content: %v", lw.buf.String())
}
newData := "B"
halfData := strings.Repeat("A", len(data)/2) + logWriterInitEndMarker
lw.Write([]byte(newData))
if lw.buf.String() != halfData+newData {
t.Fatalf("unexpected new buf content: %v", lw.buf.String())
}
bigData := strings.Repeat("B", 256*1024)
expected := halfData + strings.Repeat("B", 16*1024)
lw.Write([]byte(bigData))
if lw.buf.String() != expected {
t.Fatalf("unexpected big buf content: %v", lw.buf.String())
}
}
func Test_logWriter_ConcurrentWrite(t *testing.T) {
size := 64 * 1024
lw := &logWriter{size: size}
n := 10
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
lw.Write([]byte(strings.Repeat("A", i)))
}()
}
wg.Wait()
if lw.buf.Len() > lw.size {
t.Fatalf("unexpected buf size: %v, content: %q", lw.buf.Len(), lw.buf.String())
}
}
func Test_logWriter_MarkerInitEnd(t *testing.T) {
size := 64 * 1024
lw := &logWriter{size: size}
lw.buf.Grow(lw.size)
paddingSize := 10
// Writing half of the size, minus len(end marker) and padding size.
dataSize := size/2 - len(logWriterInitEndMarker) - paddingSize
data := strings.Repeat("A", dataSize)
// Inserting newline for making partial init data
data += "\n"
// Filling left over buffer to make the log full.
// The data length: len(end marker) + padding size - 1 (for newline above) + size/2
data += strings.Repeat("A", len(logWriterInitEndMarker)+paddingSize-1+(size/2))
lw.Write([]byte(data))
if lw.buf.String() != data {
t.Fatalf("unexpected buf content: %v", lw.buf.String())
}
lw.Write([]byte("B"))
lw.Write([]byte(strings.Repeat("B", 256*1024)))
firstIdx := strings.Index(lw.buf.String(), logWriterInitEndMarker)
lastIdx := strings.LastIndex(lw.buf.String(), logWriterInitEndMarker)
// Check if init end marker present.
if firstIdx == -1 || lastIdx == -1 {
t.Fatalf("missing init end marker: %s", lw.buf.String())
}
// Check if init end marker appears only once.
if firstIdx != lastIdx {
t.Fatalf("log init end marker appears more than once: %s", lw.buf.String())
}
// Ensure that we have the correct init log data.
if !strings.Contains(lw.buf.String(), strings.Repeat("A", dataSize)+logWriterInitEndMarker) {
t.Fatalf("unexpected log content: %s", lw.buf.String())
}
}

View File

@@ -105,6 +105,10 @@ func (p *prog) checkDnsLoop() {
for uid := range p.loop {
msg := loopTestMsg(uid)
uc := upstream[uid]
// Skipping upstream which is being marked as down.
if uc == nil {
continue
}
resolver, err := ctrld.NewResolver(uc)
if err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)

View File

@@ -29,11 +29,16 @@ var (
silent bool
cdUID string
cdOrg string
customHostname string
cdDev bool
iface string
ifaceStartStop string
nextdns string
cdUpstreamProto string
deactivationPin int64
skipSelfChecks bool
cleanup bool
startOnly bool
mainLog atomic.Pointer[zerolog.Logger]
consoleWriter zerolog.ConsoleWriter
@@ -41,9 +46,10 @@ var (
)
const (
cdUidFlagName = "cd"
cdOrgFlagName = "cd-org"
nextdnsFlagName = "nextdns"
cdUidFlagName = "cd"
cdOrgFlagName = "cd-org"
customHostnameFlagName = "custom-hostname"
nextdnsFlagName = "nextdns"
)
func init() {
@@ -82,22 +88,33 @@ func initConsoleLogging() {
multi := zerolog.MultiLevelWriter(consoleWriter)
l := mainLog.Load().Output(multi).With().Timestamp().Logger()
mainLog.Store(&l)
switch {
case silent:
zerolog.SetGlobalLevel(zerolog.NoLevel)
case verbose == 1:
ctrld.ProxyLogger.Store(&l)
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case verbose > 1:
ctrld.ProxyLogger.Store(&l)
zerolog.SetGlobalLevel(zerolog.DebugLevel)
default:
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
}
}
// initLogging initializes global logging setup.
func initLogging() {
// initInteractiveLogging is like initLogging, but the ProxyLogger is discarded
// to be used for all interactive commands.
//
// Current log file config will also be ignored.
func initInteractiveLogging() {
old := cfg.Service.LogPath
cfg.Service.LogPath = ""
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
initLoggingWithBackup(true)
initLoggingWithBackup(false)
cfg.Service.LogPath = old
l := zerolog.New(io.Discard)
ctrld.ProxyLogger.Store(&l)
}
// initLoggingWithBackup initializes log setup base on current config.
@@ -106,8 +123,8 @@ func initLogging() {
// This is only used in runCmd for special handling in case of logging config
// change in cd mode. Without special reason, the caller should use initLogging
// wrapper instead of calling this function directly.
func initLoggingWithBackup(doBackup bool) {
writers := []io.Writer{io.Discard}
func initLoggingWithBackup(doBackup bool) []io.Writer {
var writers []io.Writer
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
// Create parent directory if necessary.
if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil {
@@ -119,14 +136,14 @@ func initLoggingWithBackup(doBackup bool) {
flags := os.O_CREATE | os.O_RDWR | os.O_APPEND
if doBackup {
// Backup old log file with .1 suffix.
if err := os.Rename(logFilePath, logFilePath+".1"); err != nil && !os.IsNotExist(err) {
if err := os.Rename(logFilePath, logFilePath+oldLogSuffix); err != nil && !os.IsNotExist(err) {
mainLog.Load().Error().Msgf("could not backup old log file: %v", err)
} else {
// Backup was created, set flags for truncating old log file.
flags = os.O_CREATE | os.O_RDWR
}
}
logFile, err := os.OpenFile(logFilePath, flags, os.FileMode(0o600))
logFile, err := openLogFile(logFilePath, flags)
if err != nil {
mainLog.Load().Error().Msgf("failed to create log file: %v", err)
os.Exit(1)
@@ -145,21 +162,22 @@ func initLoggingWithBackup(doBackup bool) {
switch {
case silent:
zerolog.SetGlobalLevel(zerolog.NoLevel)
return
return writers
case verbose == 1:
logLevel = "info"
case verbose > 1:
logLevel = "debug"
}
if logLevel == "" {
return
return writers
}
level, err := zerolog.ParseLevel(logLevel)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not set log level")
return
return writers
}
zerolog.SetGlobalLevel(level)
return writers
}
func initCache() {

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 p.metricsQueryStats.Load() {
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
}
}

View File

@@ -9,17 +9,18 @@ import (
"strings"
)
func patchNetIfaceName(iface *net.Interface) error {
func patchNetIfaceName(iface *net.Interface) (bool, error) {
b, err := exec.Command("networksetup", "-listnetworkserviceorder").Output()
if err != nil {
return err
return false, err
}
patched := false
if name := networkServiceName(iface.Name, bytes.NewReader(b)); name != "" {
patched = true
iface.Name = name
mainLog.Load().Debug().Str("network_service", name).Msg("found network service name for interface")
}
return nil
return patched, nil
}
func networkServiceName(ifaceName string, r io.Reader) string {
@@ -42,3 +43,34 @@ func networkServiceName(ifaceName string, r io.Reader) string {
}
return ""
}
// validInterface reports whether the *net.Interface is a valid one.
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
_, ok := validIfacesMap[iface.Name]
return ok
}
// validInterfacesMap returns a set of all valid hardware ports.
func validInterfacesMap() map[string]struct{} {
b, err := exec.Command("networksetup", "-listallhardwareports").Output()
if err != nil {
return nil
}
return parseListAllHardwarePorts(bytes.NewReader(b))
}
// parseListAllHardwarePorts parses output of "networksetup -listallhardwareports"
// and returns map presents all hardware ports.
func parseListAllHardwarePorts(r io.Reader) map[string]struct{} {
m := make(map[string]struct{})
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
after, ok := strings.CutPrefix(line, "Device: ")
if !ok {
continue
}
m[after] = struct{}{}
}
return m
}

View File

@@ -1,6 +1,7 @@
package cli
import (
"maps"
"strings"
"testing"
@@ -57,3 +58,47 @@ func Test_networkServiceName(t *testing.T) {
})
}
}
const listallhardwareportsOutput = `
Hardware Port: Ethernet Adapter (en6)
Device: en6
Ethernet Address: 3a:3e:fc:1e:ab:41
Hardware Port: Ethernet Adapter (en7)
Device: en7
Ethernet Address: 3a:3e:fc:1e:ab:42
Hardware Port: Thunderbolt Bridge
Device: bridge0
Ethernet Address: 36:21:bb:3a:7a:40
Hardware Port: Wi-Fi
Device: en0
Ethernet Address: a0:78:17:68:56:3f
Hardware Port: Thunderbolt 1
Device: en1
Ethernet Address: 36:21:bb:3a:7a:40
Hardware Port: Thunderbolt 2
Device: en2
Ethernet Address: 36:21:bb:3a:7a:44
VLAN Configurations
===================
`
func Test_parseListAllHardwarePorts(t *testing.T) {
expected := map[string]struct{}{
"en0": {},
"en1": {},
"en2": {},
"en6": {},
"en7": {},
"bridge0": {},
}
m := parseListAllHardwarePorts(strings.NewReader(listallhardwareportsOutput))
if !maps.Equal(m, expected) {
t.Errorf("unexpected output, want: %v, got: %v", expected, m)
}
}

52
cmd/cli/net_linux.go Normal file
View File

@@ -0,0 +1,52 @@
package cli
import (
"net"
"net/netip"
"os"
"strings"
"tailscale.com/net/netmon"
)
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
// validInterface reports whether the *net.Interface is a valid one.
// Only non-virtual interfaces are considered valid.
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
_, ok := validIfacesMap[iface.Name]
return ok
}
// validInterfacesMap returns a set containing non virtual interfaces.
func validInterfacesMap() map[string]struct{} {
m := make(map[string]struct{})
vis := virtualInterfaces()
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
if _, existed := vis[i.Name]; existed {
return
}
m[i.Name] = struct{}{}
})
// Fallback to default route interface if found nothing.
if len(m) == 0 {
defaultRoute, err := netmon.DefaultRoute()
if err != nil {
return m
}
m[defaultRoute.InterfaceName] = struct{}{}
}
return m
}
// virtualInterfaces returns a map of virtual interfaces on current machine.
func virtualInterfaces() map[string]struct{} {
s := make(map[string]struct{})
entries, _ := os.ReadDir("/sys/devices/virtual/net")
for _, entry := range entries {
if entry.IsDir() {
s[strings.TrimSpace(entry.Name())] = struct{}{}
}
}
return s
}

View File

@@ -1,7 +1,22 @@
//go:build !darwin
//go:build !darwin && !windows && !linux
package cli
import "net"
import (
"net"
func patchNetIfaceName(iface *net.Interface) error { return nil }
"tailscale.com/net/netmon"
)
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool { return true }
// validInterfacesMap returns a set containing only default route interfaces.
func validInterfacesMap() map[string]struct{} {
defaultRoute, err := netmon.DefaultRoute()
if err != nil {
return nil
}
return map[string]struct{}{defaultRoute.InterfaceName: {}}
}

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

@@ -0,0 +1,93 @@
package cli
import (
"io"
"log"
"net"
"os"
"github.com/microsoft/wmi/pkg/base/host"
"github.com/microsoft/wmi/pkg/base/instance"
"github.com/microsoft/wmi/pkg/base/query"
"github.com/microsoft/wmi/pkg/constant"
"github.com/microsoft/wmi/pkg/hardware/network/netadapter"
)
func patchNetIfaceName(iface *net.Interface) (bool, error) {
return true, nil
}
// validInterface reports whether the *net.Interface is a valid one.
// On Windows, only physical interfaces are considered valid.
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
_, ok := validIfacesMap[iface.Name]
return ok
}
// validInterfacesMap returns a set of all physical interfaces.
func validInterfacesMap() map[string]struct{} {
m := make(map[string]struct{})
for _, ifaceName := range validInterfaces() {
m[ifaceName] = struct{}{}
}
return m
}
// validInterfaces returns a list of all physical interfaces.
func validInterfaces() []string {
log.SetOutput(io.Discard)
defer log.SetOutput(os.Stderr)
whost := host.NewWmiLocalHost()
q := query.NewWmiQuery("MSFT_NetAdapter")
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.StadardCimV2), q)
if instances != nil {
defer instances.Close()
}
if err != nil {
mainLog.Load().Warn().Err(err).Msg("failed to get wmi network adapter")
return nil
}
var adapters []string
for _, i := range instances {
adapter, err := netadapter.NewNetworkAdapter(i)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("failed to get network adapter")
continue
}
name, err := adapter.GetPropertyName()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("failed to get interface name")
continue
}
// From: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/hh968170(v=vs.85)
//
// "Indicates if a connector is present on the network adapter. This value is set to TRUE
// if this is a physical adapter or FALSE if this is not a physical adapter."
physical, err := adapter.GetPropertyConnectorPresent()
if err != nil {
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("failed to get network adapter connector present property")
continue
}
if !physical {
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("skipping non-physical adapter")
continue
}
// Check if it's a hardware interface. Checking only for connector present is not enough
// because some interfaces are not physical but have a connector.
hardware, err := adapter.GetPropertyHardwareInterface()
if err != nil {
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("failed to get network adapter hardware interface property")
continue
}
if !hardware {
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("skipping non-hardware interface")
continue
}
adapters = append(adapters, name)
}
return adapters
}

View File

@@ -0,0 +1,42 @@
package cli
import (
"bufio"
"bytes"
"slices"
"strings"
"testing"
"time"
)
func Test_validInterfaces(t *testing.T) {
verbose = 3
initConsoleLogging()
start := time.Now()
ifaces := validInterfaces()
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
start = time.Now()
ifacesPowershell := validInterfacesPowershell()
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
slices.Sort(ifaces)
slices.Sort(ifacesPowershell)
if !slices.Equal(ifaces, ifacesPowershell) {
t.Fatalf("result mismatch, want: %v, got: %v", ifacesPowershell, ifaces)
}
}
func validInterfacesPowershell() []string {
out, err := powershell("Get-NetAdapter -Physical | Select-Object -ExpandProperty Name")
if err != nil {
return nil
}
var res []string
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
ifaceName := strings.TrimSpace(scanner.Text())
res = append(res, ifaceName)
}
return res
}

5
cmd/cli/nocgo.go Normal file
View File

@@ -0,0 +1,5 @@
//go:build !cgo
package cli
const cgoEnabled = false

View File

@@ -1,8 +1,12 @@
package cli
import (
"bufio"
"bytes"
"fmt"
"net"
"os/exec"
"strings"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
@@ -27,6 +31,18 @@ func deAllocateIP(ip string) error {
return nil
}
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
if err := setDNS(iface, nameservers); err != nil {
// TODO: investiate whether we can detect this without relying on error message.
if strings.Contains(err.Error(), " is not a recognized network service") {
return nil
}
return err
}
return nil
}
// set the dns server for the provided network interface
// networksetup -setdnsservers Wi-Fi 8.8.8.8 1.1.1.1
// TODO(cuonglm): use system API
@@ -34,9 +50,19 @@ func setDNS(iface *net.Interface, nameservers []string) error {
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name}
args = append(args, nameservers...)
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
return fmt.Errorf("%v: %w", string(out), err)
}
return nil
}
if err := exec.Command(cmd, args...).Run(); err != nil {
mainLog.Load().Error().Err(err).Msgf("setDNS failed, ips = %q", nameservers)
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
if err := resetDNS(iface); err != nil {
// TODO: investiate whether we can detect this without relying on error message.
if strings.Contains(err.Error(), " is not a recognized network service") {
return nil
}
return err
}
return nil
@@ -46,14 +72,40 @@ func setDNS(iface *net.Interface, nameservers []string) error {
func resetDNS(iface *net.Interface) error {
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
}
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
if ns := savedStaticNameservers(iface); len(ns) > 0 {
err = setDNS(iface, ns)
}
return err
}
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

@@ -5,6 +5,9 @@ import (
"net/netip"
"os/exec"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"github.com/Control-D-Inc/ctrld/internal/dns"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
@@ -29,9 +32,14 @@ func deAllocateIP(ip string) error {
return nil
}
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
return setDNS(iface, nameservers)
}
// set the dns server for the provided network interface
func setDNS(iface *net.Interface, nameservers []string) error {
r, err := dns.NewOSConfigurator(logf, iface.Name)
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
@@ -49,8 +57,13 @@ func setDNS(iface *net.Interface, nameservers []string) error {
return nil
}
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
return resetDNS(iface)
}
func resetDNS(iface *net.Interface) error {
r, err := dns.NewOSConfigurator(logf, iface.Name)
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
@@ -63,6 +76,17 @@ func resetDNS(iface *net.Interface) error {
return nil
}
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
return err
}
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,15 +9,16 @@ import (
"net"
"net/netip"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/client6"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/dns"
@@ -25,10 +26,7 @@ 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"
)
const 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
@@ -52,9 +50,13 @@ func deAllocateIP(ip string) error {
const maxSetDNSAttempts = 5
// set the dns server for the provided network interface
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
return setDNS(iface, nameservers)
}
func setDNS(iface *net.Interface, nameservers []string) error {
r, err := dns.NewOSConfigurator(logf, iface.Name)
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
@@ -69,41 +71,26 @@ 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 {
if strings.Contains(err.Error(), "Rejected send message") &&
strings.Contains(err.Error(), "org.freedesktop.network1.Manager") {
mainLog.Load().Warn().Msg("Interfaces are managed by systemd-networkd, switch to systemd-resolve for setting DNS")
trySystemdResolve = true
break
}
// This error happens on read-only file system, which causes ctrld failed to create backup
// for /etc/resolv.conf file. It is ok, because the DNS is still set anyway, and restore
// DNS will fallback to use DHCP if there's no backup /etc/resolv.conf file.
// The error format is controlled by us, so checking for error string is fine.
// See: ../../internal/dns/direct.go:L278
if r.Mode() == "direct" && strings.Contains(err.Error(), resolvConfBackupFailedMsg) {
return nil
}
return err
if err := r.SetDNS(osConfig); err != nil {
if strings.Contains(err.Error(), "Rejected send message") &&
strings.Contains(err.Error(), "org.freedesktop.network1.Manager") {
mainLog.Load().Warn().Msg("Interfaces are managed by systemd-networkd, switch to systemd-resolve for setting DNS")
trySystemdResolve = true
goto systemdResolve
}
if useSystemdResolved {
if out, err := exec.Command("systemctl", "restart", "systemd-resolved").CombinedOutput(); err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not restart systemd-resolved: %s", string(out))
}
}
currentNS := currentDNS(iface)
if isSubSet(nameservers, currentNS) {
// This error happens on read-only file system, which causes ctrld failed to create backup
// for /etc/resolv.conf file. It is ok, because the DNS is still set anyway, and restore
// DNS will fallback to use DHCP if there's no backup /etc/resolv.conf file.
// The error format is controlled by us, so checking for error string is fine.
// See: ../../internal/dns/direct.go:L278
if r.Mode() == "direct" && strings.Contains(err.Error(), resolvConfBackupFailedMsg) {
return nil
}
return err
}
systemdResolve:
if trySystemdResolve {
// Stop systemd-networkd and retry setting DNS.
if out, err := exec.Command("systemctl", "stop", "systemd-networkd").CombinedOutput(); err != nil {
@@ -123,11 +110,16 @@ func setDNS(iface *net.Interface, nameservers []string) error {
}
time.Sleep(time.Second)
}
mainLog.Load().Debug().Msg("DNS was not set for some reason")
}
mainLog.Load().Debug().Msg("DNS was not set for some reason")
return nil
}
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
return resetDNS(iface)
}
func resetDNS(iface *net.Interface) (err error) {
defer func() {
if err == nil {
@@ -137,7 +129,7 @@ func resetDNS(iface *net.Interface) (err error) {
if exe, _ := exec.LookPath("/lib/systemd/systemd-networkd"); exe != "" {
_ = exec.Command("systemctl", "start", "systemd-networkd").Run()
}
if r, oerr := dns.NewOSConfigurator(logf, iface.Name); oerr == nil {
if r, oerr := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name); oerr == nil {
_ = r.SetDNS(dns.OSConfig{})
if err := r.Close(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
@@ -168,6 +160,7 @@ func resetDNS(iface *net.Interface) (err error) {
}
// TODO(cuonglm): handle DHCPv6 properly.
mainLog.Load().Debug().Msg("checking for IPv6 availability")
if ctrldnet.IPv6Available(ctx) {
c := client6.NewClient()
conversation, err := c.Exchange(iface.Name)
@@ -187,6 +180,8 @@ func resetDNS(iface *net.Interface) (err error) {
}
}
}
} else {
mainLog.Load().Debug().Msg("IPv6 is not available")
}
return ignoringEINTR(func() error {
@@ -194,6 +189,12 @@ func resetDNS(iface *net.Interface) (err error) {
})
}
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
return err
}
func currentDNS(iface *net.Interface) []string {
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconffile.NameServers} {
if ns := fn(iface.Name); len(ns) > 0 {
@@ -203,6 +204,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 {
@@ -284,8 +290,7 @@ func ignoringEINTR(fn func() error) error {
func isSubSet(s1, s2 []string) bool {
ok := true
for _, ns := range s1 {
// TODO(cuonglm): use slices.Contains once upgrading to go1.21
if sliceContains(s2, ns) {
if slices.Contains(s2, ns) {
continue
}
ok = false
@@ -293,75 +298,3 @@ func isSubSet(s1, s2 []string) bool {
}
return ok
}
// sliceContains reports whether v is present in s.
func sliceContains[S ~[]E, E comparable](s S, v E) bool {
return sliceIndex(s, v) >= 0
}
// sliceIndex returns the index of the first occurrence of v in s,
// or -1 if not present.
func sliceIndex[S ~[]E, E comparable](s S, v E) int {
for i := range s {
if v == s[i] {
return i
}
}
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

@@ -1,79 +1,203 @@
package cli
import (
"bytes"
"errors"
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"strconv"
"slices"
"strings"
"sync"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
const (
v4InterfaceKeyPathFormat = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`
v6InterfaceKeyPathFormat = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
)
var (
setDNSOnce sync.Once
resetDNSOnce sync.Once
)
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
return setDNS(iface, nameservers)
}
// setDNS sets the dns server for the provided network interface
func setDNS(iface *net.Interface, nameservers []string) error {
if len(nameservers) == 0 {
return errors.New("empty DNS nameservers")
}
primaryDNS := nameservers[0]
if err := setPrimaryDNS(iface, primaryDNS); err != nil {
return err
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 hasLocalDnsServerRunning() {
mainLog.Load().Debug().Msg("Local DNS server detected, configuring forwarders")
file := absHomeDir(windowsForwardersFilename)
mainLog.Load().Debug().Msgf("Using forwarders file: %s", file)
oldForwardersContent, err := os.ReadFile(file)
if err != nil {
mainLog.Load().Debug().Err(err).Msg("Could not read existing forwarders file")
} else {
mainLog.Load().Debug().Msgf("Existing forwarders content: %s", string(oldForwardersContent))
}
hasLocalIPv6Listener := needLocalIPv6Listener()
mainLog.Load().Debug().Bool("has_ipv6_listener", hasLocalIPv6Listener).Msg("IPv6 listener status")
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
if !hasLocalIPv6Listener {
return false
}
return s == "::1"
})
mainLog.Load().Debug().Strs("forwarders", forwarders).Msg("Filtered forwarders list")
if err := os.WriteFile(file, []byte(strings.Join(forwarders, ",")), 0600); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
} else {
mainLog.Load().Debug().Msg("Successfully wrote new forwarders file")
}
oldForwarders := strings.Split(string(oldForwardersContent), ",")
mainLog.Load().Debug().Strs("old_forwarders", oldForwarders).Msg("Previous forwarders")
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
} else {
mainLog.Load().Debug().Msg("Successfully configured DNS server forwarders")
}
}
})
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return fmt.Errorf("setDNS: %w", err)
}
if len(nameservers) > 1 {
secondaryDNS := nameservers[1]
_ = addSecondaryDNS(iface, secondaryDNS)
var (
serversV4 []netip.Addr
serversV6 []netip.Addr
)
for _, ns := range nameservers {
if addr, err := netip.ParseAddr(ns); err == nil {
if addr.Is4() {
serversV4 = append(serversV4, addr)
} else {
serversV6 = append(serversV6, addr)
}
}
}
if len(serversV4) == 0 && len(serversV6) == 0 {
return errors.New("invalid DNS nameservers")
}
if len(serversV4) > 0 {
if err := luid.SetDNS(windows.AF_INET, serversV4, nil); err != nil {
return fmt.Errorf("could not set DNS ipv4: %w", err)
}
}
if len(serversV6) > 0 {
if err := luid.SetDNS(windows.AF_INET6, serversV6, nil); err != nil {
return fmt.Errorf("could not set DNS ipv6: %w", err)
}
}
return nil
}
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
return resetDNS(iface)
}
// TODO(cuonglm): should we use system API?
func resetDNS(iface *net.Interface) error {
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))
resetDNSOnce.Do(func() {
// See corresponding comment in setDNS.
if hasLocalDnsServerRunning() {
file := absHomeDir(windowsForwardersFilename)
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
}
}
})
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return fmt.Errorf("resetDNS: %w", err)
}
// Restoring DHCP settings.
if err := luid.SetDNS(windows.AF_INET, nil, nil); err != nil {
return fmt.Errorf("could not reset DNS ipv4: %w", err)
}
if err := luid.SetDNS(windows.AF_INET6, nil, nil); err != nil {
return fmt.Errorf("could not reset DNS ipv6: %w", err)
}
return nil
}
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
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)
}
}
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return fmt.Errorf("restoreDNS: %w", err)
}
if len(v4ns) > 0 {
mainLog.Load().Debug().Msgf("restoring IPv4 static DNS for interface %q: %v", iface.Name, v4ns)
if err := setDNS(iface, v4ns); err != nil {
return fmt.Errorf("restoreDNS (IPv4): %w", err)
}
} else {
mainLog.Load().Debug().Msgf("restoring IPv4 DHCP for interface %q", iface.Name)
if err := luid.SetDNS(windows.AF_INET, nil, nil); err != nil {
return fmt.Errorf("restoreDNS (IPv4 clear): %w", err)
}
}
if len(v6ns) > 0 {
mainLog.Load().Debug().Msgf("restoring IPv6 static DNS for interface %q: %v", iface.Name, v6ns)
if err := setDNS(iface, v6ns); err != nil {
return fmt.Errorf("restoreDNS (IPv6): %w", err)
}
} else {
mainLog.Load().Debug().Msgf("restoring IPv6 DHCP for interface %q", iface.Name)
if err := luid.SetDNS(windows.AF_INET6, nil, nil); err != nil {
return fmt.Errorf("restoreDNS (IPv6 clear): %w", err)
}
}
}
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 nil
}
func setPrimaryDNS(iface *net.Interface, dns string) error {
ipVer := "ipv4"
if ctrldnet.IsIPv6(dns) {
ipVer = "ipv6"
}
idx := strconv.Itoa(iface.Index)
output, err := netsh("interface", ipVer, "set", "dnsserver", idx, "static", dns)
if err != nil {
mainLog.Load().Error().Err(err).Msgf("failed to set primary DNS: %s", string(output))
return err
}
if ipVer == "ipv4" && ctrldnet.SupportsIPv6ListenLocal() {
// Disable IPv6 DNS, so the query will be fallback to IPv4.
_, _ = netsh("interface", "ipv6", "set", "dnsserver", idx, "static", "::1", "primary")
}
return nil
}
func addSecondaryDNS(iface *net.Interface, dns string) error {
ipVer := "ipv4"
if ctrldnet.IsIPv6(dns) {
ipVer = "ipv6"
}
output, err := netsh("interface", ipVer, "add", "dns", strconv.Itoa(iface.Index), dns, "index=2")
if err != nil {
mainLog.Load().Warn().Err(err).Msgf("failed to add secondary DNS: %s", string(output))
}
return nil
}
func netsh(args ...string) ([]byte, error) {
return exec.Command("netsh", args...).Output()
return err
}
func currentDNS(iface *net.Interface) []string {
@@ -93,3 +217,112 @@ func currentDNS(iface *net.Interface) []string {
}
return ns
}
// currentStaticDNS checks both the IPv4 and IPv6 paths for static DNS values using keys
// like "NameServer" and "ProfileNameServer".
func currentStaticDNS(iface *net.Interface) ([]string, error) {
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return nil, fmt.Errorf("fallback winipcfg.LUIDFromIndex: %w", err)
}
guid, err := luid.GUID()
if err != nil {
return nil, fmt.Errorf("fallback luid.GUID: %w", err)
}
var ns []string
keyPaths := []string{v4InterfaceKeyPathFormat, v6InterfaceKeyPathFormat}
for _, path := range keyPaths {
interfaceKeyPath := path + guid.String()
k, err := registry.OpenKey(registry.LOCAL_MACHINE, interfaceKeyPath, registry.QUERY_VALUE)
if err != nil {
mainLog.Load().Debug().Err(err).Msgf("failed to open registry key %q for interface %q; trying next key", interfaceKeyPath, iface.Name)
continue
}
func() {
defer k.Close()
for _, keyName := range []string{"NameServer", "ProfileNameServer"} {
value, _, err := k.GetStringValue(keyName)
if err != nil && !errors.Is(err, registry.ErrNotExist) {
mainLog.Load().Debug().Err(err).Msgf("error reading %s registry key", keyName)
continue
}
if len(value) > 0 {
mainLog.Load().Debug().Msgf("found static DNS for interface %q: %s", iface.Name, value)
parsed := parseDNSServers(value)
for _, pns := range parsed {
if !slices.Contains(ns, pns) {
ns = append(ns, pns)
}
}
}
}
}()
}
if len(ns) == 0 {
mainLog.Load().Debug().Msgf("no static DNS values found for interface %q", iface.Name)
}
return ns, nil
}
// parseDNSServers splits a DNS server string that may be comma- or space-separated,
// and trims any extraneous whitespace or null characters.
func parseDNSServers(val string) []string {
fields := strings.FieldsFunc(val, func(r rune) bool {
return r == ' ' || r == ','
})
var servers []string
for _, f := range fields {
trimmed := strings.TrimSpace(f)
if len(trimmed) > 0 {
servers = append(servers, trimmed)
}
}
return servers
}
// addDnsServerForwarders adds given nameservers to DNS server forwarders list,
// and also removing old forwarders if provided.
func addDnsServerForwarders(nameservers, old []string) error {
newForwardersMap := make(map[string]struct{})
newForwarders := make([]string, len(nameservers))
for i := range nameservers {
newForwardersMap[nameservers[i]] = struct{}{}
newForwarders[i] = fmt.Sprintf("%q", nameservers[i])
}
oldForwarders := old[:0]
for _, fwd := range old {
if _, ok := newForwardersMap[fwd]; !ok {
oldForwarders = append(oldForwarders, fwd)
}
}
// NOTE: It is important to add new forwarder before removing old one.
// Testing on Windows Server 2022 shows that removing forwarder1
// then adding forwarder2 sometimes ends up adding both of them
// to the forwarders list.
cmd := fmt.Sprintf("Add-DnsServerForwarder -IPAddress %s", strings.Join(newForwarders, ","))
if len(oldForwarders) > 0 {
cmd = fmt.Sprintf("%s ; Remove-DnsServerForwarder -IPAddress %s -Force", cmd, strings.Join(oldForwarders, ","))
}
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
}
// 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
}

View File

@@ -0,0 +1,68 @@
package cli
import (
"fmt"
"net"
"slices"
"strings"
"testing"
"time"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
func Test_currentStaticDNS(t *testing.T) {
iface, err := net.InterfaceByName(defaultIfaceName())
if err != nil {
t.Fatal(err)
}
start := time.Now()
staticDns, err := currentStaticDNS(iface)
if err != nil {
t.Fatal(err)
}
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
start = time.Now()
staticDnsPowershell, err := currentStaticDnsPowershell(iface)
if err != nil {
t.Fatal(err)
}
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
slices.Sort(staticDns)
slices.Sort(staticDnsPowershell)
if !slices.Equal(staticDns, staticDnsPowershell) {
t.Fatalf("result mismatch, want: %v, got: %v", staticDnsPowershell, staticDns)
}
}
func currentStaticDnsPowershell(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{"HKLM:\\" + v4InterfaceKeyPathFormat, "HKLM:\\" + 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
for _, e := range strings.Split(string(out), ",") {
ns = append(ns, strings.TrimRight(e, "\x00"))
}
}
}
}
return ns, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,29 @@
package cli
import (
"bufio"
"bytes"
"io"
"os"
"os/exec"
"strings"
"github.com/kardianos/service"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"github.com/Control-D-Inc/ctrld/internal/dns"
"github.com/Control-D-Inc/ctrld/internal/router"
)
func init() {
if r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo"); err == nil {
if r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, &health.Tracker{}, &controlknobs.Knobs{}, "lo"); err == nil {
useSystemdResolved = r.Mode() == "systemd-resolved"
}
// Disable quic-go's ECN support by default, see https://github.com/quic-go/quic-go/issues/3911
if os.Getenv("QUIC_GO_DISABLE_ECN") == "" {
os.Setenv("QUIC_GO_DISABLE_ECN", "true")
}
}
func setDependencies(svc *service.Config) {
@@ -18,12 +32,37 @@ func setDependencies(svc *service.Config) {
"After=network-online.target",
"Wants=NetworkManager-wait-online.service",
"After=NetworkManager-wait-online.service",
"Wants=systemd-networkd-wait-online.service",
"Wants=nss-lookup.target",
"After=nss-lookup.target",
}
if out, _ := exec.Command("networkctl", "--no-pager").CombinedOutput(); len(out) > 0 {
if wantsSystemDNetworkdWaitOnline(bytes.NewReader(out)) {
svc.Dependencies = append(svc.Dependencies, "Wants=systemd-networkd-wait-online.service")
}
}
if routerDeps := router.ServiceDependencies(); len(routerDeps) > 0 {
svc.Dependencies = append(svc.Dependencies, routerDeps...)
}
}
func setWorkingDirectory(svc *service.Config, dir string) {
svc.WorkingDirectory = dir
}
// wantsSystemDNetworkdWaitOnline reports whether "systemd-networkd-wait-online" service
// is required to be added to ctrld dependencies services.
// The input reader r is the output of "networkctl --no-pager" command.
func wantsSystemDNetworkdWaitOnline(r io.Reader) bool {
scanner := bufio.NewScanner(r)
// Skip header
scanner.Scan()
configured := false
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) > 0 && fields[len(fields)-1] == "configured" {
configured = true
break
}
}
return configured
}

View File

@@ -0,0 +1,48 @@
package cli
import (
"io"
"strings"
"testing"
)
const (
networkctlUnmanagedOutput = `IDX LINK TYPE OPERATIONAL SETUP
1 lo loopback carrier unmanaged
2 wlp0s20f3 wlan routable unmanaged
3 tailscale0 none routable unmanaged
4 br-9ac33145e060 bridge no-carrier unmanaged
5 docker0 bridge no-carrier unmanaged
5 links listed.
`
networkctlManagedOutput = `IDX LINK TYPE OPERATIONAL SETUP
1 lo loopback carrier unmanaged
2 wlp0s20f3 wlan routable configured
3 tailscale0 none routable unmanaged
4 br-9ac33145e060 bridge no-carrier unmanaged
5 docker0 bridge no-carrier unmanaged
5 links listed.
`
)
func Test_wantsSystemDNetworkdWaitOnline(t *testing.T) {
tests := []struct {
name string
r io.Reader
required bool
}{
{"unmanaged", strings.NewReader(networkctlUnmanagedOutput), false},
{"managed", strings.NewReader(networkctlManagedOutput), true},
{"empty", strings.NewReader(""), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if required := wantsSystemDNetworkdWaitOnline(tc.r); required != tc.required {
t.Errorf("wants %v got %v", tc.required, required)
}
})
}
}

View File

@@ -1,4 +1,4 @@
//go:build !linux && !freebsd && !darwin
//go:build !linux && !freebsd && !darwin && !windows
package cli

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

@@ -0,0 +1,57 @@
package cli
import (
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/stretchr/testify/assert"
)
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
p := &prog{cfg: &ctrld.Config{}}
// Default value is true.
assert.True(t, p.dnsWatchdogEnabled())
tests := []struct {
name string
enabled bool
}{
{"enabled", true},
{"disabled", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p.cfg.Service.DnsWatchdogEnabled = &tc.enabled
assert.Equal(t, tc.enabled, p.dnsWatchdogEnabled())
})
}
}
func Test_prog_dnsWatchdogInterval(t *testing.T) {
p := &prog{cfg: &ctrld.Config{}}
// Default value is 20s.
assert.Equal(t, dnsWatchdogDefaultInterval, p.dnsWatchdogDuration())
tests := []struct {
name string
duration time.Duration
expected time.Duration
}{
{"valid", time.Minute, time.Minute},
{"zero", 0, dnsWatchdogDefaultInterval},
{"nagative", time.Duration(-1 * time.Minute), dnsWatchdogDefaultInterval},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p.cfg.Service.DnsWatchdogInvterval = &tc.duration
assert.Equal(t, tc.expected, p.dnsWatchdogDuration())
})
}
}

14
cmd/cli/prog_windows.go Normal file
View File

@@ -0,0 +1,14 @@
package cli
import "github.com/kardianos/service"
func setDependencies(svc *service.Config) {
if hasLocalDnsServerRunning() {
svc.Dependencies = []string{"DNS"}
}
}
func setWorkingDirectory(svc *service.Config, dir string) {
// WorkingDirectory is not supported on Windows.
svc.WorkingDirectory = dir
}

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.metricsQueryStats.Load() {
c.WithLabelValues(lvs...).Inc()
}
}

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

@@ -0,0 +1,164 @@
package cli
import (
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// parseResolvConfNameservers reads the resolv.conf file and returns the nameservers found.
// Returns nil if no nameservers are found.
func (p *prog) parseResolvConfNameservers(path string) ([]string, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// Parse the file for "nameserver" lines
var currentNS []string
lines := strings.Split(string(content), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "nameserver") {
parts := strings.Fields(trimmed)
if len(parts) >= 2 {
currentNS = append(currentNS, parts[1])
}
}
}
return currentNS, nil
}
// watchResolvConf watches any changes to /etc/resolv.conf file,
// and reverting to the original config set by ctrld.
func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
resolvConfPath := "/etc/resolv.conf"
// Evaluating symbolics link to watch the target file that /etc/resolv.conf point to.
if rp, _ := filepath.EvalSymlinks(resolvConfPath); rp != "" {
resolvConfPath = rp
}
mainLog.Load().Debug().Msgf("start watching %s file", resolvConfPath)
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).Msgf("could not add %s to watcher list", watchDir)
return
}
for {
select {
case <-p.dnsWatcherStopCh:
return
case <-p.stopCh:
mainLog.Load().Debug().Msgf("stopping watcher for %s", resolvConfPath)
return
case event, ok := <-watcher.Events:
if p.recoveryRunning.Load() {
return
}
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().Msgf("/etc/resolv.conf changes detected, reading changes...")
// Convert expected nameservers to strings for comparison
expectedNS := make([]string, len(ns))
for i, addr := range ns {
expectedNS[i] = addr.String()
}
var foundNS []string
var err error
maxRetries := 1
for retry := 0; retry < maxRetries; retry++ {
foundNS, err = p.parseResolvConfNameservers(resolvConfPath)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to read resolv.conf content")
break
}
// If we found nameservers, break out of retry loop
if len(foundNS) > 0 {
break
}
// Only retry if we found no nameservers
if retry < maxRetries-1 {
mainLog.Load().Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
select {
case <-p.stopCh:
return
case <-p.dnsWatcherStopCh:
return
case <-time.After(2 * time.Second):
continue
}
} else {
mainLog.Load().Debug().Msg("resolv.conf remained empty after all retries")
}
}
// If we found nameservers, check if they match what we expect
if len(foundNS) > 0 {
// Check if the nameservers match exactly what we expect
matches := len(foundNS) == len(expectedNS)
if matches {
for i := range foundNS {
if foundNS[i] != expectedNS[i] {
matches = false
break
}
}
}
mainLog.Load().Debug().
Strs("found", foundNS).
Strs("expected", expectedNS).
Bool("matches", matches).
Msg("checking nameservers")
// Only revert if the nameservers don't match
if !matches {
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,49 @@
package cli
import (
"net"
"net/netip"
"os"
"slices"
"github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile"
)
const resolvConfPath = "/etc/resolv.conf"
// 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()
}
if err := setDNS(iface, servers); err != nil {
return err
}
slices.Sort(servers)
curNs := currentDNS(iface)
slices.Sort(curNs)
if !slices.Equal(curNs, servers) {
c, err := resolvconffile.ParseFile(resolvConfPath)
if err != nil {
return err
}
c.Nameservers = ns
f, err := os.Create(resolvConfPath)
if err != nil {
return err
}
defer f.Close()
if err := c.Write(f); err != nil {
return err
}
return f.Close()
}
return nil
}
// 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,42 @@
//go:build unix && !darwin
package cli
import (
"net"
"net/netip"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"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) {}, &health.Tracker{}, &controlknobs.Knobs{}, "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) {}, &health.Tracker{}, &controlknobs.Knobs{}, "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,7 @@
//go:build !windows
package cli
var supportedSelfDelete = true
func selfDeleteExe() error { return nil }

View File

@@ -0,0 +1,134 @@
// Copied from https://github.com/secur30nly/go-self-delete
// with modification to suitable for ctrld usage.
/*
License: MIT Licence
References:
- https://github.com/LloydLabs/delete-self-poc
- https://twitter.com/jonasLyk/status/1350401461985955840
*/
package cli
import (
"unsafe"
"golang.org/x/sys/windows"
)
var supportedSelfDelete = false
type FILE_RENAME_INFO struct {
Union struct {
ReplaceIfExists bool
Flags uint32
}
RootDirectory windows.Handle
FileNameLength uint32
FileName [1]uint16
}
type FILE_DISPOSITION_INFO struct {
DeleteFile bool
}
func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
handle, err := windows.CreateFile(
pwPath,
windows.DELETE,
0,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
return 0, err
}
return handle, nil
}
func dsRenameHandle(hHandle windows.Handle) error {
var fRename FILE_RENAME_INFO
DS_STREAM_RENAME, err := windows.UTF16FromString(":deadbeef")
if err != nil {
return err
}
lpwStream := &DS_STREAM_RENAME[0]
fRename.FileNameLength = uint32(unsafe.Sizeof(lpwStream))
windows.NewLazyDLL("kernel32.dll").NewProc("RtlCopyMemory").Call(
uintptr(unsafe.Pointer(&fRename.FileName[0])),
uintptr(unsafe.Pointer(lpwStream)),
unsafe.Sizeof(lpwStream),
)
err = windows.SetFileInformationByHandle(
hHandle,
windows.FileRenameInfo,
(*byte)(unsafe.Pointer(&fRename)),
uint32(unsafe.Sizeof(fRename)+unsafe.Sizeof(lpwStream)),
)
if err != nil {
return err
}
return nil
}
func dsDepositeHandle(hHandle windows.Handle) error {
var fDelete FILE_DISPOSITION_INFO
fDelete.DeleteFile = true
err := windows.SetFileInformationByHandle(
hHandle,
windows.FileDispositionInfo,
(*byte)(unsafe.Pointer(&fDelete)),
uint32(unsafe.Sizeof(fDelete)),
)
if err != nil {
return err
}
return nil
}
func selfDeleteExe() error {
var wcPath [windows.MAX_PATH + 1]uint16
var hCurrent windows.Handle
_, err := windows.GetModuleFileName(0, &wcPath[0], windows.MAX_PATH)
if err != nil {
return err
}
hCurrent, err = dsOpenHandle(&wcPath[0])
if err != nil || hCurrent == windows.InvalidHandle {
return err
}
if err := dsRenameHandle(hCurrent); err != nil {
_ = windows.CloseHandle(hCurrent)
return err
}
_ = windows.CloseHandle(hCurrent)
hCurrent, err = dsOpenHandle(&wcPath[0])
if err != nil || hCurrent == windows.InvalidHandle {
return err
}
if err := dsDepositeHandle(hCurrent); err != nil {
_ = windows.CloseHandle(hCurrent)
return err
}
return windows.CloseHandle(hCurrent)
}

View File

@@ -0,0 +1,16 @@
//go:build !unix
package cli
import (
"os"
"github.com/rs/zerolog"
)
func selfUninstall(p *prog, logger zerolog.Logger) {
if uninstallInvalidCdUID(p, logger, false) {
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
os.Exit(0)
}
}

45
cmd/cli/self_kill_unix.go Normal file
View File

@@ -0,0 +1,45 @@
//go:build unix
package cli
import (
"fmt"
"os"
"os/exec"
"runtime"
"syscall"
"github.com/rs/zerolog"
)
func selfUninstall(p *prog, logger zerolog.Logger) {
if runtime.GOOS == "linux" {
selfUninstallLinux(p, logger)
}
bin, err := os.Executable()
if err != nil {
logger.Fatal().Err(err).Msg("could not determine executable")
}
args := []string{"uninstall"}
if !deactivationPinNotSet() {
args = append(args, fmt.Sprintf("--pin=%d", cdDeactivationPin.Load()))
}
cmd := exec.Command(bin, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
logger.Fatal().Err(err).Msg("could not start self uninstall command")
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
_ = cmd.Wait()
os.Exit(0)
}
func selfUninstallLinux(p *prog, logger zerolog.Logger) {
if uninstallInvalidCdUID(p, logger, true) {
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
os.Exit(0)
}
}

View File

@@ -6,10 +6,12 @@ import (
"fmt"
"os"
"os/exec"
"runtime"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
"github.com/Control-D-Inc/ctrld/internal/router/openwrt"
)
// newService wraps service.New call to return service.Service
@@ -20,14 +22,17 @@ func newService(i service.Interface, c *service.Config) (service.Service, error)
return nil, err
}
switch {
case router.IsOldOpenwrt():
return &procd{&sysV{s}}, nil
case router.IsOldOpenwrt(), router.IsNetGearOrbi():
return &procd{sysV: &sysV{s}, svcConfig: c}, nil
case router.IsGLiNet():
return &sysV{s}, nil
case s.Platform() == "unix-systemv":
return &sysV{s}, nil
case s.Platform() == "linux-systemd":
return &systemd{s}, nil
case s.Platform() == "darwin-launchd":
return newLaunchd(s), nil
}
return s, nil
}
@@ -89,25 +94,31 @@ func (s *sysV) Status() (service.Status, error) {
// like old GL.iNET Opal router.
type procd struct {
*sysV
svcConfig *service.Config
}
func (s *procd) Status() (service.Status, error) {
if !s.installed() {
return service.StatusUnknown, service.ErrNotInstalled
}
exe, err := os.Executable()
if err != nil {
return service.StatusUnknown, nil
bin := s.svcConfig.Executable
if bin == "" {
exe, err := os.Executable()
if err != nil {
return service.StatusUnknown, nil
}
bin = exe
}
// Looking for something like "/sbin/ctrld run ".
shellCmd := fmt.Sprintf("ps | grep -q %q", exe+" [r]un ")
shellCmd := fmt.Sprintf("ps | grep -q %q", bin+" [r]un ")
if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil {
return service.StatusStopped, nil
}
return service.StatusRunning, nil
}
// procd wraps a service.Service, and provide status command to
// systemd wraps a service.Service, and provide status command to
// report the status correctly.
type systemd struct {
service.Service
@@ -121,20 +132,48 @@ func (s *systemd) Status() (service.Status, error) {
return s.Service.Status()
}
func newLaunchd(s service.Service) *launchd {
return &launchd{
Service: s,
statusErrMsg: "Permission denied",
}
}
// launchd wraps a service.Service, and provide status command to
// report the status correctly when not running as root on Darwin.
//
// TODO: remove this wrapper once https://github.com/kardianos/service/issues/400 fixed.
type launchd struct {
service.Service
statusErrMsg string
}
func (l *launchd) Status() (service.Status, error) {
if os.Geteuid() != 0 {
return service.StatusUnknown, errors.New(l.statusErrMsg)
}
return l.Service.Status()
}
type task struct {
f func() error
abortOnError bool
Name string
}
func doTasks(tasks []task) bool {
var prevErr error
for _, task := range tasks {
mainLog.Load().Debug().Msgf("Running task %s", task.Name)
if err := task.f(); err != nil {
if task.abortOnError {
mainLog.Load().Error().Msg(errors.Join(prevErr, err).Error())
mainLog.Load().Error().Msgf("error running task %s: %v", task.Name, err)
return false
}
prevErr = err
// if this is darwin stop command, dont print debug
// since launchctl complains on every start
if runtime.GOOS != "darwin" || task.Name != "Stop" {
mainLog.Load().Debug().Msgf("error running task %s: %v", task.Name, err)
}
}
}
return true
@@ -155,6 +194,13 @@ func checkHasElevatedPrivilege() {
func unixSystemVServiceStatus() (service.Status, error) {
out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput()
if err != nil {
// Specific case for openwrt >= 24.10, it returns non-success code
// for above status command, which may not right.
if router.Name() == openwrt.Name {
if string(bytes.ToLower(bytes.TrimSpace(out))) == "inactive" {
return service.StatusStopped, nil
}
}
return service.StatusUnknown, nil
}

View File

@@ -9,3 +9,14 @@ import (
func hasElevatedPrivilege() (bool, error) {
return os.Geteuid() == 0, nil
}
func openLogFile(path string, flags int) (*os.File, error) {
return os.OpenFile(path, flags, os.FileMode(0o600))
}
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
func hasLocalDnsServerRunning() bool { return false }
func ConfigureWindowsServiceFailureActions(serviceName string) error { return nil }
func isRunningOnDomainControllerWindows() (bool, int) { return false, 0 }

View File

@@ -1,6 +1,22 @@
package cli
import "golang.org/x/sys/windows"
import (
"os"
"reflect"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"github.com/microsoft/wmi/pkg/base/host"
"github.com/microsoft/wmi/pkg/base/instance"
"github.com/microsoft/wmi/pkg/base/query"
"github.com/microsoft/wmi/pkg/constant"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc/mgr"
)
func hasElevatedPrivilege() (bool, error) {
var sid *windows.SID
@@ -22,3 +38,190 @@ func hasElevatedPrivilege() (bool, error) {
token := windows.Token(0)
return token.IsMember(sid)
}
// ConfigureWindowsServiceFailureActions checks if the given service
// has the correct failure actions configured, and updates them if not.
func ConfigureWindowsServiceFailureActions(serviceName string) error {
if runtime.GOOS != "windows" {
return nil // no-op on non-Windows
}
m, err := mgr.Connect()
if err != nil {
return err
}
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
return err
}
defer s.Close()
// 1. Retrieve the current config
cfg, err := s.Config()
if err != nil {
return err
}
// 2. Update the Description
cfg.Description = "A highly configurable, multi-protocol DNS forwarding proxy"
// 3. Apply the updated config
if err := s.UpdateConfig(cfg); err != nil {
return err
}
// Then proceed with existing actions, e.g. setting failure actions
actions := []mgr.RecoveryAction{
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
{Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds
}
// Set the recovery actions (3 restarts, reset period = 120).
err = s.SetRecoveryActions(actions, 120)
if err != nil {
return err
}
// Ensure that failure actions are NOT triggered on user-initiated stops.
var failureActionsFlag windows.SERVICE_FAILURE_ACTIONS_FLAG
failureActionsFlag.FailureActionsOnNonCrashFailures = 0
if err := windows.ChangeServiceConfig2(
s.Handle,
windows.SERVICE_CONFIG_FAILURE_ACTIONS_FLAG,
(*byte)(unsafe.Pointer(&failureActionsFlag)),
); err != nil {
return err
}
return nil
}
func openLogFile(path string, mode int) (*os.File, error) {
if len(path) == 0 {
return nil, &os.PathError{Path: path, Op: "open", Err: syscall.ERROR_FILE_NOT_FOUND}
}
pathP, err := syscall.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
var access uint32
switch mode & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) {
case os.O_RDONLY:
access = windows.GENERIC_READ
case os.O_WRONLY:
access = windows.GENERIC_WRITE
case os.O_RDWR:
access = windows.GENERIC_READ | windows.GENERIC_WRITE
}
if mode&os.O_CREATE != 0 {
access |= windows.GENERIC_WRITE
}
if mode&os.O_APPEND != 0 {
access &^= windows.GENERIC_WRITE
access |= windows.FILE_APPEND_DATA
}
shareMode := uint32(syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE)
var sa *syscall.SecurityAttributes
var createMode uint32
switch {
case mode&(os.O_CREATE|os.O_EXCL) == (os.O_CREATE | os.O_EXCL):
createMode = windows.CREATE_NEW
case mode&(os.O_CREATE|os.O_TRUNC) == (os.O_CREATE | os.O_TRUNC):
createMode = windows.CREATE_ALWAYS
case mode&os.O_CREATE == os.O_CREATE:
createMode = windows.OPEN_ALWAYS
case mode&os.O_TRUNC == os.O_TRUNC:
createMode = windows.TRUNCATE_EXISTING
default:
createMode = windows.OPEN_EXISTING
}
handle, err := syscall.CreateFile(pathP, access, shareMode, sa, createMode, syscall.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
return nil, &os.PathError{Path: path, Op: "open", Err: err}
}
return os.NewFile(uintptr(handle), path), nil
}
const processEntrySize = uint32(unsafe.Sizeof(windows.ProcessEntry32{}))
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
func hasLocalDnsServerRunning() bool {
h, e := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if e != nil {
return false
}
p := windows.ProcessEntry32{Size: processEntrySize}
for {
e := windows.Process32Next(h, &p)
if e != nil {
return false
}
if strings.ToLower(windows.UTF16ToString(p.ExeFile[:])) == "dns.exe" {
return true
}
}
}
func isRunningOnDomainControllerWindows() (bool, int) {
whost := host.NewWmiLocalHost()
q := query.NewWmiQuery("Win32_ComputerSystem")
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.CimV2), q)
if err != nil {
mainLog.Load().Debug().Err(err).Msg("WMI query failed")
return false, 0
}
if instances == nil {
mainLog.Load().Debug().Msg("WMI query returned nil instances")
return false, 0
}
defer instances.Close()
if len(instances) == 0 {
mainLog.Load().Debug().Msg("no rows returned from Win32_ComputerSystem")
return false, 0
}
val, err := instances[0].GetProperty("DomainRole")
if err != nil {
mainLog.Load().Debug().Err(err).Msg("failed to get DomainRole property")
return false, 0
}
if val == nil {
mainLog.Load().Debug().Msg("DomainRole property is nil")
return false, 0
}
// Safely handle varied types: string or integer
var roleInt int
switch v := val.(type) {
case string:
// "4", "5", etc.
parsed, parseErr := strconv.Atoi(v)
if parseErr != nil {
mainLog.Load().Debug().Err(parseErr).Msgf("failed to parse DomainRole value %q", v)
return false, 0
}
roleInt = parsed
case int8, int16, int32, int64:
roleInt = int(reflect.ValueOf(v).Int())
case uint8, uint16, uint32, uint64:
roleInt = int(reflect.ValueOf(v).Uint())
default:
mainLog.Load().Debug().Msgf("unexpected DomainRole type: %T value=%v", v, v)
return false, 0
}
// Check if role indicates a domain controller
isDC := roleInt == BackupDomainController || roleInt == PrimaryDomainController
return isDC, roleInt
}

View File

@@ -0,0 +1,25 @@
package cli
import (
"testing"
"time"
)
func Test_hasLocalDnsServerRunning(t *testing.T) {
start := time.Now()
hasDns := hasLocalDnsServerRunning()
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
start = time.Now()
hasDnsPowershell := hasLocalDnsServerRunningPowershell()
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
if hasDns != hasDnsPowershell {
t.Fatalf("result mismatch, want: %v, got: %v", hasDnsPowershell, hasDns)
}
}
func hasLocalDnsServerRunningPowershell() bool {
_, err := powershell("Get-Process -Name DNS")
return err == nil
}

View File

@@ -1,18 +1,15 @@
package cli
import (
"context"
"sync"
"time"
"github.com/miekg/dns"
"github.com/Control-D-Inc/ctrld"
)
const (
// maxFailureRequest is the maximum failed queries allowed before an upstream is marked as down.
maxFailureRequest = 100
maxFailureRequest = 50
// checkUpstreamBackoffSleep is the time interval between each upstream checks.
checkUpstreamBackoffSleep = 2 * time.Second
)
@@ -21,18 +18,24 @@ const (
type upstreamMonitor struct {
cfg *ctrld.Config
mu sync.Mutex
mu sync.RWMutex
checking map[string]bool
down map[string]bool
failureReq map[string]uint64
recovered map[string]bool
// failureTimerActive tracks if a timer is already running for a given upstream.
failureTimerActive map[string]bool
}
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
um := &upstreamMonitor{
cfg: cfg,
checking: make(map[string]bool),
down: make(map[string]bool),
failureReq: make(map[string]uint64),
cfg: cfg,
checking: make(map[string]bool),
down: make(map[string]bool),
failureReq: make(map[string]uint64),
recovered: make(map[string]bool),
failureTimerActive: make(map[string]bool),
}
for n := range cfg.Upstream {
upstream := upstreamPrefix + n
@@ -42,14 +45,47 @@ func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
return um
}
// increaseFailureCount increase failed queries count for an upstream by 1.
// increaseFailureCount increases failed queries count for an upstream by 1 and logs debug information.
// It uses a timer to debounce failure detection, ensuring that an upstream is marked as down
// within 10 seconds if failures persist, without spawning duplicate goroutines.
func (um *upstreamMonitor) increaseFailureCount(upstream string) {
um.mu.Lock()
defer um.mu.Unlock()
if um.recovered[upstream] {
mainLog.Load().Debug().Msgf("upstream %q is recovered, skipping failure count increase", upstream)
return
}
um.failureReq[upstream] += 1
failedCount := um.failureReq[upstream]
um.down[upstream] = failedCount >= maxFailureRequest
// Log the updated failure count.
mainLog.Load().Debug().Msgf("upstream %q failure count updated to %d", upstream, failedCount)
// If this is the first failure and no timer is running, start a 10-second timer.
if failedCount == 1 && !um.failureTimerActive[upstream] {
um.failureTimerActive[upstream] = true
go func(upstream string) {
time.Sleep(10 * time.Second)
um.mu.Lock()
defer um.mu.Unlock()
// If no success occurred during the 10-second window (i.e. counter remains > 0)
// and the upstream is not in a recovered state, mark it as down.
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
um.down[upstream] = true
mainLog.Load().Warn().Msgf("upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
}
// Reset the timer flag so that a new timer can be spawned if needed.
um.failureTimerActive[upstream] = false
}(upstream)
}
// If the failure count quickly reaches the threshold, mark the upstream as down immediately.
if failedCount >= maxFailureRequest {
um.down[upstream] = true
mainLog.Load().Warn().Msgf("upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
}
}
// isDown reports whether the given upstream is being marked as down.
@@ -63,50 +99,28 @@ func (um *upstreamMonitor) isDown(upstream string) bool {
// reset marks an upstream as up and set failed queries counter to zero.
func (um *upstreamMonitor) reset(upstream string) {
um.mu.Lock()
defer um.mu.Unlock()
um.failureReq[upstream] = 0
um.down[upstream] = false
}
// checkUpstream checks the given upstream status, periodically sending query to upstream
// until successfully. An upstream status/counter will be reset once it becomes reachable.
func (um *upstreamMonitor) checkUpstream(upstream string, uc *ctrld.UpstreamConfig) {
um.mu.Lock()
isChecking := um.checking[upstream]
if isChecking {
um.mu.Unlock()
return
}
um.checking[upstream] = true
um.recovered[upstream] = true
um.mu.Unlock()
defer func() {
go func() {
// debounce the recovery to avoid incrementing failure counts already in flight
time.Sleep(1 * time.Second)
um.mu.Lock()
um.checking[upstream] = false
um.recovered[upstream] = false
um.mu.Unlock()
}()
resolver, err := ctrld.NewResolver(uc)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not check upstream")
return
}
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
check := func() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
uc.ReBootstrap()
_, err := resolver.Resolve(ctx, msg)
return err
}
for {
if err := check(); err == nil {
mainLog.Load().Debug().Msgf("upstream %q is online", uc.Endpoint)
um.reset(upstream)
return
}
time.Sleep(checkUpstreamBackoffSleep)
}
}
// countHealthy returns the number of upstreams in the provided map that are considered healthy.
func (um *upstreamMonitor) countHealthy(upstreams []string) int {
var count int
um.mu.RLock()
for _, upstream := range upstreams {
if !um.down[upstream] {
count++
}
}
um.mu.RUnlock()
return count
}

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

@@ -1,7 +1,13 @@
package main
import "github.com/Control-D-Inc/ctrld/cmd/cli"
import (
"os"
"github.com/Control-D-Inc/ctrld/cmd/cli"
)
func main() {
cli.Main()
// make sure we exit with 0 if there are no errors
os.Exit(0)
}

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,18 @@ func mapCallback(callback AppCallback) cli.AppCallback {
}
}
func (c *Controller) Stop() bool {
if c.stopCh != nil {
func (c *Controller) Stop(restart bool, pin int64) int {
var errorCode = 0
// Force disconnect without checking pin.
// In iOS restart is required if vpn detects no connectivity after network change.
if !restart {
errorCode = cli.CheckDeactivationPin(pin, c.stopCh)
}
if errorCode == 0 && c.stopCh != nil {
close(c.stopCh)
c.stopCh = nil
return true
}
return false
return errorCode
}
func (c *Controller) IsRunning() bool {

236
config.go
View File

@@ -7,6 +7,7 @@ import (
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"io"
"math/rand"
"net"
@@ -22,9 +23,11 @@ import (
"sync/atomic"
"time"
"github.com/ameshkov/dnsstamps"
"github.com/go-playground/validator/v10"
"github.com/miekg/dns"
"github.com/spf13/viper"
"golang.org/x/net/http2"
"golang.org/x/sync/singleflight"
"tailscale.com/logtail/backoff"
"tailscale.com/net/tsaddr"
@@ -46,9 +49,23 @@ const (
// depending on the record type of the DNS query.
IpStackSplit = "split"
// FreeDnsDomain is the domain name of free ControlD service.
FreeDnsDomain = "freedns.controld.com"
// FreeDNSBoostrapIP is the IP address of freedns.controld.com.
FreeDNSBoostrapIP = "76.76.2.11"
// PremiumDnsDomain is the domain name of premium ControlD service.
PremiumDnsDomain = "dns.controld.com"
// PremiumDNSBoostrapIP is the IP address of dns.controld.com.
PremiumDNSBoostrapIP = "76.76.2.22"
controlDComDomain = "controld.com"
controlDNetDomain = "controld.net"
controlDDevDomain = "controld.dev"
endpointPrefixHTTPS = "https://"
endpointPrefixQUIC = "quic://"
endpointPrefixH3 = "h3://"
endpointPrefixSdns = "sdns://"
)
var (
@@ -104,14 +121,14 @@ func InitConfig(v *viper.Viper, name string) {
})
v.SetDefault("upstream", map[string]*UpstreamConfig{
"0": {
BootstrapIP: "76.76.2.11",
BootstrapIP: FreeDNSBoostrapIP,
Name: "Control D - Anti-Malware",
Type: ResolverTypeDOH,
Endpoint: "https://freedns.controld.com/p1",
Timeout: 5000,
},
"1": {
BootstrapIP: "76.76.2.11",
BootstrapIP: FreeDNSBoostrapIP,
Name: "Control D - No Ads",
Type: ResolverTypeDOQ,
Endpoint: "p2.freedns.controld.com",
@@ -179,23 +196,32 @@ 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"`
CacheFlushDomains []string `mapstructure:"cache_flush_domains" toml:"cache_flush_domains" validate:"max=256"`
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 kea-dhcp4"`
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"`
DnsWatchdogEnabled *bool `mapstructure:"dns_watchdog_enabled" toml:"dns_watchdog_enabled,omitempty"`
DnsWatchdogInvterval *time.Duration `mapstructure:"dns_watchdog_interval" toml:"dns_watchdog_interval,omitempty"`
RefetchTime *int `mapstructure:"refetch_time" toml:"refetch_time,omitempty"`
ForceRefetchWaitTime *int `mapstructure:"force_refetch_wait_time" toml:"force_refetch_wait_time,omitempty"`
LeakOnUpstreamFailure *bool `mapstructure:"leak_on_upstream_failure" toml:"leak_on_upstream_failure,omitempty"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
}
// NetworkConfig specifies configuration for networks where ctrld will handle requests.
@@ -208,7 +234,7 @@ type NetworkConfig struct {
// UpstreamConfig specifies configuration for upstreams that ctrld will forward requests to.
type UpstreamConfig struct {
Name string `mapstructure:"name" toml:"name,omitempty"`
Type string `mapstructure:"type" toml:"type,omitempty" validate:"oneof=doh doh3 dot doq os legacy"`
Type string `mapstructure:"type" toml:"type,omitempty" validate:"oneof=doh doh3 dot doq os legacy sdns ''"`
Endpoint string `mapstructure:"endpoint" toml:"endpoint,omitempty"`
BootstrapIP string `mapstructure:"bootstrap_ip" toml:"bootstrap_ip,omitempty"`
Domain string `mapstructure:"-" toml:"-"`
@@ -282,9 +308,13 @@ type Rule map[string][]string
// Init initialized necessary values for an UpstreamConfig.
func (uc *UpstreamConfig) Init() {
if err := uc.initDnsStamps(); err != nil {
ProxyLogger.Load().Fatal().Err(err).Msg("invalid DNS Stamps")
}
uc.initDoHScheme()
uc.uid = upstreamUID()
if u, err := url.Parse(uc.Endpoint); err == nil {
uc.Domain = u.Host
uc.Domain = u.Hostname()
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
uc.u = u
@@ -302,7 +332,7 @@ func (uc *UpstreamConfig) Init() {
}
}
if uc.IPStack == "" {
if uc.isControlD() {
if uc.IsControlD() {
uc.IPStack = IpStackSplit
} else {
uc.IPStack = IpStackBoth
@@ -340,7 +370,7 @@ func (uc *UpstreamConfig) UpstreamSendClientInfo() bool {
}
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
if uc.isControlD() || uc.isNextDNS() {
if uc.IsControlD() || uc.isNextDNS() {
return true
}
}
@@ -354,7 +384,7 @@ func (uc *UpstreamConfig) IsDiscoverable() bool {
return *uc.Discoverable
}
switch uc.Type {
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate:
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate, ResolverTypeLocal:
if ip, err := netip.ParseAddr(uc.Domain); err == nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
}
@@ -387,7 +417,7 @@ func (uc *UpstreamConfig) UID() string {
// The first usable IP will be used as bootstrap IP of the upstream.
func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) {
b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second)
isControlD := uc.isControlD()
isControlD := uc.IsControlD()
for {
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, withBootstrapDNS)
// For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses,
@@ -428,7 +458,7 @@ func (uc *UpstreamConfig) ReBootstrap() {
}
_, _, _ = uc.g.Do("ReBootstrap", func() (any, error) {
if uc.rebootstrap.CompareAndSwap(false, true) {
ProxyLogger.Load().Debug().Msg("re-bootstrapping upstream ip")
ProxyLogger.Load().Debug().Msgf("re-bootstrapping upstream ip for %v", uc)
}
return true, nil
})
@@ -472,6 +502,13 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
ClientSessionCache: tls.NewLRUClientSessionCache(0),
}
// Prevent bad tcp connection hanging the requests for too long.
// See: https://github.com/golang/go/issues/36026
if t2, err := http2.ConfigureTransports(transport); err == nil {
t2.ReadIdleTimeout = 10 * time.Second
t2.PingTimeout = 5 * time.Second
}
dialerTimeoutMs := 2000
if uc.Timeout > 0 && uc.Timeout < dialerTimeoutMs {
dialerTimeoutMs = uc.Timeout
@@ -492,7 +529,7 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
for i := range addrs {
dialAddrs[i] = net.JoinHostPort(addrs[i], port)
}
conn, err := pd.DialContext(ctx, network, dialAddrs)
conn, err := pd.DialContext(ctx, network, dialAddrs, ProxyLogger.Load())
if err != nil {
return nil, err
}
@@ -507,38 +544,59 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
// Ping warms up the connection to DoH/DoH3 upstream.
func (uc *UpstreamConfig) Ping() {
_ = uc.ping()
}
// ErrorPing is like Ping, but return an error if any.
func (uc *UpstreamConfig) ErrorPing() error {
return uc.ping()
}
func (uc *UpstreamConfig) ping() error {
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
default:
return
return nil
}
ping := func(t http.RoundTripper) {
ping := func(t http.RoundTripper) error {
if t == nil {
return
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "HEAD", uc.Endpoint, nil)
resp, _ := t.RoundTrip(req)
if resp == nil {
return
req, err := http.NewRequestWithContext(ctx, "HEAD", uc.Endpoint, nil)
if err != nil {
return err
}
resp, err := t.RoundTrip(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} {
switch uc.Type {
case ResolverTypeDOH:
ping(uc.dohTransport(typ))
if err := ping(uc.dohTransport(typ)); err != nil {
return err
}
case ResolverTypeDOH3:
ping(uc.doh3Transport(typ))
if err := ping(uc.doh3Transport(typ)); err != nil {
return err
}
}
}
return nil
}
func (uc *UpstreamConfig) isControlD() bool {
// IsControlD reports whether this is a ControlD upstream.
func (uc *UpstreamConfig) IsControlD() bool {
domain := uc.Domain
if domain == "" {
if u, err := url.Parse(uc.Endpoint); err == nil {
@@ -628,6 +686,69 @@ func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
return "tcp-tls", "udp"
}
// initDoHScheme initializes the endpoint scheme for DoH/DoH3 upstream if not present.
func (uc *UpstreamConfig) initDoHScheme() {
if strings.HasPrefix(uc.Endpoint, endpointPrefixH3) && uc.Type == "" {
uc.Type = ResolverTypeDOH3
}
switch uc.Type {
case ResolverTypeDOH:
case ResolverTypeDOH3:
if after, found := strings.CutPrefix(uc.Endpoint, endpointPrefixH3); found {
uc.Endpoint = endpointPrefixHTTPS + after
}
default:
return
}
if !strings.HasPrefix(uc.Endpoint, endpointPrefixHTTPS) {
uc.Endpoint = endpointPrefixHTTPS + uc.Endpoint
}
}
// initDnsStamps initializes upstream config based on encoded DNS Stamps Endpoint.
func (uc *UpstreamConfig) initDnsStamps() error {
if strings.HasPrefix(uc.Endpoint, endpointPrefixSdns) && uc.Type == "" {
uc.Type = ResolverTypeSDNS
}
if uc.Type != ResolverTypeSDNS {
return nil
}
sdns, err := dnsstamps.NewServerStampFromString(uc.Endpoint)
if err != nil {
return err
}
ip, port, _ := net.SplitHostPort(sdns.ServerAddrStr)
providerName, port2, _ := net.SplitHostPort(sdns.ProviderName)
if port2 != "" {
port = port2
}
if providerName == "" {
providerName = sdns.ProviderName
}
switch sdns.Proto {
case dnsstamps.StampProtoTypeDoH:
uc.Type = ResolverTypeDOH
host := sdns.ProviderName
if port != "" && port != defaultPortFor(uc.Type) {
host = net.JoinHostPort(providerName, port)
}
uc.Endpoint = "https://" + host + sdns.Path
case dnsstamps.StampProtoTypeTLS:
uc.Type = ResolverTypeDOT
uc.Endpoint = net.JoinHostPort(providerName, port)
case dnsstamps.StampProtoTypeDoQ:
uc.Type = ResolverTypeDOQ
uc.Endpoint = net.JoinHostPort(providerName, port)
case dnsstamps.StampProtoTypePlain:
uc.Type = ResolverTypeLegacy
uc.Endpoint = sdns.ServerAddrStr
default:
return fmt.Errorf("unsupported stamp protocol %q", sdns.Proto)
}
uc.BootstrapIP = ip
return nil
}
// Init initialized necessary values for an ListenerConfig.
func (lc *ListenerConfig) Init() {
if lc.Policy != nil {
@@ -680,6 +801,24 @@ func upstreamConfigStructLevelValidation(sl validator.StructLevel) {
return
}
// Empty type is ok only for endpoints starts with "h3://" and "sdns://".
if uc.Type == "" && !strings.HasPrefix(uc.Endpoint, endpointPrefixH3) && !strings.HasPrefix(uc.Endpoint, endpointPrefixSdns) {
sl.ReportError(uc.Endpoint, "type", "type", "oneof", "doh doh3 dot doq os legacy sdns")
return
}
// initDoHScheme/initDnsStamps may change upstreams information,
// so restoring changed values after validation to keep original one.
defer func(ep, typ string) {
uc.Endpoint = ep
uc.Type = typ
}(uc.Endpoint, uc.Type)
if err := uc.initDnsStamps(); err != nil {
sl.ReportError(uc.Endpoint, "endpoint", "Endpoint", "http_url", "")
return
}
uc.initDoHScheme()
// DoH/DoH3 requires endpoint is an HTTP url.
if uc.Type == ResolverTypeDOH || uc.Type == ResolverTypeDOH3 {
u, err := url.Parse(uc.Endpoint)
@@ -687,10 +826,6 @@ func upstreamConfigStructLevelValidation(sl validator.StructLevel) {
sl.ReportError(uc.Endpoint, "endpoint", "Endpoint", "http_url", "")
return
}
if u.Scheme != "http" && u.Scheme != "https" {
sl.ReportError(uc.Endpoint, "endpoint", "Endpoint", "http_url", "")
return
}
}
}
@@ -712,13 +847,19 @@ func defaultPortFor(typ string) string {
// - If endpoint is an IP address -> ResolverTypeLegacy
// - If endpoint starts with "https://" -> ResolverTypeDOH
// - If endpoint starts with "quic://" -> ResolverTypeDOQ
// - If endpoint starts with "h3://" -> ResolverTypeDOH3
// - If endpoint starts with "sdns://" -> ResolverTypeSDNS
// - For anything else -> ResolverTypeDOT
func ResolverTypeFromEndpoint(endpoint string) string {
switch {
case strings.HasPrefix(endpoint, "https://"):
case strings.HasPrefix(endpoint, endpointPrefixHTTPS):
return ResolverTypeDOH
case strings.HasPrefix(endpoint, "quic://"):
case strings.HasPrefix(endpoint, endpointPrefixQUIC):
return ResolverTypeDOQ
case strings.HasPrefix(endpoint, endpointPrefixH3):
return ResolverTypeDOH3
case strings.HasPrefix(endpoint, endpointPrefixSdns):
return ResolverTypeSDNS
}
host := endpoint
if strings.Contains(endpoint, ":") {
@@ -745,3 +886,12 @@ func upstreamUID() string {
return hex.EncodeToString(b)
}
}
// String returns a string representation of the UpstreamConfig for logging.
func (uc *UpstreamConfig) String() string {
if uc == nil {
return "<nil>"
}
return fmt.Sprintf("{name: %q, type: %q, endpoint: %q, bootstrap_ip: %q, domain: %q, ip_stack: %q}",
uc.Name, uc.Type, uc.Endpoint, uc.BootstrapIP, uc.Domain, uc.IPStack)
}

View File

@@ -2,12 +2,16 @@ package ctrld
import (
"net/url"
"os"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
l := zerolog.New(os.Stdout)
ProxyLogger.Store(&l)
uc := &UpstreamConfig{
Name: "test",
Type: ResolverTypeDOH,
@@ -17,7 +21,7 @@ func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
uc.Init()
uc.setupBootstrapIP(false)
if len(uc.bootstrapIPs) == 0 {
t.Log(nameservers())
t.Log(defaultNameservers())
t.Fatal("could not bootstrap ip without bootstrap DNS")
}
t.Log(uc)
@@ -26,6 +30,7 @@ func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
func TestUpstreamConfig_Init(t *testing.T) {
u1, _ := url.Parse("https://example.com")
u2, _ := url.Parse("https://example.com?k=v")
u3, _ := url.Parse("https://freedns.controld.com/p1")
tests := []struct {
name string
uc *UpstreamConfig
@@ -178,6 +183,152 @@ func TestUpstreamConfig_Init(t *testing.T) {
u: u2,
},
},
{
"h3",
&UpstreamConfig{
Name: "doh3",
Type: "doh3",
Endpoint: "h3://example.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "doh3",
Type: "doh3",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u1,
},
},
{
"h3 without type",
&UpstreamConfig{
Name: "doh3",
Endpoint: "h3://example.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "doh3",
Type: "doh3",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u1,
},
},
{
"sdns -> doh",
&UpstreamConfig{
Name: "sdns",
Type: "sdns",
Endpoint: "sdns://AgMAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29tAy9wMQ",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "doh",
Endpoint: "https://freedns.controld.com/p1",
BootstrapIP: "76.76.2.11",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u3,
},
},
{
"sdns -> dot",
&UpstreamConfig{
Name: "sdns",
Type: "sdns",
Endpoint: "sdns://AwcAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29t",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "dot",
Endpoint: "freedns.controld.com:843",
BootstrapIP: "76.76.2.11",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"sdns -> doq",
&UpstreamConfig{
Name: "sdns",
Type: "sdns",
Endpoint: "sdns://BAcAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29t",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "doq",
Endpoint: "freedns.controld.com:784",
BootstrapIP: "76.76.2.11",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"sdns -> legacy",
&UpstreamConfig{
Name: "sdns",
Type: "sdns",
Endpoint: "sdns://AAcAAAAAAAAACjc2Ljc2LjIuMTE",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "legacy",
Endpoint: "76.76.2.11:53",
BootstrapIP: "76.76.2.11",
Domain: "76.76.2.11",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"sdns without type",
&UpstreamConfig{
Name: "sdns",
Endpoint: "sdns://AAcAAAAAAAAACjc2Ljc2LjIuMTE",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "sdns",
Type: "legacy",
Endpoint: "76.76.2.11:53",
BootstrapIP: "76.76.2.11",
Domain: "76.76.2.11",
Timeout: 0,
IPStack: IpStackBoth,
},
},
}
for _, tc := range tests {

View File

@@ -1,5 +1,3 @@
//go:build !qf
package ctrld
import (
@@ -36,7 +34,7 @@ func (uc *UpstreamConfig) setupDOH3Transport() {
}
func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
rt := &http3.RoundTripper{}
rt := &http3.Transport{}
rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool}
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
_, port, _ := net.SplitHostPort(addr)
@@ -66,7 +64,7 @@ func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", conn.RemoteAddr())
return conn, err
}
runtime.SetFinalizer(rt, func(rt *http3.RoundTripper) {
runtime.SetFinalizer(rt, func(rt *http3.Transport) {
rt.CloseIdleConnections()
})
return rt

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

@@ -1,9 +1,11 @@
package ctrld_test
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/go-playground/validator/v10"
"github.com/spf13/viper"
@@ -21,6 +23,8 @@ func TestLoadConfig(t *testing.T) {
assert.Equal(t, "info", cfg.Service.LogLevel)
assert.Equal(t, "/path/to/log.log", cfg.Service.LogPath)
assert.Equal(t, false, *cfg.Service.DnsWatchdogEnabled)
assert.Equal(t, time.Duration(20*time.Second), *cfg.Service.DnsWatchdogInvterval)
assert.Len(t, cfg.Network, 2)
assert.Contains(t, cfg.Network, "0")
@@ -102,6 +106,12 @@ func TestConfigValidation(t *testing.T) {
{"invalid lease file format", configWithInvalidLeaseFileFormat(t), true},
{"invalid doh/doh3 endpoint", configWithInvalidDoHEndpoint(t), true},
{"invalid client id pref", configWithInvalidClientIDPref(t), true},
{"doh endpoint without scheme", dohUpstreamEndpointWithoutScheme(t), false},
{"doh endpoint without type", dohUpstreamEndpointWithoutType(t), true},
{"doh3 endpoint without type", doh3UpstreamEndpointWithoutType(t), false},
{"sdns endpoint without type", sdnsUpstreamEndpointWithoutType(t), false},
{"maximum number of flush cache domains", configWithInvalidFlushCacheDomain(t), true},
{"kea dhcp4 format", configWithDhcp4KeaFormat(t), false},
}
for _, tc := range tests {
@@ -121,6 +131,44 @@ func TestConfigValidation(t *testing.T) {
}
}
func TestConfigValidationDoNotChangeEndpoint(t *testing.T) {
cfg := configWithInvalidDoHEndpoint(t)
endpointMap := map[string]struct{}{}
for _, uc := range cfg.Upstream {
endpointMap[uc.Endpoint] = struct{}{}
}
validate := validator.New()
_ = ctrld.ValidateConfig(validate, cfg)
for _, uc := range cfg.Upstream {
if _, ok := endpointMap[uc.Endpoint]; !ok {
t.Fatalf("expected endpoint '%s' to exist", uc.Endpoint)
}
}
}
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")
@@ -144,6 +192,33 @@ func invalidUpstreamType(t *testing.T) *ctrld.Config {
return cfg
}
func dohUpstreamEndpointWithoutScheme(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Upstream["0"].Endpoint = "freedns.controld.com/p1"
return cfg
}
func dohUpstreamEndpointWithoutType(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Upstream["0"].Endpoint = "https://freedns.controld.com/p1"
cfg.Upstream["0"].Type = ""
return cfg
}
func doh3UpstreamEndpointWithoutType(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Upstream["0"].Endpoint = "h3://freedns.controld.com/p1"
cfg.Upstream["0"].Type = ""
return cfg
}
func sdnsUpstreamEndpointWithoutType(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Upstream["0"].Endpoint = "sdns://AgMAAAAAAAAACjc2Ljc2LjIuMTEAFGZyZWVkbnMuY29udHJvbGQuY29tAy9wMQ"
cfg.Upstream["0"].Type = ""
return cfg
}
func invalidUpstreamTimeout(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Upstream["0"].Timeout = -1
@@ -233,9 +308,15 @@ func configWithInvalidLeaseFileFormat(t *testing.T) *ctrld.Config {
return cfg
}
func configWithDhcp4KeaFormat(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Service.DHCPLeaseFileFormat = "kea-dhcp4"
return cfg
}
func configWithInvalidDoHEndpoint(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Upstream["0"].Endpoint = "1.1.1.1"
cfg.Upstream["0"].Endpoint = "/1.1.1.1"
cfg.Upstream["0"].Type = ctrld.ResolverTypeDOH
return cfg
}
@@ -245,3 +326,12 @@ func configWithInvalidClientIDPref(t *testing.T) *ctrld.Config {
cfg.Service.ClientIDPref = "foo"
return cfg
}
func configWithInvalidFlushCacheDomain(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Service.CacheFlushDomains = make([]string, 257)
for i := range cfg.Service.CacheFlushDomains {
cfg.Service.CacheFlushDomains[i] = fmt.Sprintf("%d.com", i)
}
return cfg
}

View File

@@ -8,7 +8,7 @@
# - Non-cgo ctrld binary.
#
# CI_COMMIT_TAG is used to set the version of ctrld binary.
FROM golang:1.20-bullseye as base
FROM golang:bullseye as base
WORKDIR /app

View File

@@ -14,7 +14,7 @@ The config file allows for advanced configuration of the `ctrld` utility to cove
## Config Location
`ctrld` uses [TOML](toml_link) format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
`ctrld` uses [TOML][toml_link] format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
- `/etc/controld` on *nix.
- User's home directory on Windows.
@@ -157,9 +157,15 @@ stale cached records (regardless of their TTLs) until upstream comes online.
- Required: no
- Default: false
### cache_flush_domains
When `ctrld` receives query with domain name in `cache_flush_domains`, the local cache will be discarded
before serving the query.
- Type: array of strings
- Required: no
### max_concurrent_requests
The number of concurrent requests that will be handled, must be a non-negative integer.
Tweaking this value depends on the capacity of your system.
- Type: number
- Required: no
@@ -200,6 +206,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.
@@ -212,20 +226,65 @@ DHCP leases file format.
- Type: string
- Required: no
- Valid values: `dnsmasq`, `isc-dhcp`
- Valid values: `dnsmasq`, `isc-dhcp`, `kea-dhcp4`
- 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: ""
### dns_watchdog_enabled
Watches all physical interfaces for DNS changes and reverts them to ctrld's settings.The DNS watchdog process only runs on Windows and MacOS.
- Type: boolean
- Required: no
- Default: true
### dns_watchdog_interval
Time duration between each DNS watchdog iteration.
A duration string is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix,
such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
If the time duration is non-positive, default value will be used.
- Type: time duration string
- Required: no
- Default: 20s
### refetch_time
Time in seconds between each iteration that reloads custom config from the API.
The value must be a positive number, any invalid value will be ignored and default value will be used.
- Type: number
- Required: no
- Default: 3600
### leak_on_upstream_failure
If a remote upstream fails to resolve a query or is unreachable, `ctrld` will forward the queries to the default DNS resolver on the network. If failures persist, `ctrld` will remove itself from all networking interfaces until connectivity is restored.
- Type: boolean
- Required: no
- Default: true on Windows, MacOS and non-router Linux.
## Upstream
The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to.
@@ -310,7 +369,7 @@ The protocol that `ctrld` will use to send DNS requests to upstream.
- Type: string
- Required: yes
- Valid values: `doh`, `doh3`, `dot`, `doq`, `legacy`, `os`
- Valid values: `doh`, `doh3`, `dot`, `doq`, `legacy`
### ip_stack
Specifying what kind of ip stack that `ctrld` will use to connect to upstream.
@@ -331,7 +390,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
@@ -469,6 +528,15 @@ rules = [
]
```
If there is no explicitly defined rules, LAN queries will be handled solely by the OS resolver.
These following domains are considered LAN queries:
- Queries does not have dot `.` in domain name, like `machine1`, `example`, ... (1)
- Queries have domain ends with: `.domain`, `.lan`, `.local`. (2)
- All `SRV` queries of LAN hostname (1) + (2).
- `PTR` queries with private IPs.
---
Note that the order of matching preference:
@@ -502,6 +570,12 @@ And within each policy, the rules are processed from top to bottom.
- Required: no
- Default: []
---
Note that the domain comparisons are done in case in-sensitive manner following [RFC 1034](https://datatracker.ietf.org/doc/html/rfc1034#section-3.1)
---
### macs:
`macs` is the list of mac rules within the policy. Mac address value is case-insensitive.
@@ -512,7 +586,7 @@ And within each policy, the rules are processed from top to bottom.
### failover_rcodes
For non success response, `failover_rcodes` allows the request to be forwarded to next upstream, if the response `RCODE` matches any value defined in `failover_rcodes`.
- Type: array of string
- Type: array of strings
- Required: no
- Default: []
-
@@ -529,7 +603,7 @@ networks = [
If `upstream.0` returns a NXDOMAIN response, the request will be forwarded to `upstream.1` instead of returning immediately to the client.
See all available DNS Rcodes value [here](rcode_link).
See all available DNS Rcodes value [here][rcode_link].
[toml_link]: https://toml.io/en
[rcode_link]: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6

BIN
docs/ctrldsplash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

20
doh.go
View File

@@ -60,17 +60,10 @@ func init() {
}
}
// TODO: use sync.OnceValue when upgrading to go1.21
var xCdOsValueOnce sync.Once
var xCdOsValue string
func dohOsHeaderValue() string {
xCdOsValueOnce.Do(func() {
oi := osinfo.New()
xCdOsValue = strings.Join([]string{EncodeOsNameMap[runtime.GOOS], EncodeArchNameMap[runtime.GOARCH], oi.Dist}, "-")
})
return xCdOsValue
}
var dohOsHeaderValue = sync.OnceValue(func() string {
oi := osinfo.New()
return strings.Join([]string{EncodeOsNameMap[runtime.GOOS], EncodeArchNameMap[runtime.GOARCH], oi.Dist}, "-")
})()
func newDohResolver(uc *UpstreamConfig) *dohResolver {
r := &dohResolver{
@@ -154,7 +147,7 @@ func addHeader(ctx context.Context, req *http.Request, uc *UpstreamConfig) {
if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil {
printed = ci.Mac != "" || ci.IP != "" || ci.Hostname != ""
switch {
case uc.isControlD():
case uc.IsControlD():
dohHeader = newControlDHeaders(ci)
case uc.isNextDNS():
dohHeader = newNextDNSHeaders(ci)
@@ -172,7 +165,6 @@ func addHeader(ctx context.Context, req *http.Request, uc *UpstreamConfig) {
// newControlDHeaders returns DoH/Doh3 HTTP request headers for ControlD upstream.
func newControlDHeaders(ci *ClientInfo) http.Header {
header := make(http.Header)
header.Set(dohOsHeader, dohOsHeaderValue())
if ci.Mac != "" {
header.Set(dohMacHeader, ci.Mac)
}
@@ -183,7 +175,7 @@ func newControlDHeaders(ci *ClientInfo) http.Header {
header.Set(dohHostHeader, ci.Hostname)
}
if ci.Self {
header.Set(dohOsHeader, dohOsHeaderValue())
header.Set(dohOsHeader, dohOsHeaderValue)
}
switch ci.ClientIDPref {
case "mac":

View File

@@ -6,7 +6,7 @@ import (
)
func Test_dohOsHeaderValue(t *testing.T) {
val := dohOsHeaderValue()
val := dohOsHeaderValue
if val == "" {
t.Fatalf("empty %s", dohOsHeader)
}

View File

@@ -1,18 +0,0 @@
//go:build qf
package ctrld
import (
"context"
"errors"
"github.com/miekg/dns"
)
type doqResolver struct {
uc *UpstreamConfig
}
func (r *doqResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
return nil, errors.New("DoQ is not supported")
}

2
dot.go
View File

@@ -18,7 +18,7 @@ func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
// dns.controld.dev first. By using a dialer with custom resolver,
// we ensure that we can always resolve the bootstrap domain
// regardless of the machine DNS status.
dialer := newDialer(net.JoinHostPort(bootstrapDNS, "53"))
dialer := newDialer(net.JoinHostPort(controldBootstrapDns, "53"))
dnsTyp := uint16(0)
if msg != nil && len(msg.Question) > 0 {
dnsTyp = msg.Question[0].Qtype

97
go.mod
View File

@@ -1,84 +1,107 @@
module github.com/Control-D-Inc/ctrld
go 1.20
go 1.23
toolchain go1.23.1
require (
github.com/Masterminds/semver v1.5.0
github.com/ameshkov/dnsstamps v1.0.3
github.com/coreos/go-systemd/v22 v22.5.0
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf
github.com/frankban/quicktest v1.14.5
github.com/fsnotify/fsnotify v1.6.0
github.com/docker/go-units v0.5.0
github.com/frankban/quicktest v1.14.6
github.com/fsnotify/fsnotify v1.7.0
github.com/go-playground/validator/v10 v10.11.1
github.com/godbus/dbus/v5 v5.1.0
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
github.com/hashicorp/golang-lru/v2 v2.0.1
github.com/illarion/gonotify v1.0.1
github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16
github.com/illarion/gonotify/v2 v2.0.3
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2
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/miekg/dns v1.1.55
github.com/mdlayher/ndp v1.0.1
github.com/microsoft/wmi v0.24.5
github.com/miekg/dns v1.1.58
github.com/minio/selfupdate v0.6.0
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.19.1
github.com/prometheus/client_model v0.5.0
github.com/prometheus/prom2json v1.3.3
github.com/quic-go/quic-go v0.48.2
github.com/rs/zerolog v1.28.0
github.com/spf13/cobra v1.7.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.3
github.com/stretchr/testify v1.9.0
github.com/vishvananda/netlink v1.2.1-beta.2
golang.org/x/net v0.17.0
golang.org/x/sync v0.2.0
golang.org/x/sys v0.13.0
golang.org/x/net v0.33.0
golang.org/x/sync v0.10.0
golang.org/x/sys v0.29.0
golang.zx2c4.com/wireguard/windows v0.5.3
tailscale.com v1.44.0
tailscale.com v1.74.0
)
require (
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
aead.dev/minisign v0.2.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // 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.2-0.20180830191138-d8f796af33cc // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 // indirect
github.com/go-ole/go-ole v1.3.0 // 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/google/go-cmp v0.5.9 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jsimonetti/rtnetlink v1.3.2 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/mdlayher/packet v1.1.2 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
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/quic-go/qpack v0.4.0 // indirect
github.com/quic-go/qtls-go1-20 v0.3.2 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect
github.com/vishvananda/netns v0.0.4 // indirect
go.uber.org/mock v0.4.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/text v0.13.0 // indirect
golang.org/x/tools v0.9.1 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.19.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.23.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mr-karan/doggo => github.com/Windscribe/doggo v0.0.0-20220919152748-2c118fc391f8
replace github.com/rs/zerolog => github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be
replace github.com/rs/zerolog => github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c

225
go.sum
View File

@@ -1,3 +1,5 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -38,42 +40,61 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be h1:qBKVRi7Mom5heOkyZ+NCIu9HZBiNCsRqrRe5t9pooik=
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/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c h1:UqFsxmwiCh/DBvwJB0m7KQ2QFDd6DdUkosznfMppdhE=
github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo=
github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A=
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.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
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=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf h1:40DHYsri+d1bnroFDU2FQAeq68f3kAlOzlQ93kCf26Q=
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf/go.mod h1:G45410zMgmnSjLVKCq4f6GpbYAzoP2plX9rPwgx6C24=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
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-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
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=
@@ -85,8 +106,8 @@ github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -98,8 +119,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 +133,8 @@ 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.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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,8 +146,9 @@ 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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -141,10 +162,12 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
@@ -155,21 +178,22 @@ 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=
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE=
github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A=
github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 h1:+aAGyK41KRn8jbF2Q7PLL0Sxwg6dShGcQSeCC7nZQ8E=
github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c h1:kbTQ8oGf+BVFvt/fM+ECI+NbZDCqoi0vtZTfB2p2hrI=
github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c/go.mod h1:k6+89xKz7BSMJ+DzIerBdtpEUeTlBMugO/hcVSzahog=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk=
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8=
github.com/jsimonetti/rtnetlink v1.3.2 h1:dcn0uWkfxycEEyNy0IGfx3GrhQ38LH7odjxAghimsVI=
github.com/jsimonetti/rtnetlink v1.3.2/go.mod h1:BBu4jZCpTjP6Gk0/wfrO8qcqymnN3g0hoFqObRmUo6U=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kardianos/service v1.2.1 h1:AYndMsehS+ywIS6RB9KOlcXzteWUzxgMgBymJD7+BYk=
@@ -189,27 +213,31 @@ github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/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/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/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=
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w=
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/microsoft/wmi v0.24.5 h1:NT+WqhjKbEcg3ldmDsRMarWgHGkpeW+gMopSCfON0kM=
github.com/microsoft/wmi v0.24.5/go.mod h1:1zbdSF0A+5OwTUII5p3hN7/K6KF2m3o27pSG6Y51VU8=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@@ -217,23 +245,34 @@ 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=
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
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/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
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.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
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=
@@ -241,16 +280,16 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -259,8 +298,9 @@ github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
@@ -268,12 +308,13 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg=
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
@@ -283,25 +324,30 @@ 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.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.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=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -312,8 +358,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -337,16 +383,14 @@ 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.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
@@ -372,10 +416,9 @@ 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=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -395,21 +438,19 @@ 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/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606122018-79a91cf218c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -435,21 +476,21 @@ 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-20210228012217-479acdf4ea46/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=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -459,11 +500,13 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -511,9 +554,8 @@ 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/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -608,7 +650,8 @@ 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.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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=
@@ -631,5 +674,5 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
tailscale.com v1.44.0 h1:MPos9n30kJvdyfL52045gVFyNg93K+bwgDsr8gqKq2o=
tailscale.com v1.44.0/go.mod h1:+iYwTdeHyVJuNDu42Zafwihq1Uqfh+pW7pRaY1GD328=
tailscale.com v1.74.0 h1:J+vRN9o3D4wCqZBiwvDg9kZpQag2mG4Xz5RXNpmV3KE=
tailscale.com v1.74.0/go.mod h1:3iACpCONQ4lauDXvwfoGlwNCpfbVxjdc2j6G9EuFOW8=

View File

@@ -14,6 +14,11 @@ import (
"github.com/Control-D-Inc/ctrld/internal/controld"
)
const (
ipV4Loopback = "127.0.0.1"
ipv6Loopback = "::1"
)
// IpResolver is the interface for retrieving IP from Mac.
type IpResolver interface {
fmt.Stringer
@@ -58,10 +63,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,28 +77,40 @@ type Table struct {
hostnameResolvers []HostnameResolver
refreshers []refresher
initOnce sync.Once
stopOnce sync.Once
refreshInterval int
dhcp *dhcp
merlin *merlinDiscover
ubios *ubiosDiscover
arp *arpDiscover
ndp *ndpDiscover
ptr *ptrDiscover
mdns *mdns
hf *hostsFile
vni *virtualNetworkIface
svcCfg ctrld.ServiceConfig
quitCh chan struct{}
stopCh chan struct{}
selfIP string
selfIPLock sync.RWMutex
cdUID string
ptrNameservers []string
}
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{}),
stopCh: make(chan struct{}),
selfIP: selfIP,
cdUID: cdUID,
ptrNameservers: ns,
refreshInterval: refreshInterval,
}
}
@@ -102,26 +121,62 @@ 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)
defer timer.Stop()
timer := time.NewTicker(time.Second * time.Duration(t.refreshInterval))
defer func() {
timer.Stop()
close(t.quitCh)
}()
for {
select {
case <-timer.C:
for _, r := range t.refreshers {
_ = r.refresh()
}
t.Refresh()
case <-t.stopCh:
return
case <-ctx.Done():
close(t.quitCh)
return
}
}
}
// Init initializes all client info discovers.
func (t *Table) Init() {
t.initOnce.Do(t.init)
}
// Refresh forces all discovers to retrieve new data.
func (t *Table) Refresh() {
for _, r := range t.refreshers {
_ = r.refresh()
}
}
// Stop stops all the discovers.
// It blocks until all the discovers done.
func (t *Table) Stop() {
t.stopOnce.Do(func() {
close(t.stopCh)
})
<-t.quitCh
}
// SelfIP returns the selfIP value of the Table in a thread-safe manner.
func (t *Table) SelfIP() string {
t.selfIPLock.RLock()
defer t.selfIPLock.RUnlock()
return t.selfIP
}
// SetSelfIP sets the selfIP value of the Table in a thread-safe manner.
func (t *Table) SetSelfIP(ip string) {
t.selfIPLock.Lock()
defer t.selfIPLock.Unlock()
t.selfIP = ip
t.dhcp.selfIP = t.selfIP
t.dhcp.addSelf()
}
func (t *Table) init() {
// Custom client ID presents, use it as the only source.
if _, clientID := controld.ParseRawUID(t.cdUID); clientID != "" {
@@ -137,14 +192,25 @@ 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().Warn().Err(err).Msgf("failed to init %s discover", platform)
}
t.hostnameResolvers = append(t.hostnameResolvers, discover)
t.refreshers = append(t.refreshers, discover)
}
}
// Hosts file mapping.
@@ -172,17 +238,36 @@ 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)
go t.ndp.subscribe(ctx)
}
// PTR lookup.
if t.discoverPTR() {
@@ -280,6 +365,16 @@ func (t *Table) LookupRFC1918IPv4(mac string) string {
return ""
}
// LocalHostname returns the localhost hostname associated with loopback IP.
func (t *Table) LocalHostname() string {
for _, ip := range []string{ipV4Loopback, ipv6Loopback} {
if name := t.LookupHostname(ip, ""); name != "" {
return name
}
}
return ""
}
type macEntry struct {
mac string
src string
@@ -324,25 +419,34 @@ func (t *Table) lookupHostnameAll(ip, mac string) []*hostnameEntry {
// ListClients returns list of clients discovered by ctrld.
func (t *Table) ListClients() []*Client {
for _, r := range t.refreshers {
_ = r.refresh()
}
t.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 {
if ir == nil {
continue
}
for _, ip := range ir.List() {
c, ok := ipMap[ip]
if !ok {
c = &Client{
IP: netip.MustParseAddr(ip),
Source: map[string]struct{}{ir.String(): {}},
// Validate IP before using MustParseAddr
if addr, err := netip.ParseAddr(ip); err == nil {
c, ok := ipMap[ip]
if !ok {
c = &Client{
IP: addr,
Source: map[string]struct{}{},
}
ipMap[ip] = c
}
// Safely get source name
if src := ir.String(); src != "" {
c.Source[src] = struct{}{}
}
ipMap[ip] = c
} else {
c.Source[ir.String()] = struct{}{}
}
}
}
clientsByMAC := make(map[string]*Client)
for ip := range ipMap {
c := ipMap[ip]
for _, e := range t.lookupMacAll(ip) {
@@ -356,6 +460,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 +469,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"
@@ -12,8 +13,9 @@ import (
"strings"
"sync"
"tailscale.com/net/netmon"
"github.com/fsnotify/fsnotify"
"tailscale.com/net/interfaces"
"tailscale.com/util/lineread"
"github.com/Control-D-Inc/ctrld"
@@ -187,6 +189,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 +206,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 +249,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 +293,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() {
@@ -296,10 +354,10 @@ func (d *dhcp) addSelf() {
return
}
hostname = normalizeHostname(hostname)
d.ip2name.Store("127.0.0.1", hostname)
d.ip2name.Store("::1", hostname)
d.ip2name.Store(ipV4Loopback, hostname)
d.ip2name.Store(ipv6Loopback, hostname)
found := false
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
mac := i.HardwareAddr.String()
// Skip loopback interfaces, info was stored above.
if mac == "" {
@@ -318,15 +376,17 @@ func (d *dhcp) addSelf() {
d.mac.Store(ip.String(), mac)
d.ip.Store(mac, ip.String())
if ip.To4() != nil {
d.mac.Store("127.0.0.1", mac)
d.mac.Store(ipV4Loopback, mac)
} else {
d.mac.Store("::1", mac)
d.mac.Store(ipv6Loopback, mac)
}
d.mac2name.Store(mac, hostname)
d.ip2name.Store(ip.String(), hostname)
// If we have self IP set, and this IP is it, use this IP only.
if ip.String() == d.selfIP {
found = true
d.mac.Store(ipV4Loopback, mac)
d.mac.Store(ipv6Loopback, mac)
}
}
})

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
}
@@ -87,7 +95,7 @@ func (hf *hostsFile) LookupHostnameByIP(ip string) string {
hf.mu.Lock()
defer hf.mu.Unlock()
if names := hf.m[ip]; len(names) > 0 {
isLoopback := ip == "127.0.0.1" || ip == "::1"
isLoopback := ip == ipV4Loopback || ip == ipv6Loopback
for _, hostname := range names {
name := normalizeHostname(hostname)
// Ignoring ipv4/ipv6 loopback entry.
@@ -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"
@@ -87,6 +92,11 @@ func (m *mdns) init(quitCh chan struct{}) error {
return err
}
// Check if IPv6 is available once and use the result for the rest of the function.
ctrld.ProxyLogger.Load().Debug().Msgf("checking for IPv6 availability in mdns init")
ipv6 := ctrldnet.IPv6Available(context.Background())
ctrld.ProxyLogger.Load().Debug().Msgf("IPv6 is %v in mdns init", ipv6)
v4ConnList := make([]*net.UDPConn, 0, len(ifaces))
v6ConnList := make([]*net.UDPConn, 0, len(ifaces))
for _, iface := range ifaces {
@@ -97,7 +107,8 @@ func (m *mdns) init(quitCh chan struct{}) error {
v4ConnList = append(v4ConnList, conn)
go m.readLoop(conn)
}
if ctrldnet.IPv6Available(context.Background()) {
if ipv6 {
if conn, err := net.ListenMulticastUDP("udp6", &iface, mdnsV6Addr); err == nil {
v6ConnList = append(v6ConnList, conn)
go m.readLoop(conn)
@@ -107,6 +118,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
}
@@ -116,8 +128,8 @@ func (m *mdns) probeLoop(conns []*net.UDPConn, remoteAddr net.Addr, quitCh chan
bo := backoff.NewBackoff("mdns probe", func(format string, args ...any) {}, time.Second*30)
for {
err := m.probe(conns, remoteAddr)
if isErrNetUnreachableOrInvalid(err) {
ctrld.ProxyLogger.Load().Warn().Msgf("stop probing %q: network unreachable or invalid", remoteAddr)
if shouldStopProbing(err) {
ctrld.ProxyLogger.Load().Warn().Msgf("stop probing %q: %v", remoteAddr, err)
break
}
if err != nil {
@@ -159,7 +171,7 @@ func (m *mdns) readLoop(conn *net.UDPConn) {
}
var ip, name string
rrs := make([]dns.RR, 0, len(msg.Answer)+len(msg.Extra))
var rrs []dns.RR
rrs = append(rrs, msg.Answer...)
rrs = append(rrs, msg.Extra...)
for _, rr := range rrs {
@@ -212,6 +224,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 {
@@ -229,10 +279,14 @@ func multicastInterfaces() ([]net.Interface, error) {
return interfaces, nil
}
func isErrNetUnreachableOrInvalid(err error) bool {
// shouldStopProbing reports whether ctrld should stop probing mdns.
func shouldStopProbing(err error) bool {
var se *os.SyscallError
if errors.As(err, &se) {
return se.Err == syscall.ENETUNREACH || se.Err == syscall.EINVAL
switch se.Err {
case syscall.ENETUNREACH, syscall.EINVAL, syscall.EPERM:
return true
}
}
return false
}

View File

@@ -67,4 +67,16 @@ var services = [...]string{
// Merlin
"_alexa._tcp",
// Newer Android TV devices
"_androidtvremote2._tcp.local.",
// https://esphome.io/
"_esphomelib._tcp.local.",
// https://www.home-assistant.io/
"_home-assistant._tcp.local.",
// https://kno.wled.ge/
"_wled._tcp.local.",
}

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)
}
}

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

@@ -0,0 +1,249 @@
package clientinfo
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/netip"
"strings"
"sync"
"time"
"github.com/mdlayher/ndp"
"github.com/Control-D-Inc/ctrld"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
// 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
}
// saveInfo saves ip and mac info to mapping table.
func (nd *ndpDiscover) saveInfo(ip, mac string) {
ip = normalizeIP(ip)
// Store ip => map mapping,
nd.mac.Store(ip, mac)
// Do not store mac => ip mapping if new ip is a link local unicast.
if ctrldnet.IsLinkLocalUnicastIPv6(ip) {
return
}
// If there is old ip => mac mapping, delete it.
if old, existed := nd.ip.Load(mac); existed {
oldIP := old.(string)
if oldIP != ip {
nd.mac.Delete(oldIP)
}
}
// Store mac => ip mapping.
nd.ip.Store(mac, ip)
}
// 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) {
ifis, err := allInterfacesWithV6LinkLocal()
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("failed to find valid ipv6 interfaces")
return
}
for _, ifi := range ifis {
go func(ifi *net.Interface) {
nd.listenOnInterface(ctx, ifi)
}(ifi)
}
}
func (nd *ndpDiscover) listenOnInterface(ctx context.Context, ifi *net.Interface) {
c, ip, err := ndp.Listen(ifi, ndp.Unspecified)
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.saveInfo(fromIP, mac)
}
}
}
}
// 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.saveInfo(fields[0], mac)
}
}
}
// 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.saveInfo(ip, mac)
}
}
}
// 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()
}
// allInterfacesWithV6LinkLocal returns all interfaces which is capable of using NDP.
func allInterfacesWithV6LinkLocal() ([]*net.Interface, error) {
ifis, err := net.Interfaces()
if err != nil {
return nil, err
}
res := make([]*net.Interface, 0, len(ifis))
for _, ifi := range ifis {
ifi := ifi
// 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() {
res = append(res, &ifi)
break
}
}
}
return res, nil
}

View File

@@ -0,0 +1,64 @@
package clientinfo
import (
"context"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"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 {
// Skipping non-reachable neighbors.
if n.State&netlink.NUD_REACHABLE == 0 {
continue
}
ip := n.IP.String()
mac := n.HardwareAddr.String()
nd.saveInfo(ip, mac)
}
}
// subscribe watches NDP table changes and update new information to local table.
func (nd *ndpDiscover) subscribe(ctx context.Context) {
ch := make(chan netlink.NeighUpdate)
done := make(chan struct{})
defer close(done)
if err := netlink.NeighSubscribe(ch, done); err != nil {
ctrld.ProxyLogger.Load().Err(err).Msg("could not perform neighbor subscribing")
return
}
for {
select {
case <-ctx.Done():
return
case nu := <-ch:
if nu.Family != netlink.FAMILY_V6 {
continue
}
ip := normalizeIP(nu.IP.String())
if nu.Type == unix.RTM_DELNEIGH {
ctrld.ProxyLogger.Load().Debug().Msgf("removing NDP neighbor: %s", ip)
nd.mac.Delete(ip)
continue
}
mac := nu.HardwareAddr.String()
switch nu.State {
case netlink.NUD_REACHABLE:
nd.saveInfo(ip, mac)
case netlink.NUD_FAILED:
ctrld.ProxyLogger.Load().Debug().Msgf("removing NDP neighbor with failed state: %s", ip)
nd.mac.Delete(ip)
}
}
}
}

View File

@@ -0,0 +1,36 @@
//go:build !linux
package clientinfo
import (
"bytes"
"context"
"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))
}
}
// subscribe watches NDP table changes and update new information to local table.
// This is a stub method, and only works on Linux at this moment.
func (nd *ndpDiscover) subscribe(ctx context.Context) {}

View File

@@ -0,0 +1,66 @@
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
expectedCount := 6
nd.mac.Range(func(key, value any) bool {
count++
return true
})
if count != expectedCount {
t.Errorf("unexpected count, want %d, got: %d", expectedCount, count)
}
count = 0
expectedCount = 4
nd.ip.Range(func(key, value any) bool {
count++
return true
})
if count != expectedCount {
t.Errorf("unexpected count, want %d, got: %d", expectedCount, count)
}
}

View File

@@ -0,0 +1,79 @@
package clientinfo
import (
"bytes"
"encoding/json"
"fmt"
"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.CombinedOutput()
if err != nil {
return fmt.Errorf("out: %s, err: %w", string(b), 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

@@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -24,19 +25,27 @@ import (
const (
apiDomainCom = "api.controld.com"
apiDomainDev = "api.controld.dev"
resolverDataURLCom = "https://api.controld.com/utility"
resolverDataURLDev = "https://api.controld.dev/utility"
InvalidConfigCode = 40401
apiURLCom = "https://api.controld.com"
apiURLDev = "https://api.controld.dev"
resolverDataURLCom = apiURLCom + "/utility"
resolverDataURLDev = apiURLDev + "/utility"
logURLCom = apiURLCom + "/logs"
logURLDev = apiURLDev + "/logs"
InvalidConfigCode = 40402
defaultTimeout = 20 * time.Second
sendLogTimeout = 300 * time.Second
)
// ResolverConfig represents Control D resolver data.
type ResolverConfig struct {
DOH string `json:"doh"`
Ctrld struct {
CustomConfig string `json:"custom_config"`
CustomConfig string `json:"custom_config"`
CustomLastUpdate int64 `json:"custom_last_update"`
} `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 {
@@ -46,14 +55,14 @@ type utilityResponse struct {
} `json:"body"`
}
type UtilityErrorResponse struct {
type ErrorResponse struct {
ErrorField struct {
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
func (u UtilityErrorResponse) Error() string {
func (u ErrorResponse) Error() string {
return u.ErrorField.Message
}
@@ -62,11 +71,18 @@ type utilityRequest struct {
ClientID string `json:"client_id,omitempty"`
}
type utilityOrgRequest struct {
// UtilityOrgRequest contains request data for calling Org API.
type UtilityOrgRequest struct {
ProvToken string `json:"prov_token"`
Hostname string `json:"hostname"`
}
// LogsRequest contains request data for sending runtime logs to API.
type LogsRequest struct {
UID string `json:"uid"`
Data io.ReadCloser `json:"-"`
}
// FetchResolverConfig fetch Control D config for given uid.
func FetchResolverConfig(rawUID, version string, cdDev bool) (*ResolverConfig, error) {
uid, clientID := ParseRawUID(rawUID)
@@ -75,17 +91,34 @@ func FetchResolverConfig(rawUID, version string, cdDev bool) (*ResolverConfig, e
req.ClientID = clientID
}
body, _ := json.Marshal(req)
return postUtilityAPI(version, cdDev, bytes.NewReader(body))
return postUtilityAPI(version, cdDev, false, bytes.NewReader(body))
}
// FetchResolverUID fetch resolver uid from provision token.
func FetchResolverUID(pt, version string, cdDev bool) (*ResolverConfig, error) {
hostname, _ := os.Hostname()
body, _ := json.Marshal(utilityOrgRequest{ProvToken: pt, Hostname: hostname})
return postUtilityAPI(version, cdDev, bytes.NewReader(body))
func FetchResolverUID(req *UtilityOrgRequest, version string, cdDev bool) (*ResolverConfig, error) {
if req == nil {
return nil, errors.New("invalid request")
}
hostname := req.Hostname
if hostname == "" {
hostname, _ = os.Hostname()
}
body, _ := json.Marshal(UtilityOrgRequest{ProvToken: req.ProvToken, Hostname: hostname})
return postUtilityAPI(version, cdDev, false, bytes.NewReader(body))
}
func postUtilityAPI(version string, cdDev bool, body io.Reader) (*ResolverConfig, error) {
// UpdateCustomLastFailed calls API to mark custom config is bad.
func UpdateCustomLastFailed(rawUID, version string, cdDev, lastUpdatedFailed bool) (*ResolverConfig, error) {
uid, clientID := ParseRawUID(rawUID)
req := utilityRequest{UID: uid}
if clientID != "" {
req.ClientID = clientID
}
body, _ := json.Marshal(req)
return postUtilityAPI(version, cdDev, true, bytes.NewReader(body))
}
func postUtilityAPI(version string, cdDev, lastUpdatedFailed bool, body io.Reader) (*ResolverConfig, error) {
apiUrl := resolverDataURLCom
if cdDev {
apiUrl = resolverDataURLDev
@@ -97,44 +130,24 @@ func postUtilityAPI(version string, cdDev bool, body io.Reader) (*ResolverConfig
q := req.URL.Query()
q.Set("platform", "ctrld")
q.Set("version", version)
if lastUpdatedFailed {
q.Set("custom_last_failed", "1")
}
req.URL.RawQuery = q.Encode()
req.Header.Add("Content-Type", "application/json")
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
apiDomain := apiDomainCom
if cdDev {
apiDomain = apiDomainDev
}
ips := ctrld.LookupIP(apiDomain)
if len(ips) == 0 {
ctrld.ProxyLogger.Load().Warn().Msgf("No IPs found for %s, connecting to %s", apiDomain, addr)
return ctrldnet.Dialer.DialContext(ctx, network, addr)
}
ctrld.ProxyLogger.Load().Debug().Msgf("API IPs: %v", ips)
_, port, _ := net.SplitHostPort(addr)
addrs := make([]string, len(ips))
for i := range ips {
addrs[i] = net.JoinHostPort(ips[i], port)
}
d := &ctrldnet.ParallelDialer{}
return d.DialContext(ctx, network, addrs)
}
if router.Name() == ddwrt.Name || runtime.GOOS == "android" {
transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool()}
}
transport := apiTransport(cdDev)
client := http.Client{
Timeout: 10 * time.Second,
Timeout: defaultTimeout,
Transport: transport,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("client.Do: %w", err)
return nil, fmt.Errorf("postUtilityAPI client.Do: %w", err)
}
defer resp.Body.Close()
d := json.NewDecoder(resp.Body)
if resp.StatusCode != http.StatusOK {
errResp := &UtilityErrorResponse{}
errResp := &ErrorResponse{}
if err := d.Decode(errResp); err != nil {
return nil, err
}
@@ -148,6 +161,43 @@ func postUtilityAPI(version string, cdDev bool, body io.Reader) (*ResolverConfig
return &ur.Body.Resolver, nil
}
// SendLogs sends runtime log to ControlD API.
func SendLogs(lr *LogsRequest, cdDev bool) error {
defer lr.Data.Close()
apiUrl := logURLCom
if cdDev {
apiUrl = logURLDev
}
req, err := http.NewRequest("POST", apiUrl, lr.Data)
if err != nil {
return fmt.Errorf("http.NewRequest: %w", err)
}
q := req.URL.Query()
q.Set("uid", lr.UID)
req.URL.RawQuery = q.Encode()
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
transport := apiTransport(cdDev)
client := http.Client{
Timeout: sendLogTimeout,
Transport: transport,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("SendLogs client.Do: %w", err)
}
defer resp.Body.Close()
d := json.NewDecoder(resp.Body)
if resp.StatusCode != http.StatusOK {
errResp := &ErrorResponse{}
if err := d.Decode(errResp); err != nil {
return err
}
return errResp
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// ParseRawUID parse the input raw UID, returning real UID and ClientID.
// The raw UID can have 2 forms:
//
@@ -157,3 +207,67 @@ func ParseRawUID(rawUID string) (string, string) {
uid, clientID, _ := strings.Cut(rawUID, "/")
return uid, clientID
}
// apiTransport returns an HTTP transport for connecting to ControlD API endpoint.
func apiTransport(cdDev bool) *http.Transport {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
apiDomain := apiDomainCom
if cdDev {
apiDomain = apiDomainDev
}
// First try IPv4
dialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
ips := ctrld.LookupIP(apiDomain)
if len(ips) == 0 {
ctrld.ProxyLogger.Load().Warn().Msgf("No IPs found for %s, falling back to direct connection to %s", apiDomain, addr)
return dialer.DialContext(ctx, network, addr)
}
// Separate IPv4 and IPv6 addresses
var ipv4s, ipv6s []string
for _, ip := range ips {
if strings.Contains(ip, ":") {
ipv6s = append(ipv6s, ip)
} else {
ipv4s = append(ipv4s, ip)
}
}
_, port, _ := net.SplitHostPort(addr)
// Try IPv4 first
if len(ipv4s) > 0 {
addrs := make([]string, len(ipv4s))
for i, ip := range ipv4s {
addrs[i] = net.JoinHostPort(ip, port)
}
d := &ctrldnet.ParallelDialer{}
if conn, err := d.DialContext(ctx, "tcp4", addrs, ctrld.ProxyLogger.Load()); err == nil {
return conn, nil
}
}
// Fall back to IPv6 if available
if len(ipv6s) > 0 {
addrs := make([]string, len(ipv6s))
for i, ip := range ipv6s {
addrs[i] = net.JoinHostPort(ip, port)
}
d := &ctrldnet.ParallelDialer{}
return d.DialContext(ctx, "tcp6", addrs, ctrld.ProxyLogger.Load())
}
// Final fallback to direct connection
return dialer.DialContext(ctx, network, addr)
}
if router.Name() == ddwrt.Name || runtime.GOOS == "android" {
transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool()}
}
return transport
}

View File

@@ -1,2 +1,2 @@
This is a fork of https://pkg.go.dev/tailscale.com@v1.34.2/net/dns with modification
This is a fork of https://pkg.go.dev/tailscale.com@v1.74.0/net/dns with modification
to fit ctrld use case.

View File

@@ -1,12 +1,12 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || freebsd || openbsd
package dns
import (
"bufio"
"bytes"
_ "embed"
"fmt"
@@ -33,7 +33,7 @@ var workaroundScript []byte
// resolvconf implementations encourage adding a suffix roughly
// indicating where the config came from, and "inet" is the "none of
// the above" value (rather than, say, "ppp" or "dhcp").
const resolvconfConfigName = "ctrld.inet"
const resolvconfConfigName = "tun-ctrld.inet"
// resolvconfLibcHookPath is the directory containing libc update
// scripts, which are run by Debian resolvconf when /etc/resolv.conf
@@ -53,8 +53,6 @@ type resolvconfManager struct {
scriptInstalled bool // libc update script has been installed
}
var _ OSConfigurator = (*resolvconfManager)(nil)
func newDebianResolvconfManager(logf logger.Logf) (*resolvconfManager, error) {
ret := &resolvconfManager{
logf: logf,
@@ -135,6 +133,43 @@ func (m *resolvconfManager) SetDNS(config OSConfig) error {
return nil
}
func (m *resolvconfManager) SupportsSplitDNS() bool {
return false
}
func (m *resolvconfManager) GetBaseConfig() (OSConfig, error) {
var bs bytes.Buffer
cmd := exec.Command(m.listRecordsPath)
// list-records assumes it's being run with CWD set to the
// interfaces runtime dir, and returns nonsense otherwise.
cmd.Dir = m.interfacesDir
cmd.Stdout = &bs
if err := cmd.Run(); err != nil {
return OSConfig{}, err
}
var conf bytes.Buffer
sc := bufio.NewScanner(&bs)
for sc.Scan() {
if sc.Text() == resolvconfConfigName {
continue
}
bs, err := os.ReadFile(filepath.Join(m.interfacesDir, sc.Text()))
if err != nil {
if os.IsNotExist(err) {
// Probably raced with a deletion, that's okay.
continue
}
return OSConfig{}, err
}
conf.Write(bs)
conf.WriteByte('\n')
}
return readResolv(&conf)
}
func (m *resolvconfManager) Close() error {
if err := m.deleteCtrldConfig(); err != nil {
return err

View File

@@ -1,9 +1,5 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//lint:file-ignore U1000 Ignore, this file is forked from upstream code.
//lint:file-ignore ST1005 Ignore, this file is forked from upstream code.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -20,11 +16,13 @@ import (
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"tailscale.com/health"
"tailscale.com/net/tsaddr"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
"tailscale.com/version/distro"
@@ -32,11 +30,6 @@ import (
"github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile"
)
const (
backupConf = "/etc/resolv.pre-ctrld-backup.conf"
resolvConf = "/etc/resolv.conf"
)
// writeResolvConf writes DNS configuration in resolv.conf format to the given writer.
func writeResolvConf(w io.Writer, servers []netip.Addr, domains []dnsname.FQDN) error {
c := &resolvconffile.Config{
@@ -60,6 +53,8 @@ func readResolv(r io.Reader) (OSConfig, error) {
// resolvOwner returns the apparent owner of the resolv.conf
// configuration in bs - one of "resolvconf", "systemd-resolved" or
// "NetworkManager", or "" if no known owner was found.
//
//lint:ignore U1000 used in linux and freebsd code
func resolvOwner(bs []byte) string {
likely := ""
b := bytes.NewBuffer(bs)
@@ -123,8 +118,9 @@ func restartResolved() error {
// The caller must call Down before program shutdown
// or as cleanup if the program terminates unexpectedly.
type directManager struct {
logf logger.Logf
fs wholeFileFS
logf logger.Logf
health *health.Tracker
fs wholeFileFS
// renameBroken is set if fs.Rename to or from /etc/resolv.conf
// fails. This can happen in some container runtimes, where
// /etc/resolv.conf is bind-mounted from outside the container,
@@ -140,19 +136,22 @@ type directManager struct {
ctx context.Context // valid until Close
ctxClose context.CancelFunc // closes ctx
mu sync.Mutex
wantResolvConf []byte // if non-nil, what we expect /etc/resolv.conf to contain
mu sync.Mutex
wantResolvConf []byte // if non-nil, what we expect /etc/resolv.conf to contain
//lint:ignore U1000 used in direct_linux.go
lastWarnContents []byte // last resolv.conf contents that we warned about
}
func newDirectManager(logf logger.Logf) *directManager {
return newDirectManagerOnFS(logf, directFS{})
//lint:ignore U1000 used in manager_{freebsd,openbsd}.go
func newDirectManager(logf logger.Logf, health *health.Tracker) *directManager {
return newDirectManagerOnFS(logf, health, directFS{})
}
func newDirectManagerOnFS(logf logger.Logf, fs wholeFileFS) *directManager {
func newDirectManagerOnFS(logf logger.Logf, health *health.Tracker, fs wholeFileFS) *directManager {
ctx, cancel := context.WithCancel(context.Background())
m := &directManager{
logf: logf,
health: health,
fs: fs,
ctx: ctx,
ctxClose: cancel,
@@ -193,13 +192,13 @@ func (m *directManager) ownedByCtrld() (bool, error) {
}
// backupConfig creates or updates a backup of /etc/resolv.conf, if
// resolv.conf does not currently contain a Tailscale-managed config.
// resolv.conf does not currently contain a ctrld-managed config.
func (m *directManager) backupConfig() error {
if _, err := m.fs.Stat(resolvConf); err != nil {
if os.IsNotExist(err) {
// No resolv.conf, nothing to back up. Also get rid of any
// existing backup file, to avoid restoring something old.
_ = m.fs.Remove(backupConf)
m.fs.Remove(backupConf)
return nil
}
return err
@@ -237,7 +236,7 @@ func (m *directManager) restoreBackup() (restored bool, err error) {
if resolvConfExists && !owned {
// There's already a non-ctrld config in place, get rid of
// our backup.
_ = m.fs.Remove(backupConf)
m.fs.Remove(backupConf)
return false, nil
}
@@ -278,6 +277,14 @@ func (m *directManager) rename(old, new string) error {
return fmt.Errorf("writing to %q in rename of %q: %w", new, old, err)
}
// Explicitly set the permissions on the new file. This ensures that
// if we have a umask set which prevents creating world-readable files,
// the file will still have the correct permissions once it's renamed
// into place. See #12609.
if err := m.fs.Chmod(new, 0644); err != nil {
return fmt.Errorf("chmod %q in rename of %q: %w", new, old, err)
}
if err := m.fs.Remove(old); err != nil {
err2 := m.fs.Truncate(old)
if err2 != nil {
@@ -298,53 +305,6 @@ func (m *directManager) setWant(want []byte) {
m.wantResolvConf = want
}
var warnTrample = health.NewWarnable()
// checkForFileTrample checks whether /etc/resolv.conf has been trampled
// by another program on the system. (e.g. a DHCP client)
func (m *directManager) checkForFileTrample() {
m.mu.Lock()
want := m.wantResolvConf
lastWarn := m.lastWarnContents
m.mu.Unlock()
if want == nil {
return
}
cur, err := m.fs.ReadFile(resolvConf)
if err != nil {
m.logf("trample: read error: %v", err)
return
}
if bytes.Equal(cur, want) {
warnTrample.Set(nil)
if lastWarn != nil {
m.mu.Lock()
m.lastWarnContents = nil
m.mu.Unlock()
m.logf("trample: resolv.conf again matches expected content")
}
return
}
if bytes.Equal(cur, lastWarn) {
// We already logged about this, so not worth doing it again.
return
}
m.mu.Lock()
m.lastWarnContents = cur
m.mu.Unlock()
show := cur
if len(show) > 1024 {
show = show[:1024]
}
m.logf("trample: resolv.conf changed from what we expected. did some other program interfere? current contents: %q", show)
//lint:ignore ST1005 This error is for human.
warnTrample.Set(errors.New("Linux DNS config not ideal. /etc/resolv.conf overwritten. See https://tailscale.com/s/dns-fight"))
}
func (m *directManager) SetDNS(config OSConfig) (err error) {
defer func() {
if err != nil && errors.Is(err, fs.ErrPermission) && runtime.GOOS == "linux" &&
@@ -370,7 +330,7 @@ func (m *directManager) SetDNS(config OSConfig) (err error) {
}
buf := new(bytes.Buffer)
_ = writeResolvConf(buf, config.Nameservers, config.SearchDomains)
writeResolvConf(buf, config.Nameservers, config.SearchDomains)
if err := m.atomicWriteFile(m.fs, resolvConf, buf.Bytes(), 0644); err != nil {
return err
}
@@ -411,12 +371,57 @@ func (m *directManager) SetDNS(config OSConfig) (err error) {
return nil
}
func (m *directManager) SupportsSplitDNS() bool {
return false
}
func (m *directManager) GetBaseConfig() (OSConfig, error) {
owned, err := m.ownedByCtrld()
if err != nil {
return OSConfig{}, err
}
fileToRead := resolvConf
if owned {
fileToRead = backupConf
}
oscfg, err := m.readResolvFile(fileToRead)
if err != nil {
return OSConfig{}, err
}
// On some systems, the backup configuration file is actually a
// symbolic link to something owned by another DNS service (commonly,
// resolved). Thus, it can be updated out from underneath us to contain
// the Tailscale service IP, which results in an infinite loop of us
// trying to send traffic to resolved, which sends back to us, and so
// on. To solve this, drop the Tailscale service IP from the base
// configuration; we do this in all situations since there's
// essentially no world where we want to forward to ourselves.
//
// See: https://github.com/tailscale/tailscale/issues/7816
var removed bool
oscfg.Nameservers = slices.DeleteFunc(oscfg.Nameservers, func(ip netip.Addr) bool {
if ip == tsaddr.TailscaleServiceIP() || ip == tsaddr.TailscaleServiceIPv6() {
removed = true
return true
}
return false
})
if removed {
m.logf("[v1] dropped Tailscale IP from base config that was a symlink")
}
return oscfg, nil
}
func (m *directManager) Close() error {
// We used to keep a file for the ctrld config and symlinked
m.ctxClose()
// We used to keep a file for the tailscale config and symlinked
// to it, but then we stopped because /etc/resolv.conf being a
// symlink to surprising places breaks snaps and other sandboxing
// things. Clean it up if it's still there.
_ = m.fs.Remove("/etc/resolv.ctrld.conf")
m.fs.Remove("/etc/resolv.ctrld.conf")
if _, err := m.fs.Stat(backupConf); err != nil {
if os.IsNotExist(err) {
@@ -436,9 +441,9 @@ func (m *directManager) Close() error {
resolvConfExists := !os.IsNotExist(err)
if resolvConfExists && !owned {
// There's already a non-ctrld config in place, get rid of
// There's already a non-tailscale config in place, get rid of
// our backup.
_ = m.fs.Remove(backupConf)
m.fs.Remove(backupConf)
return nil
}
@@ -475,6 +480,14 @@ func (m *directManager) atomicWriteFile(fs wholeFileFS, filename string, data []
if err := fs.WriteFile(tmpName, data, perm); err != nil {
return fmt.Errorf("atomicWriteFile: %w", err)
}
// Explicitly set the permissions on the temporary file before renaming
// it. This ensures that if we have a umask set which prevents creating
// world-readable files, the file will still have the correct
// permissions once it's renamed into place. See #12609.
if err := fs.Chmod(tmpName, perm); err != nil {
return fmt.Errorf("atomicWriteFile: Chmod: %w", err)
}
return m.rename(tmpName, filename)
}
@@ -483,10 +496,11 @@ func (m *directManager) atomicWriteFile(fs wholeFileFS, filename string, data []
//
// All name parameters are absolute paths.
type wholeFileFS interface {
Stat(name string) (isRegular bool, err error)
Rename(oldName, newName string) error
Remove(name string) error
Chmod(name string, mode os.FileMode) error
ReadFile(name string) ([]byte, error)
Remove(name string) error
Rename(oldName, newName string) error
Stat(name string) (isRegular bool, err error)
Truncate(name string) error
WriteFile(name string, contents []byte, perm os.FileMode) error
}
@@ -510,6 +524,10 @@ func (fs directFS) Stat(name string) (isRegular bool, err error) {
return fi.Mode().IsRegular(), nil
}
func (fs directFS) Chmod(name string, mode os.FileMode) error {
return os.Chmod(fs.path(name), mode)
}
func (fs directFS) Rename(oldName, newName string) error {
return os.Rename(fs.path(oldName), fs.path(newName))
}

View File

@@ -1,26 +1,26 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dns
import (
"bytes"
"context"
"github.com/illarion/gonotify"
"github.com/illarion/gonotify/v2"
"tailscale.com/health"
)
func (m *directManager) runFileWatcher() {
in, err := gonotify.NewInotify()
ctx, cancel := context.WithCancel(m.ctx)
defer cancel()
in, err := gonotify.NewInotify(ctx)
if err != nil {
// Oh well, we tried. This is all best effort for now, to
// surface warnings to users.
m.logf("dns: inotify new: %v", err)
return
}
ctx, cancel := context.WithCancel(m.ctx)
defer cancel()
go m.closeInotifyOnDone(ctx, in)
const events = gonotify.IN_ATTRIB |
gonotify.IN_CLOSE_WRITE |
@@ -56,7 +56,53 @@ func (m *directManager) runFileWatcher() {
}
}
func (m *directManager) closeInotifyOnDone(ctx context.Context, in *gonotify.Inotify) {
<-ctx.Done()
_ = in.Close()
var resolvTrampleWarnable = health.Register(&health.Warnable{
Code: "ctrld-resolv-conf-overwritten",
Severity: health.SeverityMedium,
Title: "Linux DNS configuration issue",
Text: health.StaticMessage("Linux DNS config not ideal. /etc/resolv.conf overwritten. See https://tailscale.com/s/dns-fight"),
})
// checkForFileTrample checks whether /etc/resolv.conf has been trampled
// by another program on the system. (e.g. a DHCP client)
func (m *directManager) checkForFileTrample() {
m.mu.Lock()
want := m.wantResolvConf
lastWarn := m.lastWarnContents
m.mu.Unlock()
if want == nil {
return
}
cur, err := m.fs.ReadFile(resolvConf)
if err != nil {
m.logf("trample: read error: %v", err)
return
}
if bytes.Equal(cur, want) {
m.health.SetHealthy(resolvTrampleWarnable)
if lastWarn != nil {
m.mu.Lock()
m.lastWarnContents = nil
m.mu.Unlock()
m.logf("trample: resolv.conf again matches expected content")
}
return
}
if bytes.Equal(cur, lastWarn) {
// We already logged about this, so not worth doing it again.
return
}
m.mu.Lock()
m.lastWarnContents = cur
m.mu.Unlock()
show := cur
if len(show) > 1024 {
show = show[:1024]
}
m.logf("trample: resolv.conf changed from what we expected. did some other program interfere? current contents: %q", show)
m.health.SetUnhealthy(resolvTrampleWarnable, nil)
}

View File

@@ -1,6 +1,5 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux

View File

@@ -1,10 +1,10 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dns
import (
"context"
"errors"
"fmt"
"io/fs"
@@ -79,7 +79,10 @@ func testDirect(t *testing.T, fs wholeFileFS) {
}
}
m := directManager{logf: t.Logf, fs: fs}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
m := directManager{logf: t.Logf, fs: fs, ctx: ctx, ctxClose: cancel}
if err := m.SetDNS(OSConfig{
Nameservers: []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("8.8.4.4")},
SearchDomains: []dnsname.FQDN{"controld.com."},
@@ -121,7 +124,7 @@ type brokenRemoveFS struct {
directFS
}
func (b brokenRemoveFS) Rename(_, _ string) error {
func (b brokenRemoveFS) Rename(old, new string) error {
return errors.New("nyaaah I'm a silly container!")
}
@@ -178,12 +181,12 @@ func TestReadResolve(t *testing.T) {
SearchDomains: []dnsname.FQDN{"controld.com."},
},
},
{in: `search controld.com # typo`,
{in: `search controld.com # comment`,
want: OSConfig{
SearchDomains: []dnsname.FQDN{"controld.com."},
},
},
{in: `searchcontrold.com`, wantErr: true},
{in: `searchctrld.com`, wantErr: true},
{in: `search`, wantErr: true},
}

View File

@@ -1,6 +1,5 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -8,13 +7,18 @@ import (
"fmt"
"os"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/types/logger"
)
func NewOSConfigurator(logf logger.Logf, _ string) (OSConfigurator, error) {
// NewOSConfigurator creates a new OS configurator.
//
// The health tracker may be nil; the knobs may be nil and are ignored on this platform.
func NewOSConfigurator(logf logger.Logf, health *health.Tracker, _ *controlknobs.Knobs, _ string) (OSConfigurator, error) {
bs, err := os.ReadFile("/etc/resolv.conf")
if os.IsNotExist(err) {
return newDirectManager(logf), nil
return newDirectManager(logf, health), nil
}
if err != nil {
return nil, fmt.Errorf("reading /etc/resolv.conf: %w", err)
@@ -24,16 +28,16 @@ func NewOSConfigurator(logf logger.Logf, _ string) (OSConfigurator, error) {
case "resolvconf":
switch resolvconfStyle() {
case "":
return newDirectManager(logf), nil
return newDirectManager(logf, health), nil
case "debian":
return newDebianResolvconfManager(logf)
case "openresolv":
return newOpenresolvManager()
return newOpenresolvManager(logf)
default:
logf("[unexpected] got unknown flavor of resolvconf %q, falling back to direct manager", resolvconfStyle())
return newDirectManager(logf), nil
return newDirectManager(logf, health), nil
}
default:
return newDirectManager(logf), nil
return newDirectManager(logf, health), nil
}
}

View File

@@ -1,8 +1,5 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//lint:file-ignore U1000 Ignore this file, it's a copy.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -17,6 +14,7 @@ import (
"time"
"github.com/godbus/dbus/v5"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/net/netaddr"
"tailscale.com/types/logger"
@@ -38,7 +36,10 @@ func (kv kv) String() string {
var publishOnce sync.Once
func NewOSConfigurator(logf logger.Logf, interfaceName string) (ret OSConfigurator, err error) {
// NewOSConfigurator created a new OS configurator.
//
// The health tracker may be nil; the knobs may be nil and are ignored on this platform.
func NewOSConfigurator(logf logger.Logf, health *health.Tracker, _ *controlknobs.Knobs, interfaceName string) (ret OSConfigurator, err error) {
env := newOSConfigEnv{
fs: directFS{},
dbusPing: dbusPing,
@@ -47,7 +48,7 @@ func NewOSConfigurator(logf logger.Logf, interfaceName string) (ret OSConfigurat
nmVersionBetween: nmVersionBetween,
resolvconfStyle: resolvconfStyle,
}
mode, err := dnsMode(logf, env)
mode, err := dnsMode(logf, health, env)
if err != nil {
return nil, err
}
@@ -59,18 +60,18 @@ func NewOSConfigurator(logf logger.Logf, interfaceName string) (ret OSConfigurat
logf("dns: using %q mode", mode)
switch mode {
case "direct":
return newDirectManagerOnFS(logf, env.fs), nil
return newDirectManagerOnFS(logf, health, env.fs), nil
case "systemd-resolved":
return newResolvedManager(logf, interfaceName)
return newResolvedManager(logf, health, interfaceName)
case "network-manager":
return newNMManager(interfaceName)
case "debian-resolvconf":
return newDebianResolvconfManager(logf)
case "openresolv":
return newOpenresolvManager()
return newOpenresolvManager(logf)
default:
logf("[unexpected] detected unknown DNS mode %q, using direct manager as last resort", mode)
return newDirectManagerOnFS(logf, env.fs), nil
return newDirectManagerOnFS(logf, health, env.fs), nil
}
}
@@ -84,7 +85,7 @@ type newOSConfigEnv struct {
resolvconfStyle func() string
}
func dnsMode(logf logger.Logf, env newOSConfigEnv) (ret string, err error) {
func dnsMode(logf logger.Logf, health *health.Tracker, env newOSConfigEnv) (ret string, err error) {
var debug []kv
dbg := func(k, v string) {
debug = append(debug, kv{k, v})
@@ -145,7 +146,7 @@ func dnsMode(logf logger.Logf, env newOSConfigEnv) (ret string, err error) {
// header, but doesn't actually point to resolved. We mustn't
// try to program resolved in that case.
// https://github.com/tailscale/tailscale/issues/2136
if err := resolvedIsActuallyResolver(bs); err != nil {
if err := resolvedIsActuallyResolver(logf, env, dbg, bs); err != nil {
logf("dns: resolvedIsActuallyResolver error: %v", err)
dbg("resolved", "not-in-use")
return "direct", nil
@@ -231,7 +232,7 @@ func dnsMode(logf logger.Logf, env newOSConfigEnv) (ret string, err error) {
dbg("rc", "nm")
// Sometimes, NetworkManager owns the configuration but points
// it at systemd-resolved.
if err := resolvedIsActuallyResolver(bs); err != nil {
if err := resolvedIsActuallyResolver(logf, env, dbg, bs); err != nil {
logf("dns: resolvedIsActuallyResolver error: %v", err)
dbg("resolved", "not-in-use")
// You'd think we would use newNMManager here. However, as
@@ -271,6 +272,14 @@ func dnsMode(logf logger.Logf, env newOSConfigEnv) (ret string, err error) {
dbg("nm-safe", "yes")
return "network-manager", nil
}
if err := env.nmIsUsingResolved(); err != nil {
// If systemd-resolved is not running at all, then we don't have any
// other choice: we take direct control of DNS.
dbg("nm-resolved", "no")
return "direct", nil
}
//lint:ignore SA1019 upstream code still use it.
health.SetDNSManagerHealth(errors.New("systemd-resolved and NetworkManager are wired together incorrectly; MagicDNS will probably not work. For more info, see https://tailscale.com/s/resolved-nm"))
dbg("nm-safe", "no")
return "systemd-resolved", nil
@@ -324,14 +333,23 @@ func nmIsUsingResolved() error {
return nil
}
// resolvedIsActuallyResolver reports whether the given resolv.conf
// bytes describe a configuration where systemd-resolved (127.0.0.53)
// is the only configured nameserver.
// resolvedIsActuallyResolver reports whether the system is using
// systemd-resolved as the resolver. There are two different ways to
// use systemd-resolved:
// - libnss_resolve, which requires adding `resolve` to the "hosts:"
// line in /etc/nsswitch.conf
// - setting the only nameserver configured in `resolv.conf` to
// systemd-resolved IP (127.0.0.53)
//
// Returns an error if the configuration is something other than
// exclusively systemd-resolved, or nil if the config is only
// systemd-resolved.
func resolvedIsActuallyResolver(bs []byte) error {
func resolvedIsActuallyResolver(logf logger.Logf, env newOSConfigEnv, dbg func(k, v string), bs []byte) error {
if err := isLibnssResolveUsed(env); err == nil {
dbg("resolved", "nss")
return nil
}
cfg, err := readResolv(bytes.NewBuffer(bs))
if err != nil {
return err
@@ -348,9 +366,34 @@ func resolvedIsActuallyResolver(bs []byte) error {
return fmt.Errorf("resolv.conf doesn't point to systemd-resolved; points to %v", cfg.Nameservers)
}
}
dbg("resolved", "file")
return nil
}
// isLibnssResolveUsed reports whether libnss_resolve is used
// for resolving names. Returns nil if it is, and an error otherwise.
func isLibnssResolveUsed(env newOSConfigEnv) error {
bs, err := env.fs.ReadFile("/etc/nsswitch.conf")
if err != nil {
return fmt.Errorf("reading /etc/resolv.conf: %w", err)
}
for _, line := range strings.Split(string(bs), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != "hosts:" {
continue
}
for _, module := range fields[1:] {
if module == "dns" {
return fmt.Errorf("dns with a higher priority than libnss_resolve")
}
if module == "resolve" {
return nil
}
}
}
return fmt.Errorf("libnss_resolve not used")
}
func dbusPing(name, objectPath string) error {
conn, err := dbus.SystemBus()
if err != nil {

View File

@@ -1,6 +1,5 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -71,7 +70,7 @@ func TestLinuxDNSMode(t *testing.T) {
{
name: "resolved_alone_without_ping",
env: env(resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53")),
wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=resolved nm=no resolv-conf-mode=error ret=systemd-resolved]",
wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=resolved resolved=file nm=no resolv-conf-mode=error ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -79,16 +78,46 @@ func TestLinuxDNSMode(t *testing.T) {
env: env(
resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"),
resolvedRunning()),
wantLog: "dns: [resolved-ping=yes rc=resolved nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
name: "resolved_and_nsswitch_resolve",
env: env(
resolvDotConf("# Managed by systemd-resolved", "nameserver 1.1.1.1"),
resolvedRunning(),
nsswitchDotConf("hosts: files resolve [!UNAVAIL=return] dns"),
),
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=nss nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
name: "resolved_and_nsswitch_dns",
env: env(
resolvDotConf("# Managed by systemd-resolved", "nameserver 1.1.1.1"),
resolvedRunning(),
nsswitchDotConf("hosts: files dns resolve [!UNAVAIL=return]"),
),
wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [1.1.1.1]\ndns: [resolved-ping=yes rc=resolved resolved=not-in-use ret=direct]",
want: "direct",
},
{
name: "resolved_and_nsswitch_none",
env: env(
resolvDotConf("# Managed by systemd-resolved", "nameserver 1.1.1.1"),
resolvedRunning(),
nsswitchDotConf("hosts:"),
),
wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [1.1.1.1]\ndns: [resolved-ping=yes rc=resolved resolved=not-in-use ret=direct]",
want: "direct",
},
{
name: "resolved_and_networkmanager_not_using_resolved",
env: env(
resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"),
resolvedRunning(),
nmRunning("1.2.3", false)),
wantLog: "dns: [resolved-ping=yes rc=resolved nm=yes nm-resolved=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=yes nm-resolved=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -97,7 +126,7 @@ func TestLinuxDNSMode(t *testing.T) {
resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"),
resolvedRunning(),
nmRunning("1.26.2", true)),
wantLog: "dns: [resolved-ping=yes rc=resolved nm=yes nm-resolved=yes nm-safe=yes ret=network-manager]",
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=yes nm-resolved=yes nm-safe=yes ret=network-manager]",
want: "network-manager",
},
{
@@ -106,7 +135,7 @@ func TestLinuxDNSMode(t *testing.T) {
resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"),
resolvedRunning(),
nmRunning("1.27.0", true)),
wantLog: "dns: [resolved-ping=yes rc=resolved nm=yes nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=yes nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -115,7 +144,7 @@ func TestLinuxDNSMode(t *testing.T) {
resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"),
resolvedRunning(),
nmRunning("1.22.0", true)),
wantLog: "dns: [resolved-ping=yes rc=resolved nm=yes nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=yes nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
// Regression tests for extreme corner cases below.
@@ -141,7 +170,7 @@ func TestLinuxDNSMode(t *testing.T) {
"nameserver 127.0.0.53",
"nameserver 127.0.0.53"),
resolvedRunning()),
wantLog: "dns: [resolved-ping=yes rc=resolved nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -156,7 +185,7 @@ func TestLinuxDNSMode(t *testing.T) {
"# run \"systemd-resolve --status\" to see details about the actual nameservers.",
"nameserver 127.0.0.53"),
resolvedRunning()),
wantLog: "dns: [resolved-ping=yes rc=resolved nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -171,7 +200,7 @@ func TestLinuxDNSMode(t *testing.T) {
"# 127.0.0.53 is the systemd-resolved stub resolver.",
"# run \"systemd-resolve --status\" to see details about the actual nameservers.",
"nameserver 127.0.0.53")),
wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=resolved nm=no resolv-conf-mode=error ret=systemd-resolved]",
wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=resolved resolved=file nm=no resolv-conf-mode=error ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -183,7 +212,7 @@ func TestLinuxDNSMode(t *testing.T) {
"options edns0 trust-ad"),
resolvedRunning(),
nmRunning("1.32.12", true)),
wantLog: "dns: [resolved-ping=yes rc=nm nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=nm resolved=file nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -194,7 +223,7 @@ func TestLinuxDNSMode(t *testing.T) {
"nameserver 127.0.0.53",
"options edns0 trust-ad"),
nmRunning("1.32.12", true)),
wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=nm nm-resolved=yes nm-safe=no resolv-conf-mode=error ret=systemd-resolved]",
wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=nm resolved=file nm-resolved=yes nm-safe=no resolv-conf-mode=error ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -206,7 +235,7 @@ func TestLinuxDNSMode(t *testing.T) {
"options edns0 trust-ad"),
resolvedRunning(),
nmRunning("1.26.3", true)),
wantLog: "dns: [resolved-ping=yes rc=nm nm-resolved=yes nm-safe=yes ret=network-manager]",
wantLog: "dns: [resolved-ping=yes rc=nm resolved=file nm-resolved=yes nm-safe=yes ret=network-manager]",
want: "network-manager",
},
{
@@ -217,7 +246,7 @@ func TestLinuxDNSMode(t *testing.T) {
"nameserver 127.0.0.53",
"options edns0 trust-ad"),
resolvedRunning()),
wantLog: "dns: [resolved-ping=yes rc=nm nm-resolved=yes nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=nm resolved=file nm-resolved=yes nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -228,7 +257,7 @@ func TestLinuxDNSMode(t *testing.T) {
"search lan",
"nameserver 127.0.0.53"),
resolvedRunning()),
wantLog: "dns: [resolved-ping=yes rc=nm nm-resolved=yes nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=nm resolved=file nm-resolved=yes nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
@@ -238,14 +267,26 @@ func TestLinuxDNSMode(t *testing.T) {
resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"),
resolvedDbusProperty(),
)),
wantLog: "dns: [resolved-ping=yes rc=resolved nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]",
want: "systemd-resolved",
},
{
// regression test for https://github.com/tailscale/tailscale/issues/9687
name: "networkmanager_endeavouros",
env: env(resolvDotConf(
"# Generated by NetworkManager",
"search example.com localdomain",
"nameserver 10.0.0.1"),
nmRunning("1.44.2", false)),
wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [10.0.0.1]\n" +
"dns: [rc=nm resolved=not-in-use ret=direct]",
want: "direct",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var logBuf tstest.MemLogger
got, err := dnsMode(logBuf.Logf, tt.env)
got, err := dnsMode(logBuf.Logf, nil, tt.env)
if err != nil {
t.Fatal(err)
}
@@ -272,8 +313,9 @@ func (m memFS) Stat(name string) (isRegular bool, err error) {
return false, nil
}
func (m memFS) Rename(_, _ string) error { panic("TODO") }
func (m memFS) Remove(_ string) error { panic("TODO") }
func (m memFS) Chmod(name string, mode os.FileMode) error { panic("TODO") }
func (m memFS) Rename(oldName, newName string) error { panic("TODO") }
func (m memFS) Remove(name string) error { panic("TODO") }
func (m memFS) ReadFile(name string) ([]byte, error) {
v, ok := m[name]
if !ok {
@@ -297,7 +339,7 @@ func (m memFS) Truncate(name string) error {
return nil
}
func (m memFS) WriteFile(name string, contents []byte, _ os.FileMode) error {
func (m memFS) WriteFile(name string, contents []byte, perm os.FileMode) error {
m[name] = string(contents)
return nil
}
@@ -381,6 +423,12 @@ func resolvDotConf(ss ...string) envOption {
})
}
func nsswitchDotConf(ss ...string) envOption {
return envOpt(func(b *envBuilder) {
b.fs["/etc/nsswitch.conf"] = strings.Join(ss, "\n")
})
}
// resolvedRunning returns an option that makes resolved reply to a dbusPing
// and the ResolvConfMode property.
func resolvedRunning() envOption {

View File

@@ -1,6 +1,5 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
@@ -11,6 +10,7 @@ import (
"fmt"
"net"
"net/netip"
"sort"
"time"
"github.com/godbus/dbus/v5"
@@ -24,6 +24,13 @@ const (
lowerPriority = int32(200) // lower than all builtin auto priorities
)
// reconfigTimeout is the time interval within which Manager.{Up,Down} should complete.
//
// This is particularly useful because certain conditions can cause indefinite hangs
// (such as improper dbus auth followed by contextless dbus.Object.Call).
// Such operations should be wrapped in a timeout context.
const reconfigTimeout = time.Second
// nmManager uses the NetworkManager DBus API.
type nmManager struct {
interfaceName string
@@ -31,8 +38,6 @@ type nmManager struct {
dnsManager dbus.BusObject
}
var _ OSConfigurator = (*nmManager)(nil)
func newNMManager(interfaceName string) (*nmManager, error) {
conn, err := dbus.SystemBus()
if err != nil {
@@ -141,18 +146,17 @@ func (m *nmManager) trySet(ctx context.Context, config OSConfig) error {
// tell it explicitly to keep it. Read out the current interface
// settings and mirror them out to NetworkManager.
var addrs6 []map[string]any
if netIface, err := net.InterfaceByName(m.interfaceName); err == nil {
if addrs, err := netIface.Addrs(); err == nil {
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
nip, ok := netip.AddrFromSlice(ipnet.IP)
nip = nip.Unmap()
if ok && nip.Is6() {
addrs6 = append(addrs6, map[string]any{
"address": nip.String(),
"prefix": uint32(128),
})
}
if tsIf, err := net.InterfaceByName(m.interfaceName); err == nil {
addrs, _ := tsIf.Addrs()
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
nip, ok := netip.AddrFromSlice(ipnet.IP)
nip = nip.Unmap()
if ok && nip.Is6() {
addrs6 = append(addrs6, map[string]any{
"address": nip.String(),
"prefix": uint32(128),
})
}
}
}
@@ -260,6 +264,125 @@ func (m *nmManager) trySet(ctx context.Context, config OSConfig) error {
return nil
}
func (m *nmManager) SupportsSplitDNS() bool {
var mode string
v, err := m.dnsManager.GetProperty("org.freedesktop.NetworkManager.DnsManager.Mode")
if err != nil {
return false
}
mode, ok := v.Value().(string)
if !ok {
return false
}
// Per NM's documentation, it only does split-DNS when it's
// programming dnsmasq or systemd-resolved. All other modes are
// primary-only.
return mode == "dnsmasq" || mode == "systemd-resolved"
}
func (m *nmManager) GetBaseConfig() (OSConfig, error) {
conn, err := dbus.SystemBus()
if err != nil {
return OSConfig{}, err
}
nm := conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager/DnsManager"))
v, err := nm.GetProperty("org.freedesktop.NetworkManager.DnsManager.Configuration")
if err != nil {
return OSConfig{}, err
}
cfgs, ok := v.Value().([]map[string]dbus.Variant)
if !ok {
return OSConfig{}, fmt.Errorf("unexpected NM config type %T", v.Value())
}
if len(cfgs) == 0 {
return OSConfig{}, nil
}
type dnsPrio struct {
resolvers []netip.Addr
domains []string
priority int32
}
order := make([]dnsPrio, 0, len(cfgs)-1)
for _, cfg := range cfgs {
if name, ok := cfg["interface"]; ok {
if s, ok := name.Value().(string); ok && s == m.interfaceName {
// Config for the tailscale interface, skip.
continue
}
}
var p dnsPrio
if v, ok := cfg["nameservers"]; ok {
if ips, ok := v.Value().([]string); ok {
for _, s := range ips {
ip, err := netip.ParseAddr(s)
if err != nil {
// hmm, what do? Shouldn't really happen.
continue
}
p.resolvers = append(p.resolvers, ip)
}
}
}
if v, ok := cfg["domains"]; ok {
if domains, ok := v.Value().([]string); ok {
p.domains = domains
}
}
if v, ok := cfg["priority"]; ok {
if prio, ok := v.Value().(int32); ok {
p.priority = prio
}
}
order = append(order, p)
}
sort.Slice(order, func(i, j int) bool {
return order[i].priority < order[j].priority
})
var (
ret OSConfig
seenResolvers = map[netip.Addr]bool{}
seenSearch = map[string]bool{}
)
for _, cfg := range order {
for _, resolver := range cfg.resolvers {
if seenResolvers[resolver] {
continue
}
ret.Nameservers = append(ret.Nameservers, resolver)
seenResolvers[resolver] = true
}
for _, dom := range cfg.domains {
if seenSearch[dom] {
continue
}
fqdn, err := dnsname.ToFQDN(dom)
if err != nil {
continue
}
ret.SearchDomains = append(ret.SearchDomains, fqdn)
seenSearch[dom] = true
}
if cfg.priority < 0 {
// exclusive configurations preempt all other
// configurations, so we're done.
break
}
}
return ret, nil
}
func (m *nmManager) Close() error {
// No need to do anything on close, NetworkManager will delete our
// settings when the tailscale interface goes away.

Some files were not shown because too many files have changed in this diff Show More