Compare commits

..

99 Commits

Author SHA1 Message Date
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
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
6bb9e7a766 docs: fix reference links in config.md 2024-02-01 14:37:28 +07:00
66 changed files with 2752 additions and 700 deletions

View File

@@ -4,6 +4,8 @@
[![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
@@ -103,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

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

@@ -10,6 +10,8 @@ import (
"sort"
"time"
"github.com/kardianos/service"
dto "github.com/prometheus/client_model/go"
"github.com/Control-D-Inc/ctrld"
@@ -21,6 +23,8 @@ const (
startedPath = "/started"
reloadPath = "/reload"
deactivationPath = "/deactivation"
cdPath = "/cd"
ifacePath = "/iface"
)
type controlServer struct {
@@ -69,7 +73,7 @@ func (p *prog) registerControlServerHandler() {
sort.Slice(clients, func(i, j int) bool {
return clients[i].IP.Less(clients[j].IP)
})
if p.cfg.Service.MetricsQueryStats {
if p.metricsQueryStats.Load() {
for _, client := range clients {
client.IncludeQueryCount = true
dm := &dto.Metric{}
@@ -171,6 +175,25 @@ func (p *prog) registerControlServerHandler() {
}
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

@@ -21,6 +21,7 @@ import (
"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"
)
@@ -32,6 +33,9 @@ const (
// 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{
@@ -89,6 +93,7 @@ func (p *prog) serveDNS(listenerNum string) error {
_ = 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()) {
@@ -101,6 +106,15 @@ func (p *prog) serveDNS(listenerNum string) error {
go p.detectLoop(m)
q := m.Question[0]
domain := canonicalName(q.Name)
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
@@ -134,6 +148,7 @@ func (p *prog) serveDNS(listenerNum string) error {
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)
@@ -201,12 +216,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():
@@ -282,7 +294,7 @@ networkRules:
macRules:
for _, rule := range lc.Policy.Macs {
for source, targets := range rule {
if source != "" && strings.EqualFold(source, srcMac) {
if source != "" && (strings.EqualFold(source, srcMac) || wildcardMatches(strings.ToLower(source), strings.ToLower(srcMac))) {
matchedPolicy = lc.Policy.Name
matchedNetwork = source
networkTargets = targets
@@ -590,7 +602,8 @@ func canonicalName(fqdn string) string {
return q
}
func wildcardMatches(wildcard, domain string) bool {
// wildcardMatches reports whether string str matches the wildcard pattern.
func wildcardMatches(wildcard, str string) bool {
// Wildcard match.
wildCardParts := strings.Split(wildcard, "*")
if len(wildCardParts) != 2 {
@@ -600,15 +613,15 @@ func wildcardMatches(wildcard, domain string) bool {
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
@@ -742,20 +755,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
}
@@ -806,6 +818,13 @@ func (p *prog) getClientInfo(remoteIP string, msg *dns.Msg) *ctrld.ClientInfo {
ci.Hostname = p.ciTable.LookupHostname(ci.IP, ci.Mac)
}
ci.Self = 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
}
@@ -823,6 +842,51 @@ func (p *prog) spoofLoopbackIpInClientInfo(ci *ctrld.ClientInfo) {
}
}
// 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()
}
// queryFromSelf reports whether the input IP is from device running ctrld.
func queryFromSelf(ip string) bool {
netIP := netip.MustParseAddr(ip)
@@ -936,3 +1000,21 @@ func isWanClient(na net.Addr) bool {
!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,21 @@ 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},
{"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 {

View File

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

View File

@@ -35,6 +35,9 @@ var (
nextdns string
cdUpstreamProto string
deactivationPin int64
skipSelfChecks bool
cleanup bool
startOnly bool
mainLog atomic.Pointer[zerolog.Logger]
consoleWriter zerolog.ConsoleWriter
@@ -62,8 +65,11 @@ func Main() {
}
func normalizeLogFilePath(logFilePath string) string {
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
return logFilePath
// In cleanup mode, we always want the full log file path.
if !cleanup {
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
return logFilePath
}
}
if homedir != "" {
return filepath.Join(homedir, logFilePath)
@@ -120,14 +126,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)

View File

@@ -107,7 +107,7 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
reg := prometheus.NewRegistry()
// Register queries count stats if enabled.
if cfg.Service.MetricsQueryStats {
if p.metricsQueryStats.Load() {
reg.MustRegister(statsQueriesCount)
reg.MustRegister(statsClientQueriesCount)
}

View File

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

@@ -6,4 +6,6 @@ import "net"
func patchNetIfaceName(iface *net.Interface) error { return nil }
func validInterface(iface *net.Interface) bool { return true }
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool { return true }
func validInterfacesMap() map[string]struct{} { return nil }

View File

@@ -10,7 +10,7 @@ func patchNetIfaceName(iface *net.Interface) error {
// validInterface reports whether the *net.Interface is a valid one.
// On Windows, only physical interfaces are considered valid.
func validInterface(iface *net.Interface) bool {
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
if iface == nil {
return false
}
@@ -19,3 +19,5 @@ func validInterface(iface *net.Interface) bool {
}
return false
}
func validInterfacesMap() map[string]struct{} { return nil }

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"os/exec"
"strings"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
@@ -30,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
@@ -43,6 +56,18 @@ 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 {
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
}
// TODO(cuonglm): use system API
func resetDNS(iface *net.Interface) error {
if ns := savedStaticNameservers(iface); len(ns) > 0 {

View File

@@ -29,6 +29,11 @@ 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)
@@ -49,6 +54,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) error {
r, err := dns.NewOSConfigurator(logf, iface.Name)
if err != nil {

View File

@@ -9,6 +9,7 @@ import (
"net"
"net/netip"
"os/exec"
"slices"
"strings"
"syscall"
"time"
@@ -23,6 +24,8 @@ import (
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
// allocate loopback ip
// sudo ip a add 127.0.0.2/24 dev lo
func allocateIP(ip string) error {
@@ -45,7 +48,11 @@ 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)
if err != nil {
@@ -115,6 +122,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 {
@@ -276,8 +288,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
@@ -285,19 +296,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

@@ -5,7 +5,7 @@ import (
"fmt"
"net"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"sync"
@@ -16,7 +16,6 @@ import (
)
const (
forwardersFilename = ".forwarders.txt"
v4InterfaceKeyPathFormat = `HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`
v6InterfaceKeyPathFormat = `HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
)
@@ -26,6 +25,20 @@ var (
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")
@@ -34,39 +47,42 @@ func setDNS(iface *net.Interface, nameservers []string) error {
// If there's a Dns server running, that means we are on AD with Dns feature enabled.
// Configuring the Dns server to forward queries to ctrld instead.
if windowsHasLocalDnsServerRunning() {
file := absHomeDir(forwardersFilename)
if data, _ := os.ReadFile(file); len(data) > 0 {
if err := removeDnsServerForwarders(strings.Split(string(data), ",")); err != nil {
mainLog.Load().Error().Err(err).Msg("could not remove current forwarders settings")
} else {
mainLog.Load().Debug().Msg("removed current forwarders settings.")
file := absHomeDir(windowsForwardersFilename)
oldForwardersContent, _ := os.ReadFile(file)
hasLocalIPv6Listener := needLocalIPv6Listener()
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
if !hasLocalIPv6Listener {
return false
}
}
if err := os.WriteFile(file, []byte(strings.Join(nameservers, ",")), 0600); err != nil {
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")
}
if err := addDnsServerForwarders(nameservers); err != nil {
oldForwarders := strings.Split(string(oldForwardersContent), ",")
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
}
}
})
primaryDNS := nameservers[0]
if err := setPrimaryDNS(iface, primaryDNS, true); err != nil {
return err
}
if len(nameservers) > 1 {
secondaryDNS := nameservers[1]
_ = addSecondaryDNS(iface, secondaryDNS)
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 {
resetDNSOnce.Do(func() {
// See corresponding comment in setDNS.
if windowsHasLocalDnsServerRunning() {
file := absHomeDir(forwardersFilename)
file := absHomeDir(windowsForwardersFilename)
content, err := os.ReadFile(file)
if err != nil {
mainLog.Load().Error().Err(err).Msg("could not read forwarders settings")
@@ -80,17 +96,13 @@ func resetDNS(iface *net.Interface) error {
}
})
// Restoring ipv6 first.
if ctrldnet.SupportsIPv6ListenLocal() {
if output, err := netsh("interface", "ipv6", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp"); err != nil {
mainLog.Load().Warn().Err(err).Msgf("failed to reset ipv6 DNS: %s", string(output))
}
}
// Restoring ipv4 DHCP.
output, err := netsh("interface", "ipv4", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp")
// Restoring DHCP settings.
cmd := fmt.Sprintf("Set-DnsClientServerAddress -InterfaceIndex %d -ResetServerAddresses", iface.Index)
out, err := powershell(cmd)
if err != nil {
return fmt.Errorf("%s: %w", string(output), err)
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)
@@ -107,54 +119,14 @@ func resetDNS(iface *net.Interface) error {
if len(ns) == 0 {
continue
}
primaryDNS := ns[0]
if err := setPrimaryDNS(iface, primaryDNS, false); err != nil {
if err := setDNS(iface, ns); err != nil {
return err
}
if len(ns) > 1 {
secondaryDNS := ns[1]
_ = addSecondaryDNS(iface, secondaryDNS)
}
}
}
return nil
}
func setPrimaryDNS(iface *net.Interface, dns string, disablev6 bool) 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 disablev6 && ipVer == "ipv4" && ctrldnet.SupportsIPv6ListenLocal() {
// Disable IPv6 DNS, so the query will be fallback to IPv4.
_, _ = netsh("interface", "ipv6", "set", "dnsserver", idx, "static", "::1", "primary")
}
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 {
@@ -195,21 +167,41 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
out, err := powershell(cmd)
if err == nil && len(out) > 0 {
found = true
ns = append(ns, strings.Split(string(out), ",")...)
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.
func addDnsServerForwarders(nameservers []string) error {
for _, ns := range nameservers {
cmd := fmt.Sprintf("Add-DnsServerForwarder -IPAddress %s", ns)
if out, err := powershell(cmd); err != nil {
return fmt.Errorf("%w: %s", err, string(out))
// 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
}

View File

@@ -12,19 +12,24 @@ import (
"net/url"
"os"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/kardianos/service"
"github.com/rs/zerolog"
"github.com/spf13/viper"
"tailscale.com/net/interfaces"
"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"
)
@@ -33,11 +38,23 @@ const (
defaultSemaphoreCap = 256
ctrldLogUnixSock = "ctrld_start.sock"
ctrldControlUnixSock = "ctrld_control.sock"
upstreamPrefix = "upstream."
upstreamOS = upstreamPrefix + "os"
upstreamPrivate = upstreamPrefix + "private"
// 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...)
}
@@ -51,25 +68,38 @@ var svcConfig = &service.Config{
var useSystemdResolved = false
type prog struct {
mu sync.Mutex
waitCh chan struct{}
stopCh chan struct{}
reloadCh chan struct{} // For Windows.
reloadDoneCh 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
logConn net.Conn
cs *controlServer
csSetDnsDone chan struct{}
csSetDnsOk bool
dnsWatchDogOnce sync.Once
dnsWg sync.WaitGroup
dnsWatcherStopCh chan struct{}
cfg *ctrld.Config
localUpstreams []string
ptrNameservers []string
appCallback *AppCallback
cache dnscache.Cacher
sema semaphore
ciTable *clientinfo.Table
um *upstreamMonitor
router router.Router
ptrLoopGuard *loopGuard
lanLoopGuard *loopGuard
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
selfUninstallMu sync.Mutex
refusedQueryCount int
canSelfUninstall atomic.Bool
checkingSelfUninstall bool
loopMu sync.Mutex
loop map[string]bool
@@ -103,11 +133,15 @@ func (p *prog) runWait() {
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
@@ -117,28 +151,31 @@ func (p *prog) runWait() {
close(reloadCh)
<-done
}
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")
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()
@@ -164,6 +201,10 @@ func (p *prog) runWait() {
continue
}
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")
@@ -174,6 +215,7 @@ func (p *prog) runWait() {
p.mu.Unlock()
logger.Notice().Msg("reloading config successfully")
select {
case p.reloadDoneCh <- struct{}{}:
default:
@@ -182,9 +224,6 @@ func (p *prog) runWait() {
}
func (p *prog) preRun() {
if !service.Interactive() {
p.setDNS()
}
if runtime.GOOS == "darwin" {
p.onStopped = append(p.onStopped, func() {
if !service.Interactive() {
@@ -194,12 +233,76 @@ func (p *prog) preRun() {
}
}
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()
}
}
// apiConfigReload calls API to check for latest config update then reload ctrld if necessary.
func (p *prog) apiConfigReload() {
if cdUID == "" {
return
}
secs := 3600
if p.cfg.Service.RefetchTime != nil && *p.cfg.Service.RefetchTime > 0 {
secs = *p.cfg.Service.RefetchTime
}
ticker := time.NewTicker(time.Duration(secs) * 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()
for {
select {
case <-ticker.C:
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")
continue
}
if resolverConfig.Ctrld.CustomConfig == "" {
continue
}
if resolverConfig.Ctrld.CustomLastUpdate > lastUpdated {
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")
}
break
}
setListenerDefaultValue(cfg)
logger.Debug().Msg("custom config changes detected, reloading...")
p.apiReloadCh <- cfg
} else {
logger.Debug().Msg("custom config does not change")
}
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]
uc.Init()
isControlDUpstream = isControlDUpstream || uc.IsControlD()
if uc.BootstrapIP == "" {
uc.SetupBootstrapIP()
mainLog.Load().Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
@@ -216,6 +319,10 @@ func (p *prog) setupUpstream(cfg *ctrld.Config) {
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
}
@@ -237,17 +344,31 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
numListeners := len(p.cfg.Listener)
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{}{}
}
}
}
@@ -364,12 +485,8 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
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")
}
}
go p.apiConfigReload()
p.postRun()
}
wg.Wait()
}
@@ -413,17 +530,25 @@ func (p *prog) deAllocateIP() error {
}
func (p *prog) setDNS() {
setDnsOK := false
defer func() {
p.csSetDnsOk = setDnsOK
p.csSetDnsDone <- struct{}{}
close(p.csSetDnsDone)
}()
if cfg.Listener == nil {
return
}
if iface == "" {
return
}
runningIface := iface
// allIfaces tracks whether we should set DNS for all physical interfaces.
allIfaces := false
if iface == "auto" {
iface = defaultIfaceName()
// If iface is "auto", it means user does not specify "--iface" flag.
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()
@@ -432,8 +557,8 @@ func (p *prog) setDNS() {
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
@@ -463,37 +588,116 @@ func (p *prog) setDNS() {
if needRFC1918Listeners(lc) {
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 shouldWatchResolvconf() {
servers := make([]netip.Addr, len(nameservers))
for i := range nameservers {
servers[i] = netip.MustParseAddr(nameservers[i])
}
go watchResolvConf(netIface, servers, setResolvConf)
p.dnsWg.Add(1)
go func() {
defer p.dnsWg.Done()
p.watchResolvConf(netIface, servers, setResolvConf)
}()
}
if allIfaces {
withEachPhysicalInterfaces(netIface.Name, "set DNS", func(i *net.Interface) error {
return setDNS(i, nameservers)
return setDnsIgnoreUnusableInterface(i, nameservers)
})
}
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
}
p.dnsWatchDogOnce.Do(func() {
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 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
}
runningIface := iface
allIfaces := false
if iface == "auto" {
iface = defaultIfaceName()
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
@@ -509,7 +713,7 @@ func (p *prog) resetDNS() {
}
logger.Debug().Msg("Restoring DNS successfully")
if allIfaces {
withEachPhysicalInterfaces(netIface.Name, "reset DNS", resetDNS)
withEachPhysicalInterfaces(netIface.Name, "reset DNS", resetDnsIgnoreUnusableInterface)
}
}
@@ -689,13 +893,14 @@ func canBeLocalUpstream(addr string) bool {
// 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()
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
// Skip loopback/virtual interface.
if i.IsLoopback() || len(i.HardwareAddr) == 0 {
return
}
// Skip invalid interface.
if !validInterface(i.Interface) {
if !validInterface(i.Interface, validIfacesMap) {
return
}
netIface := i.Interface
@@ -709,7 +914,9 @@ func withEachPhysicalInterfaces(excludeIfaceName, context string, f func(i *net.
}
// TODO: investigate whether we should report this error?
if err := f(netIface); err == nil {
mainLog.Load().Debug().Msgf("%s for interface %q successfully", context, i.Name)
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)
}
@@ -768,3 +975,28 @@ func savedStaticNameservers(iface *net.Interface) []string {
}
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 {
// Ensure all DNS watchers goroutine are terminated, so it won't mess up with self-uninstall.
close(p.dnsWatcherStopCh)
p.dnsWg.Wait()
// Perform self-uninstall now.
selfUninstall(p, logger)
}
}

View File

@@ -1,6 +1,13 @@
package cli
import (
"bufio"
"bytes"
"io"
"os"
"os/exec"
"strings"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/dns"
@@ -10,6 +17,10 @@ func init() {
if r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo"); err == nil {
useSystemdResolved = r.Mode() == "systemd-resolved"
}
// Disable quic-go's ECN support by default, see https://github.com/quic-go/quic-go/issues/3911
if os.Getenv("QUIC_GO_DISABLE_ECN") == "" {
os.Setenv("QUIC_GO_DISABLE_ECN", "true")
}
}
func setDependencies(svc *service.Config) {
@@ -18,12 +29,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",
"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())
})
}
}

View File

@@ -51,7 +51,7 @@ var statsClientQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
// WithLabelValuesInc increases prometheus counter by 1 if query stats is enabled.
func (p *prog) WithLabelValuesInc(c *prometheus.CounterVec, lvs ...string) {
if p.cfg.Service.MetricsQueryStats {
if p.metricsQueryStats.Load() {
c.WithLabelValues(lvs...).Inc()
}
}

View File

@@ -8,15 +8,15 @@ import (
"github.com/fsnotify/fsnotify"
)
const (
resolvConfPath = "/etc/resolv.conf"
resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
)
// watchResolvConf watches any changes to /etc/resolv.conf file,
// and reverting to the original config set by ctrld.
func watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
mainLog.Load().Debug().Msg("start watching /etc/resolv.conf file")
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")
@@ -28,12 +28,17 @@ func watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
watchDir := filepath.Dir(resolvConfPath)
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not add /etc/resolv.conf to watcher list")
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 !ok {
return

View File

@@ -3,15 +3,44 @@ 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()
}
return setDNS(iface, servers)
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.

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

@@ -61,8 +61,13 @@ func mapCallback(callback AppCallback) cli.AppCallback {
}
}
func (c *Controller) Stop(Pin int64) int {
errorCode := cli.CheckDeactivationPin(Pin)
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

130
config.go
View File

@@ -25,6 +25,7 @@ import (
"github.com/go-playground/validator/v10"
"github.com/miekg/dns"
"github.com/spf13/viper"
"golang.org/x/net/http2"
"golang.org/x/sync/singleflight"
"tailscale.com/logtail/backoff"
"tailscale.com/net/tsaddr"
@@ -46,6 +47,15 @@ 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"
@@ -104,14 +114,14 @@ func InitConfig(v *viper.Viper, name string) {
})
v.SetDefault("upstream", map[string]*UpstreamConfig{
"0": {
BootstrapIP: "76.76.2.11",
BootstrapIP: FreeDNSBoostrapIP,
Name: "Control D - Anti-Malware",
Type: ResolverTypeDOH,
Endpoint: "https://freedns.controld.com/p1",
Timeout: 5000,
},
"1": {
BootstrapIP: "76.76.2.11",
BootstrapIP: FreeDNSBoostrapIP,
Name: "Control D - No Ads",
Type: ResolverTypeDOQ,
Endpoint: "p2.freedns.controld.com",
@@ -179,26 +189,30 @@ 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_arp,omitempty"`
DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"`
DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"`
DiscoverHosts *bool `mapstructure:"discover_hosts" toml:"discover_hosts,omitempty"`
DiscoverRefreshInterval int `mapstructure:"discover_refresh_interval" toml:"discover_refresh_interval,omitempty"`
ClientIDPref string `mapstructure:"client_id_preference" toml:"client_id_preference,omitempty" validate:"omitempty,oneof=host mac"`
MetricsQueryStats bool `mapstructure:"metrics_query_stats" toml:"metrics_query_stats,omitempty"`
MetricsListener string `mapstructure:"metrics_listener" toml:"metrics_listener,omitempty"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
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"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
}
// NetworkConfig specifies configuration for networks where ctrld will handle requests.
@@ -285,6 +299,7 @@ type Rule map[string][]string
// Init initialized necessary values for an UpstreamConfig.
func (uc *UpstreamConfig) Init() {
uc.initDoHScheme()
uc.uid = upstreamUID()
if u, err := url.Parse(uc.Endpoint); err == nil {
uc.Domain = u.Host
@@ -305,7 +320,7 @@ func (uc *UpstreamConfig) Init() {
}
}
if uc.IPStack == "" {
if uc.isControlD() {
if uc.IsControlD() {
uc.IPStack = IpStackSplit
} else {
uc.IPStack = IpStackBoth
@@ -343,7 +358,7 @@ func (uc *UpstreamConfig) UpstreamSendClientInfo() bool {
}
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
if uc.isControlD() || uc.isNextDNS() {
if uc.IsControlD() || uc.isNextDNS() {
return true
}
}
@@ -390,7 +405,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,
@@ -475,6 +490,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
@@ -510,38 +532,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 {
@@ -631,6 +674,18 @@ 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() {
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
default:
return
}
if !strings.HasPrefix(uc.Endpoint, "https://") {
uc.Endpoint = "https://" + uc.Endpoint
}
}
// Init initialized necessary values for an ListenerConfig.
func (lc *ListenerConfig) Init() {
if lc.Policy != nil {
@@ -683,6 +738,7 @@ func upstreamConfigStructLevelValidation(sl validator.StructLevel) {
return
}
uc.initDoHScheme()
// DoH/DoH3 requires endpoint is an HTTP url.
if uc.Type == ResolverTypeDOH || uc.Type == ResolverTypeDOH3 {
u, err := url.Parse(uc.Endpoint)
@@ -690,10 +746,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
}
}
}

View File

@@ -1,9 +1,11 @@
package ctrld_test
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/go-playground/validator/v10"
"github.com/spf13/viper"
@@ -21,6 +23,8 @@ func TestLoadConfig(t *testing.T) {
assert.Equal(t, "info", cfg.Service.LogLevel)
assert.Equal(t, "/path/to/log.log", cfg.Service.LogPath)
assert.Equal(t, false, *cfg.Service.DnsWatchdogEnabled)
assert.Equal(t, time.Duration(20*time.Second), *cfg.Service.DnsWatchdogInvterval)
assert.Len(t, cfg.Network, 2)
assert.Contains(t, cfg.Network, "0")
@@ -102,6 +106,8 @@ func TestConfigValidation(t *testing.T) {
{"invalid lease file format", configWithInvalidLeaseFileFormat(t), true},
{"invalid doh/doh3 endpoint", configWithInvalidDoHEndpoint(t), true},
{"invalid client id pref", configWithInvalidClientIDPref(t), true},
{"doh endpoint without scheme", dohUpstreamEndpointWithoutScheme(t), false},
{"maximum number of flush cache domains", configWithInvalidFlushCacheDomain(t), true},
}
for _, tc := range tests {
@@ -167,6 +173,12 @@ 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 invalidUpstreamTimeout(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Upstream["0"].Timeout = -1
@@ -258,7 +270,7 @@ 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
}
@@ -268,3 +280,12 @@ func configWithInvalidClientIDPref(t *testing.T) *ctrld.Config {
cfg.Service.ClientIDPref = "foo"
return cfg
}
func configWithInvalidFlushCacheDomain(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Service.CacheFlushDomains = make([]string, 257)
for i := range cfg.Service.CacheFlushDomains {
cfg.Service.CacheFlushDomains[i] = fmt.Sprintf("%d.com", i)
}
return cfg
}

View File

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

View File

@@ -14,7 +14,7 @@ The config file allows for advanced configuration of the `ctrld` utility to cove
## Config Location
`ctrld` uses [TOML](toml_link) format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
`ctrld` uses [TOML][toml_link] format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
- `/etc/controld` on *nix.
- User's home directory on Windows.
@@ -157,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.
@@ -220,7 +227,7 @@ DHCP leases file format.
- Type: string
- Required: no
- Valid values: `dnsmasq`, `isc-dhcp`
- Valid values: `dnsmasq`, `isc-dhcp`, `kea-dhcp4`
- Default: ""
### client_id_preference
@@ -245,6 +252,35 @@ Specifying the `ip` and `port` of the Prometheus metrics server. The Prometheus
- 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
## Upstream
The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to.
@@ -329,7 +365,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.
@@ -531,7 +567,7 @@ And within each policy, the rules are processed from top to bottom.
### failover_rcodes
For non success response, `failover_rcodes` allows the request to be forwarded to next upstream, if the response `RCODE` matches any value defined in `failover_rcodes`.
- Type: array of string
- Type: array of strings
- Required: no
- Default: []
-
@@ -548,7 +584,7 @@ networks = [
If `upstream.0` returns a NXDOMAIN response, the request will be forwarded to `upstream.1` instead of returning immediately to the client.
See all available DNS Rcodes value [here](rcode_link).
See all available DNS Rcodes value [here][rcode_link].
[toml_link]: https://toml.io/en
[rcode_link]: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6

BIN
docs/ctrldsplash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

20
doh.go
View File

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

View File

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

View File

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

2
dot.go
View File

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

17
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/Control-D-Inc/ctrld
go 1.21
require (
github.com/Masterminds/semver v1.5.0
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
@@ -17,26 +18,28 @@ require (
github.com/kardianos/service v1.2.1
github.com/mdlayher/ndp v1.0.1
github.com/miekg/dns v1.1.55
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/prometheus/client_golang v1.15.1
github.com/prometheus/client_model v0.4.0
github.com/prometheus/prom2json v1.3.3
github.com/quic-go/quic-go v0.41.0
github.com/quic-go/quic-go v0.42.0
github.com/rs/zerolog v1.28.0
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.3
github.com/vishvananda/netlink v1.2.1-beta.2
golang.org/x/net v0.17.0
golang.org/x/net v0.23.0
golang.org/x/sync v0.2.0
golang.org/x/sys v0.13.0
golang.org/x/sys v0.18.0
golang.zx2c4.com/wireguard/windows v0.5.3
tailscale.com v1.44.0
)
require (
aead.dev/minisign v0.2.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
@@ -77,14 +80,14 @@ require (
github.com/subosito/gotenv v1.4.2 // indirect
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
go.uber.org/mock v0.3.0 // indirect
go.uber.org/mock v0.4.0 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
golang.org/x/mod v0.11.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.9.1 // indirect
google.golang.org/protobuf v1.30.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
)

41
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,6 +40,8 @@ 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=
@@ -222,6 +226,8 @@ 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/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=
@@ -253,8 +259,8 @@ github.com/prometheus/prom2json v1.3.3 h1:IYfSMiZ7sSOfliBoo89PcufjWO4eAR0gznGcET
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/quic-go v0.41.0 h1:aD8MmHfgqTURWNJy48IYFg2OnxwHT3JL7ahGs73lb4k=
github.com/quic-go/quic-go v0.41.0/go.mod h1:qCkNjqczPEvgsOnxZ0eCD14lv+B2LHlFAB++CNOh9hA=
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=
@@ -310,8 +316,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo=
go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -319,11 +325,13 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
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=
@@ -394,8 +402,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
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-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
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=
@@ -429,6 +437,7 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w
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=
@@ -454,6 +463,7 @@ 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-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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -465,8 +475,9 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.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=
@@ -476,11 +487,13 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=
@@ -626,8 +639,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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=

View File

@@ -14,6 +14,11 @@ import (
"github.com/Control-D-Inc/ctrld/internal/controld"
)
const (
ipV4Loopback = "127.0.0.1"
ipv6Loopback = "::1"
)
// IpResolver is the interface for retrieving IP from Mac.
type IpResolver interface {
fmt.Stringer
@@ -224,6 +229,7 @@ func (t *Table) init() {
cancel()
}()
go t.ndp.listen(ctx)
go t.ndp.subscribe(ctx)
}
// PTR lookup.
if t.discoverPTR() {
@@ -321,6 +327,16 @@ func (t *Table) LookupRFC1918IPv4(mac string) string {
return ""
}
// LocalHostname returns the localhost hostname associated with loopback IP.
func (t *Table) LocalHostname() string {
for _, ip := range []string{ipV4Loopback, ipv6Loopback} {
if name := t.LookupHostname(ip, ""); name != "" {
return name
}
}
return ""
}
type macEntry struct {
mac string
src string

View File

@@ -353,8 +353,8 @@ 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) {
mac := i.HardwareAddr.String()
@@ -375,15 +375,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

@@ -95,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.

View File

@@ -122,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 {
@@ -165,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 {
@@ -273,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

@@ -15,6 +15,7 @@ import (
"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.
@@ -69,15 +70,45 @@ func (nd *ndpDiscover) List() []string {
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) {
ifi, err := firstInterfaceWithV6LinkLocal()
ifis, err := allInterfacesWithV6LinkLocal()
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("failed to find valid ipv6")
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("failed to find valid ipv6 interfaces")
return
}
c, ip, err := ndp.Listen(ifi, ndp.LinkLocal)
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
@@ -111,8 +142,7 @@ func (nd *ndpDiscover) listen(ctx context.Context) {
for _, opt := range am.Options {
if lla, ok := opt.(*ndp.LinkLayerAddress); ok {
mac := lla.Addr.String()
nd.mac.Store(fromIP, mac)
nd.ip.Store(mac, fromIP)
nd.saveInfo(fromIP, mac)
}
}
}
@@ -127,8 +157,7 @@ func (nd *ndpDiscover) scanWindows(r io.Reader) {
continue
}
if mac := parseMAC(fields[1]); mac != "" {
nd.mac.Store(fields[0], mac)
nd.ip.Store(mac, fields[0])
nd.saveInfo(fields[0], mac)
}
}
}
@@ -147,8 +176,7 @@ func (nd *ndpDiscover) scanUnix(r io.Reader) {
if idx := strings.IndexByte(ip, '%'); idx != -1 {
ip = ip[:idx]
}
nd.mac.Store(ip, mac)
nd.ip.Store(mac, ip)
nd.saveInfo(ip, mac)
}
}
}
@@ -183,14 +211,15 @@ func parseMAC(mac string) string {
return hw.String()
}
// firstInterfaceWithV6LinkLocal returns the first interface which is capable of using NDP.
func firstInterfaceWithV6LinkLocal() (*net.Interface, error) {
// 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
@@ -211,9 +240,10 @@ func firstInterfaceWithV6LinkLocal() (*net.Interface, error) {
return nil, fmt.Errorf("invalid ip address: %s", ipNet.String())
}
if ip.Is6() && !ip.Is4In6() {
return &ifi, nil
res = append(res, &ifi)
break
}
}
}
return nil, errors.New("no interface can be used")
return res, nil
}

View File

@@ -1,7 +1,10 @@
package clientinfo
import (
"context"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"github.com/Control-D-Inc/ctrld"
)
@@ -15,10 +18,47 @@ func (nd *ndpDiscover) scan() {
}
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.mac.Store(ip, mac)
nd.ip.Store(mac, ip)
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

@@ -4,6 +4,7 @@ package clientinfo
import (
"bytes"
"context"
"os/exec"
"runtime"
@@ -29,3 +30,7 @@ func (nd *ndpDiscover) scan() {
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

@@ -45,20 +45,22 @@ ff02::c 33-33-00-00-00-0c Permanent
nd.scanWindows(r)
count := 0
expectedCount := 6
nd.mac.Range(func(key, value any) bool {
count++
return true
})
if count != 6 {
t.Errorf("unexpected count, want 6, got: %d", count)
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 != 5 {
t.Errorf("unexpected count, want 5, got: %d", count)
if count != expectedCount {
t.Errorf("unexpected count, want %d, got: %d", expectedCount, count)
}
}

View File

@@ -26,14 +26,15 @@ 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"`
@@ -76,17 +77,28 @@ 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))
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
@@ -98,6 +110,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()

View File

@@ -12,6 +12,7 @@ import (
type Cacher interface {
Get(Key) *Value
Add(Key, *Value)
Purge()
}
// Key is the caching key for DNS message.
@@ -34,15 +35,22 @@ type LRUCache struct {
cacher *lru.ARCCache[Key, *Value]
}
// Get looks up key's value from cache.
func (l *LRUCache) Get(key Key) *Value {
v, _ := l.cacher.Get(key)
return v
}
// Add adds a value to cache.
func (l *LRUCache) Add(key Key, value *Value) {
l.cacher.Add(key, value)
}
// Purge clears the cache.
func (l *LRUCache) Purge() {
l.cacher.Purge()
}
// NewLRUCache creates a new LRUCache instance with given size.
func NewLRUCache(size int) (*LRUCache, error) {
cacher, err := lru.NewARC[Key, *Value](size)

View File

@@ -115,6 +115,15 @@ func IsIPv6(ip string) bool {
return parsedIP != nil && parsedIP.To4() == nil && parsedIP.To16() != nil
}
// IsLinkLocalUnicastIPv6 checks if the provided IP is a link local unicast v6 address.
func IsLinkLocalUnicastIPv6(ip string) bool {
parsedIP := net.ParseIP(ip)
if parsedIP == nil || parsedIP.To4() != nil || parsedIP.To16() == nil {
return false
}
return parsedIP.To16().IsLinkLocalUnicast()
}
type parallelDialerResult struct {
conn net.Conn
err error

View File

@@ -10,6 +10,8 @@ import (
"github.com/Control-D-Inc/ctrld"
)
const CtrldMarker = `# GENERATED BY ctrld - DO NOT MODIFY`
const ConfigContentTmpl = `# GENERATED BY ctrld - DO NOT MODIFY
no-resolv
{{- range .Upstreams}}

View File

@@ -0,0 +1,22 @@
package netgear
const openWrtScript = `#!/bin/sh /etc/rc.common
USE_PROCD=1
# After dnsmasq starts
START=61
# Before network stops
STOP=89
cmd="{{.Path}}{{range .Arguments}} {{.|cmd}}{{end}}"
name="{{.Name}}"
pid_file="/var/run/${name}.pid"
start_service() {
echo "Starting ${name}"
procd_open_instance
procd_set_param command ${cmd}
procd_set_param respawn # respawn automatically if something died
procd_set_param pidfile ${pid_file} # write a pid file on instance start and remove it on stop
procd_close_instance
echo "${name} has been started"
}
`

View File

@@ -0,0 +1,220 @@
package netgear
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq"
"github.com/Control-D-Inc/ctrld/internal/router/nvram"
)
const (
Name = "netgear_orbi_voxel"
netgearOrbiVoxelDNSMasqConfigPath = "/etc/dnsmasq.conf"
netgearOrbiVoxelHomedir = "/mnt/bitdefender"
netgearOrbiVoxelStartupScript = "/mnt/bitdefender/rc.user"
netgearOrbiVoxelStartupScriptBackup = "/mnt/bitdefender/rc.user.bak"
netgearOrbiVoxelStartupScriptMarker = "\n# GENERATED BY ctrld"
)
var nvramKvMap = map[string]string{
"dns_hijack": "0", // Disable dns hijacking
}
type NetgearOrbiVoxel struct {
cfg *ctrld.Config
}
// New returns a router.Router for configuring/setup/run ctrld on ddwrt routers.
func New(cfg *ctrld.Config) *NetgearOrbiVoxel {
return &NetgearOrbiVoxel{cfg: cfg}
}
func (d *NetgearOrbiVoxel) ConfigureService(svc *service.Config) error {
if err := d.checkInstalledDir(); err != nil {
return err
}
svc.Option["SysvScript"] = openWrtScript
return nil
}
func (d *NetgearOrbiVoxel) Install(_ *service.Config) error {
// Ignoring error here at this moment is ok, since everything will be wiped out on reboot.
_ = exec.Command("/etc/init.d/ctrld", "enable").Run()
if err := d.checkInstalledDir(); err != nil {
return err
}
if err := backupVoxelStartupScript(); err != nil {
return fmt.Errorf("backup startup script: %w", err)
}
if err := writeVoxelStartupScript(); err != nil {
return fmt.Errorf("writing startup script: %w", err)
}
return nil
}
func (d *NetgearOrbiVoxel) Uninstall(_ *service.Config) error {
if err := os.Remove(netgearOrbiVoxelStartupScript); err != nil && !os.IsNotExist(err) {
return err
}
err := os.Rename(netgearOrbiVoxelStartupScriptBackup, netgearOrbiVoxelStartupScript)
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (d *NetgearOrbiVoxel) PreRun() error {
return nil
}
func (d *NetgearOrbiVoxel) Setup() error {
if d.cfg.FirstListener().IsDirectDnsListener() {
return nil
}
// Already setup.
if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" {
return nil
}
data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, d.cfg, false)
if err != nil {
return err
}
currentConfig, _ := os.ReadFile(netgearOrbiVoxelDNSMasqConfigPath)
configContent := append(currentConfig, data...)
if err := os.WriteFile(netgearOrbiVoxelDNSMasqConfigPath, configContent, 0600); err != nil {
return err
}
// Restart dnsmasq service.
if err := restartDNSMasq(); err != nil {
return err
}
if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil {
return err
}
return nil
}
func (d *NetgearOrbiVoxel) Cleanup() error {
if d.cfg.FirstListener().IsDirectDnsListener() {
return nil
}
if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val != "1" {
return nil // was restored, nothing to do.
}
// Restore old configs.
if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil {
return err
}
// Restore dnsmasq config.
if err := restoreDnsmasqConf(); err != nil {
return err
}
// Restart dnsmasq service.
if err := restartDNSMasq(); err != nil {
return err
}
return nil
}
// checkInstalledDir checks that ctrld binary was installed in the correct directory.
func (d *NetgearOrbiVoxel) checkInstalledDir() error {
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("checkHomeDir: failed to get binary path %w", err)
}
if !strings.HasSuffix(filepath.Dir(exePath), netgearOrbiVoxelHomedir) {
return fmt.Errorf("checkHomeDir: could not install service outside %s", netgearOrbiVoxelHomedir)
}
return nil
}
// backupVoxelStartupScript creates a backup of original startup script if existed.
func backupVoxelStartupScript() error {
// Do nothing if the startup script was modified by ctrld.
script, _ := os.ReadFile(netgearOrbiVoxelStartupScript)
if bytes.Contains(script, []byte(netgearOrbiVoxelStartupScriptMarker)) {
return nil
}
err := os.Rename(netgearOrbiVoxelStartupScript, netgearOrbiVoxelStartupScriptBackup)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("backupVoxelStartupScript: %w", err)
}
return nil
}
// writeVoxelStartupScript writes startup script to re-install ctrld upon reboot.
// See: https://github.com/SVoxel/ORBI-RBK50/pull/7
func writeVoxelStartupScript() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("configure service: failed to get binary path %w", err)
}
// This is called when "ctrld start ..." runs, so recording
// the same command line arguments to use in startup script.
argStr := strings.Join(os.Args[1:], " ")
script, _ := os.ReadFile(netgearOrbiVoxelStartupScriptBackup)
script = append(script, fmt.Sprintf("%s\n%q %s\n", netgearOrbiVoxelStartupScriptMarker, exe, argStr)...)
f, err := os.Create(netgearOrbiVoxelStartupScript)
if err != nil {
return fmt.Errorf("failed to create startup script: %w", err)
}
defer f.Close()
if _, err := f.Write(script); err != nil {
return fmt.Errorf("failed to write startup script: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("failed to save startup script: %w", err)
}
return nil
}
// restoreDnsmasqConf restores original dnsmasq configuration.
func restoreDnsmasqConf() error {
f, err := os.Open(netgearOrbiVoxelDNSMasqConfigPath)
if err != nil {
return err
}
defer f.Close()
var bs []byte
buf := bytes.NewBuffer(bs)
removed := false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == dnsmasq.CtrldMarker {
removed = true
}
if !removed {
_, err := buf.WriteString(line + "\n")
if err != nil {
return err
}
}
}
return os.WriteFile(netgearOrbiVoxelDNSMasqConfigPath, buf.Bytes(), 0644)
}
func restartDNSMasq() error {
if out, err := exec.Command("/etc/init.d/dnsmasq", "restart").CombinedOutput(); err != nil {
return fmt.Errorf("restartDNSMasq: %s, %w", string(out), err)
}
return nil
}

View File

@@ -18,6 +18,7 @@ start_service() {
procd_set_param stdout 1 # forward stdout of the command to logd
procd_set_param stderr 1 # same for stderr
procd_set_param pidfile ${pid_file} # write a pid file on instance start and remove it on stop
procd_set_param term_timeout 10
procd_close_instance
echo "${name} has been started"
}

View File

@@ -0,0 +1,40 @@
package router
import (
"encoding/xml"
"os"
)
// Config represents /conf/config.xml file found on pfsense/opnsense.
type Config struct {
PfsenseUnbound *string `xml:"unbound>enable,omitempty"`
OPNsenseUnbound *string `xml:"OPNsense>unboundplus>general>enabled,omitempty"`
Dnsmasq *string `xml:"dnsmasq>enable,omitempty"`
}
// DnsmasqEnabled reports whether dnsmasq is enabled.
func (c *Config) DnsmasqEnabled() bool {
if isPfsense() { // pfsense only set the attribute if dnsmasq is enabled.
return c.Dnsmasq != nil
}
return c.Dnsmasq != nil && *c.Dnsmasq == "1"
}
// UnboundEnabled reports whether unbound is enabled.
func (c *Config) UnboundEnabled() bool {
if isPfsense() { // pfsense only set the attribute if unbound is enabled.
return c.PfsenseUnbound != nil
}
return c.OPNsenseUnbound != nil && *c.OPNsenseUnbound == "1"
}
// currentConfig does unmarshalling /conf/config.xml file,
// return the corresponding *Config represent it.
func currentConfig() (*Config, error) {
buf, _ := os.ReadFile("/conf/config.xml")
c := Config{}
if err := xml.Unmarshal(buf, &c); err != nil {
return nil, err
}
return &c, nil
}

View File

@@ -111,8 +111,16 @@ func (or *osRouter) Setup() error {
func (or *osRouter) Cleanup() error {
if or.cdMode {
_ = exec.Command(unboundRcPath, "onerestart").Run()
_ = exec.Command(dnsmasqRcPath, "onerestart").Run()
c, err := currentConfig()
if err != nil {
return err
}
if c.UnboundEnabled() {
_ = exec.Command(unboundRcPath, "onerestart").Run()
}
if c.DnsmasqEnabled() {
_ = exec.Command(dnsmasqRcPath, "onerestart").Run()
}
}
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"github.com/kardianos/service"
@@ -18,6 +19,7 @@ import (
"github.com/Control-D-Inc/ctrld/internal/router/edgeos"
"github.com/Control-D-Inc/ctrld/internal/router/firewalla"
"github.com/Control-D-Inc/ctrld/internal/router/merlin"
netgear "github.com/Control-D-Inc/ctrld/internal/router/netgear_orbi_voxel"
"github.com/Control-D-Inc/ctrld/internal/router/openwrt"
"github.com/Control-D-Inc/ctrld/internal/router/synology"
"github.com/Control-D-Inc/ctrld/internal/router/tomato"
@@ -66,10 +68,17 @@ func New(cfg *ctrld.Config, cdMode bool) Router {
return tomato.New(cfg)
case firewalla.Name:
return firewalla.New(cfg)
case netgear.Name:
return netgear.New(cfg)
}
return newOsRouter(cfg, cdMode)
}
// IsNetGearOrbi reports whether the router is a Netgear Orbi router.
func IsNetGearOrbi() bool {
return Name() == netgear.Name
}
// IsGLiNet reports whether the router is an GL.iNet router.
func IsGLiNet() bool {
if Name() != openwrt.Name {
@@ -90,6 +99,11 @@ func IsOldOpenwrt() bool {
return cmd == ""
}
// WaitProcessExited reports whether the "ctrld stop" command have to wait until ctrld process exited.
func WaitProcessExited() bool {
return Name() == openwrt.Name
}
var routerPlatform atomic.Pointer[router]
type router struct {
@@ -145,12 +159,22 @@ func LocalResolverIP() string {
// HomeDir returns the home directory of ctrld on current router.
func HomeDir() (string, error) {
switch Name() {
case ddwrt.Name, merlin.Name, tomato.Name:
case ddwrt.Name, firewalla.Name, merlin.Name, netgear.Name, tomato.Name:
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Dir(exe), nil
case edgeos.Name:
exe, err := os.Executable()
if err != nil {
return "", err
}
// Using binary directory as home dir if it is located in /config.
// Otherwise, fallback to old behavior for compatibility.
if strings.HasPrefix(exe, "/config/") {
return filepath.Dir(exe), nil
}
}
return "", nil
}
@@ -198,6 +222,9 @@ func distroName() string {
case bytes.HasPrefix(unameO(), []byte("ASUSWRT-Merlin")):
return merlin.Name
case haveFile("/etc/openwrt_version"):
if haveFile("/bin/config") { // TODO: is there any more reliable way?
return netgear.Name
}
return openwrt.Name
case isUbios():
return ubios.Name

View File

@@ -49,11 +49,15 @@ func (s *merlinSvc) Platform() string {
}
func (s *merlinSvc) configPath() string {
path, err := os.Executable()
if err != nil {
return ""
bin := s.Config.Executable
if bin == "" {
path, err := os.Executable()
if err != nil {
return ""
}
bin = path
}
return path + ".startup"
return bin + ".startup"
}
func (s *merlinSvc) template() *template.Template {

View File

@@ -1,12 +1,9 @@
package ctrld
import (
"net"
"syscall"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"golang.org/x/sys/windows"
)
func dnsFns() []dnsFn {
@@ -20,40 +17,23 @@ func dnsFromAdapter() []string {
}
ns := make([]string, 0, len(aas)*2)
seen := make(map[string]bool)
do := func(addr windows.SocketAddress) {
sa, err := addr.Sockaddr.Sockaddr()
if err != nil {
return
addressMap := make(map[string]struct{})
for _, aa := range aas {
for a := aa.FirstUnicastAddress; a != nil; a = a.Next {
addressMap[a.Address.IP().String()] = struct{}{}
}
var ip net.IP
switch sa := sa.(type) {
case *syscall.SockaddrInet4:
ip = net.IPv4(sa.Addr[0], sa.Addr[1], sa.Addr[2], sa.Addr[3])
case *syscall.SockaddrInet6:
ip = make(net.IP, net.IPv6len)
copy(ip, sa.Addr[:])
if ip[0] == 0xfe && ip[1] == 0xc0 {
// Ignore these fec0/10 ones. Windows seems to
// populate them as defaults on its misc rando
// interfaces.
return
}
default:
return
}
if ip.IsLoopback() || seen[ip.String()] {
return
}
seen[ip.String()] = true
ns = append(ns, ip.String())
}
for _, aa := range aas {
for dns := aa.FirstDNSServerAddress; dns != nil; dns = dns.Next {
do(dns.Address)
}
for gw := aa.FirstGatewayAddress; gw != nil; gw = gw.Next {
do(gw.Address)
ip := dns.Address.IP()
if ip == nil || ip.IsLoopback() || seen[ip.String()] {
continue
}
if _, ok := addressMap[ip.String()]; ok {
continue
}
seen[ip.String()] = true
ns = append(ns, ip.String())
}
}
return ns

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
"slices"
"sync"
"time"
@@ -30,18 +31,49 @@ const (
ResolverTypePrivate = "private"
)
const bootstrapDNS = "76.76.2.22"
const (
controldBootstrapDns = "76.76.2.22"
controldPublicDns = "76.76.2.0"
)
var controldPublicDnsWithPort = net.JoinHostPort(controldPublicDns, "53")
// or is the Resolver used for ResolverTypeOS.
var or = &osResolver{nameservers: defaultNameservers()}
// defaultNameservers returns OS nameservers plus ctrld bootstrap nameserver.
// defaultNameservers returns OS nameservers plus ControlD public DNS.
func defaultNameservers() []string {
ns := nameservers()
ns = append(ns, net.JoinHostPort(bootstrapDNS, "53"))
return ns
}
// InitializeOsResolver initializes OS resolver using the current system DNS settings.
// It returns the nameservers that is going to be used by the OS resolver.
//
// It's the caller's responsibility to ensure the system DNS is in a clean state before
// calling this function.
func InitializeOsResolver() []string {
or.nameservers = or.nameservers[:0]
for _, ns := range defaultNameservers() {
if testNameserver(ns) {
or.nameservers = append(or.nameservers, ns)
}
}
or.nameservers = append(or.nameservers, controldPublicDnsWithPort)
return or.nameservers
}
// testPlainDnsNameserver sends a test query to DNS nameserver to check if the server is available.
func testNameserver(addr string) bool {
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
client := new(dns.Client)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, _, err := client.ExchangeContext(ctx, msg, addr)
return err == nil
}
// Resolver is the interface that wraps the basic DNS operations.
//
// Resolve resolves the DNS query, return the result and the corresponding error.
@@ -76,8 +108,9 @@ type osResolver struct {
}
type osResolverResult struct {
answer *dns.Msg
err error
answer *dns.Msg
err error
isControlDPublicDNS bool
}
// Resolve resolves DNS queries using pre-configured nameservers.
@@ -103,19 +136,34 @@ func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error
go func(server string) {
defer wg.Done()
answer, _, err := dnsClient.ExchangeContext(ctx, msg.Copy(), server)
ch <- &osResolverResult{answer: answer, err: err}
ch <- &osResolverResult{answer: answer, err: err, isControlDPublicDNS: server == controldPublicDnsWithPort}
}(server)
}
var (
nonSuccessAnswer *dns.Msg
controldSuccessAnswer *dns.Msg
)
errs := make([]error, 0, numServers)
for res := range ch {
if res.err == nil {
cancel()
return res.answer, res.err
switch {
case res.answer != nil && res.answer.Rcode == dns.RcodeSuccess:
if res.isControlDPublicDNS {
controldSuccessAnswer = res.answer // only use ControlD answer as last one.
} else {
cancel()
return res.answer, nil
}
case res.answer != nil:
nonSuccessAnswer = res.answer
}
errs = append(errs, res.err)
}
for _, answer := range []*dns.Msg{controldSuccessAnswer, nonSuccessAnswer} {
if answer != nil {
return answer, nil
}
}
return nil, errors.Join(errs...)
}
@@ -125,7 +173,7 @@ type legacyResolver struct {
func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
// See comment in (*dotResolver).resolve method.
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
@@ -163,7 +211,7 @@ func LookupIP(domain string) []string {
func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) {
resolver := &osResolver{nameservers: nameservers()}
if withBootstrapDNS {
resolver.nameservers = append([]string{net.JoinHostPort(bootstrapDNS, "53")}, resolver.nameservers...)
resolver.nameservers = append([]string{net.JoinHostPort(controldBootstrapDns, "53")}, resolver.nameservers...)
}
ProxyLogger.Load().Debug().Msgf("resolving %q using bootstrap DNS %q", domain, resolver.nameservers)
timeoutMs := 2000
@@ -239,7 +287,7 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string)
// - Input servers.
func NewBootstrapResolver(servers ...string) Resolver {
resolver := &osResolver{nameservers: nameservers()}
resolver.nameservers = append([]string{net.JoinHostPort(bootstrapDNS, "53")}, resolver.nameservers...)
resolver.nameservers = append([]string{controldPublicDnsWithPort}, resolver.nameservers...)
for _, ns := range servers {
resolver.nameservers = append([]string{net.JoinHostPort(ns, "53")}, resolver.nameservers...)
}
@@ -266,11 +314,11 @@ func NewPrivateResolver() Resolver {
// - Direct listener that has ctrld as an upstream (e.g: dnsmasq).
//
// causing the query always succeed.
if sliceContains(resolveConfNss, host) {
if slices.Contains(resolveConfNss, host) {
continue
}
// Ignoring local RFC 1918 addresses.
if sliceContains(localRfc1918Addrs, host) {
if slices.Contains(localRfc1918Addrs, host) {
continue
}
ip := net.ParseIP(host)
@@ -322,20 +370,3 @@ func newDialer(dnsAddress string) *net.Dialer {
},
}
}
// TODO(cuonglm): use slices.Contains once upgrading to go1.21
// 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,6 +2,8 @@ package ctrld
import (
"context"
"net"
"sync"
"testing"
"time"
@@ -28,6 +30,57 @@ func Test_osResolver_Resolve(t *testing.T) {
}
}
func Test_osResolver_ResolveWithNonSuccessAnswer(t *testing.T) {
ns := make([]string, 0, 2)
servers := make([]*dns.Server, 0, 2)
successHandler := dns.HandlerFunc(func(w dns.ResponseWriter, msg *dns.Msg) {
m := new(dns.Msg)
m.SetRcode(msg, dns.RcodeSuccess)
w.WriteMsg(m)
})
nonSuccessHandlerWithRcode := func(rcode int) dns.HandlerFunc {
return dns.HandlerFunc(func(w dns.ResponseWriter, msg *dns.Msg) {
m := new(dns.Msg)
m.SetRcode(msg, rcode)
w.WriteMsg(m)
})
}
handlers := []dns.Handler{
nonSuccessHandlerWithRcode(dns.RcodeRefused),
nonSuccessHandlerWithRcode(dns.RcodeNameError),
successHandler,
}
for i := range handlers {
pc, err := net.ListenPacket("udp", ":0")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
s, addr, err := runLocalPacketConnTestServer(t, pc, handlers[i])
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ns = append(ns, addr)
servers = append(servers, s)
}
defer func() {
for _, server := range servers {
server.Shutdown()
}
}()
resolver := &osResolver{nameservers: ns}
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
answer, err := resolver.Resolve(context.Background(), msg)
if err != nil {
t.Fatal(err)
}
if answer.Rcode != dns.RcodeSuccess {
t.Errorf("unexpected return code: %s", dns.RcodeToString[answer.Rcode])
}
}
func Test_upstreamTypeFromEndpoint(t *testing.T) {
tests := []struct {
name string
@@ -51,3 +104,33 @@ func Test_upstreamTypeFromEndpoint(t *testing.T) {
})
}
}
func runLocalPacketConnTestServer(t *testing.T, pc net.PacketConn, handler dns.Handler, opts ...func(*dns.Server)) (*dns.Server, string, error) {
t.Helper()
server := &dns.Server{
PacketConn: pc,
ReadTimeout: time.Hour,
WriteTimeout: time.Hour,
Handler: handler,
}
waitLock := sync.Mutex{}
waitLock.Lock()
server.NotifyStartedFunc = waitLock.Unlock
for _, opt := range opts {
opt(server)
}
addr, closer := pc.LocalAddr().String(), pc
go func() {
if err := server.ActivateAndServe(); err != nil {
t.Error(err)
}
closer.Close()
}()
waitLock.Lock()
return server, addr, nil
}

View File

@@ -27,6 +27,8 @@ var sampleConfigContent = `
[service]
log_level = "info"
log_path = "/path/to/log.log"
dns_watchdog_enabled = false
dns_watchdog_interval = "20s"
[network.0]
name = "Home Wifi"