Compare commits

...

245 Commits

Author SHA1 Message Date
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
Cuong Manh Le
b82ad3720c cmd/cli: guard against nil client info
Though it's only possible raised in testing, still better to be safe.
2023-12-19 01:48:07 +07:00
Cuong Manh Le
8d2cb6091e cmd/cli: add QUERY/REPLY prefix to proxying log
So the log in INFO log is aligned, making it easier for human to
monitoring the log, either via console or running "tail" command.
2023-12-19 01:31:30 +07:00
Yegor Sak
3023f33dff Update file config.md 2023-12-18 21:32:26 +07:00
Cuong Manh Le
22e97e981a cmd/cli: ignore invalid flags for "ctrld run" 2023-12-18 21:32:01 +07:00
Cuong Manh Le
44484e1231 cmd/cli: add WSAEHOSTUNREACH to network error
Windows may raise WSAEHOSTUNREACH instead WSAENETUNREACH in case of
network not available when resuming from sleep or switching network, so
checkUpstream is never kicked in for this type of error.
2023-12-18 21:31:46 +07:00
Cuong Manh Le
eac60b87c7 Improving DOH header logging 2023-12-18 21:31:35 +07:00
Cuong Manh Le
8db28cb76e cmd/cli: improving logging of proxying action
INFO level becomes a sensible setting for normal operation that does not
overwhelm. Adding some small details to make DEBUG level more useful.
2023-12-18 21:31:08 +07:00
Cuong Manh Le
8dbe828b99 cmd/cli: change socket dir to /var/run on *nix 2023-12-18 21:30:53 +07:00
Cuong Manh Le
5c24acd952 cmd/cli: fix bug causes checkUpstream run only once
To prevent duplicated running of checkUpstream function at the same
time, upstream monitor uses a boolean to report whether the upstream is
checking. If this boolean is true, then other calls after the first one
will be returned immediately.

However, checkUpstream does not set this boolean to false when it
finishes, thus all future calls to checkUpstream won't be run, causing
the upstream is marked as down forever.

Fixing this by ensuring the boolean is reset once checkUpstream done.
While at it, also guarding all upstream monitor operations with a mutex,
ensuring there's no race condition between marking upstream state.
2023-12-18 21:30:36 +07:00
Yegor S
998b9a5c5d Merge pull request #103 from Control-D-Inc/release-branch-v1.3.2
Release branch v1.3.2
2023-12-13 10:00:11 -05:00
Cuong Manh Le
0084e9ef26 internal/clientinfo: silent staticcheck S1008
The code is written for readability purpose.
2023-12-13 14:53:29 +07:00
Cuong Manh Le
122600bff2 cmd/cli: remove redundant return statement 2023-12-13 14:53:29 +07:00
Cuong Manh Le
41846b6d4c all: add config to enable/disable answering WAN clients 2023-12-13 14:53:29 +07:00
Cuong Manh Le
dfbcb1489d cmd/cli: improving loop guard test
We see number of failed test in Github Action, mostly on MacOS or
Windows due to the fact that goroutines are scheduled to be run
consequently.

This commit improves the test, ensuring at least 2 goroutines were
started before increasing the counting.
2023-12-13 14:53:29 +07:00
Cuong Manh Le
684019c2e3 all: force re-bootstrapping with timeout error 2023-12-11 22:55:16 +07:00
Cuong Manh Le
e92619620d cmd/cli: doing router setup based on "--iface" flag
Solving downgrading issue from newer version to v1.3.1, and also easier
to explain the logic: either doing "magic stuff" or do nothing.
2023-12-11 22:55:16 +07:00
Cuong Manh Le
cebfd12d5c internal/clientinfo: ensure RFC1918 address is chosen over others 2023-12-07 00:04:17 +07:00
Cuong Manh Le
874ff01ab8 cmd/cli: ensure log time field is formated with ms 2023-12-06 22:31:35 +07:00
Alex Paguis
0bb8703f78 Update document for new client_id_preference param 2023-12-06 15:33:05 +07:00
Cuong Manh Le
0bb51aa71d cmd/cli: add loop guard for LAN/PTR queries 2023-12-06 15:33:05 +07:00
Cuong Manh Le
af2c1c87e0 cmd/cli: improve logging for new LAN/PTR flow 2023-12-06 15:33:05 +07:00
Cuong Manh Le
8939debbc0 cmd/cli: do not send test query to external upstreams 2023-12-06 15:33:05 +07:00
Cuong Manh Le
7591a0ccc6 all: add client id preference config param
So client can chose how client id is generated.
2023-12-06 15:33:05 +07:00
Cuong Manh Le
c3ff8182af all: ignoring local interfaces RFC1918 IP for private resolver
Otherwises, the discovery may make a looping with new PTR query flow.
2023-12-06 15:33:05 +07:00
Cuong Manh Le
5897c174d3 all: fix LAN hostname checking condition
The LAN hostname in question is FQDN, "." suffix must be trimmed before
checking.

While at it, also add tests for LAN/PTR query checking functions.
2023-12-06 15:33:05 +07:00
Cuong Manh Le
f9a3f4c045 Implement new flow for LAN and private PTR resolution
- Use client info table.
 - If no sufficient data, use gateway/os/defined local upstreams.
 - If no data is returned, use remote upstream
2023-11-30 18:28:51 +07:00
Cuong Manh Le
a2cb895cdc cmd/cli: watch changes to /etc/resolv.conf
On some routers, change to network may trigger re-rendering
/etc/resolv.conf file, causing requests from router itself stop using
ctrld.

Fixing this by watching changes to /etc/resolv.conf, then revert them.
2023-11-27 22:19:16 +07:00
Cuong Manh Le
2bebe93e47 internal/router: do not disable cache on EdgeOS
The dnsmasq cache-size setting on EdgeOS could be re-generated anytime
by vyatta router/dhcp components. This conflicts with setting generated
by ctrld, causing dnsmasq fails to start.

It's better to keep dnsmasq cache enabled on EdgeOS, we can turn it off
again once we find a reliable way to control cache-size setting.
2023-11-27 22:19:16 +07:00
Cuong Manh Le
28ec1869fc internal/router/merlin: hardening pre-run condition
The postconf script added by ctrld requires all of these conditions to
work correctly:

 - /proc, /tmp were mounted.
 - dnsmasq is running.

Currently, ctrld is only waiting for NTP ready, which may not ensure
both of those conditions are true. Explicitly checking those conditions
is a safer approach.
2023-11-27 22:19:16 +07:00
Cuong Manh Le
17f6d7a77b cmd/cli: notice writing default config in local mode 2023-11-27 22:19:16 +07:00
Cuong Manh Le
9e6e647ff8 Use discover_ptr_endpoints for PTR resolver 2023-11-27 22:19:16 +07:00
Cuong Manh Le
a2116e5eb5 cmd/cli: do not substitute MAC if empty
Using IPv4 as hostname is enough to distinguish clients.
2023-11-27 22:19:16 +07:00
Cuong Manh Le
564c9ef712 cmd/cli: use IP as hostname for ipv4 clients only
For Android devices, when it joins the network, it uses ctrld to resolve
its private DNS once and never reaches ctrld again. For each time, it uses
a different IPv6 address, which causes hundreds/thousands different client
IDs created for the same device, which is pointless.
2023-11-27 22:19:16 +07:00
Cuong Manh Le
856abb71b7 cmd/cli: only notice reading config with "ctrld start"
While at it, also updating the documentation of related functions.
2023-11-27 22:19:16 +07:00
Cuong Manh Le
0a30fdea69 Add listener policy to default generated config
So technical user can figure thing out based on self-documented
commands, without referring to actual documentation.
2023-11-16 20:59:31 +07:00
Cuong Manh Le
4f125cf107 cmd/cli: notice users where config file is written/read 2023-11-16 20:59:12 +07:00
Cuong Manh Le
494d8be777 cmd/cli: skip router setup with "ctrld service start"
Either do magic stuff and make things work automatically (normal users),
or don't do any of it and just run ctrld as a service (power users).
2023-11-16 20:58:41 +07:00
Cuong Manh Le
cd9c750884 cmd/cli: do not run pre run on reload 2023-11-16 20:58:26 +07:00
Cuong Manh Le
91d319804b cmd/cli: only use failover rcodes if defined 2023-11-16 20:58:10 +07:00
Cuong Manh Le
180eae60f2 all: allowing config defined discover ptr endpoints
The default gateway is usually the DNS server in normal home network
setup for most users. However, there's case that it is not, causing
discover ptr failed.

This commit add discover_ptr_endpoints config parameter, so users can
define what DNS nameservers will be used.
2023-11-16 20:57:52 +07:00
Cuong Manh Le
d01f5c2777 cmd/cli: do not stop listener when reloading
We could not do a reload if the listener config changes, so do not turn
them off to try updating new listener config.
2023-11-16 20:56:57 +07:00
Cuong Manh Le
294a90a807 internal/router/openwrt: ensure dnsmasq cache is disabled
Users may have their own dnsmasq cache set via LUCI web, thus ctrld
needs to delete the cache-size setting to ensure its dnsmasq config
works.
2023-11-16 20:56:42 +07:00
Ginder Singh
c3b4ae9c79 Older android missing certificate 2023-11-16 20:56:24 +07:00
Cuong Manh Le
09188bedf7 cmd/cli: fix wrong generated config for nextdns resolver
Generating nextdns config must happen after stopping current ctrld
process. Otherwise, config processing may pick wrong IP+Port.

While at it, also making logging better when updating listener config:

 - Change warn to info, prevent confusing that "something is wrong".
 - Do not emit info when generating working default config, which may
   cause duplicated messages printed.
2023-11-16 20:55:39 +07:00
Cuong Manh Le
4614b98e94 internal/clientinfo: emit error once if ptr discovery failed
So it won't spam ctrld log unnecessary, prevent confusion. While at it,
also change the log level from Warn to Info, since this error is not
actionable by the user.
2023-11-09 00:30:56 +07:00
Cuong Manh Le
990bc620f7 cmd/cli: strip EDNS0_SUBNET for RFC 1918 and loopback address
Since passing them to upstream is pointless, these cannot be used by
anything on the WAN.
2023-11-09 00:23:38 +07:00
Cuong Manh Le
efb5a92571 Using time interval for probing ipv6
A backoff with small max time will flood requests to Control D server,
causing false positive for abuse mitiation system. While a big max time
will cause ctrld not realize network change as fast as possible.

While at it, also sync DoH3 code with DoH code, ensuring no others place
can trigger requests flooding for ipv6 probing.
2023-11-08 23:51:18 +07:00
Cuong Manh Le
8e0a96a44c Fix panic dues to quic-go changes
quic.DialEarly requires separate UDP connection for each
quic.EarlyConnection instead of re-using the same one.
2023-11-08 23:51:18 +07:00
Cuong Manh Le
43ff2f648c internal/router/dnsmasq: disable cache
So multiple upstreams config could work properly.
2023-11-08 23:51:18 +07:00
Cuong Manh Le
4816a09e3a all: use private resolver for private IP address
These queries could not be resolved by Control D upstreams, so it's
useless and less performance to send them to servers.
2023-11-08 23:51:18 +07:00
Cuong Manh Le
3fea92c8b1 Bump golang.org/x/net to v0.17.0 2023-11-08 23:51:08 +07:00
Cuong Manh Le
63f959c951 all: spoof loopback ranges in client info
Sending them are useless, so using RFC1918 address instead.
2023-11-06 20:01:57 +07:00
Cuong Manh Le
44ba6aadd9 internal/clientinfo: do not complain about net.ErrClosed
The probeLoop may have closed the connection before readLoop return, and
we don't care about this error. So prevent it from annoying the log.
2023-11-06 20:01:42 +07:00
Cuong Manh Le
d88cf52b4e cmd/cli: always rebootstrap when check upstream
Otherwise, network changes may not be seen on some platforms, causing
ctrld failed to recover and failing all requests.

While at it, also doing the check DNS in separate goroutine, prevent it
from blocking ctrld from notifying others that it "started". The issue
was seen when ctrld is configured as direct listener, requests are
flooded before ctrld started, causing the healtch process failed.
2023-11-06 20:01:25 +07:00
Cuong Manh Le
58a00ea24a all: implement reload command
This commit adds reload command to ctrld for re-fetch new config from
ContorlD API or re-read the current config on disk.
2023-11-06 20:01:03 +07:00
Cuong Manh Le
712b23a4bb cmd/cli: initialize upstream proto for mobile 2023-11-06 20:00:28 +07:00
Cuong Manh Le
baf836557c cmd/cli: fix wrong checking condition in removeProvTokenFromArgs
The provision token is only used once, then do not have any effect after
Control D uid is fetched. So making it appears in "ctrld run" command is
useless.
2023-11-06 20:00:10 +07:00
Cuong Manh Le
904b23eeac cmd/cli: add --proto flag to set upstream type in cd mode 2023-11-06 19:59:52 +07:00
Cuong Manh Le
6aafe445f5 cmd/cli: add nextdns mode
Adding --nextdns flag to "ctrld start" command for generating ctrld
config with nextdns resolver id, then use nextdns as an upstream.
2023-11-06 19:59:31 +07:00
Ginder Singh
ebd516855b added safe return if error happens during resolver fetch. 2023-11-06 19:58:53 +07:00
Cuong Manh Le
df4e04719e cmd/cli: relax service dependency on systemd-networkd-wait-online
ctrld wants systemd-networkd-wait-online starts before starting itself,
but ctrld should not be blocked waiting for it started.
2023-11-06 19:58:32 +07:00
Cuong Manh Le
2440d922c6 all: add MAC address base policy
While at it, also update the config doc to clarify the order of matching
preference, and the matter of rules order within each policy.
2023-11-06 19:57:50 +07:00
Yegor S
f1b8d1c4ad Merge pull request #93 from Control-D-Inc/release-branch-v1.3.1
Release branch v1.3.1
2023-10-10 22:29:43 -04:00
125 changed files with 8551 additions and 1481 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
@@ -21,6 +21,6 @@ jobs:
- run: "go test -race ./..."
- uses: dominikh/staticcheck-action@v1.2.0
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

@@ -5,10 +5,11 @@ type ClientInfoCtxKey struct{}
// ClientInfo represents ctrld's clients information.
type ClientInfo struct {
Mac string
IP string
Hostname string
Self bool
Mac string
IP string
Hostname string
Self bool
ClientIDPref string
}
// LeaseFileFormat specifies the format of DHCP lease file.
@@ -17,4 +18,5 @@ type LeaseFileFormat string
const (
Dnsmasq LeaseFileFormat = "dnsmasq"
IscDhcpd LeaseFileFormat = "isc-dhcpd"
KeaDHCP4 LeaseFileFormat = "kea-dhcp4"
)

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

@@ -0,0 +1,10 @@
//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 }

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

@@ -0,0 +1,49 @@
package cli
import (
"fmt"
"strings"
"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)
for n, lc := range cfg.Listener {
if lc.Policy == nil {
lc.Policy = &ctrld.ListenerPolicyConfig{}
}
domainRule := "*." + strings.TrimPrefix(domain, ".")
for _, rule := range lc.Policy.Rules {
if _, ok := rule[domainRule]; ok {
mainLog.Load().Debug().Msgf("domain rule already exist for listener.%s", n)
return false
}
}
mainLog.Load().Debug().Msgf("adding active directory domain for listener.%s", n)
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{domainRule: []string{}})
}
return true
}
// getActiveDirectoryDomain returns AD domain name of this computer.
func getActiveDirectoryDomain() (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
}

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

View File

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

View File

