diff --git a/README.md b/README.md index 437f82f..f6c9716 100644 --- a/README.md +++ b/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 diff --git a/dnssec.rules b/dnssec.rules index aa19685..10fb169 100644 --- a/dnssec.rules +++ b/dnssec.rules @@ -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 diff --git a/main.go b/main.go index 058cb6c..2316120 100644 --- a/main.go +++ b/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 { @@ -34,16 +35,16 @@ type Logger interface { type Exporter struct { Records []Records - records *prometheus.GaugeVec - valid *prometheus.GaugeVec + records *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, - logger: logger, + 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,32 +175,28 @@ 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{} - msg.SetQuestion(hostname(zone, record), dns.StringToType[recordType]) - - response, _, err = e.dnsClient.Exchange(msg, e.resolver) - if err != nil { - e.logger.Printf("while looking up RRSet for %v type %v: %v", hostname(zone, record), recordType, 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 } +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, resolver) + if err != nil { + e.logger.Printf("while resolving for %v: %v", hostname(zone, record), err) + return + } + + return response.AuthenticatedData && + !response.CheckingDisabled && + response.Rcode == dns.RcodeSuccess + +} + func hostname(zone, record string) string { if record == "@" { @@ -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) diff --git a/main_test.go b/main_test.go index ab7b047..710ef2d 100644 --- a/main_test.go +++ b/main_test.go @@ -1,10 +1,7 @@ package main import ( - "crypto" "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" "io/ioutil" "log" "net" @@ -15,16 +12,17 @@ import ( ) type opts struct { - signed time.Time - expires time.Time - privkey crypto.PrivateKey + signed time.Time + expires time.Time + 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") } }