Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support CNAME records in customDNS mappings #1352

Merged
merged 41 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
25eca1f
Support CNAME: mappings in customDNS.mapping
BenMcH Jan 26, 2024
fc1dd6f
Make custom_dns more easily extensible for other dns types
BenMcH Jan 26, 2024
db6e8d6
Don't recreate function while resolving custom dns
BenMcH Jan 26, 2024
f9cbf42
Update tests to expect an array of dns.RR
BenMcH Jan 26, 2024
e1a52d3
Add cname example to sample config
BenMcH Jan 26, 2024
cf47cc9
Add cname example to configuration.md
BenMcH Jan 26, 2024
8dee017
Removed confusing yaml tag from CustomDNSMapping
BenMcH Jan 26, 2024
858cd2f
Restructured custom dns resolver to align with pull request comments
BenMcH Jan 26, 2024
f9fd567
Added CNAME documentation and loop warning to configuration.md
BenMcH Jan 26, 2024
140f157
Added CNAME mention to parameter table
BenMcH Jan 26, 2024
471e20e
Adds error responses to custom resolver
BenMcH Jan 27, 2024
2e21a55
Directly pass IP values rather than converting to string and back to …
BenMcH Jan 27, 2024
3a62b4f
Adds error check to processIP calls
BenMcH Jan 27, 2024
27e0da6
Recursively resolve custom CNAMES for AAAA records
BenMcH Jan 27, 2024
ffa3418
Adds root context for dns resolution timeout, set to 100 times the up…
BenMcH Jan 27, 2024
4dbb650
Convert CustomDNSMapping from a struct to a map[string]CustomDNSEntries
BenMcH Jan 27, 2024
585ff69
Fix linting errors
BenMcH Jan 27, 2024
01ba8c5
Fixed unmarshall test
BenMcH Jan 27, 2024
00c4b98
Corrected reverse dns logic
BenMcH Jan 27, 2024
cc17580
Add tests to cover custom CNAME resolution
BenMcH Jan 27, 2024
60e57c6
Test unsupported query type
BenMcH Jan 27, 2024
a3586b6
Test when the parent context has been cancelled
BenMcH Jan 27, 2024
1e3c86d
Forward request error test
BenMcH Jan 27, 2024
ec18118
Add test to cover ipv6 forward resolution failure
BenMcH Jan 28, 2024
cbf2123
Fix the ipv6 error test
BenMcH Jan 28, 2024
013dcba
Test that a malformed IPv6 address throws an error
BenMcH Jan 28, 2024
1dd858a
Merge branch 'main' into custom-record-types
BenMcH Jan 28, 2024
b010d0c
Use the CustomDNSMapping type in the custom DNS resolver
BenMcH Jan 28, 2024
7743666
Adds a test to cover the last missing line in codecov for answer crea…
BenMcH Jan 28, 2024
24b480d
Don't export the createAnswerFunc; Instead just export a setter
BenMcH Jan 28, 2024
c01650a
Add check to ensure recursive CNAMEs exit early
BenMcH Jan 28, 2024
411ea79
Updated configuration.md to indicate that we are now checking for cna…
BenMcH Jan 28, 2024
f30fccb
Fixed linter warnings :shirt:
BenMcH Jan 28, 2024
e32fd9e
Remove blocky artifact and add to gitignore
BenMcH Jan 28, 2024
a02ca9d
Implemented majority of code review comments
BenMcH Jan 28, 2024
ba2c317
Correct gitignore
BenMcH Jan 28, 2024
6262cdb
Only recursively resolve CNAMES conditionally based on the question type
BenMcH Jan 28, 2024
7f72b97
Prevent blocky from starting if there are more than one values in the…
BenMcH Jan 28, 2024
234d34d
Linting fixes
BenMcH Jan 28, 2024
0c8f302
Addressing code review comments
BenMcH Jan 28, 2024
cf2db7b
Merge branch 'main' into custom-record-types
BenMcH Jan 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ vendor/
coverage.html
coverage.txt
coverage/
blocky
35 changes: 29 additions & 6 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,17 @@ blocking:
Expect(err.Error()).Should(ContainSubstring("invalid IP address '192.168.178.WRONG'"))
})
})
When("CustomDNS hast wrong IPv6 defined", func() {
It("should return error", func() {
cfg := Config{}
data := `customDNS:
mapping:
someDomain: 2001:MALFORMED:IP:ADDRESS:0000:8a2e:0370:7334`
err := unmarshalConfig(logger, []byte(data), &cfg)
Expect(err).Should(HaveOccurred())
Expect(err.Error()).Should(ContainSubstring("invalid IP address '2001:MALFORMED:IP:ADDRESS:0000:8a2e:0370:7334'"))
})
})
When("Conditional mapping hast wrong defined upstreams", func() {
It("should return error", func() {
cfg := Config{}
Expand Down Expand Up @@ -866,12 +877,24 @@ func defaultTestFileConfig(config *Config) {
Expect(config.Upstreams.Groups["default"][0].Host).Should(Equal("8.8.8.8"))
Expect(config.Upstreams.Groups["default"][1].Host).Should(Equal("8.8.4.4"))
Expect(config.Upstreams.Groups["default"][2].Host).Should(Equal("1.1.1.1"))
Expect(config.CustomDNS.Mapping.HostIPs).Should(HaveLen(2))
Expect(config.CustomDNS.Mapping.HostIPs["my.duckdns.org"][0]).Should(Equal(net.ParseIP("192.168.178.3")))
Expect(config.CustomDNS.Mapping.HostIPs["multiple.ips"][0]).Should(Equal(net.ParseIP("192.168.178.3")))
Expect(config.CustomDNS.Mapping.HostIPs["multiple.ips"][1]).Should(Equal(net.ParseIP("192.168.178.4")))
Expect(config.CustomDNS.Mapping.HostIPs["multiple.ips"][2]).Should(Equal(
net.ParseIP("2001:0db8:85a3:08d3:1319:8a2e:0370:7344")))
Expect(config.CustomDNS.Mapping).Should(HaveLen(2))

duckDNSEntry := config.CustomDNS.Mapping["my.duckdns.org"][0]
duckDNSA := duckDNSEntry.(*dns.A)
Expect(duckDNSA.A).Should(Equal(net.ParseIP("192.168.178.3")))

multipleIpsEntry := config.CustomDNS.Mapping["multiple.ips"][0]
multipleIpsA := multipleIpsEntry.(*dns.A)
Expect(multipleIpsA.A).Should(Equal(net.ParseIP("192.168.178.3")))

multipleIpsEntry = config.CustomDNS.Mapping["multiple.ips"][1]
multipleIpsA = multipleIpsEntry.(*dns.A)
Expect(multipleIpsA.A).Should(Equal(net.ParseIP("192.168.178.4")))

multipleIpsEntry = config.CustomDNS.Mapping["multiple.ips"][2]
multipleIpsAAAA := multipleIpsEntry.(*dns.AAAA)
Expect(multipleIpsAAAA.AAAA).Should(Equal(net.ParseIP("2001:db8:85a3:8d3:1319:8a2e:370:7344")))

Expect(config.Conditional.Mapping.Upstreams).Should(HaveLen(2))
Expect(config.Conditional.Mapping.Upstreams["fritz.box"]).Should(HaveLen(1))
Expect(config.Conditional.Mapping.Upstreams["multiple.resolvers"]).Should(HaveLen(2))
Expand Down
96 changes: 72 additions & 24 deletions config/custom_dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net"
"strings"

"github.com/miekg/dns"
"github.com/sirupsen/logrus"
)

Expand All @@ -16,14 +17,45 @@ type CustomDNS struct {
FilterUnmappedTypes bool `yaml:"filterUnmappedTypes" default:"true"`
}

// CustomDNSMapping mapping for the custom DNS configuration
type CustomDNSMapping struct {
HostIPs map[string][]net.IP `yaml:"hostIPs"`
type (
CustomDNSMapping map[string]CustomDNSEntries
CustomDNSEntries []dns.RR
)

func (c *CustomDNSEntries) UnmarshalYAML(unmarshal func(interface{}) error) error {
var input string
if err := unmarshal(&input); err != nil {
return err
}

parts := strings.Split(input, ",")
result := make(CustomDNSEntries, len(parts))
containsCNAME := false

for i, part := range parts {
rr, err := configToRR(part)
if err != nil {
return err
}

_, isCNAME := rr.(*dns.CNAME)
containsCNAME = containsCNAME || isCNAME

result[i] = rr
}

if containsCNAME && len(result) > 1 {
return fmt.Errorf("when a CNAME record is present, it must be the only record in the mapping")
}

*c = result

return nil
}

// IsEnabled implements `config.Configurable`.
func (c *CustomDNS) IsEnabled() bool {
return len(c.Mapping.HostIPs) != 0
return len(c.Mapping) != 0
}

// LogConfig implements `config.Configurable`.
Expand All @@ -33,36 +65,52 @@ func (c *CustomDNS) LogConfig(logger *logrus.Entry) {

logger.Info("mapping:")

for key, val := range c.Mapping.HostIPs {
for key, val := range c.Mapping {
logger.Infof(" %s = %s", key, val)
}
}

// UnmarshalYAML implements `yaml.Unmarshaler`.
func (c *CustomDNSMapping) UnmarshalYAML(unmarshal func(interface{}) error) error {
var input map[string]string
if err := unmarshal(&input); err != nil {
return err
}
func removePrefixSuffix(in, prefix string) string {
in = strings.TrimPrefix(in, fmt.Sprintf("%s(", prefix))
in = strings.TrimSuffix(in, ")")

return strings.TrimSpace(in)
}

result := make(map[string][]net.IP, len(input))
func configToRR(part string) (dns.RR, error) {
if strings.HasPrefix(part, "CNAME(") {
domain := removePrefixSuffix(part, "CNAME")
domain = dns.Fqdn(domain)
cname := &dns.CNAME{Target: domain}

for k, v := range input {
var ips []net.IP
return cname, nil
}

for _, part := range strings.Split(v, ",") {
ip := net.ParseIP(strings.TrimSpace(part))
if ip == nil {
return fmt.Errorf("invalid IP address '%s'", part)
}
// Fall back to A/AAAA records to maintain backwards compatibility in config.yml
// We will still remove the A() or AAAA() if it exists
if strings.Contains(part, ".") { // IPV4 address
ipStr := removePrefixSuffix(part, "A")
ip := net.ParseIP(ipStr)

ips = append(ips, ip)
if ip == nil {
return nil, fmt.Errorf("invalid IP address '%s'", part)
}

result[k] = ips
}
a := new(dns.A)
a.A = ip

c.HostIPs = result
return a, nil
} else { // IPV6 address
ipStr := removePrefixSuffix(part, "AAAA")
ip := net.ParseIP(ipStr)

return nil
if ip == nil {
return nil, fmt.Errorf("invalid IP address '%s'", part)
}

aaaa := new(dns.AAAA)
aaaa.AAAA = ip

return aaaa, nil
}
}
42 changes: 28 additions & 14 deletions config/custom_dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net"

"github.com/creasty/defaults"
"github.com/miekg/dns"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
Expand All @@ -17,15 +18,14 @@ var _ = Describe("CustomDNSConfig", func() {
BeforeEach(func() {
cfg = CustomDNS{
Mapping: CustomDNSMapping{
HostIPs: map[string][]net.IP{
"custom.domain": {net.ParseIP("192.168.143.123")},
"ip6.domain": {net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")},
"multiple.ips": {
net.ParseIP("192.168.143.123"),
net.ParseIP("192.168.143.125"),
net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
},
"custom.domain": {&dns.A{A: net.ParseIP("192.168.143.123")}},
"ip6.domain": {&dns.AAAA{AAAA: net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")}},
"multiple.ips": {
&dns.A{A: net.ParseIP("192.168.143.123")},
&dns.A{A: net.ParseIP("192.168.143.125")},
&dns.AAAA{AAAA: net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")},
},
"cname.domain": {&dns.CNAME{Target: "custom.domain"}},
},
}
})
Expand Down Expand Up @@ -60,27 +60,41 @@ var _ = Describe("CustomDNSConfig", func() {
Expect(hook.Calls).ShouldNot(BeEmpty())
Expect(hook.Messages).Should(ContainElements(
ContainSubstring("custom.domain = "),
ContainSubstring("ip6.domain = "),
ContainSubstring("multiple.ips = "),
ContainSubstring("cname.domain = "),
))
})
})

Describe("UnmarshalYAML", func() {
It("Should parse config as map", func() {
c := &CustomDNSMapping{}
c := CustomDNSEntries{}
err := c.UnmarshalYAML(func(i interface{}) error {
*i.(*map[string]string) = map[string]string{"key": "1.2.3.4"}
*i.(*string) = "1.2.3.4"

return nil
})
Expect(err).Should(Succeed())
Expect(c.HostIPs).Should(HaveLen(1))
Expect(c.HostIPs["key"]).Should(HaveLen(1))
Expect(c.HostIPs["key"][0]).Should(Equal(net.ParseIP("1.2.3.4")))
Expect(c).Should(HaveLen(1))

aRecord := c[0].(*dns.A)
Expect(aRecord.A).Should(Equal(net.ParseIP("1.2.3.4")))
})

It("Should return an error if a CNAME is accomanied by any other record", func() {
c := CustomDNSEntries{}
err := c.UnmarshalYAML(func(i interface{}) error {
*i.(*string) = "CNAME(example.com),A(1.2.3.4)"

return nil
})
Expect(err).Should(HaveOccurred())
Expect(err).Should(MatchError("when a CNAME record is present, it must be the only record in the mapping"))
})

It("should fail if wrong YAML format", func() {
c := &CustomDNSMapping{}
c := &CustomDNSEntries{}
err := c.UnmarshalYAML(func(i interface{}) error {
return errors.New("some err")
})
Expand Down
1 change: 1 addition & 0 deletions docs/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ customDNS:
example.com: printer.lan
mapping:
printer.lan: 192.168.178.3,2001:0db8:85a3:08d3:1319:8a2e:0370:7344
second-printer-address.lan: CNAME(printer.lan)

# optional: definition, which DNS resolver(s) should be used for queries to the domain (with all sub-domains). Multiple resolvers must be separated by a comma
# Example: Query client.fritz.box will ask DNS server 192.168.178.1. This is necessary for local network, to resolve clients by host name
Expand Down
15 changes: 9 additions & 6 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,12 @@ You can define your own domain name to IP mappings. For example, you can use a u
or define a domain name for your local device on order to use the HTTPS certificate. Multiple IP addresses for one
domain must be separated by a comma.

| Parameter | Type | Mandatory | Default value |
| ------------------- | --------------------------------------- | --------- | ------------- |
| customTTL | duration (no unit is minutes) | no | 1h |
| rewrite | string: string (domain: domain) | no | |
| mapping | string: string (hostname: address list) | no | |
| filterUnmappedTypes | boolean | no | true |
| Parameter | Type | Mandatory | Default value |
| ------------------- | ------------------------------------------- | --------- | ------------- |
| customTTL | duration (no unit is minutes) | no | 1h |
| rewrite | string: string (domain: domain) | no | |
| mapping | string: string (hostname: address or CNAME) | no | |
| filterUnmappedTypes | boolean | no | true |

!!! example

Expand All @@ -278,11 +278,14 @@ domain must be separated by a comma.
mapping:
printer.lan: 192.168.178.3
otherdevice.lan: 192.168.178.15,2001:0db8:85a3:08d3:1319:8a2e:0370:7344
anothername.lan: CNAME(otherdevice.lan)
```

This configuration will also resolve any subdomain of the defined domain, recursively. For example querying any of
`printer.lan`, `my.printer.lan` or `i.love.my.printer.lan` will return 192.168.178.3.

CNAME records are supported by setting the value of the mapping to `CNAME(target)`. Note that the target will be recursively resolved and will return an error if a loop is detected.

With the optional parameter `rewrite` you can replace domain part of the query with the defined part **before** the
resolver lookup is performed.
The query "printer.home" will be rewritten to "printer.lan" and return 192.168.178.3.
Expand Down
2 changes: 1 addition & 1 deletion e2e/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func createDNSMokkaContainer(ctx context.Context, alias string, rules ...string)
// createHTTPServerContainer creates a static HTTP server container that serves one file with the given lines
// and is attached to the test network under the given alias.
// It is automatically terminated when the test is finished.
func createHTTPServerContainer(ctx context.Context, alias string, filename string, lines ...string,
func createHTTPServerContainer(ctx context.Context, alias, filename string, lines ...string,
) (testcontainers.Container, error) {
file := createTempFile(lines...)

Expand Down
2 changes: 2 additions & 0 deletions helpertest/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ func (matcher *dnsRecordMatcher) matchSingle(rr dns.RR) (success bool, err error
return v.A.String() == matcher.answer, nil
case *dns.AAAA:
return v.AAAA.String() == matcher.answer, nil
case *dns.CNAME:
return v.Target == matcher.answer, nil
case *dns.PTR:
return v.Ptr == matcher.answer, nil
case *dns.MX:
Expand Down
Loading