Compare commits

..

358 Commits

Author SHA1 Message Date
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
Cuong Manh Le
5553490b27 docs: add default value to configs
While at it, also correct some configs to match the latest version.

Fixes #44
2023-06-08 21:54:06 +07:00
Yegor S
eaf39f48a0 Update README.md 2023-06-08 01:48:37 -04:00
Yegor S
a5ddbdcb42 Update README.md 2023-06-08 01:40:13 -04:00
Yegor S
0c99d27be5 Merge pull request #51 from Control-D-Inc/release-branch-v1.2.1
Release branch v1.2.1
2023-06-08 00:19:07 -04:00
Cuong Manh Le
b9eb89c02e internal/router: fix missing Run() call 2023-06-08 02:27:20 +07:00
Cuong Manh Le
53f8d006f0 all: support older version of Openwrt 2023-06-08 02:07:32 +07:00
Cuong Manh Le
929de49c7b cmd/ctrld: only spawn DNS server for ntpd if necessary
On some platforms, like pfsense, ntpd is not problem, so do not spawn
the DNS server for it, which may conflict with default DNS server.

While at it, also make sure that ctrld will be run at last on startup.
2023-06-08 02:07:10 +07:00
Cuong Manh Le
542c4f7daf all: adding more function/type documentation 2023-06-06 00:07:15 +07:00
Cuong Manh Le
c941f9c621 all: add flag to use dev domain for testing 2023-06-06 00:07:05 +07:00
Cuong Manh Le
25eae187db internal/router: do not exit when stopping successfully on freshtomato
Otherwise, "restart" will be broken because "start" won't never be called.
2023-06-03 10:31:08 +07:00
Cuong Manh Le
726a25a7ea internal/router: emit error if dnsfilter is enabled on Ubios/EdgeOS 2023-06-02 22:45:39 +07:00
Cuong Manh Le
a46bb152af cmd/ctrld: do not mutual net.Addr when spoofing client source IP
Otherwise, the original address will be overwritten, causing the
connection between the listener and dnsmasq broken.
2023-06-02 22:43:00 +07:00
Cuong Manh Le
bbfa7c6c22 internal/router: relax dnsmasq lease file parsing condition
On DD-WRT v3.0-r52189, dnsmasq version 2.89 lease format looks like:

1685794060 <mac> <ip> <hostname> 00:00:00:00:00:04 9

It has 6 fields, while the current parser only looks for line with exact
5 fields, which is too restricted. In fact, the parser shold just skip
line with less than 4 fields, because the 4th field is the hostname,
which is the last client info that ctrld needs.
2023-06-02 22:42:47 +07:00
Cuong Manh Le
1cd54a48e9 all: rework routers ntp waiting mechanism
Currently, on routers that require NTP waiting, ctrld makes the cleanup
process, and restart dnsmasq for restoring default DNS config, so ntpd
can query the NTP servers. It did work, but the code will depends on
router platforms.

Instead, we can spawn a plain DNS listener before PreRun on routers,
this listener will serve NTP dns queries and once ntp is configured, the
listener is terminated and ctrld will start serving using its configured
upstreams.

While at it, also fix the userHomeDir function on freshtomato, which
must return the binary directory for routers that requires JFFS.
2023-06-02 20:25:11 +07:00
Cuong Manh Le
2d950eecdf cmd/ctrld: spoofing client IP on routers 2023-06-02 20:24:59 +07:00
Cuong Manh Le
b143e46eb0 all: add support for pfsense 2023-06-02 20:24:42 +07:00
Cuong Manh Le
8fda856e24 all: add UpstreamConfig.VerifyDomain
So the self-check process is only done for ControlD upstream, and can be
distinguished between .com and .dev resolvers.
2023-06-02 20:24:25 +07:00
Cuong Manh Le
54e63ccf9b all: add support for EdgeOS 2023-06-02 20:23:37 +07:00
Cuong Manh Le
ee53db1e35 all: add support for freshtomato 2023-06-02 20:21:17 +07:00
Cuong Manh Le
fc502b920b internal/router: add Synology client info file 2023-06-02 20:21:02 +07:00
Cuong Manh Le
20eae82f11 cmd/ctrld: ensure error passed to backoff is wrapped in self-check
In commit 670879d1, the backoff is changed to be passed a real error,
instead of a place holder. However, the test query may return a failed
response with a nil error, causing the backoff never fire.

Fixing this by ensuring the error is wrapped, so the backoff always see
a non-nil error.
2023-06-02 20:20:47 +07:00
Cuong Manh Le
d2fc530316 all: add support for Synology router 2023-06-02 20:20:31 +07:00
Cuong Manh Le
7ac5555a84 internal/router: fix wrong platform check in PreStart
The NTP workaround is intended to be run on Merlin only.
2023-06-02 20:20:12 +07:00
Cuong Manh Le
15d397d8a6 cmd/ctrld: fix problem with default iface name on WSL 1
On WSL 1, the routing table do not contain default route, causing ctrld
failed to get the default iface for setting DNS. However, WSL 1 only use
/etc/resolv.conf for setting up DNS, so the interface does not matter,
because the setting is applied global anyway.

To fix it, just return "lo" as the default interface name on WSL 1.
While at it, also removing the useless service.Logger call, which is not
unified with the current logger, and may cause false positive on system
where syslog is not configured properly (like WSL 1).

Also passing the real error when doing sel-check to backoff, so we don't
have to use a place holder error.
2023-06-02 20:19:57 +07:00
Cuong Manh Le
b471adfb09 Fix split mode for all protocols but DoH
In split mode, the code must check for ipv6 availability to return the
correct network stack. Otherwise, we may end up using "tcp6-tls" even
though the upstream IP is an ipv4.
2023-06-02 20:19:25 +07:00
Yegor S
d7a38363e6 Merge pull request #42 from Control-D-Inc/update-readme
Update README.md
2023-05-16 15:17:05 -04:00
Yegor Sak
90def8f9b5 Update README.md 2023-05-17 01:59:11 +07:00
Yegor S
b126db453b Update README.md 2023-05-15 21:49:44 -04:00
Yegor S
601d357456 Merge pull request #41 from Control-D-Inc/release-branch-v1.2.0
Release branch v1.2.0
2023-05-15 21:48:05 -04:00
Yegor Sak
3a2024ebd7 Update README.md 2023-05-16 08:39:47 +07:00
Yegor Sak
6cd451acec Update README.md 2023-05-16 00:17:48 +07:00
Cuong Manh Le
3b6c12abd4 all: support GL.iNET router 2023-05-16 00:17:13 +07:00
Cuong Manh Le
d9dfc584e7 internal/router: disable DNSSEC on ddwrt/merlin 2023-05-16 00:16:17 +07:00
Cuong Manh Le
57fa68970a internal/router: fix lint ignore comment 2023-05-15 22:51:33 +07:00
Cuong Manh Le
fa14f1dadf Fix wrong timeout in lookupIP
The assignment is changed wrongly in process of refactoring parallel
dialer for resolving bootstrap IP.

While at it, also satisfy staticheck for jffs not enabled error.
2023-05-15 22:37:47 +07:00
Cuong Manh Le
9689607409 all: wait NTP synced on Merlin
On some Merlin routers, the time is broken when system reboot, and need
to wait for NTP synced to get the correct time. For fetching API in cd
mode successfully, ctrld need to wait until NTP set the time correctly,
otherwise, the certificate validation would complain.
2023-05-15 21:13:23 +07:00
Cuong Manh Le
d75f871541 internal/router: workaround problem with ntp bug on some Merlin routers
On some Merlin routers, due to ntp bug, after rebooing, dnsmasq config
was restored to default without ctrld changes, causing ctrld stop
working. Workaround this problem by catching restart diskmon event,
which is triggered by ntpd_synced, then restart dnsmasq.
2023-05-15 21:13:23 +07:00
Cuong Manh Le
45895067c6 cmd/ctrld: only ignore listener.0 setup when setup router 2023-05-15 21:13:23 +07:00
Cuong Manh Le
521f06dcc1 cmd/ctrld: force 127.0.0.1:53 for listener.0 only 2023-05-15 21:13:23 +07:00
Cuong Manh Le
5b6a3a4c6f internal/router: disable native dot on merlin
While at it, also ensure custom config is ignored when running on
router, because we need to point to 127.0.0.1:53 (dnsmasq listener).
2023-05-15 21:13:23 +07:00
Cuong Manh Le
be497a68de internal/router: skip bad entry in leases file
Seen in UDM Dream Machine.
2023-05-15 21:13:21 +07:00
Cuong Manh Le
c872a3b3f6 cmd/ctrld: add "--silent" to disable log output 2023-05-15 20:54:01 +07:00
Cuong Manh Le
e0ae0f8e7b cmd/ctrld: set default value for ip/port from custom config if missing 2023-05-15 20:54:01 +07:00
Cuong Manh Le
ad4ca32873 cmd/ctrld: factor out code to read config file
So start/run command will use the same code path, prevent mismatch from
reading/searching/writing config file.
2023-05-15 20:54:01 +07:00
Cuong Manh Le
24100c4cbe cmd/ctrld: use Windscribe fork of zerolog
For supporting default log level notice. While at it, also fix a missing
os.Exit call when setup router on non-supported platforms.
2023-05-15 20:54:01 +07:00
Cuong Manh Le
e3a792d50d cmd/ctrld: start listener with no default upstream
We can have more listeners than upstreams.
2023-05-15 20:54:01 +07:00
Cuong Manh Le
440d085c6d cmd/ctrld: unified logging
By using a separate console logging and use it in all places before
reading in logging config.
2023-05-15 20:54:01 +07:00
Cuong Manh Le
270ea9f6ca Do not block when ping upstream
Because the network may not be available at the time ping upstream
happens, so ctrld will stuck there waiting for pinging upstream.
2023-05-15 20:54:01 +07:00
Cuong Manh Le
7a156d7d15 Wait until bootstrap IPs resolved
When bootstrapping, if the network changed, for example, firewall rules
changed during VPN connection, the bootstrap IPs may not be resolved, so
ctrld won't work. Since bootstrap IPs is necessary for ctrld to work
properly, we should wait until we can resolve upstream IP before we can
start serving requests.
2023-05-15 20:54:01 +07:00
Cuong Manh Le
4c45e6cf3d Lock while getting doh/doh3 transport 2023-05-15 20:54:01 +07:00
Cuong Manh Le
704bc27dba Check msg is not nil before access Question field 2023-05-15 20:54:01 +07:00
Cuong Manh Le
b267572b38 all: implement split upstreams
This commit introduces split upstreams feature, allowing to configure
what ip stack that ctrld will use to connect to upstream.
2023-05-15 20:53:59 +07:00
Cuong Manh Le
5cad0d6be1 all: watch link state on Linux using netlink
So we can detect changed to link and trigger re-bootstrap.
2023-05-13 12:24:16 +07:00
Cuong Manh Le
56d8dc865f Use different failover mechanism on Linux
Instead of always doubling the request, first we wrap the request with a
failover timeout, 500ms, which is an average time for a normal request.
If this request failed, trigger re-bootstrapping and retry the request.
2023-05-13 12:18:26 +07:00
Cuong Manh Le
d57c1d6d44 Workaround for DOH broken transport when network changes
When network changes, for example: connect/disconnect VPN, the old
connection will become broken, but still can be re-used for new
requests. That would cause un-necessary delay for ctrld clients:

 - Time 0   - do request with broken transport, 5s timeout.
 - Time 0.5 - network stack become usable.
 - Time 5   - timeout reached.
 - Time 5.1 - do request with new transport -> success.

Instead, we can do two requests in parallel, with the failover one using
a fresh new transport. So if the main one is broken, we still can get
the result from the failover one.
2023-05-13 12:18:01 +07:00
Cuong Manh Le
02fa7fbe2e Workaround issue with weird DNS server when bootstraping
We see in practice on fresh new VM test, there's a DNS server that
return the answer with record not for the query domain.

To workaround this, filter out the answers not for the query domain.
2023-05-13 12:17:49 +07:00
Cuong Manh Le
07689954bf cmd/ctrld: change default log level to warn 2023-05-13 12:17:02 +07:00
Cuong Manh Le
a7ea20b117 cmd/ctrld: ensure runDNSServer returns when error happens 2023-05-13 12:07:52 +07:00
Cuong Manh Le
43fecdf60f all: log when client info included in the request 2023-05-13 12:07:32 +07:00
Cuong Manh Le
31239684c7 Revert "cmd/ctrld: add "start --no-cd" flag to disable cd mode"
This reverts commit 00fe7f59d13774f2ea6c325bdbb8165be58a1edd.

The purpose is disable cd mode for already installed service, which is
a hard problem than we thought. So leave it out of v1.2 cycle.
2023-05-13 12:07:20 +07:00
Cuong Manh Le
5528ac8bf1 internal/router: log invalid ip address entry 2023-05-13 12:06:26 +07:00
Cuong Manh Le
411e23ecfe cmd/ctrld: fix missing content for default config
When writing default config file, the content must be marshalled to the
config object first before writing to disk.

While at it, also use full path for default config file to make it clear
to the user where the config is written.
2023-05-13 12:06:11 +07:00
Cuong Manh Le
7bf231643b internal/router: normalize ip address from dnsmasq lease file
dnsmasq may put an ip address with the interface index in lease file,
causing bad data sent to the Control-D backend.
2023-05-13 12:05:49 +07:00
Cuong Manh Le
2326160f2f Do not rely on unspecified assignment order of return statement
See: https://github.com/golang/go/issues/58233
2023-05-13 12:05:33 +07:00
Cuong Manh Le
68fe7e8406 cmd/ctrld: add "start --no-cd" flag to disable cd mode 2023-05-13 12:05:18 +07:00
Cuong Manh Le
c7bad63869 all: allow chosing random address and port for listener 2023-05-13 12:04:58 +07:00
Cuong Manh Le
69319c6b41 all: support custom config from Control-D resolver 2023-05-13 12:04:39 +07:00
Cuong Manh Le
9df381d3d1 all: add "version" query param when fetching config 2023-05-13 12:04:21 +07:00
Cuong Manh Le
0af7f64bca all: use parallel dialer for bootstrapping ip
So we don't have to depend on network probing for checking ipv4/ipv6
enabled, making ctrld working more stably.
2023-05-13 12:04:06 +07:00
Cuong Manh Le
f73cbde7a5 Update HTTP request headers 2023-05-13 12:03:51 +07:00
Cuong Manh Le
0645a738ad all: add router client info detection
This commit add the ability for ctrld to gather client information,
including mac/ip/hostname, and send to Control-D server through a
config per upstream.

 - Add send_client_info upstream config.
 - Read/Watch dnsmasq leases files on supported platforms.
 - Add corresponding client info to DoH query header

All of these only apply for Control-D upstream, though.
2023-05-13 12:03:24 +07:00
Cuong Manh Le
d52cd11322 all: use parallel dialer for connecting upstream/api
So we don't have to depend on network stack probing to decide whether
ipv4 or ipv6 will be used.

While at it, also prevent a race report when doing the same parallel
resolving for os resolver, even though this race is harmless.
2023-05-13 12:02:18 +07:00
Cuong Manh Le
d3d08022cc cmd/ctrld: restoring DNS on darwin before stop
Otherwise, we experiment with ctrld slow start after rebooting, because
the network check continuously report failed status even the network
state is up. Restoring the DNS before stopping, we leave the network
state as default, as long as ctrld starts, the DNS is configured again.
2023-05-13 12:00:33 +07:00
Cuong Manh Le
21c8b9f8e7 Revert ignoring SIGCHLD
Using signal.Ignore causes exec.Command failed with no child process
error.
2023-05-13 12:00:13 +07:00
Cuong Manh Le
6c55d8f139 internal/router: remove ctrld-boot service when uninstall 2023-05-13 11:59:55 +07:00
Cuong Manh Le
ccdb2a3f70 Tweak log message for policy logging 2023-05-13 11:59:33 +07:00
Cuong Manh Le
f5ef9b917e all: implement router setup for ubios 2023-05-13 11:59:14 +07:00
Cuong Manh Le
a5443d5ca4 all: implement router setup for merlin 2023-05-13 11:58:56 +07:00
Cuong Manh Le
2c7d95bba2 Support query param in upstream value 2023-05-13 11:58:31 +07:00
Cuong Manh Le
8a2cdbfaa3 all: implement router setup for ddwrt 2023-05-13 11:58:02 +07:00
Cuong Manh Le
c94be0df35 all: implement router setup for openwrt 2023-05-13 11:53:48 +07:00
Cuong Manh Le
4b6a976747 all: initial support for setup linux router
Wiring code to configure router when running ctrld. Future commits will
add implementation for each supported platforms.
2023-05-13 11:51:29 +07:00
alexelisenko
0043fdf859 enable compression 2023-05-13 11:18:57 +07:00
Cuong Manh Le
24e62e18fa Use errors.Join instead of copied version 2023-05-13 11:13:00 +07:00
Yegor S
663dbbb476 Merge pull request #39 from Control-D-Inc/timeout-no-config-mode
cmd/ctrld: add default timeout when generating config in no config mode
2023-04-05 16:17:03 -04:00
Cuong Manh Le
471427a439 cmd/ctrld: add default timeout when generating config in no config mode 2023-04-06 00:57:07 +07:00
Yegor S
a777c4b00f Merge pull request #38 from Control-D-Inc/issue-33
Add support for mipsle
2023-04-04 11:15:55 -04:00
Cuong Manh Le
dcc4cdd316 Add support for mipsle
While at it, also add 386 and arm to quic free build

Fixes #33
2023-04-04 21:55:04 +07:00
Yegor S
9c22701940 Merge pull request #37 from Control-D-Inc/release-branch-v1.1.4
Release branch v1.1.4
2023-04-03 12:44:02 -04:00
Cuong Manh Le
a77a924320 Require go1.20 for building ctrld 2023-03-31 23:31:38 +07:00
Cuong Manh Le
95dbf71939 Upgrage tailscale.com for fixing security issue 2023-03-31 23:31:38 +07:00
Cuong Manh Le
8869e33a20 Inject version and commit during goreleaser build 2023-03-31 23:31:38 +07:00
Cuong Manh Le
c94e1b02d2 all: supports multiple protocols for no config mode
Updates #78
2023-03-31 23:31:38 +07:00
Cuong Manh Le
42d29b626b Adding more source for getting available DNS
On some platforms, the gateway may not be a usable DNS. So extending the
current approach to allow retrieving DNS from many sources.
2023-03-31 12:37:37 +07:00
Cuong Manh Le
b65a5ac283 all: fix bug that causes ctrld stop working if bootstrap failed
The bootstrap process has two issues that can make ctrld stop resolving
after restarting machine host.

