Compare commits

..

551 Commits

Author SHA1 Message Date
Cuong Manh Le
26257cf24a Merge pull request #250 from Control-D-Inc/release-branch-v1.4.5
Release branch v1.4.5
2025-07-25 04:06:24 +07:00
Cuong Manh Le
36a7423634 refactor: extract empty string filtering to reusable function
- Add filterEmptyStrings utility function for consistent string filtering
- Replace inline slices.DeleteFunc calls with filterEmptyStrings
- Apply filtering to osArgs in addition to command args
- Improves code readability and reduces duplication
- Uses slices.DeleteFunc internally for efficient filtering
2025-07-15 23:09:54 +07:00
Cuong Manh Le
e616091249 cmd/cli: ignore empty positional argument for start command
The validation was added during v1.4.0 release, but causing one-liner
install failed unexpectedly.
2025-07-15 21:57:36 +07:00
Cuong Manh Le
0948161529 Avoiding Windows runners file locking issue 2025-07-15 20:59:57 +07:00
Cuong Manh Le
ce29b5d217 refactor: split selfUpgradeCheck into version check and upgrade execution
- Move version checking logic to shouldUpgrade for testability
- Move upgrade command execution to performUpgrade
- selfUpgradeCheck now composes these two for clarity
- Update and expand tests: focus on logic, not side effects
- Improves maintainability, testability, and separation of concerns
2025-07-15 19:12:23 +07:00
Cuong Manh Le
de24fa293e internal/router: support Ubios 4.3+
This change improves compatibility with newer UniFi OS versions while
maintaining backward compatibility with UniFi OS 4.2 and earlier.
The refactoring also reduces code duplication and improves maintainability
by centralizing dnsmasq configuration path logic.
2025-07-15 19:11:13 +07:00
Cuong Manh Le
6663925c4d internal/router: support Merlin Guest Network Pro VLAN
By looking for any additional dnsmasq configuration files under
/tmp/etc, and handling them like default one.
2025-07-15 19:10:10 +07:00
Cuong Manh Le
b9ece6d7b9 Merge pull request #239 from Control-D-Inc/release-branch-v1.4.4
Release branch v1.4.4
2025-06-16 16:45:11 +07:00
Cuong Manh Le
c4efa1ab97 Initializing default os resolver during upstream bootstrap
Since calling defaultNameservers may block the whole bootstrap process
if there's no valid DNS servers available.
2025-06-12 16:22:52 +07:00
Cuong Manh Le
7cea5305e1 all: fix a regression causing invalid reloading timeout
In v1.4.3, ControlD bootstrap DNS is used again for bootstrapping
process. When this happened, the default system nameservers will be
retrieved first, then ControlD DNS will be used if none available.

However, getting default system nameservers process may take longer than
reloading command timeout, causing invalid error message printed.

To fix this, ensuring default system nameservers is retrieved once.
2025-06-10 19:42:26 +07:00
Cuong Manh Le
a20fbf95de all: enhanced TLS certificate verification error messages
Added more descriptive error messages for TLS certificate verification
failures across DoH, DoT, DoQ, and DoH3 protocols. The error messages
now include:

- Certificate subject information
- Issuer organization details
- Common name of the certificate

This helps users and developers better understand certificate validation
failures by providing specific details about the untrusted certificate,
rather than just a generic "unknown authority" message.

Example error message change:
Before: "certificate signed by unknown authority"
After: "certificate signed by unknown authority: TestCA, TestOrg, TestIssuerOrg"
2025-06-10 19:42:00 +07:00
Cuong Manh Le
628c4302aa cmd/cli: preserve search domains when reverting resolv.conf
Fixes search domains not being preserved when the resolv.conf file is
reverted to its previous state. This ensures that important domain
search configuration is maintained during DNS configuration changes.

The search domains handling was missing in setResolvConf function,
which is responsible for restoring DNS settings.
2025-06-04 18:36:51 +07:00
Cuong Manh Le
8dc34f8bf5 internal/net: improve IPv6 support detection with multiple common ports
Changed the IPv6 support detection to try multiple common ports (HTTP/HTTPS) instead of
just testing against a DNS port. The function now returns both the IPv6 support status
and the successful port that confirmed the connectivity. This makes the IPv6 detection
more reliable by not depending solely on DNS port availability.

Previously, the function only tested connectivity to a DNS port (53) over IPv6.
Now it tries to connect to commonly available ports like HTTP (80) and HTTPS (443)
until it finds a working one, making the detection more robust in environments where
certain ports might be blocked.
2025-06-04 16:29:28 +07:00
Cuong Manh Le
b4faf82f76 all: set edns0 cookie for shared message
For cached or singleflight messages, the edns0 cookie is currently
shared among all of them, causing mismatch cookie warning from clients.
The ctrld proxy should re-set client cookies for each request
separately, even though they use the same shared answer.
2025-05-27 18:09:16 +07:00
Cuong Manh Le
a983dfaee2 all: optimizing multiple queries to upstreams
To guard ctrld from possible DoS to remote upstreams, this commit
implements following things:

 - Optimizing multiple queries with the same domain and qtype to use
   singleflight group, so there's only 1 query to remote upstreams at
   any time.
 - Adding a hot cache with 1 second TTL, so repeated queries will re-use
   the result from cache if existed, preventing unnecessary requests to
   remote upstreams.
2025-05-23 21:09:15 +07:00
Cuong Manh Le
62f73bcaa2 all: preserve search domains settings
So bare hostname will be resolved as expected when ctrld is running.
2025-05-15 17:00:59 +07:00
Cuong Manh Le
00e9d2bdd3 all: do not listen on 0.0.0.0 on desktop clients
Since this may create security vulnerabilities such as DNS amplification
or abusing because the listener was exposed to the entire local network.
2025-05-15 16:59:24 +07:00
Cuong Manh Le
ace3b1e66e Merge pull request #233 from Control-D-Inc/release-branch-v1.4.3
[WIP] Release branch v1.4.3
2025-04-28 17:08:34 +07:00
Cuong Manh Le
d1ea1ba08c Disable parallel test for TestUpstreamConfig_SetupBootstrapIP
There's a bug in wmi library which causes race condition when getting
wmi instance manager concurrently. The new tests for setup bootstrap ip
concurrently thus failed unexpectedly.

There's going to be a fix sent to the upstream, in the meantime, disable
the parallel test temporary.

See: https://github.com/microsoft/wmi/issues/165
2025-04-18 00:36:58 +07:00
Cuong Manh Le
c06c8aa859 Unifying DNS from /etc/resolv.conf function
As part of v1.4.0 release, reading DNS from /etc/resolv.conf file is
only available for Macos. However, there's no reason to prevent this
function from working on other *nix systems.

This commit unify the function to *nix, so it could be added as DNS
source for Linux and Freebsd.
2025-04-17 17:19:47 +07:00
Cuong Manh Le
0c2cc00c4f Using ControlD bootstrap DNS again
So on system where there's no available DNS, non-ControlD upstreams
could be bootstrapped like before.

While at it, also improving lookupIP to not initializing OS resolver
anymore, removing the un-necessary contention for accquiring/releasing
OS resolver mutex.
2025-04-17 17:15:15 +07:00
Cuong Manh Le
8d6ea91f35 Allowing bootstrap IPs for ControlD sub-domains
So protocol which uses sub-domain like doq/dot could be bootstrap in
case of no DNS available.
2025-04-17 17:13:10 +07:00
Cuong Manh Le
7dfb77228f cmd/cli: handle ipc warning message more precisely
If the socket file does not exist, it means that "ctrld start" was never
run. In this case, the warning message should not be printed to avoid
needless confusion.
2025-04-17 17:12:06 +07:00
Cuong Manh Le
24910f1fa6 Merge pull request #230 from Control-D-Inc/release-branch-v1.4.2
Release branch v1.4.2
2025-04-10 23:27:30 +07:00
Yegor Sak
433a61d2ee Update file README.md 2025-04-08 10:10:32 +07:00
Cuong Manh Le
3937e885f0 Bump golang.org/x/net to v0.38.0
Fixes CVE-2025-22872
2025-04-01 23:20:12 +07:00
Cuong Manh Le
c651003cc4 Support direct ip in lookupIP function
So users can supply ip directly in config, avoiding unnecessary domain
lookup while bootstrapping.
2025-03-31 23:02:59 +07:00
Cuong Manh Le
b7ccfcb8b4 Do not include commit hash when releasing tag 2025-03-27 20:11:57 +07:00
Cuong Manh Le
a9ed70200b internal/router: change dnsmasq config manipulation on Merlin
Generally, using /jffs/scripts/dnsmasq.postconf is the right way to add
custom configuration to dnsmasq on Merlin. However, we have seen many
reports that the postconf does not work on their devices.

This commit changes how dnsmasq config manipulation is done on Merlin,
so it's expected to work on all Merlin devices:

 - Writing /jffs/scripts/dnsmasq.postconf script
 - Copy current dnsmasq.conf to /jffs/configs/dnsmasq.conf
 - Run postconf script directly on /jffs/configs/dnsmasq.conf
 - Restart dnsmasq

This way, the /jffs/configs/dnsmasq.conf will contain both current
dnsmasq config, and also custom config added by ctrld, without worrying
about conflicting, because configuration was added by postconf.

See (1) for more details about custom config files on Merlin.

(1) https://github.com/RMerl/asuswrt-merlin.ng/wiki/Custom-config-files
2025-03-26 23:18:53 +07:00
Cuong Manh Le
c6365e6b74 cmd/cli: handle stop signal from service manager
So using "ctrld stop" or service manager to stop ctrld will end up with
the same result, stopped ctrld with a working DNS, and deactivation pin
code will always have effects if set.
2025-03-26 23:18:36 +07:00
Cuong Manh Le
dacc67e50f Using LAN servers from OS resolver for private resolver
So heavy functions are only called once and could be re-used in
subsequent calls to NewPrivateResolver.
2025-03-26 23:18:21 +07:00
Cuong Manh Le
c60cf33af3 all: implement self-upgrade flag from API
So upgrading don't have to be initiated manually, helping large
deployments to upgrade to latest ctrld version easily.
2025-03-26 23:18:04 +07:00
Cuong Manh Le
f27cbe3525 all: fallback to use direct IPs for ControlD assets 2025-03-26 23:17:50 +07:00
Cuong Manh Le
2de1b9929a Do not send legacy DNS queries to bootstrap DNS 2025-03-26 23:17:26 +07:00
Cuong Manh Le
8bf654aece Bump golang.org/x/net to v0.36.0
Fixing https://pkg.go.dev/vuln/GO-2025-3503
2025-03-26 23:17:18 +07:00
Cuong Manh Le
84376ed719 cmd/cli: add missing pre-run setup for start command
Otherwise, ctrld won't be able to reset DNS correctly if problems
happened during self-check process.
2025-03-26 23:17:06 +07:00
Cuong Manh Le
7a136b8874 all: disable client discover on desktop platforms
Since requests are mostly originated from the machine itself, so all
necessary metadata is local to it.

Currently, the desktop platforms are Windows desktop and darwin.
2025-03-26 23:16:57 +07:00
Cuong Manh Le
58c0e4f15a all: remove ipv6 check polling
netmon provides ipv6 availability during network event changes, so use
this metadata instead of wasting on polling check.

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

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

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

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

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

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

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

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

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

put back validInterface check

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

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

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

use winipcfg DNS method

use WMI with registry fallback

go back to registry method

restore saved static configs on stop and uninstall

restore ipv6 DHCP if no saved static ipv6 addresses

do not save loopback IPs for static configs

handle watchdog interface changed for new interfaces

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

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

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

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

patch darwin interface name

patch darwin interface name, debugging

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

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

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

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

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

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

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

more debugging for ipv6 availability checks

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

use first public non success answer when no LAN nameservers exist

use first public non success answer when no LAN nameservers exist

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

fixed comment

simpler index check

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

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

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

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

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

While at it, also removing unused method stopClientInfoDiscover.

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

debugging

debugging

debugging

use default route interface IP for OS resolver queries

remove retries

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

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

handle ipv6 local addresses

guard ciTable from nil pointer

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

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

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

fix get valid ifaces in nameservers_bsd

nameservers on MacOS can be found in resolv.conf reliably

nameservers on MacOS can be found in resolv.conf reliably

exclude local IPs from MacOS resolve conf check

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

add more dns server fetching options

never skip OS resolver in IsDown check

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

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

While at it:

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

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

fix real ip in debug log

treat all upstreams as down upon network change

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

early return for stop cmd when not installed or stopped

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

set service on new run, fix duplicate args

revert startCmd in upgrade flow due to pin compat issues

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

debugging

debugging

debugging

debugging

debugging WMI

remove stackexchange lib, use ms wmi pkg

debugging

debugging

set correct class

fix os reolver init issues

fix netadapter class

use os resolver instead of fetching default nameservers while already running

remove debug lines

fix lookup IP

fix lookup IP

fix lookup IP

fix lookup IP

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

skip type 24 in nameserver detection

skip type 24 in nameserver detection

remove interface type check from valid interfaces for now

skip non hardware interfaces in DNS nameserver lookup

ignore win api log output

set retries to 5 and 1s backoff

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

init running iface for upgrade

update windows service options for auto restarts on failure

make upgrade use the actual stop and start commands

fix the windows service retry logic

fix the windows service retry logic

task debugging

more task debugging

windows service name fix

windows service name fix

fix start command args

fix restart delay

dont recover from non crash failures

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

fix logging

try to enable nameserver logs

try to enable nameserver logs

handle flags in interface state changes

debugging

debugging

debugging

fix state detection, AD status fix

fix debugging line

more dc info

always log state changes

remove unused method

windows AD IP discovery

windows AD IP discovery

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

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

use upstreamIS var

init map, fix watcher flag

attempt to detect network changes

attempt to detect network changes

cancel and rerun reinitializeOSResolver

cancel and rerun reinitializeOSResolver

cancel and rerun reinitializeOSResolver

ignore invalid inferaces

ignore invalid inferaces

allow OS resolver upstream to fail

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

fix unused var

simpler active iface check, debug logs

dont spam network service name patching on Mac

dont wait for os resolver nameserver testing

remove test for osresovlers for now

async nameserver testing

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

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

fix restoreDNS errors

add some debugging information

fix wront type in log msg

set send logs command timeout to 5 mins

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

prefer default route, ignore non physical interfaces

prefer default route, ignore non physical interfaces

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

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

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

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

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

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

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

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

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

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

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

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

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

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

To fix these problems:

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

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

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

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

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

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

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

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

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

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

While at it, some optimizations are made:

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes for these problems are quite obvious:

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

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

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

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

---

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

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

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

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

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

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

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

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

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

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

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

Using a sane timeout value of 30 seconds, which should be enough for the
ctrld service started in normal condition.
2024-03-22 15:57:42 +07:00
Yegor S
810cbd1f4f Merge pull request #138 from Control-D-Inc/release-branch-v1.3.5
Release branch v1.3.5
2024-03-04 12:40:40 -05:00
Cuong Manh Le
49eebcdcbc .github/workflows: bump go version to 1.21.x 2024-03-04 14:49:52 +07:00
Cuong Manh Le
e89021ec3a cmd/cli: only set DNS for physical interfaces on Windows
By filtering the interfaces by MAC address instead of name.
2024-03-04 14:49:52 +07:00
Cuong Manh Le
73a697b2fa cmd/cli: remove old DNS settings on installing 2024-02-27 23:18:11 +07:00
Yegor Sak
9319d08046 Update file config.md 2024-02-27 23:18:11 +07:00
Cuong Manh Le
7dc5138e91 cmd/cli: watch resolv.conf on all unix platforms 2024-02-22 18:15:36 +07:00
Cuong Manh Le
8f189c919a cmd/cli: skip deactivation check for old socket server
If the server is running old version of ctrld, the deactivation pin
check will return 404 not found, the client should consider this as no
error instead of returning invalid pin code.

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

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

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

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

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

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

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

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

Fixing this by using max-cache-ttl, which have the same effect with
setting cache-size=0 but won't conflict with existing routers config.
2024-01-22 23:05:56 +07:00
Cuong Manh Le
cb445825f4 internal/clientinfo: add NDP discovery 2024-01-22 23:05:44 +07:00
Cuong Manh Le
4d996e317b Fix wrong toml struct tag for arp discovery 2024-01-22 23:04:22 +07:00
Yegor S
30c9012004 Update config.md 2023-12-19 16:58:49 -05:00
Yegor S
2a23feaf4b Merge pull request #113 from Control-D-Inc/release-branch-v1.3.3
Release branch v1.3.3
2023-12-18 22:28:45 -05:00
Cuong Manh Le
b82ad3720c cmd/cli: guard against nil client info
Though it's only possible raised in testing, still better to be safe.
2023-12-19 01:48:07 +07:00
Cuong Manh Le
8d2cb6091e cmd/cli: add QUERY/REPLY prefix to proxying log
So the log in INFO log is aligned, making it easier for human to
monitoring the log, either via console or running "tail" command.
2023-12-19 01:31:30 +07:00
Yegor Sak
3023f33dff Update file config.md 2023-12-18 21:32:26 +07:00
Cuong Manh Le
22e97e981a cmd/cli: ignore invalid flags for "ctrld run" 2023-12-18 21:32:01 +07:00
Cuong Manh Le
44484e1231 cmd/cli: add WSAEHOSTUNREACH to network error
Windows may raise WSAEHOSTUNREACH instead WSAENETUNREACH in case of
network not available when resuming from sleep or switching network, so
checkUpstream is never kicked in for this type of error.
2023-12-18 21:31:46 +07:00
Cuong Manh Le
eac60b87c7 Improving DOH header logging 2023-12-18 21:31:35 +07:00
Cuong Manh Le
8db28cb76e cmd/cli: improving logging of proxying action
INFO level becomes a sensible setting for normal operation that does not
overwhelm. Adding some small details to make DEBUG level more useful.
2023-12-18 21:31:08 +07:00
Cuong Manh Le
8dbe828b99 cmd/cli: change socket dir to /var/run on *nix 2023-12-18 21:30:53 +07:00
Cuong Manh Le
5c24acd952 cmd/cli: fix bug causes checkUpstream run only once
To prevent duplicated running of checkUpstream function at the same
time, upstream monitor uses a boolean to report whether the upstream is
checking. If this boolean is true, then other calls after the first one
will be returned immediately.

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

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

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

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

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

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

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

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

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

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

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

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

While at it, also doing the check DNS in separate goroutine, prevent it
from blocking ctrld from notifying others that it "started". The issue
was seen when ctrld is configured as direct listener, requests are
flooded before ctrld started, causing the healtch process failed.
2023-11-06 20:01:25 +07:00
Cuong Manh Le
58a00ea24a all: implement reload command
This commit adds reload command to ctrld for re-fetch new config from
ContorlD API or re-read the current config on disk.
2023-11-06 20:01:03 +07:00
Cuong Manh Le
712b23a4bb cmd/cli: initialize upstream proto for mobile 2023-11-06 20:00:28 +07:00
Cuong Manh Le
baf836557c cmd/cli: fix wrong checking condition in removeProvTokenFromArgs
The provision token is only used once, then do not have any effect after
Control D uid is fetched. So making it appears in "ctrld run" command is
useless.
2023-11-06 20:00:10 +07:00
Cuong Manh Le
904b23eeac cmd/cli: add --proto flag to set upstream type in cd mode 2023-11-06 19:59:52 +07:00
Cuong Manh Le
6aafe445f5 cmd/cli: add nextdns mode
Adding --nextdns flag to "ctrld start" command for generating ctrld
config with nextdns resolver id, then use nextdns as an upstream.
2023-11-06 19:59:31 +07:00
Ginder Singh
ebd516855b added safe return if error happens during resolver fetch. 2023-11-06 19:58:53 +07:00
Cuong Manh Le
df4e04719e cmd/cli: relax service dependency on systemd-networkd-wait-online
ctrld wants systemd-networkd-wait-online starts before starting itself,
but ctrld should not be blocked waiting for it started.
2023-11-06 19:58:32 +07:00
Cuong Manh Le
2440d922c6 all: add MAC address base policy
While at it, also update the config doc to clarify the order of matching
preference, and the matter of rules order within each policy.
2023-11-06 19:57:50 +07:00
Yegor S
f1b8d1c4ad Merge pull request #93 from Control-D-Inc/release-branch-v1.3.1
Release branch v1.3.1
2023-10-10 22:29:43 -04:00
Cuong Manh Le
79076bda35 scripts: fix wrong package path 2023-10-10 22:04:59 +07:00
Cuong Manh Le
9d2ea15346 internal/clientinfo: ignoring localhost entry for hostsfile mapping
Otherwise, actual hostname will be overriden with "localhost", which is
rather confusing/bad for UX.
2023-10-10 22:04:59 +07:00
Cuong Manh Le
77c1113ff7 Excluding nameservers from /etc/resolv.conf for private resolver
Since these ones are either ctrld itself or direct listener that ctrld
is being upstream for, which makes health check query always succeed.
2023-10-06 08:57:47 +07:00
Cuong Manh Le
e03ad4cd77 cmd/cli: ensure cd/cd-org flags must be non-empty 2023-10-04 16:34:47 +07:00
Cuong Manh Le
6e28517454 all: generalize vpn client info
VPN clients often have empty MAC address, because they come from virtual
network interface. However, there's other setup/devices also create
virtual interface, but is not VPN.

Changing source of those clients to empty to prevent confustion in
clients list command output.
2023-10-04 16:34:47 +07:00
Cuong Manh Le
8ddbf881b3 Sync quic transport code with DOH transport
Otherwise, the old code will leave un-used connections open-ed, causing
ports leaking and prevent others from creating UDP conn.
2023-10-04 16:34:47 +07:00
Connie Lukawski
c58516cfb0 Fix windows config/socket dir location
RMM uses non-user account which results in config + socket file being
written to a random directory, which is not a real directory that can be
accessed.

Fix this by using directory of ctrld binary as user home dir.
2023-10-04 16:34:47 +07:00
Cuong Manh Le
34758f6205 Sending OS information in DoH header 2023-09-22 18:47:14 +07:00
Cuong Manh Le
a9959a6f3d all: guarding against DNS forwarding loop
Based on how dnsmasq "--dns-loop-detect" mechanism.

See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
2023-09-22 18:46:43 +07:00
Cuong Manh Le
511c4e696f cmd/cli: add upstream monitor
Some users mentioned that when there is an Internet outage, ctrld fails
to recover, crashing or locks up the router. When requests start
failing, this results in the clients emitting more queries, creating a
resource spiral of death that can brick the device entirely.

To guard against this case, this commit implement an upstream monitor
approach:

 - Marking upstream as down after 100 consecutive failed queries.
 - Start a goroutine to check when the upstream is back again.
 - When upstream is down, answer all queries with SERVFAIL.
 - The checking process uses backoff retry to reduce high requests rate.
 - As long as the query succeeded, marking the upstream as alive then
   start operate normally.
2023-09-22 18:45:59 +07:00
Cuong Manh Le
bed7435b0c cmd: refactoring Run function
So it's easier, more clear, more isolation between code on non-mobile
and mobile platforms.
2023-09-22 18:45:00 +07:00
Ginder Singh
507c1afd59 cmd: allow import/running ctrld as library 2023-09-22 18:44:24 +07:00
Cuong Manh Le
2765487f10 cmd/cli: use better approach for detecting NetworkManager
Currently, ctrld assumes that NetworkManager is not available if writing
to /etc/NetworkManager/conf.d return directory not exist error. That
would work on most Linux distros. However, cloud provider may do some
hacks, causing ctrld confusion and think that NetworkManager is
available.

Fixing this by checking whether NetworkManager binary presents first.

While at it, also fixing a bug when restarting NetworkManager failed
causing ctrld hangs. The go-systemd library is not clear about this, but
the waitCh channel won't never be closed if error occurred, so we must
return immediately instead of receiving from it blindly.
2023-09-22 18:42:21 +07:00
Cuong Manh Le
80a88811cd cmd/cli: restart systemd-resolved after setting DNS
So the current selected DNS server will be reset, and the new one will
be used by systemd-resolved after first query made.
2023-09-22 18:41:48 +07:00
Cuong Manh Le
823195c504 internal/clientinfo: monitor nameserver health
In case the resolver could not reach nameserver, ptr discover should
only print error message once, then stop doing the query until the
nameserver is reachable. This would prevent ptr discover from flooding
ctrld log with a lot of duplicated messages.
2023-09-22 18:41:40 +07:00
Cuong Manh Le
0f3e8c7ada all: include client IP if ctrld is dnsmasq upstream
So ctrld can record the raw/original client IP instead of looking up
from MAC to IP, which may not the right choice in some network setup
like using wireguard/vpn on Merlin router.
2023-09-22 18:40:25 +07:00
Cuong Manh Le
ee5eb4fc4e cmd/cli: another fix for finding default route IP
The current approach to get default route IP is finding the LAN
interface with the same MAC address. However, there could be multiple
interfaces like that, making ctrld confused.

This commit fixes this issue, by listing all possible private IPs, then
sorting them and use the smallest one for router self queries.
2023-09-22 18:39:47 +07:00
Cuong Manh Le
d58d8074f4 internal/clientinfo: use jaytaylor/go-hostsfile for parsing hosts file
txn2/txeh lower the hostname, which is not suitable for ctrld use case.
2023-09-22 18:39:04 +07:00
Cuong Manh Le
94a0530991 cmd/cli: fix default route IP with public interface
For reporting router queries, ctrld uses private IP of the default route
interface. However, when the default route is conntected directly to
ISP, the interface will have a public IP, and another interface with the
same MAC address will be created for LAN ip. So when no private IP found
for default route interface, ctrld must look at the other interface to
find the corret LAN ip.
2023-09-22 18:38:40 +07:00
Cuong Manh Le
073af0f89c Always use ctrld bootstrap nameserver for ResolverTypeOS
So in case no nameservers can be found, default OS resolver could still
resolve queries.
2023-09-22 18:37:54 +07:00
Cuong Manh Le
6028b8f186 internal/router/edgeos: use /etc/version for checking USG
Since mca-cli-op may not be available during boot time.
2023-09-22 18:37:04 +07:00
Cuong Manh Le
126477ef88 all: do not depend on vyatta-dhcpd service on EdgeOS
The only reason that forces ctrld to depend on vyatta-dhcpd service on
EdgeOS is allowing ctrld to watch lease files properly, because those
files may not be created at the time client info table initialized.

