Use external resolvers to validate the signatures.

This commit is contained in:
Christian Joergensen 2018-11-10 15:18:26 +01:00
parent 969171cea8
commit caa7d89b2d
4 changed files with 135 additions and 143 deletions

View File

@ -13,8 +13,8 @@ Check for validity and expiration in DNSSEC signatures and expose metrics for Pr
Configuration file (default "/etc/dnssec-checks")
-listen-address string
Prometheus metrics port (default ":9204")
-resolver string
Resolver to use (default "8.8.8.8:53")
-resolvers string
Resolvers to use (comma separated) (default "8.8.8.8:53,1.1.1.1:53")
-timeout duration
Timeout for network operations (default 10s)
@ -30,12 +30,13 @@ Labels:
* `record`
* `type`
### Gauge: `dnssec_zone_record_valid`
### Gauge: `dnssec_zone_record_resolves`
Does this record pass DNSSEC validation.
Does the record resolve using the specified DNSSEC enabled resolvers.
Labels:
* `resolver`
* `zone`
* `record`
* `type`
@ -44,12 +45,14 @@ Labels:
# HELP dnssec_zone_record_days_left Number of days the signature will be valid
# TYPE dnssec_zone_record_days_left gauge
dnssec_zone_record_days_left{record="@",type="SOA",zone="ietf.org"} 357.5
dnssec_zone_record_days_left{record="@",type="SOA",zone="verisigninc.com"} 11.333333333333334
# HELP dnssec_zone_record_valid Does this record pass DNSSEC validation
# TYPE dnssec_zone_record_valid gauge
dnssec_zone_record_valid{record="@",type="SOA",zone="ietf.org"} 1
dnssec_zone_record_valid{record="@",type="SOA",zone="verisigninc.com"} 1
dnssec_zone_record_days_left{record="@",type="SOA",zone="ietf.org"} 320.3333333333333
dnssec_zone_record_days_left{record="@",type="SOA",zone="verisigninc.com"} 9.333333333333334
# HELP dnssec_zone_record_resolves Does the record resolve using the specified DNSSEC enabled resolvers
# TYPE dnssec_zone_record_resolves gauge
dnssec_zone_record_resolves{record="@",resolver="1.1.1.1:53",type="SOA",zone="ietf.org"} 1
dnssec_zone_record_resolves{record="@",resolver="1.1.1.1:53",type="SOA",zone="verisigninc.com"} 1
dnssec_zone_record_resolves{record="@",resolver="8.8.8.8:53",type="SOA",zone="ietf.org"} 1
dnssec_zone_record_resolves{record="@",resolver="8.8.8.8:53",type="SOA",zone="verisigninc.com"} 1
## Configuration

View File

@ -10,10 +10,10 @@ groups:
description: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} type {{$labels.type}}) expires in {{$value}} day(s)
title: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} is expiring
- alert: DNSSECSignatureInvalid
expr: dnssec_zone_record_valid == 0
expr: dnssec_zone_record_resolves == 0
for: 1m
labels:
urgency: immediate
annotations:
description: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} type {{$labels.type}}) is invalid
title: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} is invalid
description: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} type {{$labels.type}}) on resolver {{$labels.resolver}} is invalid
title: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} on resolver {{$labels.resolver}} is invalid

119
main.go
View File