@@ -6,14 +6,25 @@ import (
"net"
"net/http"
"os"
"reflect"
"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"
contentTypeJson = "application/json"
listClientsPath = "/clients"
startedPath = "/started"
reloadPath = "/reload"
deactivationPath = "/deactivation"
cdPath = "/cd"
ifacePath = "/iface"
)
type controlServer struct {
@@ -62,6 +73,25 @@ func (p *prog) registerControlServerHandler() {
sort.Slice(clients, func(i, j int) bool {
return clients[i].IP.Less(clients[j].IP)
})
if p.metricsQueryStats.Load() {
for _, client := range clients {
client.IncludeQueryCount = true
dm := &dto.Metric{}
m, err := statsClientQueriesCount.MetricVec.GetMetricWithLabelValues(
client.IP.String(),
client.Mac,
client.Hostname,
)
if err != nil {
mainLog.Load().Debug().Err(err).Msgf("could not get metrics for client: %v", client)
continue
}
if err := m.Write(dm); err == nil {
client.QueryCount = int64(dm.Counter.GetValue())
}
}
}
if err := json.NewEncoder(w).Encode(&clients); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -75,6 +105,112 @@ func (p *prog) registerControlServerHandler() {
w.WriteHeader(http.StatusRequestTimeout)
}
}))
p.cs.register(reloadPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
listeners := make(map[string]*ctrld.ListenerConfig)
p.mu.Lock()
for k, v := range p.cfg.Listener {
listeners[k] = &ctrld.ListenerConfig{
IP: v.IP,
Port: v.Port,
}
}
oldSvc := p.cfg.Service
p.mu.Unlock()
if err := p.sendReloadSignal(); err != nil {
mainLog.Load().Err(err).Msg("could not send reload signal")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
select {
case <-p.reloadDoneCh:
case <-time.After(5 * time.Second):
http.Error(w, "timeout waiting for ctrld reload", http.StatusInternalServerError)
return
}
p.mu.Lock()
defer p.mu.Unlock()
// Checking for cases that we could not do a reload.
// 1. Listener config ip or port changes.
for k, v := range p.cfg.Listener {
l := listeners[k]
if l == nil || l.IP != v.IP || l.Port != v.Port {
w.WriteHeader(http.StatusCreated)
return
}
}
// 2. Service config changes.
if !reflect.DeepEqual(oldSvc, p.cfg.Service) {
w.WriteHeader(http.StatusCreated)
return
}
// 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) {
// p.setDNS is only called when running as a service
if !service.Interactive() {
<-p.csSetDnsDone
if p.csSetDnsOk {
w.Write([]byte(iface))
return
}
}
w.WriteHeader(http.StatusBadRequest)
}))
}
func jsonResponse(next http.Handler) http.Handler {

View File

@@ -4,10 +4,12 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"net/netip"
"runtime"
"slices"
"strconv"
"strings"
"sync"
@@ -15,20 +17,25 @@ import (
"github.com/miekg/dns"
"golang.org/x/sync/errgroup"
"tailscale.com/net/interfaces"
"tailscale.com/net/netaddr"
"tailscale.com/net/netmon"
"tailscale.com/net/tsaddr"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
const (
staleTTL = 60 * time.Second
localTTL = 3600 * time.Second
// EDNS0_OPTION_MAC is dnsmasq EDNS0 code for adding mac option.
// https://thekelleys.org.uk/gitweb/?p=dnsmasq.git;a=blob;f=src/dns-protocol.h;h=76ac66a8c28317e9c121a74ab5fd0e20f6237dc8;hb=HEAD#l81
// This is also dns.EDNS0LOCALSTART, but define our own constant here for clarification.
EDNS0_OPTION_MAC = 0xFDE9
// selfUninstallMaxQueries is number of REFUSED queries seen before checking for self-uninstallation.
selfUninstallMaxQueries = 32
)
var osUpstreamConfig = &ctrld.UpstreamConfig{
@@ -37,6 +44,38 @@ var osUpstreamConfig = &ctrld.UpstreamConfig{
Timeout: 2000,
}
var privateUpstreamConfig = &ctrld.UpstreamConfig{
Name: "Private resolver",
Type: ctrld.ResolverTypePrivate,
Timeout: 2000,
}
// proxyRequest contains data for proxying a DNS query to upstream.
type proxyRequest struct {
msg *dns.Msg
ci *ctrld.ClientInfo
failoverRcodes []int
ufr *upstreamForResult
}
// proxyResponse contains data for proxying a DNS response from upstream.
type proxyResponse struct {
answer *dns.Msg
cached bool
clientInfo bool
upstream string
}
// upstreamForResult represents the result of processing rules for a request.
type upstreamForResult struct {
upstreams []string
matchedPolicy string
matchedNetwork string
matchedRule string
matched bool
srcAddr string
}
func (p *prog) serveDNS(listenerNum string) error {
listenerConfig := p.cfg.Listener[listenerNum]
// make sure ip is allocated
@@ -44,36 +83,94 @@ func (p *prog) serveDNS(listenerNum string) error {
mainLog.Load().Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip")
return allocErr
}
var failoverRcodes []int
if listenerConfig.Policy != nil {
failoverRcodes = listenerConfig.Policy.FailoverRcodeNumbers
}
handler := dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) {
p.sema.acquire()
defer p.sema.release()
if len(m.Question) == 0 {
answer := new(dns.Msg)
answer.SetRcode(m, dns.RcodeFormatError)
_ = w.WriteMsg(answer)
return
}
listenerConfig := p.cfg.Listener[listenerNum]
reqId := requestID()
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
if !listenerConfig.AllowWanClients && isWanClient(w.RemoteAddr()) {
ctrld.Log(ctx, mainLog.Load().Debug(), "query refused, listener does not allow WAN clients: %s", w.RemoteAddr().String())
answer := new(dns.Msg)
answer.SetRcode(m, dns.RcodeRefused)
_ = w.WriteMsg(answer)
return
}
go p.detectLoop(m)
q := m.Question[0]
domain := canonicalName(q.Name)
reqId := requestID()
if domain == selfCheckInternalTestDomain {
answer := resolveInternalDomainTestQuery(ctx, domain, m)
_ = w.WriteMsg(answer)
return
}
if _, ok := p.cacheFlushDomainsMap[domain]; ok && p.cache != nil {
p.cache.Purge()
ctrld.Log(ctx, mainLog.Load().Debug(), "received query %q, local cache is purged", domain)
}
remoteIP, _, _ := net.SplitHostPort(w.RemoteAddr().String())
ci := p.getClientInfo(remoteIP, m)
ci.ClientIDPref = p.cfg.Service.ClientIDPref
stripClientSubnet(m)
remoteAddr := spoofRemoteAddr(w.RemoteAddr(), ci)
fmtSrcToDest := fmtRemoteToLocal(listenerNum, remoteAddr.String(), w.LocalAddr().String())
fmtSrcToDest := fmtRemoteToLocal(listenerNum, ci.Hostname, remoteAddr.String())
t := time.Now()
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
ctrld.Log(ctx, mainLog.Load().Debug(), "%s received query: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain)
upstreams, matched := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, domain)
ctrld.Log(ctx, mainLog.Load().Info(), "QUERY: %s: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain)
ur := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, ci.Mac, domain)
labelValues := make([]string, 0, len(statsQueriesCountLabels))
labelValues = append(labelValues, net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)))
labelValues = append(labelValues, ci.IP)
labelValues = append(labelValues, ci.Mac)
labelValues = append(labelValues, ci.Hostname)
var answer *dns.Msg
if !matched && listenerConfig.Restricted {
if !ur.matched && listenerConfig.Restricted {
ctrld.Log(ctx, mainLog.Load().Info(), "query refused, %s does not match any network policy", remoteAddr.String())
answer = new(dns.Msg)
answer.SetRcode(m, dns.RcodeRefused)
labelValues = append(labelValues, "") // no upstream
} else {
answer = p.proxy(ctx, upstreams, failoverRcodes, m, ci)
var failoverRcode []int
if listenerConfig.Policy != nil {
failoverRcode = listenerConfig.Policy.FailoverRcodeNumbers
}
pr := p.proxy(ctx, &proxyRequest{
msg: m,
ci: ci,
failoverRcodes: failoverRcode,
ufr: ur,
})
go p.doSelfUninstall(pr.answer)
answer = pr.answer
rtt := time.Since(t)
ctrld.Log(ctx, mainLog.Load().Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
upstream := pr.upstream
switch {
case pr.cached:
upstream = "cache"
case pr.clientInfo:
upstream = "client_info_table"
}
labelValues = append(labelValues, upstream)
}
labelValues = append(labelValues, dns.TypeToString[q.Qtype])
labelValues = append(labelValues, dns.RcodeToString[answer.Rcode])
go func() {
p.WithLabelValuesInc(statsQueriesCount, labelValues...)
p.WithLabelValuesInc(statsClientQueriesCount, []string{ci.IP, ci.Mac, ci.Hostname}...)
p.forceFetchingAPI(domain)
}()
if err := w.WriteMsg(answer); err != nil {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveUDP: failed to send DNS response to client")
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveDNS: failed to send DNS response to client")
}
})
@@ -99,7 +196,7 @@ func (p *prog) serveDNS(listenerNum string) error {
// addresses of the machine. So ctrld could receive queries from LAN clients.
if needRFC1918Listeners(listenerConfig) {
g.Go(func() error {
for _, addr := range rfc1918Addresses() {
for _, addr := range ctrld.Rfc1918Addresses() {
func() {
listenAddr := net.JoinHostPort(addr, strconv.Itoa(listenerConfig.Port))
s, errCh := runDNSServer(listenAddr, proto, handler)
@@ -121,12 +218,9 @@ func (p *prog) serveDNS(listenerNum string) error {
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
s, errCh := runDNSServer(addr, proto, handler)
defer s.Shutdown()
select {
case err := <-errCh:
return err
case <-time.After(5 * time.Second):
p.started <- struct{}{}
}
p.started <- struct{}{}
select {
case <-p.stopCh:
case <-ctx.Done():
@@ -146,27 +240,24 @@ func (p *prog) serveDNS(listenerNum string) error {
// Though domain policy has higher priority than network policy, it is still
// processed later, because policy logging want to know whether a network rule
// is disregarded in favor of the domain level rule.
func (p *prog) upstreamFor(ctx context.Context, defaultUpstreamNum string, lc *ctrld.ListenerConfig, addr net.Addr, domain string) ([]string, bool) {
func (p *prog) upstreamFor(ctx context.Context, defaultUpstreamNum string, lc *ctrld.ListenerConfig, addr net.Addr, srcMac, domain string) (res *upstreamForResult) {
upstreams := []string{upstreamPrefix + defaultUpstreamNum}
matchedPolicy := "no policy"
matchedNetwork := "no network"
matchedRule := "no rule"
matched := false
res = &upstreamForResult{srcAddr: addr.String()}
defer func() {
if !matched && lc.Restricted {
ctrld.Log(ctx, mainLog.Load().Info(), "query refused, %s does not match any network policy", addr.String())
return
}
if matched {
ctrld.Log(ctx, mainLog.Load().Info(), "%s, %s, %s -> %v", matchedPolicy, matchedNetwork, matchedRule, upstreams)
} else {
ctrld.Log(ctx, mainLog.Load().Info(), "no explicit policy matched, using default routing -> %v", upstreams)
}
res.upstreams = upstreams
res.matched = matched
res.matchedPolicy = matchedPolicy
res.matchedNetwork = matchedNetwork
res.matchedRule = matchedRule
}()
if lc.Policy == nil {
return upstreams, false
return
}
do := func(policyUpstreams []string) {
@@ -202,6 +293,19 @@ networkRules:
}
}
macRules:
for _, rule := range lc.Policy.Macs {
for source, targets := range rule {
if source != "" && (strings.EqualFold(source, srcMac) || wildcardMatches(strings.ToLower(source), strings.ToLower(srcMac))) {
matchedPolicy = lc.Policy.Name
matchedNetwork = source
networkTargets = targets
matched = true
break macRules
}
}
}
for _, rule := range lc.Policy.Rules {
// There's only one entry per rule, config validation ensures this.
for source, targets := range rule {
@@ -213,7 +317,7 @@ networkRules:
matchedRule = source
do(targets)
matched = true
return upstreams, matched
return
}
}
}
@@ -222,31 +326,164 @@ networkRules:
do(networkTargets)
}
return upstreams, matched
return
}
func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []int, msg *dns.Msg, ci *ctrld.ClientInfo) *dns.Msg {
func (p *prog) proxyPrivatePtrLookup(ctx context.Context, msg *dns.Msg) *dns.Msg {
cDomainName := msg.Question[0].Name
locked := p.ptrLoopGuard.TryLock(cDomainName)
defer p.ptrLoopGuard.Unlock(cDomainName)
if !locked {
return nil
}
ip := ipFromARPA(cDomainName)
if name := p.ciTable.LookupHostname(ip.String(), ""); name != "" {
answer := new(dns.Msg)
answer.SetReply(msg)
answer.Compress = true
answer.Answer = []dns.RR{&dns.PTR{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
},
Ptr: dns.Fqdn(name),
}}
ctrld.Log(ctx, mainLog.Load().Info(), "private PTR lookup, using client info table")
ctrld.Log(ctx, mainLog.Load().Debug(), "client info: %v", ctrld.ClientInfo{
Mac: p.ciTable.LookupMac(ip.String()),
IP: ip.String(),
Hostname: name,
})
return answer
}
return nil
}
func (p *prog) proxyLanHostnameQuery(ctx context.Context, msg *dns.Msg) *dns.Msg {
q := msg.Question[0]
hostname := strings.TrimSuffix(q.Name, ".")
locked := p.lanLoopGuard.TryLock(hostname)
defer p.lanLoopGuard.Unlock(hostname)
if !locked {
return nil
}
if ip := p.ciTable.LookupIPByHostname(hostname, q.Qtype == dns.TypeAAAA); ip != nil {
answer := new(dns.Msg)
answer.SetReply(msg)
answer.Compress = true
switch {
case ip.Is4():
answer.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: uint32(localTTL.Seconds()),
},
A: ip.AsSlice(),
}}
case ip.Is6():
answer.Answer = []dns.RR{&dns.AAAA{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: uint32(localTTL.Seconds()),
},
AAAA: ip.AsSlice(),
}}
}
ctrld.Log(ctx, mainLog.Load().Info(), "lan hostname lookup, using client info table")
ctrld.Log(ctx, mainLog.Load().Debug(), "client info: %v", ctrld.ClientInfo{
Mac: p.ciTable.LookupMac(ip.String()),
IP: ip.String(),
Hostname: hostname,
})
return answer
}
return nil
}
func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
var staleAnswer *dns.Msg
upstreams := req.ufr.upstreams
serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale
upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams)
leaked := false
// If ctrld is going to leak query to OS resolver, check remote upstream in background,
// so ctrld could be back to normal operation as long as the network is back online.
if len(upstreamConfigs) > 0 && p.leakingQuery.Load() {
for n, uc := range upstreamConfigs {
go p.checkUpstream(upstreams[n], uc)
}
upstreamConfigs = nil
leaked = true
ctrld.Log(ctx, mainLog.Load().Debug(), "%v is down, leaking query to OS resolver", upstreams)
}
if len(upstreamConfigs) == 0 {
upstreamConfigs = []*ctrld.UpstreamConfig{osUpstreamConfig}
upstreams = []string{upstreamOS}
}
res := &proxyResponse{}
// LAN/PTR lookup flow:
//
// 1. If there's matching rule, follow it.
// 2. Try from client info table.
// 3. Try private resolver.
// 4. Try remote upstream.
isLanOrPtrQuery := false
if req.ufr.matched {
if leaked {
ctrld.Log(ctx, mainLog.Load().Debug(), "%s, %s, %s -> %v (leaked)", req.ufr.matchedPolicy, req.ufr.matchedNetwork, req.ufr.matchedRule, upstreams)
} else {
ctrld.Log(ctx, mainLog.Load().Debug(), "%s, %s, %s -> %v", req.ufr.matchedPolicy, req.ufr.matchedNetwork, req.ufr.matchedRule, upstreams)
}
} else {
switch {
case isPrivatePtrLookup(req.msg):
isLanOrPtrQuery = true
if answer := p.proxyPrivatePtrLookup(ctx, req.msg); answer != nil {
res.answer = answer
res.clientInfo = true
return res
}
upstreams, upstreamConfigs = p.upstreamsAndUpstreamConfigForLanAndPtr(upstreams, upstreamConfigs)
ctrld.Log(ctx, mainLog.Load().Debug(), "private PTR lookup, using upstreams: %v", upstreams)
case isLanHostnameQuery(req.msg):
isLanOrPtrQuery = true
if answer := p.proxyLanHostnameQuery(ctx, req.msg); answer != nil {
res.answer = answer
res.clientInfo = true
return res
}
upstreams, upstreamConfigs = p.upstreamsAndUpstreamConfigForLanAndPtr(upstreams, upstreamConfigs)
ctrld.Log(ctx, mainLog.Load().Debug(), "lan hostname lookup, using upstreams: %v", upstreams)
default:
ctrld.Log(ctx, mainLog.Load().Debug(), "no explicit policy matched, using default routing -> %v", upstreams)
}
}
// Inverse query should not be cached: https://www.rfc-editor.org/rfc/rfc1035#section-7.4
if p.cache != nil && msg.Question[0].Qtype != dns.TypePTR {
if p.cache != nil && req.msg.Question[0].Qtype != dns.TypePTR {
for _, upstream := range upstreams {
cachedValue := p.cache.Get(dnscache.NewKey(msg, upstream))
cachedValue := p.cache.Get(dnscache.NewKey(req.msg, upstream))
if cachedValue == nil {
continue
}
answer := cachedValue.Msg.Copy()
answer.SetRcode(msg, answer.Rcode)
answer.SetRcode(req.msg, answer.Rcode)
now := time.Now()
if cachedValue.Expire.After(now) {
ctrld.Log(ctx, mainLog.Load().Debug(), "hit cached response")
setCachedAnswerTTL(answer, now, cachedValue.Expire)
return answer
res.answer = answer
res.cached = true
return res
}
staleAnswer = answer
}
@@ -268,19 +505,25 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
return dnsResolver.Resolve(resolveCtx, msg)
}
resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg {
if upstreamConfig.UpstreamSendClientInfo() && ci != nil {
if upstreamConfig.UpstreamSendClientInfo() && req.ci != nil {
ctrld.Log(ctx, mainLog.Load().Debug(), "including client info with the request")
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci)
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, req.ci)
}
answer, err := resolve1(n, upstreamConfig, msg)
if err != nil {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to resolve query")
if errNetworkError(err) {
isNetworkErr := errNetworkError(err)
if isNetworkErr {
p.um.increaseFailureCount(upstreams[n])
if p.um.isDown(upstreams[n]) {
go p.um.checkUpstream(upstreams[n], upstreamConfig)
go p.checkUpstream(upstreams[n], upstreamConfig)
}
}
// For timeout error (i.e: context deadline exceed), force re-bootstrapping.
var e net.Error
if errors.As(err, &e) && e.Timeout() {
upstreamConfig.ReBootstrap()
}
return nil
}
return answer
@@ -297,17 +540,25 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
ctrld.Log(ctx, mainLog.Load().Warn(), "%s is down", upstreams[n])
continue
}
answer := resolve(n, upstreamConfig, msg)
answer := resolve(n, upstreamConfig, req.msg)
if answer == nil {
if serveStaleCache && staleAnswer != nil {
ctrld.Log(ctx, mainLog.Load().Debug(), "serving stale cached response")
now := time.Now()
setCachedAnswerTTL(staleAnswer, now, now.Add(staleTTL))
return staleAnswer
res.answer = staleAnswer
res.cached = true
return res
}
continue
}
if answer.Rcode != dns.RcodeSuccess && len(upstreamConfigs) > 1 && containRcode(failoverRcodes, answer.Rcode) {
// We are doing LAN/PTR lookup using private resolver, so always process next one.
// Except for the last, we want to send response instead of saying all upstream failed.
if answer.Rcode != dns.RcodeSuccess && isLanOrPtrQuery && n != len(upstreamConfigs)-1 {
ctrld.Log(ctx, mainLog.Load().Debug(), "no response from %s, process to next upstream", upstreams[n])
continue
}
if answer.Rcode != dns.RcodeSuccess && len(upstreamConfigs) > 1 && containRcode(req.failoverRcodes, answer.Rcode) {
ctrld.Log(ctx, mainLog.Load().Debug(), "failover rcode matched, process to next upstream")
continue
}
@@ -315,7 +566,7 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
// set compression, as it is not set by default when unpacking
answer.Compress = true
if p.cache != nil {
if p.cache != nil && req.msg.Question[0].Qtype != dns.TypePTR {
ttl := ttlFromMsg(answer)
now := time.Now()
expired := now.Add(time.Duration(ttl) * time.Second)
@@ -323,15 +574,41 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
expired = now.Add(time.Duration(cachedTTL) * time.Second)
}
setCachedAnswerTTL(answer, now, expired)
p.cache.Add(dnscache.NewKey(msg, upstreams[n]), dnscache.NewValue(answer, expired))
p.cache.Add(dnscache.NewKey(req.msg, upstreams[n]), dnscache.NewValue(answer, expired))
ctrld.Log(ctx, mainLog.Load().Debug(), "add cached response")
}
return answer
hostname := ""
if req.ci != nil {
hostname = req.ci.Hostname
}
ctrld.Log(ctx, mainLog.Load().Info(), "REPLY: %s -> %s (%s): %s", upstreams[n], req.ufr.srcAddr, hostname, dns.RcodeToString[answer.Rcode])
res.answer = answer
res.upstream = upstreamConfig.Endpoint
return res
}
ctrld.Log(ctx, mainLog.Load().Error(), "all %v endpoints failed", upstreams)
if cdUID != "" && p.leakOnUpstreamFailure() {
p.leakingQueryMu.Lock()
if !p.leakingQueryWasRun {
p.leakingQueryWasRun = true
go p.performLeakingQuery()
}
p.leakingQueryMu.Unlock()
}
answer := new(dns.Msg)
answer.SetRcode(msg, dns.RcodeServerFailure)
return answer
answer.SetRcode(req.msg, dns.RcodeServerFailure)
res.answer = answer
return res
}
func (p *prog) upstreamsAndUpstreamConfigForLanAndPtr(upstreams []string, upstreamConfigs []*ctrld.UpstreamConfig) ([]string, []*ctrld.UpstreamConfig) {
if len(p.localUpstreams) > 0 {
tmp := make([]string, 0, len(p.localUpstreams)+len(upstreams))
tmp = append(tmp, p.localUpstreams...)
tmp = append(tmp, upstreams...)
return tmp, p.upstreamConfigsFromUpstreamNumbers(tmp)
}
return append([]string{upstreamOS}, upstreams...), append([]*ctrld.UpstreamConfig{privateUpstreamConfig}, upstreamConfigs...)
}
func (p *prog) upstreamConfigsFromUpstreamNumbers(upstreams []string) []*ctrld.UpstreamConfig {
@@ -353,32 +630,34 @@ func canonicalName(fqdn string) string {
return q
}
func wildcardMatches(wildcard, domain string) bool {
// wildcardMatches reports whether string str matches the wildcard pattern in case-insensitive manner.
func wildcardMatches(wildcard, str string) bool {
// Wildcard match.
wildCardParts := strings.Split(wildcard, "*")
wildCardParts := strings.Split(strings.ToLower(wildcard), "*")
if len(wildCardParts) != 2 {
return false
}
str = strings.ToLower(str)
switch {
case len(wildCardParts[0]) > 0 && len(wildCardParts[1]) > 0:
// Domain must match both prefix and suffix.
return strings.HasPrefix(domain, wildCardParts[0]) && strings.HasSuffix(domain, wildCardParts[1])
return strings.HasPrefix(str, wildCardParts[0]) && strings.HasSuffix(str, wildCardParts[1])
case len(wildCardParts[1]) > 0:
// Only suffix must match.
return strings.HasSuffix(domain, wildCardParts[1])
return strings.HasSuffix(str, wildCardParts[1])
case len(wildCardParts[0]) > 0:
// Only prefix must match.
return strings.HasPrefix(domain, wildCardParts[0])
return strings.HasPrefix(str, wildCardParts[0])
}
return false
}
func fmtRemoteToLocal(listenerNum, remote, local string) string {
return fmt.Sprintf("%s -> listener.%s: %s:", remote, listenerNum, local)
func fmtRemoteToLocal(listenerNum, hostname, remote string) string {
return fmt.Sprintf("%s (%s) -> listener.%s", remote, hostname, listenerNum)
}
func requestID() string {
@@ -454,6 +733,23 @@ func ipAndMacFromMsg(msg *dns.Msg) (string, string) {
return ip, mac
}
// stripClientSubnet removes EDNS0_SUBNET from DNS message if the IP is RFC1918 or loopback address,
// passing them to upstream is pointless, these cannot be used by anything on the WAN.
func stripClientSubnet(msg *dns.Msg) {
if opt := msg.IsEdns0(); opt != nil {
opts := make([]dns.EDNS0, 0, len(opt.Option))
for _, s := range opt.Option {
if e, ok := s.(*dns.EDNS0_SUBNET); ok && (e.Address.IsPrivate() || e.Address.IsLoopback()) {
continue
}
opts = append(opts, s)
}
if len(opts) != len(opt.Option) {
opt.Option = opts
}
}
}
func spoofRemoteAddr(addr net.Addr, ci *ctrld.ClientInfo) net.Addr {
if ci != nil && ci.IP != "" {
switch addr := addr.(type) {
@@ -488,20 +784,19 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha
Handler: handler,
}
waitLock := sync.Mutex{}
waitLock.Lock()
s.NotifyStartedFunc = waitLock.Unlock
startedCh := make(chan struct{})
s.NotifyStartedFunc = func() { sync.OnceFunc(func() { close(startedCh) })() }
errCh := make(chan error)
go func() {
defer close(errCh)
if err := s.ListenAndServe(); err != nil {
waitLock.Unlock()
s.NotifyStartedFunc()
mainLog.Load().Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr)
errCh <- err
}
}()
waitLock.Lock()
<-startedCh
return s, errCh
}
@@ -531,46 +826,161 @@ func (p *prog) getClientInfo(remoteIP string, msg *dns.Msg) *ctrld.ClientInfo {
}
// If MAC is still empty here, that mean the requests are made from virtual interface,
// like VPN/Wireguard clients, so we use whatever MAC address associated with remoteIP
// (most likely 127.0.0.1), and ci.IP as hostname, so we can distinguish those clients.
// like VPN/Wireguard clients, so we use ci.IP as hostname to distinguish those clients.
if ci.Mac == "" {
ci.Mac = p.ciTable.LookupMac(remoteIP)
if hostname := p.ciTable.LookupHostname(ci.IP, ""); hostname != "" {
ci.Hostname = hostname
} else {
ci.Hostname = ci.IP
p.ciTable.StoreVPNClient(ci)
// Only use IP as hostname for IPv4 clients.
// For Android devices, when it joins the network, it uses ctrld to resolve
// its private DNS once and never reaches ctrld again. For each time, it uses
// a different IPv6 address, which causes hundreds/thousands different client
// IDs created for the same device, which is pointless.
//
// TODO(cuonglm): investigate whether this can be a false positive for other clients?
if !ctrldnet.IsIPv6(ci.IP) {
ci.Hostname = ci.IP
p.ciTable.StoreVPNClient(ci)
}
}
} else {
ci.Hostname = p.ciTable.LookupHostname(ci.IP, ci.Mac)
}
ci.Self = queryFromSelf(ci.IP)
ci.Self = p.queryFromSelf(ci.IP)
// If this is a query from self, but ci.IP is not loopback IP,
// try using hostname mapping for lookback IP if presents.
if ci.Self {
if name := p.ciTable.LocalHostname(); name != "" {
ci.Hostname = name
}
}
p.spoofLoopbackIpInClientInfo(ci)
return ci
}
// spoofLoopbackIpInClientInfo replaces loopback IPs in client info.
//
// - Preference IPv4.
// - Preference RFC1918.
func (p *prog) spoofLoopbackIpInClientInfo(ci *ctrld.ClientInfo) {
if ip := net.ParseIP(ci.IP); ip == nil || !ip.IsLoopback() {
return
}
if ip := p.ciTable.LookupRFC1918IPv4(ci.Mac); ip != "" {
ci.IP = ip
}
}
// doSelfUninstall performs self-uninstall if these condition met:
//
// - There is only 1 ControlD upstream in-use.
// - Number of refused queries seen so far equals to selfUninstallMaxQueries.
// - The cdUID is deleted.
func (p *prog) doSelfUninstall(answer *dns.Msg) {
if !p.canSelfUninstall.Load() || answer == nil || answer.Rcode != dns.RcodeRefused {
return
}
p.selfUninstallMu.Lock()
defer p.selfUninstallMu.Unlock()
if p.checkingSelfUninstall {
return
}
logger := mainLog.Load().With().Str("mode", "self-uninstall").Logger()
if p.refusedQueryCount > selfUninstallMaxQueries {
p.checkingSelfUninstall = true
_, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev)
logger.Debug().Msg("maximum number of refused queries reached, checking device status")
selfUninstallCheck(err, p, logger)
if err != nil {
logger.Warn().Err(err).Msg("could not fetch resolver config")
}
// Cool-of period to prevent abusing the API.
go p.selfUninstallCoolOfPeriod()
return
}
p.refusedQueryCount++
}
// selfUninstallCoolOfPeriod waits for 30 minutes before
// calling API again for checking ControlD device status.
func (p *prog) selfUninstallCoolOfPeriod() {
t := time.NewTimer(time.Minute * 30)
defer t.Stop()
<-t.C
p.selfUninstallMu.Lock()
p.checkingSelfUninstall = false
p.refusedQueryCount = 0
p.selfUninstallMu.Unlock()
}
// performLeakingQuery performs necessary works to leak queries to OS resolver.
func (p *prog) performLeakingQuery() {
mainLog.Load().Warn().Msg("leaking query to OS resolver")
// Signal dns watchers to stop, so changes made below won't be reverted.
p.leakingQuery.Store(true)
p.resetDNS()
ns := ctrld.InitializeOsResolver()
mainLog.Load().Debug().Msgf("re-initialized OS resolver with nameservers: %v", ns)
p.dnsWg.Wait()
p.setDNS()
}
// forceFetchingAPI sends signal to force syncing API config if run in cd mode,
// and the domain == "cdUID.verify.controld.com"
func (p *prog) forceFetchingAPI(domain string) {
if cdUID == "" {
return
}
resolverID, parent, _ := strings.Cut(domain, ".")
if resolverID != cdUID {
return
}
switch {
case cdDev && parent == "verify.controld.dev":
// match ControlD dev
case parent == "verify.controld.com":
// match ControlD
default:
return
}
_ = p.apiForceReloadGroup.DoChan("force_sync_api", func() (interface{}, error) {
p.apiForceReloadCh <- struct{}{}
// Wait here to prevent abusing API if we are flooded.
time.Sleep(timeDurationOrDefault(p.cfg.Service.ForceRefetchWaitTime, 30) * time.Second)
return nil, nil
})
}
// timeDurationOrDefault returns time duration value from n if not nil.
// Otherwise, it returns time duration value defaultN.
func timeDurationOrDefault(n *int, defaultN int) time.Duration {
if n != nil && *n > 0 {
return time.Duration(*n)
}
return time.Duration(defaultN)
}
// queryFromSelf reports whether the input IP is from device running ctrld.
func queryFromSelf(ip string) bool {
func (p *prog) queryFromSelf(ip string) bool {
if val, ok := p.queryFromSelfMap.Load(ip); ok {
return val.(bool)
}
netIP := netip.MustParseAddr(ip)
ifaces, err := interfaces.GetList()
regularIPs, loopbackIPs, err := netmon.LocalAddresses()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not get interfaces list")
mainLog.Load().Warn().Err(err).Msg("could not get local addresses")
return false
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not get interfaces addresses: %s", iface.Name)
continue
}
for _, a := range addrs {
switch v := a.(type) {
case *net.IPNet:
if pfx, ok := netaddr.FromStdIPNet(v); ok && pfx.Addr().Compare(netIP) == 0 {
return true
}
}
for _, localIP := range slices.Concat(regularIPs, loopbackIPs) {
if localIP.Compare(netIP) == 0 {
p.queryFromSelfMap.Store(ip, true)
return true
}
}
p.queryFromSelfMap.Store(ip, false)
return false
}
@@ -578,17 +988,104 @@ func needRFC1918Listeners(lc *ctrld.ListenerConfig) bool {
return lc.IP == "127.0.0.1" && lc.Port == 53
}
func rfc1918Addresses() []string {
var res []string
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
addrs, _ := i.Addrs()
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || !ipNet.IP.IsPrivate() {
continue
}
res = append(res, ipNet.IP.String())
// ipFromARPA parses a FQDN arpa domain and return the IP address if valid.
func ipFromARPA(arpa string) net.IP {
if arpa, ok := strings.CutSuffix(arpa, ".in-addr.arpa."); ok {
if ptrIP := net.ParseIP(arpa); ptrIP != nil {
return net.IP{ptrIP[15], ptrIP[14], ptrIP[13], ptrIP[12]}
}
})
return res
}
if arpa, ok := strings.CutSuffix(arpa, ".ip6.arpa."); ok {
l := net.IPv6len * 2
base := 16
ip := make(net.IP, net.IPv6len)
for i := 0; i < l && arpa != ""; i++ {
idx := strings.LastIndexByte(arpa, '.')
off := idx + 1
if idx == -1 {
idx = 0
off = 0
} else if idx == len(arpa)-1 {
return nil
}
n, err := strconv.ParseUint(arpa[off:], base, 8)
if err != nil {
return nil
}
b := byte(n)
ii := i / 2
if i&1 == 1 {
b |= ip[ii] << 4
}
ip[ii] = b
arpa = arpa[:idx]
}
return ip
}
return nil
}
// isPrivatePtrLookup reports whether DNS message is an PTR query for LAN/CGNAT network.
func isPrivatePtrLookup(m *dns.Msg) bool {
if m == nil || len(m.Question) == 0 {
return false
}
q := m.Question[0]
if ip := ipFromARPA(q.Name); ip != nil {
if addr, ok := netip.AddrFromSlice(ip); ok {
return addr.IsPrivate() ||
addr.IsLoopback() ||
addr.IsLinkLocalUnicast() ||
tsaddr.CGNATRange().Contains(addr)
}
}
return false
}
// isLanHostnameQuery reports whether DNS message is an A/AAAA query with LAN hostname.
func isLanHostnameQuery(m *dns.Msg) bool {
if m == nil || len(m.Question) == 0 {
return false
}
q := m.Question[0]
switch q.Qtype {
case dns.TypeA, dns.TypeAAAA:
default:
return false
}
name := strings.TrimSuffix(q.Name, ".")
return !strings.Contains(name, ".") ||
strings.HasSuffix(name, ".domain") ||
strings.HasSuffix(name, ".lan")
}
// isWanClient reports whether the input is a WAN address.
func isWanClient(na net.Addr) bool {
var ip netip.Addr
if ap, err := netip.ParseAddrPort(na.String()); err == nil {
ip = ap.Addr()
}
return !ip.IsLoopback() &&
!ip.IsPrivate() &&
!ip.IsLinkLocalUnicast() &&
!ip.IsLinkLocalMulticast() &&
!tsaddr.CGNATRange().Contains(ip)
}
// resolveInternalDomainTestQuery resolves internal test domain query, returning the answer to the caller.
func resolveInternalDomainTestQuery(ctx context.Context, domain string, m *dns.Msg) *dns.Msg {
ctrld.Log(ctx, mainLog.Load().Debug(), "internal domain test query")
q := m.Question[0]
answer := new(dns.Msg)
rrStr := fmt.Sprintf("%s A %s", domain, net.IPv4zero)
if q.Qtype == dns.TypeAAAA {
rrStr = fmt.Sprintf("%s AAAA %s", domain, net.IPv6zero)
}
rr, err := dns.NewRR(rrStr)
if err == nil {
answer.Answer = append(answer.Answer, rr)
}
answer.SetReply(m)
return answer
}

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,8 +75,11 @@ func Test_canonicalName(t *testing.T) {
func Test_prog_upstreamFor(t *testing.T) {
cfg := testhelper.SampleConfig(t)
prog := &prog{cfg: cfg}
for _, nc := range prog.cfg.Network {
p := &prog{cfg: cfg}
p.um = newUpstreamMonitor(p.cfg)
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
for _, nc := range p.cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
@@ -81,6 +92,7 @@ func Test_prog_upstreamFor(t *testing.T) {
tests := []struct {
name string
ip string
mac string
defaultUpstreamNum string
lc *ctrld.ListenerConfig
domain string
@@ -88,11 +100,14 @@ func Test_prog_upstreamFor(t *testing.T) {
matched bool
testLogMsg string
}{
{"Policy map matches", "192.168.0.1:0", "0", prog.cfg.Listener["0"], "abc.xyz", []string{"upstream.1", "upstream.0"}, true, ""},
{"Policy split matches", "192.168.0.1:0", "0", prog.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, ""},
{"Policy map for other network matches", "192.168.1.2:0", "0", prog.cfg.Listener["0"], "abc.xyz", []string{"upstream.0"}, true, ""},
{"No policy map for listener", "192.168.1.2:0", "1", prog.cfg.Listener["1"], "abc.ru", []string{"upstream.1"}, false, ""},
{"unenforced loging", "192.168.1.2:0", "0", prog.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, "My Policy, network.1 (unenforced), *.ru -> [upstream.1]"},
{"Policy map matches", "192.168.0.1:0", "", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.1", "upstream.0"}, true, ""},
{"Policy split matches", "192.168.0.1:0", "", "0", p.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, ""},
{"Policy map for other network matches", "192.168.1.2:0", "", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.0"}, true, ""},
{"No policy map for listener", "192.168.1.2:0", "", "1", p.cfg.Listener["1"], "abc.ru", []string{"upstream.1"}, false, ""},
{"unenforced loging", "192.168.1.2:0", "", "0", p.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, "My Policy, network.1 (unenforced), *.ru -> [upstream.1]"},
{"Policy Macs matches upper", "192.168.0.1:0", "14:45:A0:67:83:0A", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:45:a0:67:83:0a"},
{"Policy Macs matches lower", "192.168.0.1:0", "14:54:4a:8e:08:2d", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:54:4a:8e:08:2d"},
{"Policy Macs matches case-insensitive", "192.168.0.1:0", "14:54:4A:8E:08:2D", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:54:4a:8e:08:2d"},
}
for _, tc := range tests {
@@ -111,9 +126,13 @@ func Test_prog_upstreamFor(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, addr)
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
upstreams, matched := prog.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.domain)
assert.Equal(t, tc.matched, matched)
assert.Equal(t, tc.upstreams, upstreams)
ufr := p.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.mac, tc.domain)
p.proxy(ctx, &proxyRequest{
msg: newDnsMsgWithHostname("foo", dns.TypeA),
ufr: ufr,
})
assert.Equal(t, tc.matched, ufr.matched)
assert.Equal(t, tc.upstreams, ufr.upstreams)
if tc.testLogMsg != "" {
assert.Contains(t, logOutput.String(), tc.testLogMsg)
}
@@ -149,11 +168,35 @@ func TestCache(t *testing.T) {
answer2.SetRcode(msg, dns.RcodeRefused)
prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute)))
got1 := prog.proxy(context.Background(), []string{"upstream.1"}, nil, msg, nil)
got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg, nil)
req1 := &proxyRequest{
msg: msg,
ci: nil,
failoverRcodes: nil,
ufr: &upstreamForResult{
upstreams: []string{"upstream.1"},
matchedPolicy: "",
matchedNetwork: "",
matchedRule: "",
matched: false,
},
}
req2 := &proxyRequest{
msg: msg,
ci: nil,
failoverRcodes: nil,
ufr: &upstreamForResult{
upstreams: []string{"upstream.0"},
matchedPolicy: "",
matchedNetwork: "",
matchedRule: "",
matched: false,
},
}
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) {
@@ -234,3 +277,165 @@ func Test_remoteAddrFromMsg(t *testing.T) {
})
}
}
func Test_ipFromARPA(t *testing.T) {
tests := []struct {
IP string
ARPA string
}{
{"1.2.3.4", "4.3.2.1.in-addr.arpa."},
{"245.110.36.114", "114.36.110.245.in-addr.arpa."},
{"::ffff:12.34.56.78", "78.56.34.12.in-addr.arpa."},
{"::1", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa."},
{"1::", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.ip6.arpa."},
{"1234:567::89a:bcde", "e.d.c.b.a.9.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.7.6.5.0.4.3.2.1.ip6.arpa."},
{"1234:567:fefe:bcbc:adad:9e4a:89a:bcde", "e.d.c.b.a.9.8.0.a.4.e.9.d.a.d.a.c.b.c.b.e.f.e.f.7.6.5.0.4.3.2.1.ip6.arpa."},
{"", "asd.in-addr.arpa."},
{"", "asd.ip6.arpa."},
}
for _, tc := range tests {
tc := tc
t.Run(tc.IP, func(t *testing.T) {
t.Parallel()
if got := ipFromARPA(tc.ARPA); !got.Equal(net.ParseIP(tc.IP)) {
t.Errorf("unexpected ip, want: %s, got: %s", tc.IP, got)
}
})
}
}
func newDnsMsgWithClientIP(ip string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeA)
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
o.Option = append(o.Option, &dns.EDNS0_SUBNET{Address: net.ParseIP(ip)})
m.Extra = append(m.Extra, o)
return m
}
func Test_stripClientSubnet(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
wantSubnet bool
}{
{"no edns0", new(dns.Msg), false},
{"loopback IP v4", newDnsMsgWithClientIP("127.0.0.1"), false},
{"loopback IP v6", newDnsMsgWithClientIP("::1"), false},
{"private IP v4", newDnsMsgWithClientIP("192.168.1.123"), false},
{"private IP v6", newDnsMsgWithClientIP("fd12:3456:789a:1::1"), false},
{"public IP", newDnsMsgWithClientIP("1.1.1.1"), true},
{"invalid IP", newDnsMsgWithClientIP(""), true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stripClientSubnet(tc.msg)
hasSubnet := false
if opt := tc.msg.IsEdns0(); opt != nil {
for _, s := range opt.Option {
if _, ok := s.(*dns.EDNS0_SUBNET); ok {
hasSubnet = true
}
}
}
if tc.wantSubnet != hasSubnet {
t.Errorf("unexpected result, want: %v, got: %v", tc.wantSubnet, hasSubnet)
}
})
}
}
func newDnsMsgWithHostname(hostname string, typ uint16) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(hostname, typ)
return m
}
func Test_isLanHostnameQuery(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isLanHostnameQuery bool
}{
{"A", newDnsMsgWithHostname("foo", dns.TypeA), true},
{"AAAA", newDnsMsgWithHostname("foo", dns.TypeAAAA), true},
{"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},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isLanHostnameQuery(tc.msg); tc.isLanHostnameQuery != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isLanHostnameQuery, got)
}
})
}
}
func newDnsMsgPtr(ip string, t *testing.T) *dns.Msg {
t.Helper()
m := new(dns.Msg)
ptr, err := dns.ReverseAddr(ip)
if err != nil {
t.Fatal(err)
}
m.SetQuestion(ptr, dns.TypePTR)
return m
}
func Test_isPrivatePtrLookup(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isPrivatePtrLookup bool
}{
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
{"10.0.0.0/8", newDnsMsgPtr("10.0.0.123", t), true},
{"172.16.0.0/12", newDnsMsgPtr("172.16.0.123", t), true},
{"192.168.0.0/16", newDnsMsgPtr("192.168.1.123", t), true},
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", t), true},
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isPrivatePtrLookup(tc.msg); tc.isPrivatePtrLookup != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isPrivatePtrLookup, got)
}
})
}
}
func Test_isWanClient(t *testing.T) {
tests := []struct {
name string
addr net.Addr
isWanClient bool
}{
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
{"10.0.0.0/8", &net.UDPAddr{IP: net.ParseIP("10.0.0.123")}, false},
{"172.16.0.0/12", &net.UDPAddr{IP: net.ParseIP("172.16.0.123")}, false},
{"192.168.0.0/16", &net.UDPAddr{IP: net.ParseIP("192.168.1.123")}, false},
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, false},
{"Link Local Unicast", &net.UDPAddr{IP: net.ParseIP("fe80::69f6:e16e:8bdb:433f")}, false},
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isWanClient(tc.addr); tc.isWanClient != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isWanClient, got)
}
})
}
}

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

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