ctrld uses bootstrap DNS and os nameservers for resolving upstream. On
unix, /etc/resolv.conf content is used to get available nameservers.
This works well when installing ctrld. However, after being installed,
ctrld may modify the content of /etc/resolv.conf itself, to make other
apps use its listener as DNS resolver. So when ctrld starts after OS
restart, it ends up using [bootstrap DNS + ctrld's listener], for
resolving upstream. At this moment, if ctrld could not contact bootstrap
DNS for any reason, upstream domain will not be resolved.

For above reason, an upstream may not have bootstrap IPs after ctrld
starts. When re-bootstrapping, if there's no bootstrap IPs, ctrld should
call the setup bootstrap process again. Currently, it does not, causing
all queries failed.

This commit fixes above issue by adding mechanism for retrieving OS
nameservers properly, by querying routing table information:

 - Parsing /proc/net subsystem on Linux.
 - For BSD variants, just fetching routing information base from OS.
 - On Windows, just include the gateway information when reading iface.

The fixing for second issue is trivial, just kickoff a bootstrap process
if there's no bootstrap IPs when re-boostrapping.

While at it, also ensure that fetching resolver information from
ControlD API is also used the same approach.

Fixes #34
2023-03-31 10:23:05 +07:00
Cuong Manh Le
ba48ff5965 all: fix os resolver hangs when all server failed
For os resolver, ctrld queries against all servers concurrently, and get
the first success result back. However, if all server failed, the result
channel is not closed, causing ctrld hang.

Fixing this by closing the result channel once getting back all response
from servers.

While at it, also shorten the backoff time when waiting for network up,
ctrld should serve as fast as possible after network is available.

Updates #34
2023-03-31 10:18:14 +07:00
Cuong Manh Le
b3a342bc44 all: some improvements for better troubleshooting
- Include version/OS information when logging
 - Make time field human readable in log file
 - Force root privilege when running status command on darwin

Updates #34
2023-03-31 10:17:42 +07:00
Cuong Manh Le
9927803497 cmd/ctrld: response to OS service manager earlier
When startup, ctrld waits for network up before calling s.Run to starts
its logic. However, if network is down on startup, ctrld will hang on
waiting for network up. That causes OS service manager unhappy, as ctrld
do not response to it, marking ctrld as failure service and never start
ctrld again.

To fix this, we should call s.Run as soon as possible, and use a channel
for waiting a signal that we can actual do our logic after network up.

Update #34
2023-03-31 10:14:46 +07:00
Cuong Manh Le
f0c604a9f1 cmd/ctrld: only watch config when doing self-check
Avoiding reading/writing global config, causing a data race. While at
it, also guarding read/write access to cfg.Service.AllocateIP field,
since when it is read/write by multiple goroutines.
2023-03-31 10:12:01 +07:00
Cuong Manh Le
8a56389396 cmd/ctrld: ensure both udp/tcp listener aborted
So either one of them return an error, the other will be terminated.
2023-03-31 10:11:12 +07:00
Yegor S
9f7bfc76db Merge pull request #31 from Control-D-Inc/release-branch-v1.1.3
Release branch v1.1.3
2023-03-17 12:33:32 -04:00
Cuong Manh Le
a7a5501ea5 Bump version to v1.1.3 2023-03-17 22:22:54 +07:00
Cuong Manh Le
c401c4ef87 cmd/ctrld: do not set default iface value for uninstall command
Fixed #30
2023-03-17 22:21:57 +07:00
Cuong Manh Le
8ffb42962a Use rcode string in error message
So it's clearer what went wrong.
2023-03-17 22:21:39 +07:00
Cuong Manh Le
aad04200cb Merge pull request #28 from Control-D-Inc/release-branch-v1.1.2
Release branch v1.1.2
2023-03-16 22:35:09 +07:00
Cuong Manh Le
4bfcacaf3c cmd/ctrld: bump version to v1.1.2 2023-03-16 10:53:33 +07:00
Cuong Manh Le
5b362412be Add quic free version to goreleaser 2023-03-16 10:40:17 +07:00
Cuong Manh Le
ccf07a7d1c cmd/ctrld: log that ctrld is starting 2023-03-16 09:53:08 +07:00
Cuong Manh Le
e4eb3b2ded Do not query ipv6 eagerly when setup bootstrap IP
We only need on demand information when re-bootstrapping. On Bootsrap,
this is already checked by ctrldnet.Up, so on demand query will cause
un-necessary slow down if external ipv6 is slow to response.
2023-03-16 09:52:57 +07:00
Cuong Manh Le
77b62f8734 cmd/ctrld: add default timeout for os resolver
So it can fail fast if internet broken suddenly. While at it, also
filtering out ipv6 nameservers if ipv6 not available.
2023-03-16 09:52:39 +07:00
Cuong Manh Le
096e7ea429 internal/net: enforce timeout for probing stack
On Windows host with StarLink network, ctrld hangs on startup for ~30s
before continue running. This dues to IPv6 is configured but no external
IPv6 can be reached. When probing stack, ctrld is dialing using ipv6
without any timeout set, so the dialing timeout is enforced by OS.

This commit adds a timeout for probing dialer, so we ensure the probing
process will fail fast.
2023-03-16 09:52:22 +07:00
Cuong Manh Le
3e6f6cc721 cmd/ctrld: add TCP listener
Fixes #25
2023-03-16 09:51:33 +07:00
Yegor S
7dab688252 Merge pull request #26 from Control-D-Inc/release-branch-v1.1.1
Release branch v1.1.1
2023-03-10 13:04:08 -05:00
Cuong Manh Le
7cd1f7adda cmd/ctrld: bump version to v1.1.1 2023-03-10 23:20:31 +07:00
Cuong Manh Le
9a249c3029 .github/workflows: use go 1.20 2023-03-10 23:20:31 +07:00
Cuong Manh Le
0dfa377e08 Add freebsd to goreleaser config
While at it, fixed the hook upx script to run per file, and ignore
binaries which are not supported.
2023-03-10 23:20:31 +07:00
Cuong Manh Le
14bc29751f Use both os and bootstrap DNS to resolve bootstrap IP 2023-03-10 23:20:22 +07:00
Cuong Manh Le
e6800fbc82 Query all possible nameservers for os resolver
So we don't have to worry about network stack changes causes an upstream
to be broken. Just send requests to all nameservers concurrently, and
get the first success response.
2023-03-10 09:25:48 +07:00
Cuong Manh Le
4f6c2032a1 cmd/ctrld: log reason if first query failed 2023-03-10 09:25:42 +07:00
Cuong Manh Le
d1589bd9d6 Use separate context when querying upstream ips
While at it, also include query type in log, and only honor upstream
timeout when it greater than zero.
2023-03-10 09:25:35 +07:00
Cuong Manh Le
85c95a6a3a all: set timeout for re-bootstrapping 2023-03-10 09:25:29 +07:00
Cuong Manh Le
fa50cd4df4 all: another rework on discovering bootstrap IPs
Instead of re-query DNS record for upstream when re-bootstrapping, just
query all records on startup, then selecting the next bootstrap ip
depends on the current network stack.
2023-03-10 09:25:17 +07:00
Cuong Manh Le
018f6651c1 Fix wrong time precision in bootstrapping timeout
The timeout is in millisecond, not second.
2023-03-08 10:19:49 +07:00
Cuong Manh Le
1a40767cb7 Use upstream timeout when querying bootstrap IP 2023-03-08 10:16:56 +07:00
Cuong Manh Le
12512a60da Always use first record from DNS response 2023-03-07 10:45:29 +07:00
Cuong Manh Le
b0114dfaeb cmd/ctrld: make staticcheck happy 2023-03-07 10:28:49 +07:00
Cuong Manh Le
fb20d443c1 all: retry the request more agressively
For better recovery and dealing with network stack changes, this commit
change the request flow to:

failure of any kind -> recreate transport/re-bootstrap -> retry once

That would make ctrld recover from all scenarios in theory.
2023-03-07 10:25:48 +07:00
Cuong Manh Le
262dcb1dff cmd/ctrld: check for ipv6 listen local
Since when the machine may not have external ipv6 capability, but still
can do ipv6 network on local network.
2023-03-07 10:25:48 +07:00
Cuong Manh Le
8b08cc8a6e all: rework bootstrap IP discovering
At startup, ctrld gathers bootstrap IP information and use this
bootstrap IP for connecting to upstream. However, in case the network
stack changed, for example, dues to VPN connection, ctrld will still use
this old (maybe invalid) bootstrap IP for the current network stack.

This commit rework the discovering process, and re-initializing the
bootstrap IP if connecting to upstream failed.
2023-03-07 10:25:48 +07:00
Cuong Manh Le
930a5ad439 cmd/ctrld: only set ::1 as DNS server on Windows if ipv6 available 2023-03-07 10:25:48 +07:00
Cuong Manh Le
8852f60ccb Add idle conn timeout for HTTP transport
Allowing the connection to be re-new once it becomes un-usable.
2023-03-07 10:25:48 +07:00
Cuong Manh Le
2e1b3f9d07 Upgrade golang.org/x/net to v0.7.0
For pulling CVE-2022-41723 fix.
2023-03-07 10:25:48 +07:00
Cuong Manh Le
6d3c82d38d internal/dns: add debian/openresolv to linux manager 2023-02-27 21:50:06 +07:00
Cuong Manh Le
cad71997aa cmd/ctrld: allocate new ip instead of port
So the alternative listener address can still be used as system
resolver.
2023-02-27 20:50:01 +07:00
Cuong Manh Le
82900eeca6 cmd/ctrld: move log file if existed on app start
Updates #59
2023-02-27 20:43:56 +07:00
Cuong Manh Le
84fca06c62 cmd/ctrld: implement allocate/deallocate ip on freebsd
Updates #56
2023-02-27 20:43:56 +07:00
Cuong Manh Le
64f2dcb25b Fix parsing network service name on darwin
The network service name appears on the previous line, not the same line
with "Device" name.

Updates #57
2023-02-27 20:43:56 +07:00
Cuong Manh Le
4c2d21a8f8 all: add freebsd supports
This commit add support for ctrld to run on freebsd, supported platforms
are amd64/arm64/armv6/armv7,386.

Supporting freebsd also requires adding debian and openresolv resolvconf.

Updates #47
2023-02-27 20:43:56 +07:00
Cuong Manh Le
4172fc09d0 cmd/ctrld: add self check for better error message reported
After telling service manager to start ctrld, performing self check
status by sending DNS query to ctrld listener. So if ctrld could not
start for any reason, an error message will be reported to user instead
of simply telling service started.

Updates #56
2023-02-27 20:43:55 +07:00
Cuong Manh Le
d9b699501d cmd/ctrld: merge proxy log to main log
There's no reason to separate those two loggers anymore, and making them
separated may lead to inconsistent logging behavior.

Updates #54
2023-02-27 20:13:44 +07:00
Cuong Manh Le
71b1b324db cmd/ctrld: honor configPath when writing config file
Updates #58
2023-02-27 20:13:44 +07:00
Cuong Manh Le
35c890048b cmd/ctrld: remove prefix main field
While at it, also make init logging with empty log path when running
start command.

Updates #55
2023-02-27 20:13:44 +07:00
Cuong Manh Le
bac6810956 cmd/ctrld: fix missing unmarshalling config without --cd
Otherwise, DNS won't be set in non-Linux systems.

Updates #54
2023-02-27 20:13:44 +07:00
Cuong Manh Le
997ec342e0 cmd/ctrld,internal/dns: support systemd-networkd dbus
For interface managed by systemd-networkd, systemd-resolved can not
reset DNS. To fix this, attempting to check before the run loop and set
the suitable manager for the system.

Updates #55
2023-02-27 20:13:44 +07:00
Cuong Manh Le
e385547461 internal/net: fix wrong address when testing network up 2023-02-27 20:13:44 +07:00
Cuong Manh Le
83b551fb2d internal/controld: check if ipv4 is available before connect to API
Updates #53
2023-02-27 20:13:42 +07:00
Cuong Manh Le
45f827a2c5 internal/controld: connect to API using ipv4 only
Connecting to API using ipv6 sometimes hang at TLS handshake, using ipv4
only so we can fetch the config more reliably.

Fixed #53
2023-02-27 19:54:52 +07:00
Cuong Manh Le
3218b5fac1 Add quic-free binaries in build pipeline
Updates #51
2023-02-27 19:54:18 +07:00
Cuong Manh Le
df514d15a5 Update quic-go to v0.32.0
Updates #51
2023-02-27 19:51:39 +07:00
Cuong Manh Le
50b0e5a4b0 cmd/ctrld: use proper exit codes for status command
While at it, disable sort commands, so help output will be in order.

Updates #48
2023-02-27 19:50:28 +07:00
Yegor S
6428ac23a0 Merge pull request #20 from Control-D-Inc/upx
Add upx to goreleaser builds
2023-02-14 14:09:13 -05:00
Yegor S
790cb773e2 Merge pull request #17 from Control-D-Inc/readme-badge
Add some badges to README.md
2023-02-13 11:52:36 -05:00
Yegor S
9dab097268 Merge pull request #10 from GiddyGoatGaming/patch-1
ci.yml: bump checkout -> v3 and setup-go-faster -> 1.8.0
2023-02-13 11:51:06 -05:00
Cuong Manh Le
f13f61592c Add upx to goreleaser builds
Reducing the size of the final binaries, except for darwin, where the
packed binaries failed to run.
2023-02-13 21:58:45 +07:00
Cuong Manh Le
2f42fc055d Add some badges to README.md 2023-02-08 00:53:16 +07:00
Spencer Comfort
1cdce73070 Update ci.yml 2023-01-16 17:23:59 -05:00
Spencer Comfort
5f9ac5889b ci.yml: bump checkout -> 3.3.0 and setup-go-faster -> 1.8.0 2023-01-15 21:56:56 -05:00
158 changed files with 19377 additions and 2571 deletions

2
.dockerignore Normal file
View File

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

View File

@@ -9,18 +9,18 @@ jobs:
fail-fast: false
matrix:
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
go: ["1.19.x"]
go: ["1.20.x"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v3
with:
fetch-depth: 1
- uses: WillAbides/setup-go-faster@v1.7.0
- uses: WillAbides/setup-go-faster@v1.8.0
with:
go-version: ${{ matrix.go }}
- run: "go test -race ./..."
- uses: dominikh/staticcheck-action@v1.2.0
with:
version: "2022.1.1"
version: "2023.1.2"
install-go: false
cache-key: ${{ matrix.go }}

4
.gitignore vendored
View File

@@ -1,3 +1,5 @@
dist/
gon.hcl
/Build
.DS_Store

View File

@@ -9,6 +9,8 @@ builds:
- -trimpath
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
goos:
- darwin
goarch:

44
.goreleaser-qf.yaml Normal file
View File

@@ -0,0 +1,44 @@
before:
hooks:
- go mod tidy
builds:
- id: ctrld
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
goos:
- darwin
- linux
- windows
goarch:
- 386
- arm
- amd64
- arm64
tags:
- qf
main: ./cmd/ctrld
hooks:
post: /bin/sh ./scripts/upx.sh {{ .Path }}
archives:
- format_overrides:
- goos: windows
format: zip
strip_parent_binary_folder: true
wrap_in_directory: true
files:
- README.md
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'

View File

@@ -9,13 +9,17 @@ builds:
- -trimpath
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
goos:
- linux
- freebsd
- windows
goarch:
- 386
- arm
- mips
- mipsle
- amd64
- arm64
goarm:
@@ -25,6 +29,12 @@ builds:
gomips:
- softfloat
main: ./cmd/ctrld
hooks:
post: /bin/sh ./scripts/upx.sh {{ .Path }}
ignore:
- goos: freebsd
goarch: arm
goarm: 5
archives:
- format_overrides:
- goos: windows

130
README.md
View File

@@ -1,9 +1,19 @@
# ctrld
![Test](https://github.com/Control-D-Inc/ctrld/actions/workflows/ci.yml/badge.svg)
[![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)
A highly configurable DNS forwarding proxy with support for:
- Multiple listeners for incoming queries
- Multiple upstreams with fallbacks
- Multiple network policy driven DNS query steering
- Policy driven domain based "split horizon" DNS with wildcard support
- Integrations with common router vendors and firmware
- LAN client discovery via DHCP, mDNS, and ARP
## TLDR
Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways.
All DNS protocols are supported, including:
- `UDP 53`
@@ -12,23 +22,46 @@ All DNS protocols are supported, including:
- `DNS-over-HTTP/3` (DOH3)
- `DNS-over-QUIC`
## Use Cases
# Use Cases
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy routers, legacy OSes, TVs, smart toasters).
2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B.
3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream.
5. Deploy on a router and create LAN client specific DNS routing policies from a web GUI (When using ControlD.com).
## OS Support
- Windows (386, amd64, arm)
- Mac (amd64, arm64)
- Linux (386, amd64, arm, mips)
- FreeBSD
- Common routers (See Router Mode below)
## Download
Download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section.
# Install
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:
```shell
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:
```shell
powershell -Command "(Invoke-WebRequest -Uri 'https://api.controld.com/dl' -UseBasicParsing).Content | Set-Content 'ctrld_install.bat'" && ctrld_install.bat
```
Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld)
```
$ docker pull controldns/ctrld
```
## 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
`ctrld` requires `go1.19+`:
Lastly, you can build `ctrld` from source which requires `go1.19+`:
```shell
$ go build ./cmd/ctrld
@@ -40,6 +73,17 @@ or
$ go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest
```
or
```
$ docker build -t controldns/ctrld . -f docker/Dockerfile
$ docker run -d --name=ctrld -p 53:53/tcp -p 53:53/udp controldns/ctrld --cd=RESOLVER_ID_GOES_HERE -vv
```
# Usage
The cli is self documenting, so free free to run `--help` on any sub-command to get specific usages.
## Arguments
```
__ .__ .___
@@ -55,18 +99,23 @@ Usage:
Available Commands:
run Run the DNS proxy server
service Manage ctrld service
start Quick start service and configure DNS on default interface
stop Quick stop service and remove DNS from default interface
start Quick start service and configure DNS on interface
stop Quick stop service and remove DNS from interface
restart Restart the ctrld service
status Show status of the ctrld service
uninstall Stop and uninstall the ctrld service
clients Manage clients
Flags:
-h, --help help for ctrld
-s, --silent do not write any log output
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging
--version version for ctrld
Use "ctrld [command] --help" for more information about a command.
```
## Usage
## 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
```
@@ -80,40 +129,33 @@ To start the server with default configuration, simply run: `./ctrld run`. This
147.185.34.1
```
If `verify.controld.com` resolves, you're successfully using the default Control D upstream.
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.
### Service Mode
To run the application in service mode, simply run: `./ctrld start` as system/root user. This will create a generic `ctrld.toml` file in the **user home** directory, start the system service, and configure the listener on the default interface. Service will start on OS boot.
## Service Mode
To run the application in service mode on any Windows, MacOS, Linux distibution or supported router, 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/` (almost everywhere else), start the system service, and configure the listener on the default 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`.
When Control D upstreams are used, `ctrld` willl [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.
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`.
```
Manage ctrld service
Usage:
ctrld service [command]
### Supported Routers
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
- Firewalla
- FreshTomato
- GL.iNet
- OpenWRT
- pfSense / OPNsense
- Synology
- Ubiquiti (UniFi, EdgeOS)
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
`ctrld` will attempt to interface with dnsmasq 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.
Flags:
-h, --help help for service
Global Flags:
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging
Use "ctrld service [command] --help" for more information about a command.
```
### 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) modes.
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) modes.
The following command will start the application in foreground mode, using the free "p2" resolver, which blocks Ads & Trackers.
@@ -121,22 +163,22 @@ The following command will start the application in foreground mode, using the f
./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 the part after the slash of your DNS-over-HTTPS resolver. ie. https://dns.controld.com/abcd1234
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
```
Once you run the above command, the following things will happen:
Once you run the above commands (in service mode 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
- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `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
## Configuration
# Configuration
See [Configuration Docs](docs/config.md).
### Example
## 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
@@ -146,8 +188,8 @@ See [Configuration Docs](docs/config.md).
[listener]
[listener.0]
ip = "127.0.0.1"
port = 53
ip = ""
port = 0
restricted = false
[network]
@@ -178,17 +220,19 @@ See [Configuration Docs](docs/config.md).
```
### Advanced
`ctrld` will pick a working config for `listener.0` then writing the default config to disk for the first run.
## 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.
You can also supply configuration via launch argeuments, in [Ephemeral Mode](docs/ephemeral_mode.md).
## Contributing
See [Contribution Guideline](./docs/contributing.md)
## Roadmap
The following functionality is on the roadmap and will be available in future releases.
- Router self-installation
- Client hostname/MAC passthrough
- Prometheus metrics exporter
- DNS intercept mode
- Direct listener mode
- Support for more routers (let us know which ones)

22
client_info.go Normal file
View File

@@ -0,0 +1,22 @@
package ctrld
// ClientInfoCtxKey is the context key to store client info.
type ClientInfoCtxKey struct{}
// ClientInfo represents ctrld's clients information.
type ClientInfo struct {
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"
)

2051
cmd/cli/cli.go Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,23 @@
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())
_, err = os.Stat(configPath)
require.NoError(t, err)
}

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
}

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

@@ -0,0 +1,29 @@
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) {
return c.c.Post("http://unix"+path, contentTypeJson, data)
}

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

@@ -0,0 +1,156 @@
package cli
import (
"context"
"encoding/json"
"net"
"net/http"
"os"
"reflect"
"sort"
"time"
dto "github.com/prometheus/client_model/go"
"github.com/Control-D-Inc/ctrld"
)
const (
contentTypeJson = "application/json"
listClientsPath = "/clients"
startedPath = "/started"
reloadPath = "/reload"
)
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) {
clients := p.ciTable.ListClients()
sort.Slice(clients, func(i, j int) bool {
return clients[i].IP.Less(clients[j].IP)
})
if p.cfg.Service.MetricsQueryStats {
for _, client := range clients {
client.IncludeQueryCount = true
dm := &dto.Metric{}
m, err := statsClientQueriesCount.MetricVec.GetMetricWithLabelValues(
client.IP.String(),
client.Mac,
client.Hostname,
)
if err != nil {
mainLog.Load().Debug().Err(err).Msgf("could not get metrics for client: %v", client)
continue
}
if err := m.Write(dm); err == nil {
client.QueryCount = int64(dm.Counter.GetValue())
}
}
}
if err := json.NewEncoder(w).Encode(&clients); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}))
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)
}))
}
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

938
cmd/cli/dns_proxy.go Normal file
View File

@@ -0,0 +1,938 @@
package cli
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"net/netip"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"golang.org/x/sync/errgroup"
"tailscale.com/net/interfaces"
"tailscale.com/net/netaddr"
"tailscale.com/net/tsaddr"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
const (
staleTTL = 60 * time.Second
localTTL = 3600 * time.Second
// EDNS0_OPTION_MAC is dnsmasq EDNS0 code for adding mac option.
// https://thekelleys.org.uk/gitweb/?p=dnsmasq.git;a=blob;f=src/dns-protocol.h;h=76ac66a8c28317e9c121a74ab5fd0e20f6237dc8;hb=HEAD#l81
// This is also dns.EDNS0LOCALSTART, but define our own constant here for clarification.
EDNS0_OPTION_MAC = 0xFDE9
)
var osUpstreamConfig = &ctrld.UpstreamConfig{
Name: "OS resolver",
Type: ctrld.ResolverTypeOS,
Timeout: 2000,
}
var privateUpstreamConfig = &ctrld.UpstreamConfig{
Name: "Private resolver",
Type: ctrld.ResolverTypePrivate,
Timeout: 2000,
}
// proxyRequest contains data for proxying a DNS query to upstream.
type proxyRequest struct {
msg *dns.Msg
ci *ctrld.ClientInfo
failoverRcodes []int
ufr *upstreamForResult
}
// proxyResponse contains data for proxying a DNS response from upstream.
type proxyResponse struct {
answer *dns.Msg
cached bool
clientInfo bool
upstream string
}
// upstreamForResult represents the result of processing rules for a request.
type upstreamForResult struct {
upstreams []string
matchedPolicy string
matchedNetwork string
matchedRule string
matched bool
srcAddr string
}
func (p *prog) serveDNS(listenerNum string) error {
listenerConfig := p.cfg.Listener[listenerNum]
// make sure ip is allocated
if allocErr := p.allocateIP(listenerConfig.IP); allocErr != nil {
mainLog.Load().Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip")
return allocErr
}
handler := dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) {
p.sema.acquire()
defer p.sema.release()
if len(m.Question) == 0 {
answer := new(dns.Msg)
answer.SetRcode(m, dns.RcodeFormatError)
_ = w.WriteMsg(answer)
return
}
reqId := requestID()
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
if !listenerConfig.AllowWanClients && isWanClient(w.RemoteAddr()) {
ctrld.Log(ctx, mainLog.Load().Debug(), "query refused, listener does not allow WAN clients: %s", w.RemoteAddr().String())
answer := new(dns.Msg)
answer.SetRcode(m, dns.RcodeRefused)
_ = w.WriteMsg(answer)
return
}
go p.detectLoop(m)
q := m.Question[0]
domain := canonicalName(q.Name)
remoteIP, _, _ := net.SplitHostPort(w.RemoteAddr().String())
ci := p.getClientInfo(remoteIP, m)
ci.ClientIDPref = p.cfg.Service.ClientIDPref
stripClientSubnet(m)
remoteAddr := spoofRemoteAddr(w.RemoteAddr(), ci)
fmtSrcToDest := fmtRemoteToLocal(listenerNum, ci.Hostname, remoteAddr.String())
t := time.Now()
ctrld.Log(ctx, mainLog.Load().Info(), "QUERY: %s: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain)
ur := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, ci.Mac, domain)
labelValues := make([]string, 0, len(statsQueriesCountLabels))
labelValues = append(labelValues, net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)))
labelValues = append(labelValues, ci.IP)
labelValues = append(labelValues, ci.Mac)
labelValues = append(labelValues, ci.Hostname)
var answer *dns.Msg
if !ur.matched && listenerConfig.Restricted {
ctrld.Log(ctx, mainLog.Load().Info(), "query refused, %s does not match any network policy", remoteAddr.String())
answer = new(dns.Msg)
answer.SetRcode(m, dns.RcodeRefused)
labelValues = append(labelValues, "") // no upstream
} else {
var failoverRcode []int
if listenerConfig.Policy != nil {
failoverRcode = listenerConfig.Policy.FailoverRcodeNumbers
}
pr := p.proxy(ctx, &proxyRequest{
msg: m,
ci: ci,
failoverRcodes: failoverRcode,
ufr: ur,
})
answer = pr.answer
rtt := time.Since(t)
ctrld.Log(ctx, mainLog.Load().Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
upstream := pr.upstream
switch {
case pr.cached:
upstream = "cache"
case pr.clientInfo:
upstream = "client_info_table"
}
labelValues = append(labelValues, upstream)
}
labelValues = append(labelValues, dns.TypeToString[q.Qtype])
labelValues = append(labelValues, dns.RcodeToString[answer.Rcode])
go func() {
p.WithLabelValuesInc(statsQueriesCount, labelValues...)
p.WithLabelValuesInc(statsClientQueriesCount, []string{ci.IP, ci.Mac, ci.Hostname}...)
}()
if err := w.WriteMsg(answer); err != nil {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveDNS: failed to send DNS response to client")
}
})
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 <-p.stopCh:
case <-ctx.Done():
case err := <-errCh:
// Local ipv6 listener should not terminate ctrld.
// It's a workaround for a quirk on Windows.
mainLog.Load().Warn().Err(err).Msg("local ipv6 listener failed")
}
return nil
})
}
// When we spawn a listener on 127.0.0.1, also spawn listeners on the RFC1918
// addresses of the machine. So ctrld could receive queries from LAN clients.
if needRFC1918Listeners(listenerConfig) {
g.Go(func() error {
for _, addr := range ctrld.Rfc1918Addresses() {
func() {
listenAddr := net.JoinHostPort(addr, strconv.Itoa(listenerConfig.Port))
s, errCh := runDNSServer(listenAddr, proto, handler)
defer s.Shutdown()
select {
case <-p.stopCh:
case <-ctx.Done():
case err := <-errCh:
// RFC1918 listener should not terminate ctrld.
// It's a workaround for a quirk on system with systemd-resolved.
mainLog.Load().Warn().Err(err).Msgf("could not listen on %s: %s", proto, listenAddr)
}
}()
}
return nil
})
}
g.Go(func() error {
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
s, errCh := runDNSServer(addr, proto, handler)
defer s.Shutdown()
select {
case err := <-errCh:
return err
case <-time.After(5 * time.Second):
p.started <- struct{}{}
}
select {
case <-p.stopCh:
case <-ctx.Done():
case err := <-errCh:
return err
}
return nil
})
}
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, srcMac, domain string) (res *upstreamForResult) {
upstreams := []string{upstreamPrefix + defaultUpstreamNum}
matchedPolicy := "no policy"
matchedNetwork := "no network"
matchedRule := "no rule"
matched := false
res = &upstreamForResult{srcAddr: addr.String()}
defer func() {
res.upstreams = upstreams
res.matched = matched
res.matchedPolicy = matchedPolicy
res.matchedNetwork = matchedNetwork
res.matchedRule = matchedRule
}()
if lc.Policy == nil {
return
}
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
}
}
}
}
macRules:
for _, rule := range lc.Policy.Macs {
for source, targets := range rule {
if source != "" && strings.EqualFold(source, srcMac) {
matchedPolicy = lc.Policy.Name
matchedNetwork = source
networkTargets = targets
matched = true
break macRules
}
}
}
for _, rule := range lc.Policy.Rules {
// There's only one entry per rule, config validation ensures this.
for source, targets := range rule {
if source == domain || wildcardMatches(source, domain) {
matchedPolicy = lc.Policy.Name
if len(networkTargets) > 0 {
matchedNetwork += " (unenforced)"
}
matchedRule = source
do(targets)
matched = true
return
}
}
}
if matched {
do(networkTargets)
}
return
}
func (p *prog) proxyPrivatePtrLookup(ctx context.Context, msg *dns.Msg) *dns.Msg {
cDomainName := msg.Question[0].Name
locked := p.ptrLoopGuard.TryLock(cDomainName)
defer p.ptrLoopGuard.Unlock(cDomainName)
if !locked {
return nil
}
ip := ipFromARPA(cDomainName)
if name := p.ciTable.LookupHostname(ip.String(), ""); name != "" {
answer := new(dns.Msg)
answer.SetReply(msg)
answer.Compress = true
answer.Answer = []dns.RR{&dns.PTR{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
},
Ptr: dns.Fqdn(name),
}}
ctrld.Log(ctx, mainLog.Load().Info(), "private PTR lookup, using client info table")
ctrld.Log(ctx, mainLog.Load().Debug(), "client info: %v", ctrld.ClientInfo{
Mac: p.ciTable.LookupMac(ip.String()),
IP: ip.String(),
Hostname: name,
})
return answer
}
return nil
}
func (p *prog) proxyLanHostnameQuery(ctx context.Context, msg *dns.Msg) *dns.Msg {
q := msg.Question[0]
hostname := strings.TrimSuffix(q.Name, ".")
locked := p.lanLoopGuard.TryLock(hostname)
defer p.lanLoopGuard.Unlock(hostname)
if !locked {
return nil
}
if ip := p.ciTable.LookupIPByHostname(hostname, q.Qtype == dns.TypeAAAA); ip != nil {
answer := new(dns.Msg)
answer.SetReply(msg)
answer.Compress = true
switch {
case ip.Is4():
answer.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: uint32(localTTL.Seconds()),
},
A: ip.AsSlice(),
}}
case ip.Is6():
answer.Answer = []dns.RR{&dns.AAAA{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: uint32(localTTL.Seconds()),
},
AAAA: ip.AsSlice(),
}}
}
ctrld.Log(ctx, mainLog.Load().Info(), "lan hostname lookup, using client info table")
ctrld.Log(ctx, mainLog.Load().Debug(), "client info: %v", ctrld.ClientInfo{
Mac: p.ciTable.LookupMac(ip.String()),
IP: ip.String(),
Hostname: hostname,
})
return answer
}
return nil
}
func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
var staleAnswer *dns.Msg
upstreams := req.ufr.upstreams
serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale
upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams)
if len(upstreamConfigs) == 0 {
upstreamConfigs = []*ctrld.UpstreamConfig{osUpstreamConfig}
upstreams = []string{upstreamOS}
}
res := &proxyResponse{}
// LAN/PTR lookup flow:
//
// 1. If there's matching rule, follow it.
// 2. Try from client info table.
// 3. Try private resolver.
// 4. Try remote upstream.
isLanOrPtrQuery := false
if req.ufr.matched {
ctrld.Log(ctx, mainLog.Load().Debug(), "%s, %s, %s -> %v", req.ufr.matchedPolicy, req.ufr.matchedNetwork, req.ufr.matchedRule, upstreams)
} else {
switch {
case isPrivatePtrLookup(req.msg):
isLanOrPtrQuery = true
if answer := p.proxyPrivatePtrLookup(ctx, req.msg); answer != nil {
res.answer = answer
res.clientInfo = true
return res
}
upstreams, upstreamConfigs = p.upstreamsAndUpstreamConfigForLanAndPtr(upstreams, upstreamConfigs)
ctrld.Log(ctx, mainLog.Load().Debug(), "private PTR lookup, using upstreams: %v", upstreams)
case isLanHostnameQuery(req.msg):
isLanOrPtrQuery = true
if answer := p.proxyLanHostnameQuery(ctx, req.msg); answer != nil {
res.answer = answer
res.clientInfo = true
return res
}
upstreams, upstreamConfigs = p.upstreamsAndUpstreamConfigForLanAndPtr(upstreams, upstreamConfigs)
ctrld.Log(ctx, mainLog.Load().Debug(), "lan hostname lookup, using upstreams: %v", upstreams)
default:
ctrld.Log(ctx, mainLog.Load().Debug(), "no explicit policy matched, using default routing -> %v", upstreams)
}
}
// Inverse query should not be cached: https://www.rfc-editor.org/rfc/rfc1035#section-7.4
if p.cache != nil && req.msg.Question[0].Qtype != dns.TypePTR {
for _, upstream := range upstreams {
cachedValue := p.cache.Get(dnscache.NewKey(req.msg, upstream))
if cachedValue == nil {
continue
}
answer := cachedValue.Msg.Copy()
answer.SetRcode(req.msg, answer.Rcode)
now := time.Now()
if cachedValue.Expire.After(now) {
ctrld.Log(ctx, mainLog.Load().Debug(), "hit cached response")
setCachedAnswerTTL(answer, now, cachedValue.Expire)
res.answer = answer
res.cached = true
return res
}
staleAnswer = answer
}
}
resolve1 := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) (*dns.Msg, error) {
ctrld.Log(ctx, mainLog.Load().Debug(), "sending query to %s: %s", upstreams[n], upstreamConfig.Name)
dnsResolver, err := ctrld.NewResolver(upstreamConfig)
if err != nil {
ctrld.Log(ctx, mainLog.Load().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() && req.ci != nil {
ctrld.Log(ctx, mainLog.Load().Debug(), "including client info with the request")
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, req.ci)
}
answer, err := resolve1(n, upstreamConfig, msg)
if err != nil {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to resolve query")
if errNetworkError(err) {
p.um.increaseFailureCount(upstreams[n])
if p.um.isDown(upstreams[n]) {
go p.um.checkUpstream(upstreams[n], upstreamConfig)
}
}
// For timeout error (i.e: context deadline exceed), force re-bootstrapping.
var e net.Error
if errors.As(err, &e) && e.Timeout() {
upstreamConfig.ReBootstrap()
}
return nil
}
return answer
}
for n, upstreamConfig := range upstreamConfigs {
if upstreamConfig == nil {
continue
}
if p.isLoop(upstreamConfig) {
mainLog.Load().Warn().Msgf("dns loop detected, upstream: %q, endpoint: %q", upstreamConfig.Name, upstreamConfig.Endpoint)
continue
}
if p.um.isDown(upstreams[n]) {
ctrld.Log(ctx, mainLog.Load().Warn(), "%s is down", upstreams[n])
continue
}
answer := resolve(n, upstreamConfig, req.msg)
if answer == nil {
if serveStaleCache && staleAnswer != nil {
ctrld.Log(ctx, mainLog.Load().Debug(), "serving stale cached response")
now := time.Now()
setCachedAnswerTTL(staleAnswer, now, now.Add(staleTTL))
res.answer = staleAnswer
res.cached = true
return res
}
continue
}
// We are doing LAN/PTR lookup using private resolver, so always process next one.
// Except for the last, we want to send response instead of saying all upstream failed.
if answer.Rcode != dns.RcodeSuccess && isLanOrPtrQuery && n != len(upstreamConfigs)-1 {
ctrld.Log(ctx, mainLog.Load().Debug(), "no response from %s, process to next upstream", upstreams[n])
continue
}
if answer.Rcode != dns.RcodeSuccess && len(upstreamConfigs) > 1 && containRcode(req.failoverRcodes, answer.Rcode) {
ctrld.Log(ctx, mainLog.Load().Debug(), "failover rcode matched, process to next upstream")
continue
}
// set compression, as it is not set by default when unpacking
answer.Compress = true
if p.cache != nil && req.msg.Question[0].Qtype != dns.TypePTR {
ttl := ttlFromMsg(answer)
now := time.Now()
expired := now.Add(time.Duration(ttl) * time.Second)
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(req.msg, upstreams[n]), dnscache.NewValue(answer, expired))
ctrld.Log(ctx, mainLog.Load().Debug(), "add cached response")
}
hostname := ""
if req.ci != nil {
hostname = req.ci.Hostname
}
ctrld.Log(ctx, mainLog.Load().Info(), "REPLY: %s -> %s (%s): %s", upstreams[n], req.ufr.srcAddr, hostname, dns.RcodeToString[answer.Rcode])
res.answer = answer
res.upstream = upstreamConfig.Endpoint
return res
}
ctrld.Log(ctx, mainLog.Load().Error(), "all %v endpoints failed", upstreams)
answer := new(dns.Msg)
answer.SetRcode(req.msg, dns.RcodeServerFailure)
res.answer = answer
return res
}
func (p *prog) upstreamsAndUpstreamConfigForLanAndPtr(upstreams []string, upstreamConfigs []*ctrld.UpstreamConfig) ([]string, []*ctrld.UpstreamConfig) {
if len(p.localUpstreams) > 0 {
tmp := make([]string, 0, len(p.localUpstreams)+len(upstreams))
tmp = append(tmp, p.localUpstreams...)
tmp = append(tmp, upstreams...)
return tmp, p.upstreamConfigsFromUpstreamNumbers(tmp)
}
return append([]string{upstreamOS}, upstreams...), append([]*ctrld.UpstreamConfig{privateUpstreamConfig}, upstreamConfigs...)
}
func (p *prog) upstreamConfigsFromUpstreamNumbers(upstreams []string) []*ctrld.UpstreamConfig {
upstreamConfigs := make([]*ctrld.UpstreamConfig, 0, len(upstreams))
for _, upstream := range upstreams {
upstreamNum := strings.TrimPrefix(upstream, upstreamPrefix)
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, hostname, remote string) string {
return fmt.Sprintf("%s (%s) -> listener.%s", remote, hostname, listenerNum)
}
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"
}
// ipAndMacFromMsg extracts IP and MAC information included in a DNS message, if any.
func ipAndMacFromMsg(msg *dns.Msg) (string, string) {
ip, mac := "", ""
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 {
mac = net.HardwareAddr(e.Data).String()
}
case *dns.EDNS0_SUBNET:
if len(e.Address) > 0 && !e.Address.IsLoopback() {
ip = e.Address.String()
}
}
}
}
return ip, mac
}
// stripClientSubnet removes EDNS0_SUBNET from DNS message if the IP is RFC1918 or loopback address,
// passing them to upstream is pointless, these cannot be used by anything on the WAN.
func stripClientSubnet(msg *dns.Msg) {
if opt := msg.IsEdns0(); opt != nil {
opts := make([]dns.EDNS0, 0, len(opt.Option))
for _, s := range opt.Option {
if e, ok := s.(*dns.EDNS0_SUBNET); ok && (e.Address.IsPrivate() || e.Address.IsLoopback()) {
continue
}
opts = append(opts, s)
}
if len(opts) != len(opt.Option) {
opt.Option = opts
}
}
}
func spoofRemoteAddr(addr net.Addr, ci *ctrld.ClientInfo) net.Addr {
if ci != nil && ci.IP != "" {
switch addr := addr.(type) {
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.Load().Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr)
errCh <- err
}
}()
waitLock.Lock()
return s, errCh
}
func (p *prog) getClientInfo(remoteIP string, msg *dns.Msg) *ctrld.ClientInfo {
ci := &ctrld.ClientInfo{}
if p.appCallback != nil {
ci.IP = p.appCallback.LanIp()
ci.Mac = p.appCallback.MacAddress()
ci.Hostname = p.appCallback.HostName()
ci.Self = true
return ci
}
ci.IP, ci.Mac = ipAndMacFromMsg(msg)
switch {
case ci.IP != "" && ci.Mac != "":
// Nothing to do.
case ci.IP == "" && ci.Mac != "":
// Have MAC, no IP.
ci.IP = p.ciTable.LookupIP(ci.Mac)
case ci.IP == "" && ci.Mac == "":
// Have nothing, use remote IP then lookup MAC.
ci.IP = remoteIP
fallthrough
case ci.IP != "" && ci.Mac == "":
// Have IP, no MAC.
ci.Mac = p.ciTable.LookupMac(ci.IP)
}
// If MAC is still empty here, that mean the requests are made from virtual interface,
// like VPN/Wireguard clients, so we use ci.IP as hostname to distinguish those clients.
if ci.Mac == "" {
if hostname := p.ciTable.LookupHostname(ci.IP, ""); hostname != "" {
ci.Hostname = hostname
} else {
// Only use IP as hostname for IPv4 clients.
// For Android devices, when it joins the network, it uses ctrld to resolve
// its private DNS once and never reaches ctrld again. For each time, it uses
// a different IPv6 address, which causes hundreds/thousands different client
// IDs created for the same device, which is pointless.
//
// TODO(cuonglm): investigate whether this can be a false positive for other clients?
if !ctrldnet.IsIPv6(ci.IP) {
ci.Hostname = ci.IP
p.ciTable.StoreVPNClient(ci)
}
}
} else {
ci.Hostname = p.ciTable.LookupHostname(ci.IP, ci.Mac)
}
ci.Self = queryFromSelf(ci.IP)
p.spoofLoopbackIpInClientInfo(ci)
return ci
}
// spoofLoopbackIpInClientInfo replaces loopback IPs in client info.
//
// - Preference IPv4.
// - Preference RFC1918.
func (p *prog) spoofLoopbackIpInClientInfo(ci *ctrld.ClientInfo) {
if ip := net.ParseIP(ci.IP); ip == nil || !ip.IsLoopback() {
return
}
if ip := p.ciTable.LookupRFC1918IPv4(ci.Mac); ip != "" {
ci.IP = ip
}
}
// queryFromSelf reports whether the input IP is from device running ctrld.
func queryFromSelf(ip string) bool {
netIP := netip.MustParseAddr(ip)
ifaces, err := interfaces.GetList()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not get interfaces list")
return false
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not get interfaces addresses: %s", iface.Name)
continue
}
for _, a := range addrs {
switch v := a.(type) {
case *net.IPNet:
if pfx, ok := netaddr.FromStdIPNet(v); ok && pfx.Addr().Compare(netIP) == 0 {
return true
}
}
}
}
return false
}
func needRFC1918Listeners(lc *ctrld.ListenerConfig) bool {
return lc.IP == "127.0.0.1" && lc.Port == 53
}
// ipFromARPA parses a FQDN arpa domain and return the IP address if valid.
func ipFromARPA(arpa string) net.IP {
if arpa, ok := strings.CutSuffix(arpa, ".in-addr.arpa."); ok {
if ptrIP := net.ParseIP(arpa); ptrIP != nil {
return net.IP{ptrIP[15], ptrIP[14], ptrIP[13], ptrIP[12]}
}
}
if arpa, ok := strings.CutSuffix(arpa, ".ip6.arpa."); ok {
l := net.IPv6len * 2
base := 16
ip := make(net.IP, net.IPv6len)
for i := 0; i < l && arpa != ""; i++ {
idx := strings.LastIndexByte(arpa, '.')
off := idx + 1
if idx == -1 {
idx = 0
off = 0
} else if idx == len(arpa)-1 {
return nil
}
n, err := strconv.ParseUint(arpa[off:], base, 8)
if err != nil {
return nil
}
b := byte(n)
ii := i / 2
if i&1 == 1 {
b |= ip[ii] << 4
}
ip[ii] = b
arpa = arpa[:idx]
}
return ip
}
return nil
}
// isPrivatePtrLookup reports whether DNS message is an PTR query for LAN/CGNAT network.
func isPrivatePtrLookup(m *dns.Msg) bool {
if m == nil || len(m.Question) == 0 {
return false
}
q := m.Question[0]
if ip := ipFromARPA(q.Name); ip != nil {
if addr, ok := netip.AddrFromSlice(ip); ok {
return addr.IsPrivate() ||
addr.IsLoopback() ||
addr.IsLinkLocalUnicast() ||
tsaddr.CGNATRange().Contains(addr)
}
}
return false
}
// isLanHostnameQuery reports whether DNS message is an A/AAAA query with LAN hostname.
func isLanHostnameQuery(m *dns.Msg) bool {
if m == nil || len(m.Question) == 0 {
return false
}
q := m.Question[0]
switch q.Qtype {
case dns.TypeA, dns.TypeAAAA:
default:
return false
}
name := strings.TrimSuffix(q.Name, ".")
return !strings.Contains(name, ".") ||
strings.HasSuffix(name, ".domain") ||
strings.HasSuffix(name, ".lan")
}
// isWanClient reports whether the input is a WAN address.
func isWanClient(na net.Addr) bool {
var ip netip.Addr
if ap, err := netip.ParseAddrPort(na.String()); err == nil {
ip = ap.Addr()
}
return !ip.IsLoopback() &&
!ip.IsPrivate() &&
!ip.IsLinkLocalUnicast() &&
!ip.IsLinkLocalMulticast() &&
!tsaddr.CGNATRange().Contains(ip)
}

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

@@ -0,0 +1,433 @@
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
}{
{"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)
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},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isLanHostnameQuery(tc.msg); tc.isLanHostnameQuery != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isLanHostnameQuery, got)
}
})
}
}
func newDnsMsgPtr(ip string, t *testing.T) *dns.Msg {
t.Helper()
m := new(dns.Msg)
ptr, err := dns.ReverseAddr(ip)
if err != nil {
t.Fatal(err)
}
m.SetQuestion(ptr, dns.TypePTR)
return m
}
func Test_isPrivatePtrLookup(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isPrivatePtrLookup bool
}{
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
{"10.0.0.0/8", newDnsMsgPtr("10.0.0.123", t), true},
{"172.16.0.0/12", newDnsMsgPtr("172.16.0.123", t), true},
{"192.168.0.0/16", newDnsMsgPtr("192.168.1.123", t), true},
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", t), true},
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isPrivatePtrLookup(tc.msg); tc.isPrivatePtrLookup != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isPrivatePtrLookup, got)
}
})
}
}
func Test_isWanClient(t *testing.T) {
tests := []struct {
name string
addr net.Addr
isWanClient bool
}{
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
{"10.0.0.0/8", &net.UDPAddr{IP: net.ParseIP("10.0.0.123")}, false},
{"172.16.0.0/12", &net.UDPAddr{IP: net.ParseIP("172.16.0.123")}, false},
{"192.168.0.0/16", &net.UDPAddr{IP: net.ParseIP("192.168.1.123")}, false},
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, false},
{"Link Local Unicast", &net.UDPAddr{IP: net.ParseIP("fe80::69f6:e16e:8bdb:433f")}, false},
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isWanClient(tc.addr); tc.isWanClient != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isWanClient, got)
}
})
}
}

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

@@ -0,0 +1,19 @@
package cli
// 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
}

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

@@ -0,0 +1,141 @@
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]
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)
}
}

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

@@ -0,0 +1,172 @@
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
cdDev bool
iface string
ifaceStartStop string
nextdns string
cdUpstreamProto string
mainLog atomic.Pointer[zerolog.Logger]
consoleWriter zerolog.ConsoleWriter
noConfigStart bool
)
const (
cdUidFlagName = "cd"
cdOrgFlagName = "cd-org"
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:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case verbose > 1:
zerolog.SetGlobalLevel(zerolog.DebugLevel)
default:
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
}
}
// initLogging initializes global logging setup.
func initLogging() {
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
initLoggingWithBackup(true)
}
// 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) {
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.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+".1"); err != nil && !os.IsNotExist(err) {
mainLog.Load().Error().Msgf("could not backup old log file: %v", err)
} else {
// Backup was created, set flags for truncating old log file.
flags = os.O_CREATE | os.O_RDWR
}
}
logFile, err := os.OpenFile(logFilePath, flags, os.FileMode(0o600))
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
case verbose == 1:
logLevel = "info"
case verbose > 1:
logLevel = "debug"
}
if logLevel == "" {
return
}
level, err := zerolog.ParseLevel(logLevel)
if err != nil {
mainLog.Load().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
}
}

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

@@ -0,0 +1,17 @@
package cli
import (
"os"
"strings"
"testing"
"github.com/rs/zerolog"
)
var logOutput strings.Builder
func TestMain(m *testing.M) {
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 cfg.Service.MetricsQueryStats {
reg.MustRegister(statsQueriesCount)
reg.MustRegister(statsClientQueriesCount)
}
addr := p.cfg.Service.MetricsListener
ms, err := newMetricsServer(addr, reg)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not create new metrics server")
return
}
// Only start listener address if defined.
if addr != "" {
// Go runtime stats.
reg.MustRegister(collectors.NewBuildInfoCollector())
reg.MustRegister(collectors.NewGoCollector(
collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll),
))
// ctrld stats.
reg.MustRegister(statsVersion)
statsVersion.WithLabelValues(commit, runtime.Version(), curVersion()).Inc()
reg.MustRegister(statsTimeStart)
statsTimeStart.Set(float64(time.Now().Unix()))
mainLog.Load().Debug().Msgf("starting metrics server on: %s", addr)
if err := ms.start(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not start metrics server")
return
}
}
select {
case <-p.stopCh:
case <-ctx.Done():
case <-reloadCh:
}
if err := ms.stop(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not stop metrics server")
return
}
}

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

@@ -0,0 +1,44 @@
package cli
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.Load().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

@@ -0,0 +1,59 @@
package cli
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
const listnetworkserviceorderOutput = `
(1) USB 10/100/1000 LAN 2
(Hardware Port: USB 10/100/1000 LAN, Device: en7)
(2) Ethernet
(Hardware Port: Ethernet, Device: en0)
(3) Wi-Fi
(Hardware Port: Wi-Fi, Device: en1)
(4) Bluetooth PAN
(Hardware Port: Bluetooth PAN, Device: en4)
(5) Thunderbolt Bridge
(Hardware Port: Thunderbolt Bridge, Device: bridge0)
(6) kernal
(Hardware Port: com.wireguard.macos, Device: )
(7) WS BT
(Hardware Port: com.wireguard.macos, Device: )
(8) ca-001-stg
(Hardware Port: com.wireguard.macos, Device: )
(9) ca-001-stg-2
(Hardware Port: com.wireguard.macos, Device: )
`
func Test_networkServiceName(t *testing.T) {
tests := []struct {
ifaceName string
networkServiceName string
}{
{"en7", "USB 10/100/1000 LAN 2"},
{"en0", "Ethernet"},
{"en1", "Wi-Fi"},
{"en4", "Bluetooth PAN"},
{"bridge0", "Thunderbolt Bridge"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.ifaceName, func(t *testing.T) {
t.Parallel()
name := networkServiceName(tc.ifaceName, strings.NewReader(listnetworkserviceorderOutput))
assert.Equal(t, tc.networkServiceName, name)
})
}
}

View File

@@ -1,6 +1,6 @@
//go:build !darwin
package main
package cli
import "net"

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,10 +1,10 @@
package main
package cli
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/coreos/go-systemd/v22/dbus"
@@ -17,53 +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 runtime.GOOS != "linux" {
mainLog.Debug().Msg("skipping NetworkManager setup, not on Linux")
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 runtime.GOOS != "linux" {
mainLog.Debug().Msg("skipping NetworkManager restoring, not on Linux")
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
}
@@ -72,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

@@ -0,0 +1,15 @@
//go:build !linux
package cli
func setupNetworkManager() error {
reloadNetworkManager()
return nil
}
func restoreNetworkManager() error {
reloadNetworkManager()
return nil
}
func 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,
},
},
}
}

View File

@@ -1,7 +1,4 @@
//go:build darwin
// +build darwin
package main
package cli
import (
"net"
@@ -15,7 +12,7 @@ import (
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")
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
return err
}
return nil
@@ -24,7 +21,7 @@ func allocateIP(ip string) error {
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")
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
return err
}
return nil
@@ -39,7 +36,7 @@ func setDNS(iface *net.Interface, nameservers []string) error {
args = append(args, nameservers...)
if err := exec.Command(cmd, args...).Run(); err != nil {
mainLog.Error().Err(err).Msgf("setDNS failed, ips = %q", nameservers)
mainLog.Load().Error().Err(err).Msgf("setDNS failed, ips = %q", nameservers)
return err
}
return nil
@@ -51,7 +48,7 @@ func resetDNS(iface *net.Interface) error {
args := []string{"-setdnsservers", iface.Name, "empty"}
if err := exec.Command(cmd, args...).Run(); err != nil {
mainLog.Error().Err(err).Msgf("resetDNS failed")
mainLog.Load().Error().Err(err).Msgf("resetDNS failed")
return err
}
return nil

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

@@ -0,0 +1,68 @@
package cli
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.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
}
// 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.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))
}
if err := r.SetDNS(dns.OSConfig{Nameservers: ns}); err != nil {
mainLog.Load().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.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
}
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers("")
}

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

@@ -0,0 +1,367 @@
package cli
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"net/netip"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/client6"
"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 (
resolvConfPath = "/etc/resolv.conf"
resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
)
// allocate loopback ip
// sudo ip a add 127.0.0.2/24 dev lo
func allocateIP(ip string) error {
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
// 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.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{},
}
defer func() {
if r.Mode() == "direct" {
go watchResolveConf(osConfig)
}
}()
trySystemdResolve := false
for i := 0; i < maxSetDNSAttempts; i++ {
if err := r.SetDNS(osConfig); err != nil {
if strings.Contains(err.Error(), "Rejected send message") &&
strings.Contains(err.Error(), "org.freedesktop.network1.Manager") {
mainLog.Load().Warn().Msg("Interfaces are managed by systemd-networkd, switch to systemd-resolve for setting DNS")
trySystemdResolve = true
break
}
// This error happens on read-only file system, which causes ctrld failed to create backup
// for /etc/resolv.conf file. It is ok, because the DNS is still set anyway, and restore
// DNS will fallback to use DHCP if there's no backup /etc/resolv.conf file.
// The error format is controlled by us, so checking for error string is fine.
// See: ../../internal/dns/direct.go:L278
if r.Mode() == "direct" && strings.Contains(err.Error(), resolvConfBackupFailedMsg) {
return nil
}
return err
}
if useSystemdResolved {
if out, err := exec.Command("systemctl", "restart", "systemd-resolved").CombinedOutput(); err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not restart systemd-resolved: %s", string(out))
}
}
currentNS := currentDNS(iface)
if isSubSet(nameservers, currentNS) {
return nil
}
}
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
}
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, 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.
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())
}
}
}
}
return ignoringEINTR(func() error {
return setDNS(iface, ns)
})
}
func currentDNS(iface *net.Interface) []string {
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, 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 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 {
// TODO(cuonglm): use slices.Contains once upgrading to go1.21
if sliceContains(s2, ns) {
continue
}
ok = false
break
}
return ok
}
// sliceContains reports whether v is present in s.
func sliceContains[S ~[]E, E comparable](s S, v E) bool {
return sliceIndex(s, v) >= 0
}
// sliceIndex returns the index of the first occurrence of v in s,
// or -1 if not present.
func sliceIndex[S ~[]E, E comparable](s S, v E) int {
for i := range s {
if v == s[i] {
return i
}
}
return -1
}
// watchResolveConf watches any changes to /etc/resolv.conf file,
// and reverting to the original config set by ctrld.
func watchResolveConf(oc dns.OSConfig) {
mainLog.Load().Debug().Msg("start watching /etc/resolv.conf file")
watcher, err := fsnotify.NewWatcher()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
return
}
// We watch /etc instead of /etc/resolv.conf directly,
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
watchDir := filepath.Dir(resolvConfPath)
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not add /etc/resolv.conf to watcher list")
return
}
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo") // interface name does not matter.
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Name != resolvConfPath { // skip if not /etc/resolv.conf changes.
continue
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
mainLog.Load().Debug().Msg("/etc/resolv.conf changes detected, reverting to ctrld setting")
if err := watcher.Remove(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
continue
}
if err := r.SetDNS(oc); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
}
if err := watcher.Add(watchDir); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
return
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
}
}
}

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

13
cmd/cli/os_others.go Normal file
View File

@@ -0,0 +1,13 @@
//go:build !linux && !darwin && !freebsd
package cli
// TODO(cuonglm): implement.
func allocateIP(ip string) error {
return nil
}
// TODO(cuonglm): implement.
func deAllocateIP(ip string) error {
return nil
}

View File

@@ -1,7 +1,4 @@
//go:build windows
// +build windows
package main
package cli
import (
"errors"
@@ -10,18 +7,10 @@ import (
"strconv"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
// TODO(cuonglm): implement.
func allocateIP(ip string) error {
return nil
}
// TODO(cuonglm): implement.
func deAllocateIP(ip string) error {
return nil
}
func setDNS(iface *net.Interface, nameservers []string) error {
if len(nameservers) == 0 {
return errors.New("empty DNS nameservers")
@@ -39,14 +28,14 @@ func setDNS(iface *net.Interface, nameservers []string) error {
// TODO(cuonglm): should we use system API?
func resetDNS(iface *net.Interface) error {
if supportsIPv6ListenLocal() {
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))
mainLog.Load().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))
mainLog.Load().Error().Err(err).Msgf("failed to reset ipv4 DNS: %s", string(output))
return err
}
return nil
@@ -54,16 +43,16 @@ func resetDNS(iface *net.Interface) error {
func setPrimaryDNS(iface *net.Interface, dns string) error {
ipVer := "ipv4"
if isIPv6(dns) {
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))
mainLog.Load().Error().Err(err).Msgf("failed to set primary DNS: %s", string(output))
return err
}
if ipVer == "ipv4" {
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")
}
@@ -73,12 +62,12 @@ func setPrimaryDNS(iface *net.Interface, dns string) error {
func addSecondaryDNS(iface *net.Interface, dns string) error {
ipVer := "ipv4"
if isIPv6(dns) {
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))
mainLog.Load().Warn().Err(err).Msgf("failed to add secondary DNS: %s", string(output))
}
return nil
}
@@ -90,12 +79,12 @@ func netsh(args ...string) ([]byte, error) {
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")
mainLog.Load().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")
mainLog.Load().Error().Err(err).Msg("failed to get interface DNS")
return nil
}
ns := make([]string, 0, len(nameservers))

651
cmd/cli/prog.go Normal file
View File

@@ -0,0 +1,651 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"math/rand"
"net"
"net/netip"
"net/url"
"os"
"runtime"
"sort"
"strconv"
"sync"
"syscall"
"github.com/kardianos/service"
"github.com/spf13/viper"
"tailscale.com/net/interfaces"
"tailscale.com/net/tsaddr"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/clientinfo"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
"github.com/Control-D-Inc/ctrld/internal/router"
)
const (
defaultSemaphoreCap = 256
ctrldLogUnixSock = "ctrld_start.sock"
ctrldControlUnixSock = "ctrld_control.sock"
upstreamPrefix = "upstream."
upstreamOS = upstreamPrefix + "os"
upstreamPrivate = upstreamPrefix + "private"
)
var logf = func(format string, args ...any) {
mainLog.Load().Debug().Msgf(format, args...)
}
var svcConfig = &service.Config{
Name: "ctrld",
DisplayName: "Control-D Helper Service",
Option: service.KeyValue{},
}
var useSystemdResolved = false
type prog struct {
mu sync.Mutex
waitCh chan struct{}
stopCh chan struct{}
reloadCh chan struct{} // For Windows.
reloadDoneCh chan struct{}
logConn net.Conn
cs *controlServer
cfg *ctrld.Config
localUpstreams []string
ptrNameservers []string
appCallback *AppCallback
cache dnscache.Cacher
sema semaphore
ciTable *clientinfo.Table
um *upstreamMonitor
router router.Router
ptrLoopGuard *loopGuard
lanLoopGuard *loopGuard
loopMu sync.Mutex
loop map[string]bool
started chan struct{}
onStartedDone chan struct{}
onStarted []func()
onStopped []func()
}
func (p *prog) Start(s service.Service) error {
go p.runWait()
return nil
}
// runWait runs ctrld components, waiting for signal to reload.
func (p *prog) runWait() {
p.mu.Lock()
p.cfg = &cfg
p.mu.Unlock()
reloadSigCh := make(chan os.Signal, 1)
notifyReloadSigCh(reloadSigCh)
reload := false
logger := mainLog.Load()
for {
reloadCh := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
p.run(reload, reloadCh)
reload = true
}()
select {
case sig := <-reloadSigCh:
logger.Notice().Msgf("got signal: %s, reloading...", sig.String())
case <-p.reloadCh:
logger.Notice().Msg("reloading...")
case <-p.stopCh:
close(reloadCh)
return
}
waitOldRunDone := func() {
close(reloadCh)
<-done
}
newCfg := &ctrld.Config{}
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "ctrld")
if configPath != "" {
v.SetConfigFile(configPath)
}
if err := v.ReadInConfig(); err != nil {
logger.Err(err).Msg("could not read new config")
waitOldRunDone()
continue
}
if err := v.Unmarshal(&newCfg); err != nil {
logger.Err(err).Msg("could not unmarshal new config")
waitOldRunDone()
continue
}
if cdUID != "" {
if err := processCDFlags(newCfg); err != nil {
logger.Err(err).Msg("could not fetch ControlD config")
waitOldRunDone()
continue
}
}
waitOldRunDone()
p.mu.Lock()
curListener := p.cfg.Listener
p.mu.Unlock()
for n, lc := range newCfg.Listener {
curLc := curListener[n]
if curLc == nil {
continue
}
if lc.IP == "" {
lc.IP = curLc.IP
}
if lc.Port == 0 {
lc.Port = curLc.Port
}
}
if err := validateConfig(newCfg); err != nil {
logger.Err(err).Msg("invalid config")
continue
}
// This needs to be done here, otherwise, the DNS handler may observe an invalid
// upstream config because its initialization function have not been called yet.
mainLog.Load().Debug().Msg("setup upstream with new config")
p.setupUpstream(newCfg)
p.mu.Lock()
*p.cfg = *newCfg
p.mu.Unlock()
logger.Notice().Msg("reloading config successfully")
select {
case p.reloadDoneCh <- struct{}{}:
default:
}
}
}
func (p *prog) preRun() {
if !service.Interactive() {
p.setDNS()
}
if runtime.GOOS == "darwin" {
p.onStopped = append(p.onStopped, func() {
if !service.Interactive() {
p.resetDNS()
}
})
}
}
func (p *prog) setupUpstream(cfg *ctrld.Config) {
localUpstreams := make([]string, 0, len(cfg.Upstream))
ptrNameservers := make([]string, 0, len(cfg.Upstream))
for n := range cfg.Upstream {
uc := cfg.Upstream[n]
uc.Init()
if uc.BootstrapIP == "" {
uc.SetupBootstrapIP()
mainLog.Load().Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
} else {
mainLog.Load().Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("using bootstrap IP for upstream.%s", n)
}
uc.SetCertPool(rootCertPool)
go uc.Ping()
if canBeLocalUpstream(uc.Domain) {
localUpstreams = append(localUpstreams, upstreamPrefix+n)
}
if uc.IsDiscoverable() {
ptrNameservers = append(ptrNameservers, uc.Endpoint)
}
}
p.localUpstreams = localUpstreams
p.ptrNameservers = ptrNameservers
}
// run runs the ctrld main components.
//
// The reload boolean indicates that the function is run when ctrld first start
// or when ctrld receive reloading signal. Platform specifics setup is only done
// on started, mean reload is "false".
//
// The reloadCh is used to signal ctrld listeners that ctrld is going to be reloaded,
// so all listeners could be terminated and re-spawned again.
func (p *prog) run(reload bool, reloadCh chan struct{}) {
// Wait the caller to signal that we can do our logic.
<-p.waitCh
if !reload {
p.preRun()
}
numListeners := len(p.cfg.Listener)
if !reload {
p.started = make(chan struct{}, numListeners)
}
p.onStartedDone = make(chan struct{})
p.loop = make(map[string]bool)
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
if p.cfg.Service.CacheEnable {
cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to create cacher, caching is disabled")
} else {
p.cache = cacher
}
}
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.Load().Error().Err(err).Str("network", nc.Name).Str("cidr", cidr).Msg("invalid cidr")
continue
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
p.um = newUpstreamMonitor(p.cfg)
if !reload {
p.sema = &chanSemaphore{ready: make(chan struct{}, defaultSemaphoreCap)}
if mcr := p.cfg.Service.MaxConcurrentRequests; mcr != nil {
n := *mcr
if n == 0 {
p.sema = &noopSemaphore{}
} else {
p.sema = &chanSemaphore{ready: make(chan struct{}, n)}
}
}
p.setupUpstream(p.cfg)
p.ciTable = clientinfo.NewTable(&cfg, defaultRouteIP(), cdUID, p.ptrNameservers)
if leaseFile := p.cfg.Service.DHCPLeaseFile; leaseFile != "" {
mainLog.Load().Debug().Msgf("watching custom lease file: %s", leaseFile)
format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat)
p.ciTable.AddLeaseFile(leaseFile, format)
}
}
// context for managing spawn goroutines.
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
// Newer versions of android and iOS denies permission which breaks connectivity.
if !isMobile() && !reload {
wg.Add(1)
go func() {
defer wg.Done()
p.ciTable.Init()
p.ciTable.RefreshLoop(ctx)
}()
go p.watchLinkState(ctx)
}
for listenerNum := range p.cfg.Listener {
p.cfg.Listener[listenerNum].Init()
if !reload {
go func(listenerNum string) {
listenerConfig := p.cfg.Listener[listenerNum]
upstreamConfig := p.cfg.Upstream[listenerNum]
if upstreamConfig == nil {
mainLog.Load().Warn().Msgf("no default upstream for: [listener.%s]", listenerNum)
}
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
mainLog.Load().Info().Msgf("starting DNS server on listener.%s: %s", listenerNum, addr)
if err := p.serveDNS(listenerNum); err != nil {
mainLog.Load().Fatal().Err(err).Msgf("unable to start dns proxy on listener.%s", listenerNum)
}
}(listenerNum)
}
go func() {
defer func() {
cancelFunc()
wg.Done()
}()
select {
case <-p.stopCh:
case <-ctx.Done():
case <-reloadCh:
}
}()
}
if !reload {
for i := 0; i < numListeners; i++ {
<-p.started
}
for _, f := range p.onStarted {
f()
}
}
close(p.onStartedDone)
wg.Add(1)
go func() {
defer wg.Done()
// Check for possible DNS loop.
p.checkDnsLoop()
// Start check DNS loop ticker.
p.checkDnsLoopTicker(ctx)
}()
wg.Add(1)
// Prometheus exporter goroutine.
go func() {
defer wg.Done()
p.runMetricsServer(ctx, reloadCh)
}()
if !reload {
// Stop writing log to unix socket.
consoleWriter.Out = os.Stdout
initLoggingWithBackup(false)
if p.logConn != nil {
_ = p.logConn.Close()
}
if p.cs != nil {
p.registerControlServerHandler()
if err := p.cs.start(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not start control server")
}
}
}
wg.Wait()
}
// metricsEnabled reports whether prometheus exporter is enabled/disabled.
func (p *prog) metricsEnabled() bool {
return p.cfg.Service.MetricsQueryStats || p.cfg.Service.MetricsListener != ""
}
func (p *prog) Stop(s service.Service) error {
mainLog.Load().Info().Msg("Service stopped")
close(p.stopCh)
if err := p.deAllocateIP(); err != nil {
mainLog.Load().Error().Err(err).Msg("de-allocate ip failed")
return err
}
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() {
if cfg.Listener == nil {
return
}
if iface == "" {
return
}
if iface == "auto" {
iface = defaultIfaceName()
}
lc := cfg.FirstListener()
if lc == nil {
return
}
logger := mainLog.Load().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")
ns := lc.IP
switch {
case lc.IsDirectDnsListener():
// If ctrld is direct listener, use 127.0.0.1 as nameserver.
ns = "127.0.0.1"
case lc.Port != 53:
ns = "127.0.0.1"
if resolver := router.LocalResolverIP(); resolver != "" {
ns = resolver
}
default:
// If we ever reach here, it means ctrld is running on lc.IP port 53,
// so we could just use lc.IP as nameserver.
}
nameservers := []string{ns}
if needRFC1918Listeners(lc) {
nameservers = append(nameservers, ctrld.Rfc1918Addresses()...)
}
if err := setDNS(netIface, nameservers); err != nil {
logger.Error().Err(err).Msgf("could not set DNS for interface")
return
}
logger.Debug().Msg("setting DNS successfully")
}
func (p *prog) resetDNS() {
if iface == "" {
return
}
if iface == "auto" {
iface = defaultIfaceName()
}
logger := mainLog.Load().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)
}
func randomPort() int {
max := 1<<16 - 1
min := 1025
n := rand.Intn(max-min) + min
return n
}
// runLogServer starts a unix listener, use by startCmd to gather log from runCmd.
func runLogServer(sockPath string) net.Conn {
addr, err := net.ResolveUnixAddr("unix", sockPath)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("invalid log sock path")
return nil
}
ln, err := net.ListenUnix("unix", addr)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not listen log socket")
return nil
}
defer ln.Close()
server, err := ln.Accept()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not accept connection")
return nil
}
return server
}
func errAddrInUse(err error) bool {
var opErr *net.OpError
if errors.As(err, &opErr) {
return errors.Is(opErr.Err, syscall.EADDRINUSE) || errors.Is(opErr.Err, windowsEADDRINUSE)
}
return false
}
var _ = errAddrInUse
// https://learn.microsoft.com/en-us/windows/win32/winsock/windows-sockets-error-codes-2
var (
windowsECONNREFUSED = syscall.Errno(10061)
windowsENETUNREACH = syscall.Errno(10051)
windowsEINVAL = syscall.Errno(10022)
windowsEADDRINUSE = syscall.Errno(10048)
windowsEHOSTUNREACH = syscall.Errno(10065)
)
func errUrlNetworkError(err error) bool {
var urlErr *url.Error
if errors.As(err, &urlErr) {
return errNetworkError(urlErr.Err)
}
return false
}
func errNetworkError(err error) bool {
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Temporary() {
return true
}
switch {
case errors.Is(opErr.Err, syscall.ECONNREFUSED),
errors.Is(opErr.Err, syscall.EINVAL),
errors.Is(opErr.Err, syscall.ENETUNREACH),
errors.Is(opErr.Err, windowsENETUNREACH),
errors.Is(opErr.Err, windowsEINVAL),
errors.Is(opErr.Err, windowsECONNREFUSED),
errors.Is(opErr.Err, windowsEHOSTUNREACH):
return true
}
}
return false
}
func ifaceFirstPrivateIP(iface *net.Interface) string {
if iface == nil {
return ""
}
do := func(addrs []net.Addr, v4 bool) net.IP {
for _, addr := range addrs {
if netIP, ok := addr.(*net.IPNet); ok && netIP.IP.IsPrivate() {
if v4 {
return netIP.IP.To4()
}
return netIP.IP
}
}
return nil
}
addrs, _ := iface.Addrs()
if ip := do(addrs, true); ip != nil {
return ip.String()
}
if ip := do(addrs, false); ip != nil {
return ip.String()
}
return ""
}
// defaultRouteIP returns private IP string of the default route if present, prefer IPv4 over IPv6.
func defaultRouteIP() string {
dr, err := interfaces.DefaultRoute()
if err != nil {
return ""
}
drNetIface, err := netInterface(dr.InterfaceName)
if err != nil {
return ""
}
mainLog.Load().Debug().Str("iface", drNetIface.Name).Msg("checking default route interface")
if ip := ifaceFirstPrivateIP(drNetIface); ip != "" {
mainLog.Load().Debug().Str("ip", ip).Msg("found ip with default route interface")
return ip
}
// If we reach here, it means the default route interface is connected directly to ISP.
// We need to find the LAN interface with the same Mac address with drNetIface.
//
// There could be multiple LAN interfaces with the same Mac address, so we find all private
// IPs then using the smallest one.
var addrs []netip.Addr
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
if i.Name == drNetIface.Name {
return
}
if bytes.Equal(i.HardwareAddr, drNetIface.HardwareAddr) {
for _, pfx := range prefixes {
addr := pfx.Addr()
if addr.IsPrivate() {
addrs = append(addrs, addr)
}
}
}
})
if len(addrs) == 0 {
mainLog.Load().Warn().Msg("no default route IP found")
return ""
}
sort.Slice(addrs, func(i, j int) bool {
return addrs[i].Less(addrs[j])
})
ip := addrs[0].String()
mainLog.Load().Debug().Str("ip", ip).Msg("found LAN interface IP")
return ip
}
// canBeLocalUpstream reports whether the IP address can be used as a local upstream.
func canBeLocalUpstream(addr string) bool {
if ip, err := netip.ParseAddr(addr); err == nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
}
return false
}

11
cmd/cli/prog_darwin.go Normal file
View File

@@ -0,0 +1,11 @@
package cli
import (
"github.com/kardianos/service"
)
func setDependencies(svc *service.Config) {}
func setWorkingDirectory(svc *service.Config, dir string) {
svc.WorkingDirectory = dir
}

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

@@ -0,0 +1,14 @@
package cli
import (
"os"
"github.com/kardianos/service"
)
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) {}

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

@@ -0,0 +1,29 @@
package cli
import (
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/dns"
)
func init() {
if r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, "lo"); err == nil {
useSystemdResolved = r.Mode() == "systemd-resolved"
}
}
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=systemd-networkd-wait-online.service",
"Wants=nss-lookup.target",
"After=nss-lookup.target",
}
}
func setWorkingDirectory(svc *service.Config, dir string) {
svc.WorkingDirectory = dir
}

12
cmd/cli/prog_others.go Normal file
View File

@@ -0,0 +1,12 @@
//go:build !linux && !freebsd && !darwin
package cli
import "github.com/kardianos/service"
func setDependencies(svc *service.Config) {}
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.cfg.Service.MetricsQueryStats {
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")
}

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
}

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

@@ -0,0 +1,167 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
)
// 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():
return &procd{&sysV{s}}, 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
}
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
}
func (s *procd) Status() (service.Status, error) {
if !s.installed() {
return service.StatusUnknown, service.ErrNotInstalled
}
exe, err := os.Executable()
if err != nil {
return service.StatusUnknown, nil
}
// Looking for something like "/sbin/ctrld run ".
shellCmd := fmt.Sprintf("ps | grep -q %q", exe+" [r]un ")
if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil {
return service.StatusStopped, nil
}
return service.StatusRunning, nil
}
// procd wraps a service.Service, and provide status command to
// 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()
}
type task struct {
f func() error
abortOnError bool
}
func doTasks(tasks []task) bool {
var prevErr error
for _, task := range tasks {
if err := task.f(); err != nil {
if task.abortOnError {
mainLog.Load().Error().Msg(errors.Join(prevErr, err).Error())
return false
}
prevErr = 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 {
return service.StatusUnknown, nil
}
switch string(bytes.ToLower(bytes.TrimSpace(out))) {
case "running":
return service.StatusRunning, nil
default:
return service.StatusStopped, nil
}
}

View File

@@ -1,6 +1,6 @@
//go:build !windows
package main
package cli
import (
"os"

View File

@@ -1,4 +1,4 @@
package main
package cli
import "golang.org/x/sys/windows"

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

@@ -0,0 +1,112 @@
package cli
import (
"context"
"sync"
"time"
"github.com/miekg/dns"
"github.com/Control-D-Inc/ctrld"
)
const (
// maxFailureRequest is the maximum failed queries allowed before an upstream is marked as down.
maxFailureRequest = 100
// 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.Mutex
checking map[string]bool
down map[string]bool
failureReq map[string]uint64
}
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),
}
for n := range cfg.Upstream {
upstream := upstreamPrefix + n
um.reset(upstream)
}
um.reset(upstreamOS)
return um
}
// increaseFailureCount increase failed queries count for an upstream by 1.
func (um *upstreamMonitor) increaseFailureCount(upstream string) {
um.mu.Lock()
defer um.mu.Unlock()
um.failureReq[upstream] += 1
failedCount := um.failureReq[upstream]
um.down[upstream] = failedCount >= maxFailureRequest
}
// isDown reports whether the given upstream is being marked as down.
func (um *upstreamMonitor) isDown(upstream string) bool {
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()
defer um.mu.Unlock()
um.failureReq[upstream] = 0
um.down[upstream] = false
}
// checkUpstream checks the given upstream status, periodically sending query to upstream
// until successfully. An upstream status/counter will be reset once it becomes reachable.
func (um *upstreamMonitor) checkUpstream(upstream string, uc *ctrld.UpstreamConfig) {
um.mu.Lock()
isChecking := um.checking[upstream]
if isChecking {
um.mu.Unlock()
return
}
um.checking[upstream] = true
um.mu.Unlock()
defer func() {
um.mu.Lock()
um.checking[upstream] = false
um.mu.Unlock()
}()
resolver, err := ctrld.NewResolver(uc)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not check upstream")
return
}
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
check := func() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
uc.ReBootstrap()
_, err := resolver.Resolve(ctx, msg)
return err
}
for {
if err := check(); err == nil {
mainLog.Load().Debug().Msgf("upstream %q is online", uc.Endpoint)
um.reset(upstream)
return
}
time.Sleep(checkUpstreamBackoffSleep)
}
}

View File

@@ -1,668 +0,0 @@
package main
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"log"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/go-playground/validator/v10"
"github.com/kardianos/service"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"tailscale.com/net/interfaces"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
var (
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
defaultConfigWritten = false
defaultConfigFile = "ctrld.toml"
)
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 \/
`
func initCLI() {
// Enable opening via explorer.exe on Windows.
// See: https://github.com/spf13/cobra/issues/844.
cobra.MousetrapHelpText = ""
rootCmd := &cobra.Command{
Use: "ctrld",
Short: strings.TrimLeft(rootShortDesc, "\n"),
Version: "1.1.0",
}
rootCmd.PersistentFlags().CountVarP(
&verbose,
"verbose",
"v",
`verbose log output, "-v" basic logging, "-vv" debug level logging`,
)
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
rootCmd.CompletionOptions.HiddenDefaultCmd = true
runCmd := &cobra.Command{
Use: "run",
Short: "Run the DNS proxy server",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
if daemon && runtime.GOOS == "windows" {
log.Fatal("Cannot run in daemon mode. Please install a Windows service.")
}
noConfigStart := isNoConfigStart(cmd)
writeDefaultConfig := !noConfigStart && configBase64 == ""
configs := []struct {
name string
written bool
}{
// For compatibility, we check for config.toml first, but only read it if exists.
{"config", false},
{"ctrld", writeDefaultConfig},
}
for _, config := range configs {
ctrld.SetConfigName(v, config.name)
v.SetConfigFile(configPath)
if readConfigFile(config.written) {
break
}
}
readBase64Config()
processNoConfigFlags(noConfigStart)
if err := v.Unmarshal(&cfg); err != nil {
log.Fatalf("failed to unmarshal config: %v", err)
}
// Wait for network up.
if !netUp() {
log.Fatal("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()
processCDFlags()
if err := ctrld.ValidateConfig(validator.New(), &cfg); err != nil {
log.Fatalf("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)
}
s, err := service.New(&prog{}, svcConfig)
if err != nil {
mainLog.Fatal().Err(err).Msg("failed create new service")
}
serviceLogger, err := s.Logger(nil)
if err != nil {
mainLog.Error().Err(err).Msg("failed to get service logger")
return
}
if err := s.Run(); err != nil {
if sErr := serviceLogger.Error(err); sErr != nil {
mainLog.Error().Err(sErr).Msg("failed to write service log")
}
mainLog.Error().Err(err).Msg("failed to start service")
}
},
}
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().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")
rootCmd.AddCommand(runCmd)
startCmd := &cobra.Command{
PreRun: checkHasElevatedPrivilege,
Use: "start",
Short: "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 dir, err := os.UserHomeDir(); err == nil {
// WorkingDirectory is not supported on Windows.
sc.WorkingDirectory = dir
// No config path, generating config in HOME directory.
noConfigStart := isNoConfigStart(cmd)
writeDefaultConfig := !noConfigStart && configBase64 == ""
if configPath == "" && writeDefaultConfig {
defaultConfigFile = filepath.Join(dir, defaultConfigFile)
readConfigFile(writeDefaultConfig && cdUID == "")
}
sc.Arguments = append(sc.Arguments, "--homedir="+dir)
}
initLogging()
processCDFlags()
// On Windows, the service will be run as SYSTEM, so if ctrld start as Admin,
// the user home dir is different, so pass specific arguments that relevant here.
if runtime.GOOS == "windows" {
if configPath == "" {
sc.Arguments = append(sc.Arguments, "--config="+defaultConfigFile)
}
}
prog := &prog{}
s, err := service.New(prog, sc)
if err != nil {
stderrMsg(err.Error())
return
}
tasks := []task{
{s.Stop, false},
{s.Uninstall, false},
{s.Install, false},
{s.Start, true},
}
if doTasks(tasks) {
prog.setDNS()
mainLog.Info().Msg("Service started")
}
},
}
// 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().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
stopCmd := &cobra.Command{
PreRun: 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 {
stderrMsg(err.Error())
return
}
initLogging()
if doTasks([]task{{s.Stop, true}}) {
prog.resetDNS()
mainLog.Info().Msg("Service stopped")
}
},
}
stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
restartCmd := &cobra.Command{
PreRun: 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 {
stderrMsg(err.Error())
return
}
initLogging()
if doTasks([]task{{s.Restart, true}}) {
stdoutMsg("Service restarted")
}
},
}
statusCmd := &cobra.Command{
Use: "status",
Short: "Show status of the ctrld service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
s, err := service.New(&prog{}, svcConfig)
if err != nil {
stderrMsg(err.Error())
return
}
status, err := s.Status()
if err != nil {
stderrMsg(err.Error())
return
}
switch status {
case service.StatusUnknown:
stdoutMsg("Unknown status")
case service.StatusRunning:
stdoutMsg("Service is running")
case service.StatusStopped:
stdoutMsg("Service is stopped")
}
},
}
uninstallCmd := &cobra.Command{
PreRun: checkHasElevatedPrivilege,
Use: "uninstall",
Short: "Uninstall the ctrld service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
prog := &prog{}
s, err := service.New(prog, svcConfig)
if err != nil {
stderrMsg(err.Error())
return
}
tasks := []task{
{s.Stop, false},
{s.Uninstall, true},
}
initLogging()
if doTasks(tasks) {
prog.resetDNS()
mainLog.Info().Msg("Service uninstalled")
return
}
},
}
uninstallCmd.Flags().StringVarP(&iface, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
listIfacesCmd := &cobra.Command{
Use: "list",
Short: "List network interfaces of the host",
Args: cobra.NoArgs,
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 {
stderrMsg(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: 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: 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)
if err := rootCmd.Execute(); err != nil {
stderrMsg(err.Error())
os.Exit(1)
}
}
func writeConfigFile() error {
if cfu := v.ConfigFileUsed(); cfu != "" {
defaultConfigFile = cfu
}
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(v.AllSettings()); 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 {
fmt.Println("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 := writeConfigFile(); err != nil {
log.Fatalf("failed to write default config file: %v", err)
} else {
fmt.Println("writing default config file to: " + defaultConfigFile)
}
defaultConfigWritten = true
return false
}
// Otherwise, report fatal error and exit.
log.Fatalf("failed to decode config file: %v", err)
return false
}
func readBase64Config() {
if configBase64 == "" {
return
}
configStr, err := base64.StdEncoding.DecodeString(configBase64)
if err != nil {
log.Fatalf("invalid base64 config: %v", err)
}
if err := v.ReadConfig(bytes.NewReader(configStr)); err != nil {
log.Fatalf("failed to read base64 config: %v", err)
}
}
func processNoConfigFlags(noConfigStart bool) {
if !noConfigStart {
return
}
if listenAddress == "" || primaryUpstream == "" {
log.Fatal(`"listen" and "primary_upstream" flags must be set in no config mode`)
}
processListenFlag()
upstream := map[string]*ctrld.UpstreamConfig{
"0": {
Name: primaryUpstream,
Endpoint: primaryUpstream,
Type: ctrld.ResolverTypeDOH,
},
}
if secondaryUpstream != "" {
upstream["1"] = &ctrld.UpstreamConfig{
Name: secondaryUpstream,
Endpoint: secondaryUpstream,
Type: ctrld.ResolverTypeLegacy,
}
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().Msg("fetching Controld-D configuration")
resolverConfig, err := controld.FetchResolverConfig(cdUID)
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 Controld-D configuration")
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,
},
}
v = viper.NewWithOptions(viper.KeyDelimiter("::"))
v.Set("network", cfg.Network)
v.Set("upstream", cfg.Upstream)
v.Set("listener", cfg.Listener)
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 {
log.Fatalf("invalid listener address: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
log.Fatalf("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 {
mainLog.Fatal().Err(err).Msg("failed to get default route interface")
}
return dri
}

View File

@@ -1,321 +0,0 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"runtime"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
)
const staleTTL = 60 * time.Second
func (p *prog) serveUDP(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) {
domain := canonicalName(m.Question[0].Name)
reqId := requestID()
fmtSrcToDest := fmtRemoteToLocal(listenerNum, w.RemoteAddr().String(), w.LocalAddr().String())
t := time.Now()
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
ctrld.Log(ctx, proxyLog.Debug(), "%s received query: %s", fmtSrcToDest, domain)
upstreams, matched := p.upstreamFor(ctx, listenerNum, listenerConfig, w.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, proxyLog.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")
}
})
// 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.
if runtime.GOOS == "windows" && supportsIPv6ListenLocal() {
go func() {
s := &dns.Server{
Addr: net.JoinHostPort("::1", strconv.Itoa(listenerConfig.Port)),
Net: "udp",
Handler: handler,
}
_ = s.ListenAndServe()
}()
}
s := &dns.Server{
Addr: net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)),
Net: "udp",
Handler: handler,
}
return s.ListenAndServe()
}
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, proxyLog.Info(), "query refused, %s does not match any network policy", addr.String())
return
}
ctrld.Log(ctx, proxyLog.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...)
}
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
matchedRule = source
do(targets)
matched = true
return upstreams, matched
}
}
}
var sourceIP net.IP
switch addr := addr.(type) {
case *net.UDPAddr:
sourceIP = addr.IP
case *net.TCPAddr:
sourceIP = addr.IP
}
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
do(targets)
matched = true
return upstreams, matched
}
}
}
}
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, proxyLog.Debug(), "hit cached response")
setCachedAnswerTTL(answer, now, cachedValue.Expire)
return answer
}
staleAnswer = answer
}
}
resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg {
ctrld.Log(ctx, proxyLog.Debug(), "sending query to %s: %s", upstreams[n], upstreamConfig.Name)
dnsResolver, err := ctrld.NewResolver(upstreamConfig)
if err != nil {
ctrld.Log(ctx, proxyLog.Error().Err(err), "failed to create resolver")
return nil
}
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
}
answer, err := dnsResolver.Resolve(resolveCtx, msg)
if err != nil {
ctrld.Log(ctx, proxyLog.Error().Err(err), "failed to resolve query")
return nil
}
return answer
}
for n, upstreamConfig := range upstreamConfigs {
answer := resolve(n, upstreamConfig, msg)
if answer == nil {
if serveStaleCache && staleAnswer != nil {
ctrld.Log(ctx, proxyLog.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, proxyLog.Debug(), "failover rcode matched, process to next upstream")
continue
}
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, proxyLog.Debug(), "add cached response")
}
return answer
}
ctrld.Log(ctx, proxyLog.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
}
var osUpstreamConfig = &ctrld.UpstreamConfig{
Name: "OS resolver",
Type: ctrld.ResolverTypeOS,
}

