From 2dbb39d29a0e708d88ab1b3385afd8286d692aef Mon Sep 17 00:00:00 2001
From: Libor Peltan <libor.peltan@nic.cz>
Date: Wed, 4 Sep 2019 15:15:28 +0000
Subject: [PATCH] dnssec: implemented pushing updated DS to parent

---
 Knot.files                                    |   3 +-
 doc/man/knot.conf.5in                         |  23 ++
 doc/reference.rst                             |  19 ++
 scripts/timerdb-info.py                       |   6 +-
 src/knot/Makefile.inc                         |   3 +-
 src/knot/conf/schema.c                        |   2 +
 src/knot/conf/schema.h                        |   1 +
 src/knot/conf/tools.c                         |  10 +
 src/knot/dnssec/key-events.c                  |   8 +-
 src/knot/dnssec/zone-events.h                 |   2 +-
 src/knot/dnssec/zone-sign.c                   |   5 +
 src/knot/events/events.c                      |   5 +-
 src/knot/events/events.h                      |   3 +-
 src/knot/events/handlers.h                    |   6 +-
 src/knot/events/handlers/dnssec.c             |   6 +-
 .../{parent_ds_query.c => ds_check.c}         |   8 +-
 src/knot/events/handlers/ds_push.c            | 244 ++++++++++++++++++
 src/knot/events/replan.c                      |  11 +-
 src/knot/modules/onlinesign/onlinesign.c      |   4 +-
 src/knot/zone/timers.c                        |  31 ++-
 src/knot/zone/timers.h                        |   5 +-
 .../tests/dnssec/ds_push/data/com.zone        |   6 +
 .../dnssec/ds_push/data/example.com.zone      |   9 +
 tests-extra/tests/dnssec/ds_push/test.py      | 160 ++++++++++++
 tests-extra/tools/dnstest/server.py           |   5 +-
 tests/knot/test_zone_timers.c                 |  26 +-
 26 files changed, 556 insertions(+), 55 deletions(-)
 rename src/knot/events/handlers/{parent_ds_query.c => ds_check.c} (88%)
 create mode 100644 src/knot/events/handlers/ds_push.c
 create mode 100644 tests-extra/tests/dnssec/ds_push/data/com.zone
 create mode 100644 tests-extra/tests/dnssec/ds_push/data/example.com.zone
 create mode 100644 tests-extra/tests/dnssec/ds_push/test.py

diff --git a/Knot.files b/Knot.files
index e766ce60fa..d865ad6786 100644
--- a/Knot.files
+++ b/Knot.files
@@ -128,13 +128,14 @@ src/knot/events/events.c
 src/knot/events/events.h
 src/knot/events/handlers.h
 src/knot/events/handlers/dnssec.c
+src/knot/events/handlers/ds_check.c
+src/knot/events/handlers/ds_push.c
 src/knot/events/handlers/expire.c
 src/knot/events/handlers/flush.c
 src/knot/events/handlers/freeze_thaw.c
 src/knot/events/handlers/load.c
 src/knot/events/handlers/notify.c
 src/knot/events/handlers/nsec3resalt.c
-src/knot/events/handlers/parent_ds_query.c
 src/knot/events/handlers/refresh.c
 src/knot/events/handlers/update.c
 src/knot/events/replan.c
diff --git a/doc/man/knot.conf.5in b/doc/man/knot.conf.5in
index abaed61297..e4f79f6946 100644
--- a/doc/man/knot.conf.5in
+++ b/doc/man/knot.conf.5in
@@ -706,6 +706,7 @@ policy:
     nsec3\-salt\-lifetime: TIME
     signing\-threads: INT
     ksk\-submission: submission_id
+    ds\-push: remote_id
     cds\-cdnskey\-publish: none | delete\-dnssec | rollover | always | double\-ds
     offline\-ksk: BOOL
 .ft P
@@ -929,6 +930,28 @@ A reference to \fI\%submission\fP section holding parameters of
 KSK submittion checks.
 .sp
 \fIDefault:\fP not set
+.SS ds\-push
+.sp
+A \fI\%reference\fP to parent\(aqs DNS server. Whenever the CDS record in
+this zone is changed, corresponding DS record is sent as an update (DDNS) to
+parent DNS server.
+.sp
+\fBNOTE:\fP
+.INDENT 0.0
+.INDENT 3.5
+For proper operation ref:\fIcds\-cdnskey\-publish<policy_cds\-cdnskey\-publish>\fP
+must not be disabled.
+.UNINDENT
+.UNINDENT
+.sp
+\fBNOTE:\fP
+.INDENT 0.0
+.INDENT 3.5
+This feature does not work with Onlinesign module.
+.UNINDENT
+.UNINDENT
+.sp
+\fIDefault:\fP not set
 .SS signing\-threads
 .sp
 When signing zone or update, use this number of threads for parallel signing.
diff --git a/doc/reference.rst b/doc/reference.rst
index 64e22889b2..d45222642e 100644
--- a/doc/reference.rst
+++ b/doc/reference.rst
@@ -770,6 +770,7 @@ DNSSEC policy configuration.
      nsec3-salt-lifetime: TIME
      signing-threads: INT
      ksk-submission: submission_id
+     ds-push: remote_id
      cds-cdnskey-publish: none | delete-dnssec | rollover | always | double-ds
      offline-ksk: BOOL
 
@@ -1034,6 +1035,24 @@ KSK submittion checks.
 
 *Default:* not set
 
+.. _policy_ds-push:
+
+ds-push
+-------
+
+A :ref:`reference<remote_id>` to parent's DNS server. Whenever the CDS record in
+this zone is changed, corresponding DS record is sent as an update (DDNS) to
+parent DNS server.
+
+.. NOTE::
+   For proper operation ref:`cds-cdnskey-publish<policy_cds-cdnskey-publish>`
+   must not be disabled.
+
+.. NOTE::
+   This feature does not work with :ref:`Onlinesign<mod-onlinesign>` module.
+
+*Default:* not set
+
 .. _policy_signing-threads:
 
 signing-threads
diff --git a/scripts/timerdb-info.py b/scripts/timerdb-info.py
index 26eb079c85..6a7cd36109 100755
--- a/scripts/timerdb-info.py
+++ b/scripts/timerdb-info.py
@@ -37,8 +37,10 @@ class TimerDBInfo:
                 0x82: ("last_refresh", cls.format_timestamp),
                 0x83: ("next_refresh", cls.format_timestamp),
                 # knot >= 2.6
-                0x84: ("last_resalt",      cls.format_timestamp),
-                0x85: ("next_parent_ds_q", cls.format_timestamp),
+                0x84: ("last_resalt",   cls.format_timestamp),
+                0x85: ("next_ds_check", cls.format_timestamp),
+                # knot >= 2.8
+                0x86: ("next_ds_push",  cls.format_timestamp),
         }
         if id in timers:
             return (timers[id][0], timers[id][1](value))