@ -6,6 +6,7 @@ import (
"log"
"net/http"
"os"
"strings"
"sync"
"time"
@ -17,7 +18,7 @@ import (
var addr = flag.String("listen-address", ":9204", "Prometheus metrics port")
var conf = flag.String("config", "/etc/dnssec-checks", "Configuration file")
var resolver = flag.String("resolver", "8.8.8.8:53", "Resolver to use")
var resolvers = flag.String("resolvers", "8.8.8.8:53,1.1.1.1:53", "Resolvers to use (comma separated)")
var timeout = flag.Duration("timeout", 10*time.Second, "Timeout for network operations")
type Records struct {
@ -35,15 +36,15 @@ type Exporter struct {
Records []Records
records *prometheus.GaugeVec
valid *prometheus.GaugeVec
resolves *prometheus.GaugeVec
resolver string
resolvers []string
dnsClient *dns.Client
logger Logger
}
func NewDNSSECExporter(timeout time.Duration, resolver string, logger Logger) *Exporter {
func NewDNSSECExporter(timeout time.Duration, resolvers []string, logger Logger) *Exporter {
return &Exporter{
records: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
@ -58,14 +59,15 @@ func NewDNSSECExporter(timeout time.Duration, resolver string, logger Logger) *E
"type",
},
),
valid: prometheus.NewGaugeVec(
resolves: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "dnssec",
Subsystem: "zone",
Name: "record_valid",
Help: "Does this record pass DNSSEC validation",
Name: "record_resolves",
Help: "Does the record resolve using the specified DNSSEC enabled resolvers",
},
[]string{
"resolver",
"zone",
"record",
"type",
@ -75,97 +77,88 @@ func NewDNSSECExporter(timeout time.Duration, resolver string, logger Logger) *E
Net: "tcp",
Timeout: timeout,
},
resolver: resolver,
resolvers: resolvers,
logger: logger,
}
}
func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
e.records.Describe(ch)
e.valid.Describe(ch)
e.resolves.Describe(ch)
}
func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
var wg sync.WaitGroup
wg.Add(len(e.Records))
wg.Add(len(e.Records) * (len(e.resolvers) + 1))
for _, rec := range e.Records {
rec := rec
// Check the expiration
go func() {
valid, exp := e.collectRecord(rec.Zone, rec.Record, rec.Type)
e.valid.WithLabelValues(
rec.Zone, rec.Record, rec.Type,
).Set(map[bool]float64{true: 1}[valid])
exp := e.expiration(rec.Zone, rec.Record, rec.Type)
e.records.WithLabelValues(
rec.Zone, rec.Record, rec.Type,
).Set(float64(time.Until(exp)/time.Hour) / 24)
wg.Done()
}()
// Check the configured resolvers
for _, resolver := range e.resolvers {
resolver := resolver
go func() {
resolves := e.resolve(rec.Zone, rec.Record, rec.Type, resolver)
e.resolves.WithLabelValues(
resolver, rec.Zone, rec.Record, rec.Type,
).Set(map[bool]float64{true: 1}[resolves])
wg.Done()
}()
}
}
wg.Wait()
e.records.Collect(ch)
e.valid.Collect(ch)
e.resolves.Collect(ch)
}
func (e *Exporter) collectRecord(zone, record, recordType string) (valid bool, exp time.Time) {
// Start by finding the DNSKEY
func (e *Exporter) expiration(zone, record, recordType string) (exp time.Time) {
msg := &dns.Msg{}
msg.SetQuestion(fmt.Sprintf("%s.", zone), dns.TypeDNSKEY)
response, _, err := e.dnsClient.Exchange(msg, e.resolver)
if err != nil {
e.logger.Printf("while looking up DNSKEY for %v: %v", zone, err)
return
}
// Found keys are mapped by tag -> key
keys := make(map[uint16]*dns.DNSKEY)
for _, rr := range response.Answer {
if dnskey, ok := rr.(*dns.DNSKEY); ok && dnskey.Flags&dns.ZONE != 0 {
keys[dnskey.KeyTag()] = dnskey
}
}
if len(keys) == 0 {
e.logger.Printf("didn't find DNSKEY for %v", zone)
}
// Now lookup the signature
msg = &dns.Msg{}
msg.SetQuestion(hostname(zone, record), dns.TypeRRSIG)
response, _, err = e.dnsClient.Exchange(msg, e.resolver)
response, _, err := e.dnsClient.Exchange(msg, e.resolvers[0])
if err != nil {
e.logger.Printf("while looking up RRSIG for %v: %v", hostname(zone, record), err)
return
}
var sig *dns.RRSIG
var key *dns.DNSKEY
for _, rr := range response.Answer {
if rrsig, ok := rr.(*dns.RRSIG); ok &&
rrsig.TypeCovered == dns.StringToType[recordType] &&
keys[rrsig.KeyTag] != nil {
rrsig.TypeCovered == dns.StringToType[recordType] {
sig = rrsig
key = keys[rrsig.KeyTag]
break
}
@ -182,29 +175,25 @@ func (e *Exporter) collectRecord(zone, record, recordType string) (valid bool, e
return
}
// Finally, lookup the records to validate
if key == nil {
e.valid.WithLabelValues(zone, record, recordType).Set(0)
return
}
msg = &dns.Msg{}
}
func (e *Exporter) resolve(zone, record, recordType, resolver string) (resolves bool) {
msg := &dns.Msg{}
msg.SetQuestion(hostname(zone, record), dns.StringToType[recordType])
msg.SetEdns0(4096, true)
response, _, err = e.dnsClient.Exchange(msg, e.resolver)
response, _, err := e.dnsClient.Exchange(msg, resolver)
if err != nil {
e.logger.Printf("while looking up RRSet for %v type %v: %v", hostname(zone, record), recordType, err)
e.logger.Printf("while resolving for %v: %v", hostname(zone, record), err)
return
}
if err := sig.Verify(key, response.Answer); err == nil {
valid = true
} else {
e.logger.Printf("verify error for %v type %v): %v", hostname(zone, record), recordType, err)
}
return
return response.AuthenticatedData &&
!response.CheckingDisabled &&
response.Rcode == dns.RcodeSuccess
}
@ -229,7 +218,9 @@ func main() {
logger := log.New(os.Stderr, "", log.LstdFlags)
exporter := NewDNSSECExporter(*timeout, *resolver, logger)
r := strings.Split(*resolvers, ",")
exporter := NewDNSSECExporter(*timeout, r, logger)
if err := toml.NewDecoder(f).Decode(exporter); err != nil {
log.Fatalf("couldn't parse configuration file: %v", err)

View File

@ -1,10 +1,7 @@
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"io/ioutil"
"log"
"net"
@ -17,14 +14,15 @@ import (
type opts struct {
signed time.Time
expires time.Time
privkey crypto.PrivateKey
rcode int
unauthenticated bool
}
func nullLogger() *log.Logger {
return log.New(ioutil.Discard, "", log.LstdFlags)
}
func runServer(t *testing.T, opts opts) (string, func()) {
func runServer(t *testing.T, opts opts) ([]string, func()) {
if opts.signed.IsZero() {
opts.signed = time.Now().Add(-time.Hour)
@ -45,10 +43,6 @@ func runServer(t *testing.T, opts opts) (string, func()) {
t.Fatalf("couldn't generate private key: %v", err)
}
if opts.privkey != nil {
privkey = opts.privkey
}
h := dns.NewServeMux()
h.HandleFunc("example.org.", func(rw dns.ResponseWriter, msg *dns.Msg) {
@ -72,25 +66,6 @@ func runServer(t *testing.T, opts opts) (string, func()) {
switch q.Qtype {
case dns.TypeDNSKEY:
rrHeader := dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeDNSKEY,
Class: dns.ClassINET,
Ttl: 3600,
}
answer := &dns.DNSKEY{
Hdr: rrHeader,
Algorithm: dnskey.Algorithm,
Flags: dnskey.Flags,
Protocol: dnskey.Protocol,
PublicKey: dnskey.PublicKey,
}
msg.Answer = append(msg.Answer, answer)
case dns.TypeRRSIG:
rrHeader := dns.RR_Header{
@ -124,6 +99,9 @@ func runServer(t *testing.T, opts opts) (string, func()) {
}
msg.AuthenticatedData = !opts.unauthenticated
msg.Rcode = opts.rcode
rw.WriteMsg(msg)
})
@ -150,24 +128,20 @@ func runServer(t *testing.T, opts opts) (string, func()) {
ln.Close()
}()
return ln.Addr().String(), func() {
return []string{ln.Addr().String()}, func() {
done <- true
}
}
func TestCollectionOK(t *testing.T) {
func TestExpirationOK(t *testing.T) {
addr, cancel := runServer(t, opts{})
defer cancel()
e := NewDNSSECExporter(time.Second, addr, nullLogger())
valid, exp := e.collectRecord("example.org", "@", "SOA")
if !valid {
t.Fatal("expected record to be valid")
}
exp := e.expiration("example.org", "@", "SOA")
if exp.Before(time.Now()) {
t.Fatalf("expected expiration to be in the future, was: %v", exp)
@ -175,7 +149,7 @@ func TestCollectionOK(t *testing.T) {
}
func TestCollectionExpired(t *testing.T) {
func TestExpired(t *testing.T) {
addr, cancel := runServer(t, opts{
signed: time.Now().Add(14 * 24 * time.Hour),
@ -186,11 +160,7 @@ func TestCollectionExpired(t *testing.T) {
e := NewDNSSECExporter(time.Second, addr, nullLogger())
valid, exp := e.collectRecord("example.org", "@", "SOA")
if !valid {
t.Fatal("expected record to be valid")
}
exp := e.expiration("example.org", "@", "SOA")
if exp.After(time.Now()) {
t.Fatalf("expected expiration to be in the past, was: %v", exp)
@ -198,29 +168,57 @@ func TestCollectionExpired(t *testing.T) {
}
func TestCollectionInvalid(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("couldn't generate fake private key: %v", err)
}
func TestValid(t *testing.T) {
addr, cancel := runServer(t, opts{
privkey: priv,
signed: time.Now().Add(14 * 24 * time.Hour),
expires: time.Now().Add(-time.Hour),
})
defer cancel()
e := NewDNSSECExporter(time.Second, addr, nullLogger())
valid, exp := e.collectRecord("example.org", "@", "SOA")
valid := e.resolve("example.org", "@", "SOA", addr[0])
if valid {
t.Fatal("expected record to be invalid")
}
if exp.Before(time.Now()) {
t.Fatalf("expected expiration to be in the future, was: %v", exp)
if !valid {
t.Fatal("expected valid result")
}
}
func TestInvalidError(t *testing.T) {
addr, cancel := runServer(t, opts{
rcode: dns.RcodeServerFailure,
})
defer cancel()
e := NewDNSSECExporter(time.Second, addr, nullLogger())
valid := e.resolve("example.org", "@", "SOA", addr[0])
if valid {
t.Fatal("expected invalid result")
}
}
func TestInvalidUnauthenticated(t *testing.T) {
addr, cancel := runServer(t, opts{
unauthenticated: true,
})
defer cancel()
e := NewDNSSECExporter(time.Second, addr, nullLogger())
valid := e.resolve("example.org", "@", "SOA", addr[0])
if valid {
t.Fatal("expected invalid result")
}
}