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(dnssec): new pkg/dnssec package #97

Open
wants to merge 6 commits into
base: v2.0.0-beta
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ issues:
- dupl
- goerr113
- containedctx
- path: internal/dnssec/.*\.go
linters:
- gocognit
- cyclop
- path: main\.go
text: File is not `goimports`-ed
linters:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ ENV \
MIDDLEWARE_LOG_RESPONSES=off \
MIDDLEWARE_LOCALDNS_ENABLED=on \
MIDDLEWARE_LOCALDNS_RESOLVERS= \
DNSSEC_VALIDATION=on \
CACHE_TYPE=lru \
CACHE_LRU_MAX_ENTRIES=10000 \
BLOCK_MALICIOUS=on \
Expand Down Expand Up @@ -116,5 +117,4 @@ LABEL \
COPY --from=build --chown=1000 /tmp/gobuild/entrypoint /entrypoint

# Downloads and install some files
# TODO once DNSSEC is operational
# RUN /entrypoint build
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# DNS over TLS or HTTPs forwarding resolver
# DNS over TLS or HTTPs forwarding security aware resolver

Resolver communicating with public DNS recursive servers over encrypted channels with TLS or HTTPs.
It also does **caching**, **filtering**, **split-horizon DNS**, **IPv6**, **Prometheus metrucs**.
Security aware resolver communicating with public DNS recursive servers over encrypted channels with TLS or HTTPs.
It also does **caching**, **filtering**, **split-horizon DNS**, **IPv6**, **DNSSEC** and **Prometheus metrucs**.
It's fully coded in Go and is a single and cross platform binary program.

**Announcement**: *I am currently working on a DNSSEC validator implementation to reach feature parity with the v1.x.x image using Unbound*
**Announcement**: *DNSSEC validation is now implemented, finally reaching feature parity with the v1.x.x image using Unbound*

**The `:v2.0.0-beta` Docker image breaks compatibility with previous images based on v1.x.x versions**