View File

@@ -1,154 +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
}{
{"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},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
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)
}
})
}
}
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)
}

View File

@@ -1,113 +1,7 @@
package main
import (
"fmt"
"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
bootstrapDNS = "76.76.2.0"
rootLogger = zerolog.New(io.Discard)
mainLog = rootLogger
proxyLog = rootLogger
cdUID string
iface string
ifaceStartStop string
)
import "github.com/Control-D-Inc/ctrld/cmd/cli"
func main() {
ctrld.InitConfig(v, "ctrld")
initCLI()
}
func normalizeLogFilePath(logFilePath string) string {
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
return logFilePath
}
if homedir != "" {
return filepath.Join(homedir, logFilePath)
}
dir, _ := os.UserHomeDir()
if dir == "" {
return logFilePath
}
return filepath.Join(dir, logFilePath)
}
func initLogging() {
writers := []io.Writer{io.Discard}
isLog := cfg.Service.LogLevel != ""
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
// Create parent directory if necessary.
if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil {
fmt.Fprintf(os.Stderr, "failed to create log path: %v", err)
os.Exit(1)
}
logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_RDWR, os.FileMode(0o600))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create log file: %v", err)
os.Exit(1)
}
isLog = true
writers = append(writers, logFile)
}
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
consoleWriter := zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
w.TimeFormat = time.StampMilli
})
writers = append(writers, consoleWriter)
multi := zerolog.MultiLevelWriter(writers...)
mainLog = mainLog.Output(multi).With().Timestamp().Str("prefix", "main").Logger()
if verbose > 0 || isLog {
proxyLog = proxyLog.Output(multi).With().Timestamp().Logger()
// TODO: find a better way.
ctrld.ProxyLog = proxyLog
}
zerolog.SetGlobalLevel(zerolog.InfoLevel)
logLevel := cfg.Service.LogLevel
if 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()
}

