Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
qdm12 committed Jan 12, 2024
1 parent 51ea21d commit 1aa9529
Show file tree
Hide file tree
Showing 34 changed files with 2,906 additions and 5 deletions.
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,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 **10MB** Docker image (uncompressed, amd64) based on the empty image [scratch](https://hub.docker.com/_/scratch)
Expand Down Expand Up @@ -133,6 +134,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
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)
})
}
}
25 changes: 25 additions & 0 deletions internal/dnssec/cname.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package dnssec

import (
"fmt"

"github.com/miekg/dns"
)

func mustRRToCNAME(rr dns.RR) *dns.CNAME {
cname, ok := rr.(*dns.CNAME)
if !ok {
panic(fmt.Sprintf("RR is of type %T and not of type *dns.CNAME", rr))
}
return cname
}

func getCnameTarget(rrSets []dnssecRRSet) (target string) {
for _, rrSet := range rrSets {
if rrSet.qtype() == dns.TypeCNAME {
cname := mustRRToCNAME(rrSet.rrSet[0])
return cname.Target
}
}
return ""
}
71 changes: 71 additions & 0 deletions internal/dnssec/dnskey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package dnssec

import (
"fmt"

"github.com/miekg/dns"
)

func mustRRToDNSKey(rr dns.RR) *dns.DNSKEY {
dnsKey, ok := rr.(*dns.DNSKEY)
if !ok {
panic(fmt.Sprintf("RR is of type %T and not of type *dns.DNSKEY", rr))
}
return dnsKey
}

// makeKeyTagToDNSKey creates a map of key tag to DNSKEY from a DNSKEY RRSet,
// ignoring any RR which is not a Zone signing key.
func makeKeyTagToDNSKey(dnsKeyRRSet []dns.RR) (keyTagToDNSKey map[uint16]*dns.DNSKEY) {
keyTagToDNSKey = make(map[uint16]*dns.DNSKEY, len(dnsKeyRRSet))
for _, dnsKeyRR := range dnsKeyRRSet {
dnsKey := mustRRToDNSKey(dnsKeyRR)
if dnsKey.Flags&dns.ZONE == 0 {
// As described in https://datatracker.ietf.org/doc/html/rfc4034#section-2.1.1
// and https://datatracker.ietf.org/doc/html/rfc4034#section-5.2:
// If bit 7 has value 0, then the DNSKEY record holds some other type of DNS
// public key and MUST NOT be used to verify RRSIGs that cover RRsets.
// The DNSKEY RR Flags MUST have Flags bit 7 set. If the
// DNSKEY flags do not indicate a DNSSEC zone key, the DS
// RR (and the DNSKEY RR it references) MUST NOT be used
// in the validation process.
continue
}
keyTagToDNSKey[dnsKey.KeyTag()] = dnsKey
}
return keyTagToDNSKey
}

const (
algoPreferenceRecommended uint8 = iota
algoPreferenceMust
algoPreferenceMay
algoPreferenceMustNot
algoPreferenceUnknown
)

// lessDNSKeyAlgorithm returns true if algoID1 < algoID2 in terms
// of preference. The preference is determined by the table defined in:
// https://datatracker.ietf.org/doc/html/rfc8624#section-3.1
func lessDNSKeyAlgorithm(algoID1, algoID2 uint8) bool {
return algoIDToPreference(algoID1) < algoIDToPreference(algoID2)
}

// algoIDToPreference returns the preference level of the algorithm ID.
// Note this is a function with a switch statement, which not only provide
// immutability compared to a global variable map, but is also x10 faster
// than map lookups.
func algoIDToPreference(algoID uint8) (preference uint8) {
switch algoID {
case dns.RSAMD5, dns.DSA, dns.DSANSEC3SHA1:
return algoPreferenceMustNot
case dns.ECCGOST:
return algoPreferenceMay
case dns.RSASHA1, dns.RSASHA1NSEC3SHA1, dns.RSASHA256, dns.RSASHA512, dns.ECDSAP256SHA256:
return algoPreferenceMust
case dns.ECDSAP384SHA384, dns.ED25519, dns.ED448:
return algoPreferenceRecommended
default:
return algoPreferenceUnknown
}
}
57 changes: 57 additions & 0 deletions internal/dnssec/dnskey_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package dnssec

import "testing"

var testGlobalMap = map[uint8]uint8{ //nolint:gochecknoglobals
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
6: 6,
7: 7,
8: 8,
}

func testSwitchStatement(key uint8) uint8 {
switch key {
case 1:
return 1
case 2:
return 2
case 3:
return 3
case 4:
return 4
case 5:
return 5
case 6:
return 6
case 7:
return 7
case 8:
return 8
default:
return 0 // TODO replace with panic
}
}

// This benchmark aims to check if, for algoIDToPreference, it is
// better to:
// 1. have a global map variable
// 2. have a function with a switch statement
// The second point at equal performance is better due to its
// immutability nature, unlike 1.
func Benchmark_globalMap_switch(b *testing.B) {
b.Run("global_map", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = testGlobalMap[1]
}
})

b.Run("switch", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = testSwitchStatement(1)
}
})
}
Loading

0 comments on commit 1aa9529

Please sign in to comment.