commit 5f709d4a527f7f52e6b1df28c35e79562b65ef49 Author: Christian Joergensen Date: Thu Oct 4 12:36:49 2018 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..437f82f --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# DNSSEC Exporter for Prometheus + +Check for validity and expiration in DNSSEC signatures and expose metrics for Prometheus + +## Installation + + $ go get -u github.com/chrj/prometheus-dnssec-exporter + +## Usage + + Usage of prometheus-dnssec-exporter: + -config string + 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") + -timeout duration + Timeout for network operations (default 10s) + +## Metrics + +### Gauge: `dnssec_zone_record_days_left` + +Number of days the signature will be valid. + +Labels: + +* `zone` +* `record` +* `type` + +### Gauge: `dnssec_zone_record_valid` + +Does this record pass DNSSEC validation. + +Labels: + +* `zone` +* `record` +* `type` + +### Examples + + # 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 + +## Configuration + +Supply a configuration file path with `-config` (optionally, defaults to `/etc/dnssec-checks`). Uses [TOML](https://github.com/toml-lang/toml). + +[Sample configuration file](config.sample) + +## Prometheus target + +Supply a listen address with `-addr` (optionally, defaults to `:9204`), and configure a Prometheus job: + + - job_name: "dnssec" + scrape_interval: "1m" + static_configs: + - targets: + - "server:9204" + +## Prometheus alert + +The real benefit is getting an alert triggered when a signature is nearing expiration or is not longer valid. Check this [sample alert definition](dnssec.rules). diff --git a/config.sample b/config.sample new file mode 100644 index 0000000..13d5d06 --- /dev/null +++ b/config.sample @@ -0,0 +1,9 @@ +[[records]] + zone = "ietf.org" + record = "@" + type = "SOA" + +[[records]] + zone = "verisigninc.com" + record = "@" + type = "SOA" diff --git a/dnssec.rules b/dnssec.rules new file mode 100644 index 0000000..aa19685 --- /dev/null +++ b/dnssec.rules @@ -0,0 +1,19 @@ +groups: +- name: prometheus/alerts/dnssec.rules + rules: + - alert: DNSSECSignatureExpiration + expr: dnssec_zone_record_days_left < 10 + for: 1m + labels: + urgency: immediate + annotations: + 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 + 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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f5949c7 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/chrj/prometheus-dnssec-exporter + +require ( + github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 // indirect + github.com/golang/protobuf v1.2.0 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/miekg/dns v1.0.12 + github.com/naoina/go-stringutil v0.1.0 // indirect + github.com/naoina/toml v0.1.1 + github.com/prometheus/client_golang v0.8.0 + github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 // indirect + github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e // indirect + github.com/prometheus/procfs v0.0.0-20180920065004-418d78d0b9a7 // indirect + golang.org/x/crypto v0.0.0-20180927165925-5295e8364332 // indirect + golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3 // indirect + golang.org/x/sys v0.0.0-20180928133829-e4b3c5e90611 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6a41931 --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.12 h1:814rTNaw7Q7pGncpSEDT06YS8rdGmpUEnKgpQzctJsk= +github.com/miekg/dns v1.0.12/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/naoina/go-stringutil v0.1.0 h1:rCUeRUHjBjGTSHl0VC00jUPLz8/F9dDzYI70Hzifhks= +github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= +github.com/naoina/toml v0.1.1 h1:PT/lllxVVN0gzzSqSlHEmP8MJB4MY2U7STGxiouV4X8= +github.com/naoina/toml v0.1.1/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= +github.com/prometheus/client_golang v0.8.0 h1:1921Yw9Gc3iSc4VQh3PIoOqgPCZS7G/4xQNVUp8Mda8= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e h1:n/3MEhJQjQxrOUCzh1Y3Re6aJUUWRp2M9+Oc3eVn/54= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/procfs v0.0.0-20180920065004-418d78d0b9a7 h1:NgR6WN8nQ4SmFC1sSUHY8SriLuWCZ6cCIQtH4vDZN3c= +github.com/prometheus/procfs v0.0.0-20180920065004-418d78d0b9a7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +golang.org/x/crypto v0.0.0-20180927165925-5295e8364332 h1:hvQVdF6P9DX4OiKA5tpehlG6JsgzmyQiThG7q5Bn3UQ= +golang.org/x/crypto v0.0.0-20180927165925-5295e8364332/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3 h1:dgd4x4kJt7G4k4m93AYLzM8Ni6h2qLTfh9n9vXJT3/0= +golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sys v0.0.0-20180928133829-e4b3c5e90611 h1:O33LKL7WyJgjN9CvxfTIomjIClbd/Kq86/iipowHQU0= +golang.org/x/sys v0.0.0-20180928133829-e4b3c5e90611/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..23a3531 --- /dev/null +++ b/main.go @@ -0,0 +1,229 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" + + "github.com/miekg/dns" + "github.com/naoina/toml" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +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 timeout = flag.Duration("timeout", 10*time.Second, "Timeout for network operations") + +var dnsClient *dns.Client + +type Records struct { + Zone string + Record string + Type string +} + +type Exporter struct { + Records []Records + + records *prometheus.GaugeVec + valid *prometheus.GaugeVec +} + +func NewDNSSECExporter() *Exporter { + return &Exporter{ + records: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "dnssec", + Subsystem: "zone", + Name: "record_days_left", + Help: "Number of days the signature will be valid", + }, + []string{ + "zone", + "record", + "type", + }, + ), + valid: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "dnssec", + Subsystem: "zone", + Name: "record_valid", + Help: "Does this record pass DNSSEC validation", + }, + []string{ + "zone", + "record", + "type", + }, + ), + } +} + +func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { + e.records.Describe(ch) + e.valid.Describe(ch) +} + +func (e *Exporter) Collect(ch chan<- prometheus.Metric) { + + var wg sync.WaitGroup + + wg.Add(len(e.Records)) + + for _, rec := range e.Records { + + rec := rec + + go func() { + e.collectRecord(rec.Zone, rec.Record, rec.Type) + wg.Done() + }() + + } + + wg.Wait() + + e.records.Collect(ch) + e.valid.Collect(ch) + +} + +func (e *Exporter) collectRecord(zone, record, recordType string) { + + // Start by finding the DNSKEY + + msg := &dns.Msg{} + msg.SetQuestion(fmt.Sprintf("%s.", zone), dns.TypeDNSKEY) + + response, _, err := dnsClient.Exchange(msg, *resolver) + if err != nil { + log.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 { + log.Printf("didn't find DNSKEY for %v", zone) + } + + // Now lookup the signature + + msg = &dns.Msg{} + msg.SetQuestion(hostname(zone, record), dns.TypeRRSIG) + + response, _, err = dnsClient.Exchange(msg, *resolver) + if err != nil { + log.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 { + + if rrsig.TypeCovered == dns.StringToType[recordType] && + keys[rrsig.KeyTag] != nil { + + sig = rrsig + key = keys[rrsig.KeyTag] + break + + } + + } + } + + if sig == nil { + log.Printf("didn't find RRSIG for %v covering type %v", hostname(zone, record), recordType) + return + } + + exp := time.Unix(int64(sig.Expiration), 0) + if exp.IsZero() { + log.Print("zero exp") + return + } + + e.records.WithLabelValues( + zone, record, recordType, + ).Set(float64(time.Until(exp)/time.Hour) / 24) + + // 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 = dnsClient.Exchange(msg, *resolver) + if err != nil { + log.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 { + e.valid.WithLabelValues(zone, record, recordType).Set(1) + } else { + log.Printf("verify error for %v type %v): %v", hostname(zone, record), recordType, err) + e.valid.WithLabelValues(zone, record, recordType).Set(0) + } + +} + +func hostname(zone, record string) string { + + if record == "@" { + return fmt.Sprintf("%s.", zone) + } + + return fmt.Sprintf("%s.%s.", record, zone) + +} + +func main() { + + flag.Parse() + + dnsClient = &dns.Client{ + Net: "tcp", + Timeout: *timeout, + } + + f, err := os.Open(*conf) + if err != nil { + log.Fatalf("couldn't open configuration file: %v", err) + } + + exporter := NewDNSSECExporter() + + if err := toml.NewDecoder(f).Decode(exporter); err != nil { + log.Fatalf("couldn't parse configuration file: %v", err) + } + + prometheus.MustRegister(exporter) + + http.Handle("/metrics", promhttp.Handler()) + + log.Fatal(http.ListenAndServe(*addr, nil)) + +}