diff --git a/src/knot/Makefile.inc b/src/knot/Makefile.inc
index f9a5f4d2db..ea2cbb497f 100644
--- a/src/knot/Makefile.inc
+++ b/src/knot/Makefile.inc
@@ -66,6 +66,8 @@ libknotd_la_SOURCES = \
 	knot/events/events.h			\
 	knot/events/handlers.h			\
 	knot/events/handlers/dnssec.c		\
+	knot/events/handlers/ds_check.c		\
+	knot/events/handlers/ds_push.c		\
 	knot/events/handlers/expire.c		\
 	knot/events/handlers/flush.c		\
 	knot/events/handlers/freeze_thaw.c	\
@@ -74,7 +76,6 @@ libknotd_la_SOURCES = \
 	knot/events/handlers/nsec3resalt.c	\
 	knot/events/handlers/refresh.c		\
 	knot/events/handlers/update.c		\
-	knot/events/handlers/parent_ds_query.c	\
 	knot/events/replan.c			\
 	knot/events/replan.h			\
 	knot/nameserver/axfr.c			\
diff --git a/src/knot/conf/schema.c b/src/knot/conf/schema.c
index cc081ebf52..97ba8c5864 100644
--- a/src/knot/conf/schema.c
+++ b/src/knot/conf/schema.c
@@ -291,6 +291,8 @@ static const yp_item_t desc_policy[] = {
 	                                   CONF_IO_FRLD_ZONES },
 	{ C_KSK_SBM,             YP_TREF,  YP_VREF = { C_SBM }, CONF_IO_FRLD_ZONES,
 	                                   { check_ref } },
+	{ C_DS_PUSH,             YP_TREF,  YP_VREF = { C_RMT }, YP_FMULTI | CONF_IO_FRLD_ZONES,
+	                                   { check_ref } },
 	{ C_SIGNING_THREADS,     YP_TINT,  YP_VINT = { 1, UINT16_MAX, 1 } },
 	{ C_CDS_CDNSKEY,         YP_TOPT,  YP_VOPT = { cds_cdnskey, CDS_CDNSKEY_ROLLOVER } },
 	{ C_OFFLINE_KSK,         YP_TBOOL, YP_VNONE, CONF_IO_FRLD_ZONES },
diff --git a/src/knot/conf/schema.h b/src/knot/conf/schema.h
index befbf2bd9e..742072b25e 100644
--- a/src/knot/conf/schema.h
+++ b/src/knot/conf/schema.h
@@ -41,6 +41,7 @@
 #define C_DNSSEC_POLICY		"\x0D""dnssec-policy"
 #define C_DNSSEC_SIGNING	"\x0E""dnssec-signing"
 #define C_DOMAIN		"\x06""domain"
+#define C_DS_PUSH		"\x07""ds-push"
 #define C_ECS			"\x12""edns-client-subnet"
 #define C_FILE			"\x04""file"
 #define C_GLOBAL_MODULE		"\x0D""global-module"
diff --git a/src/knot/conf/tools.c b/src/knot/conf/tools.c
index 1e3d3c4a43..88c58483c3 100644
--- a/src/knot/conf/tools.c
+++ b/src/knot/conf/tools.c
@@ -400,6 +400,16 @@ int check_policy(
 		}
 	}
 
+	conf_val_t cds_cdnskey = conf_rawid_get_txn(args->extra->conf, args->extra->txn, C_POLICY,
+	                                            C_CDS_CDNSKEY, args->id, args->id_len);
+	conf_val_t ds_push = conf_rawid_get_txn(args->extra->conf, args->extra->txn, C_POLICY,
+	                                        C_DS_PUSH, args->id, args->id_len);
+
+	if (conf_val_count(&ds_push) > 0 && conf_opt(&cds_cdnskey) == CDS_CDNSKEY_NONE) {
+		args->err_str = "DS push requires enabled CDS/CDNSKEY publication";
+		return KNOT_EINVAL;
+	}
+
 	return KNOT_EOK;
 }
 
diff --git a/src/knot/dnssec/key-events.c b/src/knot/dnssec/key-events.c
index 658fa77033..3172efbb93 100644
--- a/src/knot/dnssec/key-events.c
+++ b/src/knot/dnssec/key-events.c
@@ -594,7 +594,7 @@ int knot_dnssec_key_rollover(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flags,
 				ret = generate_key(ctx, GEN_KSK_FLAGS, ctx->now, false);
 			}
 			if (ret == KNOT_EOK) {
-				reschedule->plan_ds_query = true;
+				reschedule->plan_ds_check = true;
 				plan_ds_keytag = dnssec_key_get_keytag(ctx->zone->keys[0].key);
 			}
 		}