View File

@@ -3,6 +3,7 @@ package cli
import (
"context"
"strings"
"sync"
"time"
"github.com/miekg/dns"
@@ -15,6 +16,36 @@ const (
loopTestQtype = dns.TypeTXT
)
// newLoopGuard returns new loopGuard.
func newLoopGuard() *loopGuard {
return &loopGuard{inflight: make(map[string]struct{})}
}
// loopGuard guards against DNS loop, ensuring only one query
// for a given domain is processed at a time.
type loopGuard struct {
mu sync.Mutex
inflight map[string]struct{}
}
// TryLock marks the domain as being processed.
func (lg *loopGuard) TryLock(domain string) bool {
lg.mu.Lock()
defer lg.mu.Unlock()
if _, inflight := lg.inflight[domain]; !inflight {
lg.inflight[domain] = struct{}{}
return true
}
return false
}
// Unlock marks the domain as being done.
func (lg *loopGuard) Unlock(domain string) {
lg.mu.Lock()
defer lg.mu.Unlock()
delete(lg.inflight, domain)
}
// isLoop reports whether the given upstream config is detected as having DNS loop.
func (p *prog) isLoop(uc *ctrld.UpstreamConfig) bool {
p.loopMu.Lock()
@@ -56,7 +87,15 @@ func (p *prog) checkDnsLoop() {
mainLog.Load().Debug().Msg("start checking DNS loop")
upstream := make(map[string]*ctrld.UpstreamConfig)
p.loopMu.Lock()
for _, uc := range p.cfg.Upstream {
for n, uc := range p.cfg.Upstream {
if p.um.isDown("upstream." + n) {
continue
}
// Do not send test query to external upstream.
if !canBeLocalUpstream(uc.Domain) {
mainLog.Load().Debug().Msgf("skipping external: upstream.%s", n)
continue
}
uid := uc.UID()
p.loop[uid] = false
upstream[uid] = uc
@@ -66,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)
@@ -79,13 +122,15 @@ func (p *prog) checkDnsLoop() {
}
// checkDnsLoopTicker performs p.checkDnsLoop every minute.
func (p *prog) checkDnsLoopTicker() {
func (p *prog) checkDnsLoopTicker(ctx context.Context) {
timer := time.NewTicker(time.Minute)
defer timer.Stop()
for {
select {
case <-p.stopCh:
return
case <-ctx.Done():
return
case <-timer.C:
p.checkDnsLoop()
}

42
cmd/cli/loop_test.go Normal file
View File

@@ -0,0 +1,42 @@
package cli
import (
"sync"
"sync/atomic"
"testing"
)
func Test_loopGuard(t *testing.T) {
lg := newLoopGuard()
key := "foo"
var i atomic.Int64
var started atomic.Int64
n := 1000
do := func() {
locked := lg.TryLock(key)
defer lg.Unlock(key)
started.Add(1)
for started.Load() < 2 {
// Wait until at least 2 goroutines started, otherwise, on system with heavy load,
// or having only 1 CPU, all goroutines can be scheduled to run consequently.
}
if locked {
i.Add(1)
}
}
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
do()
}()
}
wg.Wait()
if i.Load() == int64(n) {
t.Fatalf("i must not be increased %d times", n)
}
}

View File

@@ -29,9 +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
@@ -39,8 +46,10 @@ var (
)
const (
cdUidFlagName = "cd"
cdOrgFlagName = "cd-org"
cdUidFlagName = "cd"
cdOrgFlagName = "cd-org"
customHostnameFlagName = "custom-hostname"
nextdnsFlagName = "nextdns"
)
func init() {
@@ -93,6 +102,7 @@ func initConsoleLogging() {
// initLogging initializes global logging setup.
func initLogging() {
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
initLoggingWithBackup(true)
}
@@ -115,14 +125,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)
@@ -131,7 +141,7 @@ func initLoggingWithBackup(doBackup bool) {
}
writers = append(writers, consoleWriter)
multi := zerolog.MultiLevelWriter(writers...)
l := mainLog.Load().Output(multi).With().Timestamp().Logger()
l := mainLog.Load().Output(multi).With().Logger()
mainLog.Store(&l)
// TODO: find a better way.
ctrld.ProxyLogger.Store(&l)

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

@@ -42,3 +42,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)
}
}

View File

@@ -1,7 +1,11 @@
//go:build !darwin
//go:build !darwin && !windows
package cli
import "net"
func patchNetIfaceName(iface *net.Interface) error { return nil }
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool { return true }
func validInterfacesMap() map[string]struct{} { return nil }

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

@@ -0,0 +1,34 @@
package cli
import (
"bufio"
"bytes"
"net"
"strings"
)
func patchNetIfaceName(iface *net.Interface) error {
return nil
}
// validInterface reports whether the *net.Interface is a valid one.
// On Windows, only physical interfaces are considered valid.
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
_, ok := validIfacesMap[iface.Name]
return ok
}
// validInterfacesMap returns a set of all physical interfaces.
func validInterfacesMap() map[string]struct{} {
out, err := powershell("Get-NetAdapter -Physical | Select-Object -ExpandProperty Name")
if err != nil {
return nil
}
m := make(map[string]struct{})
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
ifaceName := strings.TrimSpace(scanner.Text())
m[ifaceName] = struct{}{}
}
return m
}

View File

@@ -1,11 +1,13 @@
package cli
import (
"context"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
func (p *prog) watchLinkState() {
func (p *prog) watchLinkState(ctx context.Context) {
ch := make(chan netlink.LinkUpdate)
done := make(chan struct{})
defer close(done)
@@ -13,14 +15,19 @@ func (p *prog) watchLinkState() {
mainLog.Load().Warn().Err(err).Msg("could not subscribe link")
return
}
for lu := range ch {
if lu.Change == 0xFFFFFFFF {
continue
}
if lu.Change&unix.IFF_UP != 0 {
mainLog.Load().Debug().Msgf("link state changed, re-bootstrapping")
for _, uc := range p.cfg.Upstream {
uc.ReBootstrap()
for {
select {
case <-ctx.Done():
return
case lu := <-ch:
if lu.Change == 0xFFFFFFFF {
continue
}
if lu.Change&unix.IFF_UP != 0 {
mainLog.Load().Debug().Msgf("link state changed, re-bootstrapping")
for _, uc := range p.cfg.Upstream {
uc.ReBootstrap()
}
}
}
}

View File

@@ -2,4 +2,6 @@
package cli
func (p *prog) watchLinkState() {}
import "context"
func (p *prog) watchLinkState(ctx context.Context) {}

31
cmd/cli/nextdns.go Normal file
View File

@@ -0,0 +1,31 @@
package cli
import (
"fmt"
"github.com/Control-D-Inc/ctrld"
)
const nextdnsURL = "https://dns.nextdns.io"
func generateNextDNSConfig(uid string) {
if uid == "" {
return
}
mainLog.Load().Info().Msg("generating ctrld config for NextDNS resolver")
cfg = ctrld.Config{
Listener: map[string]*ctrld.ListenerConfig{
"0": {
IP: "0.0.0.0",
Port: 53,
},
},
Upstream: map[string]*ctrld.UpstreamConfig{
"0": {
Type: ctrld.ResolverTypeDOH3,
Endpoint: fmt.Sprintf("%s/%s", nextdnsURL, uid),
Timeout: 5000,
},
},
}
}

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

View File

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

View File

@@ -9,6 +9,7 @@ import (
"net"
"net/netip"
"os/exec"
"slices"
"strings"
"syscall"
"time"
@@ -16,6 +17,8 @@ import (
"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"
@@ -47,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
@@ -64,7 +71,6 @@ func setDNS(iface *net.Interface, nameservers []string) error {
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
trySystemdResolve := false
for i := 0; i < maxSetDNSAttempts; i++ {
if err := r.SetDNS(osConfig); err != nil {
@@ -118,6 +124,11 @@ 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) (err error) {
defer func() {
if err == nil {
@@ -127,7 +138,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")
@@ -193,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 {
@@ -274,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
@@ -283,19 +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
}

View File

@@ -2,80 +2,132 @@ package cli
import (
"errors"
"fmt"
"net"
"os/exec"
"os"
"slices"
"strconv"
"strings"
"sync"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
const (
v4InterfaceKeyPathFormat = `HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`
v6InterfaceKeyPathFormat = `HKLM:\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)
}
func setDnsPowershellCmd(iface *net.Interface, nameservers []string) string {
nss := make([]string, 0, len(nameservers))
for _, ns := range nameservers {
nss = append(nss, strconv.Quote(ns))
}
return fmt.Sprintf("Set-DnsClientServerAddress -InterfaceIndex %d -ServerAddresses (%s)", iface.Index, strings.Join(nss, ","))
}
// 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
}
if len(nameservers) > 1 {
secondaryDNS := nameservers[1]
_ = addSecondaryDNS(iface, secondaryDNS)
setDNSOnce.Do(func() {
// If there's a Dns server running, that means we are on AD with Dns feature enabled.
// Configuring the Dns server to forward queries to ctrld instead.
if windowsHasLocalDnsServerRunning() {
file := absHomeDir(windowsForwardersFilename)
oldForwardersContent, _ := os.ReadFile(file)
hasLocalIPv6Listener := needLocalIPv6Listener()
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
if !hasLocalIPv6Listener {
return false
}
return s == "::1"
})
if err := os.WriteFile(file, []byte(strings.Join(forwarders, ",")), 0600); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
}
oldForwarders := strings.Split(string(oldForwardersContent), ",")
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
}
}
})
out, err := powershell(setDnsPowershellCmd(iface, nameservers))
if err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
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 windowsHasLocalDnsServerRunning() {
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
}
}
})
// Restoring DHCP settings.
cmd := fmt.Sprintf("Set-DnsClientServerAddress -InterfaceIndex %d -ResetServerAddresses", iface.Index)
out, err := powershell(cmd)
if err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
// If there's static DNS saved, restoring it.
if nss := savedStaticNameservers(iface); len(nss) > 0 {
v4ns := make([]string, 0, 2)
v6ns := make([]string, 0, 2)
for _, ns := range nss {
if ctrldnet.IsIPv6(ns) {
v6ns = append(v6ns, ns)
} else {
v4ns = append(v4ns, ns)
}
}
for _, ns := range [][]string{v4ns, v6ns} {
if len(ns) == 0 {
continue
}
mainLog.Load().Debug().Msgf("setting static DNS for interface %q", iface.Name)
if err := setDNS(iface, ns); err != nil {
return 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()
}
func currentDNS(iface *net.Interface) []string {
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
@@ -93,3 +145,74 @@ func currentDNS(iface *net.Interface) []string {
}
return ns
}
// currentStaticDNS returns the current static DNS settings of given interface.
func currentStaticDNS(iface *net.Interface) ([]string, error) {
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return nil, err
}
guid, err := luid.GUID()
if err != nil {
return nil, err
}
var ns []string
for _, path := range []string{v4InterfaceKeyPathFormat, v6InterfaceKeyPathFormat} {
interfaceKeyPath := path + guid.String()
found := false
for _, key := range []string{"NameServer", "ProfileNameServer"} {
if found {
continue
}
cmd := fmt.Sprintf(`Get-ItemPropertyValue -Path "%s" -Name "%s"`, interfaceKeyPath, key)
out, err := powershell(cmd)
if err == nil && len(out) > 0 {
found = true
for _, e := range strings.Split(string(out), ",") {
ns = append(ns, strings.TrimRight(e, "\x00"))
}
}
}
}
return ns, nil
}
// 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
}

View File

@@ -2,24 +2,35 @@ package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"math/rand"
"net"
"net/netip"
"net/url"
"os"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/kardianos/service"
"tailscale.com/net/interfaces"
"github.com/rs/zerolog"
"github.com/spf13/viper"
"golang.org/x/sync/singleflight"
"tailscale.com/net/netmon"
"tailscale.com/net/tsaddr"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/clientinfo"
"github.com/Control-D-Inc/ctrld/internal/controld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
"github.com/Control-D-Inc/ctrld/internal/router"
)
@@ -28,10 +39,23 @@ const (
defaultSemaphoreCap = 256
ctrldLogUnixSock = "ctrld_start.sock"
ctrldControlUnixSock = "ctrld_control.sock"
upstreamPrefix = "upstream."
upstreamOS = upstreamPrefix + "os"
// iOS unix socket name max length is 11.
ctrldControlUnixSockMobile = "cd.sock"
upstreamPrefix = "upstream."
upstreamOS = upstreamPrefix + "os"
upstreamPrivate = upstreamPrefix + "private"
dnsWatchdogDefaultInterval = 20 * time.Second
)
// ControlSocketName returns name for control unix socket.
func ControlSocketName() string {
if isMobile() {
return ctrldControlUnixSockMobile
} else {
return ctrldControlUnixSock
}
}
var logf = func(format string, args ...any) {
mainLog.Load().Debug().Msgf(format, args...)
}
@@ -45,23 +69,49 @@ var svcConfig = &service.Config{
var useSystemdResolved = false
type prog struct {
mu sync.Mutex
waitCh chan struct{}
stopCh chan struct{}
logConn net.Conn
cs *controlServer
mu sync.Mutex
waitCh chan struct{}
stopCh chan struct{}
reloadCh chan struct{} // For Windows.
reloadDoneCh chan struct{}
apiReloadCh chan *ctrld.Config
apiForceReloadCh chan struct{}
apiForceReloadGroup singleflight.Group
logConn net.Conn
cs *controlServer
csSetDnsDone chan struct{}
csSetDnsOk bool
dnsWg sync.WaitGroup
dnsWatcherClosedOnce sync.Once
dnsWatcherStopCh chan struct{}
cfg *ctrld.Config
appCallback *AppCallback
cache dnscache.Cacher
sema semaphore
ciTable *clientinfo.Table
um *upstreamMonitor
router router.Router
cfg *ctrld.Config
localUpstreams []string
ptrNameservers []string
appCallback *AppCallback
cache dnscache.Cacher
cacheFlushDomainsMap map[string]struct{}
sema semaphore
ciTable *clientinfo.Table
um *upstreamMonitor
router router.Router
ptrLoopGuard *loopGuard
lanLoopGuard *loopGuard
metricsQueryStats atomic.Bool
queryFromSelfMap sync.Map
selfUninstallMu sync.Mutex
refusedQueryCount int
canSelfUninstall atomic.Bool
checkingSelfUninstall bool
loopMu sync.Mutex
loop map[string]bool
leakingQueryMu sync.Mutex
leakingQueryWasRun bool
leakingQuery atomic.Bool
started chan struct{}
onStartedDone chan struct{}
onStarted []func()
@@ -69,15 +119,120 @@ type prog struct {
}
func (p *prog) Start(s service.Service) error {
p.cfg = &cfg
go p.run()
go p.runWait()
return nil
}
func (p *prog) preRun() {
if !service.Interactive() {
p.setDNS()
// runWait runs ctrld components, waiting for signal to reload.
func (p *prog) runWait() {
p.mu.Lock()
p.cfg = &cfg
p.mu.Unlock()
reloadSigCh := make(chan os.Signal, 1)
notifyReloadSigCh(reloadSigCh)
reload := false
logger := mainLog.Load()
for {
reloadCh := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
p.run(reload, reloadCh)
reload = true
}()
var newCfg *ctrld.Config
select {
case sig := <-reloadSigCh:
logger.Notice().Msgf("got signal: %s, reloading...", sig.String())
case <-p.reloadCh:
logger.Notice().Msg("reloading...")
case apiCfg := <-p.apiReloadCh:
newCfg = apiCfg
case <-p.stopCh:
close(reloadCh)
return
}
waitOldRunDone := func() {
close(reloadCh)
<-done
}
if newCfg == nil {
newCfg = &ctrld.Config{}
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "ctrld")
if configPath != "" {
v.SetConfigFile(configPath)
}
if err := v.ReadInConfig(); err != nil {
logger.Err(err).Msg("could not read new config")
waitOldRunDone()
continue
}
if err := v.Unmarshal(&newCfg); err != nil {
logger.Err(err).Msg("could not unmarshal new config")
waitOldRunDone()
continue
}
if cdUID != "" {
if err := processCDFlags(newCfg); err != nil {
logger.Err(err).Msg("could not fetch ControlD config")
waitOldRunDone()
continue
}
}
}
waitOldRunDone()
p.mu.Lock()
curListener := p.cfg.Listener
p.mu.Unlock()
for n, lc := range newCfg.Listener {
curLc := curListener[n]
if curLc == nil {
continue
}
if lc.IP == "" {
lc.IP = curLc.IP
}
if lc.Port == 0 {
lc.Port = curLc.Port
}
}
if err := validateConfig(newCfg); err != nil {
logger.Err(err).Msg("invalid config")
continue
}
addExtraSplitDnsRule(newCfg)
if err := writeConfigFile(newCfg); err != nil {
logger.Err(err).Msg("could not write new config")
}
// This needs to be done here, otherwise, the DNS handler may observe an invalid
// upstream config because its initialization function have not been called yet.
mainLog.Load().Debug().Msg("setup upstream with new config")
p.setupUpstream(newCfg)
p.mu.Lock()
*p.cfg = *newCfg
p.mu.Unlock()
logger.Notice().Msg("reloading config successfully")
select {
case p.reloadDoneCh <- struct{}{}:
default:
}
}
}
func (p *prog) preRun() {
if runtime.GOOS == "darwin" {
p.onStopped = append(p.onStopped, func() {
if !service.Interactive() {
@@ -87,31 +242,166 @@ func (p *prog) preRun() {
}
}
func (p *prog) run() {
func (p *prog) postRun() {
if !service.Interactive() {
p.resetDNS()
ns := ctrld.InitializeOsResolver()
mainLog.Load().Debug().Msgf("initialized OS resolver with nameservers: %v", ns)
p.setDNS()
p.csSetDnsDone <- struct{}{}
close(p.csSetDnsDone)
}
}
// apiConfigReload calls API to check for latest config update then reload ctrld if necessary.
func (p *prog) apiConfigReload() {
if cdUID == "" {
return
}
ticker := time.NewTicker(timeDurationOrDefault(p.cfg.Service.RefetchTime, 3600) * time.Second)
defer ticker.Stop()
logger := mainLog.Load().With().Str("mode", "api-reload").Logger()
logger.Debug().Msg("starting custom config reload timer")
lastUpdated := time.Now().Unix()
doReloadApiConfig := func(forced bool, logger zerolog.Logger) {
resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev)
selfUninstallCheck(err, p, logger)
if err != nil {
logger.Warn().Err(err).Msg("could not fetch resolver config")
return
}
if resolverConfig.DeactivationPin != nil {
newDeactivationPin := *resolverConfig.DeactivationPin
curDeactivationPin := cdDeactivationPin.Load()
switch {
case curDeactivationPin != defaultDeactivationPin:
logger.Debug().Msg("saving deactivation pin")
case curDeactivationPin != newDeactivationPin:
logger.Debug().Msg("update deactivation pin")
}
cdDeactivationPin.Store(newDeactivationPin)
} else {
cdDeactivationPin.Store(defaultDeactivationPin)
}
if resolverConfig.Ctrld.CustomConfig == "" {
return
}
if resolverConfig.Ctrld.CustomLastUpdate > lastUpdated || forced {
lastUpdated = time.Now().Unix()
cfg := &ctrld.Config{}
if err := validateCdRemoteConfig(resolverConfig, cfg); err != nil {
logger.Warn().Err(err).Msg("skipping invalid custom config")
if _, err := controld.UpdateCustomLastFailed(cdUID, rootCmd.Version, cdDev, true); err != nil {
logger.Error().Err(err).Msg("could not mark custom last update failed")
}
return
}
setListenerDefaultValue(cfg)
logger.Debug().Msg("custom config changes detected, reloading...")
p.apiReloadCh <- cfg
} else {
logger.Debug().Msg("custom config does not change")
}
}
for {
select {
case <-p.apiForceReloadCh:
doReloadApiConfig(true, logger.With().Bool("forced", true).Logger())
case <-ticker.C:
doReloadApiConfig(false, logger)
case <-p.stopCh:
return
}
}
}
func (p *prog) setupUpstream(cfg *ctrld.Config) {
localUpstreams := make([]string, 0, len(cfg.Upstream))
ptrNameservers := make([]string, 0, len(cfg.Upstream))
isControlDUpstream := false
for n := range cfg.Upstream {
uc := cfg.Upstream[n]
sdns := uc.Type == ctrld.ResolverTypeSDNS
uc.Init()
if sdns {
mainLog.Load().Debug().Msgf("initialized DNS Stamps with endpoint: %s, type: %s", uc.Endpoint, uc.Type)
}
isControlDUpstream = isControlDUpstream || uc.IsControlD()
if uc.BootstrapIP == "" {
uc.SetupBootstrapIP()
mainLog.Load().Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
} else {
mainLog.Load().Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("using bootstrap IP for upstream.%s", n)
}
uc.SetCertPool(rootCertPool)
go uc.Ping()
if canBeLocalUpstream(uc.Domain) {
localUpstreams = append(localUpstreams, upstreamPrefix+n)
}
if uc.IsDiscoverable() {
ptrNameservers = append(ptrNameservers, uc.Endpoint)
}
}
// Self-uninstallation is ok If there is only 1 ControlD upstream, and no remote config.
if len(cfg.Upstream) == 1 && isControlDUpstream {
p.canSelfUninstall.Store(true)
}
p.localUpstreams = localUpstreams
p.ptrNameservers = ptrNameservers
}
// run runs the ctrld main components.
//
// The reload boolean indicates that the function is run when ctrld first start
// or when ctrld receive reloading signal. Platform specifics setup is only done
// on started, mean reload is "false".
//
// The reloadCh is used to signal ctrld listeners that ctrld is going to be reloaded,
// so all listeners could be terminated and re-spawned again.
func (p *prog) run(reload bool, reloadCh chan struct{}) {
// Wait the caller to signal that we can do our logic.
<-p.waitCh
p.preRun()
if !reload {
p.preRun()
}
numListeners := len(p.cfg.Listener)
p.started = make(chan struct{}, numListeners)
if !reload {
p.started = make(chan struct{}, numListeners)
if p.cs != nil {
p.csSetDnsDone = make(chan struct{}, 1)
p.registerControlServerHandler()
if err := p.cs.start(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not start control server")
}
mainLog.Load().Debug().Msgf("control server started: %s", p.cs.addr)
}
}
p.onStartedDone = make(chan struct{})
p.loop = make(map[string]bool)
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
p.cacheFlushDomainsMap = nil
p.metricsQueryStats.Store(p.cfg.Service.MetricsQueryStats)
if p.cfg.Service.CacheEnable {
cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create cacher, caching is disabled")
} else {
p.cache = cacher
p.cacheFlushDomainsMap = make(map[string]struct{}, 256)
for _, domain := range p.cfg.Service.CacheFlushDomains {
p.cacheFlushDomainsMap[canonicalName(domain)] = struct{}{}
}
}
}
p.sema = &chanSemaphore{ready: make(chan struct{}, defaultSemaphoreCap)}
if mcr := p.cfg.Service.MaxConcurrentRequests; mcr != nil {
n := *mcr
if n == 0 {
p.sema = &noopSemaphore{}
} else {
p.sema = &chanSemaphore{ready: make(chan struct{}, n)}
}
}
var wg sync.WaitGroup
wg.Add(len(p.cfg.Listener))
@@ -127,80 +417,118 @@ func (p *prog) run() {
}
p.um = newUpstreamMonitor(p.cfg)
for n := range p.cfg.Upstream {
uc := p.cfg.Upstream[n]
uc.Init()
if uc.BootstrapIP == "" {
uc.SetupBootstrapIP()
mainLog.Load().Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
} else {
mainLog.Load().Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("using bootstrap IP for upstream.%s", n)
if !reload {
p.sema = &chanSemaphore{ready: make(chan struct{}, defaultSemaphoreCap)}
if mcr := p.cfg.Service.MaxConcurrentRequests; mcr != nil {
n := *mcr
if n == 0 {
p.sema = &noopSemaphore{}
} else {
p.sema = &chanSemaphore{ready: make(chan struct{}, n)}
}
}
p.setupUpstream(p.cfg)
p.ciTable = clientinfo.NewTable(&cfg, defaultRouteIP(), cdUID, p.ptrNameservers)
if leaseFile := p.cfg.Service.DHCPLeaseFile; leaseFile != "" {
mainLog.Load().Debug().Msgf("watching custom lease file: %s", leaseFile)
format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat)
p.ciTable.AddLeaseFile(leaseFile, format)
}
uc.SetCertPool(rootCertPool)
go uc.Ping()
}
p.ciTable = clientinfo.NewTable(&cfg, defaultRouteIP(), cdUID)
if leaseFile := p.cfg.Service.DHCPLeaseFile; leaseFile != "" {
mainLog.Load().Debug().Msgf("watching custom lease file: %s", leaseFile)
format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat)
p.ciTable.AddLeaseFile(leaseFile, format)
}
// context for managing spawn goroutines.
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
// Newer versions of android and iOS denies permission which breaks connectivity.
if !isMobile() {
if !isMobile() && !reload {
wg.Add(1)
go func() {
defer wg.Done()
p.ciTable.Init()
p.ciTable.RefreshLoop(p.stopCh)
p.ciTable.RefreshLoop(ctx)
}()
go p.watchLinkState()
go p.watchLinkState(ctx)
}
for listenerNum := range p.cfg.Listener {
p.cfg.Listener[listenerNum].Init()
go func(listenerNum string) {
defer wg.Done()
listenerConfig := p.cfg.Listener[listenerNum]
upstreamConfig := p.cfg.Upstream[listenerNum]
if upstreamConfig == nil {
mainLog.Load().Warn().Msgf("no default upstream for: [listener.%s]", listenerNum)
if !reload {
go func(listenerNum string) {
listenerConfig := p.cfg.Listener[listenerNum]
upstreamConfig := p.cfg.Upstream[listenerNum]
if upstreamConfig == nil {
mainLog.Load().Warn().Msgf("no default upstream for: [listener.%s]", listenerNum)
}
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
mainLog.Load().Info().Msgf("starting DNS server on listener.%s: %s", listenerNum, addr)
if err := p.serveDNS(listenerNum); err != nil {
mainLog.Load().Fatal().Err(err).Msgf("unable to start dns proxy on listener.%s", listenerNum)
}
}(listenerNum)
}
go func() {
defer func() {
cancelFunc()
wg.Done()
}()
select {
case <-p.stopCh:
case <-ctx.Done():
case <-reloadCh:
}
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
mainLog.Load().Info().Msgf("starting DNS server on listener.%s: %s", listenerNum, addr)
if err := p.serveDNS(listenerNum); err != nil {
mainLog.Load().Fatal().Err(err).Msgf("unable to start dns proxy on listener.%s", listenerNum)
}
}(listenerNum)
}()
}
for i := 0; i < numListeners; i++ {
<-p.started
if !reload {
for i := 0; i < numListeners; i++ {
<-p.started
}
for _, f := range p.onStarted {
f()
}
}
for _, f := range p.onStarted {
f()
}
// Check for possible DNS loop.
p.checkDnsLoop()
close(p.onStartedDone)
// Start check DNS loop ticker.
go p.checkDnsLoopTicker()
wg.Add(1)
go func() {
defer wg.Done()
// Check for possible DNS loop.
p.checkDnsLoop()
// Start check DNS loop ticker.
p.checkDnsLoopTicker(ctx)
}()
// Stop writing log to unix socket.
consoleWriter.Out = os.Stdout
initLoggingWithBackup(false)
if p.logConn != nil {
_ = p.logConn.Close()
}
if p.cs != nil {
p.registerControlServerHandler()
if err := p.cs.start(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not start control server")
wg.Add(1)
// Prometheus exporter goroutine.
go func() {
defer wg.Done()
p.runMetricsServer(ctx, reloadCh)
}()
if !reload {
// Stop writing log to unix socket.
consoleWriter.Out = os.Stdout
initLoggingWithBackup(false)
if p.logConn != nil {
_ = p.logConn.Close()
}
go p.apiConfigReload()
p.postRun()
}
wg.Wait()
}
// metricsEnabled reports whether prometheus exporter is enabled/disabled.
func (p *prog) metricsEnabled() bool {
return p.cfg.Service.MetricsQueryStats || p.cfg.Service.MetricsListener != ""
}
func (p *prog) Stop(s service.Service) error {
p.stopDnsWatchers()
mainLog.Load().Debug().Msg("dns watchers stopped")
mainLog.Load().Info().Msg("Service stopped")
close(p.stopCh)
if err := p.deAllocateIP(); err != nil {
@@ -210,6 +538,15 @@ func (p *prog) Stop(s service.Service) error {
return nil
}
func (p *prog) stopDnsWatchers() {
// Ensure all DNS watchers goroutine are terminated,
// so it won't mess up with other DNS changes.
p.dnsWatcherClosedOnce.Do(func() {
close(p.dnsWatcherStopCh)
})
p.dnsWg.Wait()
}
func (p *prog) allocateIP(ip string) error {
p.mu.Lock()
defer p.mu.Unlock()
@@ -234,21 +571,33 @@ func (p *prog) deAllocateIP() error {
}
func (p *prog) setDNS() {
setDnsOK := false
defer func() {
p.csSetDnsOk = setDnsOK
}()
if cfg.Listener == nil {
return
}
if iface == "" {
return
}
if iface == "auto" {
iface = defaultIfaceName()
runningIface := iface
// allIfaces tracks whether we should set DNS for all physical interfaces.
allIfaces := false
if runningIface == "auto" {
runningIface = defaultIfaceName()
// If runningIface is "auto", it means user does not specify "--iface" flag.
// In this case, ctrld has to set DNS for all physical interfaces, so
// thing will still work when user switch from one to the other.
allIfaces = requiredMultiNICsConfig()
}
lc := cfg.FirstListener()
if lc == nil {
return
}
logger := mainLog.Load().With().Str("iface", iface).Logger()
netIface, err := netInterface(iface)
logger := mainLog.Load().With().Str("iface", runningIface).Logger()
netIface, err := netInterface(runningIface)
if err != nil {
logger.Error().Err(err).Msg("could not get interface")
return
@@ -276,24 +625,119 @@ func (p *prog) setDNS() {
nameservers := []string{ns}
if needRFC1918Listeners(lc) {
nameservers = append(nameservers, rfc1918Addresses()...)
nameservers = append(nameservers, ctrld.Rfc1918Addresses()...)
}
if needLocalIPv6Listener() {
nameservers = append(nameservers, "::1")
}
slices.Sort(nameservers)
if err := setDNS(netIface, nameservers); err != nil {
logger.Error().Err(err).Msgf("could not set DNS for interface")
return
}
setDnsOK = true
logger.Debug().Msg("setting DNS successfully")
if allIfaces {
withEachPhysicalInterfaces(netIface.Name, "set DNS", func(i *net.Interface) error {
return setDnsIgnoreUnusableInterface(i, nameservers)
})
}
if shouldWatchResolvconf() {
servers := make([]netip.Addr, len(nameservers))
for i := range nameservers {
servers[i] = netip.MustParseAddr(nameservers[i])
}
p.dnsWg.Add(1)
go func() {
defer p.dnsWg.Done()
p.watchResolvConf(netIface, servers, setResolvConf)
}()
}
if p.dnsWatchdogEnabled() {
p.dnsWg.Add(1)
go func() {
defer p.dnsWg.Done()
p.dnsWatchdog(netIface, nameservers, allIfaces)
}()
}
}
// dnsWatchdogEnabled reports whether DNS watchdog is enabled.
func (p *prog) dnsWatchdogEnabled() bool {
if ptr := p.cfg.Service.DnsWatchdogEnabled; ptr != nil {
return *ptr
}
return true
}
// dnsWatchdogDuration returns the time duration between each DNS watchdog loop.
func (p *prog) dnsWatchdogDuration() time.Duration {
if ptr := p.cfg.Service.DnsWatchdogInvterval; ptr != nil {
if (*ptr).Seconds() > 0 {
return *ptr
}
}
return dnsWatchdogDefaultInterval
}
// dnsWatchdog watches for DNS changes on Darwin and Windows then re-applying ctrld's settings.
// This is only works when deactivation pin set.
func (p *prog) dnsWatchdog(iface *net.Interface, nameservers []string, allIfaces bool) {
if !requiredMultiNICsConfig() {
return
}
mainLog.Load().Debug().Msg("start DNS settings watchdog")
ns := nameservers
slices.Sort(ns)
ticker := time.NewTicker(p.dnsWatchdogDuration())
logger := mainLog.Load().With().Str("iface", iface.Name).Logger()
for {
select {
case <-p.dnsWatcherStopCh:
return
case <-p.stopCh:
mainLog.Load().Debug().Msg("stop dns watchdog")
return
case <-ticker.C:
if p.leakingQuery.Load() {
return
}
if dnsChanged(iface, ns) {
logger.Debug().Msg("DNS settings were changed, re-applying settings")
if err := setDNS(iface, ns); err != nil {
mainLog.Load().Error().Err(err).Str("iface", iface.Name).Msgf("could not re-apply DNS settings")
}
}
if allIfaces {
withEachPhysicalInterfaces(iface.Name, "", func(i *net.Interface) error {
if dnsChanged(i, ns) {
if err := setDnsIgnoreUnusableInterface(i, nameservers); err != nil {
mainLog.Load().Error().Err(err).Str("iface", i.Name).Msgf("could not re-apply DNS settings")
} else {
mainLog.Load().Debug().Msgf("re-applying DNS for interface %q successfully", i.Name)
}
}
return nil
})
}
}
}
}
func (p *prog) resetDNS() {
if iface == "" {
return
}
if iface == "auto" {
iface = defaultIfaceName()
runningIface := iface
allIfaces := false
if runningIface == "auto" {
runningIface = defaultIfaceName()
// See corresponding comments in (*prog).setDNS function.
allIfaces = requiredMultiNICsConfig()
}
logger := mainLog.Load().With().Str("iface", iface).Logger()
netIface, err := netInterface(iface)
logger := mainLog.Load().With().Str("iface", runningIface).Logger()
netIface, err := netInterface(runningIface)
if err != nil {
logger.Error().Err(err).Msg("could not get interface")
return
@@ -308,6 +752,21 @@ func (p *prog) resetDNS() {
return
}
logger.Debug().Msg("Restoring DNS successfully")
if allIfaces {
withEachPhysicalInterfaces(netIface.Name, "reset DNS", resetDnsIgnoreUnusableInterface)
}
}
// leakOnUpstreamFailure reports whether ctrld should leak query to OS resolver when failed to connect all upstreams.
func (p *prog) leakOnUpstreamFailure() bool {
if ptr := p.cfg.Service.LeakOnUpstreamFailure; ptr != nil {
return *ptr
}
// Default is false on routers, since this leaking is only useful for devices that move between networks.
if router.Name() != "" {
return false
}
return true
}
func randomLocalIP() string {
@@ -360,6 +819,7 @@ var (
windowsENETUNREACH = syscall.Errno(10051)
windowsEINVAL = syscall.Errno(10022)
windowsEADDRINUSE = syscall.Errno(10048)
windowsEHOSTUNREACH = syscall.Errno(10065)
)
func errUrlNetworkError(err error) bool {
@@ -382,13 +842,23 @@ func errNetworkError(err error) bool {
errors.Is(opErr.Err, syscall.ENETUNREACH),
errors.Is(opErr.Err, windowsENETUNREACH),
errors.Is(opErr.Err, windowsEINVAL),
errors.Is(opErr.Err, windowsECONNREFUSED):
errors.Is(opErr.Err, windowsECONNREFUSED),
errors.Is(opErr.Err, windowsEHOSTUNREACH):
return true
}
}
return false
}
// errConnectionRefused reports whether err is connection refused.
func errConnectionRefused(err error) bool {
var opErr *net.OpError
if !errors.As(err, &opErr) {
return false
}
return errors.Is(opErr.Err, syscall.ECONNREFUSED) || errors.Is(opErr.Err, windowsECONNREFUSED)
}
func ifaceFirstPrivateIP(iface *net.Interface) string {
if iface == nil {
return ""
@@ -416,7 +886,7 @@ func ifaceFirstPrivateIP(iface *net.Interface) string {
// defaultRouteIP returns private IP string of the default route if present, prefer IPv4 over IPv6.
func defaultRouteIP() string {
dr, err := interfaces.DefaultRoute()
dr, err := netmon.DefaultRoute()
if err != nil {
return ""
}
@@ -436,7 +906,7 @@ func defaultRouteIP() string {
// There could be multiple LAN interfaces with the same Mac address, so we find all private
// IPs then using the smallest one.
var addrs []netip.Addr
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
if i.Name == drNetIface.Name {
return
}
@@ -462,3 +932,123 @@ func defaultRouteIP() string {
mainLog.Load().Debug().Str("ip", ip).Msg("found LAN interface IP")
return ip
}
// canBeLocalUpstream reports whether the IP address can be used as a local upstream.
func canBeLocalUpstream(addr string) bool {
if ip, err := netip.ParseAddr(addr); err == nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
}
return false
}
// withEachPhysicalInterfaces runs the function f with each physical interfaces, excluding
// the interface that matches excludeIfaceName. The context is used to clarify the
// log message when error happens.
func withEachPhysicalInterfaces(excludeIfaceName, context string, f func(i *net.Interface) error) {
validIfacesMap := validInterfacesMap()
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
// Skip loopback/virtual interface.
if i.IsLoopback() || len(i.HardwareAddr) == 0 {
return
}
// Skip invalid interface.
if !validInterface(i.Interface, validIfacesMap) {
return
}
netIface := i.Interface
if err := patchNetIfaceName(netIface); err != nil {
mainLog.Load().Debug().Err(err).Msg("failed to patch net interface name")
return
}
// Skip excluded interface.
if netIface.Name == excludeIfaceName {
return
}
// TODO: investigate whether we should report this error?
if err := f(netIface); err == nil {
if context != "" {
mainLog.Load().Debug().Msgf("%s for interface %q successfully", context, i.Name)
}
} else if !errors.Is(err, errSaveCurrentStaticDNSNotSupported) {
mainLog.Load().Err(err).Msgf("%s for interface %q failed", context, i.Name)
}
})
}
// requiredMultiNicConfig reports whether ctrld needs to set/reset DNS for multiple NICs.
func requiredMultiNICsConfig() bool {
switch runtime.GOOS {
case "windows", "darwin":
return true
default:
return false
}
}
var errSaveCurrentStaticDNSNotSupported = errors.New("saving current DNS is not supported on this platform")
// saveCurrentStaticDNS saves the current static DNS settings for restoring later.
// Only works on Windows and Mac.
func saveCurrentStaticDNS(iface *net.Interface) error {
switch runtime.GOOS {
case "windows", "darwin":
default:
return errSaveCurrentStaticDNSNotSupported
}
file := savedStaticDnsSettingsFilePath(iface)
ns, _ := currentStaticDNS(iface)
if len(ns) == 0 {
_ = os.Remove(file) // removing old static DNS settings
return nil
}
if err := os.Remove(file); err != nil && !errors.Is(err, fs.ErrNotExist) {
mainLog.Load().Warn().Err(err).Msg("could not remove old static DNS settings file")
}
nss := strings.Join(ns, ",")
mainLog.Load().Debug().Msgf("DNS settings for %q is static: %v, saving ...", iface.Name, nss)
if err := os.WriteFile(file, []byte(nss), 0600); err != nil {
mainLog.Load().Err(err).Msgf("could not save DNS settings for iface: %s", iface.Name)
return err
}
mainLog.Load().Debug().Msgf("save DNS settings for interface %q successfully", iface.Name)
return nil
}
// savedStaticDnsSettingsFilePath returns the path to saved DNS settings of the given interface.
func savedStaticDnsSettingsFilePath(iface *net.Interface) string {
return absHomeDir(".dns_" + iface.Name)
}
// savedStaticNameservers returns the static DNS nameservers of the given interface.
//
//lint:ignore U1000 use in os_windows.go and os_darwin.go
func savedStaticNameservers(iface *net.Interface) []string {
file := savedStaticDnsSettingsFilePath(iface)
if data, _ := os.ReadFile(file); len(data) > 0 {
return strings.Split(string(data), ",")
}
return nil
}
// dnsChanged reports whether DNS settings for given interface was changed.
// The caller must sort the nameservers before calling this function.
func dnsChanged(iface *net.Interface, nameservers []string) bool {
curNameservers, _ := currentStaticDNS(iface)
slices.Sort(curNameservers)
if !slices.Equal(curNameservers, nameservers) {
mainLog.Load().Debug().Msgf("interface %q current DNS settings: %v, expected: %v", iface.Name, curNameservers, nameservers)
return true
}
return false
}
// selfUninstallCheck checks if the error dues to controld.InvalidConfigCode, perform self-uninstall then.
func selfUninstallCheck(uninstallErr error, p *prog, logger zerolog.Logger) {
var uer *controld.UtilityErrorResponse
if errors.As(uninstallErr, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
p.stopDnsWatchers()
// Perform self-uninstall now.
selfUninstall(p, logger)
}
}

View File

@@ -1,15 +1,28 @@
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"
)
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,13 +31,34 @@ 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",
"After=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")
}
}
}
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)
}
})
}
}

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

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

17
cmd/cli/reload_others.go Normal file
View File

@@ -0,0 +1,17 @@
//go:build !windows
package cli
import (
"os"
"os/signal"
"syscall"
)
func notifyReloadSigCh(ch chan os.Signal) {
signal.Notify(ch, syscall.SIGUSR1)
}
func (p *prog) sendReloadSignal() error {
return syscall.Kill(syscall.Getpid(), syscall.SIGUSR1)
}

18
cmd/cli/reload_windows.go Normal file
View File

@@ -0,0 +1,18 @@
package cli
import (
"errors"
"os"
"time"
)
func notifyReloadSigCh(ch chan os.Signal) {}
func (p *prog) sendReloadSignal() error {
select {
case p.reloadCh <- struct{}{}:
return nil
case <-time.After(5 * time.Second):
}
return errors.New("timeout while sending reload signal")
}

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

@@ -0,0 +1,73 @@
package cli
import (
"net"
"net/netip"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
// 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.leakingQuery.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().Msg("/etc/resolv.conf changes detected, reverting to ctrld setting")
if err := watcher.Remove(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
continue
}
if err := setDnsFn(iface, ns); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
}
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
return
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
}
}
}

View File

@@ -0,0 +1,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

@@ -20,14 +20,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 +92,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,6 +130,29 @@ 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

View File

@@ -9,3 +9,7 @@ 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))
}

View File

@@ -1,6 +1,11 @@
package cli
import "golang.org/x/sys/windows"
import (
"os"
"syscall"
"golang.org/x/sys/windows"
)
func hasElevatedPrivilege() (bool, error) {
var sid *windows.SID
@@ -22,3 +27,55 @@ func hasElevatedPrivilege() (bool, error) {
token := windows.Token(0)
return token.IsMember(sid)
}
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
}

View File

@@ -3,11 +3,9 @@ package cli
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"tailscale.com/logtail/backoff"
"github.com/Control-D-Inc/ctrld"
)
@@ -15,68 +13,79 @@ import (
const (
// maxFailureRequest is the maximum failed queries allowed before an upstream is marked as down.
maxFailureRequest = 100
// checkUpstreamMaxBackoff is the max backoff time when checking upstream status.
checkUpstreamMaxBackoff = 2 * time.Minute
// checkUpstreamBackoffSleep is the time interval between each upstream checks.
checkUpstreamBackoffSleep = 2 * time.Second
)
// upstreamMonitor performs monitoring upstreams health.
type upstreamMonitor struct {
cfg *ctrld.Config
down map[string]*atomic.Bool
failureReq map[string]*atomic.Uint64
mu sync.Mutex
checking map[string]bool
mu sync.Mutex
checking map[string]bool
down map[string]bool
failureReq map[string]uint64
}
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
um := &upstreamMonitor{
cfg: cfg,
down: make(map[string]*atomic.Bool),
failureReq: make(map[string]*atomic.Uint64),
checking: make(map[string]bool),
down: make(map[string]bool),
failureReq: make(map[string]uint64),
}
for n := range cfg.Upstream {
upstream := upstreamPrefix + n
um.down[upstream] = new(atomic.Bool)
um.failureReq[upstream] = new(atomic.Uint64)
um.reset(upstream)
}
um.down[upstreamOS] = new(atomic.Bool)
um.failureReq[upstreamOS] = new(atomic.Uint64)
um.reset(upstreamOS)
return um
}
// increaseFailureCount increase failed queries count for an upstream by 1.
func (um *upstreamMonitor) increaseFailureCount(upstream string) {
failedCount := um.failureReq[upstream].Add(1)
um.down[upstream].Store(failedCount >= maxFailureRequest)
um.mu.Lock()
defer um.mu.Unlock()
um.failureReq[upstream] += 1
failedCount := um.failureReq[upstream]
um.down[upstream] = failedCount >= maxFailureRequest
}
// isDown reports whether the given upstream is being marked as down.
func (um *upstreamMonitor) isDown(upstream string) bool {
return um.down[upstream].Load()
um.mu.Lock()
defer um.mu.Unlock()
return um.down[upstream]
}
// reset marks an upstream as up and set failed queries counter to zero.
func (um *upstreamMonitor) reset(upstream string) {
um.failureReq[upstream].Store(0)
um.down[upstream].Store(false)
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]
func (p *prog) checkUpstream(upstream string, uc *ctrld.UpstreamConfig) {
p.um.mu.Lock()
isChecking := p.um.checking[upstream]
if isChecking {
um.mu.Unlock()
p.um.mu.Unlock()
return
}
um.checking[upstream] = true
um.mu.Unlock()
p.um.checking[upstream] = true
p.um.mu.Unlock()
defer func() {
p.um.mu.Lock()
p.um.checking[upstream] = false
p.um.mu.Unlock()
}()
bo := backoff.NewBackoff("checkUpstream", logf, checkUpstreamMaxBackoff)
resolver, err := ctrld.NewResolver(uc)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not check upstream")
@@ -84,15 +93,26 @@ func (um *upstreamMonitor) checkUpstream(upstream string, uc *ctrld.UpstreamConf
}
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
ctx := context.Background()
for {
check := func() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
uc.ReBootstrap()
_, err := resolver.Resolve(ctx, msg)
if err == nil {
return err
}
for {
if err := check(); err == nil {
mainLog.Load().Debug().Msgf("upstream %q is online", uc.Endpoint)
um.reset(upstream)
p.um.reset(upstream)
if p.leakingQuery.CompareAndSwap(true, false) {
p.leakingQueryMu.Lock()
p.leakingQueryWasRun = false
p.leakingQueryMu.Unlock()
mainLog.Load().Warn().Msg("stop leaking query")
}
return
}
bo.BackOff(ctx, err)
time.Sleep(checkUpstreamBackoffSleep)
}
}

View File

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

View File

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

View File

@@ -28,14 +28,15 @@ type AppCallback interface {
// Start configures utility with config.toml from provided directory.
// This function will block until Stop is called
// Check port availability prior to calling it.
func (c *Controller) Start(CdUID string, HomeDir string, logLevel int, logPath string) {
func (c *Controller) Start(CdUID string, HomeDir string, UpstreamProto string, logLevel int, logPath string) {
if c.stopCh == nil {
c.stopCh = make(chan struct{})
c.Config = cli.AppConfig{
CdUID: CdUID,
HomeDir: HomeDir,
Verbose: logLevel,
LogPath: logPath,
CdUID: CdUID,
HomeDir: HomeDir,
UpstreamProto: UpstreamProto,
Verbose: logLevel,
LogPath: logPath,
}
appCallback := mapCallback(c.AppCallback)
cli.RunMobile(&c.Config, &appCallback, c.stopCh)
@@ -60,13 +61,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 {

275
config.go
View File

@@ -7,10 +7,12 @@ import (
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"runtime"
@@ -21,11 +23,14 @@ 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"
"github.com/Control-D-Inc/ctrld/internal/dnsrcode"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
@@ -44,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 (
@@ -82,6 +101,16 @@ func InitConfig(v *viper.Viper, name string) {
"0": {
IP: "",
Port: 0,
Policy: &ListenerPolicyConfig{
Name: "Main Policy",
Networks: []Rule{
{"network.0": []string{"upstream.0"}},
},
Rules: []Rule{
{"example.com": []string{"upstream.0"}},
{"*.ads.com": []string{"upstream.1"}},
},
},
},
})
v.SetDefault("network", map[string]*NetworkConfig{
@@ -92,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",
@@ -167,22 +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"`
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"`
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.
@@ -195,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:"-"`
@@ -204,6 +243,9 @@ type UpstreamConfig struct {
// The caller should not access this field directly.
// Use UpstreamSendClientInfo instead.
SendClientInfo *bool `mapstructure:"send_client_info" toml:"send_client_info,omitempty"`
// The caller should not access this field directly.
// Use IsDiscoverable instead.
Discoverable *bool `mapstructure:"discoverable" toml:"discoverable"`
g singleflight.Group
rebootstrap atomic.Bool
@@ -224,10 +266,11 @@ type UpstreamConfig struct {
// ListenerConfig specifies the networks configuration that ctrld will run on.
type ListenerConfig struct {
IP string `mapstructure:"ip" toml:"ip,omitempty" validate:"iporempty"`
Port int `mapstructure:"port" toml:"port,omitempty" validate:"gte=0"`
Restricted bool `mapstructure:"restricted" toml:"restricted,omitempty"`
Policy *ListenerPolicyConfig `mapstructure:"policy" toml:"policy,omitempty"`
IP string `mapstructure:"ip" toml:"ip,omitempty" validate:"iporempty"`
Port int `mapstructure:"port" toml:"port,omitempty" validate:"gte=0"`
Restricted bool `mapstructure:"restricted" toml:"restricted,omitempty"`
AllowWanClients bool `mapstructure:"allow_wan_clients" toml:"allow_wan_clients,omitempty"`
Policy *ListenerPolicyConfig `mapstructure:"policy" toml:"policy,omitempty"`
}
// IsDirectDnsListener reports whether ctrld can be a direct listener on port 53.
@@ -253,6 +296,7 @@ type ListenerPolicyConfig struct {
Name string `mapstructure:"name" toml:"name,omitempty"`
Networks []Rule `mapstructure:"networks" toml:"networks,omitempty,inline,multiline" validate:"dive,len=1"`
Rules []Rule `mapstructure:"rules" toml:"rules,omitempty,inline,multiline" validate:"dive,len=1"`
Macs []Rule `mapstructure:"macs" toml:"macs,omitempty,inline,multiline" validate:"dive,len=1"`
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
}
@@ -264,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
@@ -284,7 +332,7 @@ func (uc *UpstreamConfig) Init() {
}
}
if uc.IPStack == "" {
if uc.isControlD() {
if uc.IsControlD() {
uc.IPStack = IpStackSplit
} else {
uc.IPStack = IpStackBoth
@@ -322,13 +370,28 @@ func (uc *UpstreamConfig) UpstreamSendClientInfo() bool {
}
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
if uc.isControlD() {
if uc.IsControlD() || uc.isNextDNS() {
return true
}
}
return false
}
// IsDiscoverable reports whether the upstream can be used for PTR discovery.
// The caller must ensure uc.Init() was called before calling this.
func (uc *UpstreamConfig) IsDiscoverable() bool {
if uc.Discoverable != nil {
return *uc.Discoverable
}
switch uc.Type {
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate:
if ip, err := netip.ParseAddr(uc.Domain); err == nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
}
}
return false
}
// BootstrapIPs returns the bootstrap IPs list of upstreams.
func (uc *UpstreamConfig) BootstrapIPs() []string {
return uc.bootstrapIPs
@@ -354,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,
@@ -394,8 +457,9 @@ func (uc *UpstreamConfig) ReBootstrap() {
return
}
_, _, _ = uc.g.Do("ReBootstrap", func() (any, error) {
ProxyLogger.Load().Debug().Msg("re-bootstrapping upstream ip")
uc.rebootstrap.Store(true)
if uc.rebootstrap.CompareAndSwap(false, true) {
ProxyLogger.Load().Debug().Msg("re-bootstrapping upstream ip")
}
return true, nil
})
}
@@ -438,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
@@ -473,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 {
@@ -519,6 +611,16 @@ func (uc *UpstreamConfig) isControlD() bool {
return false
}
func (uc *UpstreamConfig) isNextDNS() bool {
domain := uc.Domain
if domain == "" {
if u, err := url.Parse(uc.Endpoint); err == nil {
domain = u.Hostname()
}
}
return domain == "dns.nextdns.io"
}
func (uc *UpstreamConfig) dohTransport(dnsType uint16) http.RoundTripper {
uc.transportOnce.Do(func() {
uc.SetupTransport()
@@ -584,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 {
@@ -636,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)
@@ -643,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
}
}
}
@@ -668,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, ":") {

View File

@@ -17,7 +17,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 +26,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 +179,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 {
@@ -279,6 +426,61 @@ func TestUpstreamConfig_UpstreamSendClientInfo(t *testing.T) {
}
}
func TestUpstreamConfig_IsDiscoverable(t *testing.T) {
tests := []struct {
name string
uc *UpstreamConfig
discoverable bool
}{
{
"loopback",
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy},
true,
},
{
"rfc1918",
&UpstreamConfig{Endpoint: "192.168.1.1", Type: ResolverTypeLegacy},
true,
},
{
"CGNAT",
&UpstreamConfig{Endpoint: "100.66.67.68", Type: ResolverTypeLegacy},
true,
},
{
"Public IP",
&UpstreamConfig{Endpoint: "8.8.8.8", Type: ResolverTypeLegacy},
false,
},
{
"override discoverable",
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(false)},
false,
},
{
"override non-public",
&UpstreamConfig{Endpoint: "1.1.1.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(true)},
true,
},
{
"non-legacy upstream",
&UpstreamConfig{Endpoint: "https://192.168.1.1/custom-doh", Type: ResolverTypeDOH},
false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init()
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
}
})
}
}
func ptrBool(b bool) *bool {
return &b
}

View File

@@ -1,5 +1,3 @@
//go:build !qf
package ctrld
import (
@@ -10,13 +8,10 @@ import (
"net/http"
"runtime"
"sync"
"time"
"github.com/miekg/dns"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
func (uc *UpstreamConfig) setupDOH3Transport() {
@@ -29,9 +24,7 @@ func (uc *UpstreamConfig) setupDOH3Transport() {
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs6)
case IpStackSplit:
uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if ctrldnet.IPv6Available(ctx) {
if hasIPv6() {
uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6)
} else {
uc.http3RoundTripper6 = uc.http3RoundTripper4
@@ -127,11 +120,6 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
close(ch)
}()
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
for _, addr := range addrs {
go func(addr string) {
defer wg.Done()
@@ -140,6 +128,11 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
ch <- &parallelDialerResult{conn: nil, err: err}
return
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
ch <- &parallelDialerResult{conn: nil, err: err}
return
}
conn, err := quic.DialEarly(ctx, udpConn, remoteAddr, tlsCfg, cfg)
select {
case ch <- &parallelDialerResult{conn: conn, err: err}:
@@ -147,6 +140,9 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
if conn != nil {
conn.CloseWithError(quic.ApplicationErrorCode(http3.ErrCodeNoError), "")
}
if udpConn != nil {
udpConn.Close()
}
}
}(addr)
}

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")
@@ -54,7 +58,12 @@ func TestLoadDefaultConfig(t *testing.T) {
cfg := defaultConfig(t)
validate := validator.New()
require.NoError(t, ctrld.ValidateConfig(validate, cfg))
assert.Len(t, cfg.Listener, 1)
if assert.Len(t, cfg.Listener, 1) {
l0 := cfg.Listener["0"]
require.NotNil(t, l0.Policy)
assert.Len(t, l0.Policy.Networks, 1)
assert.Len(t, l0.Policy.Rules, 2)
}
assert.Len(t, cfg.Upstream, 2)
}
@@ -96,6 +105,12 @@ func TestConfigValidation(t *testing.T) {
{"lease file format required if lease file exist", configWithExistedLeaseFile(t), true},
{"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},
}
for _, tc := range tests {
@@ -115,6 +130,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")
@@ -138,6 +191,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
@@ -229,7 +309,22 @@ func configWithInvalidLeaseFileFormat(t *testing.T) *ctrld.Config {
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
}
func configWithInvalidClientIDPref(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
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,6 +157,13 @@ 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.
@@ -200,6 +207,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,9 +227,67 @@ 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. 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.
- 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
Checking DNS changes to network interfaces and reverting to ctrld's own 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 if changed.
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
Once ctrld is "offline", mean ctrld could not connect to any upstream, next queries will be leaked to OS resolver.
- 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.
@@ -299,7 +372,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.
@@ -319,6 +392,24 @@ If `ip_stack` is empty, or undefined:
- Default value is `both` for non-Control D resolvers.
- Default value is `split` for Control D resolvers.
### send_client_info
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
- Default:
- `true` for ControlD upstreams.
- `false` for other upstreams.
### discoverable
Specifying whether the upstream can be used for PTR discovery.
- Type: boolean
- Required: no
- Default:
- `true` for loopback/RFC1918/CGNAT IP address.
- `false` for public IP address.
## Network
The `[network]` section defines networks from which DNS queries can originate from. These are used in policies. You can define multiple networks, and each one can have multiple cidrs.
@@ -376,7 +467,14 @@ Port number that the listener will listen on for incoming requests. If `port` is
- Default: 0 or 53 or 5354 (depending on platform)
### restricted
If set to `true` makes the listener `REFUSE` DNS queries from all source IP addresses that are not explicitly defined in the policy using a `network`.
If set to `true`, makes the listener `REFUSED` DNS queries from all source IP addresses that are not explicitly defined in the policy using a `network`.
- Type: bool
- Required: no
- Default: false
### allow_wan_clients
The listener will refuse DNS queries from WAN IPs using `REFUSED` RCODE by default. Set to `true` to disable this behavior, but this is not recommended.
- Type: bool
- Required: no
@@ -386,7 +484,15 @@ If set to `true` makes the listener `REFUSE` DNS queries from all source IP addr
Allows `ctrld` to set policy rules to determine which upstreams the requests will be forwarded to.
If no `policy` is defined or the requests do not match any policy rules, it will be forwarded to corresponding upstream of the listener. For example, the request to `listener.0` will be forwarded to `upstream.0`.
The policy `rule` syntax is a simple `toml` inline table with exactly one key/value pair per rule. `key` is either the `network` or a domain. Value is the list of the upstreams. For example:
The policy `rule` syntax is a simple `toml` inline table with exactly one key/value pair per rule. `key` is either:
- Network.
- Domain.
- Mac Address.
Value is the list of the upstreams.
For example:
```toml
[listener.0.policy]
@@ -400,12 +506,18 @@ rules = [
{"*.local" = ["upstream.1"]},
{"test.com" = ["upstream.2", "upstream.1"]},
]
macs = [
{"14:54:4a:8e:08:2d" = ["upstream.3"]},
]
```
Above policy will:
- Forward requests on `listener.0` from `network.0` to `upstream.1`.
- Forward requests on `listener.0` for `.local` suffixed domains to `upstream.1`.
- Forward requests on `listener.0` for `test.com` to `upstream.2`. If timeout is reached, retry on `upstream.1`.
- Forward requests on `listener.0` from client with Mac `14:54:4a:8e:08:2d` to `upstream.3`.
- Forward requests on `listener.0` from `network.0` to `upstream.1`.
- All other requests on `listener.0` that do not match above conditions will be forwarded to `upstream.0`.
An empty upstream would not route the request to any defined upstreams, and use the OS default resolver.
@@ -419,6 +531,18 @@ rules = [
]
```
---
Note that the order of matching preference:
```
rules => macs => networks
```
And within each policy, the rules are processed from top to bottom.
---
#### name
`name` is the name for the policy.
@@ -440,10 +564,23 @@ rules = [
- 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.
- Type: array of macs
- Required: no
- Default: []
### 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: []
-
@@ -460,7 +597,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

103
doh.go
View File

@@ -18,11 +18,12 @@ import (
)
const (
dohMacHeader = "x-cd-mac"
dohIPHeader = "x-cd-ip"
dohHostHeader = "x-cd-host"
dohOsHeader = "x-cd-os"
headerApplicationDNS = "application/dns-message"
dohMacHeader = "x-cd-mac"
dohIPHeader = "x-cd-ip"
dohHostHeader = "x-cd-host"
dohOsHeader = "x-cd-os"
dohClientIDPrefHeader = "x-cd-cpref"
headerApplicationDNS = "application/dns-message"
)
// EncodeOsNameMap provides mapping from OS name to a shorter string, used for encoding x-cd-os value.
@@ -59,24 +60,16 @@ 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{
endpoint: uc.u,
isDoH3: uc.Type == ResolverTypeDOH3,
http3RoundTripper: uc.http3RoundTripper,
sendClientInfo: uc.UpstreamSendClientInfo(),
uc: uc,
}
return r
@@ -87,9 +80,9 @@ type dohResolver struct {
endpoint *url.URL
isDoH3 bool
http3RoundTripper http.RoundTripper
sendClientInfo bool
}
// Resolve performs DNS query with given DNS message using DOH protocol.
func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
data, err := msg.Pack()
if err != nil {
@@ -106,7 +99,7 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
if err != nil {
return nil, fmt.Errorf("could not create request: %w", err)
}
addHeader(ctx, req, r.sendClientInfo)
addHeader(ctx, req, r.uc)
dnsTyp := uint16(0)
if len(msg.Question) > 0 {
dnsTyp = msg.Question[0].Qtype
@@ -146,30 +139,66 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
return answer, nil
}
func addHeader(ctx context.Context, req *http.Request, sendClientInfo bool) {
req.Header.Set("Content-Type", headerApplicationDNS)
req.Header.Set("Accept", headerApplicationDNS)
req.Header.Set(dohOsHeader, dohOsHeaderValue())
// addHeader adds necessary HTTP header to request based on upstream config.
func addHeader(ctx context.Context, req *http.Request, uc *UpstreamConfig) {
printed := false
if sendClientInfo {
dohHeader := make(http.Header)
if uc.UpstreamSendClientInfo() {
if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil {
printed = ci.Mac != "" || ci.IP != "" || ci.Hostname != ""
if ci.Mac != "" {
req.Header.Set(dohMacHeader, ci.Mac)
}
if ci.IP != "" {
req.Header.Set(dohIPHeader, ci.IP)
}
if ci.Hostname != "" {
req.Header.Set(dohHostHeader, ci.Hostname)
}
if ci.Self {
req.Header.Set(dohOsHeader, dohOsHeaderValue())
switch {
case uc.IsControlD():
dohHeader = newControlDHeaders(ci)
case uc.isNextDNS():
dohHeader = newNextDNSHeaders(ci)
}
}
}
if printed {
Log(ctx, ProxyLogger.Load().Debug().Interface("header", req.Header), "sending request header")
Log(ctx, ProxyLogger.Load().Debug(), "sending request header: %v", dohHeader)
}
dohHeader.Set("Content-Type", headerApplicationDNS)
dohHeader.Set("Accept", headerApplicationDNS)
req.Header = dohHeader
}
// newControlDHeaders returns DoH/Doh3 HTTP request headers for ControlD upstream.
func newControlDHeaders(ci *ClientInfo) http.Header {
header := make(http.Header)
if ci.Mac != "" {
header.Set(dohMacHeader, ci.Mac)
}
if ci.IP != "" {
header.Set(dohIPHeader, ci.IP)
}
if ci.Hostname != "" {
header.Set(dohHostHeader, ci.Hostname)
}
if ci.Self {
header.Set(dohOsHeader, dohOsHeaderValue)
}
switch ci.ClientIDPref {
case "mac":
header.Set(dohClientIDPrefHeader, "1")
case "host":
header.Set(dohClientIDPrefHeader, "2")
}
return header
}
// newNextDNSHeaders returns DoH/Doh3 HTTP request headers for nextdns upstream.
// https://github.com/nextdns/nextdns/blob/v1.41.0/resolver/doh.go#L100
func newNextDNSHeaders(ci *ClientInfo) http.Header {
header := make(http.Header)
if ci.Mac != "" {
// https: //github.com/nextdns/nextdns/blob/v1.41.0/run.go#L543
header.Set("X-Device-Model", "mac:"+ci.Mac[:8])
}
if ci.IP != "" {
header.Set("X-Device-Ip", ci.IP)
}
if ci.Hostname != "" {
header.Set("X-Device-Name", ci.Hostname)
}
return header
}

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

89
go.mod
View File

@@ -1,81 +1,98 @@
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/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/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.42.0
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.10.0
golang.org/x/sync v0.2.0
golang.org/x/sys v0.8.1-0.20230609144347-5059a07aa46a
golang.org/x/net v0.27.0
golang.org/x/sync v0.7.0
golang.org/x/sys v0.22.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-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/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/pierrec/lz4/v4 v4.1.21 // 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.4.0 // indirect
github.com/quic-go/qtls-go1-20 v0.3.2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/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.9.0 // indirect
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/tools v0.9.1 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
golang.org/x/mod v0.19.0 // indirect
golang.org/x/text v0.16.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
)

203
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,44 +40,57 @@ 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/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-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/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-20230329055532-c513f836da19 h1:7P/f19Mr0oa3ug8BYt4JuRe/Zq3dF4Mrr4m8+Kw+Hcs=
github.com/cuonglm/osinfo v0.0.0-20230329055532-c513f836da19/go.mod h1:G45410zMgmnSjLVKCq4f6GpbYAzoP2plX9rPwgx6C24=
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/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-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=
@@ -87,8 +102,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=
@@ -100,8 +115,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=
@@ -116,7 +129,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=
@@ -128,8 +142,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=
@@ -143,8 +158,8 @@ 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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
@@ -157,21 +172,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=
@@ -196,22 +212,25 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
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.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/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=
@@ -219,23 +238,33 @@ 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/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/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.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/quic-go/qtls-go1-20 v0.3.2 h1:rRgN3WfnKbyik4dBV8A6girlJVxGand/d+jVKbQq5GI=
github.com/quic-go/qtls-go1-20 v0.3.2/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
github.com/quic-go/quic-go v0.38.0 h1:T45lASr5q/TrVwt+jrVccmqHhPL2XuSyoCLVCpfOSLc=
github.com/quic-go/quic-go v0.38.0/go.mod h1:MPCuRq7KBK2hNcfKj/1iD1BGuN3eAYMeNxp3T42LRUg=
github.com/quic-go/quic-go v0.42.0 h1:uSfdap0eveIl8KXnipv9K7nlwZ5IqLlYOpJ58u5utpM=
github.com/quic-go/quic-go v0.42.0/go.mod h1:132kz4kL3F9vxhW3CtQJLDVwcFe5wdWeJXXijhsO57M=
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=
@@ -243,16 +272,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/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.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
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=
@@ -261,8 +290,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=
@@ -270,12 +300,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=
@@ -285,25 +316,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.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
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=
@@ -314,8 +350,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-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
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=
@@ -331,8 +367,6 @@ golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPI
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda h1:O+EUvnBNPwI4eLthn8W5K+cS8zQZfgTABPLNm6Bna34=
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
@@ -341,16 +375,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=
@@ -376,10 +408,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.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
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=
@@ -399,21 +430,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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.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=
@@ -439,21 +468,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.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.8.1-0.20230609144347-5059a07aa46a h1:qMsju+PNttu/NMbq8bQ9waDdxgJMu9QNoUDuhnBaYt0=
golang.org/x/sys v0.8.1-0.20230609144347-5059a07aa46a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.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=
@@ -463,11 +492,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.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
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=
@@ -515,9 +546,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=
@@ -612,7 +642,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=
@@ -635,5 +666,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

@@ -1,8 +1,11 @@
package clientinfo
import (
"context"
"fmt"
"net"
"net/netip"
"strconv"
"strings"
"sync"
"time"
@@ -11,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
@@ -55,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 {
@@ -67,26 +77,36 @@ type Table struct {
hostnameResolvers []HostnameResolver
refreshers []refresher
initOnce sync.Once
refreshInterval int
dhcp *dhcp
merlin *merlinDiscover
arp *arpDiscover
ptr *ptrDiscover
mdns *mdns
hf *hostsFile
vni *virtualNetworkIface
cfg *ctrld.Config
quitCh chan struct{}
selfIP string
cdUID string
dhcp *dhcp
merlin *merlinDiscover
ubios *ubiosDiscover
arp *arpDiscover
ndp *ndpDiscover
ptr *ptrDiscover
mdns *mdns
hf *hostsFile
vni *virtualNetworkIface
svcCfg ctrld.ServiceConfig
quitCh chan struct{}
selfIP string
cdUID string
ptrNameservers []string
}
func NewTable(cfg *ctrld.Config, selfIP, cdUID string) *Table {
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{
cfg: cfg,
quitCh: make(chan struct{}),
selfIP: selfIP,
cdUID: cdUID,
svcCfg: cfg.Service,
quitCh: make(chan struct{}),
selfIP: selfIP,
cdUID: cdUID,
ptrNameservers: ns,
refreshInterval: refreshInterval,
}
}
@@ -97,8 +117,9 @@ func (t *Table) AddLeaseFile(name string, format ctrld.LeaseFileFormat) {
clientInfoFiles[name] = format
}
func (t *Table) RefreshLoop(stopCh chan struct{}) {
timer := time.NewTicker(time.Minute * 5)
// RefreshLoop runs all the refresher to update new client info data.
func (t *Table) RefreshLoop(ctx context.Context) {
timer := time.NewTicker(time.Second * time.Duration(t.refreshInterval))
defer timer.Stop()
for {
select {
@@ -106,7 +127,7 @@ func (t *Table) RefreshLoop(stopCh chan struct{}) {
for _, r := range t.refreshers {
_ = r.refresh()
}
case <-stopCh:
case <-ctx.Done():
close(t.quitCh)
return
}
@@ -132,14 +153,26 @@ func (t *Table) init() {
// Otherwise, process all possible sources in order, that means
// the first result of IP/MAC/Hostname lookup will be used.
//
// Merlin custom clients.
// Routers custom clients:
// - Merlin
// - Ubios
if t.discoverDHCP() || t.discoverARP() {
t.merlin = &merlinDiscover{}
if err := t.merlin.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init Merlin discover")
} else {
t.hostnameResolvers = append(t.hostnameResolvers, t.merlin)
t.refreshers = append(t.refreshers, t.merlin)
t.ubios = &ubiosDiscover{}
discovers := map[string]interface {
refresher
HostnameResolver
}{
"Merlin": t.merlin,
"Ubios": t.ubios,
}
for platform, discover := range discovers {
if err := discover.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msgf("could not init %s discover", platform)
} else {
t.hostnameResolvers = append(t.hostnameResolvers, discover)
t.refreshers = append(t.refreshers, discover)
}
}
}
// Hosts file mapping.
@@ -167,21 +200,60 @@ 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() {
t.ptr = &ptrDiscover{resolver: ctrld.NewPrivateResolver()}
if len(t.ptrNameservers) > 0 {
nss := make([]string, 0, len(t.ptrNameservers))
for _, ns := range t.ptrNameservers {
host, port := ns, "53"
if h, p, err := net.SplitHostPort(ns); err == nil {
host, port = h, p
}
// Only use valid ip:port pair.
if _, portErr := strconv.Atoi(port); portErr == nil && port != "0" && net.ParseIP(host) != nil {
nss = append(nss, net.JoinHostPort(host, port))
} else {
ctrld.ProxyLogger.Load().Warn().Msgf("ignoring invalid nameserver for ptr discover: %q", ns)
}
}
if len(nss) > 0 {
t.ptr.resolver = ctrld.NewResolverWithNameserver(nss)
ctrld.ProxyLogger.Load().Debug().Msgf("using nameservers %v for ptr discovery", nss)
}
}
ctrld.ProxyLogger.Load().Debug().Msg("start ptr discovery")
if err := t.ptr.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init PTR discover")
@@ -240,6 +312,31 @@ func (t *Table) LookupHostname(ip, mac string) string {
return ""
}
// LookupRFC1918IPv4 returns the RFC1918 IPv4 address for the given MAC address, if any.
func (t *Table) LookupRFC1918IPv4(mac string) string {
t.initOnce.Do(t.init)
for _, r := range t.ipResolvers {
ip, err := netip.ParseAddr(r.LookupIP(mac))
if err != nil || ip.Is6() {
continue
}
if ip.IsPrivate() {
return ip.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
@@ -288,7 +385,7 @@ func (t *Table) ListClients() []*Client {
_ = r.refresh()
}
ipMap := make(map[string]*Client)
il := []ipLister{t.dhcp, t.arp, t.ptr, t.mdns, t.vni}
il := []ipLister{t.dhcp, t.arp, t.ndp, t.ptr, t.mdns, t.vni}
for _, ir := range il {
for _, ip := range ir.List() {
c, ok := ipMap[ip]
@@ -303,6 +400,7 @@ func (t *Table) ListClients() []*Client {
}
}
}
clientsByMAC := make(map[string]*Client)
for ip := range ipMap {
c := ipMap[ip]
for _, e := range t.lookupMacAll(ip) {
@@ -316,6 +414,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{}{}
@@ -324,6 +423,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
@@ -338,39 +442,60 @@ func (t *Table) StoreVPNClient(ci *ctrld.ClientInfo) {
t.vni.ip2name.Store(ci.IP, ci.Hostname)
}
// ipFinder is the interface for retrieving IP address from hostname.
type ipFinder interface {
lookupIPByHostname(name string, v6 bool) string
}
// LookupIPByHostname returns the ip address of given hostname.
// If v6 is true, return IPv6 instead of default IPv4.
func (t *Table) LookupIPByHostname(hostname string, v6 bool) *netip.Addr {
if t == nil {
return nil
}
for _, finder := range []ipFinder{t.hf, t.ptr, t.mdns, t.dhcp} {
if addr := finder.lookupIPByHostname(hostname, v6); addr != "" {
if ip, err := netip.ParseAddr(addr); err == nil {
return &ip
}
}
}
return nil
}
func (t *Table) discoverDHCP() bool {
if t.cfg.Service.DiscoverDHCP == nil {
if t.svcCfg.DiscoverDHCP == nil {
return true
}
return *t.cfg.Service.DiscoverDHCP
return *t.svcCfg.DiscoverDHCP
}
func (t *Table) discoverARP() bool {
if t.cfg.Service.DiscoverARP == nil {
if t.svcCfg.DiscoverARP == nil {
return true
}
return *t.cfg.Service.DiscoverARP
return *t.svcCfg.DiscoverARP
}
func (t *Table) discoverMDNS() bool {
if t.cfg.Service.DiscoverMDNS == nil {
if t.svcCfg.DiscoverMDNS == nil {
return true
}
return *t.cfg.Service.DiscoverMDNS
return *t.svcCfg.DiscoverMDNS
}
func (t *Table) discoverPTR() bool {
if t.cfg.Service.DiscoverPtr == nil {
if t.svcCfg.DiscoverPtr == nil {
return true
}
return *t.cfg.Service.DiscoverPtr
return *t.svcCfg.DiscoverPtr
}
func (t *Table) discoverHosts() bool {
if t.cfg.Service.DiscoverHosts == nil {
if t.svcCfg.DiscoverHosts == nil {
return true
}
return *t.cfg.Service.DiscoverHosts
return *t.svcCfg.DiscoverHosts
}
// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file.

View File

@@ -25,3 +25,50 @@ func Test_normalizeIP(t *testing.T) {
})
}
}
func TestTable_LookupRFC1918IPv4(t *testing.T) {
table := &Table{
dhcp: &dhcp{},
arp: &arpDiscover{},
}
table.ipResolvers = append(table.ipResolvers, table.dhcp)
table.ipResolvers = append(table.ipResolvers, table.arp)
macAddress := "cc:19:f9:8a:49:e6"
rfc1918IPv4 := "10.0.10.245"
table.dhcp.ip.Store(macAddress, "127.0.0.1")
table.arp.ip.Store(macAddress, rfc1918IPv4)
if got := table.LookupRFC1918IPv4(macAddress); got != rfc1918IPv4 {
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,16 +3,19 @@ package clientinfo
import (
"bufio"
"bytes"
"encoding/csv"
"fmt"
"io"
"net"
"net/netip"
"os"
"sort"
"strings"
"sync"
"tailscale.com/net/netmon"
"github.com/fsnotify/fsnotify"
"tailscale.com/net/interfaces"
"tailscale.com/util/lineread"
"github.com/Control-D-Inc/ctrld"
@@ -134,6 +137,39 @@ func (d *dhcp) List() []string {
return ips
}
func (d *dhcp) lookupIPByHostname(name string, v6 bool) string {
if d == nil {
return ""
}
var (
rfc1918Addrs []netip.Addr
others []netip.Addr
)
d.ip2name.Range(func(key, value any) bool {
if value != name {
return true
}
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
if addr.IsPrivate() {
rfc1918Addrs = append(rfc1918Addrs, addr)
} else {
others = append(others, addr)
}
}
return true
})
result := [][]netip.Addr{rfc1918Addrs, others}
for _, addrs := range result {
if len(addrs) > 0 {
sort.Slice(addrs, func(i, j int) bool {
return addrs[i].Less(addrs[j])
})
return addrs[0].String()
}
}
return ""
}
// AddLeaseFile adds given lease file for reading/watching clients info.
func (d *dhcp) addLeaseFile(name string, format ctrld.LeaseFileFormat) error {
if d.watcher == nil {
@@ -153,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)
}
@@ -168,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)
@@ -210,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
@@ -253,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() {
@@ -262,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 == "" {
@@ -284,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")
}
@@ -86,3 +127,15 @@ lease 192.168.1.2 {
})
}
}
func Test_dhcp_lookupIPByHostname(t *testing.T) {
d := &dhcp{}
want := "192.168.1.123"
d.ip2name.Store(want, "foo")
d.ip2name.Store("127.0.0.1", "foo")
d.ip2name.Store("169.254.123.123", "foo")
if got := d.lookupIPByHostname("foo", false); got != want {
t.Fatalf("unexpected result, want: %s, got: %s", want, got)
}
}

View File

@@ -1,7 +1,12 @@
package clientinfo
import (
"bufio"
"bytes"
"io"
"net/netip"
"os"
"strings"
"sync"
"github.com/fsnotify/fsnotify"
@@ -11,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.
@@ -33,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.
@@ -51,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
}
@@ -86,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.
@@ -109,6 +118,24 @@ func (hf *hostsFile) String() string {
return "hosts"
}
func (hf *hostsFile) lookupIPByHostname(name string, v6 bool) string {
if hf == nil {
return ""
}
hf.mu.Lock()
defer hf.mu.Unlock()
for addr, names := range hf.m {
if ip, err := netip.ParseAddr(addr); err == nil && !ip.IsLoopback() {
for _, n := range names {
if n == name && ip.Is6() == v6 {
return ip.String()
}
}
}
}
return ""
}
// isLocalhostName reports whether the given hostname represents localhost.
func isLocalhostName(hostname string) bool {
switch hostname {
@@ -118,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,10 +1,16 @@
package clientinfo
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net"
"net/netip"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -59,6 +65,27 @@ func (m *mdns) List() []string {
return ips
}
func (m *mdns) lookupIPByHostname(name string, v6 bool) string {
if m == nil {
return ""
}
var ip string
m.name.Range(func(key, value any) bool {
if value == name {
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
ip = addr.String()
//lint:ignore S1008 This is used for readable.
if addr.IsLoopback() { // Continue searching if this is loopback address.
return true
}
return false
}
}
return true
})
return ip
}
func (m *mdns) init(quitCh chan struct{}) error {
ifaces, err := multicastInterfaces()
if err != nil {
@@ -85,6 +112,7 @@ func (m *mdns) init(quitCh chan struct{}) error {
go m.probeLoop(v4ConnList, mdnsV4Addr, quitCh)
go m.probeLoop(v6ConnList, mdnsV6Addr, quitCh)
go m.getDataFromAvahiDaemonCache()
return nil
}
@@ -94,8 +122,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 {
@@ -123,6 +151,10 @@ func (m *mdns) readLoop(conn *net.UDPConn) {
if err, ok := err.(*net.OpError); ok && (err.Timeout() || err.Temporary()) {
continue
}
// Do not complain about use of closed network connection.
if errors.Is(err, net.ErrClosed) {
return
}
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("mdns readLoop error")
return
}
@@ -133,7 +165,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 {
@@ -186,6 +218,44 @@ func (m *mdns) probe(conns []*net.UDPConn, remoteAddr net.Addr) error {
return err
}
// getDataFromAvahiDaemonCache reads entries from avahi-daemon cache to update mdns data.
func (m *mdns) getDataFromAvahiDaemonCache() {
if _, err := exec.LookPath("avahi-browse"); err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not find avahi-browse binary, skipping.")
return
}
// Run avahi-browse to discover services from cache:
// - "-a" -> all services.
// - "-r" -> resolve found services.
// - "-p" -> parseable format.
// - "-c" -> read from cache.
out, err := exec.Command("avahi-browse", "-a", "-r", "-p", "-c").Output()
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not browse services from avahi cache")
return
}
m.storeDataFromAvahiBrowseOutput(bytes.NewReader(out))
}
// storeDataFromAvahiBrowseOutput parses avahi-browse output from reader, then updating found data to mdns table.
func (m *mdns) storeDataFromAvahiBrowseOutput(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.FieldsFunc(scanner.Text(), func(r rune) bool {
return r == ';'
})
if len(fields) < 8 || fields[0] != "=" {
continue
}
ip := fields[7]
name := normalizeHostname(fields[6])
// Only using cache value if we don't have existed one.
if _, loaded := m.name.LoadOrStore(ip, name); !loaded {
ctrld.ProxyLogger.Load().Debug().Msgf("found hostname: %q, ip: %q via avahi cache", name, ip)
}
}
}
func multicastInterfaces() ([]net.Interface, error) {
ifaces, err := net.Interfaces()
if err != nil {
@@ -203,10 +273,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

@@ -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

@@ -2,6 +2,7 @@ package clientinfo
import (
"context"
"net/netip"
"sync"
"sync/atomic"
"time"
@@ -72,15 +73,16 @@ func (p *ptrDiscover) lookupHostname(ip string) string {
msg := new(dns.Msg)
addr, err := dns.ReverseAddr(ip)
if err != nil {
ctrld.ProxyLogger.Load().Warn().Str("discovery", "ptr").Err(err).Msg("invalid ip address")
ctrld.ProxyLogger.Load().Info().Str("discovery", "ptr").Err(err).Msg("invalid ip address")
return ""
}
msg.SetQuestion(addr, dns.TypePTR)
ans, err := p.resolver.Resolve(ctx, msg)
if err != nil {
ctrld.ProxyLogger.Load().Warn().Str("discovery", "ptr").Err(err).Msg("could not perform PTR lookup")
p.serverDown.Store(true)
go p.checkServer()
if p.serverDown.CompareAndSwap(false, true) {
ctrld.ProxyLogger.Load().Info().Str("discovery", "ptr").Err(err).Msg("could not perform PTR lookup")
go p.checkServer()
}
return ""
}
for _, rr := range ans.Answer {
@@ -93,6 +95,27 @@ func (p *ptrDiscover) lookupHostname(ip string) string {
return ""
}
func (p *ptrDiscover) lookupIPByHostname(name string, v6 bool) string {
if p == nil {
return ""
}
var ip string
p.hostname.Range(func(key, value any) bool {
if value == name {
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
ip = addr.String()
//lint:ignore S1008 This is used for readable.
if addr.IsLoopback() { // Continue searching if this is loopback address.
return true
}
return false
}
}
return true
})
return ip
}
// checkServer monitors if the resolver can reach its nameserver. When the nameserver
// is reachable, set p.serverDown to false, so p.lookupHostname can continue working.
func (p *ptrDiscover) checkServer() {

View File

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

View File

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

View File

@@ -5,11 +5,13 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"runtime"
"strings"
"time"
@@ -25,17 +27,19 @@ const (
apiDomainDev = "api.controld.dev"
resolverDataURLCom = "https://api.controld.com/utility"
resolverDataURLDev = "https://api.controld.dev/utility"
InvalidConfigCode = 40401
InvalidConfigCode = 40402
)
// 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 {
@@ -61,7 +65,8 @@ 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"`
}
@@ -74,17 +79,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
@@ -96,6 +118,9 @@ 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()
@@ -119,7 +144,7 @@ func postUtilityAPI(version string, cdDev bool, body io.Reader) (*ResolverConfig
return d.DialContext(ctx, network, addrs)
}
if router.Name() == ddwrt.Name {
if router.Name() == ddwrt.Name || runtime.GOOS == "android" {
transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool()}
}
client := http.Client{

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.

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
//go:build linux || freebsd || openbsd
@@ -10,22 +9,41 @@ import (
"bytes"
"fmt"
"os/exec"
"strings"
"tailscale.com/types/logger"
)
// openresolvManager manages DNS configuration using the openresolv
// implementation of the `resolvconf` program.
type openresolvManager struct{}
type openresolvManager struct {
logf logger.Logf
}
var _ OSConfigurator = (*openresolvManager)(nil)
func newOpenresolvManager(logf logger.Logf) (openresolvManager, error) {
return openresolvManager{logf}, nil
}
func newOpenresolvManager() (openresolvManager, error) {
return openresolvManager{}, nil
func (m openresolvManager) logCmdErr(cmd *exec.Cmd, err error) {
if err == nil {
return
}
commandStr := fmt.Sprintf("path=%q args=%q", cmd.Path, cmd.Args)
exerr, ok := err.(*exec.ExitError)
if !ok {
m.logf("error running command %s: %v", commandStr, err)
return
}
m.logf("error running command %s stderr=%q exitCode=%d: %v", commandStr, exerr.Stderr, exerr.ExitCode(), err)
}
func (m openresolvManager) deleteTailscaleConfig() error {
cmd := exec.Command("resolvconf", "-f", "-d", "ctrld")
out, err := cmd.CombinedOutput()
if err != nil {
m.logCmdErr(cmd, err)
return fmt.Errorf("running %s: %s", cmd, out)
}
return nil
@@ -43,11 +61,55 @@ func (m openresolvManager) SetDNS(config OSConfig) error {
cmd.Stdin = &stdin
out, err := cmd.CombinedOutput()
if err != nil {
m.logCmdErr(cmd, err)
return fmt.Errorf("running %s: %s", cmd, out)
}
return nil
}
func (m openresolvManager) SupportsSplitDNS() bool {
return false
}
func (m openresolvManager) GetBaseConfig() (OSConfig, error) {
// List the names of all config snippets openresolv is aware
// of. Snippets get listed in priority order (most to least),
// which we'll exploit later.
bs, err := exec.Command("resolvconf", "-i").CombinedOutput()
if err != nil {
return OSConfig{}, err
}
// Remove the "tailscale" snippet from the list.
args := []string{"-l"}
for _, f := range strings.Split(strings.TrimSpace(string(bs)), " ") {
if f == "tailscale" {
continue
}
args = append(args, f)
}
// List all resolvconf snippets except our own, and parse that as
// a resolv.conf. This effectively generates a blended config of
// "everyone except tailscale", which is what would be in use if
// tailscale hadn't set exclusive mode.
//
// Note that this is not _entirely_ true. To be perfectly correct,
// we should be looking for other interfaces marked exclusive that
// predated tailscale, and stick to only those. However, in
// practice, openresolv uses are generally quite limited, and boil
// down to 1-2 DHCP leases, for which the correct outcome is a
// blended config like the one we produce here.
var buf bytes.Buffer
cmd := exec.Command("resolvconf", args...)
cmd.Stdout = &buf
if err := cmd.Run(); err != nil {
m.logCmdErr(cmd, err)
return OSConfig{}, err
}
return readResolv(&buf)
}
func (m openresolvManager) Close() error {
return m.deleteTailscaleConfig()
}

View File

@@ -1,20 +1,20 @@
// 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 (
"bufio"
"errors"
"fmt"
"net/netip"
"slices"
"strings"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
)
var _ OSConfigurator = (*directManager)(nil)
// An OSConfigurator applies DNS settings to the operating system.
type OSConfigurator interface {
// SetDNS updates the OS's DNS configuration to match cfg.
@@ -23,8 +23,21 @@ type OSConfigurator interface {
// SetDNS must not be called after Close.
// SetDNS takes ownership of cfg.
SetDNS(cfg OSConfig) error
// SupportsSplitDNS reports whether the configurator is capable of
// installing a resolver only for specific DNS suffixes. If false,
// the configurator can only set a global resolver.
SupportsSplitDNS() bool
// GetBaseConfig returns the OS's "base" configuration, i.e. the
// resolver settings the OS would use without Tailscale
// contributing any configuration.
// GetBaseConfig must return the tailscale-free base config even
// after SetDNS has been called to set a Tailscale configuration.
// Only works when SupportsSplitDNS=false.
// Close removes ctrld-related DNS configuration from the OS.
// Implementations that don't support getting the base config must
// return ErrGetBaseConfigNotSupported.
GetBaseConfig() (OSConfig, error)
// Close removes Tailscale-related DNS configuration from the OS.
Close() error
Mode() string
@@ -50,14 +63,59 @@ type OSConfig struct {
SearchDomains []dnsname.FQDN
// MatchDomains are the DNS suffixes for which Nameservers should
// be used. If empty, Nameservers is installed as the "primary" resolver.
// A non-empty MatchDomains requests a "split DNS" configuration
// from the OS, which will only work with OSConfigurators that
// report SupportsSplitDNS()=true.
MatchDomains []dnsname.FQDN
}
func (o *OSConfig) WriteToBufioWriter(w *bufio.Writer) {
if o == nil {
w.WriteString("<nil>")
return
}
w.WriteString("{")
if len(o.Hosts) > 0 {
fmt.Fprintf(w, "Hosts:%v ", o.Hosts)
}
if len(o.Nameservers) > 0 {
fmt.Fprintf(w, "Nameservers:%v ", o.Nameservers)
}
if len(o.SearchDomains) > 0 {
fmt.Fprintf(w, "SearchDomains:%v ", o.SearchDomains)
}
if len(o.MatchDomains) > 0 {
w.WriteString("MatchDomains:[")
sp := ""
var numARPA int
for _, s := range o.MatchDomains {
if strings.HasSuffix(string(s), ".arpa.") {
numARPA++
continue
}
w.WriteString(sp)
w.WriteString(string(s))
sp = " "
}
w.WriteString("]")
if numARPA > 0 {
fmt.Fprintf(w, "+%darpa", numARPA)
}
}
w.WriteString("}")
}
func (o OSConfig) IsZero() bool {
return len(o.Nameservers) == 0 && len(o.SearchDomains) == 0 && len(o.MatchDomains) == 0
return len(o.Hosts) == 0 &&
len(o.Nameservers) == 0 &&
len(o.SearchDomains) == 0 &&
len(o.MatchDomains) == 0
}
func (a OSConfig) Equal(b OSConfig) bool {
if len(a.Hosts) != len(b.Hosts) {
return false
}
if len(a.Nameservers) != len(b.Nameservers) {
return false
}
@@ -68,6 +126,15 @@ func (a OSConfig) Equal(b OSConfig) bool {
return false
}
for i := range a.Hosts {
ha, hb := a.Hosts[i], b.Hosts[i]
if ha.Addr != hb.Addr {
return false
}
if !slices.Equal(ha.Hosts, hb.Hosts) {
return false
}
}
for i := range a.Nameservers {
if a.Nameservers[i] != b.Nameservers[i] {
return false
@@ -93,34 +160,39 @@ func (a OSConfig) Equal(b OSConfig) bool {
// Fixes https://github.com/tailscale/tailscale/issues/5669
func (a OSConfig) Format(f fmt.State, verb rune) {
logger.ArgWriter(func(w *bufio.Writer) {
_, _ = w.WriteString(`{Nameservers:[`)
w.WriteString(`{Nameservers:[`)
for i, ns := range a.Nameservers {
if i != 0 {
_, _ = w.WriteString(" ")
w.WriteString(" ")
}
_, _ = fmt.Fprintf(w, "%+v", ns)
fmt.Fprintf(w, "%+v", ns)
}
_, _ = w.WriteString(`] SearchDomains:[`)
w.WriteString(`] SearchDomains:[`)
for i, domain := range a.SearchDomains {
if i != 0 {
_, _ = w.WriteString(" ")
w.WriteString(" ")
}
_, _ = fmt.Fprintf(w, "%+v", domain)
fmt.Fprintf(w, "%+v", domain)
}
_, _ = w.WriteString(`] MatchDomains:[`)
w.WriteString(`] MatchDomains:[`)
for i, domain := range a.MatchDomains {
if i != 0 {
_, _ = w.WriteString(" ")
w.WriteString(" ")
}
_, _ = fmt.Fprintf(w, "%+v", domain)
fmt.Fprintf(w, "%+v", domain)
}
_, _ = w.WriteString(`] Hosts:[`)
w.WriteString(`] Hosts:[`)
for i, host := range a.Hosts {
if i != 0 {
_, _ = w.WriteString(" ")
w.WriteString(" ")
}
_, _ = fmt.Fprintf(w, "%+v", host)
fmt.Fprintf(w, "%+v", host)
}
_, _ = w.WriteString(`]}`)
w.WriteString(`]}`)
}).Format(f, verb)
}
// ErrGetBaseConfigNotSupported is the error
// OSConfigurator.GetBaseConfig returns when the OSConfigurator
// doesn't support reading the underlying configuration out of the OS.
var ErrGetBaseConfigNotSupported = errors.New("getting OS base config is not supported")

View File

@@ -1,14 +1,15 @@
// 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 (
"fmt"
"net/netip"
"reflect"
"testing"
"tailscale.com/tstest"
"tailscale.com/util/dnsname"
)
@@ -42,3 +43,13 @@ func TestOSConfigPrintable(t *testing.T) {
t.Errorf("format mismatch:\n got: %s\n want: %s", s, expected)
}
}
func TestIsZero(t *testing.T) {
tstest.CheckIsZero[OSConfig](t, map[reflect.Type]any{
reflect.TypeFor[dnsname.FQDN](): dnsname.FQDN("foo.bar."),
reflect.TypeFor[*HostEntry](): &HostEntry{
Addr: netip.AddrFrom4([4]byte{100, 1, 2, 3}),
Hosts: []string{"foo", "bar"},
},
})
}

View File

@@ -1,7 +1,6 @@
#!/bin/sh
# 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) Ctrld Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
#
# This script is a workaround for a vpn-unfriendly behavior of the
# original resolvconf by Thomas Hood. Unlike the `openresolv`
@@ -29,7 +28,7 @@ if [ -n "$CTRLD_RESOLVCONF_HOOK_LOOP" ]; then
exit 0
fi
if [ ! -f ctrld.inet ]; then
if [ ! -f tun-ctrld.inet ]; then
# Ctrld isn't trying to manage DNS, do nothing.
exit 0
fi
@@ -60,4 +59,4 @@ if [ -d /etc/resolvconf/update-libc.d ] ; then
# Re-notify libc watchers that we've changed resolv.conf again.
export CTRLD_RESOLVCONF_HOOK_LOOP=1
exec run-parts /etc/resolvconf/update-libc.d
fi
fi

View File

@@ -1,12 +1,12 @@
// 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
//go:build linux || freebsd || openbsd
package dns
import (
"bytes"
"os/exec"
)
@@ -14,13 +14,17 @@ func resolvconfStyle() string {
if _, err := exec.LookPath("resolvconf"); err != nil {
return ""
}
if _, err := exec.Command("resolvconf", "--version").CombinedOutput(); err != nil {
output, err := exec.Command("resolvconf", "--version").CombinedOutput()
if err != nil {
// Debian resolvconf doesn't understand --version, and
// exits with a specific error code.
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 99 {
return "debian"
}
}
if bytes.HasPrefix(output, []byte("Debian resolvconf")) {
return "debian"
}
// Treat everything else as openresolv, by far the more popular implementation.
return "openresolv"
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !gokrazy
package dns
const (
resolvConf = "/etc/resolv.conf"
backupConf = "/etc/resolv.pre-ctrld-backup.conf"
)

View File

@@ -0,0 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build gokrazy
package dns
const (
resolvConf = "/tmp/resolv.conf"
backupConf = "/tmp/resolv.pre-ctrld-backup.conf"
)

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