Compare commits

...

83 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

While at it, also doing the check DNS in separate goroutine, prevent it
from blocking ctrld from notifying others that it "started". The issue
was seen when ctrld is configured as direct listener, requests are
flooded before ctrld started, causing the healtch process failed.
2023-11-06 20:01:25 +07:00
Cuong Manh Le 58a00ea24a all: implement reload command
This commit adds reload command to ctrld for re-fetch new config from
ContorlD API or re-read the current config on disk.
2023-11-06 20:01:03 +07:00
Cuong Manh Le 712b23a4bb cmd/cli: initialize upstream proto for mobile 2023-11-06 20:00:28 +07:00
Cuong Manh Le baf836557c cmd/cli: fix wrong checking condition in removeProvTokenFromArgs
The provision token is only used once, then do not have any effect after
Control D uid is fetched. So making it appears in "ctrld run" command is
useless.
2023-11-06 20:00:10 +07:00
Cuong Manh Le 904b23eeac cmd/cli: add --proto flag to set upstream type in cd mode 2023-11-06 19:59:52 +07:00
Cuong Manh Le 6aafe445f5 cmd/cli: add nextdns mode
Adding --nextdns flag to "ctrld start" command for generating ctrld
config with nextdns resolver id, then use nextdns as an upstream.
2023-11-06 19:59:31 +07:00
Ginder Singh ebd516855b added safe return if error happens during resolver fetch. 2023-11-06 19:58:53 +07:00
Cuong Manh Le df4e04719e cmd/cli: relax service dependency on systemd-networkd-wait-online
ctrld wants systemd-networkd-wait-online starts before starting itself,
but ctrld should not be blocked waiting for it started.
2023-11-06 19:58:32 +07:00
Cuong Manh Le 2440d922c6 all: add MAC address base policy
While at it, also update the config doc to clarify the order of matching
preference, and the matter of rules order within each policy.
2023-11-06 19:57:50 +07:00
57 changed files with 3410 additions and 530 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ $ docker pull controldns/ctrld
Alternatively, if you know what you're doing you can download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section for the appropriate platform. 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 ## Build
Lastly, you can build `ctrld` from source which requires `go1.19+`: Lastly, you can build `ctrld` from source which requires `go1.20+`:
```shell ```shell
$ go build ./cmd/ctrld $ go build ./cmd/ctrld
+2
View File
@@ -9,6 +9,7 @@ type ClientInfo struct {
IP string IP string
Hostname string Hostname string
Self bool Self bool
ClientIDPref string
} }
// LeaseFileFormat specifies the format of DHCP lease file. // LeaseFileFormat specifies the format of DHCP lease file.
@@ -17,4 +18,5 @@ type LeaseFileFormat string
const ( const (
Dnsmasq LeaseFileFormat = "dnsmasq" Dnsmasq LeaseFileFormat = "dnsmasq"
IscDhcpd LeaseFileFormat = "isc-dhcpd" IscDhcpd LeaseFileFormat = "isc-dhcpd"
KeaDHCP4 LeaseFileFormat = "kea-dhcp4"
) )
+326 -75
View File
@@ -144,7 +144,9 @@ func initCLI() {
_ = runCmd.Flags().MarkHidden("homedir") _ = runCmd.Flags().MarkHidden("homedir")
runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
_ = runCmd.Flags().MarkHidden("iface") _ = runCmd.Flags().MarkHidden("iface")
runCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
rootCmd.AddCommand(runCmd) rootCmd.AddCommand(runCmd)
startCmd := &cobra.Command{ startCmd := &cobra.Command{
@@ -158,6 +160,7 @@ func initCLI() {
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
checkStrFlagEmpty(cmd, cdUidFlagName) checkStrFlagEmpty(cmd, cdUidFlagName)
checkStrFlagEmpty(cmd, cdOrgFlagName) checkStrFlagEmpty(cmd, cdOrgFlagName)
validateCdAndNextDNSFlags()
sc := &service.Config{} sc := &service.Config{}
*sc = *svcConfig *sc = *svcConfig
osArgs := os.Args[2:] osArgs := os.Args[2:]
@@ -176,6 +179,9 @@ func initCLI() {
// Pass --cd flag to "ctrld run" command, so the provision token takes no effect. // Pass --cd flag to "ctrld run" command, so the provision token takes no effect.
sc.Arguments = append(sc.Arguments, "--cd="+cdUID) sc.Arguments = append(sc.Arguments, "--cd="+cdUID)
} }
if cdUID != "" {
validateCdUpstreamProtocol()
}
p := &prog{ p := &prog{
router: router.New(&cfg, cdUID != ""), router: router.New(&cfg, cdUID != ""),
@@ -201,7 +207,11 @@ func initCLI() {
defaultConfigFile = filepath.Join(dir, defaultConfigFile) defaultConfigFile = filepath.Join(dir, defaultConfigFile)
} }
sc.Arguments = append(sc.Arguments, "--homedir="+dir) sc.Arguments = append(sc.Arguments, "--homedir="+dir)
sockPath := filepath.Join(dir, ctrldLogUnixSock) sockDir := dir
if d, err := socketDir(); err == nil {
sockDir = d
}
sockPath := filepath.Join(sockDir, ctrldLogUnixSock)
_ = os.Remove(sockPath) _ = os.Remove(sockPath)
go func() { go func() {
defer func() { defer func() {
@@ -223,7 +233,7 @@ func initCLI() {
}() }()
} }
tryReadingConfig(writeDefaultConfig) tryReadingConfigWithNotice(writeDefaultConfig, true)
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err)
@@ -231,6 +241,10 @@ func initCLI() {
initLogging() initLogging()
if nextdns != "" {
removeNextDNSFromArgs(sc)
}
// Explicitly passing config, so on system where home directory could not be obtained, // Explicitly passing config, so on system where home directory could not be obtained,
// or sub-process env is different with the parent, we still behave correctly and use // or sub-process env is different with the parent, we still behave correctly and use
// the expected config file. // the expected config file.
@@ -244,16 +258,20 @@ func initCLI() {
return return
} }
if router.Name() != "" { if router.Name() != "" && iface != "" {
mainLog.Load().Debug().Msg("cleaning up router before installing") mainLog.Load().Debug().Msg("cleaning up router before installing")
_ = p.router.Cleanup() _ = p.router.Cleanup()
} }
tasks := []task{ tasks := []task{
{s.Stop, false}, {s.Stop, false},
{func() error { return doGenerateNextDNSConfig(nextdns) }, true},
{s.Uninstall, false}, {s.Uninstall, false},
{s.Install, false}, {s.Install, false},
{s.Start, true}, {s.Start, true},
// Note that startCmd do not actually write ControlD config, but the config file was
// generated after s.Start, so we notice users here for consistent with nextdns mode.
{noticeWritingControlDConfig, false},
} }
mainLog.Load().Notice().Msg("Starting service") mainLog.Load().Notice().Msg("Starting service")
if doTasks(tasks) { if doTasks(tasks) {
@@ -281,7 +299,7 @@ func initCLI() {
} }
}, },
} }
// Keep these flags in sync with runCmd above, except for "-d". // Keep these flags in sync with runCmd above, except for "-d"/"--nextdns".
startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
startCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config") 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(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
@@ -295,6 +313,8 @@ func initCLI() {
startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
_ = startCmd.Flags().MarkHidden("dev") _ = startCmd.Flags().MarkHidden("dev")
startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
startCmd.Flags().StringVarP(&nextdns, nextdnsFlagName, "", "", "NextDNS resolver id")
startCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
routerCmd := &cobra.Command{ routerCmd := &cobra.Command{
Use: "setup", Use: "setup",
@@ -367,6 +387,10 @@ func initCLI() {
mainLog.Load().Error().Msg(err.Error()) mainLog.Load().Error().Msg(err.Error())
return return
} }
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("service not installed")
return
}
initLogging() initLogging()
tasks := []task{ tasks := []task{
@@ -374,7 +398,7 @@ func initCLI() {
{s.Start, true}, {s.Start, true},
} }
if doTasks(tasks) { if doTasks(tasks) {
dir, err := userHomeDir() dir, err := socketDir()
if err != nil { if err != nil {
mainLog.Load().Warn().Err(err).Msg("Service was restarted, but could not ping the control server") mainLog.Load().Warn().Err(err).Msg("Service was restarted, but could not ping the control server")
return return
@@ -388,6 +412,50 @@ func initCLI() {
}, },
} }
reloadCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
},
Use: "reload",
Short: "Reload the ctrld service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dir, err := socketDir()
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to find ctrld home dir")
}
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
resp, err := cc.post(reloadPath, nil)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to send reload signal to ctrld")
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
mainLog.Load().Notice().Msg("Service reloaded")
case http.StatusCreated:
s, err := newService(&prog{}, svcConfig)
if err != nil {
mainLog.Load().Error().Msg(err.Error())
return
}
mainLog.Load().Warn().Msg("Service was reloaded, but new config requires service restart.")
mainLog.Load().Warn().Msg("Restarting service")
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("Service not installed")
return
}
restartCmd.Run(cmd, args)
default:
buf, err := io.ReadAll(resp.Body)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("could not read response from control server")
}
mainLog.Load().Error().Err(err).Msgf("failed to reload ctrld: %s", string(buf))
}
},
}
statusCmd := &cobra.Command{ statusCmd := &cobra.Command{
Use: "status", Use: "status",
Short: "Show status of the ctrld service", Short: "Show status of the ctrld service",
@@ -503,9 +571,10 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
Short: "Manage ctrld service", Short: "Manage ctrld service",
Args: cobra.OnlyValidArgs, Args: cobra.OnlyValidArgs,
ValidArgs: []string{ ValidArgs: []string{
statusCmd.Use, startCmd.Use,
stopCmd.Use, stopCmd.Use,
restartCmd.Use, restartCmd.Use,
reloadCmd.Use,
statusCmd.Use, statusCmd.Use,
uninstallCmd.Use, uninstallCmd.Use,
interfacesCmd.Use, interfacesCmd.Use,
@@ -514,6 +583,7 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
serviceCmd.AddCommand(startCmd) serviceCmd.AddCommand(startCmd)
serviceCmd.AddCommand(stopCmd) serviceCmd.AddCommand(stopCmd)
serviceCmd.AddCommand(restartCmd) serviceCmd.AddCommand(restartCmd)
serviceCmd.AddCommand(reloadCmd)
serviceCmd.AddCommand(statusCmd) serviceCmd.AddCommand(statusCmd)
serviceCmd.AddCommand(uninstallCmd) serviceCmd.AddCommand(uninstallCmd)
serviceCmd.AddCommand(interfacesCmd) serviceCmd.AddCommand(interfacesCmd)
@@ -568,6 +638,19 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
} }
rootCmd.AddCommand(restartCmdAlias) rootCmd.AddCommand(restartCmdAlias)
reloadCmdAlias := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
initConsoleLogging()
checkHasElevatedPrivilege()
},
Use: "reload",
Short: "Reload the ctrld service",
Run: func(cmd *cobra.Command, args []string) {
reloadCmd.Run(cmd, args)
},
}
rootCmd.AddCommand(reloadCmdAlias)
statusCmdAlias := &cobra.Command{ statusCmdAlias := &cobra.Command{
Use: "status", Use: "status",
Short: "Show status of the ctrld service", Short: "Show status of the ctrld service",
@@ -610,7 +693,7 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
checkHasElevatedPrivilege() checkHasElevatedPrivilege()
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
dir, err := userHomeDir() dir, err := socketDir()
if err != nil { if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to find ctrld home dir") mainLog.Load().Fatal().Err(err).Msg("failed to find ctrld home dir")
} }
@@ -636,6 +719,10 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
sort.Strings(s) sort.Strings(s)
return s return s
} }
// If metrics is enabled, server set this for all clients, so we can check only the first one.
// Ideally, we may have a field in response to indicate that query count should be shown, but
// it would break earlier version of ctrld, which only look list of clients in response.
withQueryCount := len(clients) > 0 && clients[0].IncludeQueryCount
data := make([][]string, len(clients)) data := make([][]string, len(clients))
for i, c := range clients { for i, c := range clients {
row := []string{ row := []string{
@@ -644,10 +731,17 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
c.Mac, c.Mac,
strings.Join(map2Slice(c.Source), ","), strings.Join(map2Slice(c.Source), ","),
} }
if withQueryCount {
row = append(row, strconv.FormatInt(c.QueryCount, 10))
}
data[i] = row data[i] = row
} }
table := tablewriter.NewWriter(os.Stdout) table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"IP", "Hostname", "Mac", "Discovered"}) headers := []string{"IP", "Hostname", "Mac", "Discovered"}
if withQueryCount {
headers = append(headers, "Queries")
}
table.SetHeader(headers)
table.SetAutoFormatHeaders(false) table.SetAutoFormatHeaders(false)
table.AppendBulk(data) table.AppendBulk(data)
table.Render() table.Render()
@@ -670,6 +764,11 @@ func isMobile() bool {
return runtime.GOOS == "android" || runtime.GOOS == "ios" return runtime.GOOS == "android" || runtime.GOOS == "ios"
} }
// isAndroid reports whether the current OS is Android.
func isAndroid() bool {
return runtime.GOOS == "android"
}
// RunCobraCommand runs ctrld cli. // RunCobraCommand runs ctrld cli.
func RunCobraCommand(cmd *cobra.Command) { func RunCobraCommand(cmd *cobra.Command) {
noConfigStart = isNoConfigStart(cmd) noConfigStart = isNoConfigStart(cmd)
@@ -688,6 +787,7 @@ func RunMobile(appConfig *AppConfig, appCallback *AppCallback, stopCh chan struc
homedir = appConfig.HomeDir homedir = appConfig.HomeDir
verbose = appConfig.Verbose verbose = appConfig.Verbose
cdUID = appConfig.CdUID cdUID = appConfig.CdUID
cdUpstreamProto = appConfig.UpstreamProto
logPath = appConfig.LogPath logPath = appConfig.LogPath
run(appCallback, stopCh) run(appCallback, stopCh)
} }
@@ -701,6 +801,8 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
p := &prog{ p := &prog{
waitCh: waitCh, waitCh: waitCh,
stopCh: stopCh, stopCh: stopCh,
reloadCh: make(chan struct{}),
reloadDoneCh: make(chan struct{}),
cfg: &cfg, cfg: &cfg,
appCallback: appCallback, appCallback: appCallback,
} }
@@ -709,7 +811,11 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
homedir = dir homedir = dir
} }
} }
sockPath := filepath.Join(homedir, ctrldLogUnixSock) sockDir := homedir
if d, err := socketDir(); err == nil {
sockDir = d
}
sockPath := filepath.Join(sockDir, ctrldLogUnixSock)
if addr, err := net.ResolveUnixAddr("unix", sockPath); err == nil { if addr, err := net.ResolveUnixAddr("unix", sockPath); err == nil {
if conn, err := net.Dial(addr.Network(), addr.String()); err == nil { if conn, err := net.Dial(addr.Network(), addr.String()); err == nil {
lc := &logConn{conn: conn} lc := &logConn{conn: conn}
@@ -740,9 +846,11 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
readBase64Config(configBase64) readBase64Config(configBase64)
processNoConfigFlags(noConfigStart) processNoConfigFlags(noConfigStart)
p.mu.Lock()
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err)
} }
p.mu.Unlock()
processLogAndCacheFlags() processLogAndCacheFlags()
@@ -759,7 +867,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
} }
p.router = router.New(&cfg, cdUID != "") p.router = router.New(&cfg, cdUID != "")
cs, err := newControlServer(filepath.Join(homedir, ctrldControlUnixSock)) cs, err := newControlServer(filepath.Join(sockDir, ctrldControlUnixSock))
if err != nil { if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not create control server") mainLog.Load().Warn().Err(err).Msg("could not create control server")
} }
@@ -777,14 +885,47 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
cdUID = uid cdUID = uid
} }
if cdUID != "" { if cdUID != "" {
err := processCDFlags() validateCdUpstreamProtocol()
if err != nil { if err := processCDFlags(&cfg); err != nil {
if isMobile() {
appCallback.Exit(err.Error()) appCallback.Exit(err.Error())
return return
} }
uninstallIfInvalidCdUID := func() {
cdLogger := mainLog.Load().With().Str("mode", "cd").Logger()
if uer, ok := err.(*controld.UtilityErrorResponse); ok && uer.ErrorField.Code == controld.InvalidConfigCode {
s, err := newService(&prog{}, svcConfig)
if err != nil {
cdLogger.Warn().Err(err).Msg("failed to create new service")
return
}
if netIface, _ := netInterface(iface); netIface != nil {
if err := restoreNetworkManager(); err != nil {
cdLogger.Error().Err(err).Msg("could not restore NetworkManager")
return
}
cdLogger.Debug().Str("iface", netIface.Name).Msg("Restoring DNS for interface")
if err := resetDNS(netIface); err != nil {
cdLogger.Warn().Err(err).Msg("something went wrong while restoring DNS")
} else {
cdLogger.Debug().Str("iface", netIface.Name).Msg("Restoring DNS successfully")
}
} }
updated := updateListenerConfig() tasks := []task{{s.Uninstall, true}}
if doTasks(tasks) {
cdLogger.Info().Msg("uninstalled service")
}
cdLogger.Fatal().Err(uer).Msg("failed to fetch resolver config")
return
}
}
uninstallIfInvalidCdUID()
}
}
updated := updateListenerConfig(&cfg)
if cdUID != "" { if cdUID != "" {
processLogAndCacheFlags() processLogAndCacheFlags()
@@ -812,7 +953,9 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
initLoggingWithBackup(false) initLoggingWithBackup(false)
} }
validateConfig(&cfg) if err := validateConfig(&cfg); err != nil {
os.Exit(1)
}
initCache() initCache()
if daemon { if daemon {
@@ -859,6 +1002,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
if cp := router.CertPool(); cp != nil { if cp := router.CertPool(); cp != nil {
rootCertPool = cp rootCertPool = cp
} }
if iface != "" {
p.onStarted = append(p.onStarted, func() { p.onStarted = append(p.onStarted, func() {
mainLog.Load().Debug().Msg("router setup on start") mainLog.Load().Debug().Msg("router setup on start")
if err := p.router.Setup(); err != nil { if err := p.router.Setup(); err != nil {
@@ -873,6 +1017,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
p.resetDNS() p.resetDNS()
}) })
} }
}
close(waitCh) close(waitCh)
<-stopCh <-stopCh
@@ -907,10 +1052,17 @@ func writeConfigFile() error {
return nil return nil
} }
func readConfigFile(writeDefaultConfig bool) bool { // readConfigFile reads in config file.
//
// - It writes default config file if config file not found if writeDefaultConfig is true.
// - It emits notice message to user if notice is true.
func readConfigFile(writeDefaultConfig, notice bool) bool {
// If err == nil, there's a config supplied via `--config`, no default config written. // If err == nil, there's a config supplied via `--config`, no default config written.
err := v.ReadInConfig() err := v.ReadInConfig()
if err == nil { if err == nil {
if notice {
mainLog.Load().Notice().Msg("Reading config: " + v.ConfigFileUsed())
}
mainLog.Load().Info().Msg("loading config file from: " + v.ConfigFileUsed()) mainLog.Load().Info().Msg("loading config file from: " + v.ConfigFileUsed())
defaultConfigFile = v.ConfigFileUsed() defaultConfigFile = v.ConfigFileUsed()
return true return true
@@ -925,7 +1077,8 @@ func readConfigFile(writeDefaultConfig bool) bool {
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
mainLog.Load().Fatal().Msgf("failed to unmarshal default config: %v", err) mainLog.Load().Fatal().Msgf("failed to unmarshal default config: %v", err)
} }
_ = updateListenerConfig() nop := zerolog.Nop()
_, _ = tryUpdateListenerConfig(&cfg, &nop, true)
if err := writeConfigFile(); err != nil { if err := writeConfigFile(); err != nil {
mainLog.Load().Fatal().Msgf("failed to write default config file: %v", err) mainLog.Load().Fatal().Msgf("failed to write default config file: %v", err)
} else { } else {
@@ -933,6 +1086,9 @@ func readConfigFile(writeDefaultConfig bool) bool {
if err != nil { if err != nil {
mainLog.Load().Fatal().Msgf("failed to get default config file path: %v", err) mainLog.Load().Fatal().Msgf("failed to get default config file path: %v", err)
} }
if cdUID == "" && nextdns == "" {
mainLog.Load().Notice().Msg("Generating controld default config: " + fp)
}
mainLog.Load().Info().Msg("writing default config file to: " + fp) mainLog.Load().Info().Msg("writing default config file to: " + fp)
} }
return false return false
@@ -1015,7 +1171,7 @@ func processNoConfigFlags(noConfigStart bool) {
v.Set("upstream", upstream) v.Set("upstream", upstream)
} }
func processCDFlags() error { func processCDFlags(cfg *ctrld.Config) error {
logger := mainLog.Load().With().Str("mode", "cd").Logger() logger := mainLog.Load().With().Str("mode", "cd").Logger()
logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID) logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID)
bo := backoff.NewBackoff("processCDFlags", logf, 30*time.Second) bo := backoff.NewBackoff("processCDFlags", logf, 30*time.Second)
@@ -1031,44 +1187,17 @@ func processCDFlags() error {
} }
break break
} }
if uer, ok := err.(*controld.UtilityErrorResponse); ok && uer.ErrorField.Code == controld.InvalidConfigCode {
s, err := newService(&prog{}, svcConfig)
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("failed to create new service")
return nil
}
if netIface, _ := netInterface(iface); netIface != nil {
if err := restoreNetworkManager(); err != nil {
logger.Error().Err(err).Msg("could not restore NetworkManager")
return nil
}
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")
}
event := logger.Fatal()
if isMobile() { if isMobile() {
event = logger.Warn() return err
} }
event.Err(uer).Msg("failed to fetch resolver config")
return uer
}
if err != nil {
logger.Warn().Err(err).Msg("could not fetch resolver config") logger.Warn().Err(err).Msg("could not fetch resolver config")
return nil return err
} }
logger.Info().Msg("generating ctrld config from Control-D configuration") logger.Info().Msg("generating ctrld config from Control-D configuration")
cfg = ctrld.Config{}
*cfg = ctrld.Config{}
// Fetch config, unmarshal to cfg. // Fetch config, unmarshal to cfg.
if resolverConfig.Ctrld.CustomConfig != "" { if resolverConfig.Ctrld.CustomConfig != "" {
logger.Info().Msg("using defined custom config of Control-D resolver") logger.Info().Msg("using defined custom config of Control-D resolver")
@@ -1085,7 +1214,7 @@ func processCDFlags() error {
cfg.Upstream = make(map[string]*ctrld.UpstreamConfig) cfg.Upstream = make(map[string]*ctrld.UpstreamConfig)
cfg.Upstream["0"] = &ctrld.UpstreamConfig{ cfg.Upstream["0"] = &ctrld.UpstreamConfig{
Endpoint: resolverConfig.DOH, Endpoint: resolverConfig.DOH,
Type: ctrld.ResolverTypeDOH, Type: cdUpstreamProto,
Timeout: 5000, Timeout: 5000,
} }
rules := make([]ctrld.Rule, 0, len(resolverConfig.Exclude)) rules := make([]ctrld.Rule, 0, len(resolverConfig.Exclude))
@@ -1191,7 +1320,7 @@ func selfCheckStatus(s service.Service) service.Status {
if status != service.StatusRunning { if status != service.StatusRunning {
return status return status
} }
dir, err := userHomeDir() dir, err := socketDir()
if err != nil { if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to check ctrld listener status: could not get home directory") mainLog.Load().Error().Err(err).Msg("failed to check ctrld listener status: could not get home directory")
return service.StatusUnknown return service.StatusUnknown
@@ -1213,6 +1342,11 @@ func selfCheckStatus(s service.Service) service.Status {
return service.StatusUnknown return service.StatusUnknown
} }
// Not a ctrld upstream, return status as-is.
if cfg.FirstUpstream().VerifyDomain() == "" {
return status
}
mainLog.Load().Debug().Msg("ctrld listener is ready") mainLog.Load().Debug().Msg("ctrld listener is ready")
mainLog.Load().Debug().Msg("performing self-check") mainLog.Load().Debug().Msg("performing self-check")
bo := backoff.NewBackoff("self-check", logf, 10*time.Second) bo := backoff.NewBackoff("self-check", logf, 10*time.Second)
@@ -1338,21 +1472,48 @@ func userHomeDir() (string, error) {
return dir, nil return dir, nil
} }
// socketDir returns directory that ctrld will create socket file for running controlServer.
func socketDir() (string, error) {
switch {
case runtime.GOOS == "windows", isMobile():
return userHomeDir()
}
dir := "/var/run"
if ok, _ := dirWritable(dir); !ok {
return userHomeDir()
}
return dir, nil
}
// tryReadingConfig is like tryReadingConfigWithNotice, with notice set to false.
func tryReadingConfig(writeDefaultConfig bool) { func tryReadingConfig(writeDefaultConfig bool) {
tryReadingConfigWithNotice(writeDefaultConfig, false)
}
// tryReadingConfigWithNotice tries reading in config files, either specified by user or from default
// locations. If notice is true, emitting a notice message to user which config file was read.
func tryReadingConfigWithNotice(writeDefaultConfig, notice bool) {
// --config is specified. // --config is specified.
if configPath != "" { if configPath != "" {
v.SetConfigFile(configPath) v.SetConfigFile(configPath)
readConfigFile(false) readConfigFile(false, notice)
return return
} }
// no config start or base64 config mode. // no config start or base64 config mode.
if !writeDefaultConfig { if !writeDefaultConfig {
return return
} }
readConfig(writeDefaultConfig) readConfigWithNotice(writeDefaultConfig, notice)
} }
// readConfig calls readConfigWithNotice with notice set to false.
func readConfig(writeDefaultConfig bool) { func readConfig(writeDefaultConfig bool) {
readConfigWithNotice(writeDefaultConfig, false)
}
// readConfigWithNotice calls readConfigFile with config file set to ctrld.toml
// or config.toml for compatible with earlier versions of ctrld.
func readConfigWithNotice(writeDefaultConfig, notice bool) {
configs := []struct { configs := []struct {
name string name string
written bool written bool
@@ -1369,7 +1530,7 @@ func readConfig(writeDefaultConfig bool) {
for _, config := range configs { for _, config := range configs {
ctrld.SetConfigNameWithPath(v, config.name, dir) ctrld.SetConfigNameWithPath(v, config.name, dir)
v.SetConfigFile(configPath) v.SetConfigFile(configPath)
if readConfigFile(config.written) { if readConfigFile(config.written, notice) {
break break
} }
} }
@@ -1401,18 +1562,17 @@ func uninstall(p *prog, s service.Service) {
} }
} }
func validateConfig(cfg *ctrld.Config) { func validateConfig(cfg *ctrld.Config) error {
err := ctrld.ValidateConfig(validator.New(), cfg) if err := ctrld.ValidateConfig(validator.New(), cfg); err != nil {
if err == nil {
return
}
var ve validator.ValidationErrors var ve validator.ValidationErrors
if errors.As(err, &ve) { if errors.As(err, &ve) {
for _, fe := range ve { for _, fe := range ve {
mainLog.Load().Error().Msgf("invalid config: %s: %s", fe.Namespace(), fieldErrorMsg(fe)) mainLog.Load().Error().Msgf("invalid config: %s: %s", fe.Namespace(), fieldErrorMsg(fe))
} }
} }
os.Exit(1) return err
}
return nil
} }
// NOTE: Add more case here once new validation tag is used in ctrld.Config struct. // NOTE: Add more case here once new validation tag is used in ctrld.Config struct.
@@ -1474,18 +1634,36 @@ type listenerConfigCheck struct {
// mobileListenerPort returns hardcoded port for mobile platforms. // mobileListenerPort returns hardcoded port for mobile platforms.
func mobileListenerPort() int { func mobileListenerPort() int {
if runtime.GOOS == "ios" { if isAndroid() {
return 53
}
return 5354 return 5354
}
return 53
}
// mobileListenerIp returns hardcoded listener ip for mobile platforms
func mobileListenerIp() string {
if isAndroid() {
return "0.0.0.0"
}
return "127.0.0.1"
} }
// updateListenerConfig updates the config for listeners if not defined, // updateListenerConfig updates the config for listeners if not defined,
// or defined but invalid to be used, e.g: using loopback address other // or defined but invalid to be used, e.g: using loopback address other
// than 127.0.0.1 with systemd-resolved. // than 127.0.0.1 with systemd-resolved.
func updateListenerConfig() (updated bool) { func updateListenerConfig(cfg *ctrld.Config) bool {
updated, _ := tryUpdateListenerConfig(cfg, nil, true)
return updated
}
// tryUpdateListenerConfig tries updating listener config with a working one.
// If fatal is true, and there's listen address conflicted, the function do
// fatal error.
func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, fatal bool) (updated, ok bool) {
ok = true
lcc := make(map[string]*listenerConfigCheck) lcc := make(map[string]*listenerConfigCheck)
cdMode := cdUID != "" cdMode := cdUID != ""
nextdnsMode := nextdns != ""
for n, listener := range cfg.Listener { for n, listener := range cfg.Listener {
lcc[n] = &listenerConfigCheck{} lcc[n] = &listenerConfigCheck{}
if listener.IP == "" { if listener.IP == "" {
@@ -1497,12 +1675,17 @@ func updateListenerConfig() (updated bool) {
lcc[n].Port = true lcc[n].Port = true
} }
// In cd mode, we always try to pick an ip:port pair to work. // In cd mode, we always try to pick an ip:port pair to work.
if cdMode { // Same if nextdns resolver is used.
if cdMode || nextdnsMode {
lcc[n].IP = true lcc[n].IP = true
lcc[n].Port = true lcc[n].Port = true
} }
updated = updated || lcc[n].IP || lcc[n].Port updated = updated || lcc[n].IP || lcc[n].Port
} }
il := mainLog.Load()
if infoLogger != nil {
il = infoLogger
}
if isMobile() { if isMobile() {
// On Mobile, only use first listener, ignore others. // On Mobile, only use first listener, ignore others.
firstLn := cfg.FirstListener() firstLn := cfg.FirstListener()
@@ -1511,9 +1694,8 @@ func updateListenerConfig() (updated bool) {
delete(cfg.Listener, k) delete(cfg.Listener, k)
} }
} }
// In cd mode, always use 127.0.0.1:5354.
if cdMode { if cdMode {
firstLn.IP = "127.0.0.1" // Mobile platforms allows running listener only on loop back address. firstLn.IP = mobileListenerIp()
firstLn.Port = mobileListenerPort() firstLn.Port = mobileListenerPort()
// TODO: use clear(lcc) once upgrading to go 1.21 // TODO: use clear(lcc) once upgrading to go 1.21
for k := range lcc { for k := range lcc {
@@ -1594,8 +1776,12 @@ func updateListenerConfig() (updated bool) {
break break
} }
if !check.IP && !check.Port { if !check.IP && !check.Port {
if fatal {
logMsg(mainLog.Load().Fatal(), n, "failed to listen: %v", err) logMsg(mainLog.Load().Fatal(), n, "failed to listen: %v", err)
} }
ok = false
break
}
if tryAllPort53 { if tryAllPort53 {
tryAllPort53 = false tryAllPort53 = false
if check.IP { if check.IP {
@@ -1605,7 +1791,7 @@ func updateListenerConfig() (updated bool) {
listener.Port = 53 listener.Port = 53
} }
if check.IP { if check.IP {
logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, trying: %s", addr, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port))) logMsg(il.Info(), n, "could not listen on address: %s, trying: %s", addr, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)))
} }
continue continue
} }
@@ -1618,7 +1804,7 @@ func updateListenerConfig() (updated bool) {
listener.Port = 53 listener.Port = 53
} }
if check.IP { if check.IP {
logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, trying localhost: %s", addr, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port))) logMsg(il.Info(), n, "could not listen on address: %s, trying localhost: %s", addr, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)))
} }
continue continue
} }
@@ -1630,7 +1816,7 @@ func updateListenerConfig() (updated bool) {
if check.Port { if check.Port {
listener.Port = 5354 listener.Port = 5354
} }
logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, trying current ip with port 5354", addr) logMsg(il.Info(), n, "could not listen on address: %s, trying current ip with port 5354", addr)
continue continue
} }
if tryPort5354 { if tryPort5354 {
@@ -1641,7 +1827,7 @@ func updateListenerConfig() (updated bool) {
if check.Port { if check.Port {
listener.Port = 5354 listener.Port = 5354
} }
logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, trying 0.0.0.0:5354", addr) logMsg(il.Info(), n, "could not listen on address: %s, trying 0.0.0.0:5354", addr)
continue continue
} }
if check.IP && !isZeroIP { // for "0.0.0.0" or "::", we only need to try new port. if check.IP && !isZeroIP { // for "0.0.0.0" or "::", we only need to try new port.
@@ -1655,12 +1841,19 @@ func updateListenerConfig() (updated bool) {
listener.Port = oldPort listener.Port = oldPort
} }
if listener.IP == oldIP && listener.Port == oldPort { if listener.IP == oldIP && listener.Port == oldPort {
if fatal {
logMsg(mainLog.Load().Fatal(), n, "could not listener on %s: %v", net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)), err) logMsg(mainLog.Load().Fatal(), n, "could not listener on %s: %v", net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)), err)
} }
logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, pick a random ip+port", addr) ok = false
break
}
logMsg(il.Info(), n, "could not listen on address: %s, pick a random ip+port", addr)
attempts++ attempts++
} }
} }
if !ok {
return
}
// Specific case for systemd-resolved. // Specific case for systemd-resolved.
if useSystemdResolved { if useSystemdResolved {
@@ -1670,7 +1863,7 @@ func updateListenerConfig() (updated bool) {
// ip address, other than "127.0.0.1", so trying to listen on default route interface // ip address, other than "127.0.0.1", so trying to listen on default route interface
// address instead. // address instead.
if ip := net.ParseIP(listener.IP); ip != nil && ip.IsLoopback() && ip.String() != "127.0.0.1" { if ip := net.ParseIP(listener.IP); ip != nil && ip.IsLoopback() && ip.String() != "127.0.0.1" {
logMsg(mainLog.Load().Warn(), n, "using loopback interface do not work with systemd-resolved") logMsg(il.Info(), n, "using loopback interface do not work with systemd-resolved")
found := false found := false
if netIface, _ := net.InterfaceByName(defaultIfaceName()); netIface != nil { if netIface, _ := net.InterfaceByName(defaultIfaceName()); netIface != nil {
addrs, _ := netIface.Addrs() addrs, _ := netIface.Addrs()
@@ -1680,7 +1873,7 @@ func updateListenerConfig() (updated bool) {
if err := tryListen(addr); err == nil { if err := tryListen(addr); err == nil {
found = true found = true
listener.IP = netIP.IP.String() listener.IP = netIP.IP.String()
logMsg(mainLog.Load().Warn(), n, "use %s as listener address", listener.IP) logMsg(il.Info(), n, "use %s as listener address", listener.IP)
break break
} }
} }
@@ -1742,12 +1935,12 @@ func removeProvTokenFromArgs(sc *service.Config) {
continue continue
} }
// For "--cd-org XXX", skip it and mark next arg skipped. // For "--cd-org XXX", skip it and mark next arg skipped.
if x == cdOrgFlagName { if x == "--"+cdOrgFlagName {
skip = true skip = true
continue continue
} }
// For "--cd-org=XXX", just skip it. // For "--cd-org=XXX", just skip it.
if strings.HasPrefix(x, cdOrgFlagName+"=") { if strings.HasPrefix(x, "--"+cdOrgFlagName+"=") {
continue continue
} }
a = append(a, x) a = append(a, x)
@@ -1795,6 +1988,64 @@ func checkStrFlagEmpty(cmd *cobra.Command, flagName string) {
return return
} }
if fl.Value.String() == "" { if fl.Value.String() == "" {
mainLog.Load().Fatal().Msgf(`flag "--%s"" value must be non-empty`, fl.Name) mainLog.Load().Fatal().Msgf(`flag "--%s" value must be non-empty`, fl.Name)
} }
} }
func validateCdUpstreamProtocol() {
if cdUID == "" {
return
}
switch cdUpstreamProto {
case ctrld.ResolverTypeDOH, ctrld.ResolverTypeDOH3:
default:
mainLog.Load().Fatal().Msg(`flag "--protocol" must be "doh" or "doh3"`)
}
}
func validateCdAndNextDNSFlags() {
if (cdUID != "" || cdOrg != "") && nextdns != "" {
mainLog.Load().Fatal().Msgf("--%s/--%s could not be used with --%s", cdUidFlagName, cdOrgFlagName, nextdnsFlagName)
}
}
// removeNextDNSFromArgs removes the --nextdns from command line arguments.
func removeNextDNSFromArgs(sc *service.Config) {
a := sc.Arguments[:0]
skip := false
for _, x := range sc.Arguments {
if skip {
skip = false
continue
}
// For "--nextdns XXX", skip it and mark next arg skipped.
if x == "--"+nextdnsFlagName {
skip = true
continue
}
// For "--nextdns=XXX", just skip it.
if strings.HasPrefix(x, "--"+nextdnsFlagName+"=") {
continue
}
a = append(a, x)
}
sc.Arguments = a
}
// doGenerateNextDNSConfig generates a working config with nextdns resolver.
func doGenerateNextDNSConfig(uid string) error {
if uid == "" {
return nil
}
mainLog.Load().Notice().Msgf("Generating nextdns config: %s", defaultConfigFile)
generateNextDNSConfig(uid)
updateListenerConfig(&cfg)
return writeConfigFile()
}
func noticeWritingControlDConfig() error {
if cdUID != "" {
mainLog.Load().Notice().Msgf("Generating controld config: %s", defaultConfigFile)
}
return nil
}
+71
View File
@@ -6,14 +6,20 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"reflect"
"sort" "sort"
"time" "time"
dto "github.com/prometheus/client_model/go"
"github.com/Control-D-Inc/ctrld"
) )
const ( const (
contentTypeJson = "application/json" contentTypeJson = "application/json"
listClientsPath = "/clients" listClientsPath = "/clients"
startedPath = "/started" startedPath = "/started"
reloadPath = "/reload"
) )
type controlServer struct { type controlServer struct {
@@ -62,6 +68,25 @@ func (p *prog) registerControlServerHandler() {
sort.Slice(clients, func(i, j int) bool { sort.Slice(clients, func(i, j int) bool {
return clients[i].IP.Less(clients[j].IP) 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 { if err := json.NewEncoder(w).Encode(&clients); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -75,6 +100,52 @@ func (p *prog) registerControlServerHandler() {
w.WriteHeader(http.StatusRequestTimeout) 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 { func jsonResponse(next http.Handler) http.Handler {
+401 -57
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
@@ -17,6 +18,7 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"tailscale.com/net/interfaces" "tailscale.com/net/interfaces"
"tailscale.com/net/netaddr" "tailscale.com/net/netaddr"
"tailscale.com/net/tsaddr"
"github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache" "github.com/Control-D-Inc/ctrld/internal/dnscache"
@@ -25,6 +27,7 @@ import (
const ( const (
staleTTL = 60 * time.Second staleTTL = 60 * time.Second
localTTL = 3600 * time.Second
// EDNS0_OPTION_MAC is dnsmasq EDNS0 code for adding mac option. // 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 // 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. // This is also dns.EDNS0LOCALSTART, but define our own constant here for clarification.
@@ -37,6 +40,38 @@ var osUpstreamConfig = &ctrld.UpstreamConfig{
Timeout: 2000, 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 { func (p *prog) serveDNS(listenerNum string) error {
listenerConfig := p.cfg.Listener[listenerNum] listenerConfig := p.cfg.Listener[listenerNum]
// make sure ip is allocated // make sure ip is allocated
@@ -44,36 +79,81 @@ func (p *prog) serveDNS(listenerNum string) error {
mainLog.Load().Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip") mainLog.Load().Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip")
return allocErr return allocErr
} }
var failoverRcodes []int
if listenerConfig.Policy != nil {
failoverRcodes = listenerConfig.Policy.FailoverRcodeNumbers
}
handler := dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) { handler := dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) {
p.sema.acquire() p.sema.acquire()
defer p.sema.release() 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) go p.detectLoop(m)
q := m.Question[0] q := m.Question[0]
domain := canonicalName(q.Name) domain := canonicalName(q.Name)
reqId := requestID()
remoteIP, _, _ := net.SplitHostPort(w.RemoteAddr().String()) remoteIP, _, _ := net.SplitHostPort(w.RemoteAddr().String())
ci := p.getClientInfo(remoteIP, m) ci := p.getClientInfo(remoteIP, m)
ci.ClientIDPref = p.cfg.Service.ClientIDPref
stripClientSubnet(m)
remoteAddr := spoofRemoteAddr(w.RemoteAddr(), ci) remoteAddr := spoofRemoteAddr(w.RemoteAddr(), ci)
fmtSrcToDest := fmtRemoteToLocal(listenerNum, remoteAddr.String(), w.LocalAddr().String()) fmtSrcToDest := fmtRemoteToLocal(listenerNum, ci.Hostname, remoteAddr.String())
t := time.Now() t := time.Now()
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId) ctrld.Log(ctx, mainLog.Load().Info(), "QUERY: %s: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain)
ctrld.Log(ctx, mainLog.Load().Debug(), "%s received query: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain) ur := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, ci.Mac, domain)
upstreams, matched := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, 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 var answer *dns.Msg
if !matched && listenerConfig.Restricted { if !ur.matched && listenerConfig.Restricted {
ctrld.Log(ctx, mainLog.Load().Info(), "query refused, %s does not match any network policy", remoteAddr.String())
answer = new(dns.Msg) answer = new(dns.Msg)
answer.SetRcode(m, dns.RcodeRefused) answer.SetRcode(m, dns.RcodeRefused)
labelValues = append(labelValues, "") // no upstream
} else { } else {
answer = p.proxy(ctx, upstreams, failoverRcodes, m, ci) var failoverRcode []int
if listenerConfig.Policy != nil {
failoverRcode = listenerConfig.Policy.FailoverRcodeNumbers
}
pr := p.proxy(ctx, &proxyRequest{
msg: m,
ci: ci,
failoverRcodes: failoverRcode,
ufr: ur,
})
answer = pr.answer
rtt := time.Since(t) rtt := time.Since(t)
ctrld.Log(ctx, mainLog.Load().Debug(), "received response of %d bytes in %s", answer.Len(), rtt) 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 { if err := w.WriteMsg(answer); err != nil {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveUDP: failed to send DNS response to client") ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveDNS: failed to send DNS response to client")
} }
}) })
@@ -99,7 +179,7 @@ func (p *prog) serveDNS(listenerNum string) error {
// addresses of the machine. So ctrld could receive queries from LAN clients. // addresses of the machine. So ctrld could receive queries from LAN clients.
if needRFC1918Listeners(listenerConfig) { if needRFC1918Listeners(listenerConfig) {
g.Go(func() error { g.Go(func() error {
for _, addr := range rfc1918Addresses() { for _, addr := range ctrld.Rfc1918Addresses() {
func() { func() {
listenAddr := net.JoinHostPort(addr, strconv.Itoa(listenerConfig.Port)) listenAddr := net.JoinHostPort(addr, strconv.Itoa(listenerConfig.Port))
s, errCh := runDNSServer(listenAddr, proto, handler) s, errCh := runDNSServer(listenAddr, proto, handler)
@@ -146,27 +226,24 @@ func (p *prog) serveDNS(listenerNum string) error {
// Though domain policy has higher priority than network policy, it is still // Though domain policy has higher priority than network policy, it is still
// processed later, because policy logging want to know whether a network rule // processed later, because policy logging want to know whether a network rule
// is disregarded in favor of the domain level rule. // is disregarded in favor of the domain level rule.
func (p *prog) upstreamFor(ctx context.Context, defaultUpstreamNum string, lc *ctrld.ListenerConfig, addr net.Addr, domain string) ([]string, bool) { func (p *prog) upstreamFor(ctx context.Context, defaultUpstreamNum string, lc *ctrld.ListenerConfig, addr net.Addr, srcMac, domain string) (res *upstreamForResult) {
upstreams := []string{upstreamPrefix + defaultUpstreamNum} upstreams := []string{upstreamPrefix + defaultUpstreamNum}
matchedPolicy := "no policy" matchedPolicy := "no policy"
matchedNetwork := "no network" matchedNetwork := "no network"
matchedRule := "no rule" matchedRule := "no rule"
matched := false matched := false
res = &upstreamForResult{srcAddr: addr.String()}
defer func() { defer func() {
if !matched && lc.Restricted { res.upstreams = upstreams
ctrld.Log(ctx, mainLog.Load().Info(), "query refused, %s does not match any network policy", addr.String()) res.matched = matched
return res.matchedPolicy = matchedPolicy
} res.matchedNetwork = matchedNetwork
if matched { res.matchedRule = matchedRule
ctrld.Log(ctx, mainLog.Load().Info(), "%s, %s, %s -> %v", matchedPolicy, matchedNetwork, matchedRule, upstreams)
} else {
ctrld.Log(ctx, mainLog.Load().Info(), "no explicit policy matched, using default routing -> %v", upstreams)
}
}() }()
if lc.Policy == nil { if lc.Policy == nil {
return upstreams, false return
} }
do := func(policyUpstreams []string) { do := func(policyUpstreams []string) {
@@ -202,6 +279,19 @@ 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 { for _, rule := range lc.Policy.Rules {
// There's only one entry per rule, config validation ensures this. // There's only one entry per rule, config validation ensures this.
for source, targets := range rule { for source, targets := range rule {
@@ -213,7 +303,7 @@ networkRules:
matchedRule = source matchedRule = source
do(targets) do(targets)
matched = true matched = true
return upstreams, matched return
} }
} }
} }
@@ -222,31 +312,147 @@ networkRules:
do(networkTargets) do(networkTargets)
} }
return upstreams, matched return
} }
func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []int, msg *dns.Msg, ci *ctrld.ClientInfo) *dns.Msg { func (p *prog) proxyPrivatePtrLookup(ctx context.Context, msg *dns.Msg) *dns.Msg {
cDomainName := msg.Question[0].Name
locked := p.ptrLoopGuard.TryLock(cDomainName)
defer p.ptrLoopGuard.Unlock(cDomainName)
if !locked {
return nil
}
ip := ipFromARPA(cDomainName)
if name := p.ciTable.LookupHostname(ip.String(), ""); name != "" {
answer := new(dns.Msg)
answer.SetReply(msg)
answer.Compress = true
answer.Answer = []dns.RR{&dns.PTR{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
},
Ptr: dns.Fqdn(name),
}}
ctrld.Log(ctx, mainLog.Load().Info(), "private PTR lookup, using client info table")
ctrld.Log(ctx, mainLog.Load().Debug(), "client info: %v", ctrld.ClientInfo{
Mac: p.ciTable.LookupMac(ip.String()),
IP: ip.String(),
Hostname: name,
})
return answer
}
return nil
}
func (p *prog) proxyLanHostnameQuery(ctx context.Context, msg *dns.Msg) *dns.Msg {
q := msg.Question[0]
hostname := strings.TrimSuffix(q.Name, ".")
locked := p.lanLoopGuard.TryLock(hostname)
defer p.lanLoopGuard.Unlock(hostname)
if !locked {
return nil
}
if ip := p.ciTable.LookupIPByHostname(hostname, q.Qtype == dns.TypeAAAA); ip != nil {
answer := new(dns.Msg)
answer.SetReply(msg)
answer.Compress = true
switch {
case ip.Is4():
answer.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: uint32(localTTL.Seconds()),
},
A: ip.AsSlice(),
}}
case ip.Is6():
answer.Answer = []dns.RR{&dns.AAAA{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: uint32(localTTL.Seconds()),
},
AAAA: ip.AsSlice(),
}}
}
ctrld.Log(ctx, mainLog.Load().Info(), "lan hostname lookup, using client info table")
ctrld.Log(ctx, mainLog.Load().Debug(), "client info: %v", ctrld.ClientInfo{
Mac: p.ciTable.LookupMac(ip.String()),
IP: ip.String(),
Hostname: hostname,
})
return answer
}
return nil
}
func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
var staleAnswer *dns.Msg var staleAnswer *dns.Msg
upstreams := req.ufr.upstreams
serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale
upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams) upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams)
if len(upstreamConfigs) == 0 { if len(upstreamConfigs) == 0 {
upstreamConfigs = []*ctrld.UpstreamConfig{osUpstreamConfig} upstreamConfigs = []*ctrld.UpstreamConfig{osUpstreamConfig}
upstreams = []string{upstreamOS} 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 // Inverse query should not be cached: https://www.rfc-editor.org/rfc/rfc1035#section-7.4
if p.cache != nil && msg.Question[0].Qtype != dns.TypePTR { if p.cache != nil && req.msg.Question[0].Qtype != dns.TypePTR {
for _, upstream := range upstreams { for _, upstream := range upstreams {
cachedValue := p.cache.Get(dnscache.NewKey(msg, upstream)) cachedValue := p.cache.Get(dnscache.NewKey(req.msg, upstream))
if cachedValue == nil { if cachedValue == nil {
continue continue
} }
answer := cachedValue.Msg.Copy() answer := cachedValue.Msg.Copy()
answer.SetRcode(msg, answer.Rcode) answer.SetRcode(req.msg, answer.Rcode)
now := time.Now() now := time.Now()
if cachedValue.Expire.After(now) { if cachedValue.Expire.After(now) {
ctrld.Log(ctx, mainLog.Load().Debug(), "hit cached response") ctrld.Log(ctx, mainLog.Load().Debug(), "hit cached response")
setCachedAnswerTTL(answer, now, cachedValue.Expire) setCachedAnswerTTL(answer, now, cachedValue.Expire)
return answer res.answer = answer
res.cached = true
return res
} }
staleAnswer = answer staleAnswer = answer
} }
@@ -268,9 +474,9 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
return dnsResolver.Resolve(resolveCtx, msg) return dnsResolver.Resolve(resolveCtx, msg)
} }
resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg { resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg {
if upstreamConfig.UpstreamSendClientInfo() && ci != nil { if upstreamConfig.UpstreamSendClientInfo() && req.ci != nil {
ctrld.Log(ctx, mainLog.Load().Debug(), "including client info with the request") ctrld.Log(ctx, mainLog.Load().Debug(), "including client info with the request")
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci) ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, req.ci)
} }
answer, err := resolve1(n, upstreamConfig, msg) answer, err := resolve1(n, upstreamConfig, msg)
if err != nil { if err != nil {
@@ -281,6 +487,11 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
go p.um.checkUpstream(upstreams[n], upstreamConfig) 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 nil
} }
return answer return answer
@@ -297,17 +508,25 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
ctrld.Log(ctx, mainLog.Load().Warn(), "%s is down", upstreams[n]) ctrld.Log(ctx, mainLog.Load().Warn(), "%s is down", upstreams[n])
continue continue
} }
answer := resolve(n, upstreamConfig, msg) answer := resolve(n, upstreamConfig, req.msg)
if answer == nil { if answer == nil {
if serveStaleCache && staleAnswer != nil { if serveStaleCache && staleAnswer != nil {
ctrld.Log(ctx, mainLog.Load().Debug(), "serving stale cached response") ctrld.Log(ctx, mainLog.Load().Debug(), "serving stale cached response")
now := time.Now() now := time.Now()
setCachedAnswerTTL(staleAnswer, now, now.Add(staleTTL)) setCachedAnswerTTL(staleAnswer, now, now.Add(staleTTL))
return staleAnswer res.answer = staleAnswer
res.cached = true
return res
} }
continue continue
} }
if answer.Rcode != dns.RcodeSuccess && len(upstreamConfigs) > 1 && containRcode(failoverRcodes, answer.Rcode) { // We are doing LAN/PTR lookup using private resolver, so always process next one.
// Except for the last, we want to send response instead of saying all upstream failed.
if answer.Rcode != dns.RcodeSuccess && isLanOrPtrQuery && n != len(upstreamConfigs)-1 {
ctrld.Log(ctx, mainLog.Load().Debug(), "no response from %s, process to next upstream", upstreams[n])
continue
}
if answer.Rcode != dns.RcodeSuccess && len(upstreamConfigs) > 1 && containRcode(req.failoverRcodes, answer.Rcode) {
ctrld.Log(ctx, mainLog.Load().Debug(), "failover rcode matched, process to next upstream") ctrld.Log(ctx, mainLog.Load().Debug(), "failover rcode matched, process to next upstream")
continue continue
} }
@@ -315,7 +534,7 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
// set compression, as it is not set by default when unpacking // set compression, as it is not set by default when unpacking
answer.Compress = true answer.Compress = true
if p.cache != nil { if p.cache != nil && req.msg.Question[0].Qtype != dns.TypePTR {
ttl := ttlFromMsg(answer) ttl := ttlFromMsg(answer)
now := time.Now() now := time.Now()
expired := now.Add(time.Duration(ttl) * time.Second) expired := now.Add(time.Duration(ttl) * time.Second)
@@ -323,15 +542,33 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i
expired = now.Add(time.Duration(cachedTTL) * time.Second) expired = now.Add(time.Duration(cachedTTL) * time.Second)
} }
setCachedAnswerTTL(answer, now, expired) setCachedAnswerTTL(answer, now, expired)
p.cache.Add(dnscache.NewKey(msg, upstreams[n]), dnscache.NewValue(answer, expired)) p.cache.Add(dnscache.NewKey(req.msg, upstreams[n]), dnscache.NewValue(answer, expired))
ctrld.Log(ctx, mainLog.Load().Debug(), "add cached response") ctrld.Log(ctx, mainLog.Load().Debug(), "add cached response")
} }
return answer hostname := ""
if req.ci != nil {
hostname = req.ci.Hostname
}
ctrld.Log(ctx, mainLog.Load().Info(), "REPLY: %s -> %s (%s): %s", upstreams[n], req.ufr.srcAddr, hostname, dns.RcodeToString[answer.Rcode])
res.answer = answer
res.upstream = upstreamConfig.Endpoint
return res
} }
ctrld.Log(ctx, mainLog.Load().Error(), "all %v endpoints failed", upstreams) ctrld.Log(ctx, mainLog.Load().Error(), "all %v endpoints failed", upstreams)
answer := new(dns.Msg) answer := new(dns.Msg)
answer.SetRcode(msg, dns.RcodeServerFailure) answer.SetRcode(req.msg, dns.RcodeServerFailure)
return answer 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 { func (p *prog) upstreamConfigsFromUpstreamNumbers(upstreams []string) []*ctrld.UpstreamConfig {
@@ -377,8 +614,8 @@ func wildcardMatches(wildcard, domain string) bool {
return false return false
} }
func fmtRemoteToLocal(listenerNum, remote, local string) string { func fmtRemoteToLocal(listenerNum, hostname, remote string) string {
return fmt.Sprintf("%s -> listener.%s: %s:", remote, listenerNum, local) return fmt.Sprintf("%s (%s) -> listener.%s", remote, hostname, listenerNum)
} }
func requestID() string { func requestID() string {
@@ -454,6 +691,23 @@ func ipAndMacFromMsg(msg *dns.Msg) (string, string) {
return ip, mac 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 { func spoofRemoteAddr(addr net.Addr, ci *ctrld.ClientInfo) net.Addr {
if ci != nil && ci.IP != "" { if ci != nil && ci.IP != "" {
switch addr := addr.(type) { switch addr := addr.(type) {
@@ -531,23 +785,44 @@ func (p *prog) getClientInfo(remoteIP string, msg *dns.Msg) *ctrld.ClientInfo {
} }
// If MAC is still empty here, that mean the requests are made from virtual interface, // If MAC is still empty here, that mean the requests are made from virtual interface,
// like VPN/Wireguard clients, so we use whatever MAC address associated with remoteIP // like VPN/Wireguard clients, so we use ci.IP as hostname to distinguish those clients.
// (most likely 127.0.0.1), and ci.IP as hostname, so we can distinguish those clients.
if ci.Mac == "" { if ci.Mac == "" {
ci.Mac = p.ciTable.LookupMac(remoteIP)
if hostname := p.ciTable.LookupHostname(ci.IP, ""); hostname != "" { if hostname := p.ciTable.LookupHostname(ci.IP, ""); hostname != "" {
ci.Hostname = hostname ci.Hostname = hostname
} else { } 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 ci.Hostname = ci.IP
p.ciTable.StoreVPNClient(ci) p.ciTable.StoreVPNClient(ci)
} }
}
} else { } else {
ci.Hostname = p.ciTable.LookupHostname(ci.IP, ci.Mac) ci.Hostname = p.ciTable.LookupHostname(ci.IP, ci.Mac)
} }
ci.Self = queryFromSelf(ci.IP) ci.Self = queryFromSelf(ci.IP)
p.spoofLoopbackIpInClientInfo(ci)
return 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. // queryFromSelf reports whether the input IP is from device running ctrld.
func queryFromSelf(ip string) bool { func queryFromSelf(ip string) bool {
netIP := netip.MustParseAddr(ip) netIP := netip.MustParseAddr(ip)
@@ -578,17 +853,86 @@ func needRFC1918Listeners(lc *ctrld.ListenerConfig) bool {
return lc.IP == "127.0.0.1" && lc.Port == 53 return lc.IP == "127.0.0.1" && lc.Port == 53
} }
func rfc1918Addresses() []string { // ipFromARPA parses a FQDN arpa domain and return the IP address if valid.
var res []string func ipFromARPA(arpa string) net.IP {
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) { if arpa, ok := strings.CutSuffix(arpa, ".in-addr.arpa."); ok {
addrs, _ := i.Addrs() if ptrIP := net.ParseIP(arpa); ptrIP != nil {
for _, addr := range addrs { return net.IP{ptrIP[15], ptrIP[14], ptrIP[13], ptrIP[12]}
ipNet, ok := addr.(*net.IPNet)
if !ok || !ipNet.IP.IsPrivate() {
continue
} }
res = append(res, ipNet.IP.String())
} }
}) if arpa, ok := strings.CutSuffix(arpa, ".ip6.arpa."); ok {
return res 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)
} }
+211 -14
View File
@@ -67,8 +67,11 @@ func Test_canonicalName(t *testing.T) {
func Test_prog_upstreamFor(t *testing.T) { func Test_prog_upstreamFor(t *testing.T) {
cfg := testhelper.SampleConfig(t) cfg := testhelper.SampleConfig(t)
prog := &prog{cfg: cfg} p := &prog{cfg: cfg}
for _, nc := range prog.cfg.Network { p.um = newUpstreamMonitor(p.cfg)
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
for _, nc := range p.cfg.Network {
for _, cidr := range nc.Cidrs { for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr) _, ipNet, err := net.ParseCIDR(cidr)
if err != nil { if err != nil {
@@ -81,6 +84,7 @@ func Test_prog_upstreamFor(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
ip string ip string
mac string
defaultUpstreamNum string defaultUpstreamNum string
lc *ctrld.ListenerConfig lc *ctrld.ListenerConfig
domain string domain string
@@ -88,11 +92,14 @@ func Test_prog_upstreamFor(t *testing.T) {
matched bool matched bool
testLogMsg string testLogMsg string
}{ }{
{"Policy map matches", "192.168.0.1:0", "0", prog.cfg.Listener["0"], "abc.xyz", []string{"upstream.1", "upstream.0"}, true, ""}, {"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", prog.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, 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", prog.cfg.Listener["0"], "abc.xyz", []string{"upstream.0"}, 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", prog.cfg.Listener["1"], "abc.ru", []string{"upstream.1"}, false, ""}, {"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", prog.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, "My Policy, network.1 (unenforced), *.ru -> [upstream.1]"}, {"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 { for _, tc := range tests {
@@ -111,9 +118,13 @@ func Test_prog_upstreamFor(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, addr) require.NotNil(t, addr)
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID()) ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
upstreams, matched := prog.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.domain) ufr := p.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.mac, tc.domain)
assert.Equal(t, tc.matched, matched) p.proxy(ctx, &proxyRequest{
assert.Equal(t, tc.upstreams, upstreams) msg: newDnsMsgWithHostname("foo", dns.TypeA),
ufr: ufr,
})
assert.Equal(t, tc.matched, ufr.matched)
assert.Equal(t, tc.upstreams, ufr.upstreams)
if tc.testLogMsg != "" { if tc.testLogMsg != "" {
assert.Contains(t, logOutput.String(), tc.testLogMsg) assert.Contains(t, logOutput.String(), tc.testLogMsg)
} }
@@ -149,11 +160,35 @@ func TestCache(t *testing.T) {
answer2.SetRcode(msg, dns.RcodeRefused) answer2.SetRcode(msg, dns.RcodeRefused)
prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute))) prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute)))
got1 := prog.proxy(context.Background(), []string{"upstream.1"}, nil, msg, nil) req1 := &proxyRequest{
got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg, nil) 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.NotSame(t, got1, got2)
assert.Equal(t, answer1.Rcode, got1.Rcode) assert.Equal(t, answer1.Rcode, got1.answer.Rcode)
assert.Equal(t, answer2.Rcode, got2.Rcode) assert.Equal(t, answer2.Rcode, got2.answer.Rcode)
} }
func Test_ipAndMacFromMsg(t *testing.T) { func Test_ipAndMacFromMsg(t *testing.T) {
@@ -234,3 +269,165 @@ func Test_remoteAddrFromMsg(t *testing.T) {
}) })
} }
} }
func Test_ipFromARPA(t *testing.T) {
tests := []struct {
IP string
ARPA string
}{
{"1.2.3.4", "4.3.2.1.in-addr.arpa."},
{"245.110.36.114", "114.36.110.245.in-addr.arpa."},
{"::ffff:12.34.56.78", "78.56.34.12.in-addr.arpa."},
{"::1", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa."},
{"1::", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.ip6.arpa."},
{"1234:567::89a:bcde", "e.d.c.b.a.9.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.7.6.5.0.4.3.2.1.ip6.arpa."},
{"1234:567:fefe:bcbc:adad:9e4a:89a:bcde", "e.d.c.b.a.9.8.0.a.4.e.9.d.a.d.a.c.b.c.b.e.f.e.f.7.6.5.0.4.3.2.1.ip6.arpa."},
{"", "asd.in-addr.arpa."},
{"", "asd.ip6.arpa."},
}
for _, tc := range tests {
tc := tc
t.Run(tc.IP, func(t *testing.T) {
t.Parallel()
if got := ipFromARPA(tc.ARPA); !got.Equal(net.ParseIP(tc.IP)) {
t.Errorf("unexpected ip, want: %s, got: %s", tc.IP, got)
}
})
}
}
func newDnsMsgWithClientIP(ip string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeA)
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
o.Option = append(o.Option, &dns.EDNS0_SUBNET{Address: net.ParseIP(ip)})
m.Extra = append(m.Extra, o)
return m
}
func Test_stripClientSubnet(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
wantSubnet bool
}{
{"no edns0", new(dns.Msg), false},
{"loopback IP v4", newDnsMsgWithClientIP("127.0.0.1"), false},
{"loopback IP v6", newDnsMsgWithClientIP("::1"), false},
{"private IP v4", newDnsMsgWithClientIP("192.168.1.123"), false},
{"private IP v6", newDnsMsgWithClientIP("fd12:3456:789a:1::1"), false},
{"public IP", newDnsMsgWithClientIP("1.1.1.1"), true},
{"invalid IP", newDnsMsgWithClientIP(""), true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stripClientSubnet(tc.msg)
hasSubnet := false
if opt := tc.msg.IsEdns0(); opt != nil {
for _, s := range opt.Option {
if _, ok := s.(*dns.EDNS0_SUBNET); ok {
hasSubnet = true
}
}
}
if tc.wantSubnet != hasSubnet {
t.Errorf("unexpected result, want: %v, got: %v", tc.wantSubnet, hasSubnet)
}
})
}
}
func newDnsMsgWithHostname(hostname string, typ uint16) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(hostname, typ)
return m
}
func Test_isLanHostnameQuery(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isLanHostnameQuery bool
}{
{"A", newDnsMsgWithHostname("foo", dns.TypeA), true},
{"AAAA", newDnsMsgWithHostname("foo", dns.TypeAAAA), true},
{"A not LAN", newDnsMsgWithHostname("example.com", dns.TypeA), false},
{"AAAA not LAN", newDnsMsgWithHostname("example.com", dns.TypeAAAA), false},
{"Not A or AAAA", newDnsMsgWithHostname("foo", dns.TypeTXT), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isLanHostnameQuery(tc.msg); tc.isLanHostnameQuery != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isLanHostnameQuery, got)
}
})
}
}
func newDnsMsgPtr(ip string, t *testing.T) *dns.Msg {
t.Helper()
m := new(dns.Msg)
ptr, err := dns.ReverseAddr(ip)
if err != nil {
t.Fatal(err)
}
m.SetQuestion(ptr, dns.TypePTR)
return m
}
func Test_isPrivatePtrLookup(t *testing.T) {
tests := []struct {
name string
msg *dns.Msg
isPrivatePtrLookup bool
}{
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
{"10.0.0.0/8", newDnsMsgPtr("10.0.0.123", t), true},
{"172.16.0.0/12", newDnsMsgPtr("172.16.0.123", t), true},
{"192.168.0.0/16", newDnsMsgPtr("192.168.1.123", t), true},
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", t), true},
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isPrivatePtrLookup(tc.msg); tc.isPrivatePtrLookup != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isPrivatePtrLookup, got)
}
})
}
}
func Test_isWanClient(t *testing.T) {
tests := []struct {
name string
addr net.Addr
isWanClient bool
}{
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
{"10.0.0.0/8", &net.UDPAddr{IP: net.ParseIP("10.0.0.123")}, false},
{"172.16.0.0/12", &net.UDPAddr{IP: net.ParseIP("172.16.0.123")}, false},
{"192.168.0.0/16", &net.UDPAddr{IP: net.ParseIP("192.168.1.123")}, false},
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, false},
{"Link Local Unicast", &net.UDPAddr{IP: net.ParseIP("fe80::69f6:e16e:8bdb:433f")}, false},
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isWanClient(tc.addr); tc.isWanClient != got {
t.Errorf("unexpected result, want: %v, got: %v", tc.isWanClient, got)
}
})
}
}
+1
View File
@@ -13,6 +13,7 @@ type AppCallback struct {
type AppConfig struct { type AppConfig struct {
CdUID string CdUID string
HomeDir string HomeDir string
UpstreamProto string
Verbose int Verbose int
LogPath string LogPath string
} }
+43 -2
View File
@@ -3,6 +3,7 @@ package cli
import ( import (
"context" "context"
"strings" "strings"
"sync"
"time" "time"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -15,6 +16,36 @@ const (
loopTestQtype = dns.TypeTXT 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. // isLoop reports whether the given upstream config is detected as having DNS loop.
func (p *prog) isLoop(uc *ctrld.UpstreamConfig) bool { func (p *prog) isLoop(uc *ctrld.UpstreamConfig) bool {
p.loopMu.Lock() p.loopMu.Lock()
@@ -56,7 +87,15 @@ func (p *prog) checkDnsLoop() {
mainLog.Load().Debug().Msg("start checking DNS loop") mainLog.Load().Debug().Msg("start checking DNS loop")
upstream := make(map[string]*ctrld.UpstreamConfig) upstream := make(map[string]*ctrld.UpstreamConfig)
p.loopMu.Lock() p.loopMu.Lock()
for _, uc := range p.cfg.Upstream { for n, uc := range p.cfg.Upstream {
if p.um.isDown("upstream." + n) {
continue
}
// Do not send test query to external upstream.
if !canBeLocalUpstream(uc.Domain) {
mainLog.Load().Debug().Msgf("skipping external: upstream.%s", n)
continue
}
uid := uc.UID() uid := uc.UID()
p.loop[uid] = false p.loop[uid] = false
upstream[uid] = uc upstream[uid] = uc
@@ -79,13 +118,15 @@ func (p *prog) checkDnsLoop() {
} }
// checkDnsLoopTicker performs p.checkDnsLoop every minute. // checkDnsLoopTicker performs p.checkDnsLoop every minute.
func (p *prog) checkDnsLoopTicker() { func (p *prog) checkDnsLoopTicker(ctx context.Context) {
timer := time.NewTicker(time.Minute) timer := time.NewTicker(time.Minute)
defer timer.Stop() defer timer.Stop()
for { for {
select { select {
case <-p.stopCh: case <-p.stopCh:
return return
case <-ctx.Done():
return
case <-timer.C: case <-timer.C:
p.checkDnsLoop() p.checkDnsLoop()
} }
+42
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)
}
}
+5 -1
View File
@@ -32,6 +32,8 @@ var (
cdDev bool cdDev bool
iface string iface string
ifaceStartStop string ifaceStartStop string
nextdns string
cdUpstreamProto string
mainLog atomic.Pointer[zerolog.Logger] mainLog atomic.Pointer[zerolog.Logger]
consoleWriter zerolog.ConsoleWriter consoleWriter zerolog.ConsoleWriter
@@ -41,6 +43,7 @@ var (
const ( const (
cdUidFlagName = "cd" cdUidFlagName = "cd"
cdOrgFlagName = "cd-org" cdOrgFlagName = "cd-org"
nextdnsFlagName = "nextdns"
) )
func init() { func init() {
@@ -93,6 +96,7 @@ func initConsoleLogging() {
// initLogging initializes global logging setup. // initLogging initializes global logging setup.
func initLogging() { func initLogging() {
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
initLoggingWithBackup(true) initLoggingWithBackup(true)
} }
@@ -131,7 +135,7 @@ func initLoggingWithBackup(doBackup bool) {
} }
writers = append(writers, consoleWriter) writers = append(writers, consoleWriter)
multi := zerolog.MultiLevelWriter(writers...) multi := zerolog.MultiLevelWriter(writers...)
l := mainLog.Load().Output(multi).With().Timestamp().Logger() l := mainLog.Load().Output(multi).With().Logger()
mainLog.Store(&l) mainLog.Store(&l)
// TODO: find a better way. // TODO: find a better way.
ctrld.ProxyLogger.Store(&l) ctrld.ProxyLogger.Store(&l)
+150
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
}
}
+9 -2
View File
@@ -1,11 +1,13 @@
package cli package cli
import ( import (
"context"
"github.com/vishvananda/netlink" "github.com/vishvananda/netlink"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
func (p *prog) watchLinkState() { func (p *prog) watchLinkState(ctx context.Context) {
ch := make(chan netlink.LinkUpdate) ch := make(chan netlink.LinkUpdate)
done := make(chan struct{}) done := make(chan struct{})
defer close(done) defer close(done)
@@ -13,7 +15,11 @@ func (p *prog) watchLinkState() {
mainLog.Load().Warn().Err(err).Msg("could not subscribe link") mainLog.Load().Warn().Err(err).Msg("could not subscribe link")
return return
} }
for lu := range ch { for {
select {
case <-ctx.Done():
return
case lu := <-ch:
if lu.Change == 0xFFFFFFFF { if lu.Change == 0xFFFFFFFF {
continue continue
} }
@@ -24,4 +30,5 @@ func (p *prog) watchLinkState() {
} }
} }
} }
}
} }
+3 -1
View File
@@ -2,4 +2,6 @@
package cli package cli
func (p *prog) watchLinkState() {} import "context"
func (p *prog) watchLinkState(ctx context.Context) {}
+31
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,
},
},
}
}
+67 -1
View File
@@ -9,10 +9,12 @@ import (
"net" "net"
"net/netip" "net/netip"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"time" "time"
"github.com/fsnotify/fsnotify"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4" "github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6" "github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/client6" "github.com/insomniacslk/dhcp/dhcpv6/client6"
@@ -23,7 +25,10 @@ import (
"github.com/Control-D-Inc/ctrld/internal/resolvconffile" "github.com/Control-D-Inc/ctrld/internal/resolvconffile"
) )
const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system" const (
resolvConfPath = "/etc/resolv.conf"
resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
)
// allocate loopback ip // allocate loopback ip
// sudo ip a add 127.0.0.2/24 dev lo // sudo ip a add 127.0.0.2/24 dev lo
@@ -64,6 +69,11 @@ func setDNS(iface *net.Interface, nameservers []string) error {
Nameservers: ns, Nameservers: ns,
SearchDomains: []dnsname.FQDN{}, SearchDomains: []dnsname.FQDN{},
} }
defer func() {
if r.Mode() == "direct" {
go watchResolveConf(osConfig)
}
}()
trySystemdResolve := false trySystemdResolve := false
for i := 0; i < maxSetDNSAttempts; i++ { for i := 0; i < maxSetDNSAttempts; i++ {
@@ -299,3 +309,59 @@ func sliceIndex[S ~[]E, E comparable](s S, v E) int {
} }
return -1 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")
}
}
}
+221 -34
View File
@@ -2,6 +2,7 @@ package cli
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
@@ -16,7 +17,9 @@ import (
"syscall" "syscall"
"github.com/kardianos/service" "github.com/kardianos/service"
"github.com/spf13/viper"
"tailscale.com/net/interfaces" "tailscale.com/net/interfaces"
"tailscale.com/net/tsaddr"
"github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/clientinfo" "github.com/Control-D-Inc/ctrld/internal/clientinfo"
@@ -30,6 +33,7 @@ const (
ctrldControlUnixSock = "ctrld_control.sock" ctrldControlUnixSock = "ctrld_control.sock"
upstreamPrefix = "upstream." upstreamPrefix = "upstream."
upstreamOS = upstreamPrefix + "os" upstreamOS = upstreamPrefix + "os"
upstreamPrivate = upstreamPrefix + "private"
) )
var logf = func(format string, args ...any) { var logf = func(format string, args ...any) {
@@ -48,16 +52,22 @@ type prog struct {
mu sync.Mutex mu sync.Mutex
waitCh chan struct{} waitCh chan struct{}
stopCh chan struct{} stopCh chan struct{}
reloadCh chan struct{} // For Windows.
reloadDoneCh chan struct{}
logConn net.Conn logConn net.Conn
cs *controlServer cs *controlServer
cfg *ctrld.Config cfg *ctrld.Config
localUpstreams []string
ptrNameservers []string
appCallback *AppCallback appCallback *AppCallback
cache dnscache.Cacher cache dnscache.Cacher
sema semaphore sema semaphore
ciTable *clientinfo.Table ciTable *clientinfo.Table
um *upstreamMonitor um *upstreamMonitor
router router.Router router router.Router
ptrLoopGuard *loopGuard
lanLoopGuard *loopGuard
loopMu sync.Mutex loopMu sync.Mutex
loop map[string]bool loop map[string]bool
@@ -69,11 +79,106 @@ type prog struct {
} }
func (p *prog) Start(s service.Service) error { func (p *prog) Start(s service.Service) error {
p.cfg = &cfg go p.runWait()
go p.run()
return nil 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() { func (p *prog) preRun() {
if !service.Interactive() { if !service.Interactive() {
p.setDNS() p.setDNS()
@@ -87,14 +192,54 @@ func (p *prog) preRun() {
} }
} }
func (p *prog) run() { 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. // Wait the caller to signal that we can do our logic.
<-p.waitCh <-p.waitCh
if !reload {
p.preRun() p.preRun()
}
numListeners := len(p.cfg.Listener) numListeners := len(p.cfg.Listener)
if !reload {
p.started = make(chan struct{}, numListeners) p.started = make(chan struct{}, numListeners)
}
p.onStartedDone = make(chan struct{}) p.onStartedDone = make(chan struct{})
p.loop = make(map[string]bool) p.loop = make(map[string]bool)
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
if p.cfg.Service.CacheEnable { if p.cfg.Service.CacheEnable {
cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize) cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize)
if err != nil { if err != nil {
@@ -103,15 +248,7 @@ func (p *prog) run() {
p.cache = cacher p.cache = cacher
} }
} }
p.sema = &chanSemaphore{ready: make(chan struct{}, defaultSemaphoreCap)}
if mcr := p.cfg.Service.MaxConcurrentRequests; mcr != nil {
n := *mcr
if n == 0 {
p.sema = &noopSemaphore{}
} else {
p.sema = &chanSemaphore{ready: make(chan struct{}, n)}
}
}
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(len(p.cfg.Listener)) wg.Add(len(p.cfg.Listener))
@@ -127,38 +264,45 @@ func (p *prog) run() {
} }
p.um = newUpstreamMonitor(p.cfg) p.um = newUpstreamMonitor(p.cfg)
for n := range p.cfg.Upstream {
uc := p.cfg.Upstream[n]
uc.Init()
if uc.BootstrapIP == "" {
uc.SetupBootstrapIP()
mainLog.Load().Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
} else {
mainLog.Load().Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("using bootstrap IP for upstream.%s", n)
}
uc.SetCertPool(rootCertPool)
go uc.Ping()
}
p.ciTable = clientinfo.NewTable(&cfg, defaultRouteIP(), cdUID) 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 != "" { if leaseFile := p.cfg.Service.DHCPLeaseFile; leaseFile != "" {
mainLog.Load().Debug().Msgf("watching custom lease file: %s", leaseFile) mainLog.Load().Debug().Msgf("watching custom lease file: %s", leaseFile)
format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat) format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat)
p.ciTable.AddLeaseFile(leaseFile, format) 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. // Newer versions of android and iOS denies permission which breaks connectivity.
if !isMobile() { if !isMobile() && !reload {
wg.Add(1)
go func() { go func() {
defer wg.Done()
p.ciTable.Init() p.ciTable.Init()
p.ciTable.RefreshLoop(p.stopCh) p.ciTable.RefreshLoop(ctx)
}() }()
go p.watchLinkState() go p.watchLinkState(ctx)
} }
for listenerNum := range p.cfg.Listener { for listenerNum := range p.cfg.Listener {
p.cfg.Listener[listenerNum].Init() p.cfg.Listener[listenerNum].Init()
if !reload {
go func(listenerNum string) { go func(listenerNum string) {
defer wg.Done()
listenerConfig := p.cfg.Listener[listenerNum] listenerConfig := p.cfg.Listener[listenerNum]
upstreamConfig := p.cfg.Upstream[listenerNum] upstreamConfig := p.cfg.Upstream[listenerNum]
if upstreamConfig == nil { if upstreamConfig == nil {
@@ -171,20 +315,47 @@ func (p *prog) run() {
} }
}(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++ { for i := 0; i < numListeners; i++ {
<-p.started <-p.started
} }
for _, f := range p.onStarted { for _, f := range p.onStarted {
f() f()
} }
// Check for possible DNS loop. }
p.checkDnsLoop()
close(p.onStartedDone) close(p.onStartedDone)
wg.Add(1)
go func() {
defer wg.Done()
// Check for possible DNS loop.
p.checkDnsLoop()
// Start check DNS loop ticker. // Start check DNS loop ticker.
go p.checkDnsLoopTicker() 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. // Stop writing log to unix socket.
consoleWriter.Out = os.Stdout consoleWriter.Out = os.Stdout
initLoggingWithBackup(false) initLoggingWithBackup(false)
@@ -197,9 +368,15 @@ func (p *prog) run() {
mainLog.Load().Warn().Err(err).Msg("could not start control server") mainLog.Load().Warn().Err(err).Msg("could not start control server")
} }
} }
}
wg.Wait() 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 { func (p *prog) Stop(s service.Service) error {
mainLog.Load().Info().Msg("Service stopped") mainLog.Load().Info().Msg("Service stopped")
close(p.stopCh) close(p.stopCh)
@@ -276,7 +453,7 @@ func (p *prog) setDNS() {
nameservers := []string{ns} nameservers := []string{ns}
if needRFC1918Listeners(lc) { if needRFC1918Listeners(lc) {
nameservers = append(nameservers, rfc1918Addresses()...) nameservers = append(nameservers, ctrld.Rfc1918Addresses()...)
} }
if err := setDNS(netIface, nameservers); err != nil { if err := setDNS(netIface, nameservers); err != nil {
logger.Error().Err(err).Msgf("could not set DNS for interface") logger.Error().Err(err).Msgf("could not set DNS for interface")
@@ -360,6 +537,7 @@ var (
windowsENETUNREACH = syscall.Errno(10051) windowsENETUNREACH = syscall.Errno(10051)
windowsEINVAL = syscall.Errno(10022) windowsEINVAL = syscall.Errno(10022)
windowsEADDRINUSE = syscall.Errno(10048) windowsEADDRINUSE = syscall.Errno(10048)
windowsEHOSTUNREACH = syscall.Errno(10065)
) )
func errUrlNetworkError(err error) bool { func errUrlNetworkError(err error) bool {
@@ -382,7 +560,8 @@ func errNetworkError(err error) bool {
errors.Is(opErr.Err, syscall.ENETUNREACH), errors.Is(opErr.Err, syscall.ENETUNREACH),
errors.Is(opErr.Err, windowsENETUNREACH), errors.Is(opErr.Err, windowsENETUNREACH),
errors.Is(opErr.Err, windowsEINVAL), errors.Is(opErr.Err, windowsEINVAL),
errors.Is(opErr.Err, windowsECONNREFUSED): errors.Is(opErr.Err, windowsECONNREFUSED),
errors.Is(opErr.Err, windowsEHOSTUNREACH):
return true return true
} }
} }
@@ -462,3 +641,11 @@ func defaultRouteIP() string {
mainLog.Load().Debug().Str("ip", ip).Msg("found LAN interface IP") mainLog.Load().Debug().Str("ip", ip).Msg("found LAN interface IP")
return 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
}
-1
View File
@@ -19,7 +19,6 @@ func setDependencies(svc *service.Config) {
"Wants=NetworkManager-wait-online.service", "Wants=NetworkManager-wait-online.service",
"After=NetworkManager-wait-online.service", "After=NetworkManager-wait-online.service",
"Wants=systemd-networkd-wait-online.service", "Wants=systemd-networkd-wait-online.service",
"After=systemd-networkd-wait-online.service",
"Wants=nss-lookup.target", "Wants=nss-lookup.target",
"After=nss-lookup.target", "After=nss-lookup.target",
} }
+57
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
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
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")
}
+37 -23
View File
@@ -3,11 +3,9 @@ package cli
import ( import (
"context" "context"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/miekg/dns" "github.com/miekg/dns"
"tailscale.com/logtail/backoff"
"github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld"
) )
@@ -15,53 +13,60 @@ import (
const ( const (
// maxFailureRequest is the maximum failed queries allowed before an upstream is marked as down. // maxFailureRequest is the maximum failed queries allowed before an upstream is marked as down.
maxFailureRequest = 100 maxFailureRequest = 100
// checkUpstreamMaxBackoff is the max backoff time when checking upstream status. // checkUpstreamBackoffSleep is the time interval between each upstream checks.
checkUpstreamMaxBackoff = 2 * time.Minute checkUpstreamBackoffSleep = 2 * time.Second
) )
// upstreamMonitor performs monitoring upstreams health. // upstreamMonitor performs monitoring upstreams health.
type upstreamMonitor struct { type upstreamMonitor struct {
cfg *ctrld.Config cfg *ctrld.Config
down map[string]*atomic.Bool
failureReq map[string]*atomic.Uint64
mu sync.Mutex mu sync.Mutex
checking map[string]bool checking map[string]bool
down map[string]bool
failureReq map[string]uint64
} }
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor { func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
um := &upstreamMonitor{ um := &upstreamMonitor{
cfg: cfg, cfg: cfg,
down: make(map[string]*atomic.Bool),
failureReq: make(map[string]*atomic.Uint64),
checking: make(map[string]bool), checking: make(map[string]bool),
down: make(map[string]bool),
failureReq: make(map[string]uint64),
} }
for n := range cfg.Upstream { for n := range cfg.Upstream {
upstream := upstreamPrefix + n upstream := upstreamPrefix + n
um.down[upstream] = new(atomic.Bool) um.reset(upstream)
um.failureReq[upstream] = new(atomic.Uint64)
} }
um.down[upstreamOS] = new(atomic.Bool) um.reset(upstreamOS)
um.failureReq[upstreamOS] = new(atomic.Uint64)
return um return um
} }
// increaseFailureCount increase failed queries count for an upstream by 1. // increaseFailureCount increase failed queries count for an upstream by 1.
func (um *upstreamMonitor) increaseFailureCount(upstream string) { func (um *upstreamMonitor) increaseFailureCount(upstream string) {
failedCount := um.failureReq[upstream].Add(1) um.mu.Lock()
um.down[upstream].Store(failedCount >= maxFailureRequest) 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. // isDown reports whether the given upstream is being marked as down.
func (um *upstreamMonitor) isDown(upstream string) bool { func (um *upstreamMonitor) isDown(upstream string) bool {
return um.down[upstream].Load() um.mu.Lock()
defer um.mu.Unlock()
return um.down[upstream]
} }
// reset marks an upstream as up and set failed queries counter to zero. // reset marks an upstream as up and set failed queries counter to zero.
func (um *upstreamMonitor) reset(upstream string) { func (um *upstreamMonitor) reset(upstream string) {
um.failureReq[upstream].Store(0) um.mu.Lock()
um.down[upstream].Store(false) defer um.mu.Unlock()
um.failureReq[upstream] = 0
um.down[upstream] = false
} }
// checkUpstream checks the given upstream status, periodically sending query to upstream // checkUpstream checks the given upstream status, periodically sending query to upstream
@@ -75,8 +80,12 @@ func (um *upstreamMonitor) checkUpstream(upstream string, uc *ctrld.UpstreamConf
} }
um.checking[upstream] = true um.checking[upstream] = true
um.mu.Unlock() um.mu.Unlock()
defer func() {
um.mu.Lock()
um.checking[upstream] = false
um.mu.Unlock()
}()
bo := backoff.NewBackoff("checkUpstream", logf, checkUpstreamMaxBackoff)
resolver, err := ctrld.NewResolver(uc) resolver, err := ctrld.NewResolver(uc)
if err != nil { if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not check upstream") mainLog.Load().Warn().Err(err).Msg("could not check upstream")
@@ -84,15 +93,20 @@ func (um *upstreamMonitor) checkUpstream(upstream string, uc *ctrld.UpstreamConf
} }
msg := new(dns.Msg) msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS) msg.SetQuestion(".", dns.TypeNS)
ctx := context.Background()
for { check := func() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
uc.ReBootstrap()
_, err := resolver.Resolve(ctx, msg) _, err := resolver.Resolve(ctx, msg)
if err == nil { return err
}
for {
if err := check(); err == nil {
mainLog.Load().Debug().Msgf("upstream %q is online", uc.Endpoint) mainLog.Load().Debug().Msgf("upstream %q is online", uc.Endpoint)
um.reset(upstream) um.reset(upstream)
return return
} }
bo.BackOff(ctx, err) time.Sleep(checkUpstreamBackoffSleep)
} }
} }
+2 -1
View File
@@ -28,12 +28,13 @@ type AppCallback interface {
// Start configures utility with config.toml from provided directory. // Start configures utility with config.toml from provided directory.
// This function will block until Stop is called // This function will block until Stop is called
// Check port availability prior to calling it. // Check port availability prior to calling it.
func (c *Controller) Start(CdUID string, HomeDir string, logLevel int, logPath string) { func (c *Controller) Start(CdUID string, HomeDir string, UpstreamProto string, logLevel int, logPath string) {
if c.stopCh == nil { if c.stopCh == nil {
c.stopCh = make(chan struct{}) c.stopCh = make(chan struct{})
c.Config = cli.AppConfig{ c.Config = cli.AppConfig{
CdUID: CdUID, CdUID: CdUID,
HomeDir: HomeDir, HomeDir: HomeDir,
UpstreamProto: UpstreamProto,
Verbose: logLevel, Verbose: logLevel,
LogPath: logPath, LogPath: logPath,
} }
+50 -3
View File
@@ -11,6 +11,7 @@ import (
"math/rand" "math/rand"
"net" "net"
"net/http" "net/http"
"net/netip"
"net/url" "net/url"
"os" "os"
"runtime" "runtime"
@@ -26,6 +27,7 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
"tailscale.com/logtail/backoff" "tailscale.com/logtail/backoff"
"tailscale.com/net/tsaddr"
"github.com/Control-D-Inc/ctrld/internal/dnsrcode" "github.com/Control-D-Inc/ctrld/internal/dnsrcode"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
@@ -82,6 +84,16 @@ func InitConfig(v *viper.Viper, name string) {
"0": { "0": {
IP: "", IP: "",
Port: 0, 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{ v.SetDefault("network", map[string]*NetworkConfig{
@@ -177,10 +189,14 @@ type ServiceConfig struct {
DHCPLeaseFile string `mapstructure:"dhcp_lease_file_path" toml:"dhcp_lease_file_path" validate:"omitempty,file"` 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"` 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"` DiscoverMDNS *bool `mapstructure:"discover_mdns" toml:"discover_mdns,omitempty"`
DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_dhcp,omitempty"` DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_arp,omitempty"`
DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"` DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"`
DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"` DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"`
DiscoverHosts *bool `mapstructure:"discover_hosts" toml:"discover_hosts,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:"-"` Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"` AllocateIP bool `mapstructure:"-" toml:"-"`
} }
@@ -204,6 +220,9 @@ type UpstreamConfig struct {
// The caller should not access this field directly. // The caller should not access this field directly.
// Use UpstreamSendClientInfo instead. // Use UpstreamSendClientInfo instead.
SendClientInfo *bool `mapstructure:"send_client_info" toml:"send_client_info,omitempty"` 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 g singleflight.Group
rebootstrap atomic.Bool rebootstrap atomic.Bool
@@ -227,6 +246,7 @@ type ListenerConfig struct {
IP string `mapstructure:"ip" toml:"ip,omitempty" validate:"iporempty"` IP string `mapstructure:"ip" toml:"ip,omitempty" validate:"iporempty"`
Port int `mapstructure:"port" toml:"port,omitempty" validate:"gte=0"` Port int `mapstructure:"port" toml:"port,omitempty" validate:"gte=0"`
Restricted bool `mapstructure:"restricted" toml:"restricted,omitempty"` 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"` Policy *ListenerPolicyConfig `mapstructure:"policy" toml:"policy,omitempty"`
} }
@@ -253,6 +273,7 @@ type ListenerPolicyConfig struct {
Name string `mapstructure:"name" toml:"name,omitempty"` Name string `mapstructure:"name" toml:"name,omitempty"`
Networks []Rule `mapstructure:"networks" toml:"networks,omitempty,inline,multiline" validate:"dive,len=1"` 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"` 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"` FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"` FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
} }
@@ -322,13 +343,28 @@ func (uc *UpstreamConfig) UpstreamSendClientInfo() bool {
} }
switch uc.Type { switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3: case ResolverTypeDOH, ResolverTypeDOH3:
if uc.isControlD() { if uc.isControlD() || uc.isNextDNS() {
return true return true
} }
} }
return false 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. // BootstrapIPs returns the bootstrap IPs list of upstreams.
func (uc *UpstreamConfig) BootstrapIPs() []string { func (uc *UpstreamConfig) BootstrapIPs() []string {
return uc.bootstrapIPs return uc.bootstrapIPs
@@ -394,8 +430,9 @@ func (uc *UpstreamConfig) ReBootstrap() {
return return
} }
_, _, _ = uc.g.Do("ReBootstrap", func() (any, error) { _, _, _ = uc.g.Do("ReBootstrap", func() (any, error) {
if uc.rebootstrap.CompareAndSwap(false, true) {
ProxyLogger.Load().Debug().Msg("re-bootstrapping upstream ip") ProxyLogger.Load().Debug().Msg("re-bootstrapping upstream ip")
uc.rebootstrap.Store(true) }
return true, nil return true, nil
}) })
} }
@@ -519,6 +556,16 @@ func (uc *UpstreamConfig) isControlD() bool {
return false 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 { func (uc *UpstreamConfig) dohTransport(dnsType uint16) http.RoundTripper {
uc.transportOnce.Do(func() { uc.transportOnce.Do(func() {
uc.SetupTransport() uc.SetupTransport()
+55
View File
@@ -279,6 +279,61 @@ func TestUpstreamConfig_UpstreamSendClientInfo(t *testing.T) {
} }
} }
func TestUpstreamConfig_IsDiscoverable(t *testing.T) {
tests := []struct {
name string
uc *UpstreamConfig
discoverable bool
}{
{
"loopback",
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy},
true,
},
{
"rfc1918",
&UpstreamConfig{Endpoint: "192.168.1.1", Type: ResolverTypeLegacy},
true,
},
{
"CGNAT",
&UpstreamConfig{Endpoint: "100.66.67.68", Type: ResolverTypeLegacy},
true,
},
{
"Public IP",
&UpstreamConfig{Endpoint: "8.8.8.8", Type: ResolverTypeLegacy},
false,
},
{
"override discoverable",
&UpstreamConfig{Endpoint: "127.0.0.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(false)},
false,
},
{
"override non-public",
&UpstreamConfig{Endpoint: "1.1.1.1", Type: ResolverTypeLegacy, Discoverable: ptrBool(true)},
true,
},
{
"non-legacy upstream",
&UpstreamConfig{Endpoint: "https://192.168.1.1/custom-doh", Type: ResolverTypeDOH},
false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init()
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
}
})
}
}
func ptrBool(b bool) *bool { func ptrBool(b bool) *bool {
return &b return &b
} }
+9 -11
View File
@@ -10,13 +10,10 @@ import (
"net/http" "net/http"
"runtime" "runtime"
"sync" "sync"
"time"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/quic-go/quic-go" "github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3" "github.com/quic-go/quic-go/http3"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
) )
func (uc *UpstreamConfig) setupDOH3Transport() { func (uc *UpstreamConfig) setupDOH3Transport() {
@@ -29,9 +26,7 @@ func (uc *UpstreamConfig) setupDOH3Transport() {
uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs6) uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs6)
case IpStackSplit: case IpStackSplit:
uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4) uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) if hasIPv6() {
defer cancel()
if ctrldnet.IPv6Available(ctx) {
uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6) uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6)
} else { } else {
uc.http3RoundTripper6 = uc.http3RoundTripper4 uc.http3RoundTripper6 = uc.http3RoundTripper4
@@ -127,11 +122,6 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
close(ch) close(ch)
}() }()
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
for _, addr := range addrs { for _, addr := range addrs {
go func(addr string) { go func(addr string) {
defer wg.Done() defer wg.Done()
@@ -140,6 +130,11 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
ch <- &parallelDialerResult{conn: nil, err: err} ch <- &parallelDialerResult{conn: nil, err: err}
return 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) conn, err := quic.DialEarly(ctx, udpConn, remoteAddr, tlsCfg, cfg)
select { select {
case ch <- &parallelDialerResult{conn: conn, err: err}: case ch <- &parallelDialerResult{conn: conn, err: err}:
@@ -147,6 +142,9 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
if conn != nil { if conn != nil {
conn.CloseWithError(quic.ApplicationErrorCode(http3.ErrCodeNoError), "") conn.CloseWithError(quic.ApplicationErrorCode(http3.ErrCodeNoError), "")
} }
if udpConn != nil {
udpConn.Close()
}
} }
}(addr) }(addr)
} }
+36 -1
View File
@@ -54,7 +54,12 @@ func TestLoadDefaultConfig(t *testing.T) {
cfg := defaultConfig(t) cfg := defaultConfig(t)
validate := validator.New() validate := validator.New()
require.NoError(t, ctrld.ValidateConfig(validate, cfg)) 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) assert.Len(t, cfg.Upstream, 2)
} }
@@ -96,6 +101,7 @@ func TestConfigValidation(t *testing.T) {
{"lease file format required if lease file exist", configWithExistedLeaseFile(t), true}, {"lease file format required if lease file exist", configWithExistedLeaseFile(t), true},
{"invalid lease file format", configWithInvalidLeaseFileFormat(t), true}, {"invalid lease file format", configWithInvalidLeaseFileFormat(t), true},
{"invalid doh/doh3 endpoint", configWithInvalidDoHEndpoint(t), true}, {"invalid doh/doh3 endpoint", configWithInvalidDoHEndpoint(t), true},
{"invalid client id pref", configWithInvalidClientIDPref(t), true},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -115,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 { func defaultConfig(t *testing.T) *ctrld.Config {
v := viper.New() v := viper.New()
ctrld.InitConfig(v, "test_load_default_config") ctrld.InitConfig(v, "test_load_default_config")
@@ -233,3 +262,9 @@ func configWithInvalidDoHEndpoint(t *testing.T) *ctrld.Config {
cfg.Upstream["0"].Type = ctrld.ResolverTypeDOH cfg.Upstream["0"].Type = ctrld.ResolverTypeDOH
return cfg return cfg
} }
func configWithInvalidClientIDPref(t *testing.T) *ctrld.Config {
cfg := defaultConfig(t)
cfg.Service.ClientIDPref = "foo"
return cfg
}
+96 -5
View File
@@ -14,7 +14,7 @@ The config file allows for advanced configuration of the `ctrld` utility to cove
## Config Location ## Config Location
`ctrld` uses [TOML](toml_link) format for its configuration file. Default configuration file is `ctrld.toml` found in following order: `ctrld` uses [TOML][toml_link] format for its configuration file. Default configuration file is `ctrld.toml` found in following order:
- `/etc/controld` on *nix. - `/etc/controld` on *nix.
- User's home directory on Windows. - User's home directory on Windows.
@@ -200,6 +200,14 @@ Perform LAN client discovery using hosts file.
- Required: no - Required: no
- Default: true - 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 ### dhcp_lease_file_path
Relative or absolute path to a custom DHCP leases file location. Relative or absolute path to a custom DHCP leases file location.
@@ -215,6 +223,31 @@ DHCP leases file format.
- Valid values: `dnsmasq`, `isc-dhcp` - Valid values: `dnsmasq`, `isc-dhcp`
- Default: "" - 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 ## Upstream
The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to. The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to.
@@ -319,6 +352,24 @@ If `ip_stack` is empty, or undefined:
- Default value is `both` for non-Control D resolvers. - Default value is `both` for non-Control D resolvers.
- Default value is `split` for 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 ## 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. The `[network]` section defines networks from which DNS queries can originate from. These are used in policies. You can define multiple networks, and each one can have multiple cidrs.
@@ -376,7 +427,14 @@ Port number that the listener will listen on for incoming requests. If `port` is
- Default: 0 or 53 or 5354 (depending on platform) - Default: 0 or 53 or 5354 (depending on platform)
### restricted ### 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 - Type: bool
- Required: no - Required: no
@@ -386,7 +444,15 @@ If set to `true` makes the listener `REFUSE` DNS queries from all source IP addr
Allows `ctrld` to set policy rules to determine which upstreams the requests will be forwarded to. 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`. 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 ```toml
[listener.0.policy] [listener.0.policy]
@@ -400,12 +466,18 @@ rules = [
{"*.local" = ["upstream.1"]}, {"*.local" = ["upstream.1"]},
{"test.com" = ["upstream.2", "upstream.1"]}, {"test.com" = ["upstream.2", "upstream.1"]},
] ]
macs = [
{"14:54:4a:8e:08:2d" = ["upstream.3"]},
]
``` ```
Above policy will: 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 `.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` 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`. - 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. An empty upstream would not route the request to any defined upstreams, and use the OS default resolver.
@@ -419,6 +491,18 @@ rules = [
] ]
``` ```
---
Note that the order of matching preference:
```
rules => macs => networks
```
And within each policy, the rules are processed from top to bottom.
---
#### name #### name
`name` is the name for the policy. `name` is the name for the policy.
@@ -440,6 +524,13 @@ rules = [
- Required: no - Required: no
- Default: [] - 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 ### 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 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`.
@@ -460,7 +551,7 @@ networks = [
If `upstream.0` returns a NXDOMAIN response, the request will be forwarded to `upstream.1` instead of returning immediately to the client. If `upstream.0` returns a NXDOMAIN response, the request will be forwarded to `upstream.1` instead of returning immediately to the client.
See all available DNS Rcodes value [here](rcode_link). See all available DNS Rcodes value [here][rcode_link].
[toml_link]: https://toml.io/en [toml_link]: https://toml.io/en
[rcode_link]: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6 [rcode_link]: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
+58 -21
View File
@@ -22,6 +22,7 @@ const (
dohIPHeader = "x-cd-ip" dohIPHeader = "x-cd-ip"
dohHostHeader = "x-cd-host" dohHostHeader = "x-cd-host"
dohOsHeader = "x-cd-os" dohOsHeader = "x-cd-os"
dohClientIDPrefHeader = "x-cd-cpref"
headerApplicationDNS = "application/dns-message" headerApplicationDNS = "application/dns-message"
) )
@@ -76,7 +77,6 @@ func newDohResolver(uc *UpstreamConfig) *dohResolver {
endpoint: uc.u, endpoint: uc.u,
isDoH3: uc.Type == ResolverTypeDOH3, isDoH3: uc.Type == ResolverTypeDOH3,
http3RoundTripper: uc.http3RoundTripper, http3RoundTripper: uc.http3RoundTripper,
sendClientInfo: uc.UpstreamSendClientInfo(),
uc: uc, uc: uc,
} }
return r return r
@@ -87,9 +87,9 @@ type dohResolver struct {
endpoint *url.URL endpoint *url.URL
isDoH3 bool isDoH3 bool
http3RoundTripper http.RoundTripper http3RoundTripper http.RoundTripper
sendClientInfo bool
} }
// Resolve performs DNS query with given DNS message using DOH protocol.
func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
data, err := msg.Pack() data, err := msg.Pack()
if err != nil { if err != nil {
@@ -106,7 +106,7 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
if err != nil { if err != nil {
return nil, fmt.Errorf("could not create request: %w", err) return nil, fmt.Errorf("could not create request: %w", err)
} }
addHeader(ctx, req, r.sendClientInfo) addHeader(ctx, req, r.uc)
dnsTyp := uint16(0) dnsTyp := uint16(0)
if len(msg.Question) > 0 { if len(msg.Question) > 0 {
dnsTyp = msg.Question[0].Qtype dnsTyp = msg.Question[0].Qtype
@@ -146,30 +146,67 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
return answer, nil return answer, nil
} }
func addHeader(ctx context.Context, req *http.Request, sendClientInfo bool) { // addHeader adds necessary HTTP header to request based on upstream config.
req.Header.Set("Content-Type", headerApplicationDNS) func addHeader(ctx context.Context, req *http.Request, uc *UpstreamConfig) {
req.Header.Set("Accept", headerApplicationDNS)
req.Header.Set(dohOsHeader, dohOsHeaderValue())
printed := false printed := false
if sendClientInfo { dohHeader := make(http.Header)
if uc.UpstreamSendClientInfo() {
if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil { if ci, ok := ctx.Value(ClientInfoCtxKey{}).(*ClientInfo); ok && ci != nil {
printed = ci.Mac != "" || ci.IP != "" || ci.Hostname != "" printed = ci.Mac != "" || ci.IP != "" || ci.Hostname != ""
if ci.Mac != "" { switch {
req.Header.Set(dohMacHeader, ci.Mac) case uc.isControlD():
} dohHeader = newControlDHeaders(ci)
if ci.IP != "" { case uc.isNextDNS():
req.Header.Set(dohIPHeader, ci.IP) dohHeader = newNextDNSHeaders(ci)
}
if ci.Hostname != "" {
req.Header.Set(dohHostHeader, ci.Hostname)
}
if ci.Self {
req.Header.Set(dohOsHeader, dohOsHeaderValue())
} }
} }
} }
if printed { if printed {
Log(ctx, ProxyLogger.Load().Debug().Interface("header", req.Header), "sending request header") Log(ctx, ProxyLogger.Load().Debug(), "sending request header: %v", dohHeader)
} }
dohHeader.Set("Content-Type", headerApplicationDNS)
dohHeader.Set("Accept", headerApplicationDNS)
req.Header = dohHeader
}
// newControlDHeaders returns DoH/Doh3 HTTP request headers for ControlD upstream.
func newControlDHeaders(ci *ClientInfo) http.Header {
header := make(http.Header)
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
} }
+15 -5
View File
@@ -15,9 +15,12 @@ require (
github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c github.com/jaytaylor/go-hostsfile v0.0.0-20220426042432-61485ac1fa6c
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86
github.com/kardianos/service v1.2.1 github.com/kardianos/service v1.2.1
github.com/mdlayher/ndp v1.0.1
github.com/miekg/dns v1.1.55 github.com/miekg/dns v1.1.55
github.com/olekukonko/tablewriter v0.0.5 github.com/olekukonko/tablewriter v0.0.5
github.com/pelletier/go-toml/v2 v2.0.8 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/quic-go/quic-go v0.38.0
github.com/rs/zerolog v1.28.0 github.com/rs/zerolog v1.28.0
github.com/spf13/cobra v1.7.0 github.com/spf13/cobra v1.7.0
@@ -25,20 +28,23 @@ require (
github.com/spf13/viper v1.16.0 github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.3 github.com/stretchr/testify v1.8.3
github.com/vishvananda/netlink v1.2.1-beta.2 github.com/vishvananda/netlink v1.2.1-beta.2
golang.org/x/net v0.10.0 golang.org/x/net v0.17.0
golang.org/x/sync v0.2.0 golang.org/x/sync v0.2.0
golang.org/x/sys v0.8.1-0.20230609144347-5059a07aa46a golang.org/x/sys v0.13.0
golang.zx2c4.com/wireguard/windows v0.5.3 golang.zx2c4.com/wireguard/windows v0.5.3
tailscale.com v1.44.0 tailscale.com v1.44.0
) )
require ( require (
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/golang/mock v1.6.0 // 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/go-cmp v0.5.9 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
@@ -51,6 +57,7 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.14 // 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/ethernet v0.0.0-20190606142754-0394541c37b7 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect
@@ -59,6 +66,9 @@ require (
github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.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/qpack v0.4.0 // indirect
github.com/quic-go/qtls-go1-20 v0.3.2 // indirect github.com/quic-go/qtls-go1-20 v0.3.2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/rivo/uniseg v0.4.4 // indirect
@@ -70,12 +80,12 @@ require (
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect
github.com/vishvananda/netns v0.0.4 // indirect github.com/vishvananda/netns v0.0.4 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
golang.org/x/crypto v0.9.0 // indirect golang.org/x/crypto v0.14.0 // indirect
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda // indirect
golang.org/x/mod v0.10.0 // indirect golang.org/x/mod v0.10.0 // indirect
golang.org/x/text v0.9.0 // indirect golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.9.1 // 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/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+32 -12
View File
@@ -42,7 +42,11 @@ github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be h1:qBKVRi7Mom5h
github.com/Windscribe/zerolog v0.0.0-20230503170159-e6aa153233be/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= 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 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 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/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/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/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/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -55,8 +59,6 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cuonglm/osinfo v0.0.0-20230329055532-c513f836da19 h1:7P/f19Mr0oa3ug8BYt4JuRe/Zq3dF4Mrr4m8+Kw+Hcs=
github.com/cuonglm/osinfo v0.0.0-20230329055532-c513f836da19/go.mod h1:G45410zMgmnSjLVKCq4f6GpbYAzoP2plX9rPwgx6C24=
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf h1:40DHYsri+d1bnroFDU2FQAeq68f3kAlOzlQ93kCf26Q= github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf h1:40DHYsri+d1bnroFDU2FQAeq68f3kAlOzlQ93kCf26Q=
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf/go.mod h1:G45410zMgmnSjLVKCq4f6GpbYAzoP2plX9rPwgx6C24= 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -116,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.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 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.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
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 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 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/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -128,6 +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.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.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.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -201,8 +206,12 @@ github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 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 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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 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/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y=
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 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= 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-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg=
@@ -229,7 +238,17 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.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 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= 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 h1:rRgN3WfnKbyik4dBV8A6girlJVxGand/d+jVKbQq5GI=
@@ -302,8 +321,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= 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-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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -331,8 +350,6 @@ golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPI
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda h1:O+EUvnBNPwI4eLthn8W5K+cS8zQZfgTABPLNm6Bna34=
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
@@ -378,8 +395,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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-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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -452,8 +469,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.1-0.20230609144347-5059a07aa46a h1:qMsju+PNttu/NMbq8bQ9waDdxgJMu9QNoUDuhnBaYt0= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.8.1-0.20230609144347-5059a07aa46a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -463,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.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 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-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-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -612,7 +629,10 @@ 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.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.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0 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 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-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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+131 -29
View File
@@ -1,8 +1,11 @@
package clientinfo package clientinfo
import ( import (
"context"
"fmt" "fmt"
"net"
"net/netip" "net/netip"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -59,6 +62,8 @@ type Client struct {
Mac string Mac string
Hostname string Hostname string
Source map[string]struct{} Source map[string]struct{}
QueryCount int64
IncludeQueryCount bool
} }
type Table struct { type Table struct {
@@ -67,26 +72,36 @@ type Table struct {
hostnameResolvers []HostnameResolver hostnameResolvers []HostnameResolver
refreshers []refresher refreshers []refresher
initOnce sync.Once initOnce sync.Once
refreshInterval int
dhcp *dhcp dhcp *dhcp
merlin *merlinDiscover merlin *merlinDiscover
ubios *ubiosDiscover
arp *arpDiscover arp *arpDiscover
ndp *ndpDiscover
ptr *ptrDiscover ptr *ptrDiscover
mdns *mdns mdns *mdns
hf *hostsFile hf *hostsFile
vni *virtualNetworkIface vni *virtualNetworkIface
cfg *ctrld.Config svcCfg ctrld.ServiceConfig
quitCh chan struct{} quitCh chan struct{}
selfIP string selfIP string
cdUID string cdUID string
ptrNameservers []string
} }
func NewTable(cfg *ctrld.Config, selfIP, cdUID string) *Table { func NewTable(cfg *ctrld.Config, selfIP, cdUID string, ns []string) *Table {
refreshInterval := cfg.Service.DiscoverRefreshInterval
if refreshInterval <= 0 {
refreshInterval = 2 * 60 // 2 minutes
}
return &Table{ return &Table{
cfg: cfg, svcCfg: cfg.Service,
quitCh: make(chan struct{}), quitCh: make(chan struct{}),
selfIP: selfIP, selfIP: selfIP,
cdUID: cdUID, cdUID: cdUID,
ptrNameservers: ns,
refreshInterval: refreshInterval,
} }
} }
@@ -97,8 +112,9 @@ func (t *Table) AddLeaseFile(name string, format ctrld.LeaseFileFormat) {
clientInfoFiles[name] = format clientInfoFiles[name] = format
} }
func (t *Table) RefreshLoop(stopCh chan struct{}) { // RefreshLoop runs all the refresher to update new client info data.
timer := time.NewTicker(time.Minute * 5) func (t *Table) RefreshLoop(ctx context.Context) {
timer := time.NewTicker(time.Second * time.Duration(t.refreshInterval))
defer timer.Stop() defer timer.Stop()
for { for {
select { select {
@@ -106,7 +122,7 @@ func (t *Table) RefreshLoop(stopCh chan struct{}) {
for _, r := range t.refreshers { for _, r := range t.refreshers {
_ = r.refresh() _ = r.refresh()
} }
case <-stopCh: case <-ctx.Done():
close(t.quitCh) close(t.quitCh)
return return
} }
@@ -132,14 +148,26 @@ func (t *Table) init() {
// Otherwise, process all possible sources in order, that means // Otherwise, process all possible sources in order, that means
// the first result of IP/MAC/Hostname lookup will be used. // the first result of IP/MAC/Hostname lookup will be used.
// //
// Merlin custom clients. // Routers custom clients:
// - Merlin
// - Ubios
if t.discoverDHCP() || t.discoverARP() { if t.discoverDHCP() || t.discoverARP() {
t.merlin = &merlinDiscover{} t.merlin = &merlinDiscover{}
if err := t.merlin.refresh(); err != nil { t.ubios = &ubiosDiscover{}
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init Merlin discover") 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 { } else {
t.hostnameResolvers = append(t.hostnameResolvers, t.merlin) t.hostnameResolvers = append(t.hostnameResolvers, discover)
t.refreshers = append(t.refreshers, t.merlin) t.refreshers = append(t.refreshers, discover)
}
} }
} }
// Hosts file mapping. // Hosts file mapping.
@@ -167,21 +195,59 @@ func (t *Table) init() {
} }
go t.dhcp.watchChanges() go t.dhcp.watchChanges()
} }
// ARP table. // ARP/NDP table.
if t.discoverARP() { if t.discoverARP() {
t.arp = &arpDiscover{} t.arp = &arpDiscover{}
t.ndp = &ndpDiscover{}
ctrld.ProxyLogger.Load().Debug().Msg("start arp discovery") ctrld.ProxyLogger.Load().Debug().Msg("start arp discovery")
if err := t.arp.refresh(); err != nil { discovers := map[string]interface {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init ARP discover") refresher
} else { IpResolver
t.ipResolvers = append(t.ipResolvers, t.arp) MacResolver
t.macResolvers = append(t.macResolvers, t.arp) }{
t.refreshers = append(t.refreshers, t.arp) "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. // PTR lookup.
if t.discoverPTR() { if t.discoverPTR() {
t.ptr = &ptrDiscover{resolver: ctrld.NewPrivateResolver()} 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") ctrld.ProxyLogger.Load().Debug().Msg("start ptr discovery")
if err := t.ptr.refresh(); err != nil { if err := t.ptr.refresh(); err != nil {
ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init PTR discover") ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init PTR discover")
@@ -240,6 +306,21 @@ func (t *Table) LookupHostname(ip, mac string) string {
return "" 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 { type macEntry struct {
mac string mac string
src string src string
@@ -288,7 +369,7 @@ func (t *Table) ListClients() []*Client {
_ = r.refresh() _ = r.refresh()
} }
ipMap := make(map[string]*Client) ipMap := make(map[string]*Client)
il := []ipLister{t.dhcp, t.arp, t.ptr, t.mdns, t.vni} il := []ipLister{t.dhcp, t.arp, t.ndp, t.ptr, t.mdns, t.vni}
for _, ir := range il { for _, ir := range il {
for _, ip := range ir.List() { for _, ip := range ir.List() {
c, ok := ipMap[ip] c, ok := ipMap[ip]
@@ -338,39 +419,60 @@ func (t *Table) StoreVPNClient(ci *ctrld.ClientInfo) {
t.vni.ip2name.Store(ci.IP, ci.Hostname) 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 { func (t *Table) discoverDHCP() bool {
if t.cfg.Service.DiscoverDHCP == nil { if t.svcCfg.DiscoverDHCP == nil {
return true return true
} }
return *t.cfg.Service.DiscoverDHCP return *t.svcCfg.DiscoverDHCP
} }
func (t *Table) discoverARP() bool { func (t *Table) discoverARP() bool {
if t.cfg.Service.DiscoverARP == nil { if t.svcCfg.DiscoverARP == nil {
return true return true
} }
return *t.cfg.Service.DiscoverARP return *t.svcCfg.DiscoverARP
} }
func (t *Table) discoverMDNS() bool { func (t *Table) discoverMDNS() bool {
if t.cfg.Service.DiscoverMDNS == nil { if t.svcCfg.DiscoverMDNS == nil {
return true return true
} }
return *t.cfg.Service.DiscoverMDNS return *t.svcCfg.DiscoverMDNS
} }
func (t *Table) discoverPTR() bool { func (t *Table) discoverPTR() bool {
if t.cfg.Service.DiscoverPtr == nil { if t.svcCfg.DiscoverPtr == nil {
return true return true
} }
return *t.cfg.Service.DiscoverPtr return *t.svcCfg.DiscoverPtr
} }
func (t *Table) discoverHosts() bool { func (t *Table) discoverHosts() bool {
if t.cfg.Service.DiscoverHosts == nil { if t.svcCfg.DiscoverHosts == nil {
return true return true
} }
return *t.cfg.Service.DiscoverHosts return *t.svcCfg.DiscoverHosts
} }
// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file. // normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file.
+19
View File
@@ -25,3 +25,22 @@ func Test_normalizeIP(t *testing.T) {
}) })
} }
} }
func TestTable_LookupRFC1918IPv4(t *testing.T) {
table := &Table{
dhcp: &dhcp{},
arp: &arpDiscover{},
}
table.ipResolvers = append(table.ipResolvers, table.dhcp)
table.ipResolvers = append(table.ipResolvers, table.arp)
macAddress := "cc:19:f9:8a:49:e6"
rfc1918IPv4 := "10.0.10.245"
table.dhcp.ip.Store(macAddress, "127.0.0.1")
table.arp.ip.Store(macAddress, rfc1918IPv4)
if got := table.LookupRFC1918IPv4(macAddress); got != rfc1918IPv4 {
t.Fatalf("unexpected result, want: %s, got: %s", rfc1918IPv4, got)
}
}
+93 -2
View File
@@ -3,11 +3,13 @@ package clientinfo
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/csv"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/netip" "net/netip"
"os" "os"
"sort"
"strings" "strings"
"sync" "sync"
@@ -134,6 +136,39 @@ func (d *dhcp) List() []string {
return ips 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. // AddLeaseFile adds given lease file for reading/watching clients info.
func (d *dhcp) addLeaseFile(name string, format ctrld.LeaseFileFormat) error { func (d *dhcp) addLeaseFile(name string, format ctrld.LeaseFileFormat) error {
if d.watcher == nil { if d.watcher == nil {
@@ -153,6 +188,8 @@ func (d *dhcp) readLeaseFile(name string, format ctrld.LeaseFileFormat) error {
return d.dnsmasqReadClientInfoFile(name) return d.dnsmasqReadClientInfoFile(name)
case ctrld.IscDhcpd: case ctrld.IscDhcpd:
return d.iscDHCPReadClientInfoFile(name) return d.iscDHCPReadClientInfoFile(name)
case ctrld.KeaDHCP4:
return d.keaDhcp4ReadClientInfoFile(name)
} }
return fmt.Errorf("unsupported format: %s, file: %s", format, name) return fmt.Errorf("unsupported format: %s, file: %s", format, name)
} }
@@ -168,7 +205,8 @@ func (d *dhcp) dnsmasqReadClientInfoFile(name string) error {
} }
// dnsmasqReadClientInfoReader likes ctrld.Dnsmasq, but reading from an io.Reader instead of file. // dnsmasqReadClientInfoReader performs the same task as dnsmasqReadClientInfoFile,
// but by reading from an io.Reader instead of file.
func (d *dhcp) dnsmasqReadClientInfoReader(reader io.Reader) error { func (d *dhcp) dnsmasqReadClientInfoReader(reader io.Reader) error {
return lineread.Reader(reader, func(line []byte) error { return lineread.Reader(reader, func(line []byte) error {
fields := bytes.Fields(line) fields := bytes.Fields(line)
@@ -210,7 +248,8 @@ func (d *dhcp) iscDHCPReadClientInfoFile(name string) error {
return d.iscDHCPReadClientInfoReader(f) return d.iscDHCPReadClientInfoReader(f)
} }
// iscDHCPReadClientInfoReader likes ctrld.IscDhcpd, but reading from an io.Reader instead of file. // iscDHCPReadClientInfoReader performs the same task as iscDHCPReadClientInfoFile,
// but by reading from an io.Reader instead of file.
func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error { func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error {
s := bufio.NewScanner(reader) s := bufio.NewScanner(reader)
var ip, mac, hostname string var ip, mac, hostname string
@@ -253,6 +292,58 @@ func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error {
return nil 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 // addSelf populates current host info to dhcp, so queries from
// the host itself can be attached with proper client info. // the host itself can be attached with proper client info.
func (d *dhcp) addSelf() { func (d *dhcp) addSelf() {
+1
View File
@@ -15,4 +15,5 @@ var clientInfoFiles = map[string]ctrld.LeaseFileFormat{
"/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS "/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS
"/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense "/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense
"/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla "/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla
"/var/lib/kea/dhcp4.leases": ctrld.KeaDHCP4, // Pfsense
} }
+53
View File
@@ -67,6 +67,41 @@ lease 192.168.1.2 {
"00:00:00:00:00:04", "00:00:00:00:00:04",
"example", "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 { for _, tc := range tests {
@@ -76,6 +111,12 @@ lease 192.168.1.2 {
t.Errorf("readClientInfoReader() error = %v", err) t.Errorf("readClientInfoReader() error = %v", err)
} }
val, existed := d.mac2name.Load(tc.mac) val, existed := d.mac2name.Load(tc.mac)
if tc.hostname == "*" {
if existed {
t.Errorf("* hostname must be skipped")
}
return
}
if !existed { if !existed {
t.Error("client info missing") t.Error("client info missing")
} }
@@ -86,3 +127,15 @@ lease 192.168.1.2 {
}) })
} }
} }
func Test_dhcp_lookupIPByHostname(t *testing.T) {
d := &dhcp{}
want := "192.168.1.123"
d.ip2name.Store(want, "foo")
d.ip2name.Store("127.0.0.1", "foo")
d.ip2name.Store("169.254.123.123", "foo")
if got := d.lookupIPByHostname("foo", false); got != want {
t.Fatalf("unexpected result, want: %s, got: %s", want, got)
}
}
+78 -8
View File
@@ -1,7 +1,12 @@
package clientinfo package clientinfo
import ( import (
"bufio"
"bytes"
"io"
"net/netip"
"os" "os"
"strings"
"sync" "sync"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -14,6 +19,7 @@ const (
ipv4LocalhostName = "localhost" ipv4LocalhostName = "localhost"
ipv6LocalhostName = "ip6-localhost" ipv6LocalhostName = "ip6-localhost"
ipv6LoopbackName = "ip6-loopback" ipv6LoopbackName = "ip6-loopback"
hostEntriesConfPath = "/var/unbound/host_entries.conf"
) )
// hostsFile provides client discovery functionality using system hosts file. // hostsFile provides client discovery functionality using system hosts file.
@@ -33,14 +39,9 @@ func (hf *hostsFile) init() error {
if err := hf.watcher.Add(hostsfile.HostsPath); err != nil { if err := hf.watcher.Add(hostsfile.HostsPath); err != nil {
return err return err
} }
m, err := hostsfile.ParseHosts(hostsfile.ReadHostsFile()) // Conservatively adding hostEntriesConfPath, since it is not available everywhere.
if err != nil { _ = hf.watcher.Add(hostEntriesConfPath)
return err return hf.refresh()
}
hf.mu.Lock()
hf.m = m
hf.mu.Unlock()
return nil
} }
// refresh reloads hosts file entries. // refresh reloads hosts file entries.
@@ -51,6 +52,14 @@ func (hf *hostsFile) refresh() error {
} }
hf.mu.Lock() hf.mu.Lock()
hf.m = m 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() hf.mu.Unlock()
return nil return nil
} }
@@ -109,6 +118,24 @@ func (hf *hostsFile) String() string {
return "hosts" 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. // isLocalhostName reports whether the given hostname represents localhost.
func isLocalhostName(hostname string) bool { func isLocalhostName(hostname string) bool {
switch hostname { switch hostname {
@@ -118,3 +145,46 @@ func isLocalhostName(hostname string) bool {
return false 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
}
+44
View File
@@ -1,6 +1,7 @@
package clientinfo package clientinfo
import ( import (
"strings"
"testing" "testing"
) )
@@ -31,3 +32,46 @@ func Test_hostsFile_LookupHostnameByIP(t *testing.T) {
}) })
} }
} }
func Test_parseHostEntriesConfFromReader(t *testing.T) {
const content = `local-zone: "localdomain" transparent
local-data-ptr: "127.0.0.1 localhost"
local-data: "localhost A 127.0.0.1"
local-data: "localhost.localdomain A 127.0.0.1"
local-data-ptr: "::1 localhost"
local-data: "localhost AAAA ::1"
local-data: "localhost.localdomain AAAA ::1"
local-data-ptr: "10.0.10.227 OPNsense.localdomain"
local-data: "OPNsense.localdomain A 10.0.10.227"
local-data: "OPNsense A 10.0.10.227"
local-data-ptr: "fe80::5a78:4e29:caa3:f9f7 OPNsense.localdomain"
local-data: "OPNsense.localdomain AAAA fe80::5a78:4e29:caa3:f9f7"
local-data: "OPNsense AAAA fe80::5a78:4e29:caa3:f9f7"
local-data-ptr: "1.1.1.1 banana-party.local.com"
local-data: "banana-party.local.com IN A 1.1.1.1"
local-data-ptr: "1.1.1.1 cheese-land.lan"
local-data: "cheese-land.lan IN A 1.1.1.1"
`
r := strings.NewReader(content)
hostsMap := parseHostEntriesConfFromReader(r)
if len(hostsMap) != 5 {
t.Fatalf("unexpected number of entries, want 5, got: %d", len(hostsMap))
}
for ip, names := range hostsMap {
switch ip {
case "1.1.1.1":
for _, name := range names {
if name != "banana-party.local.com" && name != "cheese-land.lan" {
t.Fatalf("unexpected names for 1.1.1.1: %v", names)
}
}
case "10.0.10.227":
if len(names) != 1 {
t.Fatalf("unexpected names for 10.0.10.227: %v", names)
}
if names[0] != "OPNsense" {
t.Fatalf("unexpected name: %s", names[0])
}
}
}
}
+70
View File
@@ -1,10 +1,16 @@
package clientinfo package clientinfo
import ( import (
"bufio"
"bytes"
"context" "context"
"errors" "errors"
"io"
"net" "net"
"net/netip"
"os" "os"
"os/exec"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -59,6 +65,27 @@ func (m *mdns) List() []string {
return ips 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 { func (m *mdns) init(quitCh chan struct{}) error {
ifaces, err := multicastInterfaces() ifaces, err := multicastInterfaces()
if err != nil { if err != nil {
@@ -85,6 +112,7 @@ func (m *mdns) init(quitCh chan struct{}) error {
go m.probeLoop(v4ConnList, mdnsV4Addr, quitCh) go m.probeLoop(v4ConnList, mdnsV4Addr, quitCh)
go m.probeLoop(v6ConnList, mdnsV6Addr, quitCh) go m.probeLoop(v6ConnList, mdnsV6Addr, quitCh)
go m.getDataFromAvahiDaemonCache()
return nil return nil
} }
@@ -123,6 +151,10 @@ func (m *mdns) readLoop(conn *net.UDPConn) {
if err, ok := err.(*net.OpError); ok && (err.Timeout() || err.Temporary()) { if err, ok := err.(*net.OpError); ok && (err.Timeout() || err.Temporary()) {
continue 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") ctrld.ProxyLogger.Load().Debug().Err(err).Msg("mdns readLoop error")
return return
} }
@@ -186,6 +218,44 @@ func (m *mdns) probe(conns []*net.UDPConn, remoteAddr net.Addr) error {
return err 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) { func multicastInterfaces() ([]net.Interface, error) {
ifaces, err := net.Interfaces() ifaces, err := net.Interfaces()
if err != nil { if err != nil {
+27
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)
}
}
+219
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")
}
+24
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)
}
}
+31
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))
}
}
+64
View File
@@ -0,0 +1,64 @@
package clientinfo
import (
"strings"
"sync"
"testing"
)
func Test_ndpDiscover_scanUnix(t *testing.T) {
r := strings.NewReader(`Neighbor Linklayer Address Netif Expire St Flgs Prbs
2405:4802:1f90:fda0:1459:ec89:523d:3583 00:0:00:0:00:01 en0 permanent R
2405:4802:1f90:fda0:186b:c54a:1370:c196 (incomplete) en0 expired N
2405:4802:1f90:fda0:88de:14ef:6a8c:579a 00:0:00:0:00:02 en0 permanent R
fe80::1%lo0 (incomplete) lo0 permanent R
`)
nd := &ndpDiscover{}
nd.scanUnix(r)
for _, m := range []*sync.Map{&nd.mac, &nd.ip} {
count := 0
m.Range(func(key, value any) bool {
count++
return true
})
if count != 2 {
t.Errorf("unexpected count, want 2, got: %d", count)
}
}
}
func Test_ndpDiscover_scanWindows(t *testing.T) {
r := strings.NewReader(`Interface 14: Wi-Fi
Internet Address Physical Address Type
-------------------------------------------- ----------------- -----------
2405:4802:1f90:fda0:ffff:ffff:ffff:ff88 00-00-00-00-00-00 Unreachable
fe80::1 60-57-47-21-dd-00 Reachable (Router)
fe80::6257:47ff:fe21:dd00 60-57-47-21-dd-00 Reachable (Router)
ff02::1 33-33-00-00-00-01 Permanent
ff02::2 33-33-00-00-00-02 Permanent
ff02::c 33-33-00-00-00-0c Permanent
`)
nd := &ndpDiscover{}
nd.scanWindows(r)
count := 0
nd.mac.Range(func(key, value any) bool {
count++
return true
})
if count != 6 {
t.Errorf("unexpected count, want 6, got: %d", count)
}
count = 0
nd.ip.Range(func(key, value any) bool {
count++
return true
})
if count != 5 {
t.Errorf("unexpected count, want 5, got: %d", count)
}
}
+26 -3
View File
@@ -2,6 +2,7 @@ package clientinfo
import ( import (
"context" "context"
"net/netip"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -72,15 +73,16 @@ func (p *ptrDiscover) lookupHostname(ip string) string {
msg := new(dns.Msg) msg := new(dns.Msg)
addr, err := dns.ReverseAddr(ip) addr, err := dns.ReverseAddr(ip)
if err != nil { if err != nil {
ctrld.ProxyLogger.Load().Warn().Str("discovery", "ptr").Err(err).Msg("invalid ip address") ctrld.ProxyLogger.Load().Info().Str("discovery", "ptr").Err(err).Msg("invalid ip address")
return "" return ""
} }
msg.SetQuestion(addr, dns.TypePTR) msg.SetQuestion(addr, dns.TypePTR)
ans, err := p.resolver.Resolve(ctx, msg) ans, err := p.resolver.Resolve(ctx, msg)
if err != nil { if err != nil {
ctrld.ProxyLogger.Load().Warn().Str("discovery", "ptr").Err(err).Msg("could not perform PTR lookup") if p.serverDown.CompareAndSwap(false, true) {
p.serverDown.Store(true) ctrld.ProxyLogger.Load().Info().Str("discovery", "ptr").Err(err).Msg("could not perform PTR lookup")
go p.checkServer() go p.checkServer()
}
return "" return ""
} }
for _, rr := range ans.Answer { for _, rr := range ans.Answer {
@@ -93,6 +95,27 @@ func (p *ptrDiscover) lookupHostname(ip string) string {
return "" return ""
} }
func (p *ptrDiscover) lookupIPByHostname(name string, v6 bool) string {
if p == nil {
return ""
}
var ip string
p.hostname.Range(func(key, value any) bool {
if value == name {
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
ip = addr.String()
//lint:ignore S1008 This is used for readable.
if addr.IsLoopback() { // Continue searching if this is loopback address.
return true
}
return false
}
}
return true
})
return ip
}
// checkServer monitors if the resolver can reach its nameserver. When the nameserver // checkServer monitors if the resolver can reach its nameserver. When the nameserver
// is reachable, set p.serverDown to false, so p.lookupHostname can continue working. // is reachable, set p.serverDown to false, so p.lookupHostname can continue working.
func (p *ptrDiscover) checkServer() { func (p *ptrDiscover) checkServer() {
+78
View File
@@ -0,0 +1,78 @@
package clientinfo
import (
"bytes"
"encoding/json"
"io"
"os/exec"
"strings"
"sync"
"github.com/Control-D-Inc/ctrld/internal/router"
"github.com/Control-D-Inc/ctrld/internal/router/ubios"
)
// ubiosDiscover provides client discovery functionality on Ubios routers.
type ubiosDiscover struct {
hostname sync.Map // mac => hostname
}
// refresh reloads unifi devices from database.
func (u *ubiosDiscover) refresh() error {
if router.Name() != ubios.Name {
return nil
}
return u.refreshDevices()
}
// LookupHostnameByIP returns hostname for given IP.
func (u *ubiosDiscover) LookupHostnameByIP(ip string) string {
return ""
}
// LookupHostnameByMac returns unifi device custom hostname for the given MAC address.
func (u *ubiosDiscover) LookupHostnameByMac(mac string) string {
val, ok := u.hostname.Load(mac)
if !ok {
return ""
}
return val.(string)
}
// refreshDevices updates unifi devices name from local mongodb.
func (u *ubiosDiscover) refreshDevices() error {
cmd := exec.Command("/usr/bin/mongo", "localhost:27117/ace", "--quiet", "--eval", `
DBQuery.shellBatchSize = 256;
db.user.find({name: {$exists: true, $ne: ""}}, {_id:0, mac:1, name:1});`)
b, err := cmd.Output()
if err != nil {
return err
}
return u.storeDevices(bytes.NewReader(b))
}
// storeDevices saves unifi devices name for caching.
func (u *ubiosDiscover) storeDevices(r io.Reader) error {
decoder := json.NewDecoder(r)
device := struct {
MAC string
Name string
}{}
for {
err := decoder.Decode(&device)
if err == io.EOF {
break
}
if err != nil {
return err
}
mac := strings.ToLower(device.MAC)
u.hostname.Store(mac, normalizeHostname(device.Name))
}
return nil
}
// String returns human-readable format of ubiosDiscover.
func (u *ubiosDiscover) String() string {
return "ubios"
}
+43
View File
@@ -0,0 +1,43 @@
package clientinfo
import (
"strings"
"testing"
)
func Test_ubiosDiscover_storeDevices(t *testing.T) {
ud := &ubiosDiscover{}
r := strings.NewReader(`{ "mac": "00:00:00:00:00:01", "name": "device 1" }
{ "mac": "00:00:00:00:00:02", "name": "device 2" }
`)
if err := ud.storeDevices(r); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
mac string
hostname string
}{
{"device 1", "00:00:00:00:00:01", "device 1"},
{"device 2", "00:00:00:00:00:02", "device 2"},
{"non-existed", "00:00:00:00:00:03", ""},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := ud.LookupHostnameByMac(tc.mac); got != tc.hostname {
t.Errorf("hostname mismatched, want: %q, got: %q", tc.hostname, got)
}
})
}
// Test for invalid input.
r = strings.NewReader(`{ "mac": "00:00:00:00:00:01", "name": "device 1"`)
if err := ud.storeDevices(r); err == nil {
t.Fatal("expected error, got nil")
} else {
t.Log(err)
}
}
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"runtime"
"strings" "strings"
"time" "time"
@@ -119,7 +120,7 @@ func postUtilityAPI(version string, cdDev bool, body io.Reader) (*ResolverConfig
return d.DialContext(ctx, network, addrs) return d.DialContext(ctx, network, addrs)
} }
if router.Name() == ddwrt.Name { if router.Name() == ddwrt.Name || runtime.GOOS == "android" {
transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool()} transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool()}
} }
client := http.Client{ client := http.Client{
+2 -2
View File
@@ -16,8 +16,8 @@ import (
const ( const (
controldIPv6Test = "ipv6.controld.io" controldIPv6Test = "ipv6.controld.io"
v4BootstrapDNS = "76.76.2.0:53" v4BootstrapDNS = "76.76.2.22:53"
v6BootstrapDNS = "[2606:1a40::]:53" v6BootstrapDNS = "[2606:1a40::22]:53"
) )
var Dialer = &net.Dialer{ var Dialer = &net.Dialer{
+32 -10
View File
@@ -15,9 +15,12 @@ no-resolv
{{- range .Upstreams}} {{- range .Upstreams}}
server={{ .IP }}#{{ .Port }} server={{ .IP }}#{{ .Port }}
{{- end}} {{- end}}
{{- if .SendClientInfo}}
add-mac add-mac
add-subnet=32,128 add-subnet=32,128
{{- if .CacheDisabled}}
cache-size=0
{{- else}}
max-cache-ttl=0
{{- end}} {{- end}}
` `
@@ -39,14 +42,14 @@ if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then
{{- range .Upstreams}} {{- range .Upstreams}}
pc_append "server={{ .IP }}#{{ .Port }}" "$config_file" pc_append "server={{ .IP }}#{{ .Port }}" "$config_file"
{{- end}} {{- end}}
{{- if .SendClientInfo}}
pc_delete "add-mac" "$config_file" pc_delete "add-mac" "$config_file"
pc_delete "add-subnet" "$config_file" pc_delete "add-subnet" "$config_file"
pc_append "add-mac" "$config_file" # add client mac pc_append "add-mac" "$config_file" # add client mac
pc_append "add-subnet=32,128" "$config_file" # add client ip pc_append "add-subnet=32,128" "$config_file" # add client ip
{{- end}}
pc_delete "dnssec" "$config_file" # disable DNSSEC pc_delete "dnssec" "$config_file" # disable DNSSEC
pc_delete "trust-anchor=" "$config_file" # disable DNSSEC pc_delete "trust-anchor=" "$config_file" # disable DNSSEC
pc_delete "cache-size=" "$config_file"
pc_append "cache-size=0" "$config_file" # disable cache
# For John fork # For John fork
pc_delete "resolv-file" "$config_file" # no WAN DNS settings pc_delete "resolv-file" "$config_file" # no WAN DNS settings
@@ -64,7 +67,18 @@ type Upstream struct {
Port int Port int
} }
// ConfTmpl generates dnsmasq configuration from ctrld config.
func ConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) { func ConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) {
return ConfTmplWithCacheDisabled(tmplText, cfg, true)
}
// ConfTmplWithCacheDisabled is like ConfTmpl, but the caller can control whether
// dnsmasq cache is disabled using cacheDisabled parameter.
//
// Generally, the caller should use ConfTmpl, but on some routers which dnsmasq config may be changed
// after ctrld started (like EdgeOS/Ubios, Firewalla ...), dnsmasq cache should not be disabled because
// the cache-size=0 generated by ctrld will conflict with router's generated config.
func ConfTmplWithCacheDisabled(tmplText string, cfg *ctrld.Config, cacheDisabled bool) (string, error) {
listener := cfg.FirstListener() listener := cfg.FirstListener()
if listener == nil { if listener == nil {
return "", errors.New("missing listener") return "", errors.New("missing listener")
@@ -74,24 +88,27 @@ func ConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) {
ip = "127.0.0.1" ip = "127.0.0.1"
} }
upstreams := []Upstream{{IP: ip, Port: listener.Port}} upstreams := []Upstream{{IP: ip, Port: listener.Port}}
return confTmpl(tmplText, upstreams, cfg.HasUpstreamSendClientInfo()) return confTmpl(tmplText, upstreams, cacheDisabled)
} }
// FirewallaConfTmpl generates dnsmasq config for Firewalla routers.
func FirewallaConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) { func FirewallaConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) {
// If ctrld listen on all interfaces, generating config for all of them.
if lc := cfg.FirstListener(); lc != nil && (lc.IP == "0.0.0.0" || lc.IP == "") { if lc := cfg.FirstListener(); lc != nil && (lc.IP == "0.0.0.0" || lc.IP == "") {
return confTmpl(tmplText, firewallaUpstreams(lc.Port), cfg.HasUpstreamSendClientInfo()) return confTmpl(tmplText, firewallaUpstreams(lc.Port), false)
} }
return ConfTmpl(tmplText, cfg) // Otherwise, generating config for the specific listener from ctrld's config.
return ConfTmplWithCacheDisabled(tmplText, cfg, false)
} }
func confTmpl(tmplText string, upstreams []Upstream, sendClientInfo bool) (string, error) { func confTmpl(tmplText string, upstreams []Upstream, cacheDisabled bool) (string, error) {
tmpl := template.Must(template.New("").Parse(tmplText)) tmpl := template.Must(template.New("").Parse(tmplText))
var to = &struct { var to = &struct {
SendClientInfo bool
Upstreams []Upstream Upstreams []Upstream
CacheDisabled bool
}{ }{
SendClientInfo: sendClientInfo,
Upstreams: upstreams, Upstreams: upstreams,
CacheDisabled: cacheDisabled,
} }
var sb strings.Builder var sb strings.Builder
if err := tmpl.Execute(&sb, to); err != nil { if err := tmpl.Execute(&sb, to); err != nil {
@@ -117,9 +134,14 @@ func firewallaUpstreams(port int) []Upstream {
return upstreams return upstreams
} }
// firewallaDnsmasqConfFiles returns dnsmasq config files of all firewalla interfaces.
func firewallaDnsmasqConfFiles() ([]string, error) {
return filepath.Glob("/home/pi/firerouter/etc/dnsmasq.dns.*.conf")
}
// FirewallaSelfInterfaces returns list of interfaces that will be configured with default dnsmasq setup on Firewalla. // FirewallaSelfInterfaces returns list of interfaces that will be configured with default dnsmasq setup on Firewalla.
func FirewallaSelfInterfaces() []*net.Interface { func FirewallaSelfInterfaces() []*net.Interface {
matches, err := filepath.Glob("/home/pi/firerouter/etc/dnsmasq.dns.*.conf") matches, err := firewallaDnsmasqConfFiles()
if err != nil { if err != nil {
return nil return nil
} }
+24 -5
View File
@@ -8,10 +8,10 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" "github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld"
"github.com/kardianos/service" "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq"
) )
const ( const (
@@ -20,11 +20,15 @@ const (
usgDNSMasqConfigPath = "/etc/dnsmasq.conf" usgDNSMasqConfigPath = "/etc/dnsmasq.conf"
usgDNSMasqBackupConfigPath = "/etc/dnsmasq.conf.bak" usgDNSMasqBackupConfigPath = "/etc/dnsmasq.conf.bak"
toggleContentFilteringLink = "https://community.ui.com/questions/UDM-Pro-disable-enable-DNS-filtering/e2cc4060-e56a-4139-b200-62d7f773ff8f" toggleContentFilteringLink = "https://community.ui.com/questions/UDM-Pro-disable-enable-DNS-filtering/e2cc4060-e56a-4139-b200-62d7f773ff8f"
toggleDnsShieldLink = "https://community.ui.com/questions/UniFi-OS-3-2-7-DNS-Shield-Missing/d3a85905-4ce0-4fe4-8bf0-6cb04f21371d"
) )
var ErrContentFilteringEnabled = fmt.Errorf(`the "Content Filtering" feature" is enabled, which is conflicted with ctrld.\n var ErrContentFilteringEnabled = fmt.Errorf(`the "Content Filtering" feature" is enabled, which is conflicted with ctrld.\n
To disable it, folowing instruction here: %s`, toggleContentFilteringLink) To disable it, folowing instruction here: %s`, toggleContentFilteringLink)
var ErrDnsShieldEnabled = fmt.Errorf(`the "DNS Shield" feature" is enabled, which is conflicted with ctrld.\n
To disable it, folowing screenshot here: %s`, toggleDnsShieldLink)
type EdgeOS struct { type EdgeOS struct {
cfg *ctrld.Config cfg *ctrld.Config
isUSG bool isUSG bool
@@ -50,6 +54,11 @@ func (e *EdgeOS) Install(_ *service.Config) error {
if ContentFilteringEnabled() { if ContentFilteringEnabled() {
return ErrContentFilteringEnabled return ErrContentFilteringEnabled
} }
// If "DNS Shield" is enabled, UniFi OS will spawn dnscrypt-proxy process, and route all DNS queries to it. So
// reporting an error and guiding users to disable the feature using UniFi OS web UI.
if DnsShieldEnabled() {
return ErrDnsShieldEnabled
}
return nil return nil
} }
@@ -95,7 +104,7 @@ func (e *EdgeOS) setupUSG() error {
return fmt.Errorf("setupUSG: backup current config: %w", err) return fmt.Errorf("setupUSG: backup current config: %w", err)
} }
// Removing all configured upstreams. // Removing all configured upstreams and cache config.
var sb strings.Builder var sb strings.Builder
scanner := bufio.NewScanner(bytes.NewReader(buf)) scanner := bufio.NewScanner(bytes.NewReader(buf))
for scanner.Scan() { for scanner.Scan() {
@@ -109,7 +118,7 @@ func (e *EdgeOS) setupUSG() error {
sb.WriteString(line) sb.WriteString(line)
} }
data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, e.cfg) data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
if err != nil { if err != nil {
return err return err
} }
@@ -127,7 +136,7 @@ func (e *EdgeOS) setupUSG() error {
} }
func (e *EdgeOS) setupUDM() error { func (e *EdgeOS) setupUDM() error {
data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, e.cfg) data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, e.cfg, false)
if err != nil { if err != nil {
return err return err
} }
@@ -169,6 +178,16 @@ func ContentFilteringEnabled() bool {
return err == nil && !st.IsDir() return err == nil && !st.IsDir()
} }
// DnsShieldEnabled reports whether DNS Shield is enabled.
// See: https://community.ui.com/releases/UniFi-OS-Dream-Machines-3-2-7/251dfc1e-f4dd-4264-a080-3be9d8b9e02b
func DnsShieldEnabled() bool {
buf, err := os.ReadFile("/var/run/dnsmasq.conf.d/dns.conf")
if err != nil {
return false
}
return bytes.Contains(buf, []byte("server=127.0.0.1#5053"))
}
func LeaseFileDir() string { func LeaseFileDir() string {
if checkUSG() { if checkUSG() {
return "" return ""
+27 -4
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"time"
"unicode" "unicode"
"github.com/kardianos/service" "github.com/kardianos/service"
@@ -44,8 +45,24 @@ func (m *Merlin) Uninstall(_ *service.Config) error {
} }
func (m *Merlin) PreRun() error { func (m *Merlin) PreRun() error {
// Wait NTP ready.
_ = m.Cleanup() _ = m.Cleanup()
return ntp.WaitNvram() if err := ntp.WaitNvram(); err != nil {
return err
}
// Wait until directories mounted.
for _, dir := range []string{"/tmp", "/proc"} {
waitDirExists(dir)
}
// Wait dnsmasq started.
for {
out, _ := exec.Command("pidof", "dnsmasq").CombinedOutput()
if len(bytes.TrimSpace(out)) > 0 {
break
}
time.Sleep(time.Second)
}
return nil
} }
func (m *Merlin) Setup() error { func (m *Merlin) Setup() error {
@@ -56,9 +73,6 @@ func (m *Merlin) Setup() error {
if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" { if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" {
return nil return nil
} }
if _, err := nvram.Run("set", nvram.CtrldSetupKey+"=1"); err != nil {
return err
}
buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath)
// Already setup. // Already setup.
if bytes.Contains(buf, []byte(dnsmasq.MerlinPostConfMarker)) { if bytes.Contains(buf, []byte(dnsmasq.MerlinPostConfMarker)) {
@@ -140,3 +154,12 @@ func merlinParsePostConf(buf []byte) []byte {
} }
return buf return buf
} }
func waitDirExists(dir string) {
for {
if _, err := os.Stat(dir); !os.IsNotExist(err) {
return
}
time.Sleep(time.Second)
}
}
+29 -8
View File
@@ -8,11 +8,10 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq"
"github.com/kardianos/service" "github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq"
) )
const ( const (
@@ -20,10 +19,9 @@ const (
openwrtDNSMasqConfigPath = "/tmp/dnsmasq.d/ctrld.conf" openwrtDNSMasqConfigPath = "/tmp/dnsmasq.d/ctrld.conf"
) )
var errUCIEntryNotFound = errors.New("uci: Entry not found")
type Openwrt struct { type Openwrt struct {
cfg *ctrld.Config cfg *ctrld.Config
dnsmasqCacheSize string
} }
// New returns a router.Router for configuring/setup/run ctrld on Openwrt routers. // New returns a router.Router for configuring/setup/run ctrld on Openwrt routers.
@@ -52,6 +50,19 @@ func (o *Openwrt) Setup() error {
if o.cfg.FirstListener().IsDirectDnsListener() { if o.cfg.FirstListener().IsDirectDnsListener() {
return nil return nil
} }
// Save current dnsmasq config cache size if present.
if cs, err := uci("get", "dhcp.@dnsmasq[0].cachesize"); err == nil {
o.dnsmasqCacheSize = cs
if _, err := uci("delete", "dhcp.@dnsmasq[0].cachesize"); err != nil {
return err
}
// Commit.
if _, err := uci("commit", "dhcp"); err != nil {
return err
}
}
data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, o.cfg) data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, o.cfg)
if err != nil { if err != nil {
return err return err
@@ -59,10 +70,6 @@ func (o *Openwrt) Setup() error {
if err := os.WriteFile(openwrtDNSMasqConfigPath, []byte(data), 0600); err != nil { if err := os.WriteFile(openwrtDNSMasqConfigPath, []byte(data), 0600); err != nil {
return err return err
} }
// Commit.
if _, err := uci("commit"); err != nil {
return err
}
// Restart dnsmasq service. // Restart dnsmasq service.
if err := restartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
@@ -78,6 +85,18 @@ func (o *Openwrt) Cleanup() error {
if err := os.Remove(openwrtDNSMasqConfigPath); err != nil { if err := os.Remove(openwrtDNSMasqConfigPath); err != nil {
return err return err
} }
// Restore original value if present.
if o.dnsmasqCacheSize != "" {
if _, err := uci("set", fmt.Sprintf("dhcp.@dnsmasq[0].cachesize=%s", o.dnsmasqCacheSize)); err != nil {
return err
}
// Commit.
if _, err := uci("commit", "dhcp"); err != nil {
return err
}
}
// Restart dnsmasq service. // Restart dnsmasq service.
if err := restartDNSMasq(); err != nil { if err := restartDNSMasq(); err != nil {
return err return err
@@ -92,6 +111,8 @@ func restartDNSMasq() error {
return nil return nil
} }
var errUCIEntryNotFound = errors.New("uci: Entry not found")
func uci(args ...string) (string, error) { func uci(args ...string) (string, error) {
cmd := exec.Command("uci", args...) cmd := exec.Command("uci", args...)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
+1 -12
View File
@@ -137,20 +137,9 @@ rcvar="${name}_enable"
pidfile="/var/run/${name}.pid" pidfile="/var/run/${name}.pid"
child_pidfile="/var/run/${name}_child.pid" child_pidfile="/var/run/${name}_child.pid"
command="/usr/sbin/daemon" command="/usr/sbin/daemon"
daemon_args="-P ${pidfile} -p ${child_pidfile} -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}" daemon_args="-r -P ${pidfile} -p ${child_pidfile} -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}"
command_args="${daemon_args} {{.Path}}{{range .Arguments}} {{.}}{{end}}" command_args="${daemon_args} {{.Path}}{{range .Arguments}} {{.}}{{end}}"
stop_cmd="ctrld_stop"
ctrld_stop() {
pid=$(cat ${pidfile})
child_pid=$(cat ${child_pidfile})
if [ -e "${child_pidfile}" ]; then
kill -s TERM "${child_pid}"
wait_for_pids "${child_pid}" "${pidfile}"
fi
}
load_rc_config "${name}" load_rc_config "${name}"
run_rc_command "$1" run_rc_command "$1"
` `
+8 -3
View File
@@ -5,16 +5,17 @@ import (
"os" "os"
"strconv" "strconv"
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" "github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq"
"github.com/Control-D-Inc/ctrld/internal/router/edgeos" "github.com/Control-D-Inc/ctrld/internal/router/edgeos"
"github.com/kardianos/service"
) )
const ( const (
Name = "ubios" Name = "ubios"
ubiosDNSMasqConfigPath = "/run/dnsmasq.conf.d/zzzctrld.conf" ubiosDNSMasqConfigPath = "/run/dnsmasq.conf.d/zzzctrld.conf"
ubiosDNSMasqDnsConfigPath = "/run/dnsmasq.conf.d/dns.conf"
) )
type Ubios struct { type Ubios struct {
@@ -35,6 +36,10 @@ func (u *Ubios) Install(config *service.Config) error {
if edgeos.ContentFilteringEnabled() { if edgeos.ContentFilteringEnabled() {
return edgeos.ErrContentFilteringEnabled return edgeos.ErrContentFilteringEnabled
} }
// See comment in (*edgeos.EdgeOS).Install method.
if edgeos.DnsShieldEnabled() {
return edgeos.ErrDnsShieldEnabled
}
return nil return nil
} }
@@ -50,7 +55,7 @@ func (u *Ubios) Setup() error {
if u.cfg.FirstListener().IsDirectDnsListener() { if u.cfg.FirstListener().IsDirectDnsListener() {
return nil return nil
} }
data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, u.cfg) data, err := dnsmasq.ConfTmplWithCacheDisabled(dnsmasq.ConfigContentTmpl, u.cfg, false)
if err != nil { if err != nil {
return err return err
} }
+11 -8
View File
@@ -2,13 +2,10 @@ package ctrld
import ( import (
"context" "context"
"errors"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"tailscale.com/logtail/backoff"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
) )
@@ -17,22 +14,28 @@ var (
ipv6Available atomic.Bool ipv6Available atomic.Bool
) )
const ipv6ProbingInterval = 10 * time.Second
func hasIPv6() bool { func hasIPv6() bool {
hasIPv6Once.Do(func() { hasIPv6Once.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
val := ctrldnet.IPv6Available(ctx) val := ctrldnet.IPv6Available(ctx)
ipv6Available.Store(val) ipv6Available.Store(val)
go probingIPv6(val) go probingIPv6(context.TODO(), val)
}) })
return ipv6Available.Load() return ipv6Available.Load()
} }
// TODO(cuonglm): doing poll check natively for supported platforms. // TODO(cuonglm): doing poll check natively for supported platforms.
func probingIPv6(old bool) { func probingIPv6(ctx context.Context, old bool) {
b := backoff.NewBackoff("probingIPv6", func(format string, args ...any) {}, 30*time.Second) ticker := time.NewTicker(ipv6ProbingInterval)
bCtx := context.Background() defer ticker.Stop()
for { for {
select {
case <-ctx.Done():
return
case <-ticker.C:
func() { func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
@@ -41,6 +44,6 @@ func probingIPv6(old bool) {
old = cur old = cur
} }
}() }()
b.BackOff(bCtx, errors.New("no change")) }
} }
} }
+46 -7
View File
@@ -5,10 +5,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/netip"
"sync" "sync"
"time" "time"
"github.com/miekg/dns" "github.com/miekg/dns"
"tailscale.com/net/interfaces"
) )
const ( const (
@@ -24,9 +26,11 @@ const (
ResolverTypeOS = "os" ResolverTypeOS = "os"
// ResolverTypeLegacy specifies legacy resolver. // ResolverTypeLegacy specifies legacy resolver.
ResolverTypeLegacy = "legacy" ResolverTypeLegacy = "legacy"
// ResolverTypePrivate is like ResolverTypeOS, but use for local resolver only.
ResolverTypePrivate = "private"
) )
var bootstrapDNS = "76.76.2.0" const bootstrapDNS = "76.76.2.22"
// or is the Resolver used for ResolverTypeOS. // or is the Resolver used for ResolverTypeOS.
var or = &osResolver{nameservers: defaultNameservers()} var or = &osResolver{nameservers: defaultNameservers()}
@@ -61,6 +65,8 @@ func NewResolver(uc *UpstreamConfig) (Resolver, error) {
return or, nil return or, nil
case ResolverTypeLegacy: case ResolverTypeLegacy:
return &legacyResolver{uc: uc}, nil return &legacyResolver{uc: uc}, nil
case ResolverTypePrivate:
return NewPrivateResolver(), nil
} }
return nil, fmt.Errorf("%w: %s", errUnknownResolver, typ) return nil, fmt.Errorf("%w: %s", errUnknownResolver, typ)
} }
@@ -74,8 +80,9 @@ type osResolverResult struct {
err error err error
} }
// Resolve performs DNS resolvers using OS default nameservers. Nameserver is chosen from // Resolve resolves DNS queries using pre-configured nameservers.
// available nameservers with a roundrobin algorithm. // Query is sent to all nameservers concurrently, and the first
// success response will be returned.
func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
numServers := len(o.nameservers) numServers := len(o.nameservers)
if numServers == 0 { if numServers == 0 {
@@ -240,12 +247,16 @@ func NewBootstrapResolver(servers ...string) Resolver {
} }
// NewPrivateResolver returns an OS resolver, which includes only private DNS servers, // NewPrivateResolver returns an OS resolver, which includes only private DNS servers,
// excluding nameservers from /etc/resolv.conf file. // excluding:
//
// - Nameservers from /etc/resolv.conf file.
// - Nameservers which is local RFC1918 addresses.
// //
// This is useful for doing PTR lookup in LAN network. // This is useful for doing PTR lookup in LAN network.
func NewPrivateResolver() Resolver { func NewPrivateResolver() Resolver {
nss := nameservers() nss := nameservers()
resolveConfNss := nameserversFromResolvconf() resolveConfNss := nameserversFromResolvconf()
localRfc1918Addrs := Rfc1918Addresses()
n := 0 n := 0
for _, ns := range nss { for _, ns := range nss {
host, _, _ := net.SplitHostPort(ns) host, _, _ := net.SplitHostPort(ns)
@@ -258,6 +269,10 @@ func NewPrivateResolver() Resolver {
if sliceContains(resolveConfNss, host) { if sliceContains(resolveConfNss, host) {
continue continue
} }
// Ignoring local RFC 1918 addresses.
if sliceContains(localRfc1918Addrs, host) {
continue
}
ip := net.ParseIP(host) ip := net.ParseIP(host)
if ip != nil && ip.IsPrivate() && !ip.IsLoopback() { if ip != nil && ip.IsPrivate() && !ip.IsLoopback() {
nss[n] = ns nss[n] = ns
@@ -265,11 +280,35 @@ func NewPrivateResolver() Resolver {
} }
} }
nss = nss[:n] nss = nss[:n]
if len(nss) == 0 { return NewResolverWithNameserver(nss)
}
// NewResolverWithNameserver returns an OS resolver which uses the given nameservers
// for resolving DNS queries. If nameservers is empty, a dummy resolver will be returned.
//
// Each nameserver must be form "host:port". It's the caller responsibility to ensure all
// nameservers are well formatted by using net.JoinHostPort function.
func NewResolverWithNameserver(nameservers []string) Resolver {
if len(nameservers) == 0 {
return &dummyResolver{} return &dummyResolver{}
} }
resolver := &osResolver{nameservers: nss} return &osResolver{nameservers: nameservers}
return resolver }
// Rfc1918Addresses returns the list of local interfaces private IP addresses
func Rfc1918Addresses() []string {
var res []string
interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) {
addrs, _ := i.Addrs()
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || !ipNet.IP.IsPrivate() {
continue
}
res = append(res, ipNet.IP.String())
}
})
return res
} }
func newDialer(dnsAddress string) *net.Dialer { func newDialer(dnsAddress string) *net.Dialer {
+4
View File
@@ -82,4 +82,8 @@ rules = [
{"*.ru" = ["upstream.1"]}, {"*.ru" = ["upstream.1"]},
{"*.local.host" = ["upstream.2", "upstream.0"]}, {"*.local.host" = ["upstream.2", "upstream.0"]},
] ]
macs = [
{"14:45:A0:67:83:0A" = ["upstream.2"]},
{"14:54:4a:8e:08:2d" = ["upstream.2"]},
]
` `