From 67797dee6f2f81349be25609324ff7e71ee7bab2 Mon Sep 17 00:00:00 2001
From: Libor Peltan <libor.peltan@nic.cz>
Date: Tue, 18 Aug 2020 15:45:55 +0200
Subject: [PATCH] catalog: implemented generating from conf

---
 doc/man/knot.conf.5in                         |  20 ++
 doc/reference.rst                             |  18 ++
 scripts/timerdb-info.py                       |  16 +-
 src/knot/conf/schema.c                        |   3 +
 src/knot/conf/schema.h                        |   3 +
 src/knot/conf/tools.c                         |   7 +
 src/knot/events/handlers/load.c               |  40 +++-
 src/knot/zone/catalog.c                       | 206 +++++++++++++++++-
 src/knot/zone/catalog.h                       |  42 ++++
 src/knot/zone/timers.c                        |   9 +-
 src/knot/zone/timers.h                        |   3 +-
 src/knot/zone/zone.c                          |   3 +
 src/knot/zone/zone.h                          |   4 +
 src/knot/zone/zonedb-load.c                   |  91 ++++++++
 src/libdnssec/binary.c                        |  44 ++++
 src/libdnssec/binary.h                        |  23 ++
 src/libdnssec/error.c                         |   2 +
 src/libdnssec/error.h                         |   2 +
 .../tests/zone/catalog_generate/test.py       |  94 ++++++++
 tests-extra/tools/dnstest/server.py           |  17 +-
 20 files changed, 623 insertions(+), 24 deletions(-)
 create mode 100644 tests-extra/tests/zone/catalog_generate/test.py

diff --git a/doc/man/knot.conf.5in b/doc/man/knot.conf.5in
index 67414b85bc..9e4182b23a 100644
--- a/doc/man/knot.conf.5in
+++ b/doc/man/knot.conf.5in
@@ -1439,6 +1439,7 @@ zone:
     refresh\-max\-interval: TIME
     catalog\-role: none | interpret
     catalog\-template: template_id
+    catalog\-zone: DNAME
     module: STR/STR ...
 .ft P
 .fi