View File

@@ -1,65 +0,0 @@
package main
import (
"context"
"net"
"sync"
"time"
"tailscale.com/logtail/backoff"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
const (
controldIPv6Test = "ipv6.controld.io"
)
var (
stackOnce sync.Once
ipv6Enabled bool
canListenIPv6Local bool
hasNetworkUp bool
)
func probeStack() {
b := backoff.NewBackoff("probeStack", func(format string, args ...any) {}, time.Minute)
for {
if _, err := controld.Dialer.Dial("udp", net.JoinHostPort(bootstrapDNS, "53")); err == nil {
hasNetworkUp = true
break
} else {
b.BackOff(context.Background(), err)
}
}
if _, err := controld.Dialer.Dial("tcp6", net.JoinHostPort(controldIPv6Test, "80")); err == nil {
ipv6Enabled = true
}
if ln, err := net.Listen("tcp6", "[::1]:53"); err == nil {
ln.Close()
canListenIPv6Local = true
}
}
func netUp() bool {
stackOnce.Do(probeStack)
return hasNetworkUp
}
func supportsIPv6() bool {
stackOnce.Do(probeStack)
return ipv6Enabled
}
func supportsIPv6ListenLocal() bool {
stackOnce.Do(probeStack)
return canListenIPv6Local
}
// isIPv6 checks if the provided IP is v6.
//
//lint:ignore U1000 use in os_windows.go
func isIPv6(ip string) bool {
parsedIP := net.ParseIP(ip)
return parsedIP != nil && parsedIP.To4() == nil && parsedIP.To16() != nil
}

View File

@@ -1,34 +0,0 @@
package main
import (
"bufio"
"bytes"
"net"
"os/exec"
"strings"
)
func patchNetIfaceName(iface *net.Interface) error {
b, err := exec.Command("networksetup", "-listnetworkserviceorder").Output()
if err != nil {
return err
}
scanner := bufio.NewScanner(bytes.NewReader(b))
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "*") {
// Network services is disabled.
continue
}
if !strings.Contains(line, "Device: "+iface.Name) {
continue
}
parts := strings.Split(line, ",")
if _, networkServiceName, ok := strings.Cut(parts[0], "(Hardware Port: "); ok {
mainLog.Debug().Str("network_service", networkServiceName).Msg("found network service name for interface")
iface.Name = networkServiceName
}
}
return nil
}

View File