However, on some EdgeOS version, vyatta-dhcpd could not start with an
empty config file, causing restart loop itself, flooding systemd log,
making the router run out of memory.

To fix this, instead of depending on vyatta-dhcpd, we should just watch
for lease files creation, then adding them to watch list.

While at it, also making ctrld starts after nss-lookup, ensuring we have
a working DNS before starting ctrld.
2023-09-22 18:35:36 +07:00
Cuong Manh Le
13391fd469 Generating working default config in non-cd mode
Using the same approach as in cd mode, but do it only once when running
ctrld the first time, then the config will be re-used then.

While at it, also adding Dockerfile.debug for better troubleshooting
with alpine base image.
2023-09-22 18:34:46 +07:00
Cuong Manh Le
82e44b01af Add hosts file as source for hostname resolver 2023-09-22 18:29:37 +07:00
Cuong Manh Le
e355fd70ab Upgrading quic-go to v0.38.0 2023-09-22 18:28:36 +07:00
Cuong Manh Le
d5c171735e internal/clientinfo: make ptr lookup failure log level WARN 2023-09-22 18:27:22 +07:00
Yegor S
b175368794 Merge pull request #83 from Control-D-Inc/issue-82
Use 1.20-bullseye in Dockerfile
2023-09-06 12:50:30 -04:00
Cuong Manh Le
bcf4c25ba8 Use 1.20-bullseye in Dockerfile
The current quic-go v0.32.0 could not be built with go 1.21, next
release of ctrld will upgrade it to latest version.

Fixes #82
2023-09-05 22:39:15 +07:00
Yegor S
11b09af76d Merge pull request #78 from Control-D-Inc/add-missing-commits
Add missing commits
2023-08-30 10:51:00 -04:00
Yegor S
af0380a96a Merge pull request #73 from Control-D-Inc/fix-missing-build-script
scripts: add missing build script
2023-08-30 10:50:30 -04:00
Cuong Manh Le
f39512b4c0 cmd/ctrld: only write to config file if listener config changed
Updates #149
2023-08-29 10:01:33 +07:00
Cuong Manh Le
7ce62ccaec Validate DoH/DoH3 endpoint properly
When resolver type is doh/doh3, the endpoint must be a valid http url.

Updates #149
2023-08-29 10:01:06 +07:00
Cuong Manh Le
44c0a06996 scripts: add missing build script 2023-08-17 16:52:04 +07:00
Yegor S
f7d3db06c6 Update README.md 2023-08-15 12:03:25 -04:00
Yegor S
0ca37dc707 Merge pull request #68 from Control-D-Inc/release-branch-v1.3.0
Release branch v1.3.0
2023-08-15 11:49:47 -04:00
Cuong Manh Le
2bcba7b578 cmd/ctrld: workaround staticcheck complain on non-Linux OSes 2023-08-15 18:22:38 +07:00
Cuong Manh Le
829e93c079 cmd: allow import/running ctrld as library 2023-08-15 18:22:38 +07:00
Cuong Manh Le
4896563e3c Various improvements and bug fixes
- Watch more events for lease file changes
 - Improving network up detection by using bootstrap IPv6 along side
   IPv4 one.
 - Emitting log to notice user that ctrld is starting.
 - Using systemd wrapper to provide correct status.
 - Restoring DNS on stop on Windows.
2023-08-14 21:22:11 +07:00
Cuong Manh Le
0c096d5f07 internal/router: make router.Cleanup idempotent
On routers where we want to wait for NTP by checking nvram key. Before
waiting, we clean up the router to ensure it's restored to original
state. However, router.Cleanup is not idempotent, causing dnsmasq
restarted. On tomato/ddwrt, restarting have no delay, and spawning new
dnsmasq process immediately. On merlin, somehow it takes time to spawn
new dnsmasq process, causing ctrld wrongly think there's no one
listening on port 53.

Fixing this by ensuring router.Cleanup is idempotent. While at it, also
adding "ntp_done" to nvram key, which is now using on latest ddwrt.
2023-08-14 21:22:11 +07:00
Yegor Sak
ab8f072388 Update README.md 2023-08-11 20:28:03 +07:00
Cuong Manh Le
32219e7d32 internal/router: wait ntp synced on Synology 2023-08-11 20:28:03 +07:00
Cuong Manh Le
d292e03d1b Satisfying staticcheck linter 2023-08-10 00:33:42 +07:00
Cuong Manh Le
5dd6336953 internal/router/synology: define normal exit condition 2023-08-10 00:00:24 +07:00
Cuong Manh Le
854a244ebb Fix restart command when ctrld service was already stopped 2023-08-09 23:57:52 +07:00
Cuong Manh Le
125b4b6077 cmd/ctrld: wait ctrld started during restart command 2023-08-09 23:57:41 +07:00
Cuong Manh Le
46e8d4fad7 cmd/ctrld: prevent race condition when ping socket control server 2023-08-09 23:57:30 +07:00
Cuong Manh Le
e5389ffecb internal/clientinfo: use all possible source IP for listing clients 2023-08-09 23:57:20 +07:00
Cuong Manh Le
46509be8a0 cmd/ctrld: start service before restart on Windows
On Windows, calling s.Restart will fail if service is not running,
ensure ctrld is started before calling restart.
2023-08-09 23:57:08 +07:00
Cuong Manh Le
d3d2ed539f cmd/ctrld: correct syscall.Errno for Windows
On Windows, the syscall error numbers are different, so correct the
value so we can detect right errors we want.
2023-08-09 23:56:55 +07:00
Cuong Manh Le
8496adc638 cmd/ctrld: make self-check process more resilient 2023-08-09 23:56:41 +07:00
Cuong Manh Le
e1d078a2c3 Ignoring RFC 1918 addresses for ControlD upstream 2023-08-09 23:56:31 +07:00
Cuong Manh Le
0dee7518c4 cmd/ctrld: validate UID during start command 2023-08-09 23:56:20 +07:00
Cuong Manh Le
774f07dd7f internal/router: only do cleanup in cd mode on freebsd 2023-08-09 23:56:07 +07:00
Cuong Manh Le
c271896551 all: add support for provision token 2023-08-09 23:55:56 +07:00
Cuong Manh Le
82d887f52d cmd/ctrld: preserve OS error when updating listener config 2023-08-09 23:55:45 +07:00
Cuong Manh Le
6e27f877ff internal/clientinfo: use ptr cache when listing clients 2023-08-09 23:55:29 +07:00
Cuong Manh Le
39a2cab051 internal/clientinfo: only do self discover with client id
While at it, also ensure that client info table was initialized before
doing any lookup.
2023-08-09 23:55:13 +07:00
Cuong Manh Le
72d2f4e7e3 internal/controld: add support for parsing client id from raw UID 2023-08-09 23:54:44 +07:00
Cuong Manh Le
19bc44a7f3 all: prevent data race when accessing zerolog.Logger 2023-08-09 23:54:23 +07:00
Cuong Manh Le
59dc74ffbb internal: record correct interfaces for queries from router on Firewalla 2023-08-09 23:54:23 +07:00
Cuong Manh Le
12c8ab696f cmd/ctrld: use RFC1918 addresses as nameservers if required 2023-08-09 23:54:23 +07:00
Cuong Manh Le
28f32bd7e5 cmd/ctrld: use controlServer register method 2023-08-09 23:54:23 +07:00
Cuong Manh Le
6b43639be5 cmd/ctrld: wait until ctrld listener ready to do self-check 2023-08-09 23:54:23 +07:00
Cuong Manh Le
6be80e4827 internal/router: generalize freebsd-like router support 2023-08-09 23:54:23 +07:00
Cuong Manh Le
437fb1b16d all: add clients list command to debug Mac discovery 2023-08-09 23:54:23 +07:00
Cuong Manh Le
61b6431b6e cmd/ctrld: trim os version on freebsd 2023-08-09 23:54:23 +07:00
Cuong Manh Le
7ccecdd9f7 cmd/ctrld: add more debugging information when self-check failed 2023-08-09 23:54:23 +07:00
Cuong Manh Le
e43b2b5530 internal/clientinfo: add doc comments for mdns operations
While at it, also remove un-used channel argument of probe function.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
2cd8b7e021 internal/clientinfo: remove dhcp from refresher list
dhcp lease files are watched separately using fsnotify, it does not need
to be in refresher list.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
d6768c4c39 internal/clientinfo: use default route IP as self client info 2023-08-09 23:54:23 +07:00
Cuong Manh Le
59a895bfe2 internal/clientinfo: improving mdns discovery
- Prevent duplicated log message.
 - Distinguish in case of create/update hostname.
 - Stop probing if network is unreachable or invalid.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
cacd957594 internal/clientinfo: do not lower case hostname 2023-08-09 23:54:23 +07:00
Cuong Manh Le
2cd063ebd6 cmd/ctrld: do client info table init in separated goroutine
So it won't cause the listener take more times to be ready.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
9ed8e49a08 all: make router setup/cleanup works more generally 2023-08-09 23:54:23 +07:00
Cuong Manh Le
66cb7cc21d cmd/ctrld: general UX improvement 2023-08-09 23:54:23 +07:00
Cuong Manh Le
4bf09120ff cmd/ctrld: spawn RFC1918 listeners if listen on 127.0.0.1:53 2023-08-09 23:54:23 +07:00
Cuong Manh Le
be0769e433 cmd/ctrld: do not create config dir if not necessary 2023-08-09 23:54:23 +07:00
Cuong Manh Le
7b476e38be cmd/ctrld: do not spawn extra listener if conflicted in cd mode 2023-08-09 23:54:23 +07:00
Cuong Manh Le
0a7d3445f4 all: use 127.0.0.1 as nameserver when ctrld is an upstream 2023-08-09 23:54:23 +07:00
Cuong Manh Le
76d2e2c226 Improving Mac discovery 2023-08-09 23:54:23 +07:00
Cuong Manh Le
3007cb86ec cmd/ctrld: add control server/client via unix socket 2023-08-09 23:54:23 +07:00
Cuong Manh Le
fa3af372ab Use ControlD anycast IP if no system DNS found 2023-08-09 23:54:23 +07:00
Cuong Manh Le
48a780fc3e cmd/ctrld: add workaround for default iface name on Ubios 2023-08-09 23:54:23 +07:00
Cuong Manh Le
28df551195 cmd/ctrld: prefix log with listener number when update listener config 2023-08-09 23:54:23 +07:00
Cuong Manh Le
e65a71b2ae cmd/ctrld: do not try random local ip if IP is v4/v6 zero 2023-08-09 23:54:23 +07:00
Cuong Manh Le
dc61fd2554 all: update handling of local config
For local config, we don't want to alter what user explicitly set, and
only try filling in missing value.

While at it, also remove the dnsmasq port delete on openwrt, we don't
need that hack anymore.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
a4edf266f0 all: workaround problem with EdgeOS dnsmasq config 2023-08-09 23:54:23 +07:00
Cuong Manh Le
7af59ee589 all: rework fetching/generating config in cd mode
Config fetching/generating in cd mode is currently weird, error prone,
and easy for user to break ctrld when using custom config.

This commit reworks the flow:

 - Fetching config from Control D API.
 - No custom config, use the current default config.
 - If custom config presents, but there's no listener, use 0.0.0.0:53.
 - Try listening on current ip+port config, if ok, ctrld could be a
   direct listener with current setup, moving on.
 - If failed, trying 127.0.0.1:53.
 - If failed, trying current ip + port 5354
 - If still failed, pick a random ip:port pair, retry until listening ok.

With this flow, thing is more predictable/stable, and help removing the
Config interface for router.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
3f3c1d6d78 Fix Ping upstream cause ctrld crash
dohTransport returns a http.RoundTripper. When pinging upstream, we do
it both for doh and doh3, and checking whether the transport is nil
before performing the check.

However, dohTransport returns a concrete *http.Transport. Thus
dohTransport will always return a non-nil http.Roundtripper, causing
invalid memory dereference when upstream is configured to use doh3.

Performing ping upstream separately will fix the issue.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
ab1d7fd796 cmd/ctrld: lower status string before checking
Depending on system, the output of `/etc/init.d/ctrld status` can be
either "Running" or "running", we must do in-sensitive comparison to get
the right status of ctrld.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
6c2996a921 cmd/ctrld: use sysv service wrapper for "unix-systemv" platform 2023-08-09 23:54:23 +07:00
Cuong Manh Le
de32dd8ba4 cmd/ctrld: better error message for parsing/validation error 2023-08-09 23:54:23 +07:00
Cuong Manh Le
d43e50ee2d cmd/ctrld: produce better message when "ctrd start" failed
The current error message is not much helpful, not all users are able to
investigate system log file to find the reason.

Instead, gathering the log output of "ctrld run" command, and if error
happens or self-check failed, print the log to users.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
aec2596262 all: refactor router code to use interface
So the code is more modular, easier to read/maintain.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
78a7c87ecc cmd/ctrld: only overwrite listener if not defined in cd mode 2023-08-09 23:54:23 +07:00
Cuong Manh Le
1d3f8757bc internal/router: fix missing EdgeOS in router ListenPort
The EdgeOS case was removed unintentionally when adding Firewalla.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
c0c69d0739 cmd/ctrld: do not assume iface "auto" in cd mode 2023-08-09 23:54:23 +07:00
Cuong Manh Le
1aa991298a all: cleaning up router before waiting ntp synchronization
On some Merlin routers reported by users, ctrld some how is not stopped
properly. So the router does not have a working DNS at boot time to do
ntp synchronization.

To fix it, just clean up the router before start waiting for ntp ready.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
f3a3227f21 all: dealing with VLAN config on Firewalla
Firewalla ignores 127.0.0.1 in all VLAN config, so making 127.0.0.1 as
dnsmasq upstream would break thing when multiple VLAN presents.

To deal with this, we need to gather all interfaces available, and
making them as upstream of dnsmasq. Then changing ctrld to listen on all
interfaces, too.

It also leads to better improvement for dnsmasq configuration template,
as the upstream server can now be generated dynamically instead of hard
coding to 127.0.0.1:5354.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
a4c1983657 cmd/ctrld: make setDNS works on system using systemd-networkd
On Ubuntu 18.04 VM with some cloud provider, using dbus call to set DNS
is forbidden. A possible solution is stopping networkd entirely then
using systemd-resolve to set DNS when ctrld starts.

While at it, only set DNS during start command on Windows. On other
platforms, "ctrld run" does set DNS in service mode already.

When using systemd-resolved, only change listener address to default
route interface address if a loopback address is used.

Also fixing a bug in upstream tailscale code for checking in container.
See tailscale/tailscale#8444
2023-08-09 23:54:23 +07:00
Cuong Manh Le
cc28b92935 all: fallback to br0 as nameserver if 127.0.0.1 is used
On Firewalla, lo interface is excluded in all dnsmasq settings of all
interfaces, to prevent conflicts. The one that ctrld adds in
dnsmasq_local directory could not work if there're multiple dnsmasq
configs for multiple interfaces (real example from an user who uses
VLAN in router setup).

Instead, if we detect 127.0.0.1 on Firewalla, fallback to "br0"
interface IP address instead.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
eaa907a647 cmd/ctrld: fix a race in using logf
While at it, also fix the import and not use error.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
de951fd895 Upgrade dependencies for security/bug fixes
- tailscale.com to its latest v1.44.0
 - github.com/spf13/viper to its latest v1.16.0
2023-08-09 23:54:23 +07:00
Cuong Manh Le
3f211d3cc2 cmd/ctrld: remove firerouter_dns dependency in systemd unit on firewalla
On firewalla, firerouter_dns is a shell script, which forks dnsmasq
processes. At the end of ctrld stopping process, ctrld attempts to
restart firerouter_dns. The systemd v237 on firewalla somehow hangs,
because ctrld depends on firerouter_dns, but attempts to restart it
before ctrld stopping.

However, thing in firewalla is ephemeral, so after reboot, ctrld is
re-installed at the end of boot process. Thus, ctrld don't have to
depend on any services.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
2f46d512c6 Not send client info with non-Control D upstream by default 2023-08-09 23:54:23 +07:00
Cuong Manh Le
12148ec231 cmd/ctrld: fixing incorrect reading base64 config
When reading base64 config, either via command line or via custom config
from Control D API, we do want new config entirely instead of mixing
with old config. So new viper instance should be re-recreated before
reading in new config.

That also helps simplifying self-check process, because the config is
now always set correctly, instead of watching change made by "ctrld run"
command.

However, log file and listener config need a special handling, because
they could be changed/unset from Control D API:

 - Log file can change dynamically each time ctrld runs, so init logging
   process need to take care of re-initializing if log setup changed.

 - For listener setup, users could leave ip and port empty, and ctrld
   will pick a random loopback 127.0.0.x:53. However, on Linux systems
   which use systemd-resolved, the stub listener won't forward queries
   from its address 127.0.0.53 to 127.0.0.x, so ctrld will use the
   default router interface address instead.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
9fe6af684f all: watch lease files if send client info enabled
So users who run ctrld in Linux can still see clients info, even though
it's not an router platform that ctrld supports.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
472bb05e95 Support building docker images multi arches 2023-08-09 23:54:23 +07:00
Cuong Manh Le
50bfed706d all: writing correct routers setup to config file
When running on routers, ctrld leverages default setup, let dnsmasq runs
on port 53, and forward queries to ctrld listener on port 5354. However,
this setup is not serialized to config file, causing confusion to users.

Fixing this by writing the correct routers setup to config file. While
at it, updating documentation to refelct that, and also adding note that
changing default router setup could break things.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
350d8355b1 all: add firewalla support 2023-08-09 23:54:23 +07:00
Cuong Manh Le
03781d4cec internal/router: add UniFi Gateway support
UniFi Gateway (USG) uses its own DNS forwarding rule, which is
configured default in /etc/dnsmasq.conf file. Adding ctrld own config in
/etc/dnsmasq.d won't take effects. Instead, we must make changes
directly to /etc/dnsmasq.conf, configuring ctrld as the only upstream.
2023-08-09 23:54:23 +07:00
Cuong Manh Le
67e4afc06e cmd/ctrld: improving ctrld stability on router
The current state of ctrld is very "high stakes" and easy to mess up,
and is unforgiving when "ctrld start" failed. That would cause the
router is in broken state, unrecoverable.

This commit makes these changes to improve the state:

 - Moving router setup process after ctrld listeners are ready, so
   dnsmasq won't flood requests to ctrld even though the listeners are
   not ready to serve requests.

 - On router, when ctrld stopped, restore router DNS setup. That leaves
   the router in good state on reboot/startup, help removing the custom
   DNS server for NTP synchronization on some routers.

 - If self-check failed, uninstall ctrld to restore router to good
   state, prevent confusion that ctrld process is still running even
   though self-check reports it did not started.
2023-08-09 23:54:21 +07:00
Cuong Manh Le
32482809b7 Rework DoH/DoH3 transport setup/bootstrapping
The current transport setup is using mutex lock for synchronization.
This could work ok in normal device, but on low capacity routers, this
high contention may affect the performance, causing ctrld hangs.

Instead of using mutex lock, using atomic operation for synchronization
yield a better performance:

 - There's no lock, so other requests won't be blocked. And even theses
   requests use old broken transport, it would be fine, because the
   client will retry them later.

 - The setup transport is now done once, on demand when the transport is
   accessed, or when signal rebootsrapping. The first call to
   dohTransport will block others, but the transport is warmup before
   ctrld start serving requests, so client requests won't be affected.

That helps ctrld handling the requests better when running on low
capacity device.

Further more, the transport configuration is also tweaked for better
default performance:

 - MaxIdleConnsPerHost is set to 100 (default is 2), which allows more
   connections to be reused, reduce the load to open/close connections
   on demand. See [1] for a real example.

 - Due to the raising of MaxIdleConnsPerHost, once the transport is
   GC-ed, it must explicitly close its idle connections.

 - TLS client session cache is now enabled.

Last but not least, the upstream ping process is also reworked. DoH
transport is an HTTP transport, so doing a HEAD request is enough to
warmup the transport, instead of doing a full DNS query.

[1]: https://gitlab.com/gitlab-org/gitlab-pages/-/merge_requests/274
2023-08-09 22:49:23 +07:00
Cuong Manh Le
c315d21be9 cmd/ctrld: do not retry failed query
Most the client will retry failed request itself. Doing this on the
server give no benefit, and could cause un-necessary load when the
server is busy.
2023-08-09 22:49:07 +07:00
Cuong Manh Le
48b2031269 internal/net: make ParallelDialer closes un-used conn
So the connection can be reclaimed more quickly, reduce resources usage
of ctrld, improving the performance a bit on low capacity devices.
2023-08-09 22:48:49 +07:00
Cuong Manh Le
41139b3343 all: add configuration to limit max concurrent requests
Currently, there's no upper bound for how many requests that ctrld will
handle at a time. This could be problem on some low capacity routers,
where CPU/RAM is very limited.

This commit adds a configuration to limit how many requests that will be
handled concurrently. The default is 256, which should works well for
most routers (the default concurrent requests of dnsmasq is 150).
2023-08-09 22:48:30 +07:00
Cuong Manh Le
d5e6c7b13f Add Dockerfile for building docker image 2023-08-09 22:48:04 +07:00
Cuong Manh Le
60d6734e1f cmd/ctrld: support older GL-inet devices
The openwrt version in old GL-inet devices do not support checking
status using /etc/init.d/<service_name>, so the sysV wrapping trick
won't work. Instead, we need to parse "ps" command output to check
whether ctrld process is running or not.

While at it, making newService as a wrapper of service.New function,
prevent the caller from calling the latter without following call to
the former, causing mismatch in service operations.
2023-08-09 22:47:40 +07:00
Cuong Manh Le
e684c7d8c4 Follow CNAME chain to find correct target
To prevent abusive response from some malicious DNS server, ctrld
ignores the response if the target does not match question domain.
However, that would break CNAME chain, which is allowed the mismatch
happens.
2023-08-09 22:40:51 +07:00
Yegor S
ce35383341 Merge pull request #57 from Control-D-Inc/issue-44
docs: add default value to configs
2023-06-28 01:58:19 -04:00
230 changed files with 23124 additions and 5229 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
Dockerfile
.git/

View File