@@ -1747,6 +1748,11 @@ Trigger zone catalog feature. Possible values:
 .IP \(bu 2
 \fBinterpret\fP – A catalog zone which is loaded from a zone file or XFR,
 and member zones shall be configured based on its contents.
+.IP \(bu 2
+\fBgenerate\fP – A catalog zone whose contents are generated according to
+assigned member zones.
+.IP \(bu 2
+\fBmember\fP – A member zone that is assigned to one generated catalog zone.
 .UNINDENT
 .sp
 \fIDefault:\fP none
@@ -1762,6 +1768,20 @@ This option must be set if and only if \fI\%catalog\-role\fP is \fIinterpret\fP\
 .UNINDENT
 .sp
 \fIDefault:\fP not set
+.SS catalog\-zone
+.sp
+Assign this member zone to specified generated catalog zone.
+.sp
+\fBNOTE:\fP
+.INDENT 0.0
+.INDENT 3.5
+This option must be set if and only if \fI\%catalog\-role\fP is \fImember\fP\&.
+.sp
+The referenced catalog zone must exist and have \fI\%catalog\-role\fP set to \fIgenerate\fP\&.
+.UNINDENT
+.UNINDENT
+.sp
+\fIDefault:\fP not set
 .SS module
 .sp
 An ordered list of references to query modules in the form of \fImodule_name\fP or
diff --git a/doc/reference.rst b/doc/reference.rst
index 9927feed19..b4e9f6ba94 100644
--- a/doc/reference.rst
+++ b/doc/reference.rst
@@ -1579,6 +1579,7 @@ Definition of zones served by the server.
      refresh-max-interval: TIME
      catalog-role: none | interpret
      catalog-template: template_id
+     catalog-zone: DNAME
      module: STR/STR ...
 
 .. _zone_domain:
@@ -1914,6 +1915,9 @@ Trigger zone catalog feature. Possible values:
 - ``none`` – Not a catalog zone.
 - ``interpret`` – A catalog zone which is loaded from a zone file or XFR,
   and member zones shall be configured based on its contents.
+- ``generate`` – A catalog zone whose contents are generated according to
+  assigned member zones.
+- ``member`` – A member zone that is assigned to one generated catalog zone.
 
 *Default:* none
 
@@ -1929,6 +1933,20 @@ For the catalog-member zones, the specified configuration template will be appli
 
 *Default:* not set
 
+.. _zone_catalog-zone:
+
+catalog-zone
+------------
+
+Assign this member zone to specified generated catalog zone.
+
+.. NOTE::
+   This option must be set if and only if :ref:`zone_catalog-role` is *member*.
+
+   The referenced catalog zone must exist and have :ref:`zone_catalog-role` set to *generate*.
+
+*Default:* not set
+
 .. _zone_module:
 
 module
diff --git a/scripts/timerdb-info.py b/scripts/timerdb-info.py
index 6a7cd36109..c96fe417e5 100755
--- a/scripts/timerdb-info.py
+++ b/scripts/timerdb-info.py
@@ -32,15 +32,17 @@ class TimerDBInfo:
                 0x02: ("legacy_expire",  cls.format_timestamp),
                 0x03: ("legacy_flush",   cls.format_timestamp),
                 # knot >= 2.4
-                0x80: ("soa_expire",   cls.format_seconds),
-                0x81: ("last_flush",   cls.format_timestamp),
-                0x82: ("last_refresh", cls.format_timestamp),
-                0x83: ("next_refresh", cls.format_timestamp),
+                0x80: ("soa_expire",     cls.format_seconds),
+                0x81: ("last_flush",     cls.format_timestamp),
+                0x82: ("last_refresh",   cls.format_timestamp),
+                0x83: ("next_refresh",   cls.format_timestamp),
                 # knot >= 2.6
-                0x84: ("last_resalt",   cls.format_timestamp),
-                0x85: ("next_ds_check", 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),
+                0x86: ("next_ds_push",   cls.format_timestamp),
+                # knot >= 3.1
+                0x87: ("catalog_member", cls.format_timestamp),
         }
         if id in timers:
             return (timers[id][0], timers[id][1](value))
diff --git a/src/knot/conf/schema.c b/src/knot/conf/schema.c
index 4aa2dae1b6..a3eb18aea4 100644
--- a/src/knot/conf/schema.c
+++ b/src/knot/conf/schema.c
@@ -143,6 +143,8 @@ static const knot_lookup_t journal_modes[] = {
 static const knot_lookup_t catalog_roles[] = {
 	{ CATALOG_ROLE_NONE,      "none" },
 	{ CATALOG_ROLE_INTERPRET, "interpret" },
+	{ CATALOG_ROLE_GENERATE,  "generate" },
+	{ CATALOG_ROLE_MEMBER,    "member" },
 	{ 0, NULL }
 };
 
@@ -370,6 +372,7 @@ static const yp_item_t desc_policy[] = {
 	{ C_REFRESH_MAX_INTERVAL,YP_TINT,  YP_VINT = { 2, UINT32_MAX, UINT32_MAX, YP_STIME } }, \
 	{ C_CATALOG_ROLE,        YP_TOPT,  YP_VOPT = { catalog_roles, CATALOG_ROLE_NONE }, FLAGS }, \
 	{ C_CATALOG_TPL,         YP_TREF,  YP_VREF = { C_TPL }, FLAGS, { check_ref } }, \
+	{ C_CATALOG_ZONE,        YP_TDNAME,YP_VNONE, FLAGS | CONF_IO_FRLD_ZONES }, \
 	{ C_MODULE,              YP_TDATA, YP_VDATA = { 0, NULL, mod_id_to_bin, mod_id_to_txt }, \
 	                                   YP_FMULTI | FLAGS, { check_modref } }, \
 	{ C_COMMENT,             YP_TSTR,  YP_VNONE }, \
diff --git a/src/knot/conf/schema.h b/src/knot/conf/schema.h
index bf0911110c..931ad12016 100644
--- a/src/knot/conf/schema.h
+++ b/src/knot/conf/schema.h
@@ -35,6 +35,7 @@
 #define C_CATALOG_DB_MAX_SIZE   "\x13""catalog-db-max-size"
 #define C_CATALOG_ROLE		"\x0C""catalog-role"
 #define C_CATALOG_TPL		"\x10""catalog-template"
+#define C_CATALOG_ZONE		"\x0C""catalog-zone"
 #define C_CDS_CDNSKEY		"\x13""cds-cdnskey-publish"
 #define C_CHK_INTERVAL		"\x0E""check-interval"
 #define C_COMMENT		"\x07""comment"
@@ -194,6 +195,8 @@ enum {
 enum {
 	CATALOG_ROLE_NONE      = 0,
 	CATALOG_ROLE_INTERPRET = 1,
+	CATALOG_ROLE_GENERATE  = 2,
+	CATALOG_ROLE_MEMBER    = 3,
 };
 
 extern const knot_lookup_t acl_actions[];
diff --git a/src/knot/conf/tools.c b/src/knot/conf/tools.c
index 5a5f25a8a7..51573ecc40 100644
--- a/src/knot/conf/tools.c
+++ b/src/knot/conf/tools.c
@@ -646,11 +646,18 @@ int check_zone(
 	                                            C_CATALOG_ROLE, yp_dname(args->id));
 	conf_val_t catalog_tpl = conf_zone_get_txn(args->extra->conf, args->extra->txn,
 	                                           C_CATALOG_TPL, yp_dname(args->id));
+	conf_val_t catalog_zone = conf_zone_get_txn(args->extra->conf, args->extra->txn,
+	                                            C_CATALOG_ZONE, yp_dname(args->id));
 	if ((bool)(conf_opt(&catalog_role) == CATALOG_ROLE_INTERPRET) !=
 	    (bool)(catalog_tpl.code == KNOT_EOK)) {
 		args->err_str = "'catalog-role' must correspond to configured 'catalog-template'";
 		return KNOT_EINVAL;
 	}
+	if ((bool)(conf_opt(&catalog_role) == CATALOG_ROLE_MEMBER) !=
+	    (bool)(catalog_zone.code == KNOT_EOK)) {
+		args->err_str = "'catalog-role' must correspond to configured 'catalog-zone'";
+		return KNOT_EINVAL;
+	}
 
 	return KNOT_EOK;
 }
diff --git a/src/knot/events/handlers/load.c b/src/knot/events/handlers/load.c
index 0cdd870575..6b2c59614a 100644
--- a/src/knot/events/handlers/load.c
+++ b/src/knot/events/handlers/load.c
@@ -66,7 +66,9 @@ int event_load(conf_t *conf, zone_t *zone)
 	int ret = KNOT_EOK;
 
 	// If configured, load journal contents.
-	if (load_from == JOURNAL_CONTENT_ALL && !old_contents_exist && zf_from != ZONEFILE_LOAD_WHOLE) {
+	if (!old_contents_exist &&
+	    ((load_from == JOURNAL_CONTENT_ALL && zf_from != ZONEFILE_LOAD_WHOLE) ||
+	     zone->cat_members != NULL)) {
 		ret = zone_load_from_journal(conf, zone, &journal_conts);
 		if (ret != KNOT_EOK && ret != KNOT_ENOENT) {
 			goto cleanup;
@@ -77,7 +79,7 @@ int event_load(conf_t *conf, zone_t *zone)
 	}
 
 	// If configured, attempt to load zonefile.
-	if (zf_from != ZONEFILE_LOAD_NONE) {
+	if (zf_from != ZONEFILE_LOAD_NONE && zone->cat_members == NULL) {
 		struct timespec mtime;
 		char *filename = conf_zonefile(conf, zone->name);
 		ret = zonefile_exists(filename, &mtime);
@@ -135,9 +137,18 @@ int event_load(conf_t *conf, zone_t *zone)
 			}
 		}
 	}
+	if (zone->cat_members != NULL && !old_contents_exist) {
+		uint32_t serial = journal_conts == NULL ? 1 : zone_contents_serial(journal_conts);
+		serial = serial_next(serial, SERIAL_POLICY_UNIXTIME); // unixtime hardcoded
+		zf_conts = catalog_update_to_zone(zone->cat_members, zone->name, serial);
+		if (zf_conts == NULL) {
+			ret = zone->cat_members->error == KNOT_EOK ? KNOT_ENOMEM : zone->cat_members->error;
+			goto cleanup;
+		}
+	}
 
 	// If configured contents=all, but not present, store zonefile.
-	if (load_from == JOURNAL_CONTENT_ALL &&
+	if ((load_from == JOURNAL_CONTENT_ALL || zone->cat_members != NULL) &&
 	    !zone_in_journal_exists && zf_conts != NULL) {
 		ret = zone_in_journal_store(conf, zone, zf_conts);
 		if (ret != KNOT_EOK) {
@@ -147,13 +158,21 @@ int event_load(conf_t *conf, zone_t *zone)
 	}
 
 	val = conf_zone_get(conf, C_DNSSEC_SIGNING, zone->name);
-	bool dnssec_enable = conf_bool(&val), zu_from_zf_conts = false;
-	bool do_diff = (zf_from == ZONEFILE_LOAD_DIFF || zf_from == ZONEFILE_LOAD_DIFSE);
+	bool dnssec_enable = (conf_bool(&val) && zone->cat_members == NULL), zu_from_zf_conts = false;
+	bool do_diff = (zf_from == ZONEFILE_LOAD_DIFF || zf_from == ZONEFILE_LOAD_DIFSE || zone->cat_members != NULL);
 	bool ignore_dnssec = (do_diff && dnssec_enable);
 
 	// Create zone_update structure according to current state.
 	if (old_contents_exist) {
-		if (zf_conts == NULL) {
+		if (zone->cat_members != NULL) {
+			ret = zone_update_init(&up, zone, UPDATE_INCREMENTAL);
+			if (ret == KNOT_EOK) {
+				ret = catalog_update_to_update(zone->cat_members, &up);
+			}
+			if (ret == KNOT_EOK) {
+				ret = zone_update_increment_soa(&up, conf);
+			}
+		} else if (zf_conts == NULL) {
 			// nothing to be re-loaded
 			ret = KNOT_EOK;
 			goto cleanup;
@@ -168,7 +187,7 @@ int event_load(conf_t *conf, zone_t *zone)
 			ret = zone_update_from_differences(&up, zone, NULL, zf_conts, UPDATE_INCREMENTAL, ignore_dnssec);
 		}
 	} else {
-		if (journal_conts != NULL && zf_from != ZONEFILE_LOAD_WHOLE) {
+		if (journal_conts != NULL && (zf_from != ZONEFILE_LOAD_WHOLE || zone->cat_members != NULL)) {
 			if (zf_conts == NULL) {
 				// load zone-in-journal
 				ret = zone_update_from_contents(&up, zone, journal_conts, UPDATE_HYBRID);
@@ -249,7 +268,8 @@ int event_load(conf_t *conf, zone_t *zone)
 	}
 
 	// If the change is only automatically incremented SOA serial, make it no change.
-	if (zf_from == ZONEFILE_LOAD_DIFSE && (up.flags & (UPDATE_INCREMENTAL | UPDATE_HYBRID)) &&
+	if ((zf_from == ZONEFILE_LOAD_DIFSE || zone->cat_members != NULL) &&
+	    (up.flags & (UPDATE_INCREMENTAL | UPDATE_HYBRID)) &&
 	    changeset_differs_just_serial(&up.change)) {
 		changeset_t *cpy = changeset_clone(&up.change);
 		if (cpy == NULL) {
@@ -282,6 +302,10 @@ int event_load(conf_t *conf, zone_t *zone)
 	log_zone_info(zone->name, "loaded, serial %s -> %u%s, %zu bytes",
 	              old_serial_str, middle_serial, new_serial_str, zone->contents->size);
 
+	if (zone->cat_members != NULL) {
+		catalog_update_clear(zone->cat_members);
+	}
+
 	// Schedule depedent events.
 	const knot_rdataset_t *soa = zone_soa(zone);
 	zone->timers.soa_expire = knot_soa_expire(soa->rdata);
diff --git a/src/knot/zone/catalog.c b/src/knot/zone/catalog.c
index c9010a9053..f725f0c72b 100644
--- a/src/knot/zone/catalog.c
+++ b/src/knot/zone/catalog.c
@@ -22,15 +22,65 @@
 #include <string.h>
 #include <urcu.h>
 
+#include "contrib/string.h"
+#include "contrib/wire_ctx.h"
+#include "libdnssec/binary.h"
+
 #include "knot/common/log.h"
 #include "knot/conf/conf.h"
-#include "knot/zone/contents.h"
+#include "knot/updates/zone-update.h"
 
 #define CATALOG_VERSION "1.0"
 #define CATALOG_ZONE_VERSION "2" // must be just one char long
+#define CATALOG_ZONES_LABEL "zones"
+#define CATALOG_SOA_REFRESH 3600
+#define CATALOG_SOA_RETRY 600
+#define CATALOG_SOA_EXPIRE (INT32_MAX - 1)
 
 const MDB_val catalog_iter_prefix = { 1, "" };
 
+knot_dname_t *catalog_member_owner(const knot_dname_t *member,
+                                   const knot_dname_t *catzone,
+                                   time_t member_time)
+{
+	dnssec_binary_t membbin = { knot_dname_size(member), (void *)member };
+	uint64_t u64time = htobe64(member_time);
+	dnssec_binary_t timebin = { sizeof(uint64_t), (void *)&u64time };
+	dnssec_binary_t rawhash;
+
+	int ret = dnssec_binary_hash(DNSSEC_BIN_HASH_MD5, &rawhash, 2, &membbin, &timebin);
+	if (ret != KNOT_EOK) {
+		return NULL;
+	}
+
+	char *hexhash = bin_to_hex(rawhash.data, rawhash.size);
+	dnssec_binary_free(&rawhash);
+
+	long hexlen = strlen(hexhash);
+	assert(hexlen == 32);
+	long zoneslen = strlen(CATALOG_ZONES_LABEL);
+	assert(hexlen <= KNOT_DNAME_MAXLABELLEN && zoneslen <= KNOT_DNAME_MAXLABELLEN);
+	size_t catzlen = knot_dname_size(catzone);
+
+	size_t outlen = 1 + hexlen + 1 + zoneslen + catzlen;
+	knot_dname_t *out;
+	if (outlen > KNOT_DNAME_MAXLEN || (out = malloc(outlen)) == NULL) {
+		free(hexhash);
+		return NULL;
+	}
+
+	wire_ctx_t wire = wire_ctx_init(out, outlen);
+	wire_ctx_write_u8(&wire, hexlen);
+	wire_ctx_write(&wire, hexhash, hexlen);
+	wire_ctx_write_u8(&wire, zoneslen);
+	wire_ctx_write(&wire, CATALOG_ZONES_LABEL, zoneslen);
+	wire_ctx_write(&wire, catzone, catzlen);
+	assert(wire.error == KNOT_EOK);
+
+	free(hexhash);
+	return out;
+}
+
 static bool check_zone_version(const zone_contents_t *zone)
 {
 	size_t zone_size = knot_dname_size(zone->apex->owner);
@@ -380,13 +430,28 @@ int catalog_update_init(catalog_update_t *u)
 		return KNOT_ENOMEM;
 	}
 	pthread_mutex_init(&u->mutex, 0);
+	u->error = KNOT_EOK;
 	return KNOT_EOK;
 }
 
+catalog_update_t *catalog_update_new()
+{
+	catalog_update_t *u = calloc(1, sizeof(*u));
+	if (u != NULL) {
+		int ret = catalog_update_init(u);
+		if (ret != KNOT_EOK) {
+			free(u);
+			u = NULL;
+		}
+	}
+	return u;
+}
+
 static int freecb(trie_val_t *tval, void *unused)
 {
+	catalog_upd_val_t *val = *tval;
 	(void)unused;
-	free(*(void **)tval);
+	free(val);
 	return 0;
 }
 
@@ -396,6 +461,7 @@ void catalog_update_clear(catalog_update_t *u)
 	trie_clear(u->add);
 	trie_apply(u->rem, freecb, NULL);
 	trie_clear(u->rem);
+	u->error = KNOT_EOK;
 }
 
 void catalog_update_deinit(catalog_update_t *u)
@@ -405,6 +471,14 @@ void catalog_update_deinit(catalog_update_t *u)
 	trie_free(u->rem);
 }
 
+void catalog_update_free(catalog_update_t *u)
+{
+	if (u != NULL) {
+		catalog_update_deinit(u);
+		free(u);
+	}
+}
+
 int catalog_update_add(catalog_update_t *u, const knot_dname_t *member,
                        const knot_dname_t *owner, const knot_dname_t *catzone,
                        bool remove)
@@ -524,6 +598,134 @@ int catalog_update_from_zone(catalog_update_t *u, struct zone_contents *zone,
 	return ret;
 }
 
+static void set_rdata(knot_rrset_t *rrset, uint8_t *data, uint16_t len)
+{
+	knot_rdata_init(rrset->rrs.rdata, len, data);
+	rrset->rrs.size = knot_rdata_size(len);
+}
+
+struct zone_contents *catalog_update_to_zone(catalog_update_t *u, const knot_dname_t *catzone,
+                                             uint32_t soa_serial)
+{
+	if (u->error != KNOT_EOK) {
+		return NULL;
+	}
+	zone_contents_t *c = zone_contents_new(catzone, true);
+	if (c == NULL) {
+		return c;
+	}
+
+	// temporary storage to avoid allocations
+	uint8_t tmp[512];
+	knot_rdata_t *rdata = (knot_rdata_t *)tmp;
+	knot_dname_t *tmpname = tmp + 256;
+	wire_ctx_t wire;
+	zone_node_t *unused = NULL;
+	knot_rrset_t rrset = { 0 };
+	char invalid[9] = "\x07""invalid";
+	char version[8] = "\x07""version";
+	uint8_t version2[2] = "\x01" CATALOG_ZONE_VERSION;
+
+	// set catalog zone's SOA
+	rrset.owner = (knot_dname_t *)catzone;
+	rrset.type = KNOT_RRTYPE_SOA;
+	rrset.rclass = KNOT_CLASS_IN;
+	rrset.ttl = 0;
+	rrset.rrs.count = 1;
+	rrset.rrs.rdata = rdata;
+	wire = wire_ctx_init(rdata->data, 250);
+	wire_ctx_write(&wire, invalid, sizeof(invalid));
+	wire_ctx_write(&wire, invalid, sizeof(invalid));
+	wire_ctx_write_u32(&wire, soa_serial);
+	wire_ctx_write_u32(&wire, CATALOG_SOA_REFRESH);
+	wire_ctx_write_u32(&wire, CATALOG_SOA_RETRY);
+	wire_ctx_write_u32(&wire, CATALOG_SOA_EXPIRE);
+	wire_ctx_write_u32(&wire, 0);
+	rdata->len = wire_ctx_offset(&wire);
+	rrset.rrs.size = knot_rdata_size(rdata->len);
+	if (zone_contents_add_rr(c, &rrset, &unused) != KNOT_EOK) {
+		goto fail;
+	}
+
+	// set catalog zone's NS
+	unused = NULL;
+	rrset.type = KNOT_RRTYPE_NS;
+	set_rdata(&rrset, (uint8_t *)invalid, sizeof(invalid));
+	if (zone_contents_add_rr(c, &rrset, &unused) != KNOT_EOK) {
+		goto fail;
+	}
+
+	// set catalog zone's version TXT
+	unused = NULL;
+	rrset.type = KNOT_RRTYPE_TXT;
+	set_rdata(&rrset, version2, 2);
+	size_t catz_len = knot_dname_size(catzone);
+	if (catz_len + sizeof(version) > KNOT_DNAME_MAXLEN) {
+		goto fail;
+	}
+	memcpy(tmpname, version, sizeof(version));
+	memcpy(tmpname + sizeof(version), catzone, catz_len);
+	rrset.owner = tmpname;
+	if (zone_contents_add_rr(c, &rrset, &unused) != KNOT_EOK) {
+		goto fail;
+	}
+
+	// insert member zone PTR records
+	rrset.type = KNOT_RRTYPE_PTR;
+	catalog_it_t *it = catalog_it_begin(u, false);
+	while (!catalog_it_finished(it)) {
+		catalog_upd_val_t *val = catalog_it_val(it);
+		rrset.owner = val->owner;
+		set_rdata(&rrset, val->member, knot_dname_size(val->member));
+		unused = NULL;
+		if (zone_contents_add_rr(c, &rrset, &unused) != KNOT_EOK) {
+			goto fail;
+		}
+		catalog_it_next(it);
+	}
+	catalog_it_free(it);
+
+	return c;
+
+fail:
+	zone_contents_deep_free(c);
+	return NULL;
+}
+
+int catalog_update_to_update(catalog_update_t *u, struct zone_update *zu)
+{
+	knot_rrset_t ptr = { 0 };
+	ptr.type = KNOT_RRTYPE_PTR;
+	ptr.rclass = KNOT_CLASS_IN;
+	ptr.ttl = 0;
+	uint8_t tmp[KNOT_DNAME_MAXLEN + sizeof(knot_rdata_t)];
+	ptr.rrs.count = 1;
+	ptr.rrs.rdata = (knot_rdata_t *)tmp;
+
+	int ret = u->error;
+	catalog_it_t *it = catalog_it_begin(u, true);
+	while (!catalog_it_finished(it) && ret == KNOT_EOK) {
+		catalog_upd_val_t *val = catalog_it_val(it);
+		ptr.owner = val->owner;
+		set_rdata(&ptr, val->member, knot_dname_size(val->member));
+		ret = zone_update_remove(zu, &ptr);
+		catalog_it_next(it);
+	}
+	catalog_it_free(it);
+
+	it = catalog_it_begin(u, false);
+	while (!catalog_it_finished(it) && ret == KNOT_EOK) {
+		catalog_upd_val_t *val = catalog_it_val(it);
+		ptr.owner = val->owner;
+		set_rdata(&ptr, val->member, knot_dname_size(val->member));
+		ret = zone_update_add(zu, &ptr);
+		catalog_it_next(it);
+	}
+	catalog_it_free(it);
+
+	return ret;
+}
+
 typedef struct {
 	const knot_dname_t *zone;
 	catalog_update_t *u;
diff --git a/src/knot/zone/catalog.h b/src/knot/zone/catalog.h
index 003be20574..eb0477aaa4 100644
--- a/src/knot/zone/catalog.h
+++ b/src/knot/zone/catalog.h
@@ -42,6 +42,7 @@ typedef enum {
 typedef struct {
 	trie_t *rem;             // tree of catalog_upd_val_t, that gonna be removed from catalog
 	trie_t *add;             // tree of catalog_upd_val_t, that gonna be added to catalog
+	int error;               // error occured during generating of upd
 	pthread_mutex_t mutex;   // lock for accessing this struct
 } catalog_update_t;
 
@@ -54,6 +55,21 @@ typedef struct {
 
 extern const MDB_val catalog_iter_prefix;
 
+/*!
+ * \brief Generate owner name for catalog PTR record.
+ *
+ * \param member        Name of the member zone respective to the PTR record.
+ * \param catzone       Catalog zone name to contain the PTR.
+ * \param member_time   Timestamp of member zone addition.
+ *
+ * \return Owner name or NULL on error (e.g. ENOMEM, too long result...).
+ *
+ * \note Don't forget to free the return value later.
+ */
+knot_dname_t *catalog_member_owner(const knot_dname_t *member,
+                                   const knot_dname_t *catzone,
+                                   time_t member_time);
+
 /*!
  * \brief Initialize catalog structure.
  *
@@ -215,6 +231,7 @@ int catalog_copy(knot_lmdb_db_t *from, knot_lmdb_db_t *to,
  * \return KNOT_EOK, KNOT_ENOMEM
  */
 int catalog_update_init(catalog_update_t *u);
+catalog_update_t *catalog_update_new(void);
 
 /*!
  * \brief Clear contents of catalog update structure.
@@ -229,6 +246,7 @@ void catalog_update_clear(catalog_update_t *u);
  * \param u   Catalog update structure.
  */
 void catalog_update_deinit(catalog_update_t *u);
+void catalog_update_free(catalog_update_t *u);
 
 /*!
  * \brief Add a new record to catalog update structure.
@@ -272,6 +290,30 @@ struct zone_contents;
 int catalog_update_from_zone(catalog_update_t *u, struct zone_contents *zone,
                              bool remove, bool check_ver, catalog_t *check);
 
+/*!
+ * \brief Generate catalog zone contents from (full) catalog update.
+ *
+ * \param u           Catalog update to read.
+ * \param catzone     Catalog zone name.
+ * \param soa_serial  SOA serial of the generated zone.
+ *
+ * \return Catalog zone contents, or NULL if ENOMEM.
+ */
+struct zone_contents *catalog_update_to_zone(catalog_update_t *u, const knot_dname_t *catzone,
+                                             uint32_t soa_serial);
+
+struct zone_update;
+
+/*!
+ * \brief Incrementally update catalog zone from catalog update.
+ *
+ * \param u    Catalog update to read.
+ * \param zu   Zone update to be updated.
+ *
+ * \return KNOT_E*
+ */
+int catalog_update_to_update(catalog_update_t *u, struct zone_update *zu);
+
 /*!
  * \brief Add to catalog update removals of all member zones of a single catalog zone.
  *
diff --git a/src/knot/zone/timers.c b/src/knot/zone/timers.c
index 42d1cc105f..ed82061313 100644
--- a/src/knot/zone/timers.c
+++ b/src/knot/zone/timers.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2020 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
@@ -56,6 +56,7 @@ enum timer_id {
 	TIMER_LAST_RESALT,
 	TIMER_NEXT_DS_CHECK,
 	TIMER_NEXT_DS_PUSH,
+	TIMER_CATALOG_MEMBER,
 };
 
 #define TIMER_SIZE (sizeof(uint8_t) + sizeof(uint64_t))
@@ -86,6 +87,7 @@ static int deserialize_timers(zone_timers_t *timers_ptr,
 		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;
+		case TIMER_CATALOG_MEMBER:timers.catalog_member = value; break;
 		default:                 break; // ignore
 		}
 	}
@@ -104,14 +106,15 @@ 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("BLBLBLBLBLBLBL",
+	MDB_val v = knot_lmdb_make_key("BLBLBLBLBLBLBLBL",
 		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);
+		TIMER_NEXT_DS_PUSH,  (uint64_t)timers->next_ds_push,
+		TIMER_CATALOG_MEMBER,(uint64_t)timers->catalog_member);
 	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 62ab9f4f4b..f6c5286f83 100644
--- a/src/knot/zone/timers.h
+++ b/src/knot/zone/timers.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2020 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
@@ -33,6 +33,7 @@ struct zone_timers {
 	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.
+	time_t catalog_member;   //!< This catalog member zone created.
 };
 
 typedef struct zone_timers zone_timers_t;
diff --git a/src/knot/zone/zone.c b/src/knot/zone/zone.c
index 5e608f28af..b4cfeb0965 100644
--- a/src/knot/zone/zone.c
+++ b/src/knot/zone/zone.c
@@ -219,6 +219,9 @@ void zone_free(zone_t **zone_ptr)
 	/* Control update. */
 	zone_control_clear(zone);
 
+	free(zone->catalog_gen);
+	catalog_update_free(zone->cat_members);
+
 	/* Free preferred master. */
 	pthread_mutex_destroy(&zone->preferred_lock);
 	free(zone->preferred_master);
diff --git a/src/knot/zone/zone.h b/src/knot/zone/zone.h
index db2e889047..7f55c20e75 100644
--- a/src/knot/zone/zone.h
+++ b/src/knot/zone/zone.h
@@ -94,6 +94,10 @@ typedef struct zone
 	catalog_t *catalog;
 	catalog_update_t *catalog_upd;
 
+	/*! \brief Catalog-generate feature. */
+	knot_dname_t *catalog_gen;
+	catalog_update_t *cat_members;
+
 	/*! \brief Preferred master lock. Also used for flags access. */
 	pthread_mutex_t preferred_lock;
 	/*! \brief Preferred master for remote operation. */
diff --git a/src/knot/zone/zonedb-load.c b/src/knot/zone/zonedb-load.c
index c5160678d6..d648e519fe 100644
--- a/src/knot/zone/zonedb-load.c
+++ b/src/knot/zone/zonedb-load.c
@@ -88,6 +88,65 @@ static void time_set_default(time_t *time, time_t value)
 	}
 }
 
+static void catalogs_generate(knot_zonedb_t *db_new, knot_zonedb_t *db_old)
+{
+	// general comment: catz->contents!=NULL means incremental update of catalog
+
+	if (db_old != NULL) {
+		knot_zonedb_iter_t *it = knot_zonedb_iter_begin(db_old);
+		while (!knot_zonedb_iter_finished(it)) {
+			zone_t *zone = knot_zonedb_iter_val(it);
+			knot_dname_t *cg = zone->catalog_gen;
+			if (cg != NULL && knot_zonedb_find(db_new, zone->name) == NULL) {
+				zone_t *catz = knot_zonedb_find(db_new, cg);
+				if (catz != NULL && catz->contents != NULL) {
+					assert(catz->cat_members != NULL); // if this failed to allocate, catz wasn't added to zonedb
+					knot_dname_t *owner = catalog_member_owner(zone->name, cg, zone->timers.catalog_member);
+					if (owner == NULL) {
+						catz->cat_members->error = KNOT_ENOENT;
+						knot_zonedb_iter_next(it);
+						continue;
+					}
+					int ret = catalog_update_add(catz->cat_members, zone->name, owner, cg, true);
+					free(owner);
+					if (ret != KNOT_EOK) {
+						catz->cat_members->error = ret;
+					}
+				}
+			}
+			knot_zonedb_iter_next(it);
+		}
+		knot_zonedb_iter_free(it);
+	}
+
+	knot_zonedb_iter_t *it = knot_zonedb_iter_begin(db_new);
+	while (!knot_zonedb_iter_finished(it)) {
+		zone_t *zone = knot_zonedb_iter_val(it);
+		knot_dname_t *cg = zone->catalog_gen;
+		zone_t *catz = cg != NULL ? knot_zonedb_find(db_new, cg) : NULL;
+		if (cg != NULL && catz == NULL) {
+			log_zone_warning(zone->name, "member zone belongs to non-existing catalog zone");
+			continue;
+		}
+		if (cg != NULL && (catz->contents == NULL || knot_zonedb_find(db_old, zone->name) == NULL)) {
+			assert(catz->cat_members != NULL);
+			knot_dname_t *owner = catalog_member_owner(zone->name, cg, zone->timers.catalog_member);
+			if (owner == NULL) {
+				catz->cat_members->error = KNOT_ENOENT;
+				knot_zonedb_iter_next(it);
+				continue;
+			}
+			int ret = catalog_update_add(catz->cat_members, zone->name, owner, cg, false);
+			free(owner);
+			if (ret != KNOT_EOK) {
+				catz->cat_members->error = ret;
+			}
+		}
+		knot_zonedb_iter_next(it);
+	}
+	knot_zonedb_iter_free(it);
+}
+
 /*!
  * \brief Set default timers for new zones or invalidate if not valid.
  */
@@ -146,6 +205,12 @@ static zone_t *create_zone_reload(conf_t *conf, const knot_dname_t *name,
 		zone_control_clear(old_zone);
 	}
 
+	zone->cat_members = old_zone->cat_members;
+	old_zone->cat_members = NULL;
+
+	zone->catalog_gen = old_zone->catalog_gen;
+	old_zone->catalog_gen = NULL;
+
 	return zone;
 }
 
@@ -167,6 +232,30 @@ static zone_t *create_zone_new(conf_t *conf, const knot_dname_t *name,
 
 	timers_sanitize(conf, zone);
 
+	conf_val_t role = conf_zone_get(conf, C_CATALOG_ROLE, name);
+	if (conf_opt(&role) == CATALOG_ROLE_MEMBER) {
+		conf_val_t catz = conf_zone_get(conf, C_CATALOG_ZONE, name);
+		assert(catz.code == KNOT_EOK); // conf consistency checked in conf/tools.c
+		zone->catalog_gen = knot_dname_copy(conf_dname(&catz), NULL);
+		if (zone->timers.catalog_member == 0) {
+			zone->timers.catalog_member = time(NULL);
+		}
+		if (zone->catalog_gen == NULL) {
+			log_zone_error(zone->name, "failed to initialize catalog member zone (%s)",
+			               knot_strerror(KNOT_ENOMEM));
+			zone_free(&zone);
+			return NULL;
+		}
+	} else if (conf_opt(&role) == CATALOG_ROLE_GENERATE) {
+		zone->cat_members = catalog_update_new();
+		if (zone->cat_members == NULL) {
+			log_zone_error(zone->name, "failed to initialize catalog zone (%s)",
+			               knot_strerror(KNOT_ENOMEM));
+			zone_free(&zone);
+			return NULL;
+		}
+	}
+
 	if (zone_expired(zone)) {
 		// expired => force bootstrap, no load attempt
 		log_zone_info(zone->name, "zone will be bootstrapped");
@@ -552,6 +641,8 @@ void zonedb_reload(conf_t *conf, server_t *server)
 		return;
 	}
 
+	catalogs_generate(db_new, server->zone_db);
+
 	/* Switch the databases. */
 	knot_zonedb_t **db_current = &server->zone_db;
 	knot_zonedb_t *db_old = rcu_xchg_pointer(db_current, db_new);
diff --git a/src/libdnssec/binary.c b/src/libdnssec/binary.c
index 59153f39e5..c556f9f670 100644
--- a/src/libdnssec/binary.c
+++ b/src/libdnssec/binary.c
@@ -15,6 +15,7 @@
  */
 
 #include <assert.h>
+#include <stdarg.h>
 #include <string.h>
 
 #include "contrib/base64.h"
@@ -161,3 +162,46 @@ int dnssec_binary_to_base64(const dnssec_binary_t *binary,
 
 	return DNSSEC_EOK;
 }
+
+_public_
+int dnssec_binary_hash(dnssec_bin_hash_t alg, dnssec_binary_t *out, size_t nbin, ...)
+{
+	if (alg == DNSSEC_BIN_HASH_INVALID || !out || nbin == 0) {
+		return DNSSEC_EINVAL;
+	}
+
+	gnutls_digest_algorithm_t gnutls_alg = GNUTLS_DIG_UNKNOWN;
+	switch (alg) {
+	case DNSSEC_BIN_HASH_INVALID: break;
+	case DNSSEC_BIN_HASH_MD5:    gnutls_alg = GNUTLS_DIG_MD5;    break;
+	case DNSSEC_BIN_HASH_SHA1:   gnutls_alg = GNUTLS_DIG_SHA1;   break;
+	case DNSSEC_BIN_HASH_SHA256: gnutls_alg = GNUTLS_DIG_SHA256; break;
+	case DNSSEC_BIN_HASH_SHA384: gnutls_alg = GNUTLS_DIG_SHA384; break;
+	}
+
+	_cleanup_hash_ gnutls_hash_hd_t digest = NULL;
+	int r = gnutls_hash_init(&digest, gnutls_alg);
+	if (r < 0) {
+		return DNSSEC_HASH_ERROR;
+	}
+
+	va_list arg;
+	va_start(arg, nbin);
+	for (size_t i = 0; i < nbin; i++) {
+		dnssec_binary_t *bin = va_arg(arg, dnssec_binary_t *);
+
+		r = gnutls_hash(digest, bin->data, bin->size);
+		if (r != 0) {
+			return DNSSEC_HASH_ERROR;
+		}
+	}
+	va_end(arg);
+
+	out->size = gnutls_hash_get_len(gnutls_alg);
+	out->data = malloc(out->size);
+	if (out->data == NULL) {
+		return DNSSEC_ENOMEM;
+	}
+	gnutls_hash_output(digest, out->data);
+	return DNSSEC_EOK;
+}
diff --git a/src/libdnssec/binary.h b/src/libdnssec/binary.h
index 8ff4174e0f..bef75e91ea 100644
--- a/src/libdnssec/binary.h
+++ b/src/libdnssec/binary.h
@@ -113,4 +113,27 @@ int dnssec_binary_from_base64(const dnssec_binary_t *base64,
  */
 int dnssec_binary_to_base64(const dnssec_binary_t *binary,
 			    dnssec_binary_t *base64);
+
+typedef enum dnssec_bin_hash {
+	DNSSEC_BIN_HASH_INVALID = 0,
+	DNSSEC_BIN_HASH_MD5 = 1,
+	DNSSEC_BIN_HASH_SHA1 = 2,
+	DNSSEC_BIN_HASH_SHA256 = 3,
+	DNSSEC_BIN_HASH_SHA384 = 4,
+} dnssec_bin_hash_t;
+
+/*!
+ * \brief Perform MD5/SHA/... checksum of any data.
+ *
+ * \param alg   Hash algorithm to use.
+ * \param out   Output: resulting hash in the form of binary.
+ * \param nbin  Number of input binaries.
+ * \param ...   Any number of (dnssec_binary_t *) input data.
+ *
+ * \note Don't forget to dnssec_binary_free() the output.
+ *
+ * \return DNSSEC_E*
+ */
+int dnssec_binary_hash(dnssec_bin_hash_t alg, dnssec_binary_t *out, size_t nbin, ...);
+
 /*! @} */
diff --git a/src/libdnssec/error.c b/src/libdnssec/error.c
index a0f5e05f4e..e52184a03e 100644
--- a/src/libdnssec/error.c
+++ b/src/libdnssec/error.c
@@ -68,6 +68,8 @@ static const error_message_t ERROR_MESSAGES[] = {
 	{ DNSSEC_P11_TOO_MANY_MODULES,      "too many PKCS #11 modules loaded" },
 	{ DNSSEC_P11_TOKEN_NOT_AVAILABLE,   "PKCS #11 token not available" },
 
+	{ DNSSEC_HASH_ERROR,            "hashing error" },
+
 	{ 0 }
 };
 
diff --git a/src/libdnssec/error.h b/src/libdnssec/error.h
index f998fcba8c..9e1b2a6be1 100644
--- a/src/libdnssec/error.h
+++ b/src/libdnssec/error.h
@@ -80,6 +80,8 @@ enum dnssec_error {
 	DNSSEC_P11_TOO_MANY_MODULES,
 	DNSSEC_P11_TOKEN_NOT_AVAILABLE,
 
+	DNSSEC_HASH_ERROR,
+
 	DNSSEC_ERROR_MAX = -1001
 };
 
diff --git a/tests-extra/tests/zone/catalog_generate/test.py b/tests-extra/tests/zone/catalog_generate/test.py
new file mode 100644
index 0000000000..dcd84a9ff9
--- /dev/null
+++ b/tests-extra/tests/zone/catalog_generate/test.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+
+'''Test of Catalog zone generation.'''
+
+from dnstest.test import Test
+from dnstest.utils import set_err, detail_log
+import random
+
+t = Test()
+
+master = t.server("knot")
+slave = t.server("knot")
+
+catz = t.zone("example.")
+zone = t.zone("example.com.")
+
+t.link(catz, master, slave)
+t.link(zone, master)
+
+for z in zone:
+    master.zones[z.name].catalog_gen_link(master.zones[catz[0].name])
+
+slave.zones[catz[0].name].catalog = True
+slave.dnssec(catz[0]).enable = True
+slave.dnssec(catz[0]).single_type_signing = True
+
+t.start()
+
+# testcatse 1: initial catalog zone with 1 member
+slave.zones_wait(zone)
+
+# testcase 2: adding member zones online/offline
+add_online = random.choice([True, False])
+
+zone_add = t.zone("flags.") + t.zone("records.")
+t.link(zone_add, master)
+for z in zone_add:
+    master.zones[z.name].catalog_gen_link(master.zones[catz[0].name])
+
+master.gen_confile()
+
+if add_online:
+    master.reload()
+else:
+    master.stop()
+    t.sleep(1)
+    master.start()
+
+slave.zones_wait(zone + zone_add)
+
+# testcase 3: removing member zone online/offline
+rem_online = random.choice([True, False])
+
+serial_bef_rem = slave.zone_wait(catz, udp=False, tsig=True)
+master.zones.pop("example.com.")
+master.gen_confile()
+
+if rem_online:
+    master.reload()
+else:
+    master.stop()
+    t.sleep(1)
+    master.start()
+
+slave.zone_wait(catz, serial_bef_rem, udp=False, tsig=True)
+resp = slave.dig("example.com.", "SOA")
+resp.check(rcode="REFUSED")
+
+#testcase 4: remove/add same member zone while slave offline, with purge
+resp0 = slave.dig("records.", "DNSKEY")
+resp0.check_count(1, "DNSKEY")
+dnskey0 = resp0.resp.answer[0].to_rdataset()
+slave.stop()
+
+temp_rem = master.zones.pop("records.")
+master.gen_confile()
+master.reload()
+t.sleep(7)
+master.ctl("-f zone-purge +orphan records.")
+master.zones["records."] = temp_rem
+master.gen_confile()
+master.reload()
+
+slave.start()
+t.sleep(3)
+slave.ctl("zone-refresh")
+t.sleep(7)
+resp1 = slave.dig("records.", "DNSKEY")
+resp1.check_count(1, "DNSKEY")
+dnskey1 = resp1.resp.answer[0].to_rdataset()
+if dnskey0 == dnskey1:
+    set_err("ZONE NOT PURGED")
+
+t.end()
diff --git a/tests-extra/tools/dnstest/server.py b/tests-extra/tools/dnstest/server.py
index 1eb58055f3..4d62ec81ac 100644
--- a/tests-extra/tools/dnstest/server.py
+++ b/tests-extra/tools/dnstest/server.py
@@ -80,6 +80,7 @@ class Zone(object):
         self.modules = []
         self.dnssec = ZoneDnssec()
         self.catalog = None
+        self.catz = None
 
     @property
     def name(self):
@@ -96,6 +97,10 @@ class Zone(object):
     def clear_modules(self):
         self.modules.clear()
 
+    def catalog_gen_link(self, catz):
+        self.catz = catz
+        catz.catz = catz
+
     def disable_master(self, new_zone_file):
         self.zfile.remove()
         self.zfile = new_zone_file
@@ -647,7 +652,7 @@ class Server(object):
                     count += 1
         return count
 
-    def zone_wait(self, zone, serial=None, equal=False, greater=True):
+    def zone_wait(self, zone, serial=None, equal=False, greater=True, udp=True, tsig=None):
         '''Try to get SOA record. With an optional serial number and given
            relation (equal or/and greater).'''
 
@@ -659,8 +664,8 @@ class Server(object):
 
         for t in range(60):
             try:
-                resp = self.dig(zone.name, "SOA", udp=True, tries=1,
-                                timeout=2, log_no_sep=True)
+                resp = self.dig(zone.name, "SOA", udp=udp, tries=1,
+                                timeout=2, log_no_sep=True, tsig=tsig)
             except:
                 pass
             else:
@@ -1472,6 +1477,12 @@ class Knot(Server):
             elif z.ixfr:
                 s.item_str("zonefile-load", "difference")
 
+            if z.catz == z:
+                s.item_str("catalog-role", "generate")
+            elif z.catz is not None:
+                s.item_str("catalog-role", "member")
+                s.item_str("catalog-zone", z.catz.name)
+
             if z.dnssec.enable:
                 s.item_str("dnssec-signing", "on")
                 s.item_str("dnssec-policy", z.dnssec.shared_policy_with or z.name)
-- 
GitLab