@@ -1,194 +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"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
var logf = func(format string, args ...any) {
mainLog.Debug().Msgf(format, args...)
}
// 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 err := cmd.Run(); err != nil {
mainLog.Error().Err(err).Msg("allocateIP failed")
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) error {
if r, err := dns.NewOSConfigurator(logf, iface.Name); err == nil {
if err := r.Close(); err != nil {
mainLog.Error().Err(err).Msg("failed to rollback DNS setting")
return err
}
if r.Mode() == "direct" {
return 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 supportsIPv6() {
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,251 +0,0 @@
package main
import (
"errors"
"net"
"os"
"strconv"
"sync"
"syscall"
"github.com/kardianos/service"
"github.com/miekg/dns"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
)
var errWindowsAddrInUse = syscall.Errno(0x2740)
var svcConfig = &service.Config{
Name: "ctrld",
DisplayName: "Control-D Helper Service",
}
type prog struct {
cfg *ctrld.Config
cache dnscache.Cacher
}
func (p *prog) Start(s service.Service) error {
p.cfg = &cfg
go p.run()
mainLog.Info().Msg("Service started")
return nil
}
func (p *prog) run() {
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 {
proxyLog.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 == "" {
// resolve it manually and set the bootstrap ip
c := new(dns.Client)
for _, dnsType := range []uint16{dns.TypeAAAA, dns.TypeA} {
if !supportsIPv6() && dnsType == dns.TypeAAAA {
continue
}
m := new(dns.Msg)
m.SetQuestion(uc.Domain+".", dnsType)
m.RecursionDesired = true
r, _, err := c.Exchange(m, net.JoinHostPort(bootstrapDNS, "53"))
if err != nil {
proxyLog.Error().Err(err).Msgf("could not resolve domain %s for upstream.%s", uc.Domain, n)
continue
}
if r.Rcode != dns.RcodeSuccess {
proxyLog.Error().Msgf("could not resolve domain return code: %d, upstream.%s", r.Rcode, n)
continue
}
if len(r.Answer) == 0 {
continue
}
for _, a := range r.Answer {
switch ar := a.(type) {
case *dns.A:
uc.BootstrapIP = ar.A.String()
case *dns.AAAA:
uc.BootstrapIP = ar.AAAA.String()
default:
continue
}
mainLog.Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("Setting bootstrap IP for upstream.%s", n)
// Stop if we reached here, because we got the bootstrap IP from r.Answer.
break
}
// If we reached here, uc.BootstrapIP was set, nothing to do anymore.
break
}
}
uc.SetupTransport()
}
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 {
proxyLog.Error().Msgf("missing upstream config for: [listener.%s]", listenerNum)
return
}
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
mainLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, addr)
err := p.serveUDP(listenerNum)
if err != nil && !defaultConfigWritten {
proxyLog.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 {
if sErr, ok := opErr.Err.(*os.SyscallError); ok && errors.Is(opErr.Err, syscall.EADDRINUSE) || errors.Is(sErr.Err, errWindowsAddrInUse) {
proxyLog.Warn().Msgf("Address %s already in used, pick a random one", addr)
pc, err := net.ListenPacket("udp", net.JoinHostPort(listenerConfig.IP, "0"))
if err != nil {
proxyLog.Fatal().Err(err).Msg("failed to listen packet")
return
}
_, portStr, _ := net.SplitHostPort(pc.LocalAddr().String())
port, err := strconv.Atoi(portStr)
if err != nil {
proxyLog.Fatal().Err(err).Msg("malformed port")
return
}
listenerConfig.Port = port
v.Set("listener", map[string]*ctrld.ListenerConfig{
"0": {
IP: "127.0.0.1",
Port: port,
},
})
if err := writeConfigFile(); err != nil {
proxyLog.Fatal().Err(err).Msg("failed to write config file")
} else {
mainLog.Info().Msg("writing config file to: " + defaultConfigFile)
}
mainLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, pc.LocalAddr())
// There can be a race between closing the listener and start our own UDP server, but it's
// rare, and we only do this once, so let conservative here.
if err := pc.Close(); err != nil {
proxyLog.Fatal().Err(err).Msg("failed to close packet conn")
return
}
if err := p.serveUDP(listenerNum); err != nil {
proxyLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum)
return
}
}
}
proxyLog.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
}
mainLog.Info().Msg("Service stopped")
return nil
}
func (p *prog) allocateIP(ip string) error {
if !p.cfg.Service.AllocateIP {
return nil
}
return allocateIP(ip)
}
func (p *prog) deAllocateIP() error {
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() {
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() {
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")
}

View File

@@ -1,20 +0,0 @@
package main
import (
"github.com/kardianos/service"
)
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",
}
}

View File

@@ -1,10 +0,0 @@
//go:build !linux
// +build !linux
package main
import "github.com/kardianos/service"
func (p *prog) preRun() {}
func setDependencies(svc *service.Config) {}

View File

@@ -1,45 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func stderrMsg(msg string) {
_, _ = fmt.Fprintln(os.Stderr, msg)
}
func stdoutMsg(msg string) {
_, _ = fmt.Fprintln(os.Stdout, msg)
}
type task struct {
f func() error
abortOnError bool
}
func doTasks(tasks []task) bool {
for _, task := range tasks {
if err := task.f(); err != nil {
if task.abortOnError {
stderrMsg(err.Error())
return false
}
}
}
return true
}
func checkHasElevatedPrivilege(cmd *cobra.Command, args []string) {
ok, err := hasElevatedPrivilege()
if err != nil {
fmt.Printf("could not detect user privilege: %v", err)
return
}
if !ok {
fmt.Println("Please relaunch process with admin/root privilege.")
os.Exit(1)
}
}

75
cmd/ctrld_library/main.go Normal file
View File

@@ -0,0 +1,75 @@
package ctrld_library
import (
"github.com/Control-D-Inc/ctrld/cmd/cli"
)
// Controller holds global state
type Controller struct {
stopCh chan struct{}
AppCallback AppCallback
Config cli.AppConfig
}
// NewController provides reference to global state to be managed by android vpn service and iOS network extension.
// reference is not safe for concurrent use.
func NewController(appCallback AppCallback) *Controller {
return &Controller{AppCallback: appCallback}
}
// AppCallback provides access to app instance.
type AppCallback interface {
Hostname() string
LanIp() string
MacAddress() string
Exit(error string)
}
// Start configures utility with config.toml from provided directory.
// This function will block until Stop is called
// Check port availability prior to calling it.
func (c *Controller) Start(CdUID string, HomeDir string, UpstreamProto string, logLevel int, logPath string) {
if c.stopCh == nil {
c.stopCh = make(chan struct{})
c.Config = cli.AppConfig{
CdUID: CdUID,
HomeDir: HomeDir,
UpstreamProto: UpstreamProto,
Verbose: logLevel,
LogPath: logPath,
}
appCallback := mapCallback(c.AppCallback)
cli.RunMobile(&c.Config, &appCallback, c.stopCh)
}
}
// As workaround to avoid circular dependency between cli and ctrld_library module
func mapCallback(callback AppCallback) cli.AppCallback {
return cli.AppCallback{
HostName: func() string {
return callback.Hostname()
},
LanIp: func() string {
return callback.LanIp()
},
MacAddress: func() string {
return callback.MacAddress()
},
Exit: func(err string) {
callback.Exit(err)
},
}
}
func (c *Controller) Stop() bool {
if c.stopCh != nil {
close(c.stopCh)
c.stopCh = nil
return true
}
return false
}
func (c *Controller) IsRunning() bool {
return c.stopCh != nil
}

670
config.go
View File

@@ -2,44 +2,98 @@ package ctrld
import (
"context"
crand "crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"io"
"math/rand"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Control-D-Inc/ctrld/internal/dnsrcode"
"github.com/go-playground/validator/v10"
"github.com/lucas-clemente/quic-go"
"github.com/lucas-clemente/quic-go/http3"
"github.com/miekg/dns"
"github.com/spf13/viper"
"golang.org/x/sync/singleflight"
"tailscale.com/logtail/backoff"
"tailscale.com/net/tsaddr"
"github.com/Control-D-Inc/ctrld/internal/dnsrcode"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
// IpStackBoth ...
const (
// IpStackBoth indicates that ctrld will use either ipv4 or ipv6 for connecting to upstream,
// depending on which stack is available when receiving the DNS query.
IpStackBoth = "both"
// IpStackV4 indicates that ctrld will use only ipv4 for connecting to upstream.
IpStackV4 = "v4"
// IpStackV6 indicates that ctrld will use only ipv6 for connecting to upstream.
IpStackV6 = "v6"
// IpStackSplit indicates that ctrld will use either ipv4 or ipv6 for connecting to upstream,
// depending on the record type of the DNS query.
IpStackSplit = "split"
controlDComDomain = "controld.com"
controlDNetDomain = "controld.net"
controlDDevDomain = "controld.dev"
)
var (
controldParentDomains = []string{controlDComDomain, controlDNetDomain, controlDDevDomain}
controldVerifiedDomain = map[string]string{
controlDComDomain: "verify.controld.com",
controlDDevDomain: "verify.controld.dev",
}
)
// SetConfigName set the config name that ctrld will look for.
// DEPRECATED: use SetConfigNameWithPath instead.
func SetConfigName(v *viper.Viper, name string) {
v.SetConfigName(name)
configPath := "$HOME"
// viper has its own way to get user home directory: https://github.com/spf13/viper/blob/v1.14.0/util.go#L134
// To be consistent, we prefer os.UserHomeDir instead.
if homeDir, err := os.UserHomeDir(); err == nil {
configPath = homeDir
}
SetConfigNameWithPath(v, name, configPath)
}
// SetConfigNameWithPath set the config path and name that ctrld will look for.
func SetConfigNameWithPath(v *viper.Viper, name, configPath string) {
v.SetConfigName(name)
v.AddConfigPath(configPath)
v.AddConfigPath(".")
}
// InitConfig initializes default config values for given *viper.Viper instance.
func InitConfig(v *viper.Viper, name string) {
SetConfigName(v, name)
v.SetDefault("listener", map[string]*ListenerConfig{
"0": {
IP: "127.0.0.1",
Port: 53,
IP: "",
Port: 0,
Policy: &ListenerPolicyConfig{
Name: "Main Policy",
Networks: []Rule{
{"network.0": []string{"upstream.0"}},
},
Rules: []Rule{
{"example.com": []string{"upstream.0"}},
{"*.ads.com": []string{"upstream.1"}},
},
},
},
})
v.SetDefault("network", map[string]*NetworkConfig{
@@ -69,21 +123,82 @@ func InitConfig(v *viper.Viper, name string) {
// Config represents ctrld supported configuration.
type Config struct {
Service ServiceConfig `mapstructure:"service" toml:"service,omitempty"`
Listener map[string]*ListenerConfig `mapstructure:"listener" toml:"listener" validate:"min=1,dive"`
Network map[string]*NetworkConfig `mapstructure:"network" toml:"network" validate:"min=1,dive"`
Upstream map[string]*UpstreamConfig `mapstructure:"upstream" toml:"upstream" validate:"min=1,dive"`
Listener map[string]*ListenerConfig `mapstructure:"listener" toml:"listener" validate:"min=1,dive"`
}
// HasUpstreamSendClientInfo reports whether the config has any upstream
// is configured to send client info to Control D DNS server.
func (c *Config) HasUpstreamSendClientInfo() bool {
for _, uc := range c.Upstream {
if uc.UpstreamSendClientInfo() {
return true
}
}
return false
}
// FirstListener returns the first listener config of current config. Listeners are sorted numerically.
//
// It panics if Config has no listeners configured.
func (c *Config) FirstListener() *ListenerConfig {
listeners := make([]int, 0, len(c.Listener))
for k := range c.Listener {
n, err := strconv.Atoi(k)
if err != nil {
continue
}
listeners = append(listeners, n)
}
if len(listeners) == 0 {
panic("missing listener config")
}
sort.Ints(listeners)
return c.Listener[strconv.Itoa(listeners[0])]
}
// FirstUpstream returns the first upstream of current config. Upstreams are sorted numerically.
//
// It panics if Config has no upstreams configured.
func (c *Config) FirstUpstream() *UpstreamConfig {
upstreams := make([]int, 0, len(c.Upstream))
for k := range c.Upstream {
n, err := strconv.Atoi(k)
if err != nil {
continue
}
upstreams = append(upstreams, n)
}
if len(upstreams) == 0 {
panic("missing listener config")
}
sort.Ints(upstreams)
return c.Upstream[strconv.Itoa(upstreams[0])]
}
// ServiceConfig specifies the general ctrld config.
type ServiceConfig struct {
LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"`
LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"`
CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"`
CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"`
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"`
LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"`
CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"`
CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"`
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"`
MaxConcurrentRequests *int `mapstructure:"max_concurrent_requests" toml:"max_concurrent_requests,omitempty" validate:"omitempty,gte=0"`
DHCPLeaseFile string `mapstructure:"dhcp_lease_file_path" toml:"dhcp_lease_file_path" validate:"omitempty,file"`
DHCPLeaseFileFormat string `mapstructure:"dhcp_lease_file_format" toml:"dhcp_lease_file_format" validate:"required_unless=DHCPLeaseFile '',omitempty,oneof=dnsmasq isc-dhcp"`
DiscoverMDNS *bool `mapstructure:"discover_mdns" toml:"discover_mdns,omitempty"`
DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_arp,omitempty"`
DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"`
DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"`
DiscoverHosts *bool `mapstructure:"discover_hosts" toml:"discover_hosts,omitempty"`
DiscoverRefreshInterval int `mapstructure:"discover_refresh_interval" toml:"discover_refresh_interval,omitempty"`
ClientIDPref string `mapstructure:"client_id_preference" toml:"client_id_preference,omitempty" validate:"omitempty,oneof=host mac"`
MetricsQueryStats bool `mapstructure:"metrics_query_stats" toml:"metrics_query_stats,omitempty"`
MetricsListener string `mapstructure:"metrics_listener" toml:"metrics_listener,omitempty"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
}
// NetworkConfig specifies configuration for networks where ctrld will handle requests.
@@ -95,22 +210,62 @@ type NetworkConfig struct {
// UpstreamConfig specifies configuration for upstreams that ctrld will forward requests to.
type UpstreamConfig struct {
Name string `mapstructure:"name" toml:"name,omitempty"`
Type string `mapstructure:"type" toml:"type,omitempty" validate:"oneof=doh doh3 dot doq os legacy"`
Endpoint string `mapstructure:"endpoint" toml:"endpoint,omitempty" validate:"required_unless=Type os"`
BootstrapIP string `mapstructure:"bootstrap_ip" toml:"bootstrap_ip,omitempty"`
Domain string `mapstructure:"-" toml:"-"`
Timeout int `mapstructure:"timeout" toml:"timeout,omitempty" validate:"gte=0"`
transport *http.Transport `mapstructure:"-" toml:"-"`
http3RoundTripper *http3.RoundTripper `mapstructure:"-" toml:"-"`
Name string `mapstructure:"name" toml:"name,omitempty"`
Type string `mapstructure:"type" toml:"type,omitempty" validate:"oneof=doh doh3 dot doq os legacy"`
Endpoint string `mapstructure:"endpoint" toml:"endpoint,omitempty"`
BootstrapIP string `mapstructure:"bootstrap_ip" toml:"bootstrap_ip,omitempty"`
Domain string `mapstructure:"-" toml:"-"`
IPStack string `mapstructure:"ip_stack" toml:"ip_stack,omitempty" validate:"ipstack"`
Timeout int `mapstructure:"timeout" toml:"timeout,omitempty" validate:"gte=0"`
// The caller should not access this field directly.
// Use UpstreamSendClientInfo instead.
SendClientInfo *bool `mapstructure:"send_client_info" toml:"send_client_info,omitempty"`
// The caller should not access this field directly.
// Use IsDiscoverable instead.
Discoverable *bool `mapstructure:"discoverable" toml:"discoverable"`
g singleflight.Group
rebootstrap atomic.Bool
bootstrapIPs []string
bootstrapIPs4 []string
bootstrapIPs6 []string
transport *http.Transport
transportOnce sync.Once
transport4 *http.Transport
transport6 *http.Transport
http3RoundTripper http.RoundTripper
http3RoundTripper4 http.RoundTripper
http3RoundTripper6 http.RoundTripper
certPool *x509.CertPool
u *url.URL
uid string
}
// ListenerConfig specifies the networks configuration that ctrld will run on.
type ListenerConfig struct {
IP string `mapstructure:"ip" toml:"ip,omitempty" validate:"ip"`
Port int `mapstructure:"port" toml:"port,omitempty" validate:"gt=0"`
Restricted bool `mapstructure:"restricted" toml:"restricted,omitempty"`
Policy *ListenerPolicyConfig `mapstructure:"policy" toml:"policy,omitempty"`
IP string `mapstructure:"ip" toml:"ip,omitempty" validate:"iporempty"`
Port int `mapstructure:"port" toml:"port,omitempty" validate:"gte=0"`
Restricted bool `mapstructure:"restricted" toml:"restricted,omitempty"`
AllowWanClients bool `mapstructure:"allow_wan_clients" toml:"allow_wan_clients,omitempty"`
Policy *ListenerPolicyConfig `mapstructure:"policy" toml:"policy,omitempty"`
}
// IsDirectDnsListener reports whether ctrld can be a direct listener on port 53.
// It returns true only if ctrld can listen on port 53 for all interfaces. That means
// there's no other software listening on port 53.
//
// If someone listening on port 53, or ctrld could only listen on port 53 for a specific
// interface, ctrld could only be configured as a DNS forwarder.
func (lc *ListenerConfig) IsDirectDnsListener() bool {
if lc == nil || lc.Port != 53 {
return false
}
switch lc.IP {
case "", "::", "0.0.0.0":
return true
default:
return false
}
}
// ListenerPolicyConfig specifies the policy rules for ctrld to filter incoming requests.
@@ -118,6 +273,7 @@ type ListenerPolicyConfig struct {
Name string `mapstructure:"name" toml:"name,omitempty"`
Networks []Rule `mapstructure:"networks" toml:"networks,omitempty,inline,multiline" validate:"dive,len=1"`
Rules []Rule `mapstructure:"rules" toml:"rules,omitempty,inline,multiline" validate:"dive,len=1"`
Macs []Rule `mapstructure:"macs" toml:"macs,omitempty,inline,multiline" validate:"dive,len=1"`
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
}
@@ -129,22 +285,156 @@ type Rule map[string][]string
// Init initialized necessary values for an UpstreamConfig.
func (uc *UpstreamConfig) Init() {
uc.uid = upstreamUID()
if u, err := url.Parse(uc.Endpoint); err == nil {
uc.Domain = u.Host
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
uc.u = u
}
}
if uc.Domain != "" {
if uc.Domain == "" {
if !strings.Contains(uc.Endpoint, ":") {
uc.Domain = uc.Endpoint
uc.Endpoint = net.JoinHostPort(uc.Endpoint, defaultPortFor(uc.Type))
}
host, _, _ := net.SplitHostPort(uc.Endpoint)
uc.Domain = host
if net.ParseIP(uc.Domain) != nil {
uc.BootstrapIP = uc.Domain
}
}
if uc.IPStack == "" {
if uc.isControlD() {
uc.IPStack = IpStackSplit
} else {
uc.IPStack = IpStackBoth
}
}
}
// VerifyDomain returns the domain name that could be resolved by the upstream endpoint.
// It returns empty for non-ControlD upstream endpoint.
func (uc *UpstreamConfig) VerifyDomain() string {
domain := uc.Domain
if domain == "" {
if u, err := url.Parse(uc.Endpoint); err == nil {
domain = u.Hostname()
}
}
for _, parent := range controldParentDomains {
if dns.IsSubDomain(parent, domain) {
return controldVerifiedDomain[parent]
}
}
return ""
}
// UpstreamSendClientInfo reports whether the upstream is
// configured to send client info to Control D DNS server.
//
// Client info includes:
// - MAC
// - Lan IP
// - Hostname
func (uc *UpstreamConfig) UpstreamSendClientInfo() bool {
if uc.SendClientInfo != nil {
return *uc.SendClientInfo
}
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
if uc.isControlD() || uc.isNextDNS() {
return true
}
}
return false
}
// IsDiscoverable reports whether the upstream can be used for PTR discovery.
// The caller must ensure uc.Init() was called before calling this.
func (uc *UpstreamConfig) IsDiscoverable() bool {
if uc.Discoverable != nil {
return *uc.Discoverable
}
switch uc.Type {
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate:
if ip, err := netip.ParseAddr(uc.Domain); err == nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
}
}
return false
}
// BootstrapIPs returns the bootstrap IPs list of upstreams.
func (uc *UpstreamConfig) BootstrapIPs() []string {
return uc.bootstrapIPs
}
// SetCertPool sets the system cert pool used for TLS connections.
func (uc *UpstreamConfig) SetCertPool(cp *x509.CertPool) {
uc.certPool = cp
}
// SetupBootstrapIP manually find all available IPs of the upstream.
// The first usable IP will be used as bootstrap IP of the upstream.
func (uc *UpstreamConfig) SetupBootstrapIP() {
uc.setupBootstrapIP(true)
}
// UID returns the unique identifier of the upstream.
func (uc *UpstreamConfig) UID() string {
return uc.uid
}
// SetupBootstrapIP manually find all available IPs of the upstream.
// The first usable IP will be used as bootstrap IP of the upstream.
func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) {
b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second)
isControlD := uc.isControlD()
for {
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, withBootstrapDNS)
// For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses,
// filtering them out here to prevent weird behavior.
if isControlD {
n := 0
for _, ip := range uc.bootstrapIPs {
netIP := net.ParseIP(ip)
if netIP != nil && !netIP.IsPrivate() {
uc.bootstrapIPs[n] = ip
n++
}
}
uc.bootstrapIPs = uc.bootstrapIPs[:n]
}
if len(uc.bootstrapIPs) > 0 {
break
}
ProxyLogger.Load().Warn().Msg("could not resolve bootstrap IPs, retrying...")
b.BackOff(context.Background(), errors.New("no bootstrap IPs"))
}
for _, ip := range uc.bootstrapIPs {
if ctrldnet.IsIPv6(ip) {
uc.bootstrapIPs6 = append(uc.bootstrapIPs6, ip)
} else {
uc.bootstrapIPs4 = append(uc.bootstrapIPs4, ip)
}
}
ProxyLogger.Load().Debug().Msgf("bootstrap IPs: %v", uc.bootstrapIPs)
}
// ReBootstrap re-setup the bootstrap IP and the transport.
func (uc *UpstreamConfig) ReBootstrap() {
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
default:
return
}
if !strings.Contains(uc.Endpoint, ":") {
uc.Domain = uc.Endpoint
uc.Endpoint = net.JoinHostPort(uc.Endpoint, defaultPortFor(uc.Type))
}
host, _, _ := net.SplitHostPort(uc.Endpoint)
uc.Domain = host
if net.ParseIP(uc.Domain) != nil {
uc.BootstrapIP = uc.Domain
}
_, _, _ = uc.g.Do("ReBootstrap", func() (any, error) {
if uc.rebootstrap.CompareAndSwap(false, true) {
ProxyLogger.Load().Debug().Msg("re-bootstrapping upstream ip")
}
return true, nil
})
}
// SetupTransport initializes the network transport used to connect to upstream server.
@@ -159,66 +449,186 @@ func (uc *UpstreamConfig) SetupTransport() {
}
func (uc *UpstreamConfig) setupDOHTransport() {
uc.transport = http.DefaultTransport.(*http.Transport).Clone()
uc.transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 10 * time.Second,
switch uc.IPStack {
case IpStackBoth, "":
uc.transport = uc.newDOHTransport(uc.bootstrapIPs)
case IpStackV4:
uc.transport = uc.newDOHTransport(uc.bootstrapIPs4)
case IpStackV6:
uc.transport = uc.newDOHTransport(uc.bootstrapIPs6)
case IpStackSplit:
uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4)
if hasIPv6() {
uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6)
} else {
uc.transport6 = uc.transport4
}
Log(ctx, ProxyLog.Debug(), "debug dial context %s - %s - %s", addr, network, bootstrapDNS)
// if we have a bootstrap ip set, use it to avoid DNS lookup
if uc.BootstrapIP != "" {
if _, port, _ := net.SplitHostPort(addr); port != "" {
addr = net.JoinHostPort(uc.BootstrapIP, port)
}
Log(ctx, ProxyLog.Debug(), "sending doh request to: %s", addr)
}
return dialer.DialContext(ctx, network, addr)
uc.transport = uc.newDOHTransport(uc.bootstrapIPs)
}
uc.pingUpstream()
}
func (uc *UpstreamConfig) setupDOH3Transport() {
uc.http3RoundTripper = &http3.RoundTripper{}
uc.http3RoundTripper.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
host := addr
ProxyLog.Debug().Msgf("debug dial context D0H3 %s - %s", addr, bootstrapDNS)
// if we have a bootstrap ip set, use it to avoid DNS lookup
func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 100
transport.TLSClientConfig = &tls.Config{
RootCAs: uc.certPool,
ClientSessionCache: tls.NewLRUClientSessionCache(0),
}
dialerTimeoutMs := 2000
if uc.Timeout > 0 && uc.Timeout < dialerTimeoutMs {
dialerTimeoutMs = uc.Timeout
}
dialerTimeout := time.Duration(dialerTimeoutMs) * time.Millisecond
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, _ := net.SplitHostPort(addr)
if uc.BootstrapIP != "" {
if _, port, _ := net.SplitHostPort(addr); port != "" {
addr = net.JoinHostPort(uc.BootstrapIP, port)
}
ProxyLog.Debug().Msgf("sending doh3 request to: %s", addr)
dialer := net.Dialer{Timeout: dialerTimeout, KeepAlive: dialerTimeout}
addr := net.JoinHostPort(uc.BootstrapIP, port)
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", addr)
return dialer.DialContext(ctx, network, addr)
}
remoteAddr, err := net.ResolveUDPAddr("udp", addr)
pd := &ctrldnet.ParallelDialer{}
pd.Timeout = dialerTimeout
pd.KeepAlive = dialerTimeout
dialAddrs := make([]string, len(addrs))
for i := range addrs {
dialAddrs[i] = net.JoinHostPort(addrs[i], port)
}
conn, err := pd.DialContext(ctx, network, dialAddrs)
if err != nil {
return nil, err
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
return quic.DialEarlyContext(ctx, udpConn, remoteAddr, host, tlsCfg, cfg)
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", conn.RemoteAddr())
return conn, nil
}
uc.pingUpstream()
runtime.SetFinalizer(transport, func(transport *http.Transport) {
transport.CloseIdleConnections()
})
return transport
}
func (uc *UpstreamConfig) pingUpstream() {
// Warming up the transport by querying a test packet.
dnsResolver, err := NewResolver(uc)
if err != nil {
ProxyLog.Error().Err(err).Msgf("failed to create resolver for upstream: %s", uc.Name)
// Ping warms up the connection to DoH/DoH3 upstream.
func (uc *UpstreamConfig) Ping() {
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3:
default:
return
}
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
msg.MsgHdr.RecursionDesired = true
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = dnsResolver.Resolve(ctx, msg)
ping := func(t http.RoundTripper) {
if t == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "HEAD", uc.Endpoint, nil)
resp, _ := t.RoundTrip(req)
if resp == nil {
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
}
for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} {
switch uc.Type {
case ResolverTypeDOH:
ping(uc.dohTransport(typ))
case ResolverTypeDOH3:
ping(uc.doh3Transport(typ))
}
}
}
func (uc *UpstreamConfig) isControlD() bool {
domain := uc.Domain
if domain == "" {
if u, err := url.Parse(uc.Endpoint); err == nil {
domain = u.Hostname()
}
}
for _, parent := range controldParentDomains {
if dns.IsSubDomain(parent, domain) {
return true
}
}
return false
}
func (uc *UpstreamConfig) isNextDNS() bool {
domain := uc.Domain
if domain == "" {
if u, err := url.Parse(uc.Endpoint); err == nil {
domain = u.Hostname()
}
}
return domain == "dns.nextdns.io"
}
func (uc *UpstreamConfig) dohTransport(dnsType uint16) http.RoundTripper {
uc.transportOnce.Do(func() {
uc.SetupTransport()
})
if uc.rebootstrap.CompareAndSwap(true, false) {
uc.SetupTransport()
}
switch uc.IPStack {
case IpStackBoth, IpStackV4, IpStackV6:
return uc.transport
case IpStackSplit:
switch dnsType {
case dns.TypeA:
return uc.transport4
default:
return uc.transport6
}
}
return uc.transport
}
func (uc *UpstreamConfig) bootstrapIPForDNSType(dnsType uint16) string {
switch uc.IPStack {
case IpStackBoth:
return pick(uc.bootstrapIPs)
case IpStackV4:
return pick(uc.bootstrapIPs4)
case IpStackV6:
return pick(uc.bootstrapIPs6)
case IpStackSplit:
switch dnsType {
case dns.TypeA:
return pick(uc.bootstrapIPs4)
default:
if hasIPv6() {
return pick(uc.bootstrapIPs6)
}
return pick(uc.bootstrapIPs4)
}
}
return pick(uc.bootstrapIPs)
}
func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
switch uc.IPStack {
case IpStackBoth:
return "tcp-tls", "udp"
case IpStackV4:
return "tcp4-tls", "udp4"
case IpStackV6:
return "tcp6-tls", "udp6"
case IpStackSplit:
switch dnsType {
case dns.TypeA:
return "tcp4-tls", "udp4"
default:
if hasIPv6() {
return "tcp6-tls", "udp6"
}
return "tcp4-tls", "udp4"
}
}
return "tcp-tls", "udp"
}
// Init initialized necessary values for an ListenerConfig.
@@ -234,6 +644,9 @@ func (lc *ListenerConfig) Init() {
// ValidateConfig validates the given config.
func ValidateConfig(validate *validator.Validate, cfg *Config) error {
_ = validate.RegisterValidation("dnsrcode", validateDnsRcode)
_ = validate.RegisterValidation("ipstack", validateIpStack)
_ = validate.RegisterValidation("iporempty", validateIpOrEmpty)
validate.RegisterStructValidation(upstreamConfigStructLevelValidation, UpstreamConfig{})
return validate.Struct(cfg)
}
@@ -241,6 +654,49 @@ func validateDnsRcode(fl validator.FieldLevel) bool {
return dnsrcode.FromString(fl.Field().String()) != -1
}
func validateIpStack(fl validator.FieldLevel) bool {
switch fl.Field().String() {
case IpStackBoth, IpStackV4, IpStackV6, IpStackSplit, "":
return true
default:
return false
}
}
func validateIpOrEmpty(fl validator.FieldLevel) bool {
val := fl.Field().String()
if val == "" {
return true
}
return net.ParseIP(val) != nil
}
func upstreamConfigStructLevelValidation(sl validator.StructLevel) {
uc := sl.Current().Addr().Interface().(*UpstreamConfig)
if uc.Type == ResolverTypeOS {
return
}
// Endpoint is required for non os resolver.
if uc.Endpoint == "" {
sl.ReportError(uc.Endpoint, "endpoint", "Endpoint", "required_unless", "")
return
}
// DoH/DoH3 requires endpoint is an HTTP url.
if uc.Type == ResolverTypeDOH || uc.Type == ResolverTypeDOH3 {
u, err := url.Parse(uc.Endpoint)
if err != nil || u.Host == "" {
sl.ReportError(uc.Endpoint, "endpoint", "Endpoint", "http_url", "")
return
}
if u.Scheme != "http" && u.Scheme != "https" {
sl.ReportError(uc.Endpoint, "endpoint", "Endpoint", "http_url", "")
return
}
}
}
func defaultPortFor(typ string) string {
switch typ {
case ResolverTypeDOH, ResolverTypeDOH3:
@@ -252,3 +708,43 @@ func defaultPortFor(typ string) string {
}
return "53"
}
// ResolverTypeFromEndpoint tries guessing the resolver type with a given endpoint
// using following rules:
//
// - If endpoint is an IP address -> ResolverTypeLegacy
// - If endpoint starts with "https://" -> ResolverTypeDOH
// - If endpoint starts with "quic://" -> ResolverTypeDOQ
// - For anything else -> ResolverTypeDOT
func ResolverTypeFromEndpoint(endpoint string) string {
switch {
case strings.HasPrefix(endpoint, "https://"):
return ResolverTypeDOH
case strings.HasPrefix(endpoint, "quic://"):
return ResolverTypeDOQ
}
host := endpoint
if strings.Contains(endpoint, ":") {
host, _, _ = net.SplitHostPort(host)
}
if ip := net.ParseIP(host); ip != nil {
return ResolverTypeLegacy
}
return ResolverTypeDOT
}
func pick(s []string) string {
return s[rand.Intn(len(s))]
}
// upstreamUID generates an unique identifier for an upstream.
func upstreamUID() string {
b := make([]byte, 4)
for {
if _, err := crand.Read(b); err != nil {
ProxyLogger.Load().Warn().Err(err).Msg("could not generate uid for upstream, retrying...")
continue
}
return hex.EncodeToString(b)
}
}

339
config_internal_test.go Normal file
View File

@@ -0,0 +1,339 @@
package ctrld
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
uc := &UpstreamConfig{
Name: "test",
Type: ResolverTypeDOH,
Endpoint: "https://freedns.controld.com/p2",
Timeout: 5000,
}
uc.Init()
uc.setupBootstrapIP(false)
if len(uc.bootstrapIPs) == 0 {
t.Log(nameservers())
t.Fatal("could not bootstrap ip without bootstrap DNS")
}
t.Log(uc)
}
func TestUpstreamConfig_Init(t *testing.T) {
u1, _ := url.Parse("https://example.com")
u2, _ := url.Parse("https://example.com?k=v")
tests := []struct {
name string
uc *UpstreamConfig
expected *UpstreamConfig
}{
{
"doh+doh3",
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u1,
},
},
{
"doh+doh3 with query param",
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com?k=v",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com?k=v",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
IPStack: IpStackBoth,
u: u2,
},
},
{
"dot+doq",
&UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:8853",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:8853",
BootstrapIP: "",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackSplit,
},
},
{
"dot+doq without port",
&UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
IPStack: IpStackSplit,
},
&UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:853",
BootstrapIP: "",
Domain: "freedns.controld.com",
Timeout: 0,
IPStack: IpStackSplit,
},
},
{
"legacy",
&UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "1.2.3.4",
Domain: "1.2.3.4",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"legacy without port",
&UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "1.2.3.4",
Domain: "1.2.3.4",
Timeout: 0,
IPStack: IpStackBoth,
},
},
{
"doh+doh3 with send client info set",
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com?k=v",
BootstrapIP: "",
Domain: "",
Timeout: 0,
SendClientInfo: ptrBool(false),
IPStack: IpStackBoth,
},
&UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com?k=v",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
SendClientInfo: ptrBool(false),
IPStack: IpStackBoth,
u: u2,
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init()
tc.uc.uid = "" // we don't care about the uid.
assert.Equal(t, tc.expected, tc.uc)
})
}
}
func TestUpstreamConfig_VerifyDomain(t *testing.T) {
tests := []struct {
name string
uc *UpstreamConfig
verifyDomain string
}{
{
controlDComDomain,
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2"},
controldVerifiedDomain[controlDComDomain],
},
{
controlDDevDomain,
&UpstreamConfig{Endpoint: "https://freedns.controld.dev/p2"},
controldVerifiedDomain[controlDDevDomain],
},
{
"non-ControlD upstream",
&UpstreamConfig{Endpoint: "https://dns.google/dns-query"},
"",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.uc.VerifyDomain(); got != tc.verifyDomain {
t.Errorf("unexpected verify domain, want: %q, got: %q", tc.verifyDomain, got)
}
})
}
}
func TestUpstreamConfig_UpstreamSendClientInfo(t *testing.T) {
tests := []struct {
name string
uc *UpstreamConfig
sendClientInfo bool
}{
{
"default with controld upstream DoH",
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH},
true,
},
{
"default with controld upstream DoH3",
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH3},
true,
},
{
"default with non-ControlD upstream",
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", Type: ResolverTypeDOH},
false,
},
{
"set false with controld upstream",
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH, SendClientInfo: ptrBool(false)},
false,
},
{
"set true with controld upstream",
&UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", SendClientInfo: ptrBool(true)},
true,
},
{
"set false with non-ControlD upstream",
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", SendClientInfo: ptrBool(false)},
false,
},
{
"set true with non-ControlD upstream",
&UpstreamConfig{Endpoint: "https://dns.google/dns-query", Type: ResolverTypeDOH, SendClientInfo: ptrBool(true)},
true,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.uc.UpstreamSendClientInfo(); got != tc.sendClientInfo {
t.Errorf("unexpected result, want: %v, got: %v", tc.sendClientInfo, got)
}
})
}
}
func TestUpstreamConfig_IsDiscoverable(t *testing.T) {
tests := []struct {
name string
uc *UpstreamConfig
discoverable bool
}{
{
"loopback",
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy},
true,
},
{
"rfc1918",
&UpstreamConfig{Endpoint: "192.168.1.1", Type: ResolverTypeLegacy},
true,
},
{
"CGNAT",
&UpstreamConfig{Endpoint: "100.66.67.68", Type: ResolverTypeLegacy},
true,
},
{
"Public IP",
&UpstreamConfig{Endpoint: "8.8.8.8", Type: ResolverTypeLegacy},
false,
},
{
"override discoverable",
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(false)},
false,
},
{
"override non-public",
&UpstreamConfig{Endpoint: "1.1.1.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(true)},
true,
},
{
"non-legacy upstream",
&UpstreamConfig{Endpoint: "https://192.168.1.1/custom-doh", Type: ResolverTypeDOH},
false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init()
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
}
})
}
}
func ptrBool(b bool) *bool {
return &b
}

162
config_quic.go Normal file
View File

@@ -0,0 +1,162 @@
//go:build !qf
package ctrld
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"runtime"
"sync"
"github.com/miekg/dns"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
)
func (uc *UpstreamConfig) setupDOH3Transport() {
switch uc.IPStack {
case IpStackBoth, "":
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs)
case IpStackV4:
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs4)
case IpStackV6:
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs6)
case IpStackSplit:
uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4)
if hasIPv6() {
uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6)
} else {
uc.http3RoundTripper6 = uc.http3RoundTripper4
}
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs)
}
}
func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
rt := &http3.RoundTripper{}
rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool}
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
_, port, _ := net.SplitHostPort(addr)
// if we have a bootstrap ip set, use it to avoid DNS lookup
if uc.BootstrapIP != "" {
addr = net.JoinHostPort(uc.BootstrapIP, port)
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", addr)
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
remoteAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return nil, err
}
return quic.DialEarly(ctx, udpConn, remoteAddr, tlsCfg, cfg)
}
dialAddrs := make([]string, len(addrs))
for i := range addrs {
dialAddrs[i] = net.JoinHostPort(addrs[i], port)
}
pd := &quicParallelDialer{}
conn, err := pd.Dial(ctx, dialAddrs, tlsCfg, cfg)
if err != nil {
return nil, err
}
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", conn.RemoteAddr())
return conn, err
}
runtime.SetFinalizer(rt, func(rt *http3.RoundTripper) {
rt.CloseIdleConnections()
})
return rt
}
func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper {
uc.transportOnce.Do(func() {
uc.SetupTransport()
})
if uc.rebootstrap.CompareAndSwap(true, false) {
uc.SetupTransport()
}
switch uc.IPStack {
case IpStackBoth, IpStackV4, IpStackV6:
return uc.http3RoundTripper
case IpStackSplit:
switch dnsType {
case dns.TypeA:
return uc.http3RoundTripper4
default:
return uc.http3RoundTripper6
}
}
return uc.http3RoundTripper
}
// Putting the code for quic parallel dialer here:
//
// - quic dialer is different with net.Dialer
// - simplification for quic free version
type parallelDialerResult struct {
conn quic.EarlyConnection
err error
}
type quicParallelDialer struct{}
// Dial performs parallel dialing to the given address list.
func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
if len(addrs) == 0 {
return nil, errors.New("empty addresses")
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan struct{})
defer close(done)
ch := make(chan *parallelDialerResult, len(addrs))
var wg sync.WaitGroup
wg.Add(len(addrs))
go func() {
wg.Wait()
close(ch)
}()
for _, addr := range addrs {
go func(addr string) {
defer wg.Done()
remoteAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
ch <- &parallelDialerResult{conn: nil, err: err}
return
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
ch <- &parallelDialerResult{conn: nil, err: err}
return
}
conn, err := quic.DialEarly(ctx, udpConn, remoteAddr, tlsCfg, cfg)
select {
case ch <- &parallelDialerResult{conn: conn, err: err}:
case <-done:
if conn != nil {
conn.CloseWithError(quic.ApplicationErrorCode(http3.ErrCodeNoError), "")
}
if udpConn != nil {
udpConn.Close()
}
}
}(addr)
}
errs := make([]error, 0, len(addrs))
for res := range ch {
if res.err == nil {
cancel()
return res.conn, res.err
}
errs = append(errs, res.err)
}
return nil, errors.Join(errs...)
}

9
config_quic_free.go Normal file
View File

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

View File

@@ -1,6 +1,8 @@
package ctrld_test
import (
"os"
"strings"
"testing"
"github.com/go-playground/validator/v10"
@@ -24,10 +26,12 @@ func TestLoadConfig(t *testing.T) {
assert.Contains(t, cfg.Network, "0")
assert.Contains(t, cfg.Network, "1")
assert.Len(t, cfg.Upstream, 3)
assert.Len(t, cfg.Upstream, 4)
assert.Contains(t, cfg.Upstream, "0")
assert.Contains(t, cfg.Upstream, "1")
assert.Contains(t, cfg.Upstream, "2")
assert.Contains(t, cfg.Upstream, "3")
assert.NotNil(t, cfg.Upstream["3"].SendClientInfo)
assert.Len(t, cfg.Listener, 2)
assert.Contains(t, cfg.Listener, "0")
@@ -42,16 +46,37 @@ func TestLoadConfig(t *testing.T) {
assert.Len(t, cfg.Listener["0"].Policy.Rules, 2)
assert.Contains(t, cfg.Listener["0"].Policy.Rules[0], "*.ru")
assert.Contains(t, cfg.Listener["0"].Policy.Rules[1], "*.local.host")
assert.True(t, cfg.HasUpstreamSendClientInfo())
}
func TestLoadDefaultConfig(t *testing.T) {
cfg := defaultConfig(t)
validate := validator.New()
require.NoError(t, ctrld.ValidateConfig(validate, cfg))
assert.Len(t, cfg.Listener, 1)
if assert.Len(t, cfg.Listener, 1) {
l0 := cfg.Listener["0"]
require.NotNil(t, l0.Policy)
assert.Len(t, l0.Policy.Networks, 1)
assert.Len(t, l0.Policy.Rules, 2)
}
assert.Len(t, cfg.Upstream, 2)
}
func TestConfigOverride(t *testing.T) {
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "test_load_config")
v.SetConfigType("toml")
require.NoError(t, v.ReadConfig(strings.NewReader(testhelper.SampleConfigStr(t))))
cfg := ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{
"0": {IP: "127.0.0.1", Port: 53},
}}
require.NoError(t, v.Unmarshal(&cfg))
assert.Equal(t, "10.10.42.69", cfg.Listener["1"].IP)
assert.Equal(t, 1337, cfg.Listener["1"].Port)
}
func TestConfigValidation(t *testing.T) {
tests := []struct {
name string
@@ -61,6 +86,7 @@ func TestConfigValidation(t *testing.T) {
{"invalid Config", &ctrld.Config{}, true},
{"default Config", defaultConfig(t), false},
{"sample Config", testhelper.SampleConfig(t), false},
{"empty listener IP", emptyListenerIP(t), false},
{"invalid cidr", invalidNetworkConfig(t), true},
{"invalid upstream type", invalidUpstreamType(t), true},
{"invalid upstream timeout", invalidUpstreamTimeout(t), true},
@@ -70,6 +96,12 @@ func TestConfigValidation(t *testing.T) {
{"os upstream", configWithOsUpstream(t), false},
{"invalid rules", configWithInvalidRules(t), true},
{"invalid dns rcodes", configWithInvalidRcodes(t), true},
{"invalid max concurrent requests", configWithInvalidMaxConcurrentRequests(t), true},
{"non-existed lease file", configWithNonExistedLeaseFile(t), true},
{"lease file format required if lease file exist", configWithExistedLeaseFile(t), true},
{"invalid lease file format", configWithInvalidLeaseFileFormat(t), true},
{"invalid doh/doh3 endpoint", configWithInvalidDoHEndpoint(t), true},
{"invalid client id pref", configWithInvalidClientIDPref(t), true},
}
for _, tc := range tests {
@@ -89,6 +121,29 @@ func TestConfigValidation(t *testing.T) {
}
}
func TestConfigDiscoverOverride(t *testing.T) {
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "test_config_discover_override")
v.SetConfigType("toml")
configStr := `
[service]
discover_arp = false
discover_dhcp = false
discover_hosts = false
discover_mdns = false
discover_ptr = false
`
require.NoError(t, v.ReadConfig(strings.NewReader(configStr)))
cfg := ctrld.Config{}
require.NoError(t, v.Unmarshal(&cfg))
require.False(t, *cfg.Service.DiscoverARP)
require.False(t, *cfg.Service.DiscoverDHCP)
require.False(t, *cfg.Service.DiscoverHosts)
require.False(t, *cfg.Service.DiscoverMDNS)
require.False(t, *cfg.Service.DiscoverPtr)
}
func defaultConfig(t *testing.T) *ctrld.Config {
v := viper.New()
ctrld.InitConfig(v, "test_load_default_config")
@@ -130,9 +185,15 @@ func invalidListenerIP(t *testing.T) *ctrld.Config {
return cfg
}
func emptyListenerIP(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Listener["0"].IP = ""
return cfg
}
func invalidListenerPort(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Listener["0"].Port = 0
cfg.Listener["0"].Port = -1
return cfg
}
@@ -166,115 +227,44 @@ func configWithInvalidRcodes(t *testing.T) *ctrld.Config {
return cfg
}
func TestUpstreamConfig_Init(t *testing.T) {
tests := []struct {
name string
uc *ctrld.UpstreamConfig
expected *ctrld.UpstreamConfig
}{
{
"doh+doh3",
&ctrld.UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&ctrld.UpstreamConfig{
Name: "doh",
Type: "doh",
Endpoint: "https://example.com",
BootstrapIP: "",
Domain: "example.com",
Timeout: 0,
},
},
{
"dot+doq",
&ctrld.UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:8853",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&ctrld.UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:8853",
BootstrapIP: "",
Domain: "freedns.controld.com",
Timeout: 0,
},
},
{
"dot+doq without port",
&ctrld.UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&ctrld.UpstreamConfig{
Name: "dot",
Type: "dot",
Endpoint: "freedns.controld.com:853",
BootstrapIP: "",
Domain: "freedns.controld.com",
Timeout: 0,
},
},
{
"legacy",
&ctrld.UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&ctrld.UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "1.2.3.4",
Domain: "1.2.3.4",
Timeout: 0,
},
},
{
"legacy without port",
&ctrld.UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4",
BootstrapIP: "",
Domain: "",
Timeout: 0,
},
&ctrld.UpstreamConfig{
Name: "legacy",
Type: "legacy",
Endpoint: "1.2.3.4:53",
BootstrapIP: "1.2.3.4",
Domain: "1.2.3.4",
Timeout: 0,
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init()
assert.Equal(t, tc.expected, tc.uc)
})
}
func configWithInvalidMaxConcurrentRequests(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
n := -1
cfg.Service.MaxConcurrentRequests = &n
return cfg
}
func configWithNonExistedLeaseFile(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Service.DHCPLeaseFile = "non-existed"
return cfg
}
func configWithExistedLeaseFile(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cfg.Service.DHCPLeaseFile = exe
return cfg
}
func configWithInvalidLeaseFileFormat(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Service.DHCPLeaseFileFormat = "invalid"
return cfg
}
func configWithInvalidDoHEndpoint(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Upstream["0"].Endpoint = "1.1.1.1"
cfg.Upstream["0"].Type = ctrld.ResolverTypeDOH
return cfg
}
func configWithInvalidClientIDPref(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Service.ClientIDPref = "foo"
return cfg
}

32
docker/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# Using Debian bullseye for building regular image.
# Using scratch image for minimal image size.
# The final image has:
#
# - Timezone info file.
# - CA certs file.
# - /etc/{passwd,group} file.
# - Non-cgo ctrld binary.
#
# CI_COMMIT_TAG is used to set the version of ctrld binary.
FROM golang:1.20-bullseye as base
WORKDIR /app
RUN apt-get update && apt-get install -y upx-ucl
COPY . .
ARG tag=master
ENV CI_COMMIT_TAG=$tag
RUN CTRLD_NO_QF=yes CGO_ENABLED=0 ./scripts/build.sh
FROM scratch
COPY --from=base /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=base /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=base /etc/passwd /etc/passwd
COPY --from=base /etc/group /etc/group
COPY --from=base /app/ctrld-linux-*-nocgo ctrld
ENTRYPOINT ["./ctrld", "run"]

32
docker/Dockerfile.debug Normal file
View File

@@ -0,0 +1,32 @@
# Using Debian bullseye for building regular image.
# Using scratch image for minimal image size.
# The final image has:
#
# - Timezone info file.
# - CA certs file.
# - /etc/{passwd,group} file.
# - Non-cgo ctrld binary.
#
# CI_COMMIT_TAG is used to set the version of ctrld binary.
FROM golang:1.20-bullseye as base
WORKDIR /app
RUN apt-get update && apt-get install -y upx-ucl
COPY . .
ARG tag=master
ENV CI_COMMIT_TAG=$tag
RUN CTRLD_NO_QF=yes CGO_ENABLED=0 ./scripts/build.sh
FROM alpine
COPY --from=base /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=base /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=base /etc/passwd /etc/passwd
COPY --from=base /etc/group /etc/group
COPY --from=base /app/ctrld-linux-*-nocgo ctrld
ENTRYPOINT ["./ctrld", "run"]

View File

@@ -14,10 +14,15 @@ The config file allows for advanced configuration of the `ctrld` utility to cove
## Config Location
`ctrld` uses [TOML](toml_link) format for its configuration file. Default configuration file is `config.toml` found in following order:
`ctrld` uses [TOML](toml_link) format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
- `$HOME/.ctrld`
- Current directory
- `/etc/controld` on *nix.
- User's home directory on Windows.
- Same directory with `ctrld` binary on these routers:
- `ddwrt`
- `merlin`
- `freshtomato`
- Current directory.
The user can choose to override default value using command line `--config` or `-c`:
@@ -38,6 +43,8 @@ if it's existed.
log_path = ""
cache_enable = true
cache_size = 4096
cache_ttl_override = 60
cache_serve_stale = true
[network.0]
cidrs = ["0.0.0.0/0"]
@@ -53,6 +60,7 @@ if it's existed.
name = "Control D - Anti-Malware"
timeout = 5000
type = "doh"
ip_stack = "both"
[upstream.1]
bootstrap_ip = "76.76.2.11"
@@ -60,6 +68,7 @@ if it's existed.
name = "Control D - No Ads"
timeout = 5000
type = "doq"
ip_stack = "split"
[upstream.2]
bootstrap_ip = "76.76.2.22"
@@ -67,6 +76,7 @@ if it's existed.
name = "Control D - Private"
timeout = 5000
type = "dot"
ip_stack = "v4"
[listener.0]
ip = "127.0.0.1"
@@ -104,8 +114,8 @@ Logging level you wish to enable.
- Type: string
- Required: no
- Valid values: `debug`, `info`, `warn`, `error`, `fatal`, `panic`
- Default: `info`
- Valid values: `debug`, `info`, `warn`, `notice`, `error`, `fatal`, `panic`
- Default: `notice`
### log_path
@@ -113,12 +123,14 @@ Relative or absolute path of the log file.
- Type: string
- Required: no
- Default: ""
### cache_enable
When `cache_enable = true`, all resolved DNS query responses will be cached for duration of the upstream record TTLs.
- Type: boolean
- Required: no
- Default: false
### cache_size
The number of cached records, must be a positive integer. Tweaking this value with care depends on your available RAM.
@@ -128,29 +140,113 @@ An invalid `cache_size` value will disable the cache, regardless of `cache_enabl
- Type: int
- Required: no
- Default: 4096
### cache_ttl_override
When `cache_ttl_override` is set to a positive value (in seconds), TTLs are overridden to this value and cached for this long.
- Type: int
- Required: no
- Default: 0
### cache_serve_stale
When `cache_serve_stale = true`, in cases of upstream failures (upstreams not reachable), `ctrld` will keep serving
stale cached records (regardless of their TTLs) until upstream comes online.
The above config will look like this at query time.
- Type: boolean
- Required: no
- Default: false
```
2022-11-14T22:18:53.808 INF Setting bootstrap IP for upstream.0 bootstrap_ip=76.76.2.11
2022-11-14T22:18:53.808 INF Starting DNS server on listener.0: 127.0.0.1:53
2022-11-14T22:18:56.381 DBG [9fd5d3] 127.0.0.1:53978 -> listener.0: 127.0.0.1:53: received query: verify.controld.com
2022-11-14T22:18:56.381 INF [9fd5d3] no policy, no network, no rule -> [upstream.0]
2022-11-14T22:18:56.381 DBG [9fd5d3] sending query to upstream.0: Control D - DOH Free
2022-11-14T22:18:56.381 DBG [9fd5d3] debug dial context freedns.controld.com:443 - tcp - 76.76.2.0
2022-11-14T22:18:56.381 DBG [9fd5d3] sending doh request to: 76.76.2.11:443
2022-11-14T22:18:56.420 DBG [9fd5d3] received response of 118 bytes in 39.662597ms
```
### max_concurrent_requests
The number of concurrent requests that will be handled, must be a non-negative integer.
Tweaking this value depends on the capacity of your system.
- Type: number
- Required: no
- Default: 256
### discover_mdns
Perform LAN client discovery using mDNS. This will spawn a listener on port 5353.
- Type: boolean
- Required: no
- Default: true
### discover_arp
Perform LAN client discovery using ARP.
- Type: boolean
- Required: no
- Default: true
### discover_dhcp
Perform LAN client discovery using DHCP leases files. Common file locations are auto-discovered.
- Type: boolean
- Required: no
- Default: true
### discover_ptr
Perform LAN client discovery using PTR queries.
- Type: boolean
- Required: no
- Default: true
### discover_hosts
Perform LAN client discovery using hosts file.
- Type: boolean
- Required: no
- Default: true
### discover_refresh_interval
Time in seconds between each discovery refresh loop to update new client information data.
The default value is 120 seconds, lower this value to make the discovery process run more aggressively.
- Type: integer
- Required: no
- Default: 120
### dhcp_lease_file_path
Relative or absolute path to a custom DHCP leases file location.
- Type: string
- Required: no
- Default: ""
### dhcp_lease_file_format
DHCP leases file format.
- Type: string
- Required: no
- Valid values: `dnsmasq`, `isc-dhcp`
- Default: ""
### client_id_preference
Decide how the client ID is generated
If `host` -> client id will only use the hostname i.e.`hash(hostname)`.
If `mac` -> client id will only use the MAC address `hash(mac)`.
Else -> client ID will use both Mac and Hostname i.e. `hash(mac + host)
- Type: string
- Required: no
- Valid values: `mac`, `host`
- Default: ""
### metrics_query_stats
If set to `true`, collect and export the query counters, and show them in `clients list` command.
- Type: boolean
- Required: no
- Default: false
### metrics_listener
Specifying the `ip` and `port` of the metrics server.
- Type: string
- Required: no
- Default: ""
## Upstream
The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to.
@@ -162,6 +258,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Control D - DOH"
timeout = 5000
type = "doh"
ip_stack = "split"
[upstream.1]
bootstrap_ip = ""
@@ -169,6 +266,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Control D - DOH3"
timeout = 5000
type = "doh3"
ip_stack = "both"
[upstream.2]
bootstrap_ip = ""
@@ -176,6 +274,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Controld D - DOT"
timeout = 5000
type = "dot"
ip_stack = "v4"
[upstream.3]
bootstrap_ip = ""
@@ -183,6 +282,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Controld D - DOT"
timeout = 5000
type = "doq"
ip_stack = "v6"
[upstream.4]
bootstrap_ip = ""
@@ -190,6 +290,7 @@ The `[upstream]` section specifies the DNS upstream servers that `ctrld` will fo
name = "Control D - Ad Blocking"
timeout = 5000
type = "legacy"
ip_stack = "both"
```
### bootstrap_ip
@@ -200,6 +301,7 @@ If `bootstrap_ip` is empty, `ctrld` will resolve this itself using its own boots
- type: ip address string
- required: no
- Default: ""
### endpoint
IP address, hostname or URL of upstream DNS. Used together with `Type` of the endpoint.
@@ -214,6 +316,7 @@ Human-readable name of the upstream.
- Type: string
- Required: no
- Default: ""
### timeout
Timeout in milliseconds before request failsover to the next upstream (if defined).
@@ -221,15 +324,52 @@ Timeout in milliseconds before request failsover to the next upstream (if define
Value `0` means no timeout.
- Type: number
- required: no
- Required: no
- Default: 0
### type
The protocol that `ctrld` will use to send DNS requests to upstream.
- Type: string
- required: yes
- Required: yes
- Valid values: `doh`, `doh3`, `dot`, `doq`, `legacy`, `os`
### ip_stack
Specifying what kind of ip stack that `ctrld` will use to connect to upstream.
- Type: string
- Required: no
- Valid values:
- `both`: using either ipv4 or ipv6.
- `v4`: only dial upstream via IPv4, never dial IPv6.
- `v6`: only dial upstream via IPv6, never dial IPv4.
- `split`:
- If `A` record is requested -> dial via ipv4.
- If `AAAA` or any other record is requested -> dial ipv6 (if available, otherwise ipv4)
If `ip_stack` is empty, or undefined:
- Default value is `both` for non-Control D resolvers.
- Default value is `split` for Control D resolvers.
### send_client_info
Specifying whether to include client info when sending query to upstream. **This will only work with `doh` or `doh3` type upstreams.**
- Type: boolean
- Required: no
- Default:
- `true` for ControlD upstreams.
- `false` for other upstreams.
### discoverable
Specifying whether the upstream can be used for PTR discovery.
- Type: boolean
- Required: no
- Default:
- `true` for loopback/RFC1918/CGNAT IP address.
- `false` for public IP address.
## Network
The `[network]` section defines networks from which DNS queries can originate from. These are used in policies. You can define multiple networks, and each one can have multiple cidrs.
@@ -248,12 +388,14 @@ Name of the network.
- Type: string
- Required: no
- Default: ""
### cidrs
Specifies the network addresses that the `listener` will accept requests from. You will see more details in the listener policy section.
- Type: array of network CIDR string
- Required: no
- Default: []
## listener
@@ -271,28 +413,46 @@ The `[listener]` section specifies the ip and port of the local DNS server. You
```
### ip
IP address that serves the incoming requests.
IP address that serves the incoming requests. If `ip` is empty, ctrld will listen on all available addresses.
- Type: string
- Required: yes
- Type: ip address string
- Required: no
- Default: "0.0.0.0" or RFC1918 addess or "127.0.0.1" (depending on platform)
### port
Port number that the listener will listen on for incoming requests.
Port number that the listener will listen on for incoming requests. If `port` is `0`, a random available port will be chosen.
- Type: number
- Required: yes
- Required: no
- Default: 0 or 53 or 5354 (depending on platform)
### restricted
If set to `true` makes the listener `REFUSE` DNS queries from all source IP addresses that are not explicitly defined in the policy using a `network`.
If set to `true`, makes the listener `REFUSED` DNS queries from all source IP addresses that are not explicitly defined in the policy using a `network`.
- Type: bool
- Required: no
- Default: false
### allow_wan_clients
The listener will refuse DNS queries from WAN IPs using `REFUSED` RCODE by default. Set to `true` to disable this behavior, but this is not recommended.
- Type: bool
- Required: no
- Default: false
### policy
Allows `ctrld` to set policy rules to determine which upstreams the requests will be forwarded to.
If no `policy` is defined or the requests do not match any policy rules, it will be forwarded to corresponding upstream of the listener. For example, the request to `listener.0` will be forwarded to `upstream.0`.
The policy `rule` syntax is a simple `toml` inline table with exactly one key/value pair per rule. `key` is either the `network` or a domain. Value is the list of the upstreams. For example:
The policy `rule` syntax is a simple `toml` inline table with exactly one key/value pair per rule. `key` is either:
- Network.
- Domain.
- Mac Address.
Value is the list of the upstreams.
For example:
```toml
[listener.0.policy]
@@ -306,12 +466,18 @@ rules = [
{"*.local" = ["upstream.1"]},
{"test.com" = ["upstream.2", "upstream.1"]},
]
macs = [
{"14:54:4a:8e:08:2d" = ["upstream.3"]},
]
```
Above policy will:
- Forward requests on `listener.0` from `network.0` to `upstream.1`.
- Forward requests on `listener.0` for `.local` suffixed domains to `upstream.1`.
- Forward requests on `listener.0` for `test.com` to `upstream.2`. If timeout is reached, retry on `upstream.1`.
- Forward requests on `listener.0` from client with Mac `14:54:4a:8e:08:2d` to `upstream.3`.
- Forward requests on `listener.0` from `network.0` to `upstream.1`.
- All other requests on `listener.0` that do not match above conditions will be forwarded to `upstream.0`.
An empty upstream would not route the request to any defined upstreams, and use the OS default resolver.
@@ -325,24 +491,54 @@ rules = [
]
```
---
Note that the order of matching preference:
```
rules => macs => networks
```
And within each policy, the rules are processed from top to bottom.
---
#### name
`name` is the name for the policy.
- Type: string
- Required: no
- Default: ""
### networks:
`networks` is the list of network rules of the policy.
- type: array of networks
- Type: array of networks
- Required: no
- Default: []
### rules:
`rules` is the list of domain rules within the policy. Domain can be either FQDN or wildcard domain.
- type: array of rule
- Type: array of rule
- Required: no
- Default: []
### macs:
`macs` is the list of mac rules within the policy. Mac address value is case-insensitive.
- Type: array of macs
- Required: no
- Default: []
### failover_rcodes
For non success response, `failover_rcodes` allows the request to be forwarded to next upstream, if the response `RCODE` matches any value defined in `failover_rcodes`. For example:
For non success response, `failover_rcodes` allows the request to be forwarded to next upstream, if the response `RCODE` matches any value defined in `failover_rcodes`.
- Type: array of string
- Required: no
- Default: []
-
For example:
```toml
[listener.0.policy]

173
doh.go
View File

@@ -3,53 +3,128 @@ package ctrld
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"runtime"
"strings"
"sync"
"github.com/cuonglm/osinfo"
"github.com/lucas-clemente/quic-go/http3"
"github.com/miekg/dns"
)
const (
dohMacHeader = "x-cd-mac"
dohIPHeader = "x-cd-ip"
dohHostHeader = "x-cd-host"
dohOsHeader = "x-cd-os"
dohClientIDPrefHeader = "x-cd-cpref"
headerApplicationDNS = "application/dns-message"
)
// EncodeOsNameMap provides mapping from OS name to a shorter string, used for encoding x-cd-os value.
var EncodeOsNameMap = map[string]string{
"windows": "1",
"darwin": "2",
"linux": "3",
"freebsd": "4",
}
// DecodeOsNameMap provides mapping from encoded OS name to real value, used for decoding x-cd-os value.
var DecodeOsNameMap = map[string]string{}
// EncodeArchNameMap provides mapping from OS arch to a shorter string, used for encoding x-cd-os value.
var EncodeArchNameMap = map[string]string{
"amd64": "1",
"arm64": "2",
"arm": "3",
"386": "4",
"mips": "5",
"mipsle": "6",
"mips64": "7",
}
// DecodeArchNameMap provides mapping from encoded OS arch to real value, used for decoding x-cd-os value.
var DecodeArchNameMap = map[string]string{}
func init() {
for k, v := range EncodeOsNameMap {
DecodeOsNameMap[v] = k
}
for k, v := range EncodeArchNameMap {
DecodeArchNameMap[v] = k
}
}
// TODO: use sync.OnceValue when upgrading to go1.21
var xCdOsValueOnce sync.Once
var xCdOsValue string
func dohOsHeaderValue() string {
xCdOsValueOnce.Do(func() {
oi := osinfo.New()
xCdOsValue = strings.Join([]string{EncodeOsNameMap[runtime.GOOS], EncodeArchNameMap[runtime.GOARCH], oi.Dist}, "-")
})
return xCdOsValue
}
func newDohResolver(uc *UpstreamConfig) *dohResolver {
r := &dohResolver{
endpoint: uc.Endpoint,
endpoint: uc.u,
isDoH3: uc.Type == ResolverTypeDOH3,
transport: uc.transport,
http3RoundTripper: uc.http3RoundTripper,
uc: uc,
}
return r
}
type dohResolver struct {
endpoint string
uc *UpstreamConfig
endpoint *url.URL
isDoH3 bool
transport *http.Transport
http3RoundTripper *http3.RoundTripper
http3RoundTripper http.RoundTripper
}
// Resolve performs DNS query with given DNS message using DOH protocol.
func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
data, err := msg.Pack()
if err != nil {
return nil, err
}
enc := base64.RawURLEncoding.EncodeToString(data)
url := fmt.Sprintf("%s?dns=%s", r.endpoint, enc)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
query := r.endpoint.Query()
query.Add("dns", enc)
endpoint := *r.endpoint
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("could not create request: %w", err)
}
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Accept", "application/dns-message")
c := http.Client{Transport: r.transport}
addHeader(ctx, req, r.uc)
dnsTyp := uint16(0)
if len(msg.Question) > 0 {
dnsTyp = msg.Question[0].Qtype
}
c := http.Client{Transport: r.uc.dohTransport(dnsTyp)}
if r.isDoH3 {
c.Transport = r.http3RoundTripper
transport := r.uc.doh3Transport(dnsTyp)
if transport == nil {
return nil, errors.New("DoH3 is not supported")
}
c.Transport = transport
}
resp, err := c.Do(req)
if err != nil {
if r.isDoH3 {
r.http3RoundTripper.Close()
if closer, ok := c.Transport.(io.Closer); ok {
closer.Close()
}
}
return nil, fmt.Errorf("could not perform request: %w", err)
}
@@ -65,5 +140,73 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
}
answer := new(dns.Msg)
return answer, answer.Unpack(buf)
if err := answer.Unpack(buf); err != nil {
return nil, fmt.Errorf("answer.Unpack: %w", err)
}
return answer, nil
}
// addHeader adds necessary HTTP header to request based on upstream config.
func addHeader(ctx context.Context, req *http.Request, uc *UpstreamConfig) {
printed := false
dohHeader := make(http.Header)
if uc.UpstreamSendClientInfo() {
if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil {
printed = ci.Mac != "" || ci.IP != "" || ci.Hostname != ""
switch {
case uc.isControlD():
dohHeader = newControlDHeaders(ci)
case uc.isNextDNS():
dohHeader = newNextDNSHeaders(ci)
}
}
}
if printed {
Log(ctx, ProxyLogger.Load().Debug(), "sending request header: %v", dohHeader)
}
dohHeader.Set("Content-Type", headerApplicationDNS)
dohHeader.Set("Accept", headerApplicationDNS)
req.Header = dohHeader
}
// newControlDHeaders returns DoH/Doh3 HTTP request headers for ControlD upstream.
func newControlDHeaders(ci *ClientInfo) http.Header {
header := make(http.Header)
header.Set(dohOsHeader, dohOsHeaderValue())
if ci.Mac != "" {
header.Set(dohMacHeader, ci.Mac)
}
if ci.IP != "" {
header.Set(dohIPHeader, ci.IP)
}
if ci.Hostname != "" {
header.Set(dohHostHeader, ci.Hostname)
}
if ci.Self {
header.Set(dohOsHeader, dohOsHeaderValue())
}
switch ci.ClientIDPref {
case "mac":
header.Set(dohClientIDPrefHeader, "1")
case "host":
header.Set(dohClientIDPrefHeader, "2")
}
return header
}
// newNextDNSHeaders returns DoH/Doh3 HTTP request headers for nextdns upstream.
// https://github.com/nextdns/nextdns/blob/v1.41.0/resolver/doh.go#L100
func newNextDNSHeaders(ci *ClientInfo) http.Header {
header := make(http.Header)
if ci.Mac != "" {
// https: //github.com/nextdns/nextdns/blob/v1.41.0/run.go#L543
header.Set("X-Device-Model", "mac:"+ci.Mac[:8])
}
if ci.IP != "" {
header.Set("X-Device-Ip", ci.IP)
}
if ci.Hostname != "" {
header.Set("X-Device-Name", ci.Hostname)
}
return header
}

23
doh_test.go Normal file
View File

@@ -0,0 +1,23 @@
package ctrld
import (
"runtime"
"testing"
)
func Test_dohOsHeaderValue(t *testing.T) {
val := dohOsHeaderValue()
if val == "" {
t.Fatalf("empty %s", dohOsHeader)
}
t.Log(val)
encodedOs := EncodeOsNameMap[runtime.GOOS]
if encodedOs == "" {
t.Fatalf("missing encoding value for: %q", runtime.GOOS)
}
decodedOs := DecodeOsNameMap[encodedOs]
if decodedOs == "" {
t.Fatalf("missing decoding value for: %q", runtime.GOOS)
}
}

20
doq.go
View File

@@ -1,3 +1,5 @@
//go:build !qf
package ctrld
import (
@@ -7,8 +9,8 @@ import (
"net"
"time"
"github.com/lucas-clemente/quic-go"
"github.com/miekg/dns"
"github.com/quic-go/quic-go"
)
type doqResolver struct {
@@ -18,11 +20,17 @@ type doqResolver struct {
func (r *doqResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
endpoint := r.uc.Endpoint
tlsConfig := &tls.Config{NextProtos: []string{"doq"}}
if r.uc.BootstrapIP != "" {
tlsConfig.ServerName = r.uc.Domain
_, port, _ := net.SplitHostPort(endpoint)
endpoint = net.JoinHostPort(r.uc.BootstrapIP, port)
ip := r.uc.BootstrapIP
if ip == "" {
dnsTyp := uint16(0)
if msg != nil && len(msg.Question) > 0 {
dnsTyp = msg.Question[0].Qtype
}
ip = r.uc.bootstrapIPForDNSType(dnsTyp)
}
tlsConfig.ServerName = r.uc.Domain
_, port, _ := net.SplitHostPort(endpoint)
endpoint = net.JoinHostPort(ip, port)
return resolve(ctx, msg, endpoint, tlsConfig)
}
@@ -43,7 +51,7 @@ func resolve(ctx context.Context, msg *dns.Msg, endpoint string, tlsConfig *tls.
}
func doResolve(ctx context.Context, msg *dns.Msg, endpoint string, tlsConfig *tls.Config) (*dns.Msg, error) {
session, err := quic.DialAddr(endpoint, tlsConfig, nil)
session, err := quic.DialAddr(ctx, endpoint, tlsConfig, nil)
if err != nil {
return nil, err
}

18
doq_quic_free.go Normal file
View File

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

16
dot.go
View File

@@ -14,18 +14,26 @@ type dotResolver struct {
func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
// The dialer is used to prevent bootstrapping cycle.
// If r.endpoing is set to dns.controld.dev, we need to resolve
// If r.endpoint is set to dns.controld.dev, we need to resolve
// dns.controld.dev first. By using a dialer with custom resolver,
// we ensure that we can always resolve the bootstrap domain
// regardless of the machine DNS status.
dialer := newDialer(net.JoinHostPort(bootstrapDNS, "53"))
dnsTyp := uint16(0)
if msg != nil && len(msg.Question) > 0 {
dnsTyp = msg.Question[0].Qtype
}
tcpNet, _ := r.uc.netForDNSType(dnsTyp)
dnsClient := &dns.Client{
Net: "tcp-tls",
Dialer: dialer,
Net: tcpNet,
Dialer: dialer,
TLSConfig: &tls.Config{RootCAs: r.uc.certPool},
}
endpoint := r.uc.Endpoint
if r.uc.BootstrapIP != "" {
dnsClient.TLSConfig = &tls.Config{ServerName: r.uc.Domain}
dnsClient.TLSConfig.ServerName = r.uc.Domain
dnsClient.Net = "tcp-tls"
_, port, _ := net.SplitHostPort(endpoint)
endpoint = net.JoinHostPort(r.uc.BootstrapIP, port)
}

109
go.mod
View File

@@ -1,76 +1,95 @@
module github.com/Control-D-Inc/ctrld
go 1.19
go 1.20
require (
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534
github.com/frankban/quicktest v1.14.3
github.com/coreos/go-systemd/v22 v22.5.0
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf
github.com/frankban/quicktest v1.14.5
github.com/fsnotify/fsnotify v1.6.0
github.com/go-playground/validator/v10 v10.11.1
github.com/godbus/dbus/v5 v5.0.6
github.com/godbus/dbus/v5 v5.1.0
github.com/hashicorp/golang-lru/v2 v2.0.1
github.com/illarion/gonotify v1.0.1
github.com/insomniacslk/dhcp v0.0.0-20211209223715-7d93572ebe8e
github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16
github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86
github.com/kardianos/service v1.2.1
github.com/lucas-clemente/quic-go v0.29.1
github.com/miekg/dns v1.1.50
github.com/pelletier/go-toml/v2 v2.0.6
github.com/mdlayher/ndp v1.0.1
github.com/miekg/dns v1.1.55
github.com/olekukonko/tablewriter v0.0.5
github.com/pelletier/go-toml/v2 v2.0.8
github.com/prometheus/client_golang v1.15.1
github.com/prometheus/prom2json v1.3.3
github.com/quic-go/quic-go v0.38.0
github.com/rs/zerolog v1.28.0
github.com/spf13/cobra v1.4.0
github.com/spf13/viper v1.14.0
github.com/stretchr/testify v1.8.1
golang.org/x/sys v0.4.0
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.3
github.com/vishvananda/netlink v1.2.1-beta.2
golang.org/x/net v0.17.0
golang.org/x/sync v0.2.0
golang.org/x/sys v0.13.0
golang.zx2c4.com/wireguard/windows v0.5.3
tailscale.com v1.34.1
tailscale.com v1.44.0
)
require (
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/josharian/native v1.0.0 // indirect
github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jsimonetti/rtnetlink v1.3.2 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/marten-seemann/qpack v0.2.1 // indirect
github.com/marten-seemann/qtls-go1-18 v0.1.2 // indirect
github.com/marten-seemann/qtls-go1-19 v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 // indirect
github.com/mdlayher/netlink v1.6.0 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect
github.com/mdlayher/socket v0.2.3 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/nxadm/tail v1.4.8 // indirect
github.com/onsi/ginkgo v1.16.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4 // indirect
github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/quic-go/qpack v0.4.0 // indirect
github.com/quic-go/qtls-go1-20 v0.3.2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4 // indirect
go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect
golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.5.1-0.20230105164244-f8411da775a6 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.6.0 // indirect
golang.org/x/tools v0.1.12 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.9.1 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mr-karan/doggo => github.com/Windscribe/doggo v0.0.0-20220919152748-2c118fc391f8
replace github.com/rs/zerolog => github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be

289
go.sum
View File

@@ -38,22 +38,29 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be h1:qBKVRi7Mom5heOkyZ+NCIu9HZBiNCsRqrRe5t9pooik=
github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cilium/ebpf v0.8.1 h1:bLSSEbBLqGPXxls55pGr5qWZaTqcmfDJHhou7t254ao=
github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk=
github.com/cilium/ebpf v0.10.0 h1:nk5HPMeoBXtOzbkZBWym+ZWq1GIiHUsBFXxwewXAHLQ=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534 h1:rtAn27wIbmOGUs7RIbVgPEjb31ehTVniDwPGXyMxm5U=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf h1:40DHYsri+d1bnroFDU2FQAeq68f3kAlOzlQ93kCf26Q=
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf/go.mod h1:G45410zMgmnSjLVKCq4f6GpbYAzoP2plX9rPwgx6C24=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -63,17 +70,14 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc=
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
@@ -82,11 +86,11 @@ github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/j
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -114,7 +118,9 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -126,8 +132,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -143,130 +148,136 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.1 h1:5pv5N1lT1fjLg2VQ5KWc7kmucp2x/kvFOnxuVTqZ6x4=
github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio=
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/insomniacslk/dhcp v0.0.0-20211209223715-7d93572ebe8e h1:IQpunlq7T+NiJJMO7ODYV2YWBiv/KnObR3gofX0mWOo=
github.com/insomniacslk/dhcp v0.0.0-20211209223715-7d93572ebe8e/go.mod h1:h+MxyHxRg9NH3terB1nfRIUaQEcI0XOVkdR9LNBlp8E=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw=
github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ=
github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok=
github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c/go.mod h1:huN4d1phzjhlOsNIjFsw2SVRbwIHj3fJDMEU2SDPTmg=
github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b h1:Yws7RV6kZr2O7PPdT+RkbSmmOponA8i/1DuGHe8BRsM=
github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b/go.mod h1:TzDCVOZKUa79z6iXbbXqhtAflVgUKaFkZ21M5tK5tzY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 h1:+aAGyK41KRn8jbF2Q7PLL0Sxwg6dShGcQSeCC7nZQ8E=
github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI=
github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c h1:kbTQ8oGf+BVFvt/fM+ECI+NbZDCqoi0vtZTfB2p2hrI=
github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c/go.mod h1:k6+89xKz7BSMJ+DzIerBdtpEUeTlBMugO/hcVSzahog=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk=
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8=
github.com/jsimonetti/rtnetlink v1.3.2 h1:dcn0uWkfxycEEyNy0IGfx3GrhQ38LH7odjxAghimsVI=
github.com/jsimonetti/rtnetlink v1.3.2/go.mod h1:BBu4jZCpTjP6Gk0/wfrO8qcqymnN3g0hoFqObRmUo6U=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/service v1.2.1 h1:AYndMsehS+ywIS6RB9KOlcXzteWUzxgMgBymJD7+BYk=
github.com/kardianos/service v1.2.1/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/lucas-clemente/quic-go v0.29.1 h1:Z+WMJ++qMLhvpFkRZA+jl3BTxUjm415YBmWanXB8zP0=
github.com/lucas-clemente/quic-go v0.29.1/go.mod h1:CTcNfLYJS2UuRNB+zcNlgvkjBhxX6Hm3WUxxAQx2mgE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/marten-seemann/qpack v0.2.1 h1:jvTsT/HpCn2UZJdP+UUB53FfUUgeOyG5K1ns0OJOGVs=
github.com/marten-seemann/qpack v0.2.1/go.mod h1:F7Gl5L1jIgN1D11ucXefiuJS9UMVP2opoCp2jDKb7wc=
github.com/marten-seemann/qtls-go1-18 v0.1.2 h1:JH6jmzbduz0ITVQ7ShevK10Av5+jBEKAHMntXmIV7kM=
github.com/marten-seemann/qtls-go1-18 v0.1.2/go.mod h1:mJttiymBAByA49mhlNZZGrH5u1uXYZJ+RW28Py7f4m4=
github.com/marten-seemann/qtls-go1-19 v0.1.0 h1:rLFKD/9mp/uq1SYGYuVZhm83wkmU95pK5df3GufyYYU=
github.com/marten-seemann/qtls-go1-19 v0.1.0/go.mod h1:5HTDWtVudo/WFsHKRNuOhWlbdjrfs5JHrYb0wIJqGpI=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 h1:lez6TS6aAau+8wXUP3G9I3TGlmPFEq2CTxBaRqY6AGE=
github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y=
github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA=
github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY=
github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o=
github.com/mdlayher/netlink v1.6.0 h1:rOHX5yl7qnlpiVkFWoqccueppMtXzeziFjWAjLg6sz0=
github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA=
github.com/mdlayher/ndp v1.0.1 h1:+yAD79/BWyFlvAoeG5ncPS0ItlHP/eVbH7bQ6/+LVA4=
github.com/mdlayher/ndp v1.0.1/go.mod h1:rf3wKaWhAYJEXFKpgF8kQ2AxypxVbfNcZbqoAo6fVzk=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg=
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w=
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg=
github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs=
github.com/mdlayher/socket v0.2.3 h1:XZA2X2TjdOwNoNPVPclRCURoX/hokBY8nkTmRZFEheM=
github.com/mdlayher/socket v0.2.3/go.mod h1:bz12/FozYNH/VbvC3q7TRIK/Y6dH1kCKsXaUeXi/FmY=
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.20.1 h1:PA/3qinGoukvymdIDV8pii6tiZgC8kbmJO6Z5+b002Q=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/prometheus/prom2json v1.3.3 h1:IYfSMiZ7sSOfliBoo89PcufjWO4eAR0gznGcETyaUgo=
github.com/prometheus/prom2json v1.3.3/go.mod h1:Pv4yIPktEkK7btWsrUTWDDDrnpUrAELaOCj+oFwlgmc=
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/quic-go/qtls-go1-20 v0.3.2 h1:rRgN3WfnKbyik4dBV8A6girlJVxGand/d+jVKbQq5GI=
github.com/quic-go/qtls-go1-20 v0.3.2/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
github.com/quic-go/quic-go v0.38.0 h1:T45lASr5q/TrVwt+jrVccmqHhPL2XuSyoCLVCpfOSLc=
github.com/quic-go/quic-go v0.38.0/go.mod h1:MPCuRq7KBK2hNcfKj/1iD1BGuN3eAYMeNxp3T42LRUg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4 h1:Ha8xCaq6ln1a+R91Km45Oq6lPXj2Mla6CRJYcuV2h1w=
github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q=
github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.14.0 h1:Rg7d3Lo706X9tHsJMUjdiwMpHB7W8WnSVOssIY+JElU=
github.com/spf13/viper v1.14.0/go.mod h1:WT//axPky3FdvXHzGw33dNdXXXfFQqmEalje+egj8As=
github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
@@ -278,13 +289,17 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/u-root/uio v0.0.0-20210528114334-82958018845c/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA=
github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4 h1:hl6sK6aFgTLISijk6xIzeqnPzQcsLqqvL6vEfTPinME=
github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg=
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -296,18 +311,18 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go4.org/mem v0.0.0-20210711025021-927187094b94 h1:OAAkygi2Js191AJP1Ds42MhJRgeofeKGjuoUqNp1QC4=
go4.org/mem v0.0.0-20210711025021-927187094b94/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8=
go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -318,8 +333,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw=
golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -344,11 +359,10 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -360,8 +374,6 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -372,25 +384,19 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.5.1-0.20230105164244-f8411da775a6 h1:pKt/LWZC6+FwNujj5E7DdVyWcbtQvKqPuN0GPKWMyB8=
golang.org/x/net v0.5.1-0.20230105164244-f8411da775a6/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -411,13 +417,11 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -426,18 +430,14 @@ golang.org/x/sys v0.0.0-20190606122018-79a91cf218c4/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -445,37 +445,33 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -484,8 +480,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -495,7 +491,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@@ -534,14 +529,12 @@ golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82u
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -636,22 +629,18 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -666,5 +655,5 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
tailscale.com v1.34.1 h1:tqm9Ww4ltyYp3IPe7vCGch6tT6j5G/WXPQ6BrVZ6pdI=
tailscale.com v1.34.1/go.mod h1:ZsBP7rjzzB2rp+UCOumr9DAe0EQ6OPivwSXcz/BrekQ=
tailscale.com v1.44.0 h1:MPos9n30kJvdyfL52045gVFyNg93K+bwgDsr8gqKq2o=
tailscale.com v1.44.0/go.mod h1:+iYwTdeHyVJuNDu42Zafwihq1Uqfh+pW7pRaY1GD328=

3372
internal/certs/cacert.pem Normal file

File diff suppressed because it is too large Load Diff

22
internal/certs/root_ca.go Normal file
View File

@@ -0,0 +1,22 @@
package certs
import (
"crypto/x509"
_ "embed"
"sync"
)
var (
//go:embed cacert.pem
caRoots []byte
caCertPoolOnce sync.Once
caCertPool *x509.CertPool
)
func CACertPool() *x509.CertPool {
caCertPoolOnce.Do(func() {
caCertPool = x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caRoots)
})
return caCertPool
}

View File

@@ -0,0 +1,27 @@
package certs
import (
"crypto/tls"
"net/http"
"testing"
"time"
)
func TestCACertPool(t *testing.T) {
c := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: CACertPool(),
},
},
Timeout: 2 * time.Second,
}
resp, err := c.Get("https://freedns.controld.com/p1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if !resp.TLS.HandshakeComplete {
t.Error("TLS handshake is not complete")
}
}

View File

@@ -0,0 +1,49 @@
package clientinfo
import "sync"
type arpDiscover struct {
mac sync.Map // ip => mac
ip sync.Map // mac => ip
}
func (a *arpDiscover) refresh() error {
a.scan()
return nil
}
func (a *arpDiscover) LookupIP(mac string) string {
val, ok := a.ip.Load(mac)
if !ok {
return ""
}
return val.(string)
}
func (a *arpDiscover) LookupMac(ip string) string {
val, ok := a.mac.Load(ip)
if !ok {
return ""
}
return val.(string)
}
func (a *arpDiscover) String() string {
return "arp"
}
func (a *arpDiscover) List() []string {
if a == nil {
return nil
}
var ips []string
a.ip.Range(func(key, value any) bool {
ips = append(ips, value.(string))
return true
})
a.mac.Range(func(key, value any) bool {
ips = append(ips, key.(string))
return true
})
return ips
}

View File

@@ -0,0 +1,28 @@
package clientinfo
import (
"bufio"
"os"
"strings"
)
const procNetArpFile = "/proc/net/arp"
func (a *arpDiscover) scan() {
f, err := os.Open(procNetArpFile)
if err != nil {
return
}
defer f.Close()
s := bufio.NewScanner(f)
s.Scan() // skip header
for s.Scan() {
line := s.Text()
fields := strings.Fields(line)
ip := fields[0]
mac := fields[3]
a.mac.Store(ip, mac)
a.ip.Store(mac, ip)
}
}

View File

@@ -0,0 +1,23 @@
package clientinfo
import (
"sync"
"testing"
)
func TestArpScan(t *testing.T) {
a := &arpDiscover{}
a.scan()
for _, table := range []*sync.Map{&a.mac, &a.ip} {
count := 0
table.Range(func(key, value any) bool {
count++
t.Logf("%s => %s", key, value)
return true
})
if count == 0 {
t.Error("empty result from arp scan")
}
}
}

View File

@@ -0,0 +1,30 @@
//go:build !linux && !windows
package clientinfo
import (
"os/exec"
"strings"
)
func (a *arpDiscover) scan() {
data, err := exec.Command("arp", "-an").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) <= 3 {
continue
}
// trim brackets
ip := strings.ReplaceAll(fields[1], "(", "")
ip = strings.ReplaceAll(ip, ")", "")
mac := fields[3]
a.mac.Store(ip, mac)
a.ip.Store(mac, ip)
}
}

View File

@@ -0,0 +1,38 @@
package clientinfo
import (
"os/exec"
"strings"
)
func (a *arpDiscover) scan() {
data, err := exec.Command("arp", "-a").Output()
if err != nil {
return
}
header := false
for _, line := range strings.Split(string(data), "\n") {
if len(line) == 0 {
continue // empty lines
}
if line[0] != ' ' {
header = true // "Interface:" lines, next is header line.
continue
}
if header {
header = false // header lines
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
ip := fields[0]
mac := strings.ReplaceAll(fields[1], "-", ":")
a.mac.Store(ip, mac)
a.ip.Store(mac, ip)
}
}

View File

@@ -0,0 +1,493 @@
package clientinfo
import (
"context"
"fmt"
"net"
"net/netip"
"strconv"
"strings"
"sync"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// IpResolver is the interface for retrieving IP from Mac.
type IpResolver interface {
fmt.Stringer
// LookupIP returns ip of the device with given mac.
LookupIP(mac string) string
}
// MacResolver is the interface for retrieving Mac from IP.
type MacResolver interface {
fmt.Stringer
// LookupMac returns mac of the device with given ip.
LookupMac(ip string) string
}
// HostnameByIpResolver is the interface for retrieving hostname from IP.
type HostnameByIpResolver interface {
// LookupHostnameByIP returns hostname of the given ip.
LookupHostnameByIP(ip string) string
}
// HostnameByMacResolver is the interface for retrieving hostname from Mac.
type HostnameByMacResolver interface {
// LookupHostnameByMac returns hostname of the device with given mac.
LookupHostnameByMac(mac string) string
}
// HostnameResolver is the interface for retrieving hostname from either IP or Mac.
type HostnameResolver interface {
fmt.Stringer
HostnameByIpResolver
HostnameByMacResolver
}
type refresher interface {
refresh() error
}
type ipLister interface {
fmt.Stringer
// List returns list of ip known by the resolver.
List() []string
}
type Client struct {
IP netip.Addr
Mac string
Hostname string
Source map[string]struct{}
QueryCount int64
IncludeQueryCount bool
}
type Table struct {
ipResolvers []IpResolver
macResolvers []MacResolver
hostnameResolvers []HostnameResolver
refreshers []refresher
initOnce sync.Once
refreshInterval int
dhcp *dhcp
merlin *merlinDiscover
ubios *ubiosDiscover
arp *arpDiscover
ndp *ndpDiscover
ptr *ptrDiscover
mdns *mdns
hf *hostsFile
vni *virtualNetworkIface
svcCfg ctrld.ServiceConfig
quitCh chan struct{}
selfIP string
cdUID string
ptrNameservers []string
}
func NewTable(cfg *ctrld.Config, selfIP, cdUID string, ns []string) *Table {
refreshInterval := cfg.Service.DiscoverRefreshInterval
if refreshInterval <= 0 {
refreshInterval = 2 * 60 // 2 minutes
}
return &Table{
svcCfg: cfg.Service,
quitCh: make(chan struct{}),
selfIP: selfIP,
cdUID: cdUID,
ptrNameservers: ns,
refreshInterval: refreshInterval,
}
}
func (t *Table) AddLeaseFile(name string, format ctrld.LeaseFileFormat) {
if !t.discoverDHCP() {
return
}
clientInfoFiles[name] = format
}
// RefreshLoop runs all the refresher to update new client info data.
func (t *Table) RefreshLoop(ctx context.Context) {
timer := time.NewTicker(time.Second * time.Duration(t.refreshInterval))
defer timer.Stop()
for {
select {
case <-timer.C:
for _, r := range t.refreshers {
_ = r.refresh()
}
case <-ctx.Done():
close(t.quitCh)
return
}
}
}
func (t *Table) Init() {
t.initOnce.Do(t.init)
}
func (t *Table) init() {
// Custom client ID presents, use it as the only source.
if _, clientID := controld.ParseRawUID(t.cdUID); clientID != "" {
ctrld.ProxyLogger.Load().Debug().Msg("start self discovery")
t.dhcp = &dhcp{selfIP: t.selfIP}
t.dhcp.addSelf()
t.ipResolvers = append(t.ipResolvers, t.dhcp)
t.macResolvers = append(t.macResolvers, t.dhcp)
t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp)
return
}
// Otherwise, process all possible sources in order, that means
// the first result of IP/MAC/Hostname lookup will be used.
//
// Routers custom clients:
// - Merlin
// - Ubios
if t.discoverDHCP() || t.discoverARP() {
t.merlin = &merlinDiscover{}
t.ubios = &ubiosDiscover{}
discovers := map[string]interface {
refresher
HostnameResolver
}{
"Merlin": t.merlin,
"Ubios": t.ubios,
}
for platform, discover := range discovers {
if err := discover.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msgf("could not init %s discover", platform)
} else {
t.hostnameResolvers = append(t.hostnameResolvers, discover)
t.refreshers = append(t.refreshers, discover)
}
}
}
// Hosts file mapping.
if t.discoverHosts() {
t.hf = &hostsFile{}
ctrld.ProxyLogger.Load().Debug().Msg("start hosts file discovery")
if err := t.hf.init(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init hosts file discover")
} else {
t.hostnameResolvers = append(t.hostnameResolvers, t.hf)
t.refreshers = append(t.refreshers, t.hf)
}
go t.hf.watchChanges()
}
// DHCP lease files.
if t.discoverDHCP() {
t.dhcp = &dhcp{selfIP: t.selfIP}
ctrld.ProxyLogger.Load().Debug().Msg("start dhcp discovery")
if err := t.dhcp.init(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init DHCP discover")
} else {
t.ipResolvers = append(t.ipResolvers, t.dhcp)
t.macResolvers = append(t.macResolvers, t.dhcp)
t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp)
}
go t.dhcp.watchChanges()
}
// ARP/NDP table.
if t.discoverARP() {
t.arp = &arpDiscover{}
t.ndp = &ndpDiscover{}
ctrld.ProxyLogger.Load().Debug().Msg("start arp discovery")
discovers := map[string]interface {
refresher
IpResolver
MacResolver
}{
"ARP": t.arp,
"NDP": t.ndp,
}
for protocol, discover := range discovers {
if err := discover.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msgf("could not init %s discover", protocol)
} else {
t.ipResolvers = append(t.ipResolvers, discover)
t.macResolvers = append(t.macResolvers, discover)
t.refreshers = append(t.refreshers, discover)
}
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-t.quitCh
cancel()
}()
go t.ndp.listen(ctx)
}
// PTR lookup.
if t.discoverPTR() {
t.ptr = &ptrDiscover{resolver: ctrld.NewPrivateResolver()}
if len(t.ptrNameservers) > 0 {
nss := make([]string, 0, len(t.ptrNameservers))
for _, ns := range t.ptrNameservers {
host, port := ns, "53"
if h, p, err := net.SplitHostPort(ns); err == nil {
host, port = h, p
}
// Only use valid ip:port pair.
if _, portErr := strconv.Atoi(port); portErr == nil && port != "0" && net.ParseIP(host) != nil {
nss = append(nss, net.JoinHostPort(host, port))
} else {
ctrld.ProxyLogger.Load().Warn().Msgf("ignoring invalid nameserver for ptr discover: %q", ns)
}
}
if len(nss) > 0 {
t.ptr.resolver = ctrld.NewResolverWithNameserver(nss)
ctrld.ProxyLogger.Load().Debug().Msgf("using nameservers %v for ptr discovery", nss)
}
}
ctrld.ProxyLogger.Load().Debug().Msg("start ptr discovery")
if err := t.ptr.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init PTR discover")
} else {
t.hostnameResolvers = append(t.hostnameResolvers, t.ptr)
t.refreshers = append(t.refreshers, t.ptr)
}
}
// mdns.
if t.discoverMDNS() {
t.mdns = &mdns{}
ctrld.ProxyLogger.Load().Debug().Msg("start mdns discovery")
if err := t.mdns.init(t.quitCh); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init mDNS discover")
} else {
t.hostnameResolvers = append(t.hostnameResolvers, t.mdns)
}
}
// VPN clients.
if t.discoverDHCP() || t.discoverARP() {
t.vni = &virtualNetworkIface{}
t.hostnameResolvers = append(t.hostnameResolvers, t.vni)
}
}
func (t *Table) LookupIP(mac string) string {
t.initOnce.Do(t.init)
for _, r := range t.ipResolvers {
if ip := r.LookupIP(mac); ip != "" {
return ip
}
}
return ""
}
func (t *Table) LookupMac(ip string) string {
t.initOnce.Do(t.init)
for _, r := range t.macResolvers {
if mac := r.LookupMac(ip); mac != "" {
return mac
}
}
return ""
}
func (t *Table) LookupHostname(ip, mac string) string {
t.initOnce.Do(t.init)
for _, r := range t.hostnameResolvers {
if name := r.LookupHostnameByIP(ip); name != "" {
return name
}
if name := r.LookupHostnameByMac(mac); name != "" {
return name
}
}
return ""
}
// LookupRFC1918IPv4 returns the RFC1918 IPv4 address for the given MAC address, if any.
func (t *Table) LookupRFC1918IPv4(mac string) string {
t.initOnce.Do(t.init)
for _, r := range t.ipResolvers {
ip, err := netip.ParseAddr(r.LookupIP(mac))
if err != nil || ip.Is6() {
continue
}
if ip.IsPrivate() {
return ip.String()
}
}
return ""
}
type macEntry struct {
mac string
src string
}
type hostnameEntry struct {
name string
src string
}
func (t *Table) lookupMacAll(ip string) []*macEntry {
var res []*macEntry
for _, r := range t.macResolvers {
res = append(res, &macEntry{mac: r.LookupMac(ip), src: r.String()})
}
return res
}
func (t *Table) lookupHostnameAll(ip, mac string) []*hostnameEntry {
var res []*hostnameEntry
for _, r := range t.hostnameResolvers {
src := r.String()
// For ptrDiscover, lookup hostname may block due to server unavailable,
// so only lookup from cache to prevent timeout reached.
if ptrResolver, ok := r.(*ptrDiscover); ok {
if name := ptrResolver.lookupHostnameFromCache(ip); name != "" {
res = append(res, &hostnameEntry{name: name, src: src})
}
continue
}
if name := r.LookupHostnameByIP(ip); name != "" {
res = append(res, &hostnameEntry{name: name, src: src})
continue
}
if name := r.LookupHostnameByMac(mac); name != "" {
res = append(res, &hostnameEntry{name: name, src: src})
continue
}
}
return res
}
// ListClients returns list of clients discovered by ctrld.
func (t *Table) ListClients() []*Client {
for _, r := range t.refreshers {
_ = r.refresh()
}
ipMap := make(map[string]*Client)
il := []ipLister{t.dhcp, t.arp, t.ndp, t.ptr, t.mdns, t.vni}
for _, ir := range il {
for _, ip := range ir.List() {
c, ok := ipMap[ip]
if !ok {
c = &Client{
IP: netip.MustParseAddr(ip),
Source: map[string]struct{}{ir.String(): {}},
}
ipMap[ip] = c
} else {
c.Source[ir.String()] = struct{}{}
}
}
}
for ip := range ipMap {
c := ipMap[ip]
for _, e := range t.lookupMacAll(ip) {
if c.Mac == "" && e.mac != "" {
c.Mac = e.mac
}
if e.mac != "" {
c.Source[e.src] = struct{}{}
}
}
for _, e := range t.lookupHostnameAll(ip, c.Mac) {
if c.Hostname == "" && e.name != "" {
c.Hostname = e.name
}
if e.name != "" {
c.Source[e.src] = struct{}{}
}
}
}
clients := make([]*Client, 0, len(ipMap))
for _, c := range ipMap {
clients = append(clients, c)
}
return clients
}
// StoreVPNClient stores client info for VPN clients.
func (t *Table) StoreVPNClient(ci *ctrld.ClientInfo) {
if ci == nil || t.vni == nil {
return
}
t.vni.mac.Store(ci.IP, ci.Mac)
t.vni.ip2name.Store(ci.IP, ci.Hostname)
}
// ipFinder is the interface for retrieving IP address from hostname.
type ipFinder interface {
lookupIPByHostname(name string, v6 bool) string
}
// LookupIPByHostname returns the ip address of given hostname.
// If v6 is true, return IPv6 instead of default IPv4.
func (t *Table) LookupIPByHostname(hostname string, v6 bool) *netip.Addr {
if t == nil {
return nil
}
for _, finder := range []ipFinder{t.hf, t.ptr, t.mdns, t.dhcp} {
if addr := finder.lookupIPByHostname(hostname, v6); addr != "" {
if ip, err := netip.ParseAddr(addr); err == nil {
return &ip
}
}
}
return nil
}
func (t *Table) discoverDHCP() bool {
if t.svcCfg.DiscoverDHCP == nil {
return true
}
return *t.svcCfg.DiscoverDHCP
}
func (t *Table) discoverARP() bool {
if t.svcCfg.DiscoverARP == nil {
return true
}
return *t.svcCfg.DiscoverARP
}
func (t *Table) discoverMDNS() bool {
if t.svcCfg.DiscoverMDNS == nil {
return true
}
return *t.svcCfg.DiscoverMDNS
}
func (t *Table) discoverPTR() bool {
if t.svcCfg.DiscoverPtr == nil {
return true
}
return *t.svcCfg.DiscoverPtr
}
func (t *Table) discoverHosts() bool {
if t.svcCfg.DiscoverHosts == nil {
return true
}
return *t.svcCfg.DiscoverHosts
}
// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file.
func normalizeIP(in string) string {
// dnsmasq may put ip with interface index in lease file, strip it here.
ip, _, found := strings.Cut(in, "%")
if found {
return ip
}
return in
}
func normalizeHostname(name string) string {
if before, _, found := strings.Cut(name, "."); found {
return before // remove ".local.", ".lan.", ... suffix
}
return name
}

View File

@@ -0,0 +1,46 @@
package clientinfo
import (
"testing"
)
func Test_normalizeIP(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"v4", "127.0.0.1", "127.0.0.1"},
{"v4 with index", "127.0.0.1%lo", "127.0.0.1"},
{"v6", "fe80::1", "fe80::1"},
{"v6 with index", "fe80::1%22002", "fe80::1"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := normalizeIP(tc.in); got != tc.want {
t.Errorf("normalizeIP() = %v, want %v", got, tc.want)
}
})
}
}
func TestTable_LookupRFC1918IPv4(t *testing.T) {
table := &Table{
dhcp: &dhcp{},
arp: &arpDiscover{},
}
table.ipResolvers = append(table.ipResolvers, table.dhcp)
table.ipResolvers = append(table.ipResolvers, table.arp)
macAddress := "cc:19:f9:8a:49:e6"
rfc1918IPv4 := "10.0.10.245"
table.dhcp.ip.Store(macAddress, "127.0.0.1")
table.arp.ip.Store(macAddress, rfc1918IPv4)
if got := table.LookupRFC1918IPv4(macAddress); got != rfc1918IPv4 {
t.Fatalf("unexpected result, want: %s, got: %s", rfc1918IPv4, got)
}
}

408
internal/clientinfo/dhcp.go Normal file
View File

@@ -0,0 +1,408 @@
package clientinfo
import (
"bufio"
"bytes"
"encoding/csv"
"fmt"
"io"
"net"
"net/netip"
"os"
"sort"
"strings"
"sync"
"github.com/fsnotify/fsnotify"
"tailscale.com/net/interfaces"
"tailscale.com/util/lineread"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/router"
)
type dhcp struct {
mac2name sync.Map // mac => name
ip2name sync.Map // ip => name
ip sync.Map // mac => ip
mac sync.Map // ip => mac
watcher *fsnotify.Watcher
selfIP string
}
func (d *dhcp) init() error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
d.addSelf()
d.watcher = watcher
for file, format := range clientInfoFiles {
// Ignore errors for default lease files.
_ = d.addLeaseFile(file, format)
}
return nil
}
func (d *dhcp) watchChanges() {
if d.watcher == nil {
return
}
if dir := router.LeaseFilesDir(); dir != "" {
if err := d.watcher.Add(dir); err != nil {
ctrld.ProxyLogger.Load().Err(err).Str("dir", dir).Msg("could not watch lease dir")
}
}
for {
select {
case event, ok := <-d.watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Create) {
if format, ok := clientInfoFiles[event.Name]; ok {
if err := d.addLeaseFile(event.Name, format); err != nil {
ctrld.ProxyLogger.Load().Err(err).Str("file", event.Name).Msg("could not add lease file")
}
}
continue
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Chmod) || event.Has(fsnotify.Remove) {
format := clientInfoFiles[event.Name]
if err := d.readLeaseFile(event.Name, format); err != nil && !os.IsNotExist(err) {
ctrld.ProxyLogger.Load().Err(err).Str("file", event.Name).Msg("leases file changed but failed to update client info")
}
}
case err, ok := <-d.watcher.Errors:
if !ok {
return
}
ctrld.ProxyLogger.Load().Err(err).Msg("could not watch client info file")
}
}
}
func (d *dhcp) LookupIP(mac string) string {
val, ok := d.ip.Load(mac)
if !ok {
return ""
}
return val.(string)
}
func (d *dhcp) LookupMac(ip string) string {
val, ok := d.mac.Load(ip)
if !ok {
return ""
}
return val.(string)
}
func (d *dhcp) LookupHostnameByIP(ip string) string {
val, ok := d.ip2name.Load(ip)
if !ok {
return ""
}
return val.(string)
}
func (d *dhcp) LookupHostnameByMac(mac string) string {
val, ok := d.mac2name.Load(mac)
if !ok {
return ""
}
return val.(string)
}
func (d *dhcp) String() string {
return "dhcp"
}
func (d *dhcp) List() []string {
if d == nil {
return nil
}
var ips []string
d.ip.Range(func(key, value any) bool {
ips = append(ips, value.(string))
return true
})
d.mac.Range(func(key, value any) bool {
ips = append(ips, key.(string))
return true
})
return ips
}
func (d *dhcp) lookupIPByHostname(name string, v6 bool) string {
if d == nil {
return ""
}
var (
rfc1918Addrs []netip.Addr
others []netip.Addr
)
d.ip2name.Range(func(key, value any) bool {
if value != name {
return true
}
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
if addr.IsPrivate() {
rfc1918Addrs = append(rfc1918Addrs, addr)
} else {
others = append(others, addr)
}
}
return true
})
result := [][]netip.Addr{rfc1918Addrs, others}
for _, addrs := range result {
if len(addrs) > 0 {
sort.Slice(addrs, func(i, j int) bool {
return addrs[i].Less(addrs[j])
})
return addrs[0].String()
}
}
return ""
}
// AddLeaseFile adds given lease file for reading/watching clients info.
func (d *dhcp) addLeaseFile(name string, format ctrld.LeaseFileFormat) error {
if d.watcher == nil {
return nil
}
if err := d.readLeaseFile(name, format); err != nil {
return fmt.Errorf("could not read lease file: %w", err)
}
clientInfoFiles[name] = format
return d.watcher.Add(name)
}
// readLeaseFile reads the lease file with given format, saving client information to dhcp table.
func (d *dhcp) readLeaseFile(name string, format ctrld.LeaseFileFormat) error {
switch format {
case ctrld.Dnsmasq:
return d.dnsmasqReadClientInfoFile(name)
case ctrld.IscDhcpd:
return d.iscDHCPReadClientInfoFile(name)
case ctrld.KeaDHCP4:
return d.keaDhcp4ReadClientInfoFile(name)
}
return fmt.Errorf("unsupported format: %s, file: %s", format, name)
}
// dnsmasqReadClientInfoFile populates dhcp table with client info reading from dnsmasq lease file.
func (d *dhcp) dnsmasqReadClientInfoFile(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
return d.dnsmasqReadClientInfoReader(f)
}
// dnsmasqReadClientInfoReader performs the same task as dnsmasqReadClientInfoFile,
// but by reading from an io.Reader instead of file.
func (d *dhcp) dnsmasqReadClientInfoReader(reader io.Reader) error {
return lineread.Reader(reader, func(line []byte) error {
fields := bytes.Fields(line)
if len(fields) < 4 {
return nil
}
mac := string(fields[1])
if _, err := net.ParseMAC(mac); err != nil {
// The second field is not a dhcp, skip.
return nil
}
ip := normalizeIP(string(fields[2]))
if net.ParseIP(ip) == nil {
ctrld.ProxyLogger.Load().Warn().Msgf("invalid ip address entry: %q", ip)
ip = ""
}
d.mac.Store(ip, mac)
d.ip.Store(mac, ip)
hostname := string(fields[3])
if hostname == "*" {
return nil
}
name := normalizeHostname(hostname)
d.mac2name.Store(mac, name)
d.ip2name.Store(ip, name)
return nil
})
}
// iscDHCPReadClientInfoFile populates dhcp table with client info reading from isc-dhcpd lease file.
func (d *dhcp) iscDHCPReadClientInfoFile(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
return d.iscDHCPReadClientInfoReader(f)
}
// iscDHCPReadClientInfoReader performs the same task as iscDHCPReadClientInfoFile,
// but by reading from an io.Reader instead of file.
func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error {
s := bufio.NewScanner(reader)
var ip, mac, hostname string
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "}") {
d.mac.Store(ip, mac)
d.ip.Store(mac, ip)
if hostname != "" && hostname != "*" {
name := normalizeHostname(hostname)
d.mac2name.Store(mac, name)
d.ip2name.Store(ip, hostname)
ip, mac, hostname = "", "", ""
}
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch fields[0] {
case "lease":
ip = normalizeIP(strings.ToLower(fields[1]))
if net.ParseIP(ip) == nil {
ctrld.ProxyLogger.Load().Warn().Msgf("invalid ip address entry: %q", ip)
ip = ""
}
case "hardware":
if len(fields) >= 3 {
mac = strings.ToLower(strings.TrimRight(fields[2], ";"))
if _, err := net.ParseMAC(mac); err != nil {
// Invalid dhcp, skip.
mac = ""
}
}
case "client-hostname":
hostname = strings.Trim(fields[1], `";`)
}
}
return nil
}
// keaDhcp4ReadClientInfoFile populates dhcp table with client info reading from kea dhcp4 lease file.
func (d *dhcp) keaDhcp4ReadClientInfoFile(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
return d.keaDhcp4ReadClientInfoReader(bufio.NewReader(f))
}
// keaDhcp4ReadClientInfoReader performs the same task as keaDhcp4ReadClientInfoFile,
// but by reading from an io.Reader instead of file.
func (d *dhcp) keaDhcp4ReadClientInfoReader(r io.Reader) error {
cr := csv.NewReader(r)
for {
record, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return err
}
if len(record) < 9 {
continue // hostname is at 9th field, so skipping record with not enough fields.
}
if record[0] == "address" {
continue // skip header.
}
mac := record[1]
if _, err := net.ParseMAC(mac); err != nil { // skip invalid MAC
continue
}
ip := normalizeIP(record[0])
if net.ParseIP(ip) == nil {
ctrld.ProxyLogger.Load().Warn().Msgf("invalid ip address entry: %q", ip)
ip = ""
}
d.mac.Store(ip, mac)
d.ip.Store(mac, ip)
hostname := record[8]
if hostname == "*" {
continue
}
name := normalizeHostname(hostname)
d.mac2name.Store(mac, name)
d.ip2name.Store(ip, name)
}
return nil
}
// addSelf populates current host info to dhcp, so queries from
// the host itself can be attached with proper client info.
func (d *dhcp) addSelf() {
hostname, err := os.Hostname()
if err != nil {
ctrld.ProxyLogger.Load().Err(err).Msg("could not get hostname")
return
}
hostname = normalizeHostname(hostname)
d.ip2name.Store("127.0.0.1", hostname)
d.ip2name.Store("::1", hostname)
found := false
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
mac := i.HardwareAddr.String()
// Skip loopback interfaces, info was stored above.
if mac == "" {
return
}
addrs, _ := i.Addrs()
for _, addr := range addrs {
if found {
return
}
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP
d.mac.Store(ip.String(), mac)
d.ip.Store(mac, ip.String())
if ip.To4() != nil {
d.mac.Store("127.0.0.1", mac)
} else {
d.mac.Store("::1", mac)
}
d.mac2name.Store(mac, hostname)
d.ip2name.Store(ip.String(), hostname)
// If we have self IP set, and this IP is it, use this IP only.
if ip.String() == d.selfIP {
found = true
}
}
})
for _, netIface := range router.SelfInterfaces() {
mac := netIface.HardwareAddr.String()
if mac == "" {
return
}
d.mac2name.Store(mac, hostname)
addrs, _ := netIface.Addrs()
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP
d.mac.LoadOrStore(ip.String(), mac)
d.ip.LoadOrStore(mac, ip.String())
d.ip2name.Store(ip.String(), hostname)
}
}
}

View File

@@ -0,0 +1,19 @@
package clientinfo
import "github.com/Control-D-Inc/ctrld"
// clientInfoFiles specifies client info files and how to read them on supported platforms.
var clientInfoFiles = map[string]ctrld.LeaseFileFormat{
"/tmp/dnsmasq.leases": ctrld.Dnsmasq, // ddwrt
"/tmp/dhcp.leases": ctrld.Dnsmasq, // openwrt
"/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // merlin
"/mnt/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDM Pro
"/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDR
"/etc/dhcpd/dhcpd-leases.log": ctrld.Dnsmasq, // Synology
"/tmp/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // Tomato
"/run/dnsmasq-dhcp.leases": ctrld.Dnsmasq, // EdgeOS
"/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS
"/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense
"/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla
"/var/lib/kea/dhcp4.leases": ctrld.KeaDHCP4, // Pfsense
}

View File

@@ -0,0 +1,141 @@
package clientinfo
import (
"io"
"strings"
"testing"
)
func Test_readClientInfoReader(t *testing.T) {
d := &dhcp{}
tests := []struct {
name string
in string
readFunc func(r io.Reader) error
mac string
hostname string
}{
{
"good dnsmasq",
`1683329857 e6:20:59:b8:c1:6d 192.168.1.186 host1 01:e6:20:59:b8:c1:6d
`,
d.dnsmasqReadClientInfoReader,
"e6:20:59:b8:c1:6d",
"host1",
},
{
"bad dnsmasq seen on UDMdream machine",
`1683329857 e6:20:59:b8:c1:6e 192.168.1.111 host1 01:e6:20:59:b8:c1:6e
duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c
1683322985 117442354 2600:4040:b0e6:b700::111 ASDASD 00:01:00:01:2a:d0:b9:81:00:07:32:4c:1c:07
`,
d.dnsmasqReadClientInfoReader,
"e6:20:59:b8:c1:6e",
"host1",
},
{
"isc-dhcpd good",
`lease 192.168.1.1 {
hardware ethernet 00:00:00:00:00:01;
client-hostname "host-1";
}
`,
d.iscDHCPReadClientInfoReader,
"00:00:00:00:00:01",
"host-1",
},
{
"isc-dhcpd bad dhcp",
`lease 192.168.1.1 {
hardware ethernet invalid-dhcp;
client-hostname "host-1";
}
lease 192.168.1.2 {
hardware ethernet 00:00:00:00:00:02;
client-hostname "host-2";
}
`,
d.iscDHCPReadClientInfoReader,
"00:00:00:00:00:02",
"host-2",
},
{
"",
`1685794060 00:00:00:00:00:04 192.168.0.209 example 00:00:00:00:00:04 9`,
d.dnsmasqReadClientInfoReader,
"00:00:00:00:00:04",
"example",
},
{
"kea-dhcp4 good",
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
`,
d.keaDhcp4ReadClientInfoReader,
"00:00:00:00:00:05",
"foo",
},
{
"kea-dhcp4 no-header",
`192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0`,
d.keaDhcp4ReadClientInfoReader,
"00:00:00:00:00:05",
"foo",
},
{
"kea-dhcp4 hostname *",
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,*,0,,0
`,
d.keaDhcp4ReadClientInfoReader,
"00:00:00:00:00:05",
"*",
},
{
"kea-dhcp4 bad",
`address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
192.168.0.123,00:00:00:00:00:05,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
192.168.0.124,invalid_MAC,00:00:00:00:00:05,7200,1703290639,1,0,0,foo,0,,0
`,
d.keaDhcp4ReadClientInfoReader,
"00:00:00:00:00:05",
"foo",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d.mac2name.Delete(tc.mac)
if err := tc.readFunc(strings.NewReader(tc.in)); err != nil {
t.Errorf("readClientInfoReader() error = %v", err)
}
val, existed := d.mac2name.Load(tc.mac)
if tc.hostname == "*" {
if existed {
t.Errorf("* hostname must be skipped")
}
return
}
if !existed {
t.Error("client info missing")
}
hostname := val.(string)
if existed && hostname != tc.hostname {
t.Errorf("hostname mismatched, want: %q, got: %q", tc.hostname, hostname)
}
})
}
}
func Test_dhcp_lookupIPByHostname(t *testing.T) {
d := &dhcp{}
want := "192.168.1.123"
d.ip2name.Store(want, "foo")
d.ip2name.Store("127.0.0.1", "foo")
d.ip2name.Store("169.254.123.123", "foo")
if got := d.lookupIPByHostname("foo", false); got != want {
t.Fatalf("unexpected result, want: %s, got: %s", want, got)
}
}

