aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Miek Gieben <miek@miek.nl> 2019-08-29 15:41:59 +0100
committerGravatar GitHub <noreply@github.com> 2019-08-29 15:41:59 +0100
commitb8a0b52a5edc05145588598e7a5e2f00b82bb84d (patch)
tree64c8cb1a06028a4ea69a3df6d74c6f233055e70a
parenteec24cb0138e74eb63f59521681f3e3b3555d4f0 (diff)
downloadcoredns-b8a0b52a5edc05145588598e7a5e2f00b82bb84d.tar.gz
coredns-b8a0b52a5edc05145588598e7a5e2f00b82bb84d.tar.zst
coredns-b8a0b52a5edc05145588598e7a5e2f00b82bb84d.zip
plugin/sign: a plugin that signs zone (#2993)
* plugin/sign: a plugin that signs zones Sign is a plugin that signs zone data (on disk). The README.md details what exactly happens to should be accurate related to the code. Signs are signed with a CSK, resigning and first time signing is all handled by *sign* plugin. Logging with a test zone looks something like this: ~~~ txt [INFO] plugin/sign: Signing "miek.nl." because open plugin/sign/testdata/db.miek.nl.signed: no such file or directory [INFO] plugin/sign: Signed "miek.nl." with key tags "59725" in 11.670985ms, saved in "plugin/sign/testdata/db.miek.nl.signed". Next: 2019-07-20T15:49:06.560Z [INFO] plugin/file: Successfully reloaded zone "miek.nl." in "plugin/sign/testdata/db.miek.nl.signed" with serial 1563636548 [INFO] plugin/sign: Signing "miek.nl." because resign was: 10m0s ago [INFO] plugin/sign: Signed "miek.nl." with key tags "59725" in 2.055895ms, saved in "plugin/sign/testdata/db.miek.nl.signed". Next: 2019-07-20T16:09:06.560Z [INFO] plugin/file: Successfully reloaded zone "miek.nl." in "plugin/sign/testdata/db.miek.nl.signed" with serial 1563637748 ~~~ Signed-off-by: Miek Gieben <miek@miek.nl> * Adjust readme and remove timestamps Signed-off-by: Miek Gieben <miek@miek.nl> * Comment on the newline Signed-off-by: Miek Gieben <miek@miek.nl> * Update plugin/sign/README.md Co-Authored-By: Michael Grosser <development@stp-ip.net>
-rw-r--r--core/dnsserver/zdirectives.go1
-rw-r--r--core/plugin/zplugin.go1
-rw-r--r--go.mod2
-rw-r--r--go.sum8
-rw-r--r--plugin.cfg1
-rw-r--r--plugin/sign/README.md161
-rw-r--r--plugin/sign/dnssec.go20
-rw-r--r--plugin/sign/file.go93
-rw-r--r--plugin/sign/file_test.go43
-rw-r--r--plugin/sign/keys.go119
-rw-r--r--plugin/sign/log_test.go5
-rw-r--r--plugin/sign/nsec.go41
-rw-r--r--plugin/sign/resign_test.go42
-rw-r--r--plugin/sign/setup.go114
-rw-r--r--plugin/sign/setup_test.go75
-rw-r--r--plugin/sign/sign.go37
-rw-r--r--plugin/sign/signer.go222
-rw-r--r--plugin/sign/signer_test.go102
-rw-r--r--plugin/sign/testdata/Kmiek.nl.+013+59725.key5
-rw-r--r--plugin/sign/testdata/Kmiek.nl.+013+59725.private6
-rw-r--r--plugin/sign/testdata/db.miek.nl17
21 files changed, 1114 insertions, 1 deletions
diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go
index b48154448..6dfb99226 100644
--- a/core/dnsserver/zdirectives.go
+++ b/core/dnsserver/zdirectives.go
@@ -51,4 +51,5 @@ var Directives = []string{
"erratic",
"whoami",
"on",
+ "sign",
}
diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go
index e761f8586..d522ed294 100644
--- a/core/plugin/zplugin.go
+++ b/core/plugin/zplugin.go
@@ -40,6 +40,7 @@ import (
_ "github.com/coredns/coredns/plugin/root"
_ "github.com/coredns/coredns/plugin/route53"
_ "github.com/coredns/coredns/plugin/secondary"
+ _ "github.com/coredns/coredns/plugin/sign"
_ "github.com/coredns/coredns/plugin/template"
_ "github.com/coredns/coredns/plugin/tls"
_ "github.com/coredns/coredns/plugin/trace"
diff --git a/go.mod b/go.mod
index 0488188f4..c23644737 100644
--- a/go.mod
+++ b/go.mod
@@ -42,7 +42,7 @@ require (
github.com/tinylib/msgp v1.1.0 // indirect
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 // indirect
go.etcd.io/etcd v0.0.0-20190823073701-67d0c21bb04c
- golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 // indirect
+ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 // indirect
google.golang.org/api v0.9.0
diff --git a/go.sum b/go.sum
index 8a320f2a3..82c84388d 100644
--- a/go.sum
+++ b/go.sum
@@ -353,6 +353,8 @@ golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443 h1:IcSOAf4PyMp3U3XbIEj1/xJ2BjNN2jWv7JoyOsMxXUU=
+golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -382,6 +384,8 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -416,6 +420,8 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190618155005-516e3c20635f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 h1:4y9KwBHBgBNwDbtu44R5o1fdOCQUEXhbk/P4A9WmJq0=
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -458,6 +464,8 @@ google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRn
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190611190212-a7e196e89fd3/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
+google.golang.org/genproto v0.0.0-20190626174449-989357319d63 h1:UsSJe9fhWNSz6emfIGPpH5DF23t7ALo2Pf3sC+/hsdg=
google.golang.org/genproto v0.0.0-20190626174449-989357319d63/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190701230453-710ae3a149df h1:k3DT34vxk64+4bD5x+fRy6U0SXxZehzUHRSYUJcKfII=
google.golang.org/genproto v0.0.0-20190701230453-710ae3a149df/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
diff --git a/plugin.cfg b/plugin.cfg
index 2bd07c557..ff856f724 100644
--- a/plugin.cfg
+++ b/plugin.cfg
@@ -60,3 +60,4 @@ grpc:grpc
erratic:erratic
whoami:whoami
on:github.com/caddyserver/caddy/onevent
+sign:sign
diff --git a/plugin/sign/README.md b/plugin/sign/README.md
new file mode 100644
index 000000000..4753598d4
--- /dev/null
+++ b/plugin/sign/README.md
@@ -0,0 +1,161 @@
+# sign
+
+## Name
+
+*sign* - add DNSSEC records to zone files.
+
+## Description
+
+The *sign* plugin is used to sign (see RFC 6781) zones. In this process DNSSEC resource records are
+added. The signatures that sign the resource records sets have an expiration date, this means the
+signing process must be repeated before this expiration data is reached. Otherwise the zone's data
+will go BAD (RFC 4035, Section 5.5). The *sign* plugin takes care of this. *Sign* works, but has
+a couple of limitations, see the "Bugs" section.
+
+Only NSEC is supported, *sign* does not support NSEC3.
+
+*Sign* works in conjunction with the *file* and *auto* plugins; this plugin **signs** the zones
+files, *auto* and *file* **serve** the zones *data*.
+
+For this plugin to work at least one Common Signing Key, (see coredns-keygen(1)) is needed. This key
+(or keys) will be used to sign the entire zone. *Sign* does not support the ZSK/KSK split, nor will
+it do key or algorithm rollovers - it just signs.
+
+*Sign* will:
+
+ * (Re)-sign the zone with the CSK(s) when:
+
+ - the last time it was signed is more than a 6 days ago. Each zone will have some jitter
+ applied to the inception date.
+
+ - the signature only has 14 days left before expiring.
+
+ Both these dates are only checked on the SOA's signature(s).
+
+ * Create signatures that have an inception of -3 hours (minus a jitter between 0 and 18 hours)
+ and a expiration of +32 days for every given DNSKEY.
+
+ * Add or replace *all* apex CDS/CDNSKEY records with the ones derived from the given keys. For
+ each key two CDS are created one with SHA1 and another with SHA256.
+
+ * Update the SOA's serial number to the *Unix epoch* of when the signing happens. This will
+ overwrite *any* previous serial number.
+
+Thus there are two ways that dictate when a zone is signed. Normally every 6 days (plus jitter) it
+will be resigned. If for some reason we fail this check, the 14 days before expiring kicks in.
+
+Keys are named (following BIND9): `K<name>+<alg>+<id>.key` and `K<name>+<alg>+<id>.private`.
+The keys **must not** be included in your zone; they will be added by *sign*. These keys can be
+generated with `coredns-keygen` or BIND9's `dnssec-keygen`. You don't have to adhere to this naming
+scheme, but then you need to name your keys explicitly, see the `keys file` directive.
+
+A generated zone is written out in a file named `db.<name>.signed` in the directory named by the
+`directory` directive (which defaults to `/var/lib/coredns`).
+
+## Syntax
+
+~~~
+sign DBFILE [ZONES...] {
+ key file|directory KEY...|DIR...
+ directory DIR
+}
+~~~
+
+* **DBFILE** the zone database file to read and parse. If the path is relative, the path from the
+ *root* directive will be prepended to it.
+* **ZONES** zones it should be sign for. If empty, the zones from the configuration block are
+ used.
+* `key` specifies the key(s) (there can be multiple) to sign the zone. If `file` is
+ used the **KEY**'s filenames are used as is. If `directory` is used, *sign* will look in **DIR**
+ for `K<name>+<alg>+<id>` files. Any metadata in these files (Activate, Publish, etc.) is
+ *ignored*. These keys must also be Key Signing Keys (KSK).
+* `directory` specifies the **DIR** where CoreDNS should save zones that have been signed.
+ If not given this defaults to `/var/lib/coredns`. The zones are saved under the name
+ `db.<name>.signed`. If the path is relative the path from the *root* directive will be prepended
+ to it.
+
+Keys can be generated with `coredns-keygen`, to create one for use in the *sign* plugin, use:
+`coredns-keygen example.org` or `dnssec-keygen -a ECDSAP256SHA256 -f KSK example.org`.
+
+## Examples
+
+Sign the `example.org` zone contained in the file `db.example.org` and write the result to
+`./db.example.org.signed` to let the *file* plugin pick it up and serve it. The keys used
+are read from `/etc/coredns/keys/Kexample.org.key` and `/etc/coredns/keys/Kexample.org.private`.
+
+~~~ txt
+example.org {
+ file db.example.org.signed
+
+ sign db.example.org {
+ key file /etc/coredns/keys/Kexample.org
+ directory .
+ }
+}
+~~~
+
+Running this leads to the following log output (note the timers in this example have been set to
+shorter intervals).
+
+~~~ txt
+[WARNING] plugin/file: Failed to open "open /tmp/db.example.org.signed: no such file or directory": trying again in 1m0s
+[INFO] plugin/sign: Signing "example.org." because open /tmp/db.example.org.signed: no such file or directory
+[INFO] plugin/sign: Successfully signed zone "example.org." in "/tmp/db.example.org.signed" with key tags "59725" and 1564766865 SOA serial, elapsed 9.357933ms, next: 2019-08-02T22:27:45.270Z
+[INFO] plugin/file: Successfully reloaded zone "example.org." in "/tmp/db.example.org.signed" with serial 1564766865
+~~~
+
+Or use a single zone file for *multiple* zones, note that the **ZONES** are repeated for both plugins.
+Also note this outputs *multiple* signed output files. Here we use the default output directory
+`/var/lib/coredns`.
+
+~~~ txt
+. {
+ file /var/lib/coredns/db.example.org.signed example.org
+ file /var/lib/coredns/db.example.net.signed example.net
+ sign db.example.org example.org example.net {
+ key directory /etc/coredns/keys
+ }
+}
+~~~
+
+This is the same configuration, but the zones are put in the server block, but note that you still
+need to specify what file is served for what zone in the *file* plugin:
+
+~~~ txt
+example.org example.net {
+ file var/lib/coredns/db.example.org.signed example.org
+ file var/lib/coredns/db.example.net.signed example.net
+ sign db.example.org {
+ key directory /etc/coredns/keys
+ }
+}
+~~~
+
+Be careful to fully list the origins you want to sign, if you don't:
+
+~~~ txt
+example.org example.net {
+ sign plugin/sign/testdata/db.example.org miek.org {
+ key file /etc/coredns/keys/Kexample.org
+ }
+}
+~~~
+
+This will lead to `db.example.org` be signed *twice*, as this entire section is parsed twice because
+you have specified the origins `example.org` and `example.net` in the server block.
+
+Forcibly resigning a zone can be accomplished by removing the signed zone file (CoreDNS will keep on
+serving it from memory), and sending SIGUSR1 to the process to make it reload and resign the zone
+file.
+
+## Also See
+
+The DNSSEC RFCs: RFC 4033, RFC 4034 and RFC 4035. And the BCP on DNSSEC, RFC 6781. Further more the
+manual pages coredns-keygen(1) and dnssec-keygen(8). And the *file* plugin's documentation.
+
+Coredns-keygen can be found at <https://github.com/coredns/coredns-utils> in the coredns-keygen directory.
+
+## Bugs
+
+`keys directory` is not implemented. Glue records are currently signed, and no DS records are added
+for child zones.
diff --git a/plugin/sign/dnssec.go b/plugin/sign/dnssec.go
new file mode 100644
index 000000000..a95e08644
--- /dev/null
+++ b/plugin/sign/dnssec.go
@@ -0,0 +1,20 @@
+package sign
+
+import (
+ "github.com/miekg/dns"
+)
+
+func (p Pair) signRRs(rrs []dns.RR, signerName string, ttl, incep, expir uint32) (*dns.RRSIG, error) {
+ rrsig := &dns.RRSIG{
+ Hdr: dns.RR_Header{Rrtype: dns.TypeRRSIG, Ttl: ttl},
+ Algorithm: p.Public.Algorithm,
+ SignerName: signerName,
+ KeyTag: p.KeyTag,
+ OrigTtl: ttl,
+ Inception: incep,
+ Expiration: expir,
+ }
+
+ e := rrsig.Sign(p.Private, rrs)
+ return rrsig, e
+}
diff --git a/plugin/sign/file.go b/plugin/sign/file.go
new file mode 100644
index 000000000..b1190126d
--- /dev/null
+++ b/plugin/sign/file.go
@@ -0,0 +1,93 @@
+package sign
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+
+ "github.com/coredns/coredns/plugin/file"
+ "github.com/coredns/coredns/plugin/file/tree"
+
+ "github.com/miekg/dns"
+)
+
+// write writes out the zone file to a temporary file which is then moved into the correct place.
+func (s *Signer) write(z *file.Zone) error {
+ f, err := ioutil.TempFile(s.directory, "signed-")
+ if err != nil {
+ return err
+ }
+
+ if err := write(f, z); err != nil {
+ f.Close()
+ return err
+ }
+
+ f.Close()
+ return os.Rename(f.Name(), filepath.Join(s.directory, s.signedfile))
+}
+
+func write(w io.Writer, z *file.Zone) error {
+ if _, err := io.WriteString(w, z.Apex.SOA.String()); err != nil {
+ return err
+ }
+ w.Write([]byte("\n")) // RR Stringer() method doesn't include newline, which ends the RR in a zone file, write that here.
+ for _, rr := range z.Apex.SIGSOA {
+ io.WriteString(w, rr.String())
+ w.Write([]byte("\n"))
+ }
+ for _, rr := range z.Apex.NS {
+ io.WriteString(w, rr.String())
+ w.Write([]byte("\n"))
+ }
+ for _, rr := range z.Apex.SIGNS {
+ io.WriteString(w, rr.String())
+ w.Write([]byte("\n"))
+ }
+ err := z.Walk(func(e *tree.Elem, _ map[uint16][]dns.RR) error {
+ for _, r := range e.All() {
+ io.WriteString(w, r.String())
+ w.Write([]byte("\n"))
+ }
+ return nil
+ })
+ return err
+}
+
+// Parse parses the zone in filename and returns a new Zone or an error. This
+// is similar to the Parse function in the *file* plugin. However when parsing
+// the record types DNSKEY, RRSIG, CDNSKEY and CDS are *not* included in the returned
+// zone (if encountered).
+func Parse(f io.Reader, origin, fileName string) (*file.Zone, error) {
+ zp := dns.NewZoneParser(f, dns.Fqdn(origin), fileName)
+ zp.SetIncludeAllowed(true)
+ z := file.NewZone(origin, fileName)
+ seenSOA := false
+
+ for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
+ if err := zp.Err(); err != nil {
+ return nil, err
+ }
+
+ switch rr.(type) {
+ case *dns.DNSKEY, *dns.RRSIG, *dns.CDNSKEY, *dns.CDS:
+ continue
+ case *dns.SOA:
+ seenSOA = true
+ if err := z.Insert(rr); err != nil {
+ return nil, err
+ }
+ default:
+ if err := z.Insert(rr); err != nil {
+ return nil, err
+ }
+ }
+ }
+ if !seenSOA {
+ return nil, fmt.Errorf("file %q has no SOA record", fileName)
+ }
+
+ return z, nil
+}
diff --git a/plugin/sign/file_test.go b/plugin/sign/file_test.go
new file mode 100644
index 000000000..72d2b02ac
--- /dev/null
+++ b/plugin/sign/file_test.go
@@ -0,0 +1,43 @@
+package sign
+
+import (
+ "os"
+ "testing"
+
+ "github.com/miekg/dns"
+)
+
+func TestFileParse(t *testing.T) {
+ f, err := os.Open("testdata/db.miek.nl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ z, err := Parse(f, "miek.nl.", "testdata/db.miek.nl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := &Signer{
+ directory: ".",
+ signedfile: "db.miek.nl.test",
+ }
+
+ s.write(z)
+ defer os.Remove("db.miek.nl.test")
+
+ f, err = os.Open("db.miek.nl.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ z, err = Parse(f, "miek.nl.", "db.miek.nl.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if x := z.Apex.SOA.Header().Name; x != "miek.nl." {
+ t.Errorf("Expected SOA name to be %s, got %s", x, "miek.nl.")
+ }
+ apex, _ := z.Search("miek.nl.")
+ key := apex.Type(dns.TypeDNSKEY)
+ if key != nil {
+ t.Errorf("Expected no DNSKEYs, but got %d", len(key))
+ }
+}
diff --git a/plugin/sign/keys.go b/plugin/sign/keys.go
new file mode 100644
index 000000000..346175be0
--- /dev/null
+++ b/plugin/sign/keys.go
@@ -0,0 +1,119 @@
+package sign
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/coredns/coredns/core/dnsserver"
+
+ "github.com/caddyserver/caddy"
+ "github.com/miekg/dns"
+ "golang.org/x/crypto/ed25519"
+)
+
+// Pair holds DNSSEC key information, both the public and private components are stored here.
+type Pair struct {
+ Public *dns.DNSKEY
+ KeyTag uint16
+ Private crypto.Signer
+}
+
+// keyParse reads the public and private key from disk.
+func keyParse(c *caddy.Controller) ([]Pair, error) {
+ if !c.NextArg() {
+ return nil, c.ArgErr()
+ }
+ pairs := []Pair{}
+ config := dnsserver.GetConfig(c)
+
+ switch c.Val() {
+ case "file":
+ ks := c.RemainingArgs()
+ if len(ks) == 0 {
+ return nil, c.ArgErr()
+ }
+ for _, k := range ks {
+ base := k
+ // Kmiek.nl.+013+26205.key, handle .private or without extension: Kmiek.nl.+013+26205
+ if strings.HasSuffix(k, ".key") {
+ base = k[:len(k)-4]
+ }
+ if strings.HasSuffix(k, ".private") {
+ base = k[:len(k)-8]
+ }
+ if !filepath.IsAbs(base) && config.Root != "" {
+ base = filepath.Join(config.Root, base)
+ }
+
+ pair, err := readKeyPair(base+".key", base+".private")
+ if err != nil {
+ return nil, err
+ }
+ pairs = append(pairs, pair)
+ }
+ case "directory":
+ return nil, fmt.Errorf("directory: not implemented")
+ }
+
+ return pairs, nil
+}
+
+func readKeyPair(public, private string) (Pair, error) {
+ rk, err := os.Open(public)
+ if err != nil {
+ return Pair{}, err
+ }
+ b, err := ioutil.ReadAll(rk)
+ if err != nil {
+ return Pair{}, err
+ }
+ dnskey, err := dns.NewRR(string(b))
+ if err != nil {
+ return Pair{}, err
+ }
+ if _, ok := dnskey.(*dns.DNSKEY); !ok {
+ return Pair{}, fmt.Errorf("RR in %q is not a DNSKEY: %d", public, dnskey.Header().Rrtype)
+ }
+ ksk := dnskey.(*dns.DNSKEY).Flags&(1<<8) == (1<<8) && dnskey.(*dns.DNSKEY).Flags&1 == 1
+ if !ksk {
+ return Pair{}, fmt.Errorf("DNSKEY in %q is not a CSK/KSK", public)
+ }
+
+ rp, err := os.Open(private)
+ if err != nil {
+ return Pair{}, err
+ }
+ privkey, err := dnskey.(*dns.DNSKEY).ReadPrivateKey(rp, private)
+ if err != nil {
+ return Pair{}, err
+ }
+ switch signer := privkey.(type) {
+ case *ecdsa.PrivateKey:
+ return Pair{Public: dnskey.(*dns.DNSKEY), KeyTag: dnskey.(*dns.DNSKEY).KeyTag(), Private: signer}, nil
+ case *ed25519.PrivateKey:
+ return Pair{Public: dnskey.(*dns.DNSKEY), KeyTag: dnskey.(*dns.DNSKEY).KeyTag(), Private: signer}, nil
+ case *rsa.PrivateKey:
+ return Pair{Public: dnskey.(*dns.DNSKEY), KeyTag: dnskey.(*dns.DNSKEY).KeyTag(), Private: signer}, nil
+ default:
+ return Pair{}, fmt.Errorf("unsupported algorithm %s", signer)
+ }
+}
+
+// keyTag returns the key tags of the keys in ps as a formatted string.
+func keyTag(ps []Pair) string {
+ if len(ps) == 0 {
+ return ""
+ }
+ s := ""
+ for _, p := range ps {
+ s += strconv.Itoa(int(p.KeyTag)) + ","
+ }
+ return s[:len(s)-1]
+}
diff --git a/plugin/sign/log_test.go b/plugin/sign/log_test.go
new file mode 100644
index 000000000..2726cd179
--- /dev/null
+++ b/plugin/sign/log_test.go
@@ -0,0 +1,5 @@
+package sign
+
+import clog "github.com/coredns/coredns/plugin/pkg/log"
+
+func init() { clog.Discard() }
diff --git a/plugin/sign/nsec.go b/plugin/sign/nsec.go
new file mode 100644
index 000000000..d726ade72
--- /dev/null
+++ b/plugin/sign/nsec.go
@@ -0,0 +1,41 @@
+package sign
+
+import (
+ "sort"
+
+ "github.com/coredns/coredns/plugin/file"
+ "github.com/coredns/coredns/plugin/file/tree"
+
+ "github.com/miekg/dns"
+)
+
+// names returns the elements of the zone in nsec order. If the returned boolean is true there were
+// no other apex records than SOA and NS, which are stored separately.
+func names(origin string, z *file.Zone) ([]string, bool) {
+ // if there are no apex records other than NS and SOA we'll miss the origin
+ // in this list. Check the first element and if not origin prepend it.
+ n := []string{}
+ z.Walk(func(e *tree.Elem, _ map[uint16][]dns.RR) error {
+ n = append(n, e.Name())
+ return nil
+ })
+ if len(n) == 0 {
+ return nil, false
+ }
+ if n[0] != origin {
+ n = append([]string{origin}, n...)
+ return n, true
+ }
+ return n, false
+}
+
+// NSEC returns an NSEC record according to name, next, ttl and bitmap. Note that the bitmap is sorted before use.
+func NSEC(name, next string, ttl uint32, bitmap []uint16) *dns.NSEC {
+ sort.Slice(bitmap, func(i, j int) bool { return bitmap[i] < bitmap[j] })
+
+ return &dns.NSEC{
+ Hdr: dns.RR_Header{Name: name, Ttl: ttl, Rrtype: dns.TypeNSEC, Class: dns.ClassINET},
+ NextDomain: next,
+ TypeBitMap: bitmap,
+ }
+}
diff --git a/plugin/sign/resign_test.go b/plugin/sign/resign_test.go
new file mode 100644
index 000000000..e2571cb5b
--- /dev/null
+++ b/plugin/sign/resign_test.go
@@ -0,0 +1,42 @@
+package sign
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestResignInception(t *testing.T) {
+ then := time.Date(2019, 7, 18, 22, 50, 0, 0, time.UTC)
+ // signed yesterday
+ zr := strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190808191936 20190717161936 59725 miek.nl. eU6gI1OkSEbyt`)
+ if x := resign(zr, then); x != nil {
+ t.Errorf("Expected RRSIG to be valid for %s, got invalid: %s", then.Format(timeFmt), x)
+ }
+
+ // inception starts after this date.
+ zr = strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190808191936 20190731161936 59725 miek.nl. eU6gI1OkSEbyt`)
+ if x := resign(zr, then); x == nil {
+ t.Errorf("Expected RRSIG to be invalid for %s, got valid", then.Format(timeFmt))
+ }
+}
+
+func TestResignExpire(t *testing.T) {
+ then := time.Date(2019, 7, 18, 22, 50, 0, 0, time.UTC)
+ // expires tomorrow
+ zr := strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190717191936 20190717161936 59725 miek.nl. eU6gI1OkSEbyt`)
+ if x := resign(zr, then); x == nil {
+ t.Errorf("Expected RRSIG to be invalid for %s, got valid", then.Format(timeFmt))
+ }
+ // expire too far away
+ zr = strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190731191936 20190717161936 59725 miek.nl. eU6gI1OkSEbyt`)
+ if x := resign(zr, then); x != nil {
+ t.Errorf("Expected RRSIG to be valid for %s, got invalid: %s", then.Format(timeFmt), x)
+ }
+
+ // expired yesterday
+ zr = strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190721191936 20190717161936 59725 miek.nl. eU6gI1OkSEbyt`)
+ if x := resign(zr, then); x == nil {
+ t.Errorf("Expected RRSIG to be invalid for %s, got valid", then.Format(timeFmt))
+ }
+}
diff --git a/plugin/sign/setup.go b/plugin/sign/setup.go
new file mode 100644
index 000000000..6cc3b711d
--- /dev/null
+++ b/plugin/sign/setup.go
@@ -0,0 +1,114 @@
+package sign
+
+import (
+ "fmt"
+ "math/rand"
+ "path/filepath"
+ "time"
+
+ "github.com/coredns/coredns/core/dnsserver"
+ "github.com/coredns/coredns/plugin"
+
+ "github.com/caddyserver/caddy"
+)
+
+func init() {
+ caddy.RegisterPlugin("sign", caddy.Plugin{
+ ServerType: "dns",
+ Action: setup,
+ })
+}
+
+func setup(c *caddy.Controller) error {
+ sign, err := parse(c)
+ if err != nil {
+ return plugin.Error("sign", err)
+ }
+
+ c.OnStartup(sign.OnStartup)
+ c.OnStartup(func() error {
+ for _, signer := range sign.signers {
+ go signer.refresh(DurationRefreshHours)
+ }
+ return nil
+ })
+ c.OnShutdown(func() error {
+ for _, signer := range sign.signers {
+ close(signer.stop)
+ }
+ return nil
+ })
+
+ // Don't call AddPlugin, *sign* is not a plugin.
+ return nil
+}
+
+func parse(c *caddy.Controller) (*Sign, error) {
+ sign := &Sign{}
+ config := dnsserver.GetConfig(c)
+
+ for c.Next() {
+ if !c.NextArg() {
+ return nil, c.ArgErr()
+ }
+ dbfile := c.Val()
+ if !filepath.IsAbs(dbfile) && config.Root != "" {
+ dbfile = filepath.Join(config.Root, dbfile)
+ }
+
+ origins := make([]string, len(c.ServerBlockKeys))
+ copy(origins, c.ServerBlockKeys)
+ args := c.RemainingArgs()
+ if len(args) > 0 {
+ origins = args
+ }
+ for i := range origins {
+ origins[i] = plugin.Host(origins[i]).Normalize()
+ }
+
+ signers := make([]*Signer, len(origins))
+ for i := range origins {
+ signers[i] = &Signer{
+ dbfile: dbfile,
+ origin: plugin.Host(origins[i]).Normalize(),
+ jitter: time.Duration(float32(DurationJitter) * rand.Float32()),
+ directory: "/var/lib/coredns",
+ stop: make(chan struct{}),
+ signedfile: fmt.Sprintf("db.%ssigned", origins[i]), // origins[i] is a fqdn, so it ends with a dot, hence %ssigned.
+ }
+ }
+
+ for c.NextBlock() {
+ switch c.Val() {
+ case "key":
+ pairs, err := keyParse(c)
+ if err != nil {
+ return sign, err
+ }
+ for i := range signers {
+ for _, p := range pairs {
+ p.Public.Header().Name = signers[i].origin
+ }
+ signers[i].keys = append(signers[i].keys, pairs...)
+ }
+ case "directory":
+ dir := c.RemainingArgs()
+ if len(dir) == 0 || len(dir) > 1 {
+ return sign, fmt.Errorf("can only be one argument after %q", "directory")
+ }
+ if !filepath.IsAbs(dir[0]) && config.Root != "" {
+ dir[0] = filepath.Join(config.Root, dir[0])
+ }
+ for i := range signers {
+ signers[i].directory = dir[0]
+ signers[i].signedfile = fmt.Sprintf("db.%ssigned", signers[i].origin)
+ }
+ default:
+ return nil, c.Errf("unknown property '%s'", c.Val())
+ }
+ }
+ sign.signers = append(sign.signers, signers...)
+ }
+
+ return sign, nil
+}
diff --git a/plugin/sign/setup_test.go b/plugin/sign/setup_test.go
new file mode 100644
index 000000000..ce25720a0
--- /dev/null
+++ b/plugin/sign/setup_test.go
@@ -0,0 +1,75 @@
+package sign
+
+import (
+ "testing"
+
+ "github.com/caddyserver/caddy"
+)
+
+func TestParse(t *testing.T) {
+ tests := []struct {
+ input string
+ shouldErr bool
+ exp *Signer
+ }{
+ {`sign testdata/db.miek.nl miek.nl {
+ key file testdata/Kmiek.nl.+013+59725
+ }`,
+ false,
+ &Signer{
+ keys: []Pair{},
+ origin: "miek.nl.",
+ dbfile: "testdata/db.miek.nl",
+ directory: "/var/lib/coredns",
+ signedfile: "db.miek.nl.signed",
+ },
+ },
+ {`sign testdata/db.miek.nl example.org {
+ key file testdata/Kmiek.nl.+013+59725
+ directory testdata
+ }`,
+ false,
+ &Signer{
+ keys: []Pair{},
+ origin: "example.org.",
+ dbfile: "testdata/db.miek.nl",
+ directory: "testdata",
+ signedfile: "db.example.org.signed",
+ },
+ },
+ // errors
+ {`sign db.example.org {
+ key file /etc/coredns/keys/Kexample.org
+ }`,
+ true,
+ nil,
+ },
+ }
+ for i, tc := range tests {
+ c := caddy.NewTestController("dns", tc.input)
+ sign, err := parse(c)
+
+ if err == nil && tc.shouldErr {
+ t.Fatalf("Test %d expected errors, but got no error", i)
+ }
+ if err != nil && !tc.shouldErr {
+ t.Fatalf("Test %d expected no errors, but got '%v'", i, err)
+ }
+ if tc.shouldErr {
+ continue
+ }
+ signer := sign.signers[0]
+ if x := signer.origin; x != tc.exp.origin {
+ t.Errorf("Test %d expected %s as origin, got %s", i, tc.exp.origin, x)
+ }
+ if x := signer.dbfile; x != tc.exp.dbfile {
+ t.Errorf("Test %d expected %s as dbfile, got %s", i, tc.exp.dbfile, x)
+ }
+ if x := signer.directory; x != tc.exp.directory {
+ t.Errorf("Test %d expected %s as directory, got %s", i, tc.exp.directory, x)
+ }
+ if x := signer.signedfile; x != tc.exp.signedfile {
+ t.Errorf("Test %d expected %s as signedfile, got %s", i, tc.exp.signedfile, x)
+ }
+ }
+}
diff --git a/plugin/sign/sign.go b/plugin/sign/sign.go
new file mode 100644
index 000000000..ebe4522e2
--- /dev/null
+++ b/plugin/sign/sign.go
@@ -0,0 +1,37 @@
+// Package sign implements a zone signer as a plugin.
+package sign
+
+import (
+ "path/filepath"
+ "time"
+)
+
+// Sign contains signers that sign the zones files.
+type Sign struct {
+ signers []*Signer
+}
+
+// OnStartup scans all signers and signs or resigns zones if needed.
+func (s *Sign) OnStartup() error {
+ for _, signer := range s.signers {
+ why := signer.resign()
+ if why == nil {
+ log.Infof("Skipping signing zone %q in %q: signatures are valid", signer.origin, filepath.Join(signer.directory, signer.signedfile))
+ continue
+ }
+ go signAndLog(signer, why)
+ }
+ return nil
+}
+
+// Various duration constants for signing of the zones.
+const (
+ DurationExpireDays = 7 * 24 * time.Hour // max time allowed before expiration
+ DurationResignDays = 6 * 24 * time.Hour // if the last sign happenend this long ago, sign again
+ DurationSignatureExpireDays = 32 * 24 * time.Hour // sign for 32 days
+ DurationRefreshHours = 5 * time.Hour // check zones every 5 hours
+ DurationJitter = -18 * time.Hour // default max jitter
+ DurationSignatureInceptionHours = -3 * time.Hour // -(2+1) hours, be sure to catch daylight saving time and such, jitter is substracted
+)
+
+const timeFmt = "2006-01-02T15:04:05.000Z07:00"
diff --git a/plugin/sign/signer.go b/plugin/sign/signer.go
new file mode 100644
index 000000000..a9a5f2e5a
--- /dev/null
+++ b/plugin/sign/signer.go
@@ -0,0 +1,222 @@
+package sign
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/coredns/coredns/plugin/file"
+ "github.com/coredns/coredns/plugin/file/tree"
+ clog "github.com/coredns/coredns/plugin/pkg/log"
+
+ "github.com/miekg/dns"
+)
+
+var log = clog.NewWithPlugin("sign")
+
+// Signer holds the data needed to sign a zone file.
+type Signer struct {
+ keys []Pair
+ origin string
+ dbfile string
+ directory string
+ jitter time.Duration
+
+ signedfile string
+ stop chan struct{}
+
+ expiration uint32
+ inception uint32
+ ttl uint32
+}
+
+// Sign signs a zone file according to the parameters in s.
+func (s *Signer) Sign(now time.Time) (*file.Zone, error) {
+ rd, err := os.Open(s.dbfile)
+ if err != nil {
+ return nil, err
+ }
+
+ z, err := Parse(rd, s.origin, s.dbfile)
+ if err != nil {
+ return nil, err
+ }
+
+ s.inception, s.expiration = lifetime(now, s.jitter)
+
+ s.ttl = z.Apex.SOA.Header().Ttl
+ z.Apex.SOA.Serial = uint32(now.Unix())
+
+ for _, pair := range s.keys {
+ pair.Public.Header().Ttl = s.ttl // set TTL on key so it matches the RRSIG.
+ z.Insert(pair.Public)
+ z.Insert(pair.Public.ToDS(dns.SHA1))
+ z.Insert(pair.Public.ToDS(dns.SHA256))
+ z.Insert(pair.Public.ToDS(dns.SHA1).ToCDS())
+ z.Insert(pair.Public.ToDS(dns.SHA256).ToCDS())
+ z.Insert(pair.Public.ToCDNSKEY())
+ }
+
+ names, apex := names(s.origin, z)
+ ln := len(names)
+
+ var nsec *dns.NSEC
+ if apex {
+ nsec = NSEC(s.origin, names[(ln+1)%ln], s.ttl, []uint16{dns.TypeSOA, dns.TypeNS, dns.TypeRRSIG, dns.TypeNSEC})
+ z.Insert(nsec)
+ }
+
+ for _, pair := range s.keys {
+ rrsig, err := pair.signRRs([]dns.RR{z.Apex.SOA}, s.origin, s.ttl, s.inception, s.expiration)
+ if err != nil {
+ return nil, err
+ }
+ z.Insert(rrsig)
+ // NS apex may not be set if RR's have been discarded because the origin doesn't match.
+ if len(z.Apex.NS) > 0 {
+ rrsig, err = pair.signRRs(z.Apex.NS, s.origin, s.ttl, s.inception, s.expiration)
+ if err != nil {
+ return nil, err
+ }
+ z.Insert(rrsig)
+ }
+ if apex {
+ rrsig, err = pair.signRRs([]dns.RR{nsec}, s.origin, s.ttl, s.inception, s.expiration)
+ if err != nil {
+ return nil, err
+ }
+ z.Insert(rrsig)
+ }
+ }
+
+ // We are walking the tree in the same direction, so names[] can be used here to indicated the next element.
+ i := 1
+ err = z.Walk(func(e *tree.Elem, zrrs map[uint16][]dns.RR) error {
+ if !apex && e.Name() == s.origin {
+ nsec := NSEC(e.Name(), names[(ln+i)%ln], s.ttl, append(e.Types(), dns.TypeNS, dns.TypeSOA, dns.TypeNSEC, dns.TypeRRSIG))
+ z.Insert(nsec)
+ } else {
+ nsec := NSEC(e.Name(), names[(ln+i)%ln], s.ttl, append(e.Types(), dns.TypeNSEC, dns.TypeRRSIG))
+ z.Insert(nsec)
+ }
+
+ for t, rrs := range zrrs {
+ if t == dns.TypeRRSIG {
+ continue
+ }
+ for _, pair := range s.keys {
+ rrsig, err := pair.signRRs(rrs, s.origin, s.ttl, s.inception, s.expiration)
+ if err != nil {
+ return err
+ }
+ e.Insert(rrsig)
+ }
+ }
+ i++
+ return nil
+ })
+ return z, err
+}
+
+// resign checks if the signed zone exists, or needs resigning.
+func (s *Signer) resign() error {
+ signedfile := filepath.Join(s.directory, s.signedfile)
+ rd, err := os.Open(signedfile)
+ if err != nil && os.IsNotExist(err) {
+ return err
+ }
+
+ now := time.Now().UTC()
+ return resign(rd, now)
+}
+
+// resign will scan rd and check the signature on the SOA record. We will resign on the basis
+// of 2 conditions:
+// * either the inception is more than 6 days ago, or
+// * we only have 1 week left on the signature
+//
+// All SOA signatures will be checked. If the SOA isn't found in the first 100
+// records, we will resign the zone.
+func resign(rd io.Reader, now time.Time) (why error) {
+ zp := dns.NewZoneParser(rd, ".", "resign")
+ zp.SetIncludeAllowed(true)
+ i := 0
+
+ for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
+ if err := zp.Err(); err != nil {
+ return err
+ }
+
+ switch x := rr.(type) {
+ case *dns.RRSIG:
+ if x.TypeCovered != dns.TypeSOA {
+ continue
+ }
+ incep, _ := time.Parse("20060102150405", dns.TimeToString(x.Inception))
+ // If too long ago, resign.
+ if now.Sub(incep) >= 0 && now.Sub(incep) > DurationResignDays {
+ return fmt.Errorf("inception %q was more than: %s ago from %s: %s", incep.Format(timeFmt), DurationResignDays, now.Format(timeFmt), now.Sub(incep))
+ }
+ // Inception hasn't even start yet.
+ if now.Sub(incep) < 0 {
+ return fmt.Errorf("inception %q date is in the future: %s", incep.Format(timeFmt), now.Sub(incep))
+ }
+
+ expire, _ := time.Parse("20060102150405", dns.TimeToString(x.Expiration))
+ if expire.Sub(now) < DurationExpireDays {
+ return fmt.Errorf("expiration %q is less than: %s away from %s: %s", expire.Format(timeFmt), DurationExpireDays, now.Format(timeFmt), expire.Sub(now))
+ }
+ }
+ i++
+ if i > 100 {
+ // 100 is a random number. A SOA record should be the first in the zonefile, but RFC 1035 doesn't actually mandate this. So it could
+ // be 3rd or even later. The number 100 looks crazy high enough that it will catch all weird zones, but not high enough to keep the CPU
+ // busy with parsing all the time.
+ return fmt.Errorf("no SOA RRSIG found in first 100 records")
+ }
+ }
+
+ return nil
+}
+
+func signAndLog(s *Signer, why error) {
+ now := time.Now().UTC()
+ z, err := s.Sign(now)
+ log.Infof("Signing %q because %s", s.origin, why)
+ if err != nil {
+ log.Warningf("Error signing %q with key tags %q in %s: %s, next: %s", s.origin, keyTag(s.keys), time.Since(now), err, now.Add(DurationRefreshHours).Format(timeFmt))
+ return
+ }
+
+ if err := s.write(z); err != nil {
+ log.Warningf("Error signing %q: failed to move zone file into place: %s", s.origin, err)
+ return
+ }
+ log.Infof("Successfully signed zone %q in %q with key tags %q and %d SOA serial, elapsed %f, next: %s", s.origin, filepath.Join(s.directory, s.signedfile), keyTag(s.keys), z.Apex.SOA.Serial, time.Since(now).Seconds(), now.Add(DurationRefreshHours).Format(timeFmt))
+}
+
+// refresh checks every val if some zones need to be resigned.
+func (s *Signer) refresh(val time.Duration) {
+ tick := time.NewTicker(val)
+ defer tick.Stop()
+ for {
+ select {
+ case <-s.stop:
+ return
+ case <-tick.C:
+ why := s.resign()
+ if why == nil {
+ continue
+ }
+ signAndLog(s, why)
+ }
+ }
+}
+
+func lifetime(now time.Time, jitter time.Duration) (uint32, uint32) {
+ incep := uint32(now.Add(DurationSignatureInceptionHours).Add(jitter).Unix())
+ expir := uint32(now.Add(DurationSignatureExpireDays).Unix())
+ return incep, expir
+}
diff --git a/plugin/sign/signer_test.go b/plugin/sign/signer_test.go
new file mode 100644
index 000000000..6ba94247d
--- /dev/null
+++ b/plugin/sign/signer_test.go
@@ -0,0 +1,102 @@
+package sign
+
+import (
+ "io/ioutil"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/caddyserver/caddy"
+ "github.com/miekg/dns"
+)
+
+func TestSign(t *testing.T) {
+ input := `sign testdata/db.miek.nl miek.nl {
+ key file testdata/Kmiek.nl.+013+59725
+ directory testdata
+ }`
+ c := caddy.NewTestController("dns", input)
+ sign, err := parse(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sign.signers) != 1 {
+ t.Fatalf("Expected 1 signer, got %d", len(sign.signers))
+ }
+ z, err := sign.signers[0].Sign(time.Now().UTC())
+ if err != nil {
+ t.Error(err)
+ }
+
+ apex, _ := z.Search("miek.nl.")
+ if x := apex.Type(dns.TypeDS); len(x) != 2 {
+ t.Errorf("Expected %d DS records, got %d", 2, len(x))
+ }
+ if x := apex.Type(dns.TypeCDS); len(x) != 2 {
+ t.Errorf("Expected %d CDS records, got %d", 2, len(x))
+ }
+ if x := apex.Type(dns.TypeCDNSKEY); len(x) != 1 {
+ t.Errorf("Expected %d CDNSKEY record, got %d", 1, len(x))
+ }
+ if x := apex.Type(dns.TypeDNSKEY); len(x) != 1 {
+ t.Errorf("Expected %d DNSKEY record, got %d", 1, len(x))
+ }
+}
+
+func TestSignApexZone(t *testing.T) {
+ apex := `$TTL 30M
+$ORIGIN example.org.
+@ IN SOA linode miek.miek.nl. ( 1282630060 4H 1H 7D 4H )
+ IN NS linode
+`
+ if err := ioutil.WriteFile("db.apex-test.example.org", []byte(apex), 0644); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove("db.apex-test.example.org")
+ input := `sign db.apex-test.example.org example.org {
+ key file testdata/Kmiek.nl.+013+59725
+ directory testdata
+ }`
+ c := caddy.NewTestController("dns", input)
+ sign, err := parse(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ z, err := sign.signers[0].Sign(time.Now().UTC())
+ if err != nil {
+ t.Error(err)
+ }
+
+ el, _ := z.Search("example.org.")
+ nsec := el.Type(dns.TypeNSEC)
+ if len(nsec) != 1 {
+ t.Errorf("Expected 1 NSEC for %s, got %d", "example.org.", len(nsec))
+ }
+ if x := nsec[0].(*dns.NSEC).NextDomain; x != "example.org." {
+ t.Errorf("Expected NSEC NextDomain %s, got %s", "example.org.", x)
+ }
+ if x := nsec[0].(*dns.NSEC).TypeBitMap; len(x) != 8 {
+ t.Errorf("Expected NSEC bitmap to be %d elements, got %d", 8, x)
+ }
+ if x := nsec[0].(*dns.NSEC).TypeBitMap; x[7] != dns.TypeCDNSKEY {
+ t.Errorf("Expected NSEC bitmap element 6 to be %d, got %d", dns.TypeCDNSKEY, x[7])
+ }
+ if x := nsec[0].(*dns.NSEC).TypeBitMap; x[5] != dns.TypeDNSKEY {
+ t.Errorf("Expected NSEC bitmap element 5 to be %d, got %d", dns.TypeDNSKEY, x[5])
+ }
+ dnskey := el.Type(dns.TypeDNSKEY)
+ if x := dnskey[0].Header().Ttl; x != 1800 {
+ t.Errorf("Expected DNSKEY TTL to be %d, got %d", 1800, x)
+ }
+ sigs := el.Type(dns.TypeRRSIG)
+ for _, s := range sigs {
+ if s.(*dns.RRSIG).TypeCovered == dns.TypeDNSKEY {
+ if s.(*dns.RRSIG).OrigTtl != dnskey[0].Header().Ttl {
+ t.Errorf("Expected RRSIG original TTL to match DNSKEY TTL, but %d != %d", s.(*dns.RRSIG).OrigTtl, dnskey[0].Header().Ttl)
+ }
+ if s.(*dns.RRSIG).SignerName != dnskey[0].Header().Name {
+ t.Errorf("Expected RRSIG signer name to match DNSKEY ownername, but %s != %s", s.(*dns.RRSIG).SignerName, dnskey[0].Header().Name)
+ }
+ }
+ }
+}
diff --git a/plugin/sign/testdata/Kmiek.nl.+013+59725.key b/plugin/sign/testdata/Kmiek.nl.+013+59725.key
new file mode 100644
index 000000000..b3e3654e3
--- /dev/null
+++ b/plugin/sign/testdata/Kmiek.nl.+013+59725.key
@@ -0,0 +1,5 @@
+; This is a key-signing key, keyid 59725, for miek.nl.
+; Created: 20190709192036 (Tue Jul 9 20:20:36 2019)
+; Publish: 20190709192036 (Tue Jul 9 20:20:36 2019)
+; Activate: 20190709192036 (Tue Jul 9 20:20:36 2019)
+miek.nl. IN DNSKEY 257 3 13 sfzRg5nDVxbeUc51su4MzjgwpOpUwnuu81SlRHqJuXe3SOYOeypR69tZ 52XLmE56TAmPHsiB8Rgk+NTpf0o1Cw==
diff --git a/plugin/sign/testdata/Kmiek.nl.+013+59725.private b/plugin/sign/testdata/Kmiek.nl.+013+59725.private
new file mode 100644
index 000000000..2545ed9a9
--- /dev/null
+++ b/plugin/sign/testdata/Kmiek.nl.+013+59725.private
@@ -0,0 +1,6 @@
+Private-key-format: v1.3
+Algorithm: 13 (ECDSAP256SHA256)
+PrivateKey: rm7EdHRca//6xKpJzeoLt/mrfgQnltJ0WpQGtOG59yo=
+Created: 20190709192036
+Publish: 20190709192036
+Activate: 20190709192036
diff --git a/plugin/sign/testdata/db.miek.nl b/plugin/sign/testdata/db.miek.nl
new file mode 100644
index 000000000..4041b1b5e
--- /dev/null
+++ b/plugin/sign/testdata/db.miek.nl
@@ -0,0 +1,17 @@
+$TTL 30M
+$ORIGIN miek.nl.
+@ IN SOA linode.atoom.net. miek.miek.nl. ( 1282630060 4H 1H 7D 4H )
+ IN NS linode.atoom.net.
+ IN MX 1 aspmx.l.google.com.
+ IN AAAA 2a01:7e00::f03c:91ff:fe79:234c
+ IN DNSKEY 257 3 13 sfzRg5nDVxbeUc51su4MzjgwpOpUwnuu81SlRHqJuXe3SOYOeypR69tZ52XLmE56TAmPHsiB8Rgk+NTpf0o1Cw==
+
+a IN AAAA 2a01:7e00::f03c:91ff:fe79:234c
+www IN CNAME a
+
+
+bla IN NS ns1.bla.com.
+ns3.blaaat.miek.nl. IN AAAA ::1 ; non-glue, should be signed.
+; in baliwick nameserver that requires glue, should not be signed
+bla IN NS ns2.bla.miek.nl.
+ns2.bla.miek.nl. IN A 127.0.0.1