You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by zr...@apache.org on 2021/05/04 22:58:29 UTC

[trafficcontrol] branch master updated: Fix CDN DNSSEC key generation timeout (#5810)

This is an automated email from the ASF dual-hosted git repository.

zrhoffman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new ed4c3a2  Fix CDN DNSSEC key generation timeout (#5810)
ed4c3a2 is described below

commit ed4c3a2c25c38fa729a82e45474b01a7dbdf7d07
Author: Rawlin Peters <ra...@apache.org>
AuthorDate: Tue May 4 16:58:08 2021 -0600

    Fix CDN DNSSEC key generation timeout (#5810)
    
    * Parallelize CDN DNSSEC key generation
    
    With large numbers of delivery services, serially generating DNSSEC keys
    for a CDN can take longer than the default 20 second timeout, causing
    the DNSSEC key generation operation to time out and fail. Instead,
    generate DNSSEC keys in parallel (by default using 66% of the available
    CPUs) in order to complete DNSSEC key generation more quickly. Also, use
    a separate DB transaction timeout for writing the changelog.
    
    * Address review comments
---
 CHANGELOG.md                                    |   1 +
 docs/source/api/v2/cdns_dnsseckeys_generate.rst |   2 -
 docs/source/api/v3/cdns_dnsseckeys_generate.rst |   2 -
 docs/source/api/v4/cdns_dnsseckeys_generate.rst |   2 -
 lib/go-tc/cdns_dnssec.go                        | 155 ++++++++++++++++++++++++
 lib/go-tc/deliveryservice_ssl_keys.go           | 129 --------------------
 traffic_ops/testing/api/v4/cdns_test.go         |  64 ++++++++++
 traffic_ops/traffic_ops_golang/cdn/dnssec.go    | 116 ++++++++++++++++--
 traffic_ops/v4-client/cdn_dnssec.go             |  51 ++++++++
 9 files changed, 378 insertions(+), 144 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index effd546..79239de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -61,6 +61,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - [#5695](https://github.com/apache/trafficcontrol/issues/5695) - Ensure vitals are calculated only against monitored interfaces
 - [#5724](https://github.com/apache/trafficcontrol/issues/5724) - Set XMPPID to hostname if the server had none, don't error on server update when XMPPID is empty
 - [#5744](https://github.com/apache/trafficcontrol/issues/5744) - Sort TM Delivery Service States page by DS name
+- [#5732](https://github.com/apache/trafficcontrol/issues/5732) - TO API POST /cdns/dnsseckeys/generate times out with large numbers of delivery services
 - Fixed server creation through legacy API versions to default `monitor` to `true`.
 - Fixed Traffic Monitor to report `ONLINE` caches as available.
 - [#5754](https://github.com/apache/trafficcontrol/issues/5754) - Ensure Health Threshold Parameters use legacy format for legacy Monitoring Config handler
diff --git a/docs/source/api/v2/cdns_dnsseckeys_generate.rst b/docs/source/api/v2/cdns_dnsseckeys_generate.rst
index 1b09071..b23e63f 100644
--- a/docs/source/api/v2/cdns_dnsseckeys_generate.rst
+++ b/docs/source/api/v2/cdns_dnsseckeys_generate.rst
@@ -32,7 +32,6 @@ Request Structure
 :effectiveDate:         An optional string containing the date and time at which the newly-generated :abbr:`ZSK (Zone-Signing Key)` and :abbr:`KSK (Key-Signing Key)` become effective, in :RFC:`3339` format. Defaults to the current time if not specified.
 :key:                   Name of the CDN
 :kskExpirationDays:     Expiration (in days) for the :abbr:`KSKs (Key-Signing Keys)`
-:name:                  Domain name used by the CDN
 :ttl:                   Time, in seconds, for which the keypairs shall remain valid
 :zskExpirationDays:     Expiration (in days) for the :abbr:`ZSKs (Zone-Signing Keys)`
 
@@ -50,7 +49,6 @@ Request Structure
 	{
 		"key": "CDN-in-a-Box",
 		"kskExpirationDays": 1095,
-		"name": "mycdn.ciab.test",
 		"ttl": 3600,
 		"zskExpirationDays": 1095
 	}
diff --git a/docs/source/api/v3/cdns_dnsseckeys_generate.rst b/docs/source/api/v3/cdns_dnsseckeys_generate.rst
index 8e2e3ec..a625f4d 100644
--- a/docs/source/api/v3/cdns_dnsseckeys_generate.rst
+++ b/docs/source/api/v3/cdns_dnsseckeys_generate.rst
@@ -32,7 +32,6 @@ Request Structure
 :effectiveDate:         An optional string containing the date and time at which the newly-generated :abbr:`ZSK (Zone-Signing Key)` and :abbr:`KSK (Key-Signing Key)` become effective, in :RFC:`3339` format. Defaults to the current time if not specified.
 :key:                   Name of the CDN
 :kskExpirationDays:     Expiration (in days) for the :abbr:`KSKs (Key-Signing Keys)`
-:name:                  Domain name used by the CDN
 :ttl:                   Time, in seconds, for which the keypairs shall remain valid
 :zskExpirationDays:     Expiration (in days) for the :abbr:`ZSKs (Zone-Signing Keys)`
 
@@ -50,7 +49,6 @@ Request Structure
 	{
 		"key": "CDN-in-a-Box",
 		"kskExpirationDays": 1095,
-		"name": "mycdn.ciab.test",
 		"ttl": 3600,
 		"zskExpirationDays": 1095
 	}
diff --git a/docs/source/api/v4/cdns_dnsseckeys_generate.rst b/docs/source/api/v4/cdns_dnsseckeys_generate.rst
index 45e9c32..0255951 100644
--- a/docs/source/api/v4/cdns_dnsseckeys_generate.rst
+++ b/docs/source/api/v4/cdns_dnsseckeys_generate.rst
@@ -32,7 +32,6 @@ Request Structure
 :effectiveDate:         An optional string containing the date and time at which the newly-generated :abbr:`ZSK (Zone-Signing Key)` and :abbr:`KSK (Key-Signing Key)` become effective, in :RFC:`3339` format. Defaults to the current time if not specified.
 :key:                   Name of the CDN
 :kskExpirationDays:     Expiration (in days) for the :abbr:`KSKs (Key-Signing Keys)`
-:name:                  Domain name used by the CDN
 :ttl:                   Time, in seconds, for which the keypairs shall remain valid
 :zskExpirationDays:     Expiration (in days) for the :abbr:`ZSKs (Zone-Signing Keys)`
 
@@ -50,7 +49,6 @@ Request Structure
 	{
 		"key": "CDN-in-a-Box",
 		"kskExpirationDays": 1095,
-		"name": "mycdn.ciab.test",
 		"ttl": 3600,
 		"zskExpirationDays": 1095
 	}
diff --git a/lib/go-tc/cdns_dnssec.go b/lib/go-tc/cdns_dnssec.go
new file mode 100644
index 0000000..b85724e
--- /dev/null
+++ b/lib/go-tc/cdns_dnssec.go
@@ -0,0 +1,155 @@
+package tc
+
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+import (
+	"database/sql"
+	"errors"
+	"strconv"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc/tovalidate"
+	"github.com/apache/trafficcontrol/lib/go-util"
+
+	validation "github.com/go-ozzo/ozzo-validation"
+)
+
+const (
+	DNSSECKSKType          = "ksk"
+	DNSSECZSKType          = "zsk"
+	DNSSECKeyStatusNew     = "new"
+	DNSSECKeyStatusExpired = "expired"
+	DNSSECStatusExisting   = "existing"
+)
+
+type CDNDNSSECKeysResponse struct {
+	Response DNSSECKeys `json:"response"`
+	Alerts
+}
+
+type GenerateCDNDNSSECKeysResponse struct {
+	Response string `json:"response"`
+	Alerts
+}
+
+type DeleteCDNDNSSECKeysResponse GenerateCDNDNSSECKeysResponse
+
+// DNSSECKeys is the DNSSEC keys as stored in Riak, plus the DS record text.
+type DNSSECKeys map[string]DNSSECKeySet
+
+// Deprecated: use DNSSECKeysTrafficVault instead
+type DNSSECKeysRiak DNSSECKeysV11
+
+type DNSSECKeysTrafficVault DNSSECKeysV11
+
+// DNSSECKeysV11 is the DNSSEC keys object stored in Riak. The map key strings are both DeliveryServiceNames and CDNNames.
+type DNSSECKeysV11 map[string]DNSSECKeySetV11
+
+type DNSSECKeySet struct {
+	ZSK []DNSSECKey `json:"zsk"`
+	KSK []DNSSECKey `json:"ksk"`
+}
+
+// DNSSECKeySetV11 is a DNSSEC key set (ZSK and KSK), as stored in Riak.
+// This is specifically the key data, without the DS record text (which can be computed), and is also the format used in API 1.1 through 1.3.
+type DNSSECKeySetV11 struct {
+	ZSK []DNSSECKeyV11 `json:"zsk"`
+	KSK []DNSSECKeyV11 `json:"ksk"`
+}
+
+type DNSSECKey struct {
+	DNSSECKeyV11
+	DSRecord *DNSSECKeyDSRecord `json:"dsRecord,omitempty"`
+}
+
+type DNSSECKeyV11 struct {
+	InceptionDateUnix  int64                 `json:"inceptionDate"`
+	ExpirationDateUnix int64                 `json:"expirationDate"`
+	Name               string                `json:"name"`
+	TTLSeconds         uint64                `json:"ttl,string"`
+	Status             string                `json:"status"`
+	EffectiveDateUnix  int64                 `json:"effectiveDate"`
+	Public             string                `json:"public"`
+	Private            string                `json:"private"`
+	DSRecord           *DNSSECKeyDSRecordV11 `json:"dsRecord,omitempty"`
+}
+
+// DNSSECKeyDSRecordRiak is a DNSSEC key DS record, as stored in Riak.
+// This is specifically the key data, without the DS record text (which can be computed), and is also the format used in API 1.1 through 1.3.
+type DNSSECKeyDSRecordRiak DNSSECKeyDSRecordV11
+
+type DNSSECKeyDSRecord struct {
+	DNSSECKeyDSRecordV11
+	Text string `json:"text"`
+}
+
+type DNSSECKeyDSRecordV11 struct {
+	Algorithm  int64  `json:"algorithm,string"`
+	DigestType int64  `json:"digestType,string"`
+	Digest     string `json:"digest"`
+}
+
+// CDNDNSSECGenerateReqDate is the date accepted by CDNDNSSECGenerateReq.
+// This will unmarshal a UNIX epoch integer, a RFC3339 string, the old format string used by Perl '2018-08-21+14:26:06', and the old format string sent by the Portal '2018-08-21 14:14:42'.
+// This exists to fix a critical bug, see https://github.com/apache/trafficcontrol/issues/2723 - it SHOULD NOT be used by any other endpoint.
+type CDNDNSSECGenerateReqDate int64
+
+func (i *CDNDNSSECGenerateReqDate) UnmarshalJSON(d []byte) error {
+	const oldPortalDateFormat = `2006-01-02 15:04:05`
+	const oldPerlUIDateFormat = `2006-01-02+15:04:05`
+	if len(d) == 0 {
+		return errors.New("empty object")
+	}
+	if d[0] == '"' {
+		d = d[1 : len(d)-1] // strip JSON quotes, to accept the UNIX epoch as a string or number
+	}
+	if di, err := strconv.ParseInt(string(d), 10, 64); err == nil {
+		*i = CDNDNSSECGenerateReqDate(di)
+		return nil
+	}
+	if t, err := time.Parse(time.RFC3339, string(d)); err == nil {
+		*i = CDNDNSSECGenerateReqDate(t.Unix())
+		return nil
+	}
+	if t, err := time.Parse(oldPortalDateFormat, string(d)); err == nil {
+		*i = CDNDNSSECGenerateReqDate(t.Unix())
+		return nil
+	}
+	if t, err := time.Parse(oldPerlUIDateFormat, string(d)); err == nil {
+		*i = CDNDNSSECGenerateReqDate(t.Unix())
+		return nil
+	}
+	return errors.New("invalid date")
+}
+
+type CDNDNSSECGenerateReq struct {
+	// Key is the CDN name, as documented in the API documentation.
+	Key               *string                   `json:"key"`
+	TTL               *util.JSONIntStr          `json:"ttl"`
+	KSKExpirationDays *util.JSONIntStr          `json:"kskExpirationDays"`
+	ZSKExpirationDays *util.JSONIntStr          `json:"zskExpirationDays"`
+	EffectiveDateUnix *CDNDNSSECGenerateReqDate `json:"effectiveDate"`
+}
+
+func (r CDNDNSSECGenerateReq) Validate(tx *sql.Tx) error {
+	validateErrs := validation.Errors{
+		"key (CDN name)":    validation.Validate(r.Key, validation.NotNil),
+		"ttl":               validation.Validate(r.TTL, validation.NotNil),
+		"kskExpirationDays": validation.Validate(r.KSKExpirationDays, validation.NotNil),
+		"zskExpirationDays": validation.Validate(r.ZSKExpirationDays, validation.NotNil),
+		// effective date is optional
+	}
+	return util.JoinErrs(tovalidate.ToErrors(validateErrs))
+}
diff --git a/lib/go-tc/deliveryservice_ssl_keys.go b/lib/go-tc/deliveryservice_ssl_keys.go
index ed41374..7021a52 100644
--- a/lib/go-tc/deliveryservice_ssl_keys.go
+++ b/lib/go-tc/deliveryservice_ssl_keys.go
@@ -18,7 +18,6 @@ package tc
 import (
 	"database/sql"
 	"errors"
-	"strconv"
 	"strings"
 	"time"
 
@@ -27,11 +26,6 @@ import (
 	"github.com/lestrrat/go-jwx/jwk"
 )
 
-const DNSSECKSKType = "ksk"
-const DNSSECZSKType = "zsk"
-const DNSSECKeyStatusNew = "new"
-const DNSSECKeyStatusExpired = "expired"
-const DNSSECStatusExisting = "existing"
 const (
 	SelfSignedCertAuthType           = "Self Signed"
 	CertificateAuthorityCertAuthType = "Certificate Authority"
@@ -236,129 +230,6 @@ type TrafficVaultPingResponse struct {
 	Alerts
 }
 
-// DNSSECKeys is the DNSSEC keys as stored in Riak, plus the DS record text.
-type DNSSECKeys map[string]DNSSECKeySet
-
-// Deprecated: use DNSSECKeysTrafficVault instead
-type DNSSECKeysRiak DNSSECKeysV11
-
-type DNSSECKeysTrafficVault DNSSECKeysV11
-
-// DNSSECKeysV11 is the DNSSEC keys object stored in Riak. The map key strings are both DeliveryServiceNames and CDNNames.
-type DNSSECKeysV11 map[string]DNSSECKeySetV11
-
-type DNSSECKeySet struct {
-	ZSK []DNSSECKey `json:"zsk"`
-	KSK []DNSSECKey `json:"ksk"`
-}
-
-// DNSSECKeySetV11 is a DNSSEC key set (ZSK and KSK), as stored in Riak.
-// This is specifically the key data, without the DS record text (which can be computed), and is also the format used in API 1.1 through 1.3.
-type DNSSECKeySetV11 struct {
-	ZSK []DNSSECKeyV11 `json:"zsk"`
-	KSK []DNSSECKeyV11 `json:"ksk"`
-}
-
-type DNSSECKey struct {
-	DNSSECKeyV11
-	DSRecord *DNSSECKeyDSRecord `json:"dsRecord,omitempty"`
-}
-
-type DNSSECKeyV11 struct {
-	InceptionDateUnix  int64                 `json:"inceptionDate"`
-	ExpirationDateUnix int64                 `json:"expirationDate"`
-	Name               string                `json:"name"`
-	TTLSeconds         uint64                `json:"ttl,string"`
-	Status             string                `json:"status"`
-	EffectiveDateUnix  int64                 `json:"effectiveDate"`
-	Public             string                `json:"public"`
-	Private            string                `json:"private"`
-	DSRecord           *DNSSECKeyDSRecordV11 `json:"dsRecord,omitempty"`
-}
-
-// DNSSECKeyDSRecordRiak is a DNSSEC key DS record, as stored in Riak.
-// This is specifically the key data, without the DS record text (which can be computed), and is also the format used in API 1.1 through 1.3.
-type DNSSECKeyDSRecordRiak DNSSECKeyDSRecordV11
-
-type DNSSECKeyDSRecord struct {
-	DNSSECKeyDSRecordV11
-	Text string `json:"text"`
-}
-
-type DNSSECKeyDSRecordV11 struct {
-	Algorithm  int64  `json:"algorithm,string"`
-	DigestType int64  `json:"digestType,string"`
-	Digest     string `json:"digest"`
-}
-
-// CDNDNSSECGenerateReqDate is the date accepted by CDNDNSSECGenerateReq.
-// This will unmarshal a UNIX epoch integer, a RFC3339 string, the old format string used by Perl '2018-08-21+14:26:06', and the old format string sent by the Portal '2018-08-21 14:14:42'.
-// This exists to fix a critical bug, see https://github.com/apache/trafficcontrol/issues/2723 - it SHOULD NOT be used by any other endpoint.
-type CDNDNSSECGenerateReqDate int64
-
-func (i *CDNDNSSECGenerateReqDate) UnmarshalJSON(d []byte) error {
-	const oldPortalDateFormat = `2006-01-02 15:04:05`
-	const oldPerlUIDateFormat = `2006-01-02+15:04:05`
-	if len(d) == 0 {
-		return errors.New("empty object")
-	}
-	if d[0] == '"' {
-		d = d[1 : len(d)-1] // strip JSON quotes, to accept the UNIX epoch as a string or number
-	}
-	if di, err := strconv.ParseInt(string(d), 10, 64); err == nil {
-		*i = CDNDNSSECGenerateReqDate(di)
-		return nil
-	}
-	if t, err := time.Parse(time.RFC3339, string(d)); err == nil {
-		*i = CDNDNSSECGenerateReqDate(t.Unix())
-		return nil
-	}
-	if t, err := time.Parse(oldPortalDateFormat, string(d)); err == nil {
-		*i = CDNDNSSECGenerateReqDate(t.Unix())
-		return nil
-	}
-	if t, err := time.Parse(oldPerlUIDateFormat, string(d)); err == nil {
-		*i = CDNDNSSECGenerateReqDate(t.Unix())
-		return nil
-	}
-	return errors.New("invalid date")
-}
-
-type CDNDNSSECGenerateReq struct {
-	// Key is the CDN name, as documented in the API documentation.
-	Key *string `json:"key"`
-	// Name is the CDN domain, as documented in the API documentation.
-	Name              *string                   `json:"name"`
-	TTL               *util.JSONIntStr          `json:"ttl"`
-	KSKExpirationDays *util.JSONIntStr          `json:"kskExpirationDays"`
-	ZSKExpirationDays *util.JSONIntStr          `json:"zskExpirationDays"`
-	EffectiveDateUnix *CDNDNSSECGenerateReqDate `json:"effectiveDate"`
-}
-
-func (r CDNDNSSECGenerateReq) Validate(tx *sql.Tx) error {
-	errs := []string{}
-	if r.Key == nil {
-		errs = append(errs, "key (cdn name) must be set")
-	}
-	if r.Name == nil {
-		errs = append(errs, "name (cdn domain name) must be set")
-	}
-	if r.TTL == nil {
-		errs = append(errs, "ttl must be set")
-	}
-	if r.KSKExpirationDays == nil {
-		errs = append(errs, "kskExpirationDays must be set")
-	}
-	if r.ZSKExpirationDays == nil {
-		errs = append(errs, "zskExpirationDays must be set")
-	}
-	// effective date is optional
-	if len(errs) > 0 {
-		return errors.New("missing fields: " + strings.Join(errs, "; "))
-	}
-	return nil
-}
-
 type URLSigKeys map[string]string
 
 type CDNSSLKeysResp []CDNSSLKey
diff --git a/traffic_ops/testing/api/v4/cdns_test.go b/traffic_ops/testing/api/v4/cdns_test.go
index f391da4..8c245e8 100644
--- a/traffic_ops/testing/api/v4/cdns_test.go
+++ b/traffic_ops/testing/api/v4/cdns_test.go
@@ -17,12 +17,14 @@ package v4
 
 import (
 	"net/http"
+	"reflect"
 	"sort"
 	"testing"
 	"time"
 
 	"github.com/apache/trafficcontrol/lib/go-rfc"
 	tc "github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
 )
 
 func TestCDNs(t *testing.T) {
@@ -46,6 +48,68 @@ func TestCDNs(t *testing.T) {
 	})
 }
 
+func TestCDNsDNSSEC(t *testing.T) {
+	WithObjs(t, []TCObj{CDNs, Types, Tenants, Users, Parameters, Profiles, Statuses, Divisions, Regions, PhysLocations, CacheGroups, Servers, Topologies, ServerCapabilities, DeliveryServices}, func() {
+		if includeSystemTests {
+			GenerateDNSSECKeys(t)
+		}
+	})
+}
+
+func GenerateDNSSECKeys(t *testing.T) {
+	if len(testData.CDNs) < 1 {
+		t.Fatalf("need at least one CDN to test updating CDNs")
+	}
+	firstCDN := testData.CDNs[0]
+	resp, _, err := TOSession.GetCDNByName(firstCDN.Name, nil)
+	if err != nil {
+		t.Fatalf("cannot GET CDN by name: '%s', %v", firstCDN.Name, err)
+	}
+	if len(resp) != 1 {
+		t.Fatalf("expected: 1 CDN, actual: %d", len(resp))
+	}
+
+	ttl := util.JSONIntStr(60)
+	req := tc.CDNDNSSECGenerateReq{
+		Key:               util.StrPtr(firstCDN.Name),
+		TTL:               &ttl,
+		KSKExpirationDays: &ttl,
+		ZSKExpirationDays: &ttl,
+	}
+	_, _, err = TOSession.GenerateCDNDNSSECKeys(req, nil)
+	if err != nil {
+		t.Fatalf("generating CDN DNSSEC keys - expected: nil error, actual: %s", err.Error())
+	}
+
+	res, _, err := TOSession.GetCDNDNSSECKeys(firstCDN.Name, nil)
+	if err != nil {
+		t.Fatalf("getting CDN DNSSEC keys - expected: nil error, actual: %s", err.Error())
+	}
+	if _, ok := res.Response[firstCDN.Name]; !ok {
+		t.Errorf("getting CDN DNSSEC keys - expected: key %s, actual: missing", firstCDN.Name)
+	}
+	originalKeys := res.Response
+
+	_, _, err = TOSession.GenerateCDNDNSSECKeys(req, nil)
+	if err != nil {
+		t.Fatalf("generating CDN DNSSEC keys - expected: nil error, actual: %s", err.Error())
+	}
+	res, _, err = TOSession.GetCDNDNSSECKeys(firstCDN.Name, nil)
+	if err != nil {
+		t.Fatalf("getting CDN DNSSEC keys - expected: nil error, actual: %s", err.Error())
+	}
+	newKeys := res.Response
+
+	if reflect.DeepEqual(originalKeys, newKeys) {
+		t.Errorf("generating CDN DNSSEC keys - expected: original keys to differ from new keys, actual: they are the same")
+	}
+
+	_, _, err = TOSession.DeleteCDNDNSSECKeys(firstCDN.Name, nil)
+	if err != nil {
+		t.Errorf("deleting CDN DNSSEC keys - expected: nil error, actual: %s", err.Error())
+	}
+}
+
 func UpdateTestCDNsWithHeaders(t *testing.T, header http.Header) {
 	if len(testData.CDNs) < 1 {
 		t.Fatalf("need at least one CDN to test updating CDNs")
diff --git a/traffic_ops/traffic_ops_golang/cdn/dnssec.go b/traffic_ops/traffic_ops_golang/cdn/dnssec.go
index 06a7c75..82e94e9 100644
--- a/traffic_ops/traffic_ops_golang/cdn/dnssec.go
+++ b/traffic_ops/traffic_ops_golang/cdn/dnssec.go
@@ -23,9 +23,13 @@ import (
 	"context"
 	"database/sql"
 	"errors"
+	"fmt"
+	"math"
 	"net/http"
+	"runtime"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/apache/trafficcontrol/lib/go-log"
@@ -37,8 +41,12 @@ import (
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/trafficvault"
 )
 
-const CDNDNSSECKeyType = "dnssec"
-const DNSSECStatusExisting = "existing"
+const (
+	CDNDNSSECKeyType     = "dnssec"
+	DNSSECStatusExisting = "existing"
+
+	DNSSECGenerationCPURatio = 0.66
+)
 
 func CreateDNSSECKeys(w http.ResponseWriter, r *http.Request) {
 	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
@@ -86,7 +94,26 @@ func CreateDNSSECKeys(w http.ResponseWriter, r *http.Request) {
 		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, errors.New("generating and storing DNSSEC CDN keys: "+err.Error()))
 		return
 	}
-	api.CreateChangeLogRawTx(api.ApiChange, "CDN: "+string(cdnName)+", ID: "+strconv.Itoa(cdnID)+", ACTION: Generated DNSSEC keys", inf.User, inf.Tx.Tx)
+	// NOTE: using a separate transaction (with its own timeout) for the changelog because the main
+	// transaction can time out if DNSSEC generation takes too long
+	db, err := api.GetDB(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, errors.New("generating CDN DNSSEC keys: getting DB from request context for changelog: "+err.Error()))
+		return
+	}
+	logCtx, logCancel := context.WithTimeout(r.Context(), 30*time.Second)
+	defer logCancel()
+	logTx, err := db.BeginTxx(logCtx, nil)
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, errors.New("generating CDN DNSSEC keys: could not begin transaction for changelog: "+err.Error()))
+		return
+	}
+	defer func() {
+		if err := logTx.Commit(); err != nil && err != sql.ErrTxDone {
+			log.Errorln("generating CDN DNSSEC keys: committing transaction for changelog: " + err.Error())
+		}
+	}()
+	api.CreateChangeLogRawTx(api.ApiChange, "CDN: "+cdnName+", ID: "+strconv.Itoa(cdnID)+", ACTION: Generated DNSSEC keys", inf.User, logTx.Tx)
 	api.WriteResp(w, r, "Successfully created dnssec keys for "+cdnName)
 }
 
@@ -247,6 +274,8 @@ func generateStoreDNSSECKeys(
 	if err != nil {
 		return errors.New("getting delivery service matchlists: " + err.Error())
 	}
+
+	jobList := make([]dnssecGenJob, 0, len(dses))
 	for _, ds := range dses {
 		if !ds.Type.IsHTTP() && !ds.Type.IsDNS() {
 			continue // skip delivery services that aren't DNS or HTTP (e.g. ANY_MAP)
@@ -258,20 +287,89 @@ func generateStoreDNSSECKeys(
 		}
 
 		exampleURLs := deliveryservice.MakeExampleURLs(ds.Protocol, ds.Type, ds.RoutingName, matchlist, cdnDomain)
-		log.Infoln("Creating keys for " + ds.Name)
-		overrideTTL := true
-		dsKeys, err := deliveryservice.CreateDNSSECKeys(exampleURLs, cdnKeys, kExp, zExp, ttl, overrideTTL)
-		if err != nil {
-			return errors.New("creating delivery service DNSSEC keys: " + err.Error())
+		jobList = append(jobList, dnssecGenJob{
+			XMLID:       ds.Name,
+			ExampleURLs: exampleURLs,
+			CDNKeys:     cdnKeys,
+			KExp:        kExp,
+			ZExp:        zExp,
+			TTL:         ttl,
+			OverrideTTL: true,
+		})
+	}
+	numWorkers := int(math.Max(1, math.Floor(float64(runtime.NumCPU())*DNSSECGenerationCPURatio)))
+	jobChan := make(chan dnssecGenJob, len(jobList))
+	resultChan := make(chan dnssecGenResult, len(jobList))
+	panickedChan := make(chan struct{}, numWorkers)
+	wg := sync.WaitGroup{}
+	wg.Add(numWorkers)
+	for w := 0; w < numWorkers; w++ {
+		go dnssecGenWorker(w, &wg, jobChan, resultChan, panickedChan)
+	}
+	for _, j := range jobList {
+		jobChan <- j
+	}
+	close(jobChan)
+	wg.Wait()
+	select {
+	case <-panickedChan:
+		return errors.New("creating DNSSEC keys, at least one worker goroutine panicked")
+	default:
+		log.Infoln("no DNSSEC generation worker goroutines panicked")
+	}
+	for i := 0; i < len(jobList); i++ {
+		res := <-resultChan
+		if res.Error != nil {
+			return fmt.Errorf("creating DNSSEC keys for delivery service %s: %s", res.XMLID, res.Error.Error())
 		}
-		newKeys[ds.Name] = dsKeys
+		newKeys[res.XMLID] = *res.Keys
 	}
+
 	if err := tv.PutDNSSECKeys(cdnName, newKeys, tx, ctx); err != nil {
 		return errors.New("putting CDN DNSSEC keys in Traffic Vault: " + err.Error())
 	}
 	return nil
 }
 
+func dnssecGenWorker(id int, waitGroup *sync.WaitGroup, jobs <-chan dnssecGenJob, results chan<- dnssecGenResult, panicked chan<- struct{}) {
+	log.Infof("DNSSEC gen worker %d starting", id)
+	defer func() {
+		if r := recover(); r != nil {
+			panicked <- struct{}{}
+			log.Errorf("DNSSEC gen worker %d recovered from panic: %v", id, r)
+		}
+		waitGroup.Done()
+		log.Infof("DNSSEC gen worker %d exiting", id)
+	}()
+	for j := range jobs {
+		log.Infof("DNSSEC gen worker %d creating keys for %s", id, j.XMLID)
+		res := dnssecGenResult{XMLID: j.XMLID}
+		dsKeys, err := deliveryservice.CreateDNSSECKeys(j.ExampleURLs, j.CDNKeys, j.KExp, j.ZExp, j.TTL, j.OverrideTTL)
+		if err != nil {
+			res.Error = err
+		} else {
+			res.Keys = &dsKeys
+		}
+		results <- res
+	}
+}
+
+type dnssecGenJob struct {
+	XMLID       string
+	ExampleURLs []string
+	CDNKeys     tc.DNSSECKeySetV11
+	KExp        time.Duration
+	ZExp        time.Duration
+	TTL         time.Duration
+	OverrideTTL bool
+}
+
+type dnssecGenResult struct {
+	XMLID string
+	Keys  *tc.DNSSECKeySetV11
+	Error error
+}
+
 const API_DNSSECKEYS = "DELETE /cdns/name/:name/dnsseckeys"
 
 type CDNDS struct {
diff --git a/traffic_ops/v4-client/cdn_dnssec.go b/traffic_ops/v4-client/cdn_dnssec.go
new file mode 100644
index 0000000..be21615
--- /dev/null
+++ b/traffic_ops/v4-client/cdn_dnssec.go
@@ -0,0 +1,51 @@
+package client
+
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
+)
+
+const (
+	apiCDNsDNSSECKeysGenerate = "/cdns/dnsseckeys/generate"
+	apiCDNsNameDNSSECKeys     = "cdns/name/%s/dnsseckeys"
+)
+
+// GenerateCDNDNSSECKeys generates DNSSEC keys for the given CDN.
+func (to *Session) GenerateCDNDNSSECKeys(req tc.CDNDNSSECGenerateReq, header http.Header) (tc.GenerateCDNDNSSECKeysResponse, toclientlib.ReqInf, error) {
+	var resp tc.GenerateCDNDNSSECKeysResponse
+	reqInf, err := to.post(apiCDNsDNSSECKeysGenerate, req, header, &resp)
+	return resp, reqInf, err
+}
+
+// GetCDNDNSSECKeys gets the DNSSEC keys for the given CDN.
+func (to *Session) GetCDNDNSSECKeys(name string, header http.Header) (tc.CDNDNSSECKeysResponse, toclientlib.ReqInf, error) {
+	route := fmt.Sprintf(apiCDNsNameDNSSECKeys, name)
+	var resp tc.CDNDNSSECKeysResponse
+	reqInf, err := to.get(route, header, &resp)
+	return resp, reqInf, err
+}
+
+// DeleteCDNDNSSECKeys deletes all the DNSSEC keys for the given CDN.
+func (to *Session) DeleteCDNDNSSECKeys(name string, header http.Header) (tc.DeleteCDNDNSSECKeysResponse, toclientlib.ReqInf, error) {
+	route := fmt.Sprintf(apiCDNsNameDNSSECKeys, name)
+	var resp tc.DeleteCDNDNSSECKeysResponse
+	reqInf, err := to.del(route, header, &resp)
+	return resp, reqInf, err
+}