View File

@@ -0,0 +1,190 @@
package clientinfo
import (
"bufio"
"bytes"
"io"
"net/netip"
"os"
"strings"
"sync"
"github.com/fsnotify/fsnotify"
"github.com/jaytaylor/go-hostsfile"
"github.com/Control-D-Inc/ctrld"
)
const (
ipv4LocalhostName = "localhost"
ipv6LocalhostName = "ip6-localhost"
ipv6LoopbackName = "ip6-loopback"
hostEntriesConfPath = "/var/unbound/host_entries.conf"
)
// hostsFile provides client discovery functionality using system hosts file.
type hostsFile struct {
watcher *fsnotify.Watcher
mu sync.Mutex
m map[string][]string
}
// init performs initialization works, which is necessary before hostsFile can be fully operated.
func (hf *hostsFile) init() error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
hf.watcher = watcher
if err := hf.watcher.Add(hostsfile.HostsPath); err != nil {
return err
}
// Conservatively adding hostEntriesConfPath, since it is not available everywhere.
_ = hf.watcher.Add(hostEntriesConfPath)
return hf.refresh()
}
// refresh reloads hosts file entries.
func (hf *hostsFile) refresh() error {
m, err := hostsfile.ParseHosts(hostsfile.ReadHostsFile())
if err != nil {
return err
}
hf.mu.Lock()
hf.m = m
// override hosts file with host_entries.conf content if present.
hem, err := parseHostEntriesConf(hostEntriesConfPath)
if err != nil && !os.IsNotExist(err) {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not read host_entries.conf file")
}
for k, v := range hem {
hf.m[k] = v
}
hf.mu.Unlock()
return nil
}
// watchChanges watches and updates hosts file data if any changes happens.
func (hf *hostsFile) watchChanges() {
if hf.watcher == nil {
return
}
for {
select {
case event, ok := <-hf.watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Chmod) || event.Has(fsnotify.Remove) {
if err := hf.refresh(); err != nil && !os.IsNotExist(err) {
ctrld.ProxyLogger.Load().Err(err).Msg("hosts file changed but failed to update client info")
}
}
case err, ok := <-hf.watcher.Errors:
if !ok {
return
}
ctrld.ProxyLogger.Load().Err(err).Msg("could not watch client info file")
}
}
}
// LookupHostnameByIP returns hostname for given IP from current hosts file entries.
func (hf *hostsFile) LookupHostnameByIP(ip string) string {
hf.mu.Lock()
defer hf.mu.Unlock()
if names := hf.m[ip]; len(names) > 0 {
isLoopback := ip == "127.0.0.1" || ip == "::1"
for _, hostname := range names {
name := normalizeHostname(hostname)
// Ignoring ipv4/ipv6 loopback entry.
if isLoopback && isLocalhostName(name) {
continue
}
return name
}
}
return ""
}
// LookupHostnameByMac returns hostname for given Mac from current hosts file entries.
func (hf *hostsFile) LookupHostnameByMac(mac string) string {
return ""
}
// String returns human-readable format of hostsFile.
func (hf *hostsFile) String() string {
return "hosts"
}
func (hf *hostsFile) lookupIPByHostname(name string, v6 bool) string {
if hf == nil {
return ""
}
hf.mu.Lock()
defer hf.mu.Unlock()
for addr, names := range hf.m {
if ip, err := netip.ParseAddr(addr); err == nil && !ip.IsLoopback() {
for _, n := range names {
if n == name && ip.Is6() == v6 {
return ip.String()
}
}
}
}
return ""
}
// isLocalhostName reports whether the given hostname represents localhost.
func isLocalhostName(hostname string) bool {
switch hostname {
case ipv4LocalhostName, ipv6LocalhostName, ipv6LoopbackName:
return true
default:
return false
}
}
// parseHostEntriesConf parses host_entries.conf file and returns parsed result.
func parseHostEntriesConf(path string) (map[string][]string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return parseHostEntriesConfFromReader(bytes.NewReader(b)), nil
}
// parseHostEntriesConfFromReader is like parseHostEntriesConf, but read from an io.Reader instead of file.
func parseHostEntriesConfFromReader(r io.Reader) map[string][]string {
hostsMap := map[string][]string{}
scanner := bufio.NewScanner(r)
localZone := ""
for scanner.Scan() {
line := scanner.Text()
if after, found := strings.CutPrefix(line, "local-zone:"); found {
after = strings.TrimSpace(after)
fields := strings.Fields(after)
if len(fields) > 1 {
localZone = strings.Trim(fields[0], `"`)
}
continue
}
// Only read "local-data-ptr: ..." line, it has all necessary information.
after, found := strings.CutPrefix(line, "local-data-ptr:")
if !found {
continue
}
after = strings.TrimSpace(after)
after = strings.Trim(after, `"`)
fields := strings.Fields(after)
if len(fields) != 2 {
continue
}
ip := fields[0]
name := strings.TrimSuffix(fields[1], "."+localZone)
hostsMap[ip] = append(hostsMap[ip], name)
}
return hostsMap
}

