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") Configuration file (default "/etc/dnssec-checks")
-listen-address string -listen-address string
Prometheus metrics port (default ":9204") Prometheus metrics port (default ":9204")
-resolver string -resolvers string
Resolver to use (default "8.8.8.8:53") Resolvers to use (comma separated) (default "8.8.8.8:53,1.1.1.1:53")
-timeout duration -timeout duration
Timeout for network operations (default 10s) Timeout for network operations (default 10s)
@ -30,12 +30,13 @@ Labels:
* `record` * `record`
* `type` * `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: Labels:
* `resolver`
* `zone` * `zone`
* `record` * `record`
* `type` * `type`
@ -44,12 +45,14 @@ Labels:
# HELP dnssec_zone_record_days_left Number of days the signature will be valid # HELP dnssec_zone_record_days_left Number of days the signature will be valid
# TYPE dnssec_zone_record_days_left gauge # 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="ietf.org"} 320.3333333333333
dnssec_zone_record_days_left{record="@",type="SOA",zone="verisigninc.com"} 11.333333333333334 dnssec_zone_record_days_left{record="@",type="SOA",zone="verisigninc.com"} 9.333333333333334
# HELP dnssec_zone_record_valid Does this record pass DNSSEC validation # HELP dnssec_zone_record_resolves Does the record resolve using the specified DNSSEC enabled resolvers
# TYPE dnssec_zone_record_valid gauge # TYPE dnssec_zone_record_resolves gauge
dnssec_zone_record_valid{record="@",type="SOA",zone="ietf.org"} 1 dnssec_zone_record_resolves{record="@",resolver="1.1.1.1:53",type="SOA",zone="ietf.org"} 1
dnssec_zone_record_valid{record="@",type="SOA",zone="verisigninc.com"} 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 ## 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) 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 title: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} is expiring
- alert: DNSSECSignatureInvalid - alert: DNSSECSignatureInvalid
expr: dnssec_zone_record_valid == 0 expr: dnssec_zone_record_resolves == 0
for: 1m for: 1m
labels: labels:
urgency: immediate urgency: immediate
annotations: annotations:
description: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} type {{$labels.type}}) 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}} is invalid title: The DNSSEC signature for the {{$labels.record}} in {{$labels.zone}} on resolver {{$labels.resolver}} is invalid

117
main.go
View File

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

View File

@ -1,10 +1,7 @@
package main package main
import ( import (
"crypto"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"io/ioutil" "io/ioutil"
"log" "log"
"net" "net"
@ -17,14 +14,15 @@ import (
type opts struct { type opts struct {
signed time.Time signed time.Time
expires time.Time expires time.Time
privkey crypto.PrivateKey rcode int
unauthenticated bool
} }
func nullLogger() *log.Logger { func nullLogger() *log.Logger {
return log.New(ioutil.Discard, "", log.LstdFlags) 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() { if opts.signed.IsZero() {
opts.signed = time.Now().Add(-time.Hour) 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) t.Fatalf("couldn't generate private key: %v", err)
} }
if opts.privkey != nil {
privkey = opts.privkey
}
h := dns.NewServeMux() h := dns.NewServeMux()
h.HandleFunc("example.org.", func(rw dns.ResponseWriter, msg *dns.Msg) { 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 { 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: case dns.TypeRRSIG:
rrHeader := dns.RR_Header{ 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) rw.WriteMsg(msg)
}) })
@ -150,24 +128,20 @@ func runServer(t *testing.T, opts opts) (string, func()) {
ln.Close() ln.Close()
}() }()
return ln.Addr().String(), func() { return []string{ln.Addr().String()}, func() {
done <- true done <- true
} }
} }
func TestCollectionOK(t *testing.T) { func TestExpirationOK(t *testing.T) {
addr, cancel := runServer(t, opts{}) addr, cancel := runServer(t, opts{})
defer cancel() defer cancel()
e := NewDNSSECExporter(time.Second, addr, nullLogger()) e := NewDNSSECExporter(time.Second, addr, nullLogger())
valid, exp := e.collectRecord("example.org", "@", "SOA") exp := e.expiration("example.org", "@", "SOA")
if !valid {
t.Fatal("expected record to be valid")
}
if exp.Before(time.Now()) { if exp.Before(time.Now()) {
t.Fatalf("expected expiration to be in the future, was: %v", exp) 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{ addr, cancel := runServer(t, opts{
signed: time.Now().Add(14 * 24 * time.Hour), signed: time.Now().Add(14 * 24 * time.Hour),
@ -186,11 +160,7 @@ func TestCollectionExpired(t *testing.T) {
e := NewDNSSECExporter(time.Second, addr, nullLogger()) e := NewDNSSECExporter(time.Second, addr, nullLogger())
valid, exp := e.collectRecord("example.org", "@", "SOA") exp := e.expiration("example.org", "@", "SOA")
if !valid {
t.Fatal("expected record to be valid")
}
if exp.After(time.Now()) { if exp.After(time.Now()) {
t.Fatalf("expected expiration to be in the past, was: %v", exp) 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) { func TestValid(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("couldn't generate fake private key: %v", err)
}
addr, cancel := runServer(t, opts{ addr, cancel := runServer(t, opts{
privkey: priv, signed: time.Now().Add(14 * 24 * time.Hour),
expires: time.Now().Add(-time.Hour),
}) })
defer cancel() defer cancel()
e := NewDNSSECExporter(time.Second, addr, nullLogger()) 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 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 { if valid {
t.Fatal("expected record to be invalid") t.Fatal("expected invalid result")
} }
if exp.Before(time.Now()) { }
t.Fatalf("expected expiration to be in the future, was: %v", exp)
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")
} }
} }