Expand Down Expand Up @@ -54,6 +54,7 @@ It's fully coded in Go and is a single and cross platform binary program.
- auto-update [block lists](https://github.com/qdm12/files) periodically with minimal downtime
- Specify custom hostnames and IP addresses
- DNS rebinding protection
- [DNSSEC validation](https://github.com/qdm12/dns/blob/v2.0.0-beta/internal/dnssec/readme.md) ✅
- [Prometheus Metrics](https://github.com/qdm12/dns/blob/v2.0.0-beta/readme/metrics)
- Container specific features 🐋
- Tiny **16MB** Docker image (uncompressed, amd64) based on the empty image [scratch](https://hub.docker.com/_/scratch)
Expand Down Expand Up @@ -134,6 +135,7 @@ For example, the environment variable `UPSTREAM_TYPE` corresponds to the CLI fla
| `LISTENING_ADDRESS` | `:53` | DNS server listening address |
| `CACHE_TYPE` | `lru` | `lru` or `noop`. LRU caches DNS responses by least recently used |
| `CACHE_LRU_MAX_ENTRIES` | `10000` | Number of elements to keep in the LRU cache. |
| `DNSSEC_VALIDATION` | `on` | `on` or `off`. Enable or disable DNSSEC validation |
| `METRICS_TYPE` | `noop` | `noop` or `prometheus` |
| `METRICS_PROMETHEUS_ADDRESS` | `:9090` | HTTP Prometheus server listening address |
| `METRICS_PROMETHEUS_SUBSYSTEM` | `dns` | Prometheus metrics prefix/subsystem |
Expand Down
3 changes: 2 additions & 1 deletion cmd/dns/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, //nolint:cycl
return fmt.Errorf("cache: %w", err)
}

dnsLoop, err := dns.New(settings, dnsLogger, blockBuilder, cache, prometheusRegistry)
dnsLoop, err := dns.New(settings, dnsLogger, blockBuilder, cache, prometheusRegistry,
*settings.DNSSEC.Enabled)
if err != nil {
return fmt.Errorf("creating DNS loop: %w", err)
}
Expand Down
39 changes: 39 additions & 0 deletions internal/config/dnssec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package config

import (
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gotree"
)

type DNSSEC struct {
Enabled *bool
}

func (d *DNSSEC) setDefaults() {
d.Enabled = gosettings.DefaultPointer(d.Enabled, true)
}

func (d DNSSEC) validate() (err error) {
return nil
}

func (d *DNSSEC) String() string {
return d.ToLinesNode().String()
}

func (d *DNSSEC) ToLinesNode() (node *gotree.Node) {
if !*d.Enabled {
return gotree.New("DNSSEC validation: disabled")
}
return gotree.New("DNSSEC validation: enabled")
}

func (d *DNSSEC) read(reader *reader.Reader) (err error) {
d.Enabled, err = reader.BoolPtr("DNSSEC_VALIDATION")
if err != nil {
return err
}

return nil
}
10 changes: 10 additions & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Settings struct {
MiddlewareLog MiddlewareLog
Metrics Metrics
LocalDNS LocalDNS
DNSSEC DNSSEC
CheckDNS *bool
UpdatePeriod *time.Duration
}
Expand All @@ -47,6 +48,7 @@ func (s *Settings) SetDefaults() {
s.MiddlewareLog.setDefaults()
s.Metrics.setDefaults()
s.LocalDNS.setDefault()
s.DNSSEC.setDefaults()
s.CheckDNS = gosettings.DefaultPointer(s.CheckDNS, true)
const defaultUpdaterPeriod = 24 * time.Hour
s.UpdatePeriod = gosettings.DefaultPointer(s.UpdatePeriod, defaultUpdaterPeriod)
Expand Down Expand Up @@ -77,6 +79,7 @@ func (s *Settings) Validate() (err error) {
"middleware log": s.MiddlewareLog.validate,
"metrics": s.Metrics.validate,
"local DNS": s.LocalDNS.validate,
"DNSSEC": s.DNSSEC.validate,
}
for name, validate := range nameToValidate {
err = validate()
Expand Down Expand Up @@ -119,6 +122,7 @@ func (s *Settings) ToLinesNode() (node *gotree.Node) {
node.AppendNode(s.MiddlewareLog.ToLinesNode())
node.AppendNode(s.Metrics.ToLinesNode())
node.AppendNode(s.LocalDNS.ToLinesNode())
node.AppendNode(s.DNSSEC.ToLinesNode())
node.Appendf("Check DNS: %s", gosettings.BoolToYesNo(s.CheckDNS))

if *s.UpdatePeriod == 0 {
Expand All @@ -130,6 +134,7 @@ func (s *Settings) ToLinesNode() (node *gotree.Node) {
return node
}

//nolint:cyclop
func (s *Settings) Read(reader *reader.Reader, warner Warner) (err error) {
warnings := checkOutdatedEnv(reader)
for _, warning := range warnings {
Expand Down Expand Up @@ -173,6 +178,11 @@ func (s *Settings) Read(reader *reader.Reader, warner Warner) (err error) {
return fmt.Errorf("local DNS settings: %w", err)
}

err = s.DNSSEC.read(reader)
if err != nil {
return fmt.Errorf("DNSSEC settings: %w", err)
}

s.CheckDNS, err = reader.BoolPtr("CHECK_DNS")
if err != nil {
return err
Expand Down
7 changes: 5 additions & 2 deletions internal/dns/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Loop struct {
blockBuilder BlockBuilder
cache Cache
prometheusRegistry PrometheusRegistry
dnssecEnabled bool

dnsServer Service
updateTimer *time.Timer
Expand All @@ -31,7 +32,8 @@ type Loop struct {

func New(settings config.Settings, logger Logger,
blockBuilder BlockBuilder, cache Cache,
prometheusRegistry PrometheusRegistry) (loop *Loop, err error) {
prometheusRegistry PrometheusRegistry, dnssecEnabled bool) (
loop *Loop, err error) {
settings.SetDefaults()
err = settings.Validate()
if err != nil {
Expand All @@ -44,6 +46,7 @@ func New(settings config.Settings, logger Logger,
blockBuilder: blockBuilder,
cache: cache,
prometheusRegistry: prometheusRegistry,
dnssecEnabled: dnssecEnabled,
}, nil
}

Expand Down Expand Up @@ -209,7 +212,7 @@ func (l *Loop) setupAll(ctx context.Context, downloadBlockFiles bool) ( //nolint
}

server, err := setup.DNS(l.settings, l.ipv6Support, l.cache,
filter, l.logger, l.prometheusRegistry)
filter, l.logger, l.prometheusRegistry, l.dnssecEnabled)
if err != nil {
return nil, err
}
Expand Down
149 changes: 149 additions & 0 deletions internal/dnssec/chain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package dnssec

import (
"errors"
"fmt"
"strings"

"github.com/miekg/dns"
)

// buildDelegationChain queries the RRs required for the zone validation.
// It begins the queries at the root zone and then go down the delegation
// chain until it reaches the desired zone, or an unsigned zone.
// It returns a delegation chain of signed zones where the
// first signed zone (index 0) is the root zone and the last signed
// zone is the last signed zone, which can be the desired zone.
func buildDelegationChain(handler dns.Handler, desiredZone string, qClass uint16) (
delegationChain []signedData, err error) {
zoneNames := desiredZoneToZoneNames(desiredZone)
delegationChain = make([]signedData, 0, len(zoneNames))

for _, zoneName := range zoneNames {
// zoneName iterates in this order: ., com., example.com.
data, signed, err := queryDelegation(handler, zoneName, qClass)
if err != nil {
return nil, fmt.Errorf("querying delegation for desired zone %s: %w",
desiredZone, err)
}
delegationChain = append(delegationChain, data)
if !signed {
// first zone without a DS RRSet, but it should
// have at least one NSEC or NSEC3 RRSet, even for
// NXDOMAIN responses.
break
}
}

return delegationChain, nil
}

func desiredZoneToZoneNames(desiredZone string) (zoneNames []string) {
if desiredZone == "." {
return []string{"."}
}

zoneParts := strings.Split(desiredZone, ".")
zoneNames = make([]string, len(zoneParts))
for i := range zoneParts {
zoneNames[i] = dns.Fqdn(strings.Join(zoneParts[len(zoneParts)-1-i:], "."))
}
return zoneNames
}

// queryDelegation obtains the DS RRSet and the DNSKEY RRSet
// for a given zone and class, and creates a signed zone with
// this information. It does not query the (non existent)
// DS record for the root zone, which is the trust root anchor.
func queryDelegation(handler dns.Handler, zone string, qClass uint16) (
data signedData, signed bool, err error) {
data.zone = zone
data.class = qClass

// TODO set root zone DS here!

// do not query DS for root zone since its DS record
// is the trust root anchor.
if zone != "." {
data.dsResponse, err = queryDS(handler, zone, qClass)
if err != nil {
return signedData{}, false, fmt.Errorf("querying DS record: %w", err)
}

if data.dsResponse.isNoData() || data.dsResponse.isNXDomain() {
// If no DS RRSet is found, the entire zone is unsigned.
// This also means no DNSKEY RRSet exists, since child zones are
// also unsigned, so return with the error errZoneHasNoDSRcord
// to signal the caller to stop the delegation chain queries for
// child zones when encountering a zone with no DS RRSet.
return data, false, nil
}
}

data.dnsKeyResponse, err = queryDNSKeys(handler, zone, qClass)
if err != nil {
return signedData{}, true, fmt.Errorf("querying DNSKEY record: %w", err)
}

return data, true, nil
}

var (
ErrDSAndNSECAbsent = errors.New("zone has no DS record and no NSEC record")
)

func queryDS(handler dns.Handler, zone string, qClass uint16) (
response dnssecResponse, err error) {
response, err = queryRRSets(handler, zone, qClass, dns.TypeDS)
switch {
case err != nil:
return dnssecResponse{}, err
case !response.isSigned():
// no signed DS answer and no NSEC/NSEC3 authority RR
return dnssecResponse{}, wrapError(
zone, qClass, dns.TypeDS, ErrDSAndNSECAbsent)
case response.isNXDomain(), response.isNoData():
// there is one or more NSEC/NSEC3 authority RRSets.
return response, nil
}
// signed answer RRSet(s)

// Double check we only have 1 DS RRSet.
// TODO remove?
err = dnssecRRSetsIsSingleOfType(response.answerRRSets, dns.TypeDS)
if err != nil {
return dnssecResponse{},
wrapError(zone, qClass, dns.TypeDS, err)
}

return response, nil
}

// queryDNSKeys queries the DNSKEY records for a given signed zone
// containing a DS RRSet. It returns an error if the DNSKEY RRSet is
// missing or is unsigned.
// Note this returns all the DNSKey RRs, even non-zone ones.
func queryDNSKeys(handler dns.Handler, qname string, qClass uint16) (
response dnssecResponse, err error) {
// DNSKey RRSet(s) should be present so the NSEC/NSEC3 RRSet is ignored.
response, err = queryRRSets(handler, qname, qClass, dns.TypeDNSKEY)
switch {
case err != nil:
return dnssecResponse{}, err
case !response.isSigned(), response.isNoData(): // cannot be NXDOMAIN
// no signed DNSKEY answer
return dnssecResponse{}, fmt.Errorf("for %s: %w",
nameClassTypeToString(qname, qClass, dns.TypeDNSKEY),
ErrDNSKeyNotFound)
}

// Double check we only have 1 DNSKEY RRSet.
// TODO remove?
err = dnssecRRSetsIsSingleOfType(response.answerRRSets, dns.TypeDNSKEY)
if err != nil {
return dnssecResponse{},
wrapError(qname, qClass, dns.TypeDNSKEY, err)
}

return response, nil
}
39 changes: 39 additions & 0 deletions internal/dnssec/chain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package dnssec

import (
"testing"

"github.com/stretchr/testify/assert"
)

func Test_desiredZoneToZoneNames(t *testing.T) {
t.Parallel()

testCases := map[string]struct {
desiredZone string
zoneNames []string
}{
"root": {
desiredZone: ".",
zoneNames: []string{"."},
},
"com": {
desiredZone: "com.",
zoneNames: []string{".", "com."},
},
"example.com": {
desiredZone: "example.com.",
zoneNames: []string{".", "com.", "example.com."},
},
}

for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()

zoneNames := desiredZoneToZoneNames(testCase.desiredZone)
assert.Equal(t, testCase.zoneNames, zoneNames)
})
}
}
Loading
Loading