@@ -9,7 +9,7 @@ jobs:
fail-fast: false
matrix:
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
go: ["1.20.x"]
go: ["1.23.x"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
@@ -19,8 +19,8 @@ jobs:
with:
go-version: ${{ matrix.go }}
- run: "go test -race ./..."
- uses: dominikh/staticcheck-action@v1.2.0
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2023.1.2"
version: "2024.1.1"
install-go: false
cache-key: ${{ matrix.go }}

9
.gitignore vendored
View File

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

245
README.md
View File

@@ -4,12 +4,16 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/Control-D-Inc/ctrld.svg)](https://pkg.go.dev/github.com/Control-D-Inc/ctrld)
[![Go Report Card](https://goreportcard.com/badge/github.com/Control-D-Inc/ctrld)](https://goreportcard.com/report/github.com/Control-D-Inc/ctrld)
![ctrld splash image](/docs/ctrldsplash.png)
A highly configurable DNS forwarding proxy with support for:
- Multiple listeners for incoming queries
- Multiple upstreams with fallbacks
- Multiple network policy driven DNS query steering
- Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN)
- Policy driven domain based "split horizon" DNS with wildcard support
- Integrations with common router vendors and firmware
- LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing
- Prometheus metrics exporter
## TLDR
Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways.
@@ -31,13 +35,29 @@ All DNS protocols are supported, including:
## OS Support
- Windows (386, amd64, arm)
- Mac (amd64, arm64)
- Windows Server (386, amd64)
- MacOS (amd64, arm64)
- Linux (386, amd64, arm, mips)
- FreeBSD
- Common routers (See Router Mode below)
- FreeBSD (386, amd64, arm)
- Common routers (See below)
### Supported Routers
You can run `ctrld` on any supported router. The list of supported routers and firmware includes:
- Asus Merlin
- DD-WRT
- Firewalla
- FreshTomato
- GL.iNet
- OpenWRT
- pfSense / OPNsense
- Synology
- Ubiquiti (UniFi, EdgeOS)
`ctrld` will attempt to interface with dnsmasq (or Windows Server) whenever possible and set itself as the upstream, while running on port 5354. On FreeBSD based OSes, `ctrld` will terminate dnsmasq and unbound in order to be able to listen on port 53 directly.
# Install
There are several ways to download and install `ctrld.
There are several ways to download and install `ctrld`.
## Quick Install
The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform:
@@ -46,25 +66,36 @@ The simplest way to download and install `ctrld` is to use the following install
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl)"'
```
Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative cmd:
Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell:
```shell
powershell -Command "(Invoke-WebRequest -Uri 'https://api.controld.com/dl' -UseBasicParsing).Content | Set-Content 'ctrld_install.bat'" && ctrld_install.bat
(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1' -UseBasicParsing).Content | Set-Content "$env:TEMPctrld_install.ps1"; Invoke-Expression "& '$env:TEMPctrld_install.ps1'"
```
Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld)
```shell
docker run -d --name=ctrld -p 127.0.0.1:53:53/tcp -p 127.0.0.1:53:53/udp controldns/ctrld:latest
```
## Download Manually
Alternatively, if you know what you're doing you can download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section for the appropriate platform.
## Build
Lastly, you can build `ctrld` from source which requires `go1.19+`:
Lastly, you can build `ctrld` from source which requires `go1.21+`:
```shell
$ go build ./cmd/ctrld
go build ./cmd/ctrld
```
or
```shell
$ go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest
go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest
```
or
```shell
docker build -t controldns/ctrld . -f docker/Dockerfile
```
@@ -85,19 +116,16 @@ Usage:
Available Commands:
run Run the DNS proxy server
service Manage ctrld service
start Quick start service and configure DNS on interface
stop Quick stop service and remove DNS from interface
setup Auto-setup Control D on a router.
Supported platforms:
ₒ ddwrt
ₒ merlin
ₒ openwrt
ₒ ubios
ₒ auto - detect the platform you are running on
restart Restart the ctrld service
reload Reload the ctrld service
status Show status of the ctrld service
uninstall Stop and uninstall the ctrld service
service Manage ctrld service
clients Manage clients
upgrade Upgrading ctrld to latest version
log Manage runtime debug logs
Flags:
-h, --help help for ctrld
@@ -109,108 +137,99 @@ Use "ctrld [command] --help" for more information about a command.
```
## Basic Run Mode
To start the server with default configuration, simply run: `./ctrld run`. This will create a generic `ctrld.toml` file in the **working directory** and start the application in foreground.
1. Start the server
```
$ sudo ./ctrld run
This is the most basic way to run `ctrld`, in foreground mode. Unless you already have a config file, a default one will be generated.
### Command
Windows (Admin Shell)
```shell
ctrld.exe run
```
2. Run a test query using a DNS client, for example, `dig`:
Linux or Macos
```shell
sudo ctrld run
```
You can then run a test query using a DNS client, for example, `dig`:
```
$ dig verify.controld.com @127.0.0.1 +short
api.controld.com.
147.185.34.1
```
If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file and go nuts with it. To enforce a new config, restart the server.
If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file that was generated. To enforce a new config, restart the server.
## Service Mode
To run the application in service mode on any Windows, MacOS or Linux distibution, simply run: `./ctrld start` as system/root user. This will create a generic `ctrld.toml` file in the **user home** directory (on Windows) or `/etc/controld/` (everywhere else), start the system service, and configure the listener on the default network interface. Service will start on OS boot.
This mode will run the application as a background system service on any Windows, MacOS, Linux, FreeBSD distribution or supported router. This will create a generic `ctrld.toml` file in the **C:\ControlD** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and **configure the listener on all physical network interface**. Service will start on OS boot.
In order to stop the service, and restore your DNS to original state, simply run `./ctrld stop`. If you wish to uninstall the service permanently, run `./ctrld service uninstall`.
When Control D upstreams are used on a router type device, `ctrld` will [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them.
For granular control of the service, run the `service` command. Each sub-command has its own help section so you can see what arguments you can supply.
### Command
```
Manage ctrld service
Windows (Admin Shell)
```shell
ctrld.exe start
```
Usage:
ctrld service [command]
Linux or Macos
```
sudo ctrld start
```
Available Commands:
interfaces Manage network interfaces
restart Restart the ctrld service
start Start the ctrld service
status Show status of the ctrld service
stop Stop the ctrld service
uninstall Uninstall the ctrld service
If `ctrld` is not in your system path (you installed it manually), you will need to run the above commands from the directory where you installed `ctrld`.
Flags:
-h, --help help for service
In order to stop the service, and restore your DNS to original state, simply run `ctrld stop`. If you wish to stop and uninstall the service permanently, run `ctrld uninstall`.
Global Flags:
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging
## Unmanaged Service Mode
This mode functions similarly to the "Service Mode" above except it will simply start a system service and the config defined listeners, but **will not make any changes to any network interfaces**. You can then set the `ctrld` listener(s) IP on the desired network interfaces manually.
Use "ctrld service [command] --help" for more information about a command.
```
### Command
## Router Mode
You can run `ctrld` on any supported router, which will function similarly to the Service Mode mentioned above. The list of supported routers and firmware includes:
- Asus Merlin
- DD-WRT
- FreshTomato
- GL.iNet
- OpenWRT
- pfSense
- Synology
- Ubiquiti (UniFi, EdgeOS)
Windows (Admin Shell)
```shell
ctrld.exe service start
```
In order to start `ctrld` as a DNS provider, simply run `./ctrld setup auto` command.
In this mode, and when Control D upstreams are used, the router will [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them.
### Control D Auto Configuration
Application can be started with a specific resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `run` (foreground) or `start` (service) or `setup` (router) modes.
The following command will start the application in foreground mode, using the free "p2" resolver, which blocks Ads & Trackers.
```shell
./ctrld run --cd p2
```
Alternatively, you can use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Device.
```shell
./ctrld start --cd abcd1234
```
You can do the same while starting in router mode:
```shell
./ctrld setup auto --cd abcd1234
```
Once you run the above commands (in service or router modes only), the following things will happen:
- You resolver configuration will be fetched from the API, and config file templated with the resolver data
- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `service uninstall` sub-commands
- Your default network interface will be updated to use the listener started by the service
- All OS DNS queries will be sent to the listener
Linux or Macos
```shell
sudo ctrld service start
```
# Configuration
See [Configuration Docs](docs/config.md).
`ctrld` can be configured in variety of different ways, which include: API, local config file or via cli launch args.
## Example
- Start `listener.0` on 127.0.0.1:53
- Accept queries from any source address
- Send all queries to `upstream.0` via DoH protocol
## API Based Auto Configuration
Application can be started with a specific Control D resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `start` (service) mode. In this mode, the application will automatically choose a non-conflicting IP and/or port and configure itself as the upstream to whatever process is running on port 53 (like dnsmasq or Windows DNS Server). This mode is used when the 1 liner installer command from the Control D onboarding guide is executed.
### Default Config
The following command will use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Endpoint.
Windows (Admin Shell)
```shell
ctrld.exe start --cd abcd1234
```
Linux or Macos
```shell
sudo ctrld start --cd abcd1234
```
Once you run the above command, the following things will happen:
- You resolver configuration will be fetched from the API, and config file templated with the resolver data
- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `uninstall` sub-commands
- All physical network interface will be updated to use the listener started by the service or dnsmasq upstream will be switched to `ctrld`
- All DNS queries will be sent to the listener
## Manual Configuration
`ctrld` is entirely config driven and can be configured in many different ways, please see [Configuration Docs](docs/config.md).
### Example
```toml
[listener]
[listener.0]
ip = "127.0.0.1"
ip = '0.0.0.0'
port = 53
restricted = false
[network]
@@ -218,10 +237,6 @@ See [Configuration Docs](docs/config.md).
cidrs = ["0.0.0.0/0"]
name = "Network 0"
[service]
log_level = "info"
log_path = ""
[upstream]
[upstream.0]
@@ -230,26 +245,26 @@ See [Configuration Docs](docs/config.md).
name = "Control D - Anti-Malware"
timeout = 5000
type = "doh"
[upstream.1]
bootstrap_ip = "76.76.2.11"
endpoint = "p2.freedns.controld.com"
name = "Control D - No Ads"
timeout = 3000
type = "doq"
```
## Advanced Configuration
The above is the most basic example, which will work out of the box. If you're looking to do advanced configurations using policies, see [Configuration Docs](docs/config.md) for complete documentation of the config file.
The above basic config will:
- Start listener on 0.0.0.0:53
- Accept queries from any source address
- Send all queries to `https://freedns.controld.com/p1` using DoH protocol
You can also supply configuration via launch argeuments, in [Ephemeral Mode](docs/ephemeral_mode.md).
## CLI Args
If you're unable to use a config file, `ctrld` can be be supplied with basic configuration via launch arguments, in [Ephemeral Mode](docs/ephemeral_mode.md).
### Example
```
ctrld run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=10.0.10.1:53 --domains=*.company.int,very-secure.local --log /path/to/log.log
```
The above will start a foreground process and:
- Listen on `127.0.0.1:53` for DNS queries
- Forward all queries to `https://freedns.controld.com/p2` using DoH protocol, while...
- Excluding `*.company.int` and `very-secure.local` matching queries, that are forwarded to `10.0.10.1:53`
- Write a debug log to `/path/to/log.log`
## Contributing
See [Contribution Guideline](./docs/contributing.md)
## Roadmap
The following functionality is on the roadmap and will be available in future releases.
- Prometheus metrics exporter
- DNS intercept mode
- Support for more routers (let us know which ones)

View File

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

4
client_info_darwin.go Normal file
View File

@@ -0,0 +1,4 @@
package ctrld
// SelfDiscover reports whether ctrld should only do self discover.
func SelfDiscover() bool { return true }

6
client_info_others.go Normal file
View File

@@ -0,0 +1,6 @@
//go:build !windows && !darwin
package ctrld
// SelfDiscover reports whether ctrld should only do self discover.
func SelfDiscover() bool { return false }

18
client_info_windows.go Normal file
View File

@@ -0,0 +1,18 @@
package ctrld
import (
"golang.org/x/sys/windows"
)
// isWindowsWorkStation reports whether ctrld was run on a Windows workstation machine.
func isWindowsWorkStation() bool {
// From https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-osversioninfoexa
const VER_NT_WORKSTATION = 0x0000001
osvi := windows.RtlGetVersion()
return osvi.ProductType == VER_NT_WORKSTATION
}
// SelfDiscover reports whether ctrld should only do self discover.
func SelfDiscover() bool {
return isWindowsWorkStation()
}

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

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

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

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

View File

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

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

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

1926
cmd/cli/cli.go Normal file

File diff suppressed because it is too large Load Diff

46
cmd/cli/cli_test.go Normal file
View File

@@ -0,0 +1,46 @@
package cli
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_writeConfigFile(t *testing.T) {
tmpdir := t.TempDir()
// simulate --config CLI flag by setting configPath manually.
configPath = filepath.Join(tmpdir, "ctrld.toml")
_, err := os.Stat(configPath)
assert.True(t, os.IsNotExist(err))
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)
}
})
}
}

1395
cmd/cli/commands.go Normal file

File diff suppressed because it is too large Load Diff

51
cmd/cli/conn.go Normal file
View File

@@ -0,0 +1,51 @@
package cli
import (
"net"
"time"
)
// logConn wraps a net.Conn, override the Write behavior.
// runCmd uses this wrapper, so as long as startCmd finished,
// ctrld log won't be flushed with un-necessary write errors.
type logConn struct {
conn net.Conn
}
func (lc *logConn) Read(b []byte) (n int, err error) {
return lc.conn.Read(b)
}
func (lc *logConn) Close() error {
return lc.conn.Close()
}
func (lc *logConn) LocalAddr() net.Addr {
return lc.conn.LocalAddr()
}
func (lc *logConn) RemoteAddr() net.Addr {
return lc.conn.RemoteAddr()
}
func (lc *logConn) SetDeadline(t time.Time) error {
return lc.conn.SetDeadline(t)
}
func (lc *logConn) SetReadDeadline(t time.Time) error {
return lc.conn.SetReadDeadline(t)
}
func (lc *logConn) SetWriteDeadline(t time.Time) error {
return lc.conn.SetWriteDeadline(t)
}
func (lc *logConn) Write(b []byte) (int, error) {
// Write performs writes with underlying net.Conn, ignore any errors happen.
// "ctrld run" command use this wrapper to report errors to "ctrld start".
// If no error occurred, "ctrld start" may finish before "ctrld run" attempt
// to close the connection, so ignore errors conservatively here, prevent
// un-necessary error "write to closed connection" flushed to ctrld log.
_, _ = lc.conn.Write(b)
return len(b), nil
}

38
cmd/cli/control_client.go Normal file
View File

@@ -0,0 +1,38 @@
package cli
import (
"context"
"io"
"net"
"net/http"
"time"
)
type controlClient struct {
c *http.Client
}
func newControlClient(addr string) *controlClient {
return &controlClient{c: &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "unix", addr)
},
},
Timeout: time.Second * 30,
}}
}
func (c *controlClient) post(path string, data io.Reader) (*http.Response, error) {
// for log/send, set the timeout to 5 minutes
if path == sendLogsPath {
c.c.Timeout = time.Minute * 5
}
return c.c.Post("http://unix"+path, contentTypeJson, data)
}
// deactivationRequest represents request for validating deactivation pin.
type deactivationRequest struct {
Pin int64 `json:"pin"`
}

344
cmd/cli/control_server.go Normal file
View File

@@ -0,0 +1,344 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"reflect"
"sort"
"time"
"github.com/kardianos/service"
dto "github.com/prometheus/client_model/go"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
const (
contentTypeJson = "application/json"
listClientsPath = "/clients"
startedPath = "/started"
reloadPath = "/reload"
deactivationPath = "/deactivation"
cdPath = "/cd"
ifacePath = "/iface"
viewLogsPath = "/log/view"
sendLogsPath = "/log/send"
)
type ifaceResponse struct {
Name string `json:"name"`
All bool `json:"all"`
OK bool `json:"ok"`
}
type controlServer struct {
server *http.Server
mux *http.ServeMux
addr string
}
func newControlServer(addr string) (*controlServer, error) {
mux := http.NewServeMux()
s := &controlServer{
server: &http.Server{Handler: mux},
mux: mux,
}
s.addr = addr
return s, nil
}
func (s *controlServer) start() error {
_ = os.Remove(s.addr)
unixListener, err := net.Listen("unix", s.addr)
if l, ok := unixListener.(*net.UnixListener); ok {
l.SetUnlinkOnClose(true)
}
if err != nil {
return err
}
go s.server.Serve(unixListener)
return nil
}
func (s *controlServer) stop() error {
_ = os.Remove(s.addr)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
defer cancel()
return s.server.Shutdown(ctx)
}
func (s *controlServer) register(pattern string, handler http.Handler) {
s.mux.Handle(pattern, jsonResponse(handler))
}
func (p *prog) registerControlServerHandler() {
p.cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
mainLog.Load().Debug().Msg("handling list clients request")
clients := p.ciTable.ListClients()
mainLog.Load().Debug().Int("client_count", len(clients)).Msg("retrieved clients list")
sort.Slice(clients, func(i, j int) bool {
return clients[i].IP.Less(clients[j].IP)
})
mainLog.Load().Debug().Msg("sorted clients by IP address")
if p.metricsQueryStats.Load() {
mainLog.Load().Debug().Msg("metrics query stats enabled, collecting query counts")
for idx, client := range clients {
mainLog.Load().Debug().
Int("index", idx).
Str("ip", client.IP.String()).
Str("mac", client.Mac).
Str("hostname", client.Hostname).
Msg("processing client metrics")
client.IncludeQueryCount = true
dm := &dto.Metric{}
if statsClientQueriesCount.MetricVec == nil {
mainLog.Load().Debug().
Str("client_ip", client.IP.String()).
Msg("skipping metrics collection: MetricVec is nil")
continue
}
m, err := statsClientQueriesCount.MetricVec.GetMetricWithLabelValues(
client.IP.String(),
client.Mac,
client.Hostname,
)
if err != nil {
mainLog.Load().Debug().
Err(err).
Str("client_ip", client.IP.String()).
Str("mac", client.Mac).
Str("hostname", client.Hostname).
Msg("failed to get metrics for client")
continue
}
if err := m.Write(dm); err == nil && dm.Counter != nil {
client.QueryCount = int64(dm.Counter.GetValue())
mainLog.Load().Debug().
Str("client_ip", client.IP.String()).
Int64("query_count", client.QueryCount).
Msg("successfully collected query count")
} else if err != nil {
mainLog.Load().Debug().
Err(err).
Str("client_ip", client.IP.String()).
Msg("failed to write metric")
}
}
} else {
mainLog.Load().Debug().Msg("metrics query stats disabled, skipping query counts")
}
if err := json.NewEncoder(w).Encode(&clients); err != nil {
mainLog.Load().Error().
Err(err).
Int("client_count", len(clients)).
Msg("failed to encode clients response")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
mainLog.Load().Debug().
Int("client_count", len(clients)).
Msg("successfully sent clients list response")
}))
p.cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
select {
case <-p.onStartedDone:
w.WriteHeader(http.StatusOK)
case <-time.After(10 * time.Second):
w.WriteHeader(http.StatusRequestTimeout)
}
}))
p.cs.register(reloadPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
listeners := make(map[string]*ctrld.ListenerConfig)
p.mu.Lock()
for k, v := range p.cfg.Listener {
listeners[k] = &ctrld.ListenerConfig{
IP: v.IP,
Port: v.Port,
}
}
oldSvc := p.cfg.Service
p.mu.Unlock()
if err := p.sendReloadSignal(); err != nil {
mainLog.Load().Err(err).Msg("could not send reload signal")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
select {
case <-p.reloadDoneCh:
case <-time.After(5 * time.Second):
http.Error(w, "timeout waiting for ctrld reload", http.StatusInternalServerError)
return
}
p.mu.Lock()
defer p.mu.Unlock()
// Checking for cases that we could not do a reload.
// 1. Listener config ip or port changes.
for k, v := range p.cfg.Listener {
l := listeners[k]
if l == nil || l.IP != v.IP || l.Port != v.Port {
w.WriteHeader(http.StatusCreated)
return
}
}
// 2. Service config changes.
if !reflect.DeepEqual(oldSvc, p.cfg.Service) {
w.WriteHeader(http.StatusCreated)
return
}
// Otherwise, reload is done.
w.WriteHeader(http.StatusOK)
}))
p.cs.register(deactivationPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
// Non-cd mode always allowing deactivation.
if cdUID == "" {
w.WriteHeader(http.StatusOK)
return
}
// Re-fetch pin code from API.
if rc, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev); rc != nil {
if rc.DeactivationPin != nil {
cdDeactivationPin.Store(*rc.DeactivationPin)
} else {
cdDeactivationPin.Store(defaultDeactivationPin)
}
} else {
mainLog.Load().Warn().Err(err).Msg("could not re-fetch deactivation pin code")
}
// If pin code not set, allowing deactivation.
if !deactivationPinSet() {
w.WriteHeader(http.StatusOK)
return
}
var req deactivationRequest
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusPreconditionFailed)
mainLog.Load().Err(err).Msg("invalid deactivation request")
return
}
code := http.StatusForbidden
switch req.Pin {
case cdDeactivationPin.Load():
code = http.StatusOK
select {
case p.pinCodeValidCh <- struct{}{}:
default:
}
case defaultDeactivationPin:
// If the pin code was set, but users do not provide --pin, return proper code to client.
code = http.StatusBadRequest
}
w.WriteHeader(code)
}))
p.cs.register(cdPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if cdUID != "" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(cdUID))
return
}
w.WriteHeader(http.StatusBadRequest)
}))
p.cs.register(ifacePath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
res := &ifaceResponse{Name: iface}
// p.setDNS is only called when running as a service
if !service.Interactive() {
<-p.csSetDnsDone
if p.csSetDnsOk {
res.Name = p.runningIface
res.All = p.requiredMultiNICsConfig
res.OK = true
}
}
if err := json.NewEncoder(w).Encode(res); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("could not marshal iface data: %v", err), http.StatusInternalServerError)
return
}
}))
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
lr, err := p.logReader()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer lr.r.Close()
if lr.size == 0 {
w.WriteHeader(http.StatusMovedPermanently)
return
}
data, err := io.ReadAll(lr.r)
if err != nil {
http.Error(w, fmt.Sprintf("could not read log: %v", err), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(&logViewResponse{Data: string(data)}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("could not marshal log data: %v", err), http.StatusInternalServerError)
return
}
}))
p.cs.register(sendLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if time.Since(p.internalLogSent) < logWriterSentInterval {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
r, err := p.logReader()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if r.size == 0 {
w.WriteHeader(http.StatusMovedPermanently)
return
}
req := &controld.LogsRequest{
UID: cdUID,
Data: r.r,
}
mainLog.Load().Debug().Msg("sending log file to ControlD server")
resp := logSentResponse{Size: r.size}
if err := controld.SendLogs(req, cdDev); err != nil {
mainLog.Load().Error().Msgf("could not send log file to ControlD server: %v", err)
resp.Error = err.Error()
w.WriteHeader(http.StatusInternalServerError)
} else {
mainLog.Load().Debug().Msg("sending log file successfully")
w.WriteHeader(http.StatusOK)
}
if err := json.NewEncoder(w).Encode(&resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
p.internalLogSent = time.Now()
}))
}
func jsonResponse(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,54 @@
package cli
import (
"bytes"
"io"
"net/http"
"os"
"testing"
)
func TestControlServer(t *testing.T) {
f, err := os.CreateTemp("", "")
if err != nil {
t.Fatal(err)
}
defer os.Remove(f.Name())
f.Close()
s, err := newControlServer(f.Name())
if err != nil {
t.Fatal(err)
}
pattern := "/ping"
respBody := []byte("pong")
s.register(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(respBody)
}))
if err := s.start(); err != nil {
t.Fatal(err)
}
c := newControlClient(f.Name())
resp, err := c.post(pattern, nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("unepxected response code: %d", resp.StatusCode)
}
if ct := resp.Header.Get("content-type"); ct != contentTypeJson {
t.Fatalf("unexpected content type: %s", ct)
}
buf, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf, respBody) {
t.Errorf("unexpected response body, want: %q, got: %q", string(respBody), string(buf))
}
if err := s.stop(); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,4 +1,4 @@
package main
package cli
//lint:ignore U1000 use in os_linux.go
type getDNS func(iface string) []string

1635
cmd/cli/dns_proxy.go Normal file

File diff suppressed because it is too large Load Diff

466
cmd/cli/dns_proxy_test.go Normal file
View File