View File

@@ -0,0 +1,77 @@
package clientinfo
import (
"strings"
"testing"
)
func Test_hostsFile_LookupHostnameByIP(t *testing.T) {
tests := []struct {
name string
ip string
hostnames []string
expectedHostname string
}{
{"ipv4 loopback", "127.0.0.1", []string{ipv4LocalhostName}, ""},
{"ipv6 loopback", "::1", []string{ipv6LocalhostName, ipv6LoopbackName}, ""},
{"non-localhost", "::1", []string{"foo"}, "foo"},
{"multiple hostnames", "::1", []string{ipv4LocalhostName, "foo"}, "foo"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
hf := &hostsFile{m: make(map[string][]string)}
hf.mu.Lock()
hf.m[tc.ip] = tc.hostnames
hf.mu.Unlock()
if got := hf.LookupHostnameByIP(tc.ip); got != tc.expectedHostname {
t.Errorf("unpexpected result, want: %q, got: %q", tc.expectedHostname, got)
}
})
}
}
func Test_parseHostEntriesConfFromReader(t *testing.T) {
const content = `local-zone: "localdomain" transparent
local-data-ptr: "127.0.0.1 localhost"
local-data: "localhost A 127.0.0.1"
local-data: "localhost.localdomain A 127.0.0.1"
local-data-ptr: "::1 localhost"
local-data: "localhost AAAA ::1"
local-data: "localhost.localdomain AAAA ::1"
local-data-ptr: "10.0.10.227 OPNsense.localdomain"
local-data: "OPNsense.localdomain A 10.0.10.227"
local-data: "OPNsense A 10.0.10.227"
local-data-ptr: "fe80::5a78:4e29:caa3:f9f7 OPNsense.localdomain"
local-data: "OPNsense.localdomain AAAA fe80::5a78:4e29:caa3:f9f7"
local-data: "OPNsense AAAA fe80::5a78:4e29:caa3:f9f7"
local-data-ptr: "1.1.1.1 banana-party.local.com"
local-data: "banana-party.local.com IN A 1.1.1.1"
local-data-ptr: "1.1.1.1 cheese-land.lan"
local-data: "cheese-land.lan IN A 1.1.1.1"
`
r := strings.NewReader(content)
hostsMap := parseHostEntriesConfFromReader(r)
if len(hostsMap) != 5 {
t.Fatalf("unexpected number of entries, want 5, got: %d", len(hostsMap))
}
for ip, names := range hostsMap {
switch ip {
case "1.1.1.1":
for _, name := range names {
if name != "banana-party.local.com" && name != "cheese-land.lan" {
t.Fatalf("unexpected names for 1.1.1.1: %v", names)
}
}
case "10.0.10.227":
if len(names) != 1 {
t.Fatalf("unexpected names for 10.0.10.227: %v", names)
}
if names[0] != "OPNsense" {
t.Fatalf("unexpected name: %s", names[0])
}
}
}
}