@@ -685,7 +685,7 @@ int knot_dnssec_key_rollover(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flags,
 		case SUBMIT:
 			ret = submit_key(ctx, next.key);
 			if (ret == KNOT_EOK) {
-				reschedule->plan_ds_query = true;
+				reschedule->plan_ds_check = true;
 				plan_ds_keytag = dnssec_key_get_keytag(next.key->key);
 			}
 			break;
@@ -716,7 +716,7 @@ int knot_dnssec_key_rollover(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flags,
 
 	if (ret == KNOT_EOK && next.ready_key >= 0) {
 		// just to make sure DS check is scheduled
-		reschedule->plan_ds_query = true;
+		reschedule->plan_ds_check = true;
 		plan_ds_keytag = next.ready_key;
 	}
 
@@ -728,7 +728,7 @@ int knot_dnssec_key_rollover(kdnssec_ctx_t *ctx, zone_sign_roll_flags_t flags,
 		ret = kdnssec_ctx_commit(ctx);
 	}
 
-	if (ret == KNOT_EOK && reschedule->plan_ds_query) {
+	if (ret == KNOT_EOK && reschedule->plan_ds_check) {
 		char param[32];
 		(void)snprintf(param, sizeof(param), "KEY_SUBMISSION=%hu", plan_ds_keytag);
 		log_fmt_zone(LOG_NOTICE, LOG_SOURCE_ZONE, ctx->zone->dname, param,
diff --git a/src/knot/dnssec/zone-events.h b/src/knot/dnssec/zone-events.h
index 7039d7b29d..292bdb1adf 100644
--- a/src/knot/dnssec/zone-events.h
+++ b/src/knot/dnssec/zone-events.h
@@ -48,7 +48,7 @@ typedef struct {
 	knot_time_t next_nsec3resalt;
 	knot_time_t last_nsec3resalt;
 	bool keys_changed;
-	bool plan_ds_query;
+	bool plan_ds_check;
 } zone_sign_reschedule_t;
 
 /*!
diff --git a/src/knot/dnssec/zone-sign.c b/src/knot/dnssec/zone-sign.c
index aa0f4140c2..85a8ff0bcf 100644
--- a/src/knot/dnssec/zone-sign.c
+++ b/src/knot/dnssec/zone-sign.c
@@ -816,6 +816,11 @@ int knot_zone_sign_update_dnskeys(zone_update_t *update,
 
 	if (!knot_rrset_empty(&add_r.cds)) {
 		ret = changeset_add_addition(&ch, &add_r.cds, CHANGESET_CHECK);
+		if (node_rrtype_exists(ch.add->apex, KNOT_RRTYPE_CDS)) {
+			// there is indeed a change to CDS
+			update->zone->timers.next_ds_push = time(NULL);
+			zone_events_schedule_now(update->zone, ZONE_EVENT_DS_PUSH);
+		}
 		CHECK_RET;
 	}
 
diff --git a/src/knot/events/events.c b/src/knot/events/events.c
index f62843a642..6082c042af 100644
--- a/src/knot/events/events.c
+++ b/src/knot/events/events.c
@@ -48,7 +48,8 @@ static const event_info_t EVENT_INFO[] = {
 	{ ZONE_EVENT_UFREEZE,      event_ufreeze,     "update freeze" },
 	{ ZONE_EVENT_UTHAW,        event_uthaw,       "update thaw" },
 	{ ZONE_EVENT_NSEC3RESALT,  event_nsec3resalt, "NSEC3 resalt" },
-	{ ZONE_EVENT_PARENT_DS_Q,  event_parent_ds_q, "parent DS query" },
+	{ ZONE_EVENT_DS_CHECK,     event_ds_check,    "DS check" },
+	{ ZONE_EVENT_DS_PUSH,      event_ds_push,     "DS push" },
 	{ 0 }
 };
 
@@ -79,7 +80,7 @@ bool ufreeze_applies(zone_event_type_t type)
 	case ZONE_EVENT_FLUSH:
 	case ZONE_EVENT_DNSSEC:
 	case ZONE_EVENT_NSEC3RESALT:
-	case ZONE_EVENT_PARENT_DS_Q:
+	case ZONE_EVENT_DS_CHECK:
 		return true;
 	default:
 		return false;
diff --git a/src/knot/events/events.h b/src/knot/events/events.h
index e14daae0ad..53612f731f 100644
--- a/src/knot/events/events.h
+++ b/src/knot/events/events.h
@@ -40,7 +40,8 @@ typedef enum zone_event_type {
 	ZONE_EVENT_UFREEZE,
 	ZONE_EVENT_UTHAW,
 	ZONE_EVENT_NSEC3RESALT,
-	ZONE_EVENT_PARENT_DS_Q,
+	ZONE_EVENT_DS_CHECK,
+	ZONE_EVENT_DS_PUSH,
 	// terminator
 	ZONE_EVENT_COUNT,
 } zone_event_type_t;
diff --git a/src/knot/events/handlers.h b/src/knot/events/handlers.h
index ca50ccbcac..b000ba22f6 100644
--- a/src/knot/events/handlers.h
+++ b/src/knot/events/handlers.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2017 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -44,4 +44,6 @@ int event_uthaw(conf_t *conf, zone_t *zone);
 /*! \brief Recreates salt for NSEC3 hashing. */
 int event_nsec3resalt(conf_t *conf, zone_t *zone);
 /*! \brief When CDS/CDNSKEY published, look for matching DS */
-int event_parent_ds_q(conf_t *conf, zone_t *zone);
+int event_ds_check(conf_t *conf, zone_t *zone);
+/*! \brief After change of CDS/CDNSKEY, push the new DS to parent zone as DDNS. */
+int event_ds_push(conf_t *conf, zone_t *zone);
diff --git a/src/knot/events/handlers/dnssec.c b/src/knot/events/handlers/dnssec.c
index 32d3b7d3d2..50af00a6e2 100644
--- a/src/knot/events/handlers/dnssec.c
+++ b/src/knot/events/handlers/dnssec.c
@@ -50,8 +50,8 @@ void event_dnssec_reschedule(conf_t *conf, zone_t *zone,
 
 	log_dnssec_next(zone->name, (time_t)refresh_at);
 
-	if (refresh->plan_ds_query) {
-		zone->timers.next_parent_ds_q = now;
+	if (refresh->plan_ds_check) {
+		zone->timers.next_ds_check = now;
 	}
 
 	if (refresh->last_nsec3resalt) {
@@ -60,7 +60,7 @@ void event_dnssec_reschedule(conf_t *conf, zone_t *zone,
 
 	zone_events_schedule_at(zone,
 		ZONE_EVENT_DNSSEC, refresh_at ? (time_t)refresh_at : ignore,
-		ZONE_EVENT_PARENT_DS_Q, refresh->plan_ds_query ? now : ignore,
+		ZONE_EVENT_DS_CHECK, refresh->plan_ds_check ? now : ignore,
 		ZONE_EVENT_NSEC3RESALT, refresh->next_nsec3resalt ? refresh->next_nsec3resalt : ignore,
 		ZONE_EVENT_NOTIFY, zone_changed ? now : ignore
 	);
diff --git a/src/knot/events/handlers/parent_ds_query.c b/src/knot/events/handlers/ds_check.c
similarity index 88%
rename from src/knot/events/handlers/parent_ds_query.c
rename to src/knot/events/handlers/ds_check.c
index 2056a654a6..96006f4d0f 100644
--- a/src/knot/events/handlers/parent_ds_query.c
+++ b/src/knot/events/handlers/ds_check.c
@@ -17,7 +17,7 @@
 #include "knot/dnssec/ds_query.h"
 #include "knot/zone/zone.h"
 
-int event_parent_ds_q(conf_t *conf, zone_t *zone)
+int event_ds_check(conf_t *conf, zone_t *zone)
 {
 	kdnssec_ctx_t ctx = { 0 };
 
@@ -35,12 +35,12 @@ int event_parent_ds_q(conf_t *conf, zone_t *zone)
 
 	ret = knot_parent_ds_query(&ctx, &keyset, conf->cache.srv_tcp_reply_timeout * 1000);
 
-	zone->timers.next_parent_ds_q = 0;
+	zone->timers.next_ds_check = 0;
 	if (ret != KNOT_EOK) {
 		if (ctx.policy->ksk_sbm_check_interval > 0) {
 			time_t next_check = time(NULL) + ctx.policy->ksk_sbm_check_interval;
-			zone->timers.next_parent_ds_q = next_check;
-			zone_events_schedule_at(zone, ZONE_EVENT_PARENT_DS_Q, next_check);
+			zone->timers.next_ds_check = next_check;
+			zone_events_schedule_at(zone, ZONE_EVENT_DS_CHECK, next_check);
 		}
 	} else {
 		zone_events_schedule_now(zone, ZONE_EVENT_DNSSEC);
diff --git a/src/knot/events/handlers/ds_push.c b/src/knot/events/handlers/ds_push.c
new file mode 100644
index 0000000000..603dd8abb3
--- /dev/null
+++ b/src/knot/events/handlers/ds_push.c
@@ -0,0 +1,244 @@
+/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+
+#include "knot/common/log.h"
+#include "knot/conf/conf.h"
+#include "knot/query/query.h"
+#include "knot/query/requestor.h"
+#include "knot/zone/zone.h"
+#include "libknot/errcode.h"
+
+struct ds_push_data {
+	const knot_dname_t *zone;
+	knot_dname_t *parent_soa;
+	knot_rrset_t del_old_ds;
+	knot_rrset_t new_ds;
+	const struct sockaddr *remote;
+	struct query_edns_data edns;
+};
+
+#define DS_PUSH_RETRY	600
+
+#define DS_PUSH_LOG(priority, zone, remote, fmt, ...) \
+	ns_log(priority, zone, LOG_OPERATION_DS_PUSH, LOG_DIRECTION_OUT, remote, \
+	       fmt, ## __VA_ARGS__)
+
+static const knot_rdata_t remove_cds = { 5, { 0, 0, 0, 0, 0 } };
+
+static int ds_push_begin(knot_layer_t *layer, void *params)
+{
+	layer->data = params;
+
+	return KNOT_STATE_PRODUCE;
+}
+
+static int parent_soa_produce(struct ds_push_data *data, knot_pkt_t *pkt)
+{
+	const knot_dname_t *query_name = knot_wire_next_label(data->zone, NULL);
+
+	int ret = knot_pkt_put_question(pkt, query_name, KNOT_CLASS_IN, KNOT_RRTYPE_SOA);
+	if (ret != KNOT_EOK) {
+		return KNOT_STATE_FAIL;
+	}
+
+	ret = query_put_edns(pkt, &data->edns);
+	if (ret != KNOT_EOK) {
+		return KNOT_STATE_FAIL;
+	}
+
+	return KNOT_STATE_CONSUME;
+}
+
+static int ds_push_produce(knot_layer_t *layer, knot_pkt_t *pkt)
+{
+	struct ds_push_data *data = layer->data;
+
+	query_init_pkt(pkt);
+
+	if (data->parent_soa == NULL) {
+		return parent_soa_produce(data, pkt);
+	}
+
+	knot_wire_set_opcode(pkt->wire, KNOT_OPCODE_UPDATE);
+	int ret = knot_pkt_put_question(pkt, data->parent_soa, KNOT_CLASS_IN, KNOT_RRTYPE_SOA);
+	if (ret != KNOT_EOK) {
+		return KNOT_STATE_FAIL;
+	}
+
+	knot_pkt_begin(pkt, KNOT_AUTHORITY);
+
+	assert(data->del_old_ds.type == KNOT_RRTYPE_DS);
+	ret = knot_pkt_put(pkt, KNOT_COMPR_HINT_NONE, &data->del_old_ds, 0);
+	if (ret != KNOT_EOK) {
+		return KNOT_STATE_FAIL;
+	}
+
+	assert(data->new_ds.type == KNOT_RRTYPE_DS);
+	assert(!knot_rrset_empty(&data->new_ds));
+	if (knot_rdata_cmp(data->new_ds.rrs.rdata, &remove_cds) != 0) {
+		// Otherwise only remove DS - it was a special "remove CDS".
+		ret = knot_pkt_put(pkt, KNOT_COMPR_HINT_NONE, &data->new_ds, 0);
+		if (ret != KNOT_EOK) {
+			return KNOT_STATE_FAIL;
+		}
+	}
+
+	query_put_edns(pkt, &data->edns);
+
+	return KNOT_STATE_CONSUME;
+}
+
+static const knot_rrset_t *sect_soa(const knot_pkt_t *pkt, knot_section_t sect)
+{
+	const knot_pktsection_t *s = knot_pkt_section(pkt, sect);
+	const knot_rrset_t *rr = s->count > 0 ? knot_pkt_rr(s, 0) : NULL;
+	if (rr == NULL || rr->type != KNOT_RRTYPE_SOA || rr->rrs.count != 1) {
+		return NULL;
+	}
+	return rr;
+}
+
+static int ds_push_consume(knot_layer_t *layer, knot_pkt_t *pkt)
+{
+	struct ds_push_data *data = layer->data;
+
+	if (data->parent_soa != NULL) {
+		// DS push has already been sent, just finish the action.
+		free(data->parent_soa);
+		return KNOT_STATE_DONE;
+	}
+
+	const knot_rrset_t *parent_soa = sect_soa(pkt, KNOT_ANSWER);
+	if (parent_soa == NULL) {
+		parent_soa = sect_soa(pkt, KNOT_AUTHORITY);
+	}
+	if (parent_soa == NULL) {
+		DS_PUSH_LOG(LOG_WARNING, data->zone, data->remote,
+		            "malformed message");
+		return KNOT_STATE_FAIL;
+	}
+
+	data->parent_soa = knot_dname_copy(parent_soa->owner, NULL);
+
+	return KNOT_STATE_RESET;
+}
+
+static int ds_push_reset(knot_layer_t *layer)
+{
+	(void)layer;
+	return KNOT_STATE_PRODUCE;
+}
+
+static const knot_layer_api_t DS_PUSH_API = {
+	.begin = ds_push_begin,
+	.produce = ds_push_produce,
+	.reset = ds_push_reset,
+	.consume = ds_push_consume,
+};
+
+static int send_ds_push(conf_t *conf, zone_t *zone,
+                        const conf_remote_t *parent, int timeout)
+{
+	knot_rrset_t zone_cds = node_rrset(zone->contents->apex, KNOT_RRTYPE_CDS);
+	if (knot_rrset_empty(&zone_cds)) {
+		return KNOT_EOK; // No CDS, do nothing.
+	}
+	zone_cds.type = KNOT_RRTYPE_DS;
+
+	struct ds_push_data data = {
+		.zone = zone->name,
+		.new_ds = zone_cds,
+		.remote = (struct sockaddr *)&parent->addr,
+	};
+
+	knot_rrset_init(&data.del_old_ds, zone->name, KNOT_RRTYPE_DS, KNOT_CLASS_ANY, 0);
+	query_edns_data_init(&data.edns, conf, zone->name, parent->addr.ss_family);
+
+	knot_requestor_t requestor;
+	knot_requestor_init(&requestor, &DS_PUSH_API, &data, NULL);
+
+	knot_pkt_t *pkt = knot_pkt_new(NULL, KNOT_WIRE_MAX_PKTSIZE, NULL);
+	if (pkt == NULL) {
+		knot_requestor_clear(&requestor);
+		return KNOT_ENOMEM;
+	}
+
+	const struct sockaddr *dst = (struct sockaddr *)&parent->addr;
+	const struct sockaddr *src = (struct sockaddr *)&parent->via;
+	knot_request_t *req = knot_request_make(NULL, dst, src, pkt, &parent->key, 0);
+	if (req == NULL) {
+		knot_request_free(req, NULL);
+		knot_requestor_clear(&requestor);
+		return KNOT_ENOMEM;
+	}
+
+	int ret = knot_requestor_exec(&requestor, req, timeout);
+
+	if (ret == KNOT_EOK && knot_pkt_ext_rcode(req->resp) == 0) {
+		DS_PUSH_LOG(LOG_INFO, zone->name, dst, "success");
+	} else if (knot_pkt_ext_rcode(req->resp) == 0) {
+		DS_PUSH_LOG(LOG_WARNING, zone->name, dst,
+		            "failed (%s)", knot_strerror(ret));
+	} else {
+		DS_PUSH_LOG(LOG_WARNING, zone->name, dst,
+		            "server responded with error '%s'",
+		            knot_pkt_ext_rcode_name(req->resp));
+	}
+
+	knot_request_free(req, NULL);
+	knot_requestor_clear(&requestor);
+
+	return ret;
+}
+
+int event_ds_push(conf_t *conf, zone_t *zone)
+{
+	assert(zone);
+
+	if (zone_contents_is_empty(zone->contents)) {
+		return KNOT_EOK;
+	}
+
+	int timeout = conf->cache.srv_tcp_reply_timeout * 1000;
+
+	conf_val_t policy_id = conf_zone_get(conf, C_DNSSEC_POLICY, zone->name);
+	conf_val_t ds_push = conf_id_get(conf, C_POLICY, C_DS_PUSH, &policy_id);
+	while (ds_push.code == KNOT_EOK) {
+		conf_val_t addr = conf_id_get(conf, C_RMT, C_ADDR, &ds_push);
+		size_t addr_count = conf_val_count(&addr);
+
+		int ret = KNOT_EOK;
+		for (int i = 0; i < addr_count; i++) {
+			conf_remote_t parent = conf_remote(conf, &ds_push, i);
+			ret = send_ds_push(conf, zone, &parent, timeout);
+			if (ret == KNOT_EOK) {
+				break;
+			}
+		}
+
+		if (ret != KNOT_EOK) {
+			time_t next_push = time(NULL) + DS_PUSH_RETRY;
+			zone_events_schedule_at(zone, ZONE_EVENT_DS_PUSH, next_push);
+			zone->timers.next_ds_push = next_push;
+		}
+
+		conf_val_next(&ds_push);
+	}
+
+	return KNOT_EOK;
+}
diff --git a/src/knot/events/replan.c b/src/knot/events/replan.c
index 9df2e2d107..206bd3aad7 100644
--- a/src/knot/events/replan.c
+++ b/src/knot/events/replan.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2017 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -130,10 +130,14 @@ void replan_from_timers(conf_t *conf, zone_t *zone)
 		}
 	}
 
-	time_t ds = zone->timers.next_parent_ds_q;
+	time_t ds = zone->timers.next_ds_check;
 	if (ds == 0) {
 		ds = TIME_IGNORE;
 	}
+	time_t ds_push = zone->timers.next_ds_push;
+	if (ds_push == 0) {
+		ds_push = TIME_IGNORE;
+	}
 
 	zone_events_schedule_at(zone,
 	                        ZONE_EVENT_REFRESH, refresh,
@@ -141,7 +145,8 @@ void replan_from_timers(conf_t *conf, zone_t *zone)
 	                        ZONE_EVENT_EXPIRE, expire,
 	                        ZONE_EVENT_FLUSH, flush,
 	                        ZONE_EVENT_NSEC3RESALT, resalt,
-	                        ZONE_EVENT_PARENT_DS_Q, ds);
+	                        ZONE_EVENT_DS_CHECK, ds,
+				ZONE_EVENT_DS_PUSH, ds_push);
 }
 
 void replan_load_new(zone_t *zone)
diff --git a/src/knot/modules/onlinesign/onlinesign.c b/src/knot/modules/onlinesign/onlinesign.c
index 72538a66ea..6c55ece808 100644
--- a/src/knot/modules/onlinesign/onlinesign.c
+++ b/src/knot/modules/onlinesign/onlinesign.c
@@ -518,7 +518,7 @@ static knotd_in_state_t pre_routine(knotd_in_state_t state, knot_pkt_t *pkt,
 		ret = knot_dnssec_key_rollover(mod->dnssec, KEY_ROLL_ALLOW_KSK_ROLL | KEY_ROLL_ALLOW_ZSK_ROLL, &resch);
 	}
 	if (ret == KNOT_EOK) {
-		if (resch.plan_ds_query && mod->dnssec->policy->ksk_sbm_check_interval > 0) {
+		if (resch.plan_ds_check && mod->dnssec->policy->ksk_sbm_check_interval > 0) {
 			ctx->event_parent_ds_q = mod->dnssec->now + mod->dnssec->policy->ksk_sbm_check_interval;
 		} else {
 			ctx->event_parent_ds_q = 0;
@@ -646,7 +646,7 @@ static int online_sign_ctx_new(online_sign_ctx_t **ctx_ptr, knotd_mod_t *mod)
 		return ret;
 	}
 
-	if (resch.plan_ds_query) {
+	if (resch.plan_ds_check) {
 		ctx->event_parent_ds_q = time(NULL);
 	}
 	ctx->event_rollover = resch.next_rollover;
diff --git a/src/knot/zone/timers.c b/src/knot/zone/timers.c
index fff69fdd1b..886039a9e9 100644
--- a/src/knot/zone/timers.c
+++ b/src/knot/zone/timers.c
@@ -54,7 +54,8 @@ enum timer_id {
 	TIMER_LAST_REFRESH,
 	TIMER_NEXT_REFRESH,
 	TIMER_LAST_RESALT,
-	TIMER_NEXT_PARENT_DS_Q
+	TIMER_NEXT_DS_CHECK,
+	TIMER_NEXT_DS_PUSH,
 };
 
 #define TIMER_SIZE (sizeof(uint8_t) + sizeof(uint64_t))
@@ -78,12 +79,13 @@ static int deserialize_timers(zone_timers_t *timers_ptr,
 		uint8_t id = wire_ctx_read_u8(&wire);
 		uint64_t value = wire_ctx_read_u64(&wire);
 		switch (id) {
-		case TIMER_SOA_EXPIRE:       timers.soa_expire = value; break;
-		case TIMER_LAST_FLUSH:       timers.last_flush = value; break;
-		case TIMER_LAST_REFRESH:     timers.last_refresh = value; break;
-		case TIMER_NEXT_REFRESH:     timers.next_refresh = value; break;
-		case TIMER_LAST_RESALT:      timers.last_resalt = value; break;
-		case TIMER_NEXT_PARENT_DS_Q: timers.next_parent_ds_q = value; break;
+		case TIMER_SOA_EXPIRE:    timers.soa_expire = value; break;
+		case TIMER_LAST_FLUSH:    timers.last_flush = value; break;
+		case TIMER_LAST_REFRESH:  timers.last_refresh = value; break;
+		case TIMER_NEXT_REFRESH:  timers.next_refresh = value; break;
+		case TIMER_LAST_RESALT:   timers.last_resalt = value; break;
+		case TIMER_NEXT_DS_CHECK: timers.next_ds_check = value; break;
+		case TIMER_NEXT_DS_PUSH:  timers.next_ds_push = value; break;
 		default:                 break; // ignore
 		}
 	}
@@ -102,13 +104,14 @@ static void txn_write_timers(knot_lmdb_txn_t *txn, const knot_dname_t *zone,
                              const zone_timers_t *timers)
 {
 	MDB_val k = { knot_dname_size(zone), (void *)zone };
-	MDB_val v = knot_lmdb_make_key("BLBLBLBLBLBL",
-		TIMER_SOA_EXPIRE,       (uint64_t)timers->soa_expire,
-		TIMER_LAST_FLUSH,       (uint64_t)timers->last_flush,
-		TIMER_LAST_REFRESH,     (uint64_t)timers->last_refresh,
-		TIMER_NEXT_REFRESH,     (uint64_t)timers->next_refresh,
-		TIMER_LAST_RESALT,      (uint64_t)timers->last_resalt,
-		TIMER_NEXT_PARENT_DS_Q, (uint64_t)timers->next_parent_ds_q);
+	MDB_val v = knot_lmdb_make_key("BLBLBLBLBLBLBL",
+		TIMER_SOA_EXPIRE,    (uint64_t)timers->soa_expire,
+		TIMER_LAST_FLUSH,    (uint64_t)timers->last_flush,
+		TIMER_LAST_REFRESH,  (uint64_t)timers->last_refresh,
+		TIMER_NEXT_REFRESH,  (uint64_t)timers->next_refresh,
+		TIMER_LAST_RESALT,   (uint64_t)timers->last_resalt,
+		TIMER_NEXT_DS_CHECK, (uint64_t)timers->next_ds_check,
+		TIMER_NEXT_DS_PUSH,  (uint64_t)timers->next_ds_push);
 	knot_lmdb_insert(txn, &k, &v);
 	free(v.mv_data);
 }
diff --git a/src/knot/zone/timers.h b/src/knot/zone/timers.h
index 5fd5b003ce..62ab9f4f4b 100644
--- a/src/knot/zone/timers.h
+++ b/src/knot/zone/timers.h
@@ -30,8 +30,9 @@ struct zone_timers {
 	time_t last_flush;       //!< Last zone file synchronization.
 	time_t last_refresh;     //!< Last successful zone refresh attempt.
 	time_t next_refresh;     //!< Next zone refresh attempt.
-	time_t last_resalt;      //!< Last NSEC3 resalt
-	time_t next_parent_ds_q; //!< Next parent ds query
+	time_t last_resalt;      //!< Last NSEC3 resalt.
+	time_t next_ds_check;    //!< Next parent DS check.
+	time_t next_ds_push;     //!< Next DDNS to parent zone with updated DS record.
 };
 
 typedef struct zone_timers zone_timers_t;
diff --git a/tests-extra/tests/dnssec/ds_push/data/com.zone b/tests-extra/tests/dnssec/ds_push/data/com.zone
new file mode 100644
index 0000000000..34859ad30c
--- /dev/null
+++ b/tests-extra/tests/dnssec/ds_push/data/com.zone
@@ -0,0 +1,6 @@
+$ORIGIN com.
+$TTL 1200
+
+@ SOA ns admin 20110100 7 7 16 600
+ns AAAA ::0
+
diff --git a/tests-extra/tests/dnssec/ds_push/data/example.com.zone b/tests-extra/tests/dnssec/ds_push/data/example.com.zone
new file mode 100644
index 0000000000..ecd27e06e9
--- /dev/null
+++ b/tests-extra/tests/dnssec/ds_push/data/example.com.zone
@@ -0,0 +1,9 @@
+example.com.        	3	SOA	dns1.example.com. hostmaster.example.com. 2010111227 21600 3600 604800 3
+example.com.        	0	NS	dns1.example.com.
+example.com.        	2	MX	10 mail.example.com.
+dns1.example.com.   	4	A	192.0.2.1
+dns1.example.com.   	3	AAAA	2001:db8::1
+foo.example.com.    	5	A	192.0.2.4
+mail.example.com.   	3	A	192.0.2.3
+mail.example.com.   	1	AAAA	2001:db8::3
+
diff --git a/tests-extra/tests/dnssec/ds_push/test.py b/tests-extra/tests/dnssec/ds_push/test.py
new file mode 100644
index 0000000000..62f81c9357
--- /dev/null
+++ b/tests-extra/tests/dnssec/ds_push/test.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+
+"""
+Check of automatic KSK rollover with DS push.
+"""
+
+import collections
+import os
+import shutil
+import datetime
+import subprocess
+from subprocess import check_call
+
+from dnstest.utils import *
+from dnstest.keys import Keymgr
+from dnstest.test import Test
+
+def pregenerate_key(server, zone, alg):
+    class a_class_with_name:
+        def __init__(self, name):
+            self.name = name
+
+    server.gen_key(a_class_with_name("notexisting.zone."), ksk=True, alg=alg,
+                   addtopolicy=zone[0].name)
+
+# check zone if keys are present and used for signing
+def check_zone(server, zone, dnskeys, dnskey_rrsigs, cdnskeys, soa_rrsigs, msg):
+    qdnskeys = server.dig("example.com", "DNSKEY", bufsize=4096)
+    found_dnskeys = qdnskeys.count("DNSKEY")
+
+    qdnskeyrrsig = server.dig("example.com", "DNSKEY", dnssec=True, bufsize=4096)
+    found_rrsigs = qdnskeyrrsig.count("RRSIG")
+
+    qcdnskey = server.dig("example.com", "CDNSKEY", bufsize=4096)
+    found_cdnskeys = qcdnskey.count("CDNSKEY")
+
+    qsoa = server.dig("example.com", "SOA", dnssec=True, bufsize=4096)
+    found_soa_rrsigs = qsoa.count("RRSIG")
+
+    check_log("DNSKEYs: %d (expected %d)" % (found_dnskeys, dnskeys));
+    check_log("RRSIGs: %d (expected %d)" % (found_soa_rrsigs, soa_rrsigs));
+    check_log("DNSKEY-RRSIGs: %d (expected %d)" % (found_rrsigs, dnskey_rrsigs));
+    check_log("CDNSKEYs: %d (expected %d)" % (found_cdnskeys, cdnskeys));
+
+    if found_dnskeys != dnskeys:
+        set_err("BAD DNSKEY COUNT: " + msg)
+        detail_log("!DNSKEYs not published and activated as expected: " + msg)
+
+    if found_soa_rrsigs != soa_rrsigs:
+        set_err("BAD RRSIG COUNT: " + msg)
+        detail_log("!RRSIGs not published and activated as expected: " + msg)
+
+    if found_rrsigs != dnskey_rrsigs:
+        set_err("BAD DNSKEY RRSIG COUNT: " + msg)
+        detail_log("!RRSIGs not published and activated as expected: " + msg)
+
+    if found_cdnskeys != cdnskeys:
+        set_err("BAD CDNSKEY COUNT: " + msg)
+        detail_log("!CDNSKEYs not published and activated as expected: " + msg)
+
+    detail_log(SEP)
+
+    # Valgrind delay breaks the timing!
+    if not server.valgrind:
+        server.zone_backup(zone, flush=True)
+        server.zone_verify(zone)
+
+def wait_for_rrsig_count(t, server, rrtype, rrsig_count, timeout):
+    rtime = 0
+    while True:
+        qdnskeyrrsig = server.dig("example.com", rrtype, dnssec=True, bufsize=4096)
+        found_rrsigs = qdnskeyrrsig.count("RRSIG")
+        if found_rrsigs == rrsig_count:
+            break
+        rtime = rtime + 1
+        t.sleep(1)
+        if rtime > timeout:
+            break
+
+def wait_for_dnskey_count(t, server, dnskey_count, timeout):
+    rtime = 0
+    while True:
+        qdnskeyrrsig = server.dig("example.com", "DNSKEY", dnssec=True, bufsize=4096)
+        found_dnskeys = qdnskeyrrsig.count("DNSKEY")
+        if found_dnskeys == dnskey_count:
+            break
+        rtime = rtime + 1
+        t.sleep(1)
+        if rtime > timeout:
+            break
+
+def watch_ksk_rollover(t, server, zone, before_keys, after_keys, total_keys, desc, set_ksk_lifetime):
+    check_zone(server, zone, before_keys, 1, 1, 1, desc + ": initial keys")
+    orig_ksk_lifetime = server.dnssec(zone).ksk_lifetime
+
+    server.dnssec(zone).ksk_lifetime = set_ksk_lifetime if set_ksk_lifetime > 0 else orig_ksk_lifetime
+    server.gen_confile()
+    server.reload()
+
+    wait_for_dnskey_count(t, server, total_keys, 20)
+
+    t.sleep(3)
+    check_zone(server, zone, total_keys, 1, 1, 1, desc + ": published new")
+
+    server.dnssec(zone).ksk_lifetime = orig_ksk_lifetime
+    server.gen_confile()
+    server.reload()
+
+    wait_for_rrsig_count(t, server, "DNSKEY", 2, 20)
+    check_zone(server, zone, total_keys, 2, 1, 1 if before_keys > 1 else 2, desc + ": both keys active")
+
+    wait_for_rrsig_count(t, server, "DNSKEY", 1, 20)
+    check_zone(server, zone, total_keys, 1, 1, 1, desc + ": old key retired")
+
+    wait_for_dnskey_count(t, server, after_keys, 20)
+    check_zone(server, zone, after_keys, 1, 1, 1, desc + ": old key removed")
+
+t = Test(tsig=False)
+
+parent = t.server("knot")
+parent_zone = t.zone("com.", storage=".")
+t.link(parent_zone, parent, ddns=True)
+
+parent.dnssec(parent_zone).enable = True
+
+child = t.server("knot")
+child_zone = t.zone("example.com.", storage=".")
+t.link(child_zone, child)
+
+child.zonefile_sync = 24 * 60 * 60
+
+child.dnssec(child_zone).enable = True
+child.dnssec(child_zone).manual = False
+child.dnssec(child_zone).alg = "ECDSAP256SHA256"
+child.dnssec(child_zone).dnskey_ttl = 2
+child.dnssec(child_zone).zsk_lifetime = 99999
+child.dnssec(child_zone).ksk_lifetime = 300 # this can be possibly left also infinity
+child.dnssec(child_zone).propagation_delay = 11
+child.dnssec(child_zone).ksk_sbm_check = [ parent ]
+child.dnssec(child_zone).ksk_sbm_check_interval = 2
+child.dnssec(child_zone).ds_push = parent
+child.dnssec(child_zone).ksk_shared = True
+child.dnssec(child_zone).cds_publish = "always"
+
+# parameters
+ZONE = "example.com."
+
+#t.start()
+t.generate_conf()
+parent.start()
+t.sleep(2)
+child.start()
+child.zone_wait(child_zone)
+
+t.sleep(5)
+
+pregenerate_key(child, child_zone, "ECDSAP256SHA256")
+watch_ksk_rollover(t, child, child_zone, 2, 2, 3, "KSK rollover", 27)
+
+t.end()
diff --git a/tests-extra/tools/dnstest/server.py b/tests-extra/tools/dnstest/server.py
index 079db0b22c..b54489ed20 100644
--- a/tests-extra/tools/dnstest/server.py
+++ b/tests-extra/tools/dnstest/server.py
@@ -58,6 +58,7 @@ class ZoneDnssec(object):
         self.nsec3_salt_len = None
         self.ksk_sbm_check = []
         self.ksk_sbm_check_interval = None
+        self.ds_push = None
         self.ksk_shared = None
         self.cds_publish = None
         self.offline_ksk = None
@@ -1116,7 +1117,7 @@ class Knot(Server):
                     if slave.tsig:
                         s.item_str("key", slave.tsig.name)
                     servers.add(slave.name)
-            for parent in z.dnssec.ksk_sbm_check:
+            for parent in z.dnssec.ksk_sbm_check + [ z.dnssec.ds_push ] if z.dnssec.ds_push else z.dnssec.ksk_sbm_check:
                 if parent.name not in servers:
                     if not have_remote:
                         s.begin("remote")
@@ -1224,6 +1225,8 @@ class Knot(Server):
             self._str(s, "nsec3-salt-length", z.dnssec.nsec3_salt_len)
             if len(z.dnssec.ksk_sbm_check) > 0:
                 s.item("ksk-submission", z.name)
+            if z.dnssec.ds_push:
+                self._str(s, "ds-push", z.dnssec.ds_push.name)
             self._bool(s, "ksk-shared", z.dnssec.ksk_shared)
             self._str(s, "cds-cdnskey-publish", z.dnssec.cds_publish)
             self._str(s, "offline-ksk", z.dnssec.offline_ksk)
diff --git a/tests/knot/test_zone_timers.c b/tests/knot/test_zone_timers.c
index 849081be3a..f364155b89 100644
--- a/tests/knot/test_zone_timers.c
+++ b/tests/knot/test_zone_timers.c
@@ -31,9 +31,21 @@ static const zone_timers_t MOCK_TIMERS = {
 	.next_refresh = 1474559960,
 	.last_flush = 1,
 	.last_resalt = 2,
-	.next_parent_ds_q = 0,
+	.next_ds_check = 1474559961,
+	.next_ds_push = 1474559962,
 };
 
+static bool timers_eq(const zone_timers_t *a, const zone_timers_t *b)
+{
+	return a->soa_expire == b->soa_expire &&
+	       a->last_refresh == b->last_refresh &&
+	       a->next_refresh == b->next_refresh &&
+	       a->last_flush == b->last_flush &&
+	       a->last_resalt == b->last_resalt &&
+	       a->next_ds_check == b->next_ds_check &&
+	       a->next_ds_push == b->next_ds_push;
+}
+
 static bool keep_all(const knot_dname_t *zone, void *data)
 {
 	return true;
@@ -44,14 +56,6 @@ static bool remove_all(const knot_dname_t *zone, void *data)
 	return false;
 }
 
-static bool timers_eq(const zone_timers_t *a, const zone_timers_t *b)
-{
-	return a->soa_expire == b->soa_expire &&
-	       a->last_refresh == b->last_refresh &&
-	       a->next_refresh == b->next_refresh &&
-	       a->last_flush == b->last_flush;
-}
-
 int main(int argc, char *argv[])
 {
 	plan_lazy();
@@ -83,9 +87,7 @@ int main(int argc, char *argv[])
 	memset(&timers, 0, sizeof(timers));
 	ret = zone_timers_read(db, zone, &timers);
 	ok(ret == KNOT_EOK, "zone_timers_read()");
-	ok(timers_eq(&timers, &MOCK_TIMERS), "timers unmalformed (%u == %u, %ld == %ld etc.)",
-	   timers.soa_expire, MOCK_TIMERS.soa_expire, (long)timers.last_refresh,
-	   (long)MOCK_TIMERS.last_refresh);
+	ok(timers_eq(&timers, &MOCK_TIMERS), "inconsistent timers");
 
 	// Sweep none
 	ret = zone_timers_sweep(db, keep_all, NULL);
-- 
GitLab