@@ -0,0 +1,466 @@
package cli
import (
"context"
"net"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
"github.com/Control-D-Inc/ctrld/testhelper"
)
func Test_wildcardMatches(t *testing.T) {
tests := []struct {
name string
wildcard string
domain string
match bool
}{
{"domain - prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
{"domain - prefix", "*.windscribe.com", "anything.windscribe.com", true},
{"domain - prefix not match other s", "*.windscribe.com", "example.com", false},
{"domain - prefix not match s in name", "*.windscribe.com", "wwindscribe.com", false},
{"domain - suffix", "suffix.*", "suffix.windscribe.com", true},
{"domain - suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
{"domain - both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
{"domain - both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
{"domain - case-insensitive", "*.WINDSCRIBE.com", "anything.windscribe.com", true},
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
{"mac - suffix", "d4:67:98:*", "d4:67:98:05:b4:2b", true},
{"mac - suffix not match other", "d4:67:98:*", "d4:67:97:15:b4:2b", false},
{"mac - both", "d4:67:98:*:b4:2b", "d4:67:98:05:b4:2b", true},
{"mac - both not match", "d4:67:98:*:b4:2b", "d4:67:97:05:c4:2b", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := wildcardMatches(tc.wildcard, tc.domain); got != tc.match {
t.Errorf("unexpected result, wildcard: %s, domain: %s, want: %v, got: %v", tc.wildcard, tc.domain, tc.match, got)
}
})
}
}
func Test_canonicalName(t *testing.T) {
tests := []struct {
name string
domain string
canonical string
}{
{"fqdn to canonical", "windscribe.com.", "windscribe.com"},
{"already canonical", "windscribe.com", "windscribe.com"},
{"case insensitive", "Windscribe.Com.", "windscribe.com"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := canonicalName(tc.domain); got != tc.canonical {
t.Errorf("unexpected result, want: %s, got: %s", tc.canonical, got)
}
})
}
}
func Test_prog_upstreamFor(t *testing.T) {
cfg := testhelper.SampleConfig(t)
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
p := &prog{cfg: cfg}
p.um = newUpstreamMonitor(p.cfg)
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
for _, nc := range p.cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatal(err)
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
tests := []struct {
name string
ip string
mac string
defaultUpstreamNum string
lc *ctrld.ListenerConfig
domain string
upstreams []string
matched bool
testLogMsg string
}{
{"Policy map matches", "192.168.0.1:0", "", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.1", "upstream.0"}, true, ""},
{"Policy split matches", "192.168.0.1:0", "", "0", p.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, ""},
{"Policy map for other network matches", "192.168.1.2:0", "", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.0"}, true, ""},
{"No policy map for listener", "192.168.1.2:0", "", "1", p.cfg.Listener["1"], "abc.ru", []string{"upstream.1"}, false, ""},
{"unenforced loging", "192.168.1.2:0", "", "0", p.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, "My Policy, network.1 (unenforced), *.ru -> [upstream.1]"},
{"Policy Macs matches upper", "192.168.0.1:0", "14:45:A0:67:83:0A", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:45:a0:67:83:0a"},
{"Policy Macs matches lower", "192.168.0.1:0", "14:54:4a:8e:08:2d", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:54:4a:8e:08:2d"},
{"Policy Macs matches case-insensitive", "192.168.0.1:0", "14:54:4A:8E:08:2D", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:54:4a:8e:08:2d"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for _, network := range []string{"udp", "tcp"} {
var (
addr net.Addr
err error
)
switch network {
case "udp":
addr, err = net.ResolveUDPAddr(network, tc.ip)
case "tcp":
addr, err = net.ResolveTCPAddr(network, tc.ip)
}
require.NoError(t, err)
require.NotNil(t, addr)
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
ufr := p.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.mac, tc.domain)
p.proxy(ctx, &proxyRequest{
msg: newDnsMsgWithHostname("foo", dns.TypeA),
ufr: ufr,
})
assert.Equal(t, tc.matched, ufr.matched)
assert.Equal(t, tc.upstreams, ufr.upstreams)
if tc.testLogMsg != "" {
assert.Contains(t, logOutput.String(), tc.testLogMsg)
}
}
})
}
}
func TestCache(t *testing.T) {
cfg := testhelper.SampleConfig(t)
prog := &prog{cfg: cfg}
for _, nc := range prog.cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatal(err)
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
cacher, err := dnscache.NewLRUCache(4096)
require.NoError(t, err)
prog.cache = cacher
msg := new(dns.Msg)
msg.SetQuestion("example.com", dns.TypeA)
msg.MsgHdr.RecursionDesired = true
answer1 := new(dns.Msg)
answer1.SetRcode(msg, dns.RcodeSuccess)
prog.cache.Add(dnscache.NewKey(msg, "upstream.1"), dnscache.NewValue(answer1, time.Now().Add(time.Minute)))
answer2 := new(dns.Msg)
answer2.SetRcode(msg, dns.RcodeRefused)
prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute)))
req1 := &proxyRequest{
msg: msg,
ci: nil,
failoverRcodes: nil,
ufr: &upstreamForResult{
upstreams: []string{"upstream.1"},
matchedPolicy: "",
matchedNetwork: "",
matchedRule: "",
matched: false,
},
}
req2 := &proxyRequest{
msg: msg,
ci: nil,
failoverRcodes: nil,
ufr: &upstreamForResult{
upstreams: []string{"upstream.0"},
matchedPolicy: "",
matchedNetwork: "",
matchedRule: "",
matched: false,
},
}
got1 := prog.proxy(context.Background(), req1)
got2 := prog.proxy(context.Background(), req2)
assert.NotSame(t, got1, got2)
assert.Equal(t, answer1.Rcode, got1.answer.Rcode)
assert.Equal(t, answer2.Rcode, got2.answer.Rcode)
}
func Test_ipAndMacFromMsg(t *testing.T) {
tests := []struct {
name string
ip string
wantIp bool
mac string
wantMac bool
}{
{"has ip v4 and mac", "1.2.3.4", true, "4c:20:b8:ab:87:1b", true},
{"has ip v6 and mac", "2606:1a40:3::1", true, "4c:20:b8:ab:87:1b", true},
{"no ip", "1.2.3.4", false, "4c:20:b8:ab:87:1b", false},
{"no mac", "1.2.3.4", false, "4c:20:b8:ab:87:1b", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatal("missing IP")
}
hw, err := net.ParseMAC(tc.mac)
if err != nil {
t.Fatal(err)
}
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeA)
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
if tc.wantMac {
ec1 := &dns.EDNS0_LOCAL{Code: EDNS0_OPTION_MAC, Data: hw}
o.Option = append(o.Option, ec1)
}
if tc.wantIp {
ec2 := &dns.EDNS0_SUBNET{Address: ip}
o.Option = append(o.Option, ec2)
}
m.Extra = append(m.Extra, o)
gotIP, gotMac := ipAndMacFromMsg(m)
if tc.wantMac && gotMac != tc.mac {
t.Errorf("mismatch, want: %q, got: %q", tc.mac, gotMac)
}
if !tc.wantMac && gotMac != "" {
t.Errorf("unexpected mac: %q", gotMac)
}
if tc.wantIp && gotIP != tc.ip {
t.Errorf("mismatch, want: %q, got: %q", tc.ip, gotIP)
}
if !tc.wantIp && gotIP != "" {
t.Errorf("unexpected ip: %q", gotIP)
}
})
}
}
func Test_remoteAddrFromMsg(t *testing.T) {
loopbackIP := net.ParseIP("127.0.0.1")
tests := []struct {
name string
addr net.Addr
ci *ctrld.ClientInfo
want string
}{
{"tcp", &net.TCPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.10"}, "192.168.1.10:12345"},
{"udp", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.11"}, "192.168.1.11:12345"},
{"nil client info", &net.UDPAddr{IP: loopbackIP, Port: 12345}, nil, "127.0.0.1:12345"},
{"empty ip", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{}, "127.0.0.1:12345"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
addr := spoofRemoteAddr(tc.addr, tc.ci)
if addr.String() != tc.want {
t.Errorf("unexpected result, want: %q, got: %q", tc.want, addr.String())
}
})
}
}
func Test_ipFromARPA(t *testing.T) {
tests := []struct {
IP string
ARPA string
}{
{"1.2.3.4", "4.3.2.1.in-addr.arpa."},
{"245.110.36.114", "114.36.110.245.in-addr.arpa."},
{"::ffff:12.34.56.78", "78.56.34.12.in-addr.arpa."},
{"::1", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa."},
{"1::", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.ip6.arpa."},
{"1234:567::89a:bcde", "e.d.c.b.a.9.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.7.6.5.0.4.3.2.1.ip6.arpa."},
{"1234:567:fefe:bcbc:adad:9e4a:89a:bcde", "e.d.c.b.a.9.8.0.a.4.e.9.d.a.d.a.c.b.c.b.e.f.e.f.7.6.5.0.4.3.2.1.ip6.arpa."},
{"", "asd.in-addr.arpa."},
{"", "asd.ip6.arpa."},
}
for _, tc := range tests {
tc := tc
t.Run(tc.IP, func(t *testing.T) {
t.Parallel()
if got := ipFromARPA(tc.ARPA); !got.Equal(net.ParseIP(tc.IP)) {
t.Errorf("unexpected ip, want: %s, got: %s", tc.IP, got)
}
})
}
}
func newDnsMsgWithClientIP(ip string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeA)
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
o.Option = append(o.Option, &dns.EDNS0_SUBNET{Address: net.ParseIP(ip)})
m.Extra = append(m.Extra, o)
return m
}
func Test_stripClientSubnet(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
wantSubnet bool
}{
{"no edns0", new(dns.Msg), false},
{"loopback IP v4", newDnsMsgWithClientIP("127.0.0.1"), false},
{"loopback IP v6", newDnsMsgWithClientIP("::1"), false},
{"private IP v4", newDnsMsgWithClientIP("192.168.1.123"), false},
{"private IP v6", newDnsMsgWithClientIP("fd12:3456:789a:1::1"), false},
{"public IP", newDnsMsgWithClientIP("1.1.1.1"), true},
{"invalid IP", newDnsMsgWithClientIP(""), true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stripClientSubnet(tc.msg)
hasSubnet := false
if opt := tc.msg.IsEdns0(); opt != nil {
for _, s := range opt.Option {
if _, ok := s.(*dns.EDNS0_SUBNET); ok {
hasSubnet = true
}
}
}
if tc.wantSubnet != hasSubnet {
t.Errorf("unexpected result, want: %v, got: %v", tc.wantSubnet, hasSubnet)
}
})
}
}
func newDnsMsgWithHostname(hostname string, typ uint16) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(hostname, typ)
return m
}
func Test_isLanHostnameQuery(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isLanHostnameQuery bool
}{
{"A", newDnsMsgWithHostname("foo", dns.TypeA), true},
{"AAAA", newDnsMsgWithHostname("foo", dns.TypeAAAA), true},
{"A not LAN", newDnsMsgWithHostname("example.com", dns.TypeA), false},
{"AAAA not LAN", newDnsMsgWithHostname("example.com", dns.TypeAAAA), false},
{"Not A or AAAA", newDnsMsgWithHostname("foo", dns.TypeTXT), false},
{".domain", newDnsMsgWithHostname("foo.domain", dns.TypeA), true},
{".lan", newDnsMsgWithHostname("foo.lan", dns.TypeA), true},
{".local", newDnsMsgWithHostname("foo.local", dns.TypeA), true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isLanHostnameQuery(tc.msg); tc.isLanHostnameQuery != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isLanHostnameQuery, got)
}
})
}
}
func newDnsMsgPtr(ip string, t *testing.T) *dns.Msg {
t.Helper()
m := new(dns.Msg)
ptr, err := dns.ReverseAddr(ip)
if err != nil {
t.Fatal(err)
}
m.SetQuestion(ptr, dns.TypePTR)
return m
}
func Test_isPrivatePtrLookup(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isPrivatePtrLookup bool
}{
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
{"10.0.0.0/8", newDnsMsgPtr("10.0.0.123", t), true},
{"172.16.0.0/12", newDnsMsgPtr("172.16.0.123", t), true},
{"192.168.0.0/16", newDnsMsgPtr("192.168.1.123", t), true},
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", t), true},
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isPrivatePtrLookup(tc.msg); tc.isPrivatePtrLookup != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isPrivatePtrLookup, got)
}
})
}
}
func Test_isSrvLanLookup(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isSrvLookup bool
}{
{"SRV LAN", newDnsMsgWithHostname("foo", dns.TypeSRV), true},
{"Not SRV", newDnsMsgWithHostname("foo", dns.TypeNone), false},
{"Not SRV LAN", newDnsMsgWithHostname("controld.com", dns.TypeSRV), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isSrvLanLookup(tc.msg); tc.isSrvLookup != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isSrvLookup, got)
}
})
}
}
func Test_isWanClient(t *testing.T) {
tests := []struct {
name string
addr net.Addr
isWanClient bool
}{
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
{"10.0.0.0/8", &net.UDPAddr{IP: net.ParseIP("10.0.0.123")}, false},
{"172.16.0.0/12", &net.UDPAddr{IP: net.ParseIP("172.16.0.123")}, false},
{"192.168.0.0/16", &net.UDPAddr{IP: net.ParseIP("192.168.1.123")}, false},
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, false},
{"Link Local Unicast", &net.UDPAddr{IP: net.ParseIP("fe80::69f6:e16e:8bdb:433f")}, false},
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isWanClient(tc.addr); tc.isWanClient != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isWanClient, got)
}
})
}
}

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

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

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

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

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

@@ -0,0 +1,93 @@
package cli
import (
"fmt"
"net"
"net/http"
"time"
)
// AppCallback provides hooks for injecting certain functionalities
// from mobile platforms to main ctrld cli.
type AppCallback struct {
HostName func() string
LanIp func() string
MacAddress func() string
Exit func(error string)
}
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
type AppConfig struct {
CdUID string
HomeDir string
UpstreamProto string
Verbose int
LogPath string
}
const (
defaultHTTPTimeout = 30 * time.Second
defaultMaxRetries = 3
downloadServerIp = "23.171.240.151"
)
// httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback
func httpClientWithFallback(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
// Prefer IPv4 over IPv6
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
FallbackDelay: 1 * time.Millisecond, // Very small delay to prefer IPv4
}).DialContext,
},
}
}
// doWithRetry performs an HTTP request with retries
func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response, error) {
var lastErr error
client := httpClientWithFallback(defaultHTTPTimeout)
var ipReq *http.Request
if ip != "" {
ipReq = req.Clone(req.Context())
ipReq.Host = ip
ipReq.URL.Host = ip
}
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(time.Second * time.Duration(attempt+1)) // Exponential backoff
}
resp, err := client.Do(req)
if err == nil {
return resp, nil
}
if ipReq != nil {
mainLog.Load().Warn().Err(err).Msgf("dial to %q failed", req.Host)
mainLog.Load().Warn().Msgf("fallback to direct IP to download prod version: %q", ip)
resp, err = client.Do(ipReq)
if err == nil {
return resp, nil
}
}
lastErr = err
mainLog.Load().Debug().Err(err).
Str("method", req.Method).
Str("url", req.URL.String()).
Msgf("HTTP request attempt %d/%d failed", attempt+1, maxRetries)
}
return nil, fmt.Errorf("failed after %d attempts to %s %s: %v", maxRetries, req.Method, req.URL, lastErr)
}
// Helper for making GET requests with retries
func getWithRetry(url string, ip string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return doWithRetry(req, defaultMaxRetries, ip)
}

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

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

View File

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

145
cmd/cli/loop.go Normal file
View File

@@ -0,0 +1,145 @@
package cli
import (
"context"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"github.com/Control-D-Inc/ctrld"
)
const (
loopTestDomain = ".test"
loopTestQtype = dns.TypeTXT
)
// newLoopGuard returns new loopGuard.
func newLoopGuard() *loopGuard {
return &loopGuard{inflight: make(map[string]struct{})}
}
// loopGuard guards against DNS loop, ensuring only one query
// for a given domain is processed at a time.
type loopGuard struct {
mu sync.Mutex
inflight map[string]struct{}
}
// TryLock marks the domain as being processed.
func (lg *loopGuard) TryLock(domain string) bool {
lg.mu.Lock()
defer lg.mu.Unlock()
if _, inflight := lg.inflight[domain]; !inflight {
lg.inflight[domain] = struct{}{}
return true
}
return false
}
// Unlock marks the domain as being done.
func (lg *loopGuard) Unlock(domain string) {
lg.mu.Lock()
defer lg.mu.Unlock()
delete(lg.inflight, domain)
}
// isLoop reports whether the given upstream config is detected as having DNS loop.
func (p *prog) isLoop(uc *ctrld.UpstreamConfig) bool {
p.loopMu.Lock()
defer p.loopMu.Unlock()
return p.loop[uc.UID()]
}
// detectLoop checks if the given DNS message is initialized sent by ctrld.
// If yes, marking the corresponding upstream as loop, prevent infinite DNS
// forwarding loop.
//
// See p.checkDnsLoop for more details how it works.
func (p *prog) detectLoop(msg *dns.Msg) {
if len(msg.Question) != 1 {
return
}
q := msg.Question[0]
if q.Qtype != loopTestQtype {
return
}
unFQDNname := strings.TrimSuffix(q.Name, ".")
uid := strings.TrimSuffix(unFQDNname, loopTestDomain)
p.loopMu.Lock()
if _, loop := p.loop[uid]; loop {
p.loop[uid] = loop
}
p.loopMu.Unlock()
}
// checkDnsLoop sends a message to check if there's any DNS forwarding loop
// with all the upstreams. The way it works based on dnsmasq --dns-loop-detect.
//
// - Generating a TXT test query and sending it to all upstream.
// - The test query is formed by upstream UID and test domain: <uid>.test
// - If the test query returns to ctrld, mark the corresponding upstream as loop (see p.detectLoop).
//
// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
func (p *prog) checkDnsLoop() {
mainLog.Load().Debug().Msg("start checking DNS loop")
upstream := make(map[string]*ctrld.UpstreamConfig)
p.loopMu.Lock()
for n, uc := range p.cfg.Upstream {
if p.um.isDown("upstream." + n) {
continue
}
// Do not send test query to external upstream.
if !canBeLocalUpstream(uc.Domain) {
mainLog.Load().Debug().Msgf("skipping external: upstream.%s", n)
continue
}
uid := uc.UID()
p.loop[uid] = false
upstream[uid] = uc
}
p.loopMu.Unlock()
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)
continue
}
if _, err := resolver.Resolve(context.Background(), msg); err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not send DNS loop check query for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
}
}
mainLog.Load().Debug().Msg("end checking DNS loop")
}
// checkDnsLoopTicker performs p.checkDnsLoop every minute.
func (p *prog) checkDnsLoopTicker(ctx context.Context) {
timer := time.NewTicker(time.Minute)
defer timer.Stop()
for {
select {
case <-p.stopCh:
return
case <-ctx.Done():
return
case <-timer.C:
p.checkDnsLoop()
}
}
}
// loopTestMsg generates DNS message for checking loop.
func loopTestMsg(uid string) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(uid+loopTestDomain), loopTestQtype)
return msg
}

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

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

190
cmd/cli/main.go Normal file
View File

@@ -0,0 +1,190 @@
package cli
import (
"io"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/kardianos/service"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
var (
configPath string
configBase64 string
daemon bool
listenAddress string
primaryUpstream string
secondaryUpstream string
domains []string
logPath string
homedir string
cacheSize int
cfg ctrld.Config
verbose int
silent bool
cdUID string
cdOrg string
customHostname string
cdDev bool
iface string
ifaceStartStop string
nextdns string
cdUpstreamProto string
deactivationPin int64
skipSelfChecks bool
cleanup bool
startOnly bool
mainLog atomic.Pointer[zerolog.Logger]
consoleWriter zerolog.ConsoleWriter
noConfigStart bool
)
const (
cdUidFlagName = "cd"
cdOrgFlagName = "cd-org"
customHostnameFlagName = "custom-hostname"
nextdnsFlagName = "nextdns"
)
func init() {
l := zerolog.New(io.Discard)
mainLog.Store(&l)
}
func Main() {
ctrld.InitConfig(v, "ctrld")
initCLI()
if err := rootCmd.Execute(); err != nil {
mainLog.Load().Error().Msg(err.Error())
os.Exit(1)
}
}
func normalizeLogFilePath(logFilePath string) string {
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
return logFilePath
}
if homedir != "" {
return filepath.Join(homedir, logFilePath)
}
dir, _ := userHomeDir()
if dir == "" {
return logFilePath
}
return filepath.Join(dir, logFilePath)
}
// initConsoleLogging initializes console logging, then storing to mainLog.
func initConsoleLogging() {
consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
w.TimeFormat = time.StampMilli
})
multi := zerolog.MultiLevelWriter(consoleWriter)
l := mainLog.Load().Output(multi).With().Timestamp().Logger()
mainLog.Store(&l)
switch {
case silent:
zerolog.SetGlobalLevel(zerolog.NoLevel)
case verbose == 1:
ctrld.ProxyLogger.Store(&l)
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case verbose > 1:
ctrld.ProxyLogger.Store(&l)
zerolog.SetGlobalLevel(zerolog.DebugLevel)
default:
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
}
}
// initInteractiveLogging is like initLogging, but the ProxyLogger is discarded
// to be used for all interactive commands.
//
// Current log file config will also be ignored.
func initInteractiveLogging() {
old := cfg.Service.LogPath
cfg.Service.LogPath = ""
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
initLoggingWithBackup(false)
cfg.Service.LogPath = old
l := zerolog.New(io.Discard)
ctrld.ProxyLogger.Store(&l)
}
// initLoggingWithBackup initializes log setup base on current config.
// If doBackup is true, backup old log file with ".1" suffix.
//
// This is only used in runCmd for special handling in case of logging config
// change in cd mode. Without special reason, the caller should use initLogging
// wrapper instead of calling this function directly.
func initLoggingWithBackup(doBackup bool) []io.Writer {
var writers []io.Writer
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
// Create parent directory if necessary.
if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil {
mainLog.Load().Error().Msgf("failed to create log path: %v", err)
os.Exit(1)
}
// Default open log file in append mode.
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+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 := openLogFile(logFilePath, flags)
if err != nil {
mainLog.Load().Error().Msgf("failed to create log file: %v", err)
os.Exit(1)
}
writers = append(writers, logFile)
}
writers = append(writers, consoleWriter)
multi := zerolog.MultiLevelWriter(writers...)
l := mainLog.Load().Output(multi).With().Logger()
mainLog.Store(&l)
// TODO: find a better way.
ctrld.ProxyLogger.Store(&l)
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
logLevel := cfg.Service.LogLevel
switch {
case silent:
zerolog.SetGlobalLevel(zerolog.NoLevel)
return writers
case verbose == 1:
logLevel = "info"
case verbose > 1:
logLevel = "debug"
}
if logLevel == "" {
return writers
}
level, err := zerolog.ParseLevel(logLevel)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not set log level")
return writers
}
zerolog.SetGlobalLevel(level)
return writers
}
func initCache() {
if !cfg.Service.CacheEnable {
return
}
if cfg.Service.CacheSize == 0 {
cfg.Service.CacheSize = 4096
}
}

View File

@@ -1,4 +1,4 @@
package main
package cli
import (
"os"
@@ -11,6 +11,7 @@ import (
var logOutput strings.Builder
func TestMain(m *testing.M) {
mainLog = zerolog.New(&logOutput)
l := zerolog.New(&logOutput)
mainLog.Store(&l)
os.Exit(m.Run())
}

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

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

76
cmd/cli/net_darwin.go Normal file
View File

@@ -0,0 +1,76 @@
package cli
import (
"bufio"
"bytes"
"io"
"net"
"os/exec"
"strings"
)
func patchNetIfaceName(iface *net.Interface) (bool, error) {
b, err := exec.Command("networksetup", "-listnetworkserviceorder").Output()
if err != nil {
return false, err
}
patched := false
if name := networkServiceName(iface.Name, bytes.NewReader(b)); name != "" {
patched = true
iface.Name = name
}
return patched, nil
}
func networkServiceName(ifaceName string, r io.Reader) string {
scanner := bufio.NewScanner(r)
prevLine := ""
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "*") {
// Network services is disabled.
continue
}
if !strings.Contains(line, "Device: "+ifaceName) {
prevLine = line
continue
}
parts := strings.SplitN(prevLine, " ", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
return ""
}
// validInterface reports whether the *net.Interface is a valid one.
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
_, ok := validIfacesMap[iface.Name]
return ok
}
// validInterfacesMap returns a set of all valid hardware ports.
func validInterfacesMap() map[string]struct{} {
b, err := exec.Command("networksetup", "-listallhardwareports").Output()
if err != nil {
return nil
}
return parseListAllHardwarePorts(bytes.NewReader(b))
}
// parseListAllHardwarePorts parses output of "networksetup -listallhardwareports"
// and returns map presents all hardware ports.
func parseListAllHardwarePorts(r io.Reader) map[string]struct{} {
m := make(map[string]struct{})
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
after, ok := strings.CutPrefix(line, "Device: ")
if !ok {
continue
}
m[after] = struct{}{}
}
return m
}

View File

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

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

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

22
cmd/cli/net_others.go Normal file
View File

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

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

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

View File

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

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

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

View File

@@ -0,0 +1,7 @@
//go:build !linux
package cli
import "context"
func (p *prog) watchLinkState(ctx context.Context) {}

View File