282
internal/clientinfo/mdns.go Normal file
View File

@@ -0,0 +1,282 @@
package clientinfo
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net"
"net/netip"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"github.com/miekg/dns"
"tailscale.com/logtail/backoff"
"github.com/Control-D-Inc/ctrld"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
var (
mdnsV4Addr = &net.UDPAddr{
IP: net.ParseIP("224.0.0.251"),
Port: 5353,
}
mdnsV6Addr = &net.UDPAddr{
IP: net.ParseIP("ff02::fb"),
Port: 5353,
}
)
type mdns struct {
name sync.Map // ip => hostname
}
func (m *mdns) LookupHostnameByIP(ip string) string {
val, ok := m.name.Load(ip)
if !ok {
return ""
}
return val.(string)
}
func (m *mdns) LookupHostnameByMac(mac string) string {
return ""
}
func (m *mdns) String() string {
return "mdns"
}
func (m *mdns) List() []string {
if m == nil {
return nil
}
var ips []string
m.name.Range(func(key, value any) bool {
ips = append(ips, key.(string))
return true
})
return ips
}
func (m *mdns) lookupIPByHostname(name string, v6 bool) string {
if m == nil {
return ""
}
var ip string
m.name.Range(func(key, value any) bool {
if value == name {
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
ip = addr.String()
//lint:ignore S1008 This is used for readable.
if addr.IsLoopback() { // Continue searching if this is loopback address.
return true
}
return false
}
}
return true
})
return ip
}
func (m *mdns) init(quitCh chan struct{}) error {
ifaces, err := multicastInterfaces()
if err != nil {
return err
}
v4ConnList := make([]*net.UDPConn, 0, len(ifaces))
v6ConnList := make([]*net.UDPConn, 0, len(ifaces))
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if conn, err := net.ListenMulticastUDP("udp4", &iface, mdnsV4Addr); err == nil {
v4ConnList = append(v4ConnList, conn)
go m.readLoop(conn)
}
if ctrldnet.IPv6Available(context.Background()) {
if conn, err := net.ListenMulticastUDP("udp6", &iface, mdnsV6Addr); err == nil {
v6ConnList = append(v6ConnList, conn)
go m.readLoop(conn)
}
}
}
go m.probeLoop(v4ConnList, mdnsV4Addr, quitCh)
go m.probeLoop(v6ConnList, mdnsV6Addr, quitCh)
go m.getDataFromAvahiDaemonCache()
return nil
}
// probeLoop performs mdns probe actively to get hostname updates.
func (m *mdns) probeLoop(conns []*net.UDPConn, remoteAddr net.Addr, quitCh chan struct{}) {
bo := backoff.NewBackoff("mdns probe", func(format string, args ...any) {}, time.Second*30)
for {
err := m.probe(conns, remoteAddr)
if isErrNetUnreachableOrInvalid(err) {
ctrld.ProxyLogger.Load().Warn().Msgf("stop probing %q: network unreachable or invalid", remoteAddr)
break
}
if err != nil {
ctrld.ProxyLogger.Load().Warn().Err(err).Msg("error while probing mdns")
bo.BackOff(context.Background(), errors.New("mdns probe backoff"))
continue
}
break
}
<-quitCh
for _, conn := range conns {
_ = conn.Close()
}
}
// readLoop reads from mdns connection, save/update any hostnames found.
func (m *mdns) readLoop(conn *net.UDPConn) {
defer conn.Close()
buf := make([]byte, dns.MaxMsgSize)
for {
_ = conn.SetReadDeadline(time.Now().Add(time.Second * 30))
n, _, err := conn.ReadFromUDP(buf)
if err != nil {
if err, ok := err.(*net.OpError); ok && (err.Timeout() || err.Temporary()) {
continue
}
// Do not complain about use of closed network connection.
if errors.Is(err, net.ErrClosed) {
return
}
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("mdns readLoop error")
return
}
var msg dns.Msg
if err := msg.Unpack(buf[:n]); err != nil {
continue
}
var ip, name string
rrs := make([]dns.RR, 0, len(msg.Answer)+len(msg.Extra))
rrs = append(rrs, msg.Answer...)
rrs = append(rrs, msg.Extra...)
for _, rr := range rrs {
switch ar := rr.(type) {
case *dns.A:
ip, name = ar.A.String(), ar.Hdr.Name
case *dns.AAAA:
ip, name = ar.AAAA.String(), ar.Hdr.Name
}
if ip != "" && name != "" {
name = normalizeHostname(name)
if val, loaded := m.name.LoadOrStore(ip, name); !loaded {
ctrld.ProxyLogger.Load().Debug().Msgf("found hostname: %q, ip: %q via mdns", name, ip)
} else {
old := val.(string)
if old != name {
ctrld.ProxyLogger.Load().Debug().Msgf("update hostname: %q, ip: %q, old: %q via mdns", name, ip, old)
m.name.Store(ip, name)
}
}
ip, name = "", ""
}
}
}
}
// probe performs mdns queries with known services.
func (m *mdns) probe(conns []*net.UDPConn, remoteAddr net.Addr) error {
msg := new(dns.Msg)
msg.Question = make([]dns.Question, len(services))
msg.Compress = true
for i, service := range services {
msg.Question[i] = dns.Question{
Name: dns.CanonicalName(service),
Qtype: dns.TypePTR,
Qclass: dns.ClassINET,
}
}
buf, err := msg.Pack()
if err != nil {
return err
}
for _, conn := range conns {
_ = conn.SetWriteDeadline(time.Now().Add(time.Second * 30))
if _, werr := conn.WriteTo(buf, remoteAddr); werr != nil {
err = werr
}
}
return err
}
// getDataFromAvahiDaemonCache reads entries from avahi-daemon cache to update mdns data.
func (m *mdns) getDataFromAvahiDaemonCache() {
if _, err := exec.LookPath("avahi-browse"); err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not find avahi-browse binary, skipping.")
return
}
// Run avahi-browse to discover services from cache:
// - "-a" -> all services.
// - "-r" -> resolve found services.
// - "-p" -> parseable format.
// - "-c" -> read from cache.
out, err := exec.Command("avahi-browse", "-a", "-r", "-p", "-c").Output()
if err != nil {
ctrld.ProxyLogger.Load().Debug().Err(err).Msg("could not browse services from avahi cache")
return
}
m.storeDataFromAvahiBrowseOutput(bytes.NewReader(out))
}
// storeDataFromAvahiBrowseOutput parses avahi-browse output from reader, then updating found data to mdns table.
func (m *mdns) storeDataFromAvahiBrowseOutput(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.FieldsFunc(scanner.Text(), func(r rune) bool {
return r == ';'
})
if len(fields) < 8 || fields[0] != "=" {
continue
}
ip := fields[7]
name := normalizeHostname(fields[6])
// Only using cache value if we don't have existed one.
if _, loaded := m.name.LoadOrStore(ip, name); !loaded {
ctrld.ProxyLogger.Load().Debug().Msgf("found hostname: %q, ip: %q via avahi cache", name, ip)
}
}
}
func multicastInterfaces() ([]net.Interface, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
interfaces := make([]net.Interface, 0, len(ifaces))
for _, ifi := range ifaces {
if (ifi.Flags & net.FlagUp) == 0 {
continue
}
if (ifi.Flags & net.FlagMulticast) > 0 {
interfaces = append(interfaces, ifi)
}
}
return interfaces, nil
}
func isErrNetUnreachableOrInvalid(err error) bool {
var se *os.SyscallError
if errors.As(err, &se) {
return se.Err == syscall.ENETUNREACH || se.Err == syscall.EINVAL
}
return false
}

View File

@@ -0,0 +1,70 @@
package clientinfo
var services = [...]string{
// From: https://jonathanmumm.com/tech-it/mdns-bonjour-bible-common-service-strings-for-various-vendors/
"_afpovertcp._tcp.local.",
"_airdroid._tcp.local.",
"_airdrop._tcp.local.",
"_airplay._tcp.local.",
"_airport._tcp.local.",
"_amzn-wplay._tcp.local.",
"_sub._apple-mobdev2._tcp.local.",
"_apple-mobdev2._tcp.local.",
"_apple-sasl._tcp.local.",
"_atc._tcp.local.",
"_sketchmirror._tcp.local.",
"_bp2p._tcp.local.",
"_Friendly._sub._bp2p._tcp.local.",
"_invoke._sub._bp2p._tcp.local.",
"_webdav._sub._bp2p._tcp.local.",
"_device-info._tcp.local.",
"_distcc._tcp.local.",
"_dpap._tcp.local.",
"_eppc._tcp.local.",
"_esdevice._tcp.local.",
"_esfileshare._tcp.local.",
"_ftp._tcp.local.",
"_googlecast._tcp.local.",
"_googlezone._tcp.local.",
"_hap._tcp.local.",
"_homekit._tcp.local.",
"_home-sharing._tcp.local.",
"_http._tcp.local.",
"_hudson._tcp.local.",
"_ica-networking._tcp.local.",
"_print._sub._ipp._tcp.local.",
"_cups._sub._ipps._tcp.local.",
"_print._sub._ipps._tcp.local.",
"_jenkins._tcp.local.",
"_KeynoteControl._tcp.local.",
"_keynotepair._tcp.local.",
"_mediaremotetv._tcp.local.",
"_nfs._tcp.local.",
"_nvstream._tcp.local.",
"_androidtvremote._tcp.local.",
"_omnistate._tcp.local.",
"_photoshopserver._tcp.local.",
"_printer._tcp.local.",
"_raop._tcp.local.",
"_readynas._tcp.local.",
"_rfb._tcp.local.",
"_physicalweb._tcp.local.",
"_rsp._tcp.local.",
"_scanner._tcp.local.",
"_sftp-ssh._tcp.local.",
"_sleep-proxy._udp.local.",
"_smb._tcp.local.",
"_spotify-connect._tcp.local.",
"_ssh._tcp.local.",
"_teamviewer._tcp.local.",
"_telnet._tcp.local.",
"_touch-able._tcp.local.",
"_tunnel._tcp.local.",
"_webdav._tcp.local.",
"_webdav._tcp.local.",
"_workstation._tcp.local.",
"_xserveraid._tcp.local.",
// Merlin
"_alexa._tcp",
}

View File

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

View File

@@ -0,0 +1,71 @@
package clientinfo
import (
"strings"
"sync"
"github.com/Control-D-Inc/ctrld/internal/router"
"github.com/Control-D-Inc/ctrld/internal/router/merlin"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/router/nvram"
)
const merlinNvramCustomClientListKey = "custom_clientlist"
type merlinDiscover struct {
hostname sync.Map // mac => hostname
}
func (m *merlinDiscover) refresh() error {
if router.Name() != merlin.Name {
return nil
}
out, err := nvram.Run("get", merlinNvramCustomClientListKey)
if err != nil {
return err
}
ctrld.ProxyLogger.Load().Debug().Msg("reading Merlin custom client list")
m.parseMerlinCustomClientList(out)
return nil
}
func (m *merlinDiscover) LookupHostnameByIP(ip string) string {
return ""
}
func (m *merlinDiscover) LookupHostnameByMac(mac string) string {
val, ok := m.hostname.Load(mac)
if !ok {
return ""
}
return val.(string)
}
// "nvram get custom_clientlist" output:
//
// <client 1>00:00:00:00:00:01>0>4>><client 2>00:00:00:00:00:02>0>24>>...
//
// So to parse it, do the following steps:
//
// - Split by "<" => entries
// - For each entry, split by ">" => parts
// - Empty parts => skip
// - Empty parts[0] => skip empty hostname
// - Empty parts[1] => skip empty MAC
func (m *merlinDiscover) parseMerlinCustomClientList(data string) {
entries := strings.Split(data, "<")
for _, entry := range entries {
parts := strings.SplitN(string(entry), ">", 3)
if len(parts) < 2 || len(parts[0]) == 0 || len(parts[1]) == 0 {
continue
}
hostname := normalizeHostname(parts[0])
mac := strings.ToLower(parts[1])
m.hostname.Store(mac, hostname)
}
}
func (m *merlinDiscover) String() string {
return "merlin"
}

View File

@@ -0,0 +1,82 @@
package clientinfo
import (
"testing"
)
func TestParseMerlinCustomClientList(t *testing.T) {
tests := []struct {
name string
clientList string
macList []string
hostnameList []string
macNotPresentList []string
}{
{
"normal",
"<client1>00:00:00:00:00:01>0>4>>",
[]string{"00:00:00:00:00:01"},
[]string{"client1"},
nil,
},
{
"multiple clients",
"<client1>00:00:00:00:00:01>0>4>><client2>00:00:00:00:00:02>0>24>>",
[]string{"00:00:00:00:00:01", "00:00:00:00:00:02"},
[]string{"client1", "client2"},
nil,
},
{
"empty hostname",
"<client1>00:00:00:00:00:01>0>4>><>00:00:00:00:00:02>0>24>>",
[]string{"00:00:00:00:00:01"},
[]string{"client1"},
[]string{"00:00:00:00:00:02"},
},
{
"empty dhcp",
"<client1>00:00:00:00:00:01>0>4>><client 1>>>",
[]string{"00:00:00:00:00:01"},
[]string{"client1"},
[]string{""},
},
{
"invalid",
"qwerty",
nil,
nil,
nil,
},
{
"empty",
"",
nil,
nil,
nil,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
m := &merlinDiscover{}
m.parseMerlinCustomClientList(tc.clientList)
for i, mac := range tc.macList {
val, ok := m.hostname.Load(mac)
if !ok {
t.Errorf("missing hostname: %s", mac)
}
hostname := val.(string)
if hostname != tc.hostnameList[i] {
t.Errorf("hostname mismatch, want: %q, got: %q", tc.hostnameList[i], hostname)
}
}
for _, mac := range tc.macNotPresentList {
if _, ok := m.hostname.Load(mac); ok {
t.Errorf("mac2name address %q should not be present", mac)
}
}
})
}
}

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

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

View File

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

View File

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

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