Use external resolvers to validate the signatures.
This commit is contained in:
parent
969171cea8
commit
caa7d89b2d
23
README.md
23
README.md
@ -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
|
||||
|
||||
|
@ -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
119
main.go
@ -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)
|
||||
|
108
main_test.go
108
main_test.go
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user