mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-02-03 22:18:39 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924304a13d | ||
|
|
0240f7ab15 | ||
|
|
64dff35143 | ||
|
|
d2c47ba523 | ||
|
|
ccada70e31 | ||
|
|
fe0faac8c4 | ||
|
|
bb51a40166 | ||
|
|
d42ee31a7c | ||
|
|
0556825a11 | ||
|
|
b2a6f18a1c |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Control D Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
31
README.md
31
README.md
@@ -15,29 +15,29 @@ All DNS protocols are supported, including:
|
||||
## Use Cases
|
||||
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy routers, legacy OSes, TVs, smart toasters).
|
||||
2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B.
|
||||
3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
|
||||
3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
|
||||
4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream.
|
||||
|
||||
|
||||
## OS Support
|
||||
- Windows (386, amd64, arm)
|
||||
- Mac (amd64, arm)
|
||||
- Mac (amd64, arm64)
|
||||
- Linux (386, amd64, arm, mips)
|
||||
|
||||
## Download
|
||||
Download pre-compiled binaries from the [Releases](#) section.
|
||||
Download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section.
|
||||
|
||||
## Build
|
||||
`ctrld` requires `go1.19+`:
|
||||
|
||||
```shell
|
||||
$ go build
|
||||
$ go build ./cmd/ctrld
|
||||
```
|
||||
|
||||
or
|
||||
or
|
||||
|
||||
```shell
|
||||
$ go install <path_to_repo>
|
||||
$ go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest
|
||||
```
|
||||
|
||||
## Arguments
|
||||
@@ -47,19 +47,18 @@ Usage:
|
||||
|
||||
Available Commands:
|
||||
help Help about any command
|
||||
interfaces Manage Interface DNS settings
|
||||
run Run the DNS proxy server
|
||||
|
||||
Flags:
|
||||
-h, --help help for ctrld
|
||||
-j, --json json output
|
||||
-v, --verbose verbose log output
|
||||
--version version for ctrld
|
||||
|
||||
Use "ctrld [command] --help" for more information about a command.
|
||||
```
|
||||
|
||||
## Usage
|
||||
To start the server with default configuration, simply run: `ctrld run`. This will create a generic `config.toml` file in the working directory and start the service.
|
||||
To start the server with default configuration, simply run: `ctrld run`. This will create a generic `config.toml` file in the working directory and start the service.
|
||||
1. Start the server
|
||||
```
|
||||
$ sudo ./ctrld run
|
||||
@@ -76,13 +75,13 @@ If `verify.controld.com` resolves, you're successfully using the default Control
|
||||
|
||||
|
||||
## Configuration
|
||||
### Example
|
||||
### Example
|
||||
- Start `listener.0` on 127.0.0.1:53
|
||||
- Accept queries from any source address
|
||||
- Send all queries to `upstream.0` via DoH protocol
|
||||
|
||||
### Default Config
|
||||
```toml
|
||||
```toml
|
||||
[listener]
|
||||
|
||||
[listener.0]
|
||||
@@ -118,15 +117,15 @@ If `verify.controld.com` resolves, you're successfully using the default Control
|
||||
|
||||
```
|
||||
|
||||
### Advanced
|
||||
The above is the most basic example, which will work out of the box. If you're looking to do advanced configurations using policies, see [Configuration Docs](docs/config.md) for complete documentation of the config file.
|
||||
### Advanced
|
||||
The above is the most basic example, which will work out of the box. If you're looking to do advanced configurations using policies, see [Configuration Docs](docs/config.md) for complete documentation of the config file.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [Contribution Guideline](./docs/contributing.md)
|
||||
|
||||
## Roadmap
|
||||
The following functionality is on the roadmap and will be available in future releases.
|
||||
- Prometheus metrics exporter
|
||||
The following functionality is on the roadmap and will be available in future releases.
|
||||
- Prometheus metrics exporter
|
||||
- Local caching
|
||||
- Service self-installation
|
||||
- Service self-installation
|
||||
|
||||
@@ -7,10 +7,13 @@ import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/kardianos/service"
|
||||
"github.com/pelletier/go-toml"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,7 +29,7 @@ func initCLI() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "ctrld",
|
||||
Short: "Running Control-D DNS proxy server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.1",
|
||||
}
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose log output")
|
||||
|
||||
@@ -52,6 +55,9 @@ func initCLI() {
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
log.Fatalf("failed to unmarshal config: %v", err)
|
||||
}
|
||||
if err := ctrld.ValidateConfig(validator.New(), &cfg); err != nil {
|
||||
log.Fatalf("invalid config: %v", err)
|
||||
}
|
||||
initLogging()
|
||||
if daemon {
|
||||
exe, err := os.Executable()
|
||||
|
||||
@@ -22,7 +22,10 @@ func (p *prog) serveUDP(listenerNum string) error {
|
||||
mainLog.Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip")
|
||||
return allocErr
|
||||
}
|
||||
|
||||
var failoverRcodes []int
|
||||
if listenerConfig.Policy != nil {
|
||||
failoverRcodes = listenerConfig.Policy.FailoverRcodeNumbers
|
||||
}
|
||||
handler := dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) {
|
||||
domain := canonicalName(m.Question[0].Name)
|
||||
reqId := requestID()
|
||||
@@ -37,7 +40,7 @@ func (p *prog) serveUDP(listenerNum string) error {
|
||||
answer.SetRcode(m, dns.RcodeRefused)
|
||||
|
||||
} else {
|
||||
answer = p.proxy(ctx, upstreams, m)
|
||||
answer = p.proxy(ctx, upstreams, failoverRcodes, m)
|
||||
rtt := time.Since(t)
|
||||
ctrld.Log(ctx, proxyLog.Debug(), "received response of %d bytes in %s", answer.Len(), rtt)
|
||||
}
|
||||
@@ -119,7 +122,7 @@ func (p *prog) upstreamFor(ctx context.Context, defaultUpstreamNum string, lc *c
|
||||
return upstreams, matched
|
||||
}
|
||||
|
||||
func (p *prog) proxy(ctx context.Context, upstreams []string, msg *dns.Msg) *dns.Msg {
|
||||
func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []int, msg *dns.Msg) *dns.Msg {
|
||||
upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams)
|
||||
resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg {
|
||||
ctrld.Log(ctx, proxyLog.Debug(), "sending query to %s: %s", upstreams[n], upstreamConfig.Name)
|
||||
@@ -128,12 +131,14 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, msg *dns.Msg) *dns
|
||||
ctrld.Log(ctx, proxyLog.Error().Err(err), "failed to create resolver")
|
||||
return nil
|
||||
}
|
||||
resolveCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
if upstreamConfig.Timeout > 0 {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, time.Millisecond*time.Duration(upstreamConfig.Timeout))
|
||||
timeoutCtx, cancel := context.WithTimeout(resolveCtx, time.Millisecond*time.Duration(upstreamConfig.Timeout))
|
||||
defer cancel()
|
||||
ctx = timeoutCtx
|
||||
resolveCtx = timeoutCtx
|
||||
}
|
||||
answer, err := dnsResolver.Resolve(ctx, msg)
|
||||
answer, err := dnsResolver.Resolve(resolveCtx, msg)
|
||||
if err != nil {
|
||||
ctrld.Log(ctx, proxyLog.Error().Err(err), "failed to resolve query")
|
||||
return nil
|
||||
@@ -141,9 +146,15 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, msg *dns.Msg) *dns
|
||||
return answer
|
||||
}
|
||||
for n, upstreamConfig := range upstreamConfigs {
|
||||
if answer := resolve(n, upstreamConfig, msg); answer != nil {
|
||||
return answer
|
||||
answer := resolve(n, upstreamConfig, msg)
|
||||
if answer == nil {
|
||||
continue
|
||||
}
|
||||
if answer.Rcode != dns.RcodeSuccess && len(upstreamConfigs) > 1 && containRcode(failoverRcodes, answer.Rcode) {
|
||||
ctrld.Log(ctx, proxyLog.Debug(), "failover rcode matched, process to next upstream")
|
||||
continue
|
||||
}
|
||||
return answer
|
||||
}
|
||||
ctrld.Log(ctx, proxyLog.Error(), "all upstreams failed")
|
||||
answer := new(dns.Msg)
|
||||
@@ -151,6 +162,18 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, msg *dns.Msg) *dns
|
||||
return answer
|
||||
}
|
||||
|
||||
func (p *prog) upstreamConfigsFromUpstreamNumbers(upstreams []string) []*ctrld.UpstreamConfig {
|
||||
upstreamConfigs := make([]*ctrld.UpstreamConfig, 0, len(upstreams))
|
||||
for _, upstream := range upstreams {
|
||||
upstreamNum := strings.TrimPrefix(upstream, "upstream.")
|
||||
upstreamConfigs = append(upstreamConfigs, p.cfg.Upstream[upstreamNum])
|
||||
}
|
||||
if len(upstreamConfigs) == 0 {
|
||||
upstreamConfigs = []*ctrld.UpstreamConfig{osUpstreamConfig}
|
||||
}
|
||||
return upstreamConfigs
|
||||
}
|
||||
|
||||
// canonicalName returns canonical name from FQDN with "." trimmed.
|
||||
func canonicalName(fqdn string) string {
|
||||
q := strings.TrimSpace(fqdn)
|
||||
@@ -189,18 +212,6 @@ func fmtRemoteToLocal(listenerNum, remote, local string) string {
|
||||
return fmt.Sprintf("%s -> listener.%s: %s:", remote, listenerNum, local)
|
||||
}
|
||||
|
||||
func (p *prog) upstreamConfigsFromUpstreamNumbers(upstreams []string) []*ctrld.UpstreamConfig {
|
||||
upstreamConfigs := make([]*ctrld.UpstreamConfig, 0, len(upstreams))
|
||||
for _, upstream := range upstreams {
|
||||
upstreamNum := strings.TrimPrefix(upstream, "upstream.")
|
||||
upstreamConfigs = append(upstreamConfigs, p.cfg.Upstream[upstreamNum])
|
||||
}
|
||||
if len(upstreamConfigs) == 0 {
|
||||
upstreamConfigs = []*ctrld.UpstreamConfig{osUpstreamConfig}
|
||||
}
|
||||
return upstreamConfigs
|
||||
}
|
||||
|
||||
func requestID() string {
|
||||
b := make([]byte, 3) // 6 chars
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -209,6 +220,15 @@ func requestID() string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func containRcode(rcodes []int, rcode int) bool {
|
||||
for i := range rcodes {
|
||||
if rcodes[i] == rcode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var osUpstreamConfig = &ctrld.UpstreamConfig{
|
||||
Name: "OS resolver",
|
||||
Type: "os",
|
||||
|
||||
@@ -74,6 +74,7 @@ func (p *prog) run() {
|
||||
}
|
||||
|
||||
for listenerNum := range p.cfg.Listener {
|
||||
p.cfg.Listener[listenerNum].Init()
|
||||
go func(listenerNum string) {
|
||||
defer wg.Done()
|
||||
listenerConfig := p.cfg.Listener[listenerNum]
|
||||
|
||||
24
config.go
24
config.go
@@ -5,6 +5,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/dnsrcode"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -92,9 +93,11 @@ type ListenerConfig struct {
|
||||
|
||||
// ListenerPolicyConfig specifies the policy rules for ctrld to filter incoming requests.
|
||||
type ListenerPolicyConfig struct {
|
||||
Name string `mapstructure:"name" toml:"name"`
|
||||
Networks []Rule `mapstructure:"networks" toml:"networks" validate:"dive,len=1"`
|
||||
Rules []Rule `mapstructure:"rules" toml:"rules" validate:"dive,len=1"`
|
||||
Name string `mapstructure:"name" toml:"name"`
|
||||
Networks []Rule `mapstructure:"networks" toml:"networks" validate:"dive,len=1"`
|
||||
Rules []Rule `mapstructure:"rules" toml:"rules" validate:"dive,len=1"`
|
||||
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes" validate:"dive,dnsrcode"`
|
||||
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
|
||||
}
|
||||
|
||||
// Rule is a map from source to list of upstreams.
|
||||
@@ -122,11 +125,26 @@ func (uc *UpstreamConfig) Init() {
|
||||
}
|
||||
}
|
||||
|
||||
// Init initialized necessary values for an ListenerConfig.
|
||||
func (lc *ListenerConfig) Init() {
|
||||
if lc.Policy != nil {
|
||||
lc.Policy.FailoverRcodeNumbers = make([]int, len(lc.Policy.FailoverRcodes))
|
||||
for i, rcode := range lc.Policy.FailoverRcodes {
|
||||
lc.Policy.FailoverRcodeNumbers[i] = dnsrcode.FromString(rcode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig validates the given config.
|
||||
func ValidateConfig(validate *validator.Validate, cfg *Config) error {
|
||||
_ = validate.RegisterValidation("dnsrcode", validateDnsRcode)
|
||||
return validate.Struct(cfg)
|
||||
}
|
||||
|
||||
func validateDnsRcode(fl validator.FieldLevel) bool {
|
||||
return dnsrcode.FromString(fl.Field().String()) != -1
|
||||
}
|
||||
|
||||
func defaultPortFor(typ string) string {
|
||||
switch typ {
|
||||
case resolverTypeDOH, resolverTypeDOH3:
|
||||
|
||||
@@ -69,6 +69,7 @@ func TestConfigValidation(t *testing.T) {
|
||||
{"invalid listener port", invalidListenerPort(t), true},
|
||||
{"os upstream", configWithOsUpstream(t), false},
|
||||
{"invalid rules", configWithInvalidRules(t), true},
|
||||
{"invalid dns rcodes", configWithInvalidRcodes(t), true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -155,6 +156,16 @@ func configWithInvalidRules(t *testing.T) *ctrld.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func configWithInvalidRcodes(t *testing.T) *ctrld.Config {
|
||||
cfg := defaultConfig(t)
|
||||
cfg.Listener["0"].Policy = &ctrld.ListenerPolicyConfig{
|
||||
Name: "Policy with invalid Rcodes",
|
||||
Networks: []ctrld.Rule{{"*.com": []string{"upstream.0"}}},
|
||||
FailoverRcodes: []string{"foo"},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestUpstreamConfig_Init(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -300,4 +300,21 @@ Above policy will:
|
||||
|
||||
- type: array of rule
|
||||
|
||||
### failover_rcodes
|
||||
For non success response, `failover_rcodes` allows the request to be forwarded to next upstream, if the response `RCODE` matches any value defined in `failover_rcodes`. For example:
|
||||
|
||||
```toml
|
||||
[listener.0.policy]
|
||||
name = "My Policy"
|
||||
failover_rcodes = ["NXDOMAIN", "SERVFAIL"]
|
||||
networks = [
|
||||
{"network.0" = ["upstream.0", "upstream.1"]},
|
||||
]
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
[toml_link]: https://toml.io/en
|
||||
[rcode_link]: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
|
||||
|
||||
39
internal/dnsrcode/rcode.go
Normal file
39
internal/dnsrcode/rcode.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package dnsrcode
|
||||
|
||||
import "strings"
|
||||
|
||||
// https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
|
||||
var dnsRcode = map[string]int{
|
||||
"NOERROR": 0, // NoError - No Error
|
||||
"FORMERR": 1, // FormErr - Format Error
|
||||
"SERVFAIL": 2, // ServFail - Server Failure
|
||||
"NXDOMAIN": 3, // NXDomain - Non-Existent Domain
|
||||
"NOTIMP": 4, // NotImp - Not Implemented
|
||||
"REFUSED": 5, // Refused - Query Refused
|
||||
"YXDOMAIN": 6, // YXDomain - Name Exists when it should not
|
||||
"YXRRSET": 7, // YXRRSet - RR Set Exists when it should not
|
||||
"NXRRSET": 8, // NXRRSet - RR Set that should exist does not
|
||||
"NOTAUTH": 9, // NotAuth - Server Not Authoritative for zone
|
||||
"NOTZONE": 10, // NotZone - Name not contained in zone
|
||||
"BADSIG": 16, // BADSIG - TSIG Signature Failure
|
||||
"BADVERS": 16, // BADVERS - Bad OPT Version
|
||||
"BADKEY": 17, // BADKEY - Key not recognized
|
||||
"BADTIME": 18, // BADTIME - Signature out of time window
|
||||
"BADMODE": 19, // BADMODE - Bad TKEY Mode
|
||||
"BADNAME": 20, // BADNAME - Duplicate key name
|
||||
"BADALG": 21, // BADALG - Algorithm not supported
|
||||
"BADTRUNC": 22, // BADTRUNC - Bad Truncation
|
||||
"BADCOOKIE": 23, // BADCOOKIE - Bad/missing Server Cookie
|
||||
}
|
||||
|
||||
// FromString returns the DNS Rcode number from given DNS Rcode string.
|
||||
// The string value is treated as case-insensitive. If the input string
|
||||
// is an invalid DNS Rcode, -1 is returned.
|
||||
func FromString(rcode string) int {
|
||||
rcode = strings.ToUpper(rcode)
|
||||
val, ok := dnsRcode[rcode]
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
return val
|
||||
}
|
||||
29
internal/dnsrcode/rcode_test.go
Normal file
29
internal/dnsrcode/rcode_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package dnsrcode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFromString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rcode string
|
||||
expectedRcode int
|
||||
}{
|
||||
{"valid", "NoError", 0},
|
||||
{"upper", "NOERROR", 0},
|
||||
{"lower", "noerror", 0},
|
||||
{"mix", "nOeRrOr", 0},
|
||||
{"invalid", "foo", -1},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.expectedRcode, FromString(tc.rcode))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ port = 1337
|
||||
|
||||
[listener.0.policy]
|
||||
name = "My Policy"
|
||||
failover_rcodes = ["NXDOMAIN", "SERVFAIL"]
|
||||
networks = [
|
||||
{"network.0" = ["upstream.1", "upstream.0"]},
|
||||
{"network.1" = ["upstream.0"]},
|
||||
|
||||
Reference in New Issue
Block a user