@@ -1,8 +1,9 @@
package main
package cli
import (
"context"
"os"
"os/exec"
"path/filepath"
"time"
@@ -16,45 +17,56 @@ const (
dns=none
systemd-resolved=false
`
nmSystemdUnitName = "NetworkManager.service"
systemdEnabledState = "enabled"
nmSystemdUnitName = "NetworkManager.service"
)
var networkManagerCtrldConfFile = filepath.Join(nmConfDir, nmCtrldConfFilename)
// hasNetworkManager reports whether NetworkManager executable found.
func hasNetworkManager() bool {
exe, _ := exec.LookPath("NetworkManager")
return exe != ""
}
func setupNetworkManager() error {
if !hasNetworkManager() {
return nil
}
if content, _ := os.ReadFile(nmCtrldConfContent); string(content) == nmCtrldConfContent {
mainLog.Debug().Msg("NetworkManager already setup, nothing to do")
mainLog.Load().Debug().Msg("NetworkManager already setup, nothing to do")
return nil
}
err := os.WriteFile(networkManagerCtrldConfFile, []byte(nmCtrldConfContent), os.FileMode(0644))
if os.IsNotExist(err) {
mainLog.Debug().Msg("NetworkManager is not available")
mainLog.Load().Debug().Msg("NetworkManager is not available")
return nil
}
if err != nil {
mainLog.Debug().Err(err).Msg("could not write NetworkManager ctrld config file")
mainLog.Load().Debug().Err(err).Msg("could not write NetworkManager ctrld config file")
return err
}
reloadNetworkManager()
mainLog.Debug().Msg("setup NetworkManager done")
mainLog.Load().Debug().Msg("setup NetworkManager done")
return nil
}
func restoreNetworkManager() error {
if !hasNetworkManager() {
return nil
}
err := os.Remove(networkManagerCtrldConfFile)
if os.IsNotExist(err) {
mainLog.Debug().Msg("NetworkManager is not available")
mainLog.Load().Debug().Msg("NetworkManager is not available")
return nil
}
if err != nil {
mainLog.Debug().Err(err).Msg("could not remove NetworkManager ctrld config file")
mainLog.Load().Debug().Err(err).Msg("could not remove NetworkManager ctrld config file")
return err
}
reloadNetworkManager()
mainLog.Debug().Msg("restore NetworkManager done")
mainLog.Load().Debug().Msg("restore NetworkManager done")
return nil
}
@@ -63,14 +75,15 @@ func reloadNetworkManager() {
defer cancel()
conn, err := dbus.NewSystemConnectionContext(ctx)
if err != nil {
mainLog.Error().Err(err).Msg("could not create new system connection")
mainLog.Load().Error().Err(err).Msg("could not create new system connection")
return
}
defer conn.Close()
waitCh := make(chan string)
if _, err := conn.ReloadUnitContext(ctx, nmSystemdUnitName, "ignore-dependencies", waitCh); err != nil {
mainLog.Debug().Err(err).Msg("could not reload NetworkManager")
mainLog.Load().Debug().Err(err).Msg("could not reload NetworkManager")
return
}
<-waitCh
}

View File

@@ -1,6 +1,6 @@
//go:build !linux
package main
package cli
func setupNetworkManager() error {
reloadNetworkManager()

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

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

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

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

114
cmd/cli/os_darwin.go Normal file
View File

@@ -0,0 +1,114 @@
package cli
import (
"bufio"
"bytes"
"fmt"
"net"
"os/exec"
"strings"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// allocate loopback ip
// sudo ifconfig lo0 alias 127.0.0.2 up
func allocateIP(ip string) error {
cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up")
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
return err
}
return nil
}
func deAllocateIP(ip string) error {
cmd := exec.Command("ifconfig", "lo0", "-alias", ip)
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
return err
}
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
func setDNS(iface *net.Interface, nameservers []string) error {
// Note that networksetup won't modify search domains settings,
// This assignment is just a placeholder to silent linter.
_ = searchDomains
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name}
args = append(args, nameservers...)
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
return fmt.Errorf("%v: %w", string(out), err)
}
return nil
}
// 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 {
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name, "empty"}
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
return fmt.Errorf("%v: %w", string(out), err)
}
return nil
}
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
if ns := savedStaticNameservers(iface); len(ns) > 0 {
err = setDNS(iface, ns)
}
return err
}
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers()
}
// currentStaticDNS returns the current static DNS settings of given interface.
func currentStaticDNS(iface *net.Interface) ([]string, error) {
cmd := "networksetup"
args := []string{"-getdnsservers", iface.Name}
out, err := exec.Command(cmd, args...).Output()
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(bytes.NewReader(out))
var ns []string
for scanner.Scan() {
line := scanner.Text()
if ip := net.ParseIP(line); ip != nil {
ns = append(ns, ip.String())
}
}
return ns, nil
}

103
cmd/cli/os_freebsd.go Normal file
View File

@@ -0,0 +1,103 @@
package cli
import (
"net"
"net/netip"
"os/exec"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/dns"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// allocate loopback ip
// sudo ifconfig lo0 127.0.0.53 alias
func allocateIP(ip string) error {
cmd := exec.Command("ifconfig", "lo0", ip, "alias")
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
return err
}
return nil
}
func deAllocateIP(ip string) error {
cmd := exec.Command("ifconfig", "lo0", ip, "-alias")
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
return err
}
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, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
ns := make([]netip.Addr, 0, len(nameservers))
for _, nameserver := range nameservers {
ns = append(ns, netip.MustParseAddr(nameserver))
}
osConfig := dns.OSConfig{
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
if sds, err := searchDomains(); err == nil {
osConfig.SearchDomains = sds
} else {
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
}
if err := r.SetDNS(osConfig); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to set DNS")
return err
}
return nil
}
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
return resetDNS(iface)
}
func resetDNS(iface *net.Interface) error {
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
if err := r.Close(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
return err
}
return nil
}
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
return err
}
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers()
}
// currentStaticDNS returns the current static DNS settings of given interface.
func currentStaticDNS(iface *net.Interface) ([]string, error) {
return currentDNS(iface), nil
}

306
cmd/cli/os_linux.go Normal file
View File

@@ -0,0 +1,306 @@
package cli
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"net/netip"
"os/exec"
"slices"
"strings"
"syscall"
"time"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/client6"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/dns"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
"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 {
cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo")
if out, err := cmd.CombinedOutput(); err != nil {
mainLog.Load().Error().Err(err).Msgf("allocateIP failed: %s", string(out))
return err
}
return nil
}
func deAllocateIP(ip string) error {
cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo")
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
return err
}
return nil
}
const maxSetDNSAttempts = 5
// 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, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
ns := make([]netip.Addr, 0, len(nameservers))
for _, nameserver := range nameservers {
ns = append(ns, netip.MustParseAddr(nameserver))
}
osConfig := dns.OSConfig{
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
if sds, err := searchDomains(); err == nil {
osConfig.SearchDomains = sds
} else {
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
}
trySystemdResolve := false
if err := r.SetDNS(osConfig); err != nil {
if strings.Contains(err.Error(), "Rejected send message") &&
strings.Contains(err.Error(), "org.freedesktop.network1.Manager") {
mainLog.Load().Warn().Msg("Interfaces are managed by systemd-networkd, switch to systemd-resolve for setting DNS")
trySystemdResolve = true
goto systemdResolve
}
// This error happens on read-only file system, which causes ctrld failed to create backup
// for /etc/resolv.conf file. It is ok, because the DNS is still set anyway, and restore
// DNS will fallback to use DHCP if there's no backup /etc/resolv.conf file.
// The error format is controlled by us, so checking for error string is fine.
// See: ../../internal/dns/direct.go:L278
if r.Mode() == "direct" && strings.Contains(err.Error(), resolvConfBackupFailedMsg) {
return nil
}
return err
}
systemdResolve:
if trySystemdResolve {
// Stop systemd-networkd and retry setting DNS.
if out, err := exec.Command("systemctl", "stop", "systemd-networkd").CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", string(out), err)
}
args := []string{"--interface=" + iface.Name, "--set-domain=~"}
for _, nameserver := range nameservers {
args = append(args, "--set-dns="+nameserver)
}
for i := 0; i < maxSetDNSAttempts; i++ {
if out, err := exec.Command("systemd-resolve", args...).CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", string(out), err)
}
currentNS := currentDNS(iface)
if isSubSet(nameservers, currentNS) {
return nil
}
time.Sleep(time.Second)
}
mainLog.Load().Debug().Msg("DNS was not set for some reason")
}
return nil
}
// resetDnsIgnoreUnusableInterface likes resetDNS, but return a nil error if the interface is not usable.
func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
return resetDNS(iface)
}
func resetDNS(iface *net.Interface) (err error) {
defer func() {
if err == nil {
return
}
// Start systemd-networkd if present.
if exe, _ := exec.LookPath("/lib/systemd/systemd-networkd"); exe != "" {
_ = exec.Command("systemctl", "start", "systemd-networkd").Run()
}
if r, oerr := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name); oerr == nil {
_ = r.SetDNS(dns.OSConfig{})
if err := r.Close(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
return
}
err = nil
}
}()
var ns []string
c, err := nclient4.New(iface.Name)
if err != nil {
return fmt.Errorf("nclient4.New: %w", err)
}
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
lease, err := c.Request(ctx)
if err != nil {
return fmt.Errorf("nclient4.Request: %w", err)
}
for _, nameserver := range lease.ACK.DNS() {
if nameserver.Equal(net.IPv4zero) {
continue
}
ns = append(ns, nameserver.String())
}
// TODO(cuonglm): handle DHCPv6 properly.
mainLog.Load().Debug().Msg("checking for IPv6 availability")
if ctrldnet.IPv6Available(ctx) {
c := client6.NewClient()
conversation, err := c.Exchange(iface.Name)
if err != nil && !errAddrInUse(err) {
mainLog.Load().Debug().Err(err).Msg("could not exchange DHCPv6")
}
for _, packet := range conversation {
if packet.Type() == dhcpv6.MessageTypeReply {
msg, err := packet.GetInnerMessage()
if err != nil {
mainLog.Load().Debug().Err(err).Msg("could not get inner DHCPv6 message")
return nil
}
nameservers := msg.Options.DNS()
for _, nameserver := range nameservers {
ns = append(ns, nameserver.String())
}
}
}
} else {
mainLog.Load().Debug().Msg("IPv6 is not available")
}
return ignoringEINTR(func() error {
return setDNS(iface, ns)
})
}
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
return err
}
func currentDNS(iface *net.Interface) []string {
resolvconfFunc := func(_ string) []string { return resolvconffile.NameServers() }
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconfFunc} {
if ns := fn(iface.Name); len(ns) > 0 {
return ns
}
}
return nil
}
// currentStaticDNS returns the current static DNS settings of given interface.
func currentStaticDNS(iface *net.Interface) ([]string, error) {
return currentDNS(iface), nil
}
func getDNSByResolvectl(iface string) []string {
b, err := exec.Command("resolvectl", "dns", "-i", iface).Output()
if err != nil {
return nil
}
parts := strings.Fields(strings.SplitN(string(b), "%", 2)[0])
if len(parts) > 2 {
return parts[3:]
}
return nil
}
func getDNSBySystemdResolved(iface string) []string {
b, err := exec.Command("systemd-resolve", "--status", iface).Output()
if err != nil {
return nil
}
return getDNSBySystemdResolvedFromReader(bytes.NewReader(b))
}
func getDNSBySystemdResolvedFromReader(r io.Reader) []string {
scanner := bufio.NewScanner(r)
var ret []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(ret) > 0 {
if net.ParseIP(line) != nil {
ret = append(ret, line)
}
continue
}
after, found := strings.CutPrefix(line, "DNS Servers: ")
if !found {
continue
}
if net.ParseIP(after) != nil {
ret = append(ret, after)
}
}
return ret
}
func getDNSByNmcli(iface string) []string {
b, err := exec.Command("nmcli", "dev", "show", iface).Output()
if err != nil {
return nil
}
s := bufio.NewScanner(bytes.NewReader(b))
var dns []string
do := func(line string) {
parts := strings.SplitN(line, ":", 2)
if len(parts) > 1 {
dns = append(dns, strings.TrimSpace(parts[1]))
}
}
for s.Scan() {
line := s.Text()
switch {
case strings.HasPrefix(line, "IP4.DNS"):
fallthrough
case strings.HasPrefix(line, "IP6.DNS"):
do(line)
}
}
return dns
}
func ignoringEINTR(fn func() error) error {
for {
err := fn()
if err != syscall.EINTR {
return err
}
}
}
// isSubSet reports whether s2 contains all elements of s1.
func isSubSet(s1, s2 []string) bool {
ok := true
for _, ns := range s1 {
if slices.Contains(s2, ns) {
continue
}
ok = false
break
}
return ok
}

23
cmd/cli/os_linux_test.go Normal file
View File

@@ -0,0 +1,23 @@
package cli
import (
"reflect"
"strings"
"testing"
)
func Test_getDNSBySystemdResolvedFromReader(t *testing.T) {
r := strings.NewReader(`Link 2 (eth0)
Current Scopes: DNS
LLMNR setting: yes
MulticastDNS setting: no
DNSSEC setting: no
DNSSEC supported: no
DNS Servers: 8.8.8.8
8.8.4.4`)
want := []string{"8.8.8.8", "8.8.4.4"}
ns := getDNSBySystemdResolvedFromReader(r)
if !reflect.DeepEqual(ns, want) {
t.Logf("unexpected result, want: %v, got: %v", want, ns)
}
}

View File

@@ -1,6 +1,6 @@
//go:build !linux && !darwin && !freebsd
package main
package cli
// TODO(cuonglm): implement.
func allocateIP(ip string) error {

332
cmd/cli/os_windows.go Normal file
View File

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

View File

@@ -0,0 +1,68 @@
package cli
import (
"fmt"
"net"
"slices"
"strings"
"testing"
"time"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
func Test_currentStaticDNS(t *testing.T) {
iface, err := net.InterfaceByName(defaultIfaceName())
if err != nil {
t.Fatal(err)
}
start := time.Now()
staticDns, err := currentStaticDNS(iface)
if err != nil {
t.Fatal(err)
}
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
start = time.Now()
staticDnsPowershell, err := currentStaticDnsPowershell(iface)
if err != nil {
t.Fatal(err)
}
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
slices.Sort(staticDns)
slices.Sort(staticDnsPowershell)
if !slices.Equal(staticDns, staticDnsPowershell) {
t.Fatalf("result mismatch, want: %v, got: %v", staticDnsPowershell, staticDns)
}
}
func currentStaticDnsPowershell(iface *net.Interface) ([]string, error) {
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return nil, err
}
guid, err := luid.GUID()
if err != nil {
return nil, err
}
var ns []string
for _, path := range []string{"HKLM:\\" + v4InterfaceKeyPathFormat, "HKLM:\\" + v6InterfaceKeyPathFormat} {
interfaceKeyPath := path + guid.String()
found := false
for _, key := range []string{"NameServer", "ProfileNameServer"} {
if found {
continue
}
cmd := fmt.Sprintf(`Get-ItemPropertyValue -Path "%s" -Name "%s"`, interfaceKeyPath, key)
out, err := powershell(cmd)
if err == nil && len(out) > 0 {
found = true
for _, e := range strings.Split(string(out), ",") {
ns = append(ns, strings.TrimRight(e, "\x00"))
}
}
}
}
return ns, nil
}

1579
cmd/cli/prog.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,11 @@
package main
package cli
import (
"github.com/kardianos/service"
)
func (p *prog) preRun() {
if !service.Interactive() {
p.setDNS()
}
}
func setDependencies(svc *service.Config) {}
func setWorkingDirectory(svc *service.Config, dir string) {
svc.WorkingDirectory = dir
}
func (p *prog) preStop() {
if !service.Interactive() {
p.resetDNS()
}
}

View File

@@ -1,4 +1,4 @@
package main
package cli
import (
"os"
@@ -6,17 +6,9 @@ import (
"github.com/kardianos/service"
)
func (p *prog) preRun() {
if !service.Interactive() {
p.setDNS()
}
}
func setDependencies(svc *service.Config) {
// TODO(cuonglm): remove once https://github.com/kardianos/service/issues/359 fixed.
_ = os.MkdirAll("/usr/local/etc/rc.d", 0755)
}
func setWorkingDirectory(svc *service.Config, dir string) {}
func (p *prog) preStop() {}

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

@@ -0,0 +1,65 @@
package cli
import (
"bufio"
"bytes"
"io"
"os"
"os/exec"
"strings"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
)
func init() {
if r, err := newLoopbackOSConfigurator(); 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) {
svc.Dependencies = []string{
"Wants=network-online.target",
"After=network-online.target",
"Wants=NetworkManager-wait-online.service",
"After=NetworkManager-wait-online.service",
"Wants=nss-lookup.target",
"After=nss-lookup.target",
}
if out, _ := exec.Command("networkctl", "--no-pager").CombinedOutput(); len(out) > 0 {
if wantsSystemDNetworkdWaitOnline(bytes.NewReader(out)) {
svc.Dependencies = append(svc.Dependencies, "Wants=systemd-networkd-wait-online.service")
}
}
if routerDeps := router.ServiceDependencies(); len(routerDeps) > 0 {
svc.Dependencies = append(svc.Dependencies, routerDeps...)
}
}
func setWorkingDirectory(svc *service.Config, dir string) {
svc.WorkingDirectory = dir
}
// wantsSystemDNetworkdWaitOnline reports whether "systemd-networkd-wait-online" service
// is required to be added to ctrld dependencies services.
// The input reader r is the output of "networkctl --no-pager" command.
func wantsSystemDNetworkdWaitOnline(r io.Reader) bool {
scanner := bufio.NewScanner(r)
// Skip header
scanner.Scan()
configured := false
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) > 0 && fields[len(fields)-1] == "configured" {
configured = true
break
}
}
return configured
}

View File

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

View File

@@ -1,16 +1,12 @@
//go:build !linux && !freebsd && !darwin
//go:build !linux && !freebsd && !darwin && !windows
package main
package cli
import "github.com/kardianos/service"
func (p *prog) preRun() {}
func setDependencies(svc *service.Config) {}
func setWorkingDirectory(svc *service.Config, dir string) {
// WorkingDirectory is not supported on Windows.
svc.WorkingDirectory = dir
}
func (p *prog) preStop() {}

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

@@ -0,0 +1,273 @@
package cli
import (
"runtime"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/Control-D-Inc/ctrld"
)
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())
})
}
}
func Test_shouldUpgrade(t *testing.T) {
// Helper function to create a version
makeVersion := func(v string) *semver.Version {
ver, err := semver.NewVersion(v)
if err != nil {
t.Fatalf("failed to create version %s: %v", v, err)
}
return ver
}
tests := []struct {
name string
versionTarget string
currentVersion *semver.Version
shouldUpgrade bool
description string
}{
{
name: "empty version target",
versionTarget: "",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should skip upgrade when version target is empty",
},
{
name: "invalid version target",
versionTarget: "invalid-version",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should skip upgrade when version target is invalid",
},
{
name: "same version",
versionTarget: "v1.0.0",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should skip upgrade when target version equals current version",
},
{
name: "older version",
versionTarget: "v1.0.0",
currentVersion: makeVersion("v1.1.0"),
shouldUpgrade: false,
description: "should skip upgrade when target version is older than current version",
},
{
name: "patch upgrade allowed",
versionTarget: "v1.0.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should allow patch version upgrade within same major version",
},
{
name: "minor upgrade allowed",
versionTarget: "v1.1.0",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should allow minor version upgrade within same major version",
},
{
name: "major upgrade blocked",
versionTarget: "v2.0.0",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should block major version upgrade",
},
{
name: "major downgrade blocked",
versionTarget: "v1.0.0",
currentVersion: makeVersion("v2.0.0"),
shouldUpgrade: false,
description: "should block major version downgrade",
},
{
name: "version without v prefix",
versionTarget: "1.0.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should handle version target without v prefix",
},
{
name: "complex version upgrade allowed",
versionTarget: "v1.5.3",
currentVersion: makeVersion("v1.4.2"),
shouldUpgrade: true,
description: "should allow complex version upgrade within same major version",
},
{
name: "complex major upgrade blocked",
versionTarget: "v3.1.0",
currentVersion: makeVersion("v2.5.3"),
shouldUpgrade: false,
description: "should block complex major version upgrade",
},
{
name: "pre-release version upgrade allowed",
versionTarget: "v1.0.1-beta.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should allow pre-release version upgrade within same major version",
},
{
name: "pre-release major upgrade blocked",
versionTarget: "v2.0.0-alpha.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should block pre-release major version upgrade",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Create test logger
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
// Call the function and capture the result
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, &testLogger)
// Assert the expected result
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
})
}
}
func Test_selfUpgradeCheck(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipped due to Windows file locking issue on Github Action runners")
}
// Helper function to create a version
makeVersion := func(v string) *semver.Version {
ver, err := semver.NewVersion(v)
if err != nil {
t.Fatalf("failed to create version %s: %v", v, err)
}
return ver
}
tests := []struct {
name string
versionTarget string
currentVersion *semver.Version
shouldUpgrade bool
description string
}{
{
name: "upgrade allowed",
versionTarget: "v1.0.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should allow upgrade and attempt to perform it",
},
{
name: "upgrade blocked",
versionTarget: "v2.0.0",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should block upgrade and not attempt to perform it",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Create test logger
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
// Call the function and capture the result
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, &testLogger)
// Assert the expected result
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
})
}
}
func Test_performUpgrade(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipped due to Windows file locking issue on Github Action runners")
}
tests := []struct {
name string
versionTarget string
expectedResult bool
description string
}{
{
name: "valid version target",
versionTarget: "v1.0.1",
expectedResult: true,
description: "should attempt to perform upgrade with valid version target",
},
{
name: "empty version target",
versionTarget: "",
expectedResult: true,
description: "should attempt to perform upgrade even with empty version target",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Call the function and capture the result
result := performUpgrade(tc.versionTarget)
assert.Equal(t, tc.expectedResult, result, tc.description)
})
}
}

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,164 @@
package cli
import (
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// parseResolvConfNameservers reads the resolv.conf file and returns the nameservers found.
// Returns nil if no nameservers are found.
func (p *prog) parseResolvConfNameservers(path string) ([]string, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// Parse the file for "nameserver" lines
var currentNS []string
lines := strings.Split(string(content), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "nameserver") {
parts := strings.Fields(trimmed)
if len(parts) >= 2 {
currentNS = append(currentNS, parts[1])
}
}
}
return currentNS, nil
}
// watchResolvConf watches any changes to /etc/resolv.conf file,
// and reverting to the original config set by ctrld.
func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
resolvConfPath := "/etc/resolv.conf"
// Evaluating symbolics link to watch the target file that /etc/resolv.conf point to.
if rp, _ := filepath.EvalSymlinks(resolvConfPath); rp != "" {
resolvConfPath = rp
}
mainLog.Load().Debug().Msgf("start watching %s file", resolvConfPath)
watcher, err := fsnotify.NewWatcher()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
return
}
defer watcher.Close()
// We watch /etc instead of /etc/resolv.conf directly,
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
watchDir := filepath.Dir(resolvConfPath)
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not add %s to watcher list", watchDir)
return
}
for {
select {
case <-p.dnsWatcherStopCh:
return
case <-p.stopCh:
mainLog.Load().Debug().Msgf("stopping watcher for %s", resolvConfPath)
return
case event, ok := <-watcher.Events:
if p.recoveryRunning.Load() {
return
}
if !ok {
return
}
if event.Name != resolvConfPath { // skip if not /etc/resolv.conf changes.
continue
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
mainLog.Load().Debug().Msgf("/etc/resolv.conf changes detected, reading changes...")
// Convert expected nameservers to strings for comparison
expectedNS := make([]string, len(ns))
for i, addr := range ns {
expectedNS[i] = addr.String()
}
var foundNS []string
var err error
maxRetries := 1
for retry := 0; retry < maxRetries; retry++ {
foundNS, err = p.parseResolvConfNameservers(resolvConfPath)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to read resolv.conf content")
break
}
// If we found nameservers, break out of retry loop
if len(foundNS) > 0 {
break
}
// Only retry if we found no nameservers
if retry < maxRetries-1 {
mainLog.Load().Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
select {
case <-p.stopCh:
return
case <-p.dnsWatcherStopCh:
return
case <-time.After(2 * time.Second):
continue
}
} else {
mainLog.Load().Debug().Msg("resolv.conf remained empty after all retries")
}
}
// If we found nameservers, check if they match what we expect
if len(foundNS) > 0 {
// Check if the nameservers match exactly what we expect
matches := len(foundNS) == len(expectedNS)
if matches {
for i := range foundNS {
if foundNS[i] != expectedNS[i] {
matches = false
break
}
}
}
mainLog.Load().Debug().
Strs("found", foundNS).
Strs("expected", expectedNS).
Bool("matches", matches).
Msg("checking nameservers")
// Only revert if the nameservers don't match
if !matches {
if err := watcher.Remove(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
continue
}
if err := setDnsFn(iface, ns); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
}
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
return
}
}
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
}
}
}

View File

@@ -0,0 +1,49 @@
package cli
import (
"net"
"net/netip"
"os"
"slices"
"github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile"
)
const resolvConfPath = "/etc/resolv.conf"
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
servers := make([]string, len(ns))
for i := range ns {
servers[i] = ns[i].String()
}
if err := setDNS(iface, servers); err != nil {
return err
}
slices.Sort(servers)
curNs := currentDNS(iface)
slices.Sort(curNs)
if !slices.Equal(curNs, servers) {
c, err := resolvconffile.ParseFile(resolvConfPath)
if err != nil {
return err
}
c.Nameservers = ns
f, err := os.Create(resolvConfPath)
if err != nil {
return err
}
defer f.Close()
if err := c.Write(f); err != nil {
return err
}
return f.Close()
}
return nil
}
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
func shouldWatchResolvconf() bool {
return true
}

View File

@@ -0,0 +1,52 @@
//go:build unix && !darwin
package cli
import (
"net"
"net/netip"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/dns"
)
// setResolvConf sets the content of the resolv.conf file using the given nameservers list.
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
r, err := newLoopbackOSConfigurator()
if err != nil {
return err
}
oc := dns.OSConfig{
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
if sds, err := searchDomains(); err == nil {
oc.SearchDomains = sds
} else {
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list when reverting resolv.conf file")
}
return r.SetDNS(oc)
}
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
func shouldWatchResolvconf() bool {
r, err := newLoopbackOSConfigurator()
if err != nil {
return false
}
switch r.Mode() {
case "direct", "resolvconf":
return true
default:
return false
}
}
// newLoopbackOSConfigurator creates an OSConfigurator for DNS management using the "lo" interface.
func newLoopbackOSConfigurator() (dns.OSConfigurator, error) {
return dns.NewOSConfigurator(noopLogf, &health.Tracker{}, &controlknobs.Knobs{}, "lo")
}

View File

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

View File

@@ -0,0 +1,14 @@
//go:build unix
package cli
import (
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// searchDomains returns the current search domains config.
func searchDomains() ([]dnsname.FQDN, error) {
return resolvconffile.SearchDomains()
}

View File

@@ -0,0 +1,43 @@
package cli
import (
"fmt"
"syscall"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"tailscale.com/util/dnsname"
)
// searchDomains returns the current search domains config.
func searchDomains() ([]dnsname.FQDN, error) {
flags := winipcfg.GAAFlagIncludeGateways |
winipcfg.GAAFlagIncludePrefix
aas, err := winipcfg.GetAdaptersAddresses(syscall.AF_UNSPEC, flags)
if err != nil {
return nil, fmt.Errorf("winipcfg.GetAdaptersAddresses: %w", err)
}
var sds []dnsname.FQDN
for _, aa := range aas {
if aa.OperStatus != winipcfg.IfOperStatusUp {
continue
}
// Skip if software loopback or other non-physical types
// This is to avoid the "Loopback Pseudo-Interface 1" issue we see on windows
if aa.IfType == winipcfg.IfTypeSoftwareLoopback {
continue
}
for a := aa.FirstDNSSuffix; a != nil; a = a.Next {
d, err := dnsname.ToFQDN(a.String())
if err != nil {
mainLog.Load().Debug().Err(err).Msgf("failed to parse domain: %s", a.String())
continue
}
sds = append(sds, d)
}
}
return sds, nil
}

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 deactivationPinSet() {
args = append(args, fmt.Sprintf("--pin=%d", cdDeactivationPin.Load()))
}
cmd := exec.Command(bin, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
logger.Fatal().Err(err).Msg("could not start self uninstall command")
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
_ = cmd.Wait()
os.Exit(0)
}
func selfUninstallLinux(p *prog, logger zerolog.Logger) {
if uninstallInvalidCdUID(p, logger, true) {
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
os.Exit(0)
}
}

View File

@@ -0,0 +1,12 @@
//go:build !windows
package cli
import (
"syscall"
)
// sysProcAttrForDetachedChildProcess returns *syscall.SysProcAttr instance for running a detached child command.
func sysProcAttrForDetachedChildProcess() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}

View File

@@ -0,0 +1,18 @@
package cli
import (
"syscall"
)
// From: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags?redirectedfrom=MSDN
// SYSCALL_CREATE_NO_WINDOW set flag to run process without a console window.
const SYSCALL_CREATE_NO_WINDOW = 0x08000000
// sysProcAttrForDetachedChildProcess returns *syscall.SysProcAttr instance for running self-upgrade command.
func sysProcAttrForDetachedChildProcess() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | SYSCALL_CREATE_NO_WINDOW,
HideWindow: true,
}
}

24
cmd/cli/sema.go Normal file
View File

@@ -0,0 +1,24 @@
package cli
type semaphore interface {
acquire()
release()
}
type noopSemaphore struct{}
func (n noopSemaphore) acquire() {}
func (n noopSemaphore) release() {}
type chanSemaphore struct {
ready chan struct{}
}
func (c *chanSemaphore) acquire() {
c.ready <- struct{}{}
}
func (c *chanSemaphore) release() {
<-c.ready
}

268
cmd/cli/service.go Normal file
View File

@@ -0,0 +1,268 @@
package cli
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"github.com/coreos/go-systemd/v22/unit"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
"github.com/Control-D-Inc/ctrld/internal/router/openwrt"
)
// newService wraps service.New call to return service.Service
// wrapper which is suitable for the current platform.
func newService(i service.Interface, c *service.Config) (service.Service, error) {
s, err := service.New(i, c)
if err != nil {
return nil, err
}
switch {
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
}
// sysV wraps a service.Service, and provide start/stop/status command
// base on "/etc/init.d/<service_name>".
//
// Use this on system where "service" command is not available, like GL.iNET router.
type sysV struct {
service.Service
}
func (s *sysV) installed() bool {
fi, err := os.Stat("/etc/init.d/ctrld")
if err != nil {
return false
}
mode := fi.Mode()
return mode.IsRegular() && (mode&0111) != 0
}
func (s *sysV) Start() error {
if !s.installed() {
return service.ErrNotInstalled
}
_, err := exec.Command("/etc/init.d/ctrld", "start").CombinedOutput()
return err
}
func (s *sysV) Stop() error {
if !s.installed() {
return service.ErrNotInstalled
}
_, err := exec.Command("/etc/init.d/ctrld", "stop").CombinedOutput()
return err
}
func (s *sysV) Restart() error {
if !s.installed() {
return service.ErrNotInstalled
}
// We don't care about error returned by s.Stop,
// because the service may already be stopped.
_ = s.Stop()
return s.Start()
}
func (s *sysV) Status() (service.Status, error) {
if !s.installed() {
return service.StatusUnknown, service.ErrNotInstalled
}
return unixSystemVServiceStatus()
}
// procd wraps a service.Service, and provide start/stop command
// base on "/etc/init.d/<service_name>", status command base on parsing "ps" command output.
//
// Use this on system where "/etc/init.d/<service_name> status" command is not available,
// 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
}
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", bin+" [r]un ")
if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil {
return service.StatusStopped, nil
}
return service.StatusRunning, nil
}
// systemd wraps a service.Service, and provide status command to
// report the status correctly.
type systemd struct {
service.Service
}
func (s *systemd) Status() (service.Status, error) {
out, _ := exec.Command("systemctl", "status", "ctrld").CombinedOutput()
if bytes.Contains(out, []byte("/FAILURE)")) {
return service.StatusStopped, nil
}
return s.Service.Status()
}
func (s *systemd) Start() error {
const systemdUnitFile = "/etc/systemd/system/ctrld.service"
f, err := os.Open(systemdUnitFile)
if err != nil {
return err
}
defer f.Close()
if opts, change := ensureSystemdKillMode(f); change {
mode := os.FileMode(0644)
buf, err := io.ReadAll(unit.Serialize(opts))
if err != nil {
return err
}
if err := os.WriteFile(systemdUnitFile, buf, mode); err != nil {
return err
}
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out))
}
mainLog.Load().Debug().Msg("set KillMode=process successfully")
}
return s.Service.Start()
}
// ensureSystemdKillMode ensure systemd unit file is configured with KillMode=process.
// This is necessary for running self-upgrade flow.
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
opts, err := unit.DeserializeOptions(r)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to deserialize options")
return
}
change = true
needKillModeOpt := true
killModeOpt := unit.NewUnitOption("Service", "KillMode", "process")
for _, opt := range opts {
if opt.Match(killModeOpt) {
needKillModeOpt = false
change = false
break
}
if opt.Section == killModeOpt.Section && opt.Name == killModeOpt.Name {
opt.Value = killModeOpt.Value
needKillModeOpt = false
break
}
}
if needKillModeOpt {
opts = append(opts, killModeOpt)
}
return opts, change
}
func newLaunchd(s service.Service) *launchd {
return &launchd{
Service: s,
statusErrMsg: "Permission denied",
}
}
// launchd wraps a service.Service, and provide status command to
// report the status correctly when not running as root on Darwin.
//
// TODO: remove this wrapper once https://github.com/kardianos/service/issues/400 fixed.
type launchd struct {
service.Service
statusErrMsg string
}
func (l *launchd) Status() (service.Status, error) {
if os.Geteuid() != 0 {
return service.StatusUnknown, errors.New(l.statusErrMsg)
}
return l.Service.Status()
}
type task struct {
f func() error
abortOnError bool
Name string
}
func doTasks(tasks []task) bool {
for _, task := range tasks {
mainLog.Load().Debug().Msgf("Running task %s", task.Name)
if err := task.f(); err != nil {
if task.abortOnError {
mainLog.Load().Error().Msgf("error running task %s: %v", task.Name, err)
return false
}
// if this is darwin stop command, dont print debug
// since launchctl complains on every start
if runtime.GOOS != "darwin" || task.Name != "Stop" {
mainLog.Load().Debug().Msgf("error running task %s: %v", task.Name, err)
}
}
}
return true
}
func checkHasElevatedPrivilege() {
ok, err := hasElevatedPrivilege()
if err != nil {
mainLog.Load().Error().Msgf("could not detect user privilege: %v", err)
return
}
if !ok {
mainLog.Load().Error().Msg("Please relaunch process with admin/root privilege.")
os.Exit(1)
}
}
func unixSystemVServiceStatus() (service.Status, error) {
out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput()
if err != nil {
// Specific case for openwrt >= 24.10, it returns non-success code
// for above status command, which may not right.
if router.Name() == openwrt.Name {
if string(bytes.ToLower(bytes.TrimSpace(out))) == "inactive" {
return service.StatusStopped, nil
}
}
return service.StatusUnknown, nil
}
switch string(bytes.ToLower(bytes.TrimSpace(out))) {
case "running":
return service.StatusRunning, nil
default:
return service.StatusStopped, nil
}
}

22
cmd/cli/service_others.go Normal file
View File

@@ -0,0 +1,22 @@
//go:build !windows
package cli
import (
"os"
)
func hasElevatedPrivilege() (bool, error) {
return os.Geteuid() == 0, nil
}
func openLogFile(path string, flags int) (*os.File, error) {
return os.OpenFile(path, flags, os.FileMode(0o600))
}
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
func hasLocalDnsServerRunning() bool { return false }
func ConfigureWindowsServiceFailureActions(serviceName string) error { return nil }
func isRunningOnDomainControllerWindows() (bool, int) { return false, 0 }

28
cmd/cli/service_test.go Normal file
View File

@@ -0,0 +1,28 @@
package cli
import (
"strings"
"testing"
)
func Test_ensureSystemdKillMode(t *testing.T) {
tests := []struct {
name string
unitFile string
wantChange bool
}{
{"no KillMode", "[Service]\nExecStart=/bin/sleep 1", true},
{"not KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=mixed", true},
{"KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=process", false},
{"invalid unit file", "[Service\nExecStart=/bin/sleep 1\nKillMode=process", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if _, change := ensureSystemdKillMode(strings.NewReader(tc.unitFile)); tc.wantChange != change {
t.Errorf("ensureSystemdKillMode(%q) = %v, want %v", tc.unitFile, change, tc.wantChange)
}
})
}
}

227
cmd/cli/service_windows.go Normal file
View File

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

View File

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

126
cmd/cli/upstream_monitor.go Normal file
View File

@@ -0,0 +1,126 @@
package cli
import (
"sync"
"time"
"github.com/Control-D-Inc/ctrld"
)
const (
// maxFailureRequest is the maximum failed queries allowed before an upstream is marked as down.
maxFailureRequest = 50
// checkUpstreamBackoffSleep is the time interval between each upstream checks.
checkUpstreamBackoffSleep = 2 * time.Second
)
// upstreamMonitor performs monitoring upstreams health.
type upstreamMonitor struct {
cfg *ctrld.Config
mu sync.RWMutex
checking map[string]bool
down map[string]bool
failureReq map[string]uint64
recovered map[string]bool
// failureTimerActive tracks if a timer is already running for a given upstream.
failureTimerActive map[string]bool
}
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
um := &upstreamMonitor{
cfg: cfg,
checking: make(map[string]bool),
down: make(map[string]bool),
failureReq: make(map[string]uint64),
recovered: make(map[string]bool),
failureTimerActive: make(map[string]bool),
}
for n := range cfg.Upstream {
upstream := upstreamPrefix + n
um.reset(upstream)
}
um.reset(upstreamOS)
return um
}
// increaseFailureCount increases failed queries count for an upstream by 1 and logs debug information.
// It uses a timer to debounce failure detection, ensuring that an upstream is marked as down
// within 10 seconds if failures persist, without spawning duplicate goroutines.
func (um *upstreamMonitor) increaseFailureCount(upstream string) {
um.mu.Lock()
defer um.mu.Unlock()
if um.recovered[upstream] {
mainLog.Load().Debug().Msgf("upstream %q is recovered, skipping failure count increase", upstream)
return
}
um.failureReq[upstream] += 1
failedCount := um.failureReq[upstream]
// Log the updated failure count.
mainLog.Load().Debug().Msgf("upstream %q failure count updated to %d", upstream, failedCount)
// If this is the first failure and no timer is running, start a 10-second timer.
if failedCount == 1 && !um.failureTimerActive[upstream] {
um.failureTimerActive[upstream] = true
go func(upstream string) {
time.Sleep(10 * time.Second)
um.mu.Lock()
defer um.mu.Unlock()
// If no success occurred during the 10-second window (i.e. counter remains > 0)
// and the upstream is not in a recovered state, mark it as down.
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
um.down[upstream] = true
mainLog.Load().Warn().Msgf("upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
}
// Reset the timer flag so that a new timer can be spawned if needed.
um.failureTimerActive[upstream] = false
}(upstream)
}
// If the failure count quickly reaches the threshold, mark the upstream as down immediately.
if failedCount >= maxFailureRequest {
um.down[upstream] = true
mainLog.Load().Warn().Msgf("upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
}
}
// isDown reports whether the given upstream is being marked as down.
func (um *upstreamMonitor) isDown(upstream string) bool {
um.mu.Lock()
defer um.mu.Unlock()
return um.down[upstream]
}
// reset marks an upstream as up and set failed queries counter to zero.
func (um *upstreamMonitor) reset(upstream string) {
um.mu.Lock()
um.failureReq[upstream] = 0
um.down[upstream] = false
um.recovered[upstream] = true
um.mu.Unlock()
go func() {
// debounce the recovery to avoid incrementing failure counts already in flight
time.Sleep(1 * time.Second)
um.mu.Lock()
um.recovered[upstream] = false
um.mu.Unlock()
}()
}
// countHealthy returns the number of upstreams in the provided map that are considered healthy.
func (um *upstreamMonitor) countHealthy(upstreams []string) int {
var count int
um.mu.RLock()
for _, upstream := range upstreams {
if !um.down[upstream] {
count++
}
}
um.mu.RUnlock()
return count
}

View File

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

View File

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

View File

@@ -1,959 +0,0 @@
package main
import (
"bytes"
"context"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/cuonglm/osinfo"
"github.com/fsnotify/fsnotify"
"github.com/go-playground/validator/v10"
"github.com/kardianos/service"
"github.com/miekg/dns"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"tailscale.com/logtail/backoff"
"tailscale.com/net/interfaces"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/certs"
"github.com/Control-D-Inc/ctrld/internal/controld"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
"github.com/Control-D-Inc/ctrld/internal/router"
)
var (
version = "dev"
commit = "none"
)
var (
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
defaultConfigWritten = false
defaultConfigFile = "ctrld.toml"
rootCertPool *x509.CertPool
)
var basicModeFlags = []string{"listen", "primary_upstream", "secondary_upstream", "domains"}
func isNoConfigStart(cmd *cobra.Command) bool {
for _, flagName := range basicModeFlags {
if cmd.Flags().Lookup(flagName).Changed {
return true
}
}
return false
}
const rootShortDesc = `
__ .__ .___
_____/ |________| | __| _/
_/ ___\ __\_ __ \ | / __ |
\ \___| | | | \/ |__/ /_/ |
\___ >__| |__| |____/\____ |
\/ dns forwarding proxy \/
`
var rootCmd = &cobra.Command{
Use: "ctrld",
Short: strings.TrimLeft(rootShortDesc, "\n"),
Version: curVersion(),
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
},
}
func curVersion() string {
if version != "dev" && !strings.HasPrefix(version, "v") {
version = "v" + version
}
if len(commit) > 7 {
commit = commit[:7]
}
return fmt.Sprintf("%s-%s", version, commit)
}
func initCLI() {
// Enable opening via explorer.exe on Windows.
// See: https://github.com/spf13/cobra/issues/844.
cobra.MousetrapHelpText = ""
cobra.EnableCommandSorting = false
rootCmd.PersistentFlags().CountVarP(
&verbose,
"verbose",
"v",
`verbose log output, "-v" basic logging, "-vv" debug level logging`,
)
rootCmd.PersistentFlags().BoolVarP(
&silent,
"silent",
"s",
false,
`do not write any log output`,
)
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
rootCmd.CompletionOptions.HiddenDefaultCmd = true
runCmd := &cobra.Command{
Use: "run",
Short: "Run the DNS proxy server",
Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
},
Run: func(cmd *cobra.Command, args []string) {
if daemon && runtime.GOOS == "windows" {
mainLog.Fatal().Msg("Cannot run in daemon mode. Please install a Windows service.")
}
waitCh := make(chan struct{})
stopCh := make(chan struct{})
if !daemon {
// We need to call s.Run() as soon as possible to response to the OS manager, so it
// can see ctrld is running and don't mark ctrld as failed service.
go func() {
p := &prog{
waitCh: waitCh,
stopCh: stopCh,
}
s, err := service.New(p, svcConfig)
if err != nil {
mainLog.Fatal().Err(err).Msg("failed create new service")
}
s = newService(s)
if err := s.Run(); err != nil {
mainLog.Error().Err(err).Msg("failed to start service")
}
}()
}
noConfigStart := isNoConfigStart(cmd)
writeDefaultConfig := !noConfigStart && configBase64 == ""
tryReadingConfig(writeDefaultConfig)
readBase64Config(configBase64)
processNoConfigFlags(noConfigStart)
if err := v.Unmarshal(&cfg); err != nil {
mainLog.Fatal().Msgf("failed to unmarshal config: %v", err)
}
mainLog.Info().Msgf("starting ctrld %s", curVersion())
oi := osinfo.New()
mainLog.Info().Msgf("os: %s", oi.String())
// Wait for network up.
if !ctrldnet.Up() {
mainLog.Fatal().Msg("network is not up yet")
}
processLogAndCacheFlags()
// Log config do not have thing to validate, so it's safe to init log here,
// so it's able to log information in processCDFlags.
initLogging()
if setupRouter {
s, errCh := runDNSServerForNTPD(router.ListenAddress())
if err := router.PreRun(); err != nil {
mainLog.Fatal().Err(err).Msg("failed to perform router pre-start check")
}
if err := s.Shutdown(); err != nil && errCh != nil {
mainLog.Fatal().Err(err).Msg("failed to shutdown dns server for ntpd")
}
}
processCDFlags()
if err := ctrld.ValidateConfig(validator.New(), &cfg); err != nil {
mainLog.Fatal().Msgf("invalid config: %v", err)
}
initCache()
if daemon {
exe, err := os.Executable()
if err != nil {
mainLog.Error().Err(err).Msg("failed to find the binary")
os.Exit(1)
}
curDir, err := os.Getwd()
if err != nil {
mainLog.Error().Err(err).Msg("failed to get current working directory")
os.Exit(1)
}
// If running as daemon, re-run the command in background, with daemon off.
cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...)
cmd.Dir = curDir
if err := cmd.Start(); err != nil {
mainLog.Error().Err(err).Msg("failed to start process as daemon")
os.Exit(1)
}
mainLog.Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started")
os.Exit(0)
}
if setupRouter {
switch platform := router.Name(); {
case platform == router.DDWrt:
rootCertPool = certs.CACertPool()
fallthrough
case platform != "":
mainLog.Debug().Msg("Router setup")
err := router.Configure(&cfg)
if errors.Is(err, router.ErrNotSupported) {
unsupportedPlatformHelp(cmd)
os.Exit(1)
}
if err != nil {
mainLog.Fatal().Err(err).Msg("failed to configure router")
}
}
}
close(waitCh)
<-stopCh
},
}
runCmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "Run as daemon")
runCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
runCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
runCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
runCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
runCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
runCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
runCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid")
runCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
_ = runCmd.Flags().MarkHidden("dev")
runCmd.Flags().StringVarP(&homedir, "homedir", "", "", "")
_ = runCmd.Flags().MarkHidden("homedir")
runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
_ = runCmd.Flags().MarkHidden("iface")
runCmd.Flags().BoolVarP(&setupRouter, "router", "", false, `setup for running on router platforms`)
_ = runCmd.Flags().MarkHidden("router")
rootCmd.AddCommand(runCmd)
startCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
},
Use: "start",
Short: "Install and start the ctrld service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
sc := &service.Config{}
*sc = *svcConfig
osArgs := os.Args[2:]
if os.Args[1] == "service" {
osArgs = os.Args[3:]
}
setDependencies(sc)
sc.Arguments = append([]string{"run"}, osArgs...)
if err := router.ConfigureService(sc); err != nil {
mainLog.Fatal().Err(err).Msg("failed to configure service on router")
}
// No config path, generating config in HOME directory.
noConfigStart := isNoConfigStart(cmd)
writeDefaultConfig := !noConfigStart && configBase64 == ""
if configPath != "" {
v.SetConfigFile(configPath)
}
if dir, err := userHomeDir(); err == nil {
setWorkingDirectory(sc, dir)
if configPath == "" && writeDefaultConfig {
defaultConfigFile = filepath.Join(dir, defaultConfigFile)
}
sc.Arguments = append(sc.Arguments, "--homedir="+dir)
}
tryReadingConfig(writeDefaultConfig)
if err := v.Unmarshal(&cfg); err != nil {
mainLog.Fatal().Msgf("failed to unmarshal config: %v", err)
}
logPath := cfg.Service.LogPath
cfg.Service.LogPath = ""
initLogging()
cfg.Service.LogPath = logPath
processCDFlags()
if err := ctrld.ValidateConfig(validator.New(), &cfg); err != nil {
mainLog.Fatal().Msgf("invalid config: %v", err)
}
// Explicitly passing config, so on system where home directory could not be obtained,
// or sub-process env is different with the parent, we still behave correctly and use
// the expected config file.
if configPath == "" {
sc.Arguments = append(sc.Arguments, "--config="+defaultConfigFile)
}
prog := &prog{}
s, err := service.New(prog, sc)
if err != nil {
mainLog.Error().Msg(err.Error())
return
}
s = newService(s)
tasks := []task{
{s.Stop, false},
{s.Uninstall, false},
{s.Install, false},
{s.Start, true},
}
if doTasks(tasks) {
if err := router.PostInstall(svcConfig); err != nil {
mainLog.Warn().Err(err).Msg("post installation failed, please check system/service log for details error")
return
}
status, err := serviceStatus(s)
if err != nil {
mainLog.Warn().Err(err).Msg("could not get service status")
return
}
domain := cfg.Upstream["0"].VerifyDomain()
status = selfCheckStatus(status, domain)
switch status {
case service.StatusRunning:
mainLog.Notice().Msg("Service started")
default:
mainLog.Error().Msg("Service did not start, please check system/service log for details error")
if runtime.GOOS == "linux" {
prog.resetDNS()
}
os.Exit(1)
}
prog.setDNS()
}
},
}
// Keep these flags in sync with runCmd above, except for "-d".
startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
startCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
startCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
startCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
startCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
startCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
startCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid")
startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
_ = startCmd.Flags().MarkHidden("dev")
startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
startCmd.Flags().BoolVarP(&setupRouter, "router", "", false, `setup for running on router platforms`)
_ = startCmd.Flags().MarkHidden("router")
stopCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
},
Use: "stop",
Short: "Stop the ctrld service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
prog := &prog{}
s, err := service.New(prog, svcConfig)
if err != nil {
mainLog.Error().Msg(err.Error())
return
}
s = newService(s)
initLogging()
if doTasks([]task{{s.Stop, true}}) {
prog.resetDNS()
mainLog.Notice().Msg("Service stopped")
}
},
}
stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
restartCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
},
Use: "restart",
Short: "Restart the ctrld service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
s, err := service.New(&prog{}, svcConfig)
if err != nil {
mainLog.Error().Msg(err.Error())
return
}
s = newService(s)
initLogging()
if doTasks([]task{{s.Restart, true}}) {
mainLog.Notice().Msg("Service restarted")
}
},
}
statusCmd := &cobra.Command{
Use: "status",
Short: "Show status of the ctrld service",
Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
},
Run: func(cmd *cobra.Command, args []string) {
s, err := service.New(&prog{}, svcConfig)
if err != nil {
mainLog.Error().Msg(err.Error())
return
}
s = newService(s)
status, err := serviceStatus(s)
if err != nil {
mainLog.Error().Msg(err.Error())
os.Exit(1)
}
switch status {
case service.StatusUnknown:
mainLog.Notice().Msg("Unknown status")
os.Exit(2)
case service.StatusRunning:
mainLog.Notice().Msg("Service is running")
os.Exit(0)
case service.StatusStopped:
mainLog.Notice().Msg("Service is stopped")
os.Exit(1)
}
},
}
if runtime.GOOS == "darwin" {
// On darwin, running status command without privileges may return wrong information.
statusCmd.PreRun = func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
}
}
uninstallCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
},
Use: "uninstall",
Short: "Stop and uninstall the ctrld service",
Long: `Stop and uninstall the ctrld service.
NOTE: Uninstalling will set DNS to values provided by DHCP.`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
prog := &prog{}
s, err := service.New(prog, svcConfig)
if err != nil {
mainLog.Error().Msg(err.Error())
return
}
tasks := []task{
{s.Stop, false},
{s.Uninstall, true},
}
initLogging()
if doTasks(tasks) {
if iface == "" {
iface = "auto"
}
prog.resetDNS()
mainLog.Debug().Msg("Router cleanup")
if err := router.Cleanup(svcConfig); err != nil {
mainLog.Warn().Err(err).Msg("could not cleanup router")
}
mainLog.Notice().Msg("Service uninstalled")
return
}
},
}
uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, use "auto" for the default gateway interface`)
listIfacesCmd := &cobra.Command{
Use: "list",
Short: "List network interfaces of the host",
Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
},
Run: func(cmd *cobra.Command, args []string) {
err := interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
fmt.Printf("Index : %d\n", i.Index)
fmt.Printf("Name : %s\n", i.Name)
addrs, _ := i.Addrs()
for i, ipaddr := range addrs {
if i == 0 {
fmt.Printf("Addrs : %v\n", ipaddr)
continue
}
fmt.Printf(" %v\n", ipaddr)
}
for i, dns := range currentDNS(i.Interface) {
if i == 0 {
fmt.Printf("DNS : %s\n", dns)
continue
}
fmt.Printf(" : %s\n", dns)
}
println()
})
if err != nil {
mainLog.Error().Msg(err.Error())
}
},
}
interfacesCmd := &cobra.Command{
Use: "interfaces",
Short: "Manage network interfaces",
Args: cobra.OnlyValidArgs,
ValidArgs: []string{
listIfacesCmd.Use,
},
}
interfacesCmd.AddCommand(listIfacesCmd)
serviceCmd := &cobra.Command{
Use: "service",
Short: "Manage ctrld service",
Args: cobra.OnlyValidArgs,
ValidArgs: []string{
statusCmd.Use,
stopCmd.Use,
restartCmd.Use,
statusCmd.Use,
uninstallCmd.Use,
interfacesCmd.Use,
},
}
serviceCmd.AddCommand(startCmd)
serviceCmd.AddCommand(stopCmd)
serviceCmd.AddCommand(restartCmd)
serviceCmd.AddCommand(statusCmd)
serviceCmd.AddCommand(uninstallCmd)
serviceCmd.AddCommand(interfacesCmd)
rootCmd.AddCommand(serviceCmd)
startCmdAlias := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
},
Use: "start",
Short: "Quick start service and configure DNS on interface",
Run: func(cmd *cobra.Command, args []string) {
if !cmd.Flags().Changed("iface") {
os.Args = append(os.Args, "--iface="+ifaceStartStop)
}
iface = ifaceStartStop
startCmd.Run(cmd, args)
},
}
startCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Update DNS setting for iface, "auto" means the default interface gateway`)
startCmdAlias.Flags().AddFlagSet(startCmd.Flags())
rootCmd.AddCommand(startCmdAlias)
stopCmdAlias := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
},
Use: "stop",
Short: "Quick stop service and remove DNS from interface",
Run: func(cmd *cobra.Command, args []string) {
if !cmd.Flags().Changed("iface") {
os.Args = append(os.Args, "--iface="+ifaceStartStop)
}
iface = ifaceStartStop
stopCmd.Run(cmd, args)
},
}
stopCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
stopCmdAlias.Flags().AddFlagSet(stopCmd.Flags())
rootCmd.AddCommand(stopCmdAlias)
}
func writeConfigFile() error {
if cfu := v.ConfigFileUsed(); cfu != "" {
defaultConfigFile = cfu
} else if configPath != "" {
defaultConfigFile = configPath
}
f, err := os.OpenFile(defaultConfigFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(0o644))
if err != nil {
return err
}
defer f.Close()
if cdUID != "" {
if _, err := f.WriteString("# AUTO-GENERATED VIA CD FLAG - DO NOT MODIFY\n\n"); err != nil {
return err
}
}
enc := toml.NewEncoder(f).SetIndentTables(true)
if err := enc.Encode(&cfg); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
return nil
}
func readConfigFile(writeDefaultConfig bool) bool {
// If err == nil, there's a config supplied via `--config`, no default config written.
err := v.ReadInConfig()
if err == nil {
mainLog.Info().Msg("loading config file from: " + v.ConfigFileUsed())
defaultConfigFile = v.ConfigFileUsed()
return true
}
if !writeDefaultConfig {
return false
}
// If error is viper.ConfigFileNotFoundError, write default config.
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
if err := v.Unmarshal(&cfg); err != nil {
mainLog.Fatal().Msgf("failed to unmarshal default config: %v", err)
}
if err := writeConfigFile(); err != nil {
mainLog.Fatal().Msgf("failed to write default config file: %v", err)
} else {
fp, err := filepath.Abs(defaultConfigFile)
if err != nil {
mainLog.Fatal().Msgf("failed to get default config file path: %v", err)
}
mainLog.Info().Msg("writing default config file to: " + fp)
}
defaultConfigWritten = true
return false
}
// Otherwise, report fatal error and exit.
mainLog.Fatal().Msgf("failed to decode config file: %v", err)
return false
}
func readBase64Config(configBase64 string) {
if configBase64 == "" {
return
}
configStr, err := base64.StdEncoding.DecodeString(configBase64)
if err != nil {
mainLog.Fatal().Msgf("invalid base64 config: %v", err)
}
if err := v.ReadConfig(bytes.NewReader(configStr)); err != nil {
mainLog.Fatal().Msgf("failed to read base64 config: %v", err)
}
}
func processNoConfigFlags(noConfigStart bool) {
if !noConfigStart {
return
}
if listenAddress == "" || primaryUpstream == "" {
mainLog.Fatal().Msg(`"listen" and "primary_upstream" flags must be set in no config mode`)
}
processListenFlag()
endpointAndTyp := func(endpoint string) (string, string) {
typ := ctrld.ResolverTypeFromEndpoint(endpoint)
return strings.TrimPrefix(endpoint, "quic://"), typ
}
pEndpoint, pType := endpointAndTyp(primaryUpstream)
upstream := map[string]*ctrld.UpstreamConfig{
"0": {
Name: pEndpoint,
Endpoint: pEndpoint,
Type: pType,
Timeout: 5000,
},
}
if secondaryUpstream != "" {
sEndpoint, sType := endpointAndTyp(secondaryUpstream)
upstream["1"] = &ctrld.UpstreamConfig{
Name: sEndpoint,
Endpoint: sEndpoint,
Type: sType,
Timeout: 5000,
}
rules := make([]ctrld.Rule, 0, len(domains))
for _, domain := range domains {
rules = append(rules, ctrld.Rule{domain: []string{"upstream.1"}})
}
lc := v.Get("listener").(map[string]*ctrld.ListenerConfig)["0"]
lc.Policy = &ctrld.ListenerPolicyConfig{Name: "My Policy", Rules: rules}
}
v.Set("upstream", upstream)
}
func processCDFlags() {
if cdUID == "" {
return
}
if iface == "" {
iface = "auto"
}
logger := mainLog.With().Str("mode", "cd").Logger()
logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID)
resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev)
if uer, ok := err.(*controld.UtilityErrorResponse); ok && uer.ErrorField.Code == controld.InvalidConfigCode {
s, err := service.New(&prog{}, svcConfig)
if err != nil {
logger.Warn().Err(err).Msg("failed to create new service")
return
}
if netIface, _ := netInterface(iface); netIface != nil {
if err := restoreNetworkManager(); err != nil {
logger.Error().Err(err).Msg("could not restore NetworkManager")
return
}
logger.Debug().Str("iface", netIface.Name).Msg("Restoring DNS for interface")
if err := resetDNS(netIface); err != nil {
logger.Warn().Err(err).Msg("something went wrong while restoring DNS")
} else {
logger.Debug().Str("iface", netIface.Name).Msg("Restoring DNS successfully")
}
}
tasks := []task{{s.Uninstall, true}}
if doTasks(tasks) {
logger.Info().Msg("uninstalled service")
}
logger.Fatal().Err(uer).Msg("failed to fetch resolver config")
}
if err != nil {
logger.Warn().Err(err).Msg("could not fetch resolver config")
return
}
logger.Info().Msg("generating ctrld config from Control-D configuration")
if resolverConfig.Ctrld.CustomConfig != "" {
logger.Info().Msg("using defined custom config of Control-D resolver")
readBase64Config(resolverConfig.Ctrld.CustomConfig)
if err := v.Unmarshal(&cfg); err != nil {
mainLog.Fatal().Msgf("failed to unmarshal config: %v", err)
}
for _, listener := range cfg.Listener {
if listener.IP == "" {
listener.IP = randomLocalIP()
}
if listener.Port == 0 {
listener.Port = 53
}
}
// On router, we want to keep the listener address point to dnsmasq listener, aka 127.0.0.1:53.
if router.Name() != "" {
if lc := cfg.Listener["0"]; lc != nil {
lc.IP = "127.0.0.1"
lc.Port = 53
}
}
} else {
cfg = ctrld.Config{}
cfg.Network = make(map[string]*ctrld.NetworkConfig)
cfg.Network["0"] = &ctrld.NetworkConfig{
Name: "Network 0",
Cidrs: []string{"0.0.0.0/0"},
}
cfg.Upstream = make(map[string]*ctrld.UpstreamConfig)
cfg.Upstream["0"] = &ctrld.UpstreamConfig{
Endpoint: resolverConfig.DOH,
Type: ctrld.ResolverTypeDOH,
Timeout: 5000,
}
rules := make([]ctrld.Rule, 0, len(resolverConfig.Exclude))
for _, domain := range resolverConfig.Exclude {
rules = append(rules, ctrld.Rule{domain: []string{}})
}
cfg.Listener = make(map[string]*ctrld.ListenerConfig)
cfg.Listener["0"] = &ctrld.ListenerConfig{
IP: "127.0.0.1",
Port: 53,
Policy: &ctrld.ListenerPolicyConfig{
Name: "My Policy",
Rules: rules,
},
}
processLogAndCacheFlags()
}
if err := writeConfigFile(); err != nil {
logger.Fatal().Err(err).Msg("failed to write config file")
} else {
logger.Info().Msg("writing config file to: " + defaultConfigFile)
}
}
func processListenFlag() {
if listenAddress == "" {
return
}
host, portStr, err := net.SplitHostPort(listenAddress)
if err != nil {
mainLog.Fatal().Msgf("invalid listener address: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
mainLog.Fatal().Msgf("invalid port number: %v", err)
}
lc := &ctrld.ListenerConfig{
IP: host,
Port: port,
}
v.Set("listener", map[string]*ctrld.ListenerConfig{
"0": lc,
})
}
func processLogAndCacheFlags() {
if logPath != "" {
cfg.Service.LogLevel = "debug"
cfg.Service.LogPath = logPath
}
if cacheSize != 0 {
cfg.Service.CacheEnable = true
cfg.Service.CacheSize = cacheSize
}
v.Set("service", cfg.Service)
}
func netInterface(ifaceName string) (*net.Interface, error) {
if ifaceName == "auto" {
ifaceName = defaultIfaceName()
}
var iface *net.Interface
err := interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
if i.Name == ifaceName {
iface = i.Interface
}
})
if iface == nil {
return nil, errors.New("interface not found")
}
if err := patchNetIfaceName(iface); err != nil {
return nil, err
}
return iface, err
}
func defaultIfaceName() string {
dri, err := interfaces.DefaultRouteInterface()
if err != nil {
// On WSL 1, the route table does not have any default route. But the fact that
// it only uses /etc/resolv.conf for setup DNS, so we can use "lo" here.
if oi := osinfo.New(); strings.Contains(oi.String(), "Microsoft") {
return "lo"
}
mainLog.Fatal().Err(err).Msg("failed to get default route interface")
}
return dri
}
func selfCheckStatus(status service.Status, domain string) service.Status {
if domain == "" {
// Nothing to do, return the status as-is.
return status
}
c := new(dns.Client)
bo := backoff.NewBackoff("self-check", logf, 10*time.Second)
bo.LogLongerThan = 500 * time.Millisecond
ctx := context.Background()
maxAttempts := 20
mainLog.Debug().Msg("Performing self-check")
var (
lcChanged map[string]*ctrld.ListenerConfig
mu sync.Mutex
)
v.OnConfigChange(func(in fsnotify.Event) {
mu.Lock()
defer mu.Unlock()
if err := v.UnmarshalKey("listener", &lcChanged); err != nil {
mainLog.Error().Msgf("failed to unmarshal listener config: %v", err)
return
}
})
v.WatchConfig()
for i := 0; i < maxAttempts; i++ {
lc := cfg.Listener["0"]
mu.Lock()
if lcChanged != nil {
lc = lcChanged["0"]
}
mu.Unlock()
m := new(dns.Msg)
m.SetQuestion(domain+".", dns.TypeA)
m.RecursionDesired = true
r, _, err := c.ExchangeContext(ctx, m, net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port)))
if r != nil && r.Rcode == dns.RcodeSuccess && len(r.Answer) > 0 {
mainLog.Debug().Msgf("self-check against %q succeeded", domain)
return status
}
bo.BackOff(ctx, fmt.Errorf("ExchangeContext: %w", err))
}
mainLog.Debug().Msgf("self-check against %q failed", domain)
return service.StatusUnknown
}
func unsupportedPlatformHelp(cmd *cobra.Command) {
mainLog.Error().Msg("Unsupported or incorrectly chosen router platform. Please open an issue and provide all relevant information: https://github.com/Control-D-Inc/ctrld/issues/new")
}
func userHomeDir() (string, error) {
switch router.Name() {
case router.DDWrt, router.Merlin, router.Tomato:
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Dir(exe), nil
}
// viper will expand for us.
if runtime.GOOS == "windows" {
return os.UserHomeDir()
}
dir := "/etc/controld"
if err := os.MkdirAll(dir, 0750); err != nil {
return "", err
}
return dir, nil
}
func tryReadingConfig(writeDefaultConfig bool) {
configs := []struct {
name string
written bool
}{
// For compatibility, we check for config.toml first, but only read it if exists.
{"config", false},
{"ctrld", writeDefaultConfig},
}
dir, err := userHomeDir()
if err != nil {
mainLog.Fatal().Msgf("failed to get config dir: %v", err)
}
for _, config := range configs {
ctrld.SetConfigNameWithPath(v, config.name, dir)
v.SetConfigFile(configPath)
if readConfigFile(config.written) {
break
}
}
}

View File

@@ -1,100 +0,0 @@
//go:build linux || freebsd
package main
import (
"os"
"os/exec"
"strings"
"github.com/spf13/cobra"
"github.com/Control-D-Inc/ctrld/internal/router"
)
func initRouterCLI() {
validArgs := append(router.SupportedPlatforms(), "auto")
var b strings.Builder
b.WriteString("Auto-setup Control D on a router.\n\nSupported platforms:\n\n")
for _, arg := range validArgs {
b.WriteString(" ₒ ")
b.WriteString(arg)
if arg == "auto" {
b.WriteString(" - detect the platform you are running on")
}
b.WriteString("\n")
}
routerCmd := &cobra.Command{
Use: "setup",
Short: b.String(),
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
},
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
_ = cmd.Help()
return
}
if len(args) != 1 {
_ = cmd.Help()
return
}
platform := args[0]
if platform == "auto" {
platform = router.Name()
}
if !router.IsSupported(platform) {
unsupportedPlatformHelp(cmd)
os.Exit(1)
}
exe, err := os.Executable()
if err != nil {
mainLog.Fatal().Msgf("could not find executable path: %v", err)
os.Exit(1)
}
cmdArgs := []string{"start"}
cmdArgs = append(cmdArgs, osArgs(platform)...)
cmdArgs = append(cmdArgs, "--router")
command := exec.Command(exe, cmdArgs...)
command.Stdout = os.Stdout
command.Stderr = os.Stderr
command.Stdin = os.Stdin
if err := command.Run(); err != nil {
mainLog.Fatal().Msg(err.Error())
}
},
}
// Keep these flags in sync with startCmd, except for "--router".
routerCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
routerCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
routerCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
routerCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
routerCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
routerCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
routerCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
routerCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
routerCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid")
routerCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
_ = routerCmd.Flags().MarkHidden("dev")
routerCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
tmpl := routerCmd.UsageTemplate()
tmpl = strings.Replace(tmpl, "{{.UseLine}}", "{{.UseLine}} [platform]", 1)
routerCmd.SetUsageTemplate(tmpl)
rootCmd.AddCommand(routerCmd)
}
func osArgs(platform string) []string {
args := os.Args[2:]
n := 0
for _, x := range args {
if x != platform && x != "auto" {
args[n] = x
n++
}
}
return args[:n]
}

View File

@@ -1,5 +0,0 @@
//go:build !linux && !freebsd
package main
func initRouterCLI() {}

View File

@@ -1,23 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_writeConfigFile(t *testing.T) {
tmpdir := t.TempDir()
// simulate --config CLI flag by setting configPath manually.
configPath = filepath.Join(tmpdir, "ctrld.toml")
_, err := os.Stat(configPath)
assert.True(t, os.IsNotExist(err))
assert.NoError(t, writeConfigFile())
_, err = os.Stat(configPath)
require.NoError(t, err)
}

View File

@@ -1,519 +0,0 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"golang.org/x/sync/errgroup"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
"github.com/Control-D-Inc/ctrld/internal/router"
)
const (
staleTTL = 60 * time.Second
// EDNS0_OPTION_MAC is dnsmasq EDNS0 code for adding mac option.
// https://thekelleys.org.uk/gitweb/?p=dnsmasq.git;a=blob;f=src/dns-protocol.h;h=76ac66a8c28317e9c121a74ab5fd0e20f6237dc8;hb=HEAD#l81
// This is also dns.EDNS0LOCALSTART, but define our own constant here for clarification.
EDNS0_OPTION_MAC = 0xFDE9
)
var osUpstreamConfig = &ctrld.UpstreamConfig{
Name: "OS resolver",
Type: ctrld.ResolverTypeOS,
Timeout: 2000,
}
func (p *prog) serveDNS(listenerNum string) error {
listenerConfig := p.cfg.Listener[listenerNum]
// make sure ip is allocated
if allocErr := p.allocateIP(listenerConfig.IP); allocErr != nil {
mainLog.Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip")
return allocErr
}
var failoverRcodes []int
if listenerConfig.Policy != nil {
failoverRcodes = listenerConfig.Policy.FailoverRcodeNumbers
}
handler := dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) {
q := m.Question[0]
domain := canonicalName(q.Name)
reqId := requestID()
remoteAddr := spoofRemoteAddr(w.RemoteAddr(), router.GetClientInfoByMac(macFromMsg(m)))
fmtSrcToDest := fmtRemoteToLocal(listenerNum, remoteAddr.String(), w.LocalAddr().String())
t := time.Now()
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
ctrld.Log(ctx, mainLog.Debug(), "%s received query: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain)
upstreams, matched := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, domain)
var answer *dns.Msg
if !matched && listenerConfig.Restricted {
answer = new(dns.Msg)
answer.SetRcode(m, dns.RcodeRefused)
} else {
answer = p.proxy(ctx, upstreams, failoverRcodes, m)
rtt := time.Since(t)
ctrld.Log(ctx, mainLog.Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
}
if err := w.WriteMsg(answer); err != nil {
ctrld.Log(ctx, mainLog.Error().Err(err), "serveUDP: failed to send DNS response to client")
}
})
g, ctx := errgroup.WithContext(context.Background())
for _, proto := range []string{"udp", "tcp"} {
proto := proto
if needLocalIPv6Listener() {
g.Go(func() error {
s, errCh := runDNSServer(net.JoinHostPort("::1", strconv.Itoa(listenerConfig.Port)), proto, handler)
defer s.Shutdown()
select {
case <-ctx.Done():
case err := <-errCh:
// Local ipv6 listener should not terminate ctrld.
// It's a workaround for a quirk on Windows.
mainLog.Warn().Err(err).Msg("local ipv6 listener failed")
}
return nil
})
}
g.Go(func() error {
s, errCh := runDNSServer(dnsListenAddress(listenerNum, listenerConfig), proto, handler)
defer s.Shutdown()
if listenerConfig.Port == 0 {
switch s.Net {
case "udp":
mainLog.Info().Msgf("Random port chosen for udp listener.%s: %s", listenerNum, s.PacketConn.LocalAddr())
case "tcp":
mainLog.Info().Msgf("Random port chosen for tcp listener.%s: %s", listenerNum, s.Listener.Addr())
}
}
select {
case <-ctx.Done():
return nil
case err := <-errCh:
return err
}
})
}
return g.Wait()
}
// upstreamFor returns the list of upstreams for resolving the given domain,
// matching by policies defined in the listener config. The second return value
// reports whether the domain matches the policy.
//
// Though domain policy has higher priority than network policy, it is still
// processed later, because policy logging want to know whether a network rule
// is disregarded in favor of the domain level rule.
func (p *prog) upstreamFor(ctx context.Context, defaultUpstreamNum string, lc *ctrld.ListenerConfig, addr net.Addr, domain string) ([]string, bool) {
upstreams := []string{"upstream." + defaultUpstreamNum}
matchedPolicy := "no policy"
matchedNetwork := "no network"
matchedRule := "no rule"
matched := false
defer func() {
if !matched && lc.Restricted {
ctrld.Log(ctx, mainLog.Info(), "query refused, %s does not match any network policy", addr.String())
return
}
ctrld.Log(ctx, mainLog.Info(), "%s, %s, %s -> %v", matchedPolicy, matchedNetwork, matchedRule, upstreams)
}()
if lc.Policy == nil {
return upstreams, false
}
do := func(policyUpstreams []string) {
upstreams = append([]string(nil), policyUpstreams...)
}
var networkTargets []string
var sourceIP net.IP
switch addr := addr.(type) {
case *net.UDPAddr:
sourceIP = addr.IP
case *net.TCPAddr:
sourceIP = addr.IP
}
networkRules:
for _, rule := range lc.Policy.Networks {
for source, targets := range rule {
networkNum := strings.TrimPrefix(source, "network.")
nc := p.cfg.Network[networkNum]
if nc == nil {
continue
}
for _, ipNet := range nc.IPNets {
if ipNet.Contains(sourceIP) {
matchedPolicy = lc.Policy.Name
matchedNetwork = source
networkTargets = targets
matched = true
break networkRules
}
}
}
}
for _, rule := range lc.Policy.Rules {
// There's only one entry per rule, config validation ensures this.
for source, targets := range rule {
if source == domain || wildcardMatches(source, domain) {
matchedPolicy = lc.Policy.Name
if len(networkTargets) > 0 {
matchedNetwork += " (unenforced)"
}
matchedRule = source
do(targets)
matched = true
return upstreams, matched
}
}
}
if matched {
do(networkTargets)
}
return upstreams, matched
}
func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []int, msg *dns.Msg) *dns.Msg {
var staleAnswer *dns.Msg
serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale
upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams)
if len(upstreamConfigs) == 0 {
upstreamConfigs = []*ctrld.UpstreamConfig{osUpstreamConfig}
upstreams = []string{"upstream.os"}
}
// Inverse query should not be cached: https://www.rfc-editor.org/rfc/rfc1035#section-7.4
if p.cache != nil && msg.Question[0].Qtype != dns.TypePTR {
for _, upstream := range upstreams {
cachedValue := p.cache.Get(dnscache.NewKey(msg, upstream))
if cachedValue == nil {
continue
}
answer := cachedValue.Msg.Copy()
answer.SetRcode(msg, answer.Rcode)
now := time.Now()
if cachedValue.Expire.After(now) {
ctrld.Log(ctx, mainLog.Debug(), "hit cached response")
setCachedAnswerTTL(answer, now, cachedValue.Expire)
return answer
}
staleAnswer = answer
}
}
resolve1 := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) (*dns.Msg, error) {
ctrld.Log(ctx, mainLog.Debug(), "sending query to %s: %s", upstreams[n], upstreamConfig.Name)
dnsResolver, err := ctrld.NewResolver(upstreamConfig)
if err != nil {
ctrld.Log(ctx, mainLog.Error().Err(err), "failed to create resolver")
return nil, err
}
resolveCtx, cancel := context.WithCancel(ctx)
defer cancel()
if upstreamConfig.Timeout > 0 {
timeoutCtx, cancel := context.WithTimeout(resolveCtx, time.Millisecond*time.Duration(upstreamConfig.Timeout))
defer cancel()
resolveCtx = timeoutCtx
}
return dnsResolver.Resolve(resolveCtx, msg)
}
resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg {
if upstreamConfig.UpstreamSendClientInfo() {
ci := router.GetClientInfoByMac(macFromMsg(msg))
if ci != nil {
ctrld.Log(ctx, mainLog.Debug(), "including client info with the request")
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci)
}
}
answer, err := resolve1(n, upstreamConfig, msg)
// Only do re-bootstrapping if bootstrap ip is not explicitly set by user.
if err != nil && upstreamConfig.BootstrapIP == "" {
ctrld.Log(ctx, mainLog.Debug().Err(err), "could not resolve query on first attempt, retrying...")
// If any error occurred, re-bootstrap transport/ip, retry the request.
upstreamConfig.ReBootstrap()
answer, err = resolve1(n, upstreamConfig, msg)
if err == nil {
return answer
}
ctrld.Log(ctx, mainLog.Error().Err(err), "failed to resolve query")
return nil
}
return answer
}
for n, upstreamConfig := range upstreamConfigs {
if upstreamConfig == nil {
continue
}
answer := resolve(n, upstreamConfig, msg)
if answer == nil {
if serveStaleCache && staleAnswer != nil {
ctrld.Log(ctx, mainLog.Debug(), "serving stale cached response")
now := time.Now()
setCachedAnswerTTL(staleAnswer, now, now.Add(staleTTL))
return staleAnswer
}
continue
}
if answer.Rcode != dns.RcodeSuccess && len(upstreamConfigs) > 1 && containRcode(failoverRcodes, answer.Rcode) {
ctrld.Log(ctx, mainLog.Debug(), "failover rcode matched, process to next upstream")
continue
}
// set compression, as it is not set by default when unpacking
answer.Compress = true
if p.cache != nil {
ttl := ttlFromMsg(answer)
now := time.Now()
expired := now.Add(time.Duration(ttl) * time.Second)
if cachedTTL := p.cfg.Service.CacheTTLOverride; cachedTTL > 0 {
expired = now.Add(time.Duration(cachedTTL) * time.Second)
}
setCachedAnswerTTL(answer, now, expired)
p.cache.Add(dnscache.NewKey(msg, upstreams[n]), dnscache.NewValue(answer, expired))
ctrld.Log(ctx, mainLog.Debug(), "add cached response")
}
return answer
}
ctrld.Log(ctx, mainLog.Error(), "all upstreams failed")
answer := new(dns.Msg)
answer.SetRcode(msg, dns.RcodeServerFailure)
return answer
}
func (p *prog) upstreamConfigsFromUpstreamNumbers(upstreams []string) []*ctrld.UpstreamConfig {
upstreamConfigs := make([]*ctrld.UpstreamConfig, 0, len(upstreams))
for _, upstream := range upstreams {
upstreamNum := strings.TrimPrefix(upstream, "upstream.")
upstreamConfigs = append(upstreamConfigs, p.cfg.Upstream[upstreamNum])
}
return upstreamConfigs
}
// canonicalName returns canonical name from FQDN with "." trimmed.
func canonicalName(fqdn string) string {
q := strings.TrimSpace(fqdn)
q = strings.TrimSuffix(q, ".")
// https://datatracker.ietf.org/doc/html/rfc4343
q = strings.ToLower(q)
return q
}
func wildcardMatches(wildcard, domain string) bool {
// Wildcard match.
wildCardParts := strings.Split(wildcard, "*")
if len(wildCardParts) != 2 {
return false
}
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])
case len(wildCardParts[1]) > 0:
// Only suffix must match.
return strings.HasSuffix(domain, wildCardParts[1])
case len(wildCardParts[0]) > 0:
// Only prefix must match.
return strings.HasPrefix(domain, wildCardParts[0])
}
return false
}
func fmtRemoteToLocal(listenerNum, remote, local string) string {
return fmt.Sprintf("%s -> listener.%s: %s:", remote, listenerNum, local)
}
func requestID() string {
b := make([]byte, 3) // 6 chars
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func containRcode(rcodes []int, rcode int) bool {
for i := range rcodes {
if rcodes[i] == rcode {
return true
}
}
return false
}
func setCachedAnswerTTL(answer *dns.Msg, now, expiredTime time.Time) {
ttlSecs := expiredTime.Sub(now).Seconds()
if ttlSecs < 0 {
return
}
ttl := uint32(ttlSecs)
for _, rr := range answer.Answer {
rr.Header().Ttl = ttl
}
for _, rr := range answer.Ns {
rr.Header().Ttl = ttl
}
for _, rr := range answer.Extra {
if rr.Header().Rrtype != dns.TypeOPT {
rr.Header().Ttl = ttl
}
}
}
func ttlFromMsg(msg *dns.Msg) uint32 {
for _, rr := range msg.Answer {
return rr.Header().Ttl
}
for _, rr := range msg.Ns {
return rr.Header().Ttl
}
return 0
}
func needLocalIPv6Listener() bool {
// On Windows, there's no easy way for disabling/removing IPv6 DNS resolver, so we check whether we can
// listen on ::1, then spawn a listener for receiving DNS requests.
return ctrldnet.SupportsIPv6ListenLocal() && runtime.GOOS == "windows"
}
func dnsListenAddress(lcNum string, lc *ctrld.ListenerConfig) string {
if addr := router.ListenAddress(); setupRouter && addr != "" && lcNum == "0" {
return addr
}
return net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))
}
func macFromMsg(msg *dns.Msg) string {
if opt := msg.IsEdns0(); opt != nil {
for _, s := range opt.Option {
switch e := s.(type) {
case *dns.EDNS0_LOCAL:
if e.Code == EDNS0_OPTION_MAC {
return net.HardwareAddr(e.Data).String()
}
}
}
}
return ""
}
func spoofRemoteAddr(addr net.Addr, ci *ctrld.ClientInfo) net.Addr {
if ci != nil && ci.IP != "" {
switch addr := addr.(type) {
case *net.UDPAddr:
udpAddr := &net.UDPAddr{
IP: net.ParseIP(ci.IP),
Port: addr.Port,
Zone: addr.Zone,
}
return udpAddr
case *net.TCPAddr:
udpAddr := &net.TCPAddr{
IP: net.ParseIP(ci.IP),
Port: addr.Port,
Zone: addr.Zone,
}
return udpAddr
}
}
return addr
}
// runDNSServer starts a DNS server for given address and network,
// with the given handler. It ensures the server has started listening.
// Any error will be reported to the caller via returned channel.
//
// It's the caller responsibility to call Shutdown to close the server.
func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-chan error) {
s := &dns.Server{
Addr: addr,
Net: network,
Handler: handler,
}
waitLock := sync.Mutex{}
waitLock.Lock()
s.NotifyStartedFunc = waitLock.Unlock
errCh := make(chan error)
go func() {
defer close(errCh)
if err := s.ListenAndServe(); err != nil {
waitLock.Unlock()
mainLog.Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr)
errCh <- err
}
}()
waitLock.Lock()
return s, errCh
}
// runDNSServerForNTPD starts a DNS server listening on router.ListenAddress(). It must only be called when ctrld
// running on router, before router.PreRun() to serve DNS request for NTP synchronization. The caller must call
// s.Shutdown() explicitly when NTP is synced successfully.
func runDNSServerForNTPD(addr string) (*dns.Server, <-chan error) {
if addr == "" {
return &dns.Server{}, nil
}
dnsResolver := ctrld.NewBootstrapResolver()
s := &dns.Server{
Addr: addr,
Net: "udp",
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) {
mainLog.Debug().Msg("Serving query for ntpd")
resolveCtx, cancel := context.WithCancel(context.Background())
defer cancel()
if osUpstreamConfig.Timeout > 0 {
timeoutCtx, cancel := context.WithTimeout(resolveCtx, time.Millisecond*time.Duration(osUpstreamConfig.Timeout))
defer cancel()
resolveCtx = timeoutCtx
}
answer, err := dnsResolver.Resolve(resolveCtx, m)
if err != nil {
mainLog.Error().Err(err).Msgf("could not resolve: %v", m)
return
}
if err := w.WriteMsg(answer); err != nil {
mainLog.Error().Err(err).Msg("runDNSServerForNTPD: failed to send DNS response")
}
}),
}
waitLock := sync.Mutex{}
waitLock.Lock()
s.NotifyStartedFunc = waitLock.Unlock
errCh := make(chan error)
go func() {
defer close(errCh)
if err := s.ListenAndServe(); err != nil {
waitLock.Unlock()
mainLog.Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr)
errCh <- err
}
}()
waitLock.Lock()
return s, errCh
}

View File

@@ -1,218 +0,0 @@
package main
import (
"context"
"net"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
"github.com/Control-D-Inc/ctrld/testhelper"
)
func Test_wildcardMatches(t *testing.T) {
tests := []struct {
name string
wildcard string
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},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := wildcardMatches(tc.wildcard, tc.domain); got != tc.match {
t.Errorf("unexpected result, wildcard: %s, domain: %s, want: %v, got: %v", tc.wildcard, tc.domain, tc.match, got)
}
})
}
}
func Test_canonicalName(t *testing.T) {
tests := []struct {
name string
domain string
canonical string
}{
{"fqdn to canonical", "windscribe.com.", "windscribe.com"},
{"already canonical", "windscribe.com", "windscribe.com"},
{"case insensitive", "Windscribe.Com.", "windscribe.com"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := canonicalName(tc.domain); got != tc.canonical {
t.Errorf("unexpected result, want: %s, got: %s", tc.canonical, got)
}
})
}
}
func Test_prog_upstreamFor(t *testing.T) {
cfg := testhelper.SampleConfig(t)
prog := &prog{cfg: cfg}
for _, nc := range prog.cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatal(err)
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
tests := []struct {
name string
ip string
defaultUpstreamNum string
lc *ctrld.ListenerConfig
domain string
upstreams []string
matched bool
testLogMsg string
}{
{"Policy map matches", "192.168.0.1:0", "0", prog.cfg.Listener["0"], "abc.xyz", []string{"upstream.1", "upstream.0"}, true, ""},
{"Policy split matches", "192.168.0.1:0", "0", prog.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, ""},
{"Policy map for other network matches", "192.168.1.2:0", "0", prog.cfg.Listener["0"], "abc.xyz", []string{"upstream.0"}, true, ""},
{"No policy map for listener", "192.168.1.2:0", "1", prog.cfg.Listener["1"], "abc.ru", []string{"upstream.1"}, false, ""},
{"unenforced loging", "192.168.1.2:0", "0", prog.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, "My Policy, network.1 (unenforced), *.ru -> [upstream.1]"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for _, network := range []string{"udp", "tcp"} {
var (
addr net.Addr
err error
)
switch network {
case "udp":
addr, err = net.ResolveUDPAddr(network, tc.ip)
case "tcp":
addr, err = net.ResolveTCPAddr(network, tc.ip)
}
require.NoError(t, err)
require.NotNil(t, addr)
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
upstreams, matched := prog.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.domain)
assert.Equal(t, tc.matched, matched)
assert.Equal(t, tc.upstreams, upstreams)
if tc.testLogMsg != "" {
assert.Contains(t, logOutput.String(), tc.testLogMsg)
}
}
})
}
}
func TestCache(t *testing.T) {
cfg := testhelper.SampleConfig(t)
prog := &prog{cfg: cfg}
for _, nc := range prog.cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatal(err)
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
cacher, err := dnscache.NewLRUCache(4096)
require.NoError(t, err)
prog.cache = cacher
msg := new(dns.Msg)
msg.SetQuestion("example.com", dns.TypeA)
msg.MsgHdr.RecursionDesired = true
answer1 := new(dns.Msg)
answer1.SetRcode(msg, dns.RcodeSuccess)
prog.cache.Add(dnscache.NewKey(msg, "upstream.1"), dnscache.NewValue(answer1, time.Now().Add(time.Minute)))
answer2 := new(dns.Msg)
answer2.SetRcode(msg, dns.RcodeRefused)
prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute)))
got1 := prog.proxy(context.Background(), []string{"upstream.1"}, nil, msg)
got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg)
assert.NotSame(t, got1, got2)
assert.Equal(t, answer1.Rcode, got1.Rcode)
assert.Equal(t, answer2.Rcode, got2.Rcode)
}
func Test_macFromMsg(t *testing.T) {
tests := []struct {
name string
mac string
wantMac bool
}{
{"has mac", "4c:20:b8:ab:87:1b", true},
{"no mac", "4c:20:b8:ab:87:1b", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
hw, err := net.ParseMAC(tc.mac)
if err != nil {
t.Fatal(err)
}
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeA)
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
if tc.wantMac {
ec1 := &dns.EDNS0_LOCAL{Code: EDNS0_OPTION_MAC, Data: hw}
o.Option = append(o.Option, ec1)
}
m.Extra = append(m.Extra, o)
got := macFromMsg(m)
if tc.wantMac && got != tc.mac {
t.Errorf("mismatch, want: %q, got: %q", tc.mac, got)
}
if !tc.wantMac && got != "" {
t.Errorf("unexpected mac: %q", got)
}
})
}
}
func Test_remoteAddrFromMsg(t *testing.T) {
loopbackIP := net.ParseIP("127.0.0.1")
tests := []struct {
name string
addr net.Addr
ci *ctrld.ClientInfo
want string
}{
{"tcp", &net.TCPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.10"}, "192.168.1.10:12345"},
{"udp", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.11"}, "192.168.1.11:12345"},
{"nil client info", &net.UDPAddr{IP: loopbackIP, Port: 12345}, nil, "127.0.0.1:12345"},
{"empty ip", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{}, "127.0.0.1:12345"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
addr := spoofRemoteAddr(tc.addr, tc.ci)
if addr.String() != tc.want {
t.Errorf("unexpected result, want: %q, got: %q", tc.want, addr.String())
}
})
}
}

View File

@@ -1,135 +1,13 @@
package main
import (
"io"
"os"
"path/filepath"
"time"
"github.com/kardianos/service"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
var (
configPath string
configBase64 string
daemon bool
listenAddress string
primaryUpstream string
secondaryUpstream string
domains []string
logPath string
homedir string
cacheSize int
cfg ctrld.Config
verbose int
silent bool
cdUID string
cdDev bool
iface string
ifaceStartStop string
setupRouter bool
mainLog = zerolog.New(io.Discard)
consoleWriter zerolog.ConsoleWriter
"github.com/Control-D-Inc/ctrld/cmd/cli"
)
func main() {
ctrld.InitConfig(v, "ctrld")
initCLI()
initRouterCLI()
if err := rootCmd.Execute(); err != nil {
mainLog.Error().Msg(err.Error())
os.Exit(1)
}
}
func normalizeLogFilePath(logFilePath string) string {
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
return logFilePath
}
if homedir != "" {
return filepath.Join(homedir, logFilePath)
}
dir, _ := userHomeDir()
if dir == "" {
return logFilePath
}
return filepath.Join(dir, logFilePath)
}
func initConsoleLogging() {
consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
w.TimeFormat = time.StampMilli
})
multi := zerolog.MultiLevelWriter(consoleWriter)
mainLog = mainLog.Output(multi).With().Timestamp().Logger()
switch {
case silent:
zerolog.SetGlobalLevel(zerolog.NoLevel)
case verbose == 1:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case verbose > 1:
zerolog.SetGlobalLevel(zerolog.DebugLevel)
default:
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
}
}
func initLogging() {
writers := []io.Writer{io.Discard}
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
// Create parent directory if necessary.
if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil {
mainLog.Error().Msgf("failed to create log path: %v", err)
os.Exit(1)
}
// Backup old log file with .1 suffix.
if err := os.Rename(logFilePath, logFilePath+".1"); err != nil && !os.IsNotExist(err) {
mainLog.Error().Msgf("could not backup old log file: %v", err)
}
logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_RDWR, os.FileMode(0o600))
if err != nil {
mainLog.Error().Msgf("failed to create log file: %v", err)
os.Exit(1)
}
writers = append(writers, logFile)
}
writers = append(writers, consoleWriter)
multi := zerolog.MultiLevelWriter(writers...)
mainLog = mainLog.Output(multi).With().Timestamp().Logger()
// TODO: find a better way.
ctrld.ProxyLog = mainLog
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
logLevel := cfg.Service.LogLevel
switch {
case silent:
zerolog.SetGlobalLevel(zerolog.NoLevel)
return
case verbose == 1:
logLevel = "info"
case verbose > 1:
logLevel = "debug"
}
if logLevel == "" {
return
}
level, err := zerolog.ParseLevel(logLevel)
if err != nil {
mainLog.Warn().Err(err).Msg("could not set log level")
return
}
zerolog.SetGlobalLevel(level)
}
func initCache() {
if !cfg.Service.CacheEnable {
return
}
if cfg.Service.CacheSize == 0 {
cfg.Service.CacheSize = 4096
}
cli.Main()
// make sure we exit with 0 if there are no errors
os.Exit(0)
}

View File

@@ -1,44 +0,0 @@
package main
import (
"bufio"
"bytes"
"io"
"net"
"os/exec"
"strings"
)
func patchNetIfaceName(iface *net.Interface) error {
b, err := exec.Command("networksetup", "-listnetworkserviceorder").Output()
if err != nil {
return err
}
if name := networkServiceName(iface.Name, bytes.NewReader(b)); name != "" {
iface.Name = name
mainLog.Debug().Str("network_service", name).Msg("found network service name for interface")
}
return nil
}
func networkServiceName(ifaceName string, r io.Reader) string {
scanner := bufio.NewScanner(r)
prevLine := ""
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "*") {
// Network services is disabled.
continue
}
if !strings.Contains(line, "Device: "+ifaceName) {
prevLine = line
continue
}
parts := strings.SplitN(prevLine, " ", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
return ""
}

View File

@@ -1,7 +0,0 @@
//go:build !darwin
package main
import "net"
func patchNetIfaceName(iface *net.Interface) error { return nil }

View File

@@ -1,27 +0,0 @@
package main
import (
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
func (p *prog) watchLinkState() {
ch := make(chan netlink.LinkUpdate)
done := make(chan struct{})
defer close(done)
if err := netlink.LinkSubscribe(ch, done); err != nil {
mainLog.Warn().Err(err).Msg("could not subscribe link")
return
}
for lu := range ch {
if lu.Change == 0xFFFFFFFF {
continue
}
if lu.Change&unix.IFF_UP != 0 {
mainLog.Debug().Msgf("link state changed, re-bootstrapping")
for _, uc := range p.cfg.Upstream {
uc.ReBootstrap()
}
}
}
}

View File

@@ -1,5 +0,0 @@
//go:build !linux
package main
func (p *prog) watchLinkState() {}

View File

@@ -1,59 +0,0 @@
package main
import (
"net"
"os/exec"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// allocate loopback ip
// sudo ifconfig lo0 alias 127.0.0.2 up
func allocateIP(ip string) error {
cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up")
if err := cmd.Run(); err != nil {
mainLog.Error().Err(err).Msg("allocateIP failed")
return err
}
return nil
}
func deAllocateIP(ip string) error {
cmd := exec.Command("ifconfig", "lo0", "-alias", ip)
if err := cmd.Run(); err != nil {
mainLog.Error().Err(err).Msg("deAllocateIP failed")
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
func setDNS(iface *net.Interface, nameservers []string) error {
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name}
args = append(args, nameservers...)
if err := exec.Command(cmd, args...).Run(); err != nil {
mainLog.Error().Err(err).Msgf("setDNS failed, ips = %q", nameservers)
return err
}
return nil
}
// TODO(cuonglm): use system API
func resetDNS(iface *net.Interface) error {
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name, "empty"}
if err := exec.Command(cmd, args...).Run(); err != nil {
mainLog.Error().Err(err).Msgf("resetDNS failed")
return err
}
return nil
}
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers("")
}

View File

@@ -1,68 +0,0 @@
package main
import (
"net"
"net/netip"
"os/exec"
"github.com/Control-D-Inc/ctrld/internal/dns"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// allocate loopback ip
// sudo ifconfig lo0 127.0.0.53 alias
func allocateIP(ip string) error {
cmd := exec.Command("ifconfig", "lo0", ip, "alias")
if err := cmd.Run(); err != nil {
mainLog.Error().Err(err).Msg("allocateIP failed")
return err
}
return nil
}
func deAllocateIP(ip string) error {
cmd := exec.Command("ifconfig", "lo0", ip, "-alias")
if err := cmd.Run(); err != nil {
mainLog.Error().Err(err).Msg("deAllocateIP failed")
return err
}
return nil
}
// set the dns server for the provided network interface
func setDNS(iface *net.Interface, nameservers []string) error {
r, err := dns.NewOSConfigurator(logf, iface.Name)
if err != nil {
mainLog.Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
ns := make([]netip.Addr, 0, len(nameservers))
for _, nameserver := range nameservers {
ns = append(ns, netip.MustParseAddr(nameserver))
}
if err := r.SetDNS(dns.OSConfig{Nameservers: ns}); err != nil {
mainLog.Error().Err(err).Msg("failed to set DNS")
return err
}
return nil
}
func resetDNS(iface *net.Interface) error {
r, err := dns.NewOSConfigurator(logf, iface.Name)
if err != nil {
mainLog.Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
if err := r.Close(); err != nil {
mainLog.Error().Err(err).Msg("failed to rollback DNS setting")
return err
}
return nil
}
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers("")
}

View File

@@ -1,195 +0,0 @@
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"net"
"net/netip"
"os/exec"
"reflect"
"strings"
"syscall"
"time"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/client6"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/dns"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// allocate loopback ip
// sudo ip a add 127.0.0.2/24 dev lo
func allocateIP(ip string) error {
cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo")
if out, err := cmd.CombinedOutput(); err != nil {
mainLog.Error().Err(err).Msgf("allocateIP failed: %s", string(out))
return err
}
return nil
}
func deAllocateIP(ip string) error {
cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo")
if err := cmd.Run(); err != nil {
mainLog.Error().Err(err).Msg("deAllocateIP failed")
return err
}
return nil
}
const maxSetDNSAttempts = 5
// set the dns server for the provided network interface
func setDNS(iface *net.Interface, nameservers []string) error {
r, err := dns.NewOSConfigurator(logf, iface.Name)
if err != nil {
mainLog.Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
ns := make([]netip.Addr, 0, len(nameservers))
for _, nameserver := range nameservers {
ns = append(ns, netip.MustParseAddr(nameserver))
}
osConfig := dns.OSConfig{
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
for i := 0; i < maxSetDNSAttempts; i++ {
if err := r.SetDNS(osConfig); err != nil {
return err
}
currentNS := currentDNS(iface)
if reflect.DeepEqual(currentNS, nameservers) {
return nil
}
}
mainLog.Debug().Msg("DNS was not set for some reason")
return nil
}
func resetDNS(iface *net.Interface) (err error) {
defer func() {
if err == nil {
return
}
if r, oerr := dns.NewOSConfigurator(logf, iface.Name); oerr == nil {
_ = r.SetDNS(dns.OSConfig{})
if err := r.Close(); err != nil {
mainLog.Error().Err(err).Msg("failed to rollback DNS setting")
return
}
err = nil
}
}()
var ns []string
c, err := nclient4.New(iface.Name)
if err != nil {
return fmt.Errorf("nclient4.New: %w", err)
}
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
lease, err := c.Request(ctx)
if err != nil {
return fmt.Errorf("nclient4.Request: %w", err)
}
for _, nameserver := range lease.ACK.DNS() {
if nameserver.Equal(net.IPv4zero) {
continue
}
ns = append(ns, nameserver.String())
}
// TODO(cuonglm): handle DHCPv6 properly.
if ctrldnet.IPv6Available(ctx) {
c := client6.NewClient()
conversation, err := c.Exchange(iface.Name)
if err != nil {
mainLog.Debug().Err(err).Msg("could not exchange DHCPv6")
}
for _, packet := range conversation {
if packet.Type() == dhcpv6.MessageTypeReply {
msg, err := packet.GetInnerMessage()
if err != nil {
mainLog.Debug().Err(err).Msg("could not get inner DHCPv6 message")
return nil
}
nameservers := msg.Options.DNS()
for _, nameserver := range nameservers {
ns = append(ns, nameserver.String())
}
}
}
}
return ignoringEINTR(func() error {
return setDNS(iface, ns)
})
}
func currentDNS(iface *net.Interface) []string {
for _, fn := range []getDNS{getDNSByResolvectl, getDNSByNmcli, resolvconffile.NameServers} {
if ns := fn(iface.Name); len(ns) > 0 {
return ns
}
}
return nil
}
func getDNSByResolvectl(iface string) []string {
b, err := exec.Command("resolvectl", "dns", "-i", iface).Output()
if err != nil {
return nil
}
parts := strings.Fields(strings.SplitN(string(b), "%", 2)[0])
if len(parts) > 2 {
return parts[3:]
}
return nil
}
func getDNSByNmcli(iface string) []string {
b, err := exec.Command("nmcli", "dev", "show", iface).Output()
if err != nil {
return nil
}
s := bufio.NewScanner(bytes.NewReader(b))
var dns []string
do := func(line string) {
parts := strings.SplitN(line, ":", 2)
if len(parts) > 1 {
dns = append(dns, strings.TrimSpace(parts[1]))
}
}
for s.Scan() {
line := s.Text()
switch {
case strings.HasPrefix(line, "IP4.DNS"):
fallthrough
case strings.HasPrefix(line, "IP6.DNS"):
do(line)
}
}
return dns
}
func ignoringEINTR(fn func() error) error {
for {
err := fn()
if err != syscall.EINTR {
return err
}
}
}

View File

@@ -1,95 +0,0 @@
package main
import (
"errors"
"net"
"os/exec"
"strconv"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
func setDNS(iface *net.Interface, nameservers []string) error {
if len(nameservers) == 0 {
return errors.New("empty DNS nameservers")
}
primaryDNS := nameservers[0]
if err := setPrimaryDNS(iface, primaryDNS); err != nil {
return err
}
if len(nameservers) > 1 {
secondaryDNS := nameservers[1]
_ = addSecondaryDNS(iface, secondaryDNS)
}
return nil
}
// TODO(cuonglm): should we use system API?
func resetDNS(iface *net.Interface) error {
if ctrldnet.SupportsIPv6ListenLocal() {
if output, err := netsh("interface", "ipv6", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp"); err != nil {
mainLog.Warn().Err(err).Msgf("failed to reset ipv6 DNS: %s", string(output))
}
}
output, err := netsh("interface", "ipv4", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp")
if err != nil {
mainLog.Error().Err(err).Msgf("failed to reset ipv4 DNS: %s", string(output))
return err
}
return nil
}
func setPrimaryDNS(iface *net.Interface, dns string) error {
ipVer := "ipv4"
if ctrldnet.IsIPv6(dns) {
ipVer = "ipv6"
}
idx := strconv.Itoa(iface.Index)
output, err := netsh("interface", ipVer, "set", "dnsserver", idx, "static", dns)
if err != nil {
mainLog.Error().Err(err).Msgf("failed to set primary DNS: %s", string(output))
return err
}
if ipVer == "ipv4" && ctrldnet.SupportsIPv6ListenLocal() {
// Disable IPv6 DNS, so the query will be fallback to IPv4.
_, _ = netsh("interface", "ipv6", "set", "dnsserver", idx, "static", "::1", "primary")
}
return nil
}
func addSecondaryDNS(iface *net.Interface, dns string) error {
ipVer := "ipv4"
if ctrldnet.IsIPv6(dns) {
ipVer = "ipv6"
}
output, err := netsh("interface", ipVer, "add", "dns", strconv.Itoa(iface.Index), dns, "index=2")
if err != nil {
mainLog.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 {
mainLog.Error().Err(err).Msg("failed to get interface LUID")
return nil
}
nameservers, err := luid.DNS()
if err != nil {
mainLog.Error().Err(err).Msg("failed to get interface DNS")
return nil
}
ns := make([]string, 0, len(nameservers))
for _, nameserver := range nameservers {
ns = append(ns, nameserver.String())
}
return ns
}

View File

@@ -1,244 +0,0 @@
package main
import (
"errors"
"fmt"
"math/rand"
"net"
"os"
"strconv"
"sync"
"syscall"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
"github.com/Control-D-Inc/ctrld/internal/router"
)
var logf = func(format string, args ...any) {
mainLog.Debug().Msgf(format, args...)
}
var errWindowsAddrInUse = syscall.Errno(0x2740)
var svcConfig = &service.Config{
Name: "ctrld",
DisplayName: "Control-D Helper Service",
Option: service.KeyValue{},
}
type prog struct {
mu sync.Mutex
waitCh chan struct{}
stopCh chan struct{}
cfg *ctrld.Config
cache dnscache.Cacher
}
func (p *prog) Start(s service.Service) error {
p.cfg = &cfg
go p.run()
return nil
}
func (p *prog) run() {
// Wait the caller to signal that we can do our logic.
<-p.waitCh
p.preRun()
if p.cfg.Service.CacheEnable {
cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize)
if err != nil {
mainLog.Error().Err(err).Msg("failed to create cacher, caching is disabled")
} else {
p.cache = cacher
}
}
var wg sync.WaitGroup
wg.Add(len(p.cfg.Listener))
for _, nc := range p.cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
mainLog.Error().Err(err).Str("network", nc.Name).Str("cidr", cidr).Msg("invalid cidr")
continue
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
for n := range p.cfg.Upstream {
uc := p.cfg.Upstream[n]
uc.Init()
if uc.BootstrapIP == "" {
uc.SetupBootstrapIP()
mainLog.Info().Msgf("Bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
} else {
mainLog.Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("Using bootstrap IP for upstream.%s", n)
}
uc.SetCertPool(rootCertPool)
uc.SetupTransport()
}
go p.watchLinkState()
for listenerNum := range p.cfg.Listener {
p.cfg.Listener[listenerNum].Init()
go func(listenerNum string) {
defer wg.Done()
listenerConfig := p.cfg.Listener[listenerNum]
upstreamConfig := p.cfg.Upstream[listenerNum]
if upstreamConfig == nil {
mainLog.Warn().Msgf("no default upstream for: [listener.%s]", listenerNum)
}
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
mainLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, addr)
err := p.serveDNS(listenerNum)
if err != nil && !defaultConfigWritten && cdUID == "" {
mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
return
}
if err == nil {
return
}
if opErr, ok := err.(*net.OpError); ok && listenerNum == "0" {
if sErr, ok := opErr.Err.(*os.SyscallError); ok && errors.Is(opErr.Err, syscall.EADDRINUSE) || errors.Is(sErr.Err, errWindowsAddrInUse) {
mainLog.Warn().Msgf("Address %s already in used, pick a random one", addr)
ip := randomLocalIP()
listenerConfig.IP = ip
port := listenerConfig.Port
cfg.Upstream = map[string]*ctrld.UpstreamConfig{"0": cfg.Upstream["0"]}
if err := writeConfigFile(); err != nil {
mainLog.Fatal().Err(err).Msg("failed to write config file")
} else {
mainLog.Info().Msg("writing config file to: " + defaultConfigFile)
}
p.mu.Lock()
p.cfg.Service.AllocateIP = true
p.mu.Unlock()
p.preRun()
mainLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, net.JoinHostPort(ip, strconv.Itoa(port)))
if err := p.serveDNS(listenerNum); err != nil {
mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
return
}
}
}
mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
}(listenerNum)
}
wg.Wait()
}
func (p *prog) Stop(s service.Service) error {
if err := p.deAllocateIP(); err != nil {
mainLog.Error().Err(err).Msg("de-allocate ip failed")
return err
}
p.preStop()
if err := router.Stop(); err != nil {
mainLog.Warn().Err(err).Msg("problem occurred while stopping router")
}
mainLog.Info().Msg("Service stopped")
close(p.stopCh)
return nil
}
func (p *prog) allocateIP(ip string) error {
p.mu.Lock()
defer p.mu.Unlock()
if !p.cfg.Service.AllocateIP {
return nil
}
return allocateIP(ip)
}
func (p *prog) deAllocateIP() error {
p.mu.Lock()
defer p.mu.Unlock()
if !p.cfg.Service.AllocateIP {
return nil
}
for _, lc := range p.cfg.Listener {
if err := deAllocateIP(lc.IP); err != nil {
return err
}
}
return nil
}
func (p *prog) setDNS() {
switch router.Name() {
case router.DDWrt, router.OpenWrt, router.Ubios:
// On router, ctrld run as a DNS forwarder, it does not have to change system DNS.
// Except for:
// + EdgeOS, which /etc/resolv.conf could be managed by vyatta_update_resolv.pl script.
// + Merlin/Tomato, which has WAN DNS setup on boot for NTP.
// + Synology, which /etc/resolv.conf is not configured to point to localhost.
return
}
if cfg.Listener == nil || cfg.Listener["0"] == nil {
return
}
if iface == "" {
return
}
if iface == "auto" {
iface = defaultIfaceName()
}
logger := mainLog.With().Str("iface", iface).Logger()
netIface, err := netInterface(iface)
if err != nil {
logger.Error().Err(err).Msg("could not get interface")
return
}
if err := setupNetworkManager(); err != nil {
logger.Error().Err(err).Msg("could not patch NetworkManager")
return
}
logger.Debug().Msg("setting DNS for interface")
if err := setDNS(netIface, []string{cfg.Listener["0"].IP}); err != nil {
logger.Error().Err(err).Msgf("could not set DNS for interface")
return
}
logger.Debug().Msg("setting DNS successfully")
}
func (p *prog) resetDNS() {
switch router.Name() {
case router.DDWrt, router.OpenWrt, router.Ubios:
// See comment in p.setDNS method.
return
}
if iface == "" {
return
}
if iface == "auto" {
iface = defaultIfaceName()
}
logger := mainLog.With().Str("iface", iface).Logger()
netIface, err := netInterface(iface)
if err != nil {
logger.Error().Err(err).Msg("could not get interface")
return
}
if err := restoreNetworkManager(); err != nil {
logger.Error().Err(err).Msg("could not restore NetworkManager")
return
}
logger.Debug().Msg("Restoring DNS for interface")
if err := resetDNS(netIface); err != nil {
logger.Error().Err(err).Msgf("could not reset DNS")
return
}
logger.Debug().Msg("Restoring DNS successfully")
}
func randomLocalIP() string {
n := rand.Intn(254-2) + 2
return fmt.Sprintf("127.0.0.%d", n)
}

View File

@@ -1,35 +0,0 @@
package main
import (
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
)
func (p *prog) preRun() {
if !service.Interactive() {
p.setDNS()
}
}
func setDependencies(svc *service.Config) {
svc.Dependencies = []string{
"Wants=network-online.target",
"After=network-online.target",
"Wants=NetworkManager-wait-online.service",
"After=NetworkManager-wait-online.service",
}
// On EdeOS, ctrld needs to start after vyatta-dhcpd, so it can read leases file.
if router.Name() == router.EdgeOS {
svc.Dependencies = append(svc.Dependencies, "Wants=vyatta-dhcpd.service")
svc.Dependencies = append(svc.Dependencies, "After=vyatta-dhcpd.service")
svc.Dependencies = append(svc.Dependencies, "Wants=dnsmasq.service")
svc.Dependencies = append(svc.Dependencies, "After=dnsmasq.service")
}
}
func setWorkingDirectory(svc *service.Config, dir string) {
svc.WorkingDirectory = dir
}
func (p *prog) preStop() {}

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