From 5f5e629d5cff32b68356459738374a7738a59a13 Mon Sep 17 00:00:00 2001
From: Dominik Taborsky <dominik.taborsky@nic.cz>
Date: Fri, 1 Jul 2016 16:48:54 +0200
Subject: [PATCH] ctl: new control commands for editing zones

---
 doc/introduction.rst            |   2 +-
 doc/man/knotc.8in               |  46 ++-
 doc/man_knotc.rst               |  42 ++-
 doc/operation.rst               |  62 ++++
 src/knot/ctl/commands.c         | 625 +++++++++++++++++++++++++++++++-
 src/knot/ctl/commands.h         |   9 +
 src/knot/events/handlers/load.c |   5 +
 src/knot/zone/zone.c            |  15 +
 src/knot/zone/zone.h            |  11 +
 src/knot/zone/zonedb-load.c     |   5 +
 src/utils/knotc/commands.c      | 336 +++++++++++++----
 src/utils/knotc/commands.h      |   3 +-
 src/utils/knotc/interactive.c   |   6 +-
 13 files changed, 1092 insertions(+), 75 deletions(-)

diff --git a/doc/introduction.rst b/doc/introduction.rst
index 88a91e87fd..5243280c59 100644
--- a/doc/introduction.rst
+++ b/doc/introduction.rst
@@ -38,7 +38,7 @@ DNS features:
 
 Server features:
 
-* Adding/removing zones on-the-fly
+* Adding/removing/editing zones on-the-fly
 * Reconfiguring server instance on-the-fly
 * Dynamic configuration
 * IPv4 and IPv6 support
diff --git a/doc/man/knotc.8in b/doc/man/knotc.8in
index 74ec5bf2cf..2fda95419c 100644
--- a/doc/man/knotc.8in
+++ b/doc/man/knotc.8in
@@ -75,7 +75,8 @@ Check if the server is running.
 Stop the server if running.
 .TP
 \fBreload\fP
-Reload the server configuration and modified zone files.
+Reload the server configuration and modified zone files. All open zone
+transactions will be aborted!
 .TP
 \fBzone\-check\fP [\fIzone\fP\&...]
 Test if the server can load the zone. Semantic checks are executed if enabled
@@ -90,7 +91,8 @@ Show the zone status. (*)
 \fBzone\-reload\fP [\fIzone\fP\&...]
 Trigger a zone reload from a disk without checking its modification time. For
 slave zone, the refresh from a master server is scheduled; for master zone,
-the notification of slave servers is scheduled.
+the notification of slave servers is scheduled. An open zone transaction
+will be aborted!
 .TP
 \fBzone\-refresh\fP [\fIzone\fP\&...]
 Trigger a check for the zone serial on the zone\(aqs master. If the master has a
@@ -107,6 +109,31 @@ Trigger a zone journal flush into the zone file.
 Trigger a DNSSEC re\-sign of the zone. Existing signatures will be dropped.
 This command is valid for zones with automatic DNSSEC signing.
 .TP
+\fBzone\-read\fP \fIzone\fP [\fIowner\fP [\fItype\fP]]
+Get zone data that are currently being presented.
+.TP
+\fBzone\-begin\fP \fIzone\fP\&...
+Begin a zone transaction.
+.TP
+\fBzone\-commit\fP \fIzone\fP\&...
+Commit the zone transaction. All changes are applied to the zone.
+.TP
+\fBzone\-abort\fP \fIzone\fP\&...
+Abort the zone transaction. All changes are discarded.
+.TP
+\fBzone\-diff\fP \fIzone\fP
+Get zone changes within the transaction.
+.TP
+\fBzone\-get\fP \fIzone\fP [\fIowner\fP [\fItype\fP]]
+Get zone data within the transaction.
+.TP
+\fBzone\-set\fP \fIzone\fP \fIowner\fP [\fIttl\fP] \fItype\fP \fIrdata\fP
+Add zone record within the transaction. The first record in a rrset
+requires a ttl value specified.
+.TP
+\fBzone\-unset\fP \fIzone\fP \fIowner\fP [\fItype\fP [\fIrdata\fP]]
+Remove zone data within the transaction.
+.TP
 \fBconf\-init\fP
 Initialize the configuration database. (*)
 .TP
@@ -150,7 +177,9 @@ Unset the item data in the transaction.
 .UNINDENT
 .SS Note
 .sp
-Empty \fIzone\fP parameter means all zones.
+Empty or \fB\-\-\fP \fIzone\fP parameter means all zones or all zones with a transaction.
+.sp
+Use \fB@\fP \fIowner\fP to denote the zone name.
 .sp
 Type \fIitem\fP parameter in the form of \fIsection\fP[\fB[\fP\fIid\fP\fB]\fP][\fB\&.\fP\fIname\fP].
 .sp
@@ -234,6 +263,17 @@ $ knotc conf\-commit
 .fi
 .UNINDENT
 .UNINDENT
+.SS Get the SOA record for each configured zone
+.INDENT 0.0
+.INDENT 3.5
+.sp
+.nf
+.ft C
+$ knotc zone\-read \-\- @ SOA
+.ft P
+.fi
+.UNINDENT
+.UNINDENT
 .SH SEE ALSO
 .sp
 \fIknotd(8)\fP, \fIknot.conf(5)\fP, \fIeditrc(5)\fP\&.
diff --git a/doc/man_knotc.rst b/doc/man_knotc.rst
index 273d7b0713..64df36aee0 100644
--- a/doc/man_knotc.rst
+++ b/doc/man_knotc.rst
@@ -52,7 +52,8 @@ Actions
   Stop the server if running.
 
 **reload**
-  Reload the server configuration and modified zone files.
+  Reload the server configuration and modified zone files. All open zone
+  transactions will be aborted!
 
 **zone-check** [*zone*...]
   Test if the server can load the zone. Semantic checks are executed if enabled
@@ -67,7 +68,8 @@ Actions
 **zone-reload** [*zone*...]
   Trigger a zone reload from a disk without checking its modification time. For
   slave zone, the refresh from a master server is scheduled; for master zone,
-  the notification of slave servers is scheduled.
+  the notification of slave servers is scheduled. An open zone transaction
+  will be aborted!
 
 **zone-refresh** [*zone*...]
   Trigger a check for the zone serial on the zone's master. If the master has a
@@ -84,6 +86,31 @@ Actions
   Trigger a DNSSEC re-sign of the zone. Existing signatures will be dropped.
   This command is valid for zones with automatic DNSSEC signing.
 
+**zone-read** *zone* [*owner* [*type*]]
+  Get zone data that are currently being presented.
+
+**zone-begin** *zone*...
+  Begin a zone transaction.
+
+**zone-commit** *zone*...
+  Commit the zone transaction. All changes are applied to the zone.
+
+**zone-abort** *zone*...
+  Abort the zone transaction. All changes are discarded.
+
+**zone-diff** *zone*
+  Get zone changes within the transaction.
+
+**zone-get** *zone* [*owner* [*type*]]
+  Get zone data within the transaction.
+
+**zone-set** *zone* *owner* [*ttl*] *type* *rdata*
+  Add zone record within the transaction. The first record in a rrset
+  requires a ttl value specified.
+
+**zone-unset** *zone* *owner* [*type* [*rdata*]]
+  Remove zone data within the transaction.
+
 **conf-init**
   Initialize the configuration database. (*)
 
@@ -128,7 +155,9 @@ Actions
 Note
 ....
 
-Empty *zone* parameter means all zones.
+Empty or **--** *zone* parameter means all zones or all zones with a transaction.
+
+Use **@** *owner* to denote the zone name.
 
 Type *item* parameter in the form of *section*\ [**[**\ *id*\ **]**\ ][**.**\ *name*].
 
@@ -193,6 +222,13 @@ Add example.org zone with a zonefile location
   $ knotc conf-set 'zone[example.org].file' '/var/zones/example.org.zone'
   $ knotc conf-commit
 
+Get the SOA record for each configured zone
+...........................................
+
+::
+
+  $ knotc zone-read -- @ SOA
+
 See Also
 --------
 
diff --git a/doc/operation.rst b/doc/operation.rst
index 83d38bb4a2..b65902aafe 100644
--- a/doc/operation.rst
+++ b/doc/operation.rst
@@ -180,6 +180,68 @@ actual consumption. Also, for slave servers with incoming transfers
 enabled, be aware that the actual memory consumption might be double
 or higher during transfers.
 
+.. _Editing zones:
+
+Reading and editing zones
+=========================
+
+Knot DNS allows you to read or change zone contents online using server
+control interface.
+
+To get contents of all configured zones, or a specific zone contents, or zone
+records with a specific owner, or even with a specific record type::
+
+    $ knotc zone-read --
+    $ knotc zone-read example.com
+    $ knotc zone-read example.com ns1
+    $ knotc zone-read example.com ns1 NS
+
+.. NOTE::
+   If the record owner is not a fully qualified domain name, then it is
+   considered as a relative name to the zone name.
+
+To start a writing transaction on all zones or on specific zones::
+
+    $ knotc zone-begin --
+    $ knotc zone-begin example.com example.net
+
+Now you can list all nodes within the transaction using the ```zone-get```
+command, which always returns current data with all changes included. The
+command has the same syntax as ```zone-read```.
+
+Within the transaction, you can add a record to a specific zone or to all
+zones with an open transaction::
+
+    $ knotc zone-add example.com ns1 3600 A 192.168.0.1
+    $ knotc zone-add -- ns1 3600 A 192.168.0.1
+
+To remove all records with a specific owner, or a specific rrset, or a
+specific record data::
+
+    $ knotc zone-remove example.com ns1
+    $ knotc zone-remove example.com ns1 A
+    $ knotc zone-remove example.com ns1 A 192.168.0.2
+
+To see the difference between the original zone and the current version::
+
+    $ knotc zone-diff example.com
+
+Finally, either commit or abort your transaction::
+
+    $ knotc zone-commit example.com
+    $ knotc zone-abort example.com
+
+A full example of setting up a completely new zone from scratch::
+
+    $ knotc conf-begin
+    $ knotc conf-set zone.domain example.com
+    $ knotc conf-commit
+    $ knotc zone-begin example.com
+    $ knotc zone-add example.com @ 7200 SOA ns hostmaster 1 86400 900 691200 3600
+    $ knotc zone-add example.com ns 3600 A 192.168.0.1
+    $ knotc zone-add example.com www 3600 A 192.168.0.100
+    $ knotc zone-commit example.com
+
 .. _Controlling running daemon:
 
 Daemon controls
diff --git a/src/knot/ctl/commands.c b/src/knot/ctl/commands.c
index 4fdc958ac2..004c6bbcbb 100644
--- a/src/knot/ctl/commands.c
+++ b/src/knot/ctl/commands.c
@@ -20,10 +20,13 @@
 #include "knot/common/log.h"
 #include "knot/conf/confio.h"
 #include "knot/ctl/commands.h"
+#include "knot/updates/zone-update.h"
 #include "libknot/libknot.h"
 #include "libknot/yparser/yptrafo.h"
 #include "contrib/macros.h"
+#include "contrib/mempattern.h"
 #include "contrib/string.h"
+#include "zscanner/scanner.h"
 
 void ctl_log_data(knot_ctl_data_t *data)
 {
@@ -102,7 +105,7 @@ static int zones_apply(ctl_args_t *args, int (*fcn)(zone_t *, ctl_args_t *))
 
 	while (true) {
 		zone_t *zone;
-		int ret = get_zone(args, &zone);
+		ret = get_zone(args, &zone);
 		if (ret == KNOT_EOK) {
 			ret = fcn(zone, args);
 		}
@@ -226,6 +229,15 @@ static int zone_status(zone_t *zone, ctl_args_t *args)
 		data[KNOT_CTL_IDX_DATA] = "disabled";
 	}
 
+	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_EXTRA, &data);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	// Zone transaction.
+	data[KNOT_CTL_IDX_TYPE] = "transaction";
+	data[KNOT_CTL_IDX_DATA] = (zone->control_update != NULL) ? "open" : "none";
+
 	return knot_ctl_send(args->ctl, KNOT_CTL_TYPE_EXTRA, &data);
 }
 
@@ -297,6 +309,592 @@ static int zone_sign(zone_t *zone, ctl_args_t *args)
 	return KNOT_EOK;
 }
 
+static int zone_txn_begin(zone_t *zone, ctl_args_t *args)
+{
+	UNUSED(args);
+
+	if (zone->control_update != NULL) {
+		return KNOT_TXN_EEXISTS;
+	}
+
+	zone->control_update = malloc(sizeof(zone_update_t));
+	if (zone->control_update == NULL) {
+		return KNOT_ENOMEM;
+	}
+
+	zone_update_flags_t type = (zone->contents == NULL) ? UPDATE_FULL : UPDATE_INCREMENTAL;
+	int ret = zone_update_init(zone->control_update, zone, type | UPDATE_SIGN);
+	if (ret != KNOT_EOK) {
+		free(zone->control_update);
+		zone->control_update = NULL;
+		return ret;
+	}
+
+	return KNOT_EOK;
+}
+
+static int zone_txn_commit(zone_t *zone, ctl_args_t *args)
+{
+	UNUSED(args);
+
+	if (zone->control_update == NULL) {
+		return KNOT_TXN_ENOTEXISTS;
+	}
+
+	rcu_read_unlock();
+	int ret = zone_update_commit(conf(), zone->control_update);
+	rcu_read_lock();
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	zone_update_clear(zone->control_update);
+	free(zone->control_update);
+	zone->control_update = NULL;
+
+	return KNOT_EOK;
+}
+
+static int zone_txn_abort(zone_t *zone, ctl_args_t *args)
+{
+	UNUSED(args);
+
+	if (zone->control_update == NULL) {
+		return KNOT_TXN_ENOTEXISTS;
+	}
+
+	zone_update_clear(zone->control_update);
+	free(zone->control_update);
+	zone->control_update = NULL;
+
+	return KNOT_EOK;
+}
+
+typedef struct {
+	ctl_args_t *args;
+	int type_filter; // -1: no specific type, [0, 2^16]: specific type.
+	knot_dump_style_t style;
+	knot_ctl_data_t data;
+	char zone[KNOT_DNAME_TXT_MAXLEN + 1];
+	char owner[KNOT_DNAME_TXT_MAXLEN + 1];
+	char ttl[16];
+	char type[32];
+	char rdata[2 * 65536];
+} send_ctx_t;
+
+static send_ctx_t *create_send_ctx(const knot_dname_t *zone_name, ctl_args_t *args)
+{
+	send_ctx_t *ctx = mm_alloc(&args->mm, sizeof(*ctx));
+	if (ctx == NULL) {
+		return NULL;
+	}
+	memset(ctx, 0, sizeof(*ctx));
+
+	ctx->args = args;
+
+	// Set the dump style.
+	ctx->style.show_ttl = true;
+	ctx->style.human_tmstamp = true;
+
+	// Set the output data buffers.
+	ctx->data[KNOT_CTL_IDX_ZONE]  = ctx->zone;
+	ctx->data[KNOT_CTL_IDX_OWNER] = ctx->owner;
+	ctx->data[KNOT_CTL_IDX_TTL]   = ctx->ttl;
+	ctx->data[KNOT_CTL_IDX_TYPE]  = ctx->type;
+	ctx->data[KNOT_CTL_IDX_DATA]  = ctx->rdata;
+
+	// Set the ZONE.
+	if (knot_dname_to_str(ctx->zone, zone_name, sizeof(ctx->zone)) == NULL) {
+		mm_free(&args->mm, ctx);
+		return NULL;
+	}
+
+	// Set the TYPE filter.
+	if (args->data[KNOT_CTL_IDX_TYPE] != NULL) {
+		uint16_t type;
+		if (knot_rrtype_from_string(args->data[KNOT_CTL_IDX_TYPE], &type) != 0) {
+			mm_free(&args->mm, ctx);
+			return NULL;
+		}
+		ctx->type_filter = type;
+	} else {
+		ctx->type_filter = -1;
+	}
+
+	return ctx;
+}
+
+static int send_rrset(knot_rrset_t *rrset, send_ctx_t *ctx)
+{
+	int ret = snprintf(ctx->ttl, sizeof(ctx->ttl), "%u", knot_rrset_ttl(rrset));
+	if (ret <= 0 || ret >= sizeof(ctx->ttl)) {
+		return KNOT_ESPACE;
+	}
+
+	if (knot_rrtype_to_string(rrset->type, ctx->type, sizeof(ctx->type)) < 0) {
+		return KNOT_ESPACE;
+	}
+
+	for (size_t i = 0; i < rrset->rrs.rr_count; ++i) {
+		ret = knot_rrset_txt_dump_data(rrset, i, ctx->rdata,
+		                               sizeof(ctx->rdata), &ctx->style);
+		if (ret < 0) {
+			return ret;
+		}
+
+		ret = knot_ctl_send(ctx->args->ctl, KNOT_CTL_TYPE_DATA, &ctx->data);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+	}
+
+	return KNOT_EOK;
+}
+
+static int send_node(zone_node_t *node, void *ctx_void)
+{
+	send_ctx_t *ctx = ctx_void;
+	if (knot_dname_to_str(ctx->owner, node->owner, sizeof(ctx->owner)) == NULL) {
+		return KNOT_EINVAL;
+	}
+
+	for (size_t i = 0; i < node->rrset_count; ++i) {
+		knot_rrset_t rrset = node_rrset_at(node, i);
+
+		// Check for requested TYPE.
+		if (ctx->type_filter != -1 && rrset.type != ctx->type_filter) {
+			continue;
+		}
+
+		int ret = send_rrset(&rrset, ctx);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+	}
+
+	return KNOT_EOK;
+}
+
+static int get_owner(uint8_t *out, size_t out_len, knot_dname_t *origin,
+                     ctl_args_t *args)
+{
+	const char *owner = args->data[KNOT_CTL_IDX_OWNER];
+	assert(owner != NULL);
+
+	bool fqdn = false;
+	int prefix_len = 0;
+
+	size_t owner_len = strlen(owner);
+	if (owner_len > 0 && (owner_len != 1 || owner[0] != '@')) {
+		// Check if the owner is FQDN.
+		if (owner[owner_len - 1] == '.') {
+			fqdn = true;
+		}
+
+		knot_dname_t *dname = knot_dname_from_str(out, owner, out_len);
+		if (dname == NULL) {
+			return KNOT_EINVAL;
+		}
+
+		int ret = knot_dname_to_lower(dname);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+
+		prefix_len = knot_dname_size(out);
+		if (prefix_len <= 0) {
+			return KNOT_EINVAL;
+		}
+
+		// Ignore trailing dot.
+		prefix_len--;
+	}
+
+	// Append the origin.
+	if (!fqdn) {
+		int origin_len = knot_dname_size(origin);
+		if (origin_len <= 0 || origin_len > out_len - prefix_len) {
+			return KNOT_EINVAL;
+		}
+		memcpy(out + prefix_len, origin, origin_len);
+	}
+
+	return KNOT_EOK;
+}
+
+static int zone_read(zone_t *zone, ctl_args_t *args)
+{
+	send_ctx_t *ctx = create_send_ctx(zone->name, args);
+	if (ctx == NULL) {
+		return KNOT_ENOMEM;
+	}
+
+	int ret = KNOT_EOK;
+
+	if (args->data[KNOT_CTL_IDX_OWNER] != NULL) {
+		uint8_t owner[KNOT_DNAME_MAXLEN];
+
+		ret = get_owner(owner, sizeof(owner), zone->name, args);
+		if (ret != KNOT_EOK) {
+			goto zone_read_failed;
+		}
+
+		const zone_node_t *node = zone_contents_find_node(zone->contents, owner);
+		if (node == NULL) {
+			ret = KNOT_ENONODE;
+			goto zone_read_failed;
+		}
+
+		ret = send_node((zone_node_t *)node, ctx);
+	} else if (zone->contents != NULL) {
+		ret = zone_contents_tree_apply_inorder(zone->contents, send_node, ctx);
+	}
+
+zone_read_failed:
+	mm_free(&args->mm, ctx);
+
+	return ret;
+}
+
+static int zone_flag_txn_get(zone_t *zone, ctl_args_t *args, const char *flag)
+{
+	if (zone->control_update == NULL) {
+		return KNOT_TXN_ENOTEXISTS;
+	}
+
+	send_ctx_t *ctx = create_send_ctx(zone->name, args);
+	if (ctx == NULL) {
+		return KNOT_ENOMEM;
+	}
+	ctx->data[KNOT_CTL_IDX_FLAGS] = flag;
+
+	int ret = KNOT_EOK;
+
+	if (args->data[KNOT_CTL_IDX_OWNER] != NULL) {
+		uint8_t owner[KNOT_DNAME_MAXLEN];
+
+		ret = get_owner(owner, sizeof(owner), zone->name, args);
+		if (ret != KNOT_EOK) {
+			goto zone_txn_get_failed;
+		}
+
+		const zone_node_t *node = zone_update_get_node(zone->control_update, owner);
+		if (node == NULL) {
+			ret = KNOT_ENONODE;
+			goto zone_txn_get_failed;
+		}
+
+		ret = send_node((zone_node_t *)node, ctx);
+	} else {
+		zone_update_iter_t it;
+		ret = zone_update_iter(&it, zone->control_update);
+		if (ret != KNOT_EOK) {
+			goto zone_txn_get_failed;
+		}
+
+		const zone_node_t *iter_node = zone_update_iter_val(&it);
+		while (iter_node != NULL) {
+			ret = send_node((zone_node_t *)iter_node, ctx);
+			if (ret != KNOT_EOK) {
+				zone_update_iter_finish(&it);
+				goto zone_txn_get_failed;
+			}
+
+			ret = zone_update_iter_next(&it);
+			if (ret != KNOT_EOK) {
+				zone_update_iter_finish(&it);
+				goto zone_txn_get_failed;
+			}
+
+			iter_node = zone_update_iter_val(&it);
+		}
+		zone_update_iter_finish(&it);
+	}
+
+zone_txn_get_failed:
+	mm_free(&args->mm, ctx);
+
+	return ret;
+}
+
+static int zone_txn_get(zone_t *zone, ctl_args_t *args)
+{
+	return zone_flag_txn_get(zone, args, NULL);
+}
+
+static int send_changeset_part(changeset_t *ch, send_ctx_t *ctx, bool from)
+{
+	ctx->data[KNOT_CTL_IDX_FLAGS] = from ? CTL_FLAG_REM : CTL_FLAG_ADD;
+
+	// Send SOA only if explicitly changed.
+	if (ch->soa_to != NULL) {
+		knot_rrset_t *soa = from ? ch->soa_from : ch->soa_to;
+		assert(soa);
+
+		char *owner = knot_dname_to_str(ctx->owner, soa->owner, sizeof(ctx->owner));
+		if (owner == NULL) {
+			return KNOT_EINVAL;
+		}
+
+		int ret = send_rrset(soa, ctx);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+	}
+
+	// Send other records.
+	changeset_iter_t it;
+	int ret = from ? changeset_iter_rem(&it, ch, true) :
+	                 changeset_iter_add(&it, ch, true);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	knot_rrset_t rrset = changeset_iter_next(&it);
+	while (!knot_rrset_empty(&rrset)) {
+		char *owner = knot_dname_to_str(ctx->owner, rrset.owner, sizeof(ctx->owner));
+		if (owner == NULL) {
+			changeset_iter_clear(&it);
+			return KNOT_EINVAL;
+		}
+
+		ret = send_rrset(&rrset, ctx);
+		if (ret != KNOT_EOK) {
+			changeset_iter_clear(&it);
+			return ret;
+		}
+
+		rrset = changeset_iter_next(&it);
+	}
+	changeset_iter_clear(&it);
+
+	return KNOT_EOK;
+}
+
+static int send_changeset(changeset_t *ch, send_ctx_t *ctx)
+{
+	// First send 'from' changeset part.
+	int ret = send_changeset_part(ch, ctx, true);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	// Second send 'to' changeset part.
+	ret = send_changeset_part(ch, ctx, false);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	return KNOT_EOK;
+}
+
+static int zone_txn_diff(zone_t *zone, ctl_args_t *args)
+{
+	if (zone->control_update == NULL) {
+		return KNOT_TXN_ENOTEXISTS;
+	}
+
+	// FULL update has no changeset to print, do a 'get' instead.
+	if (zone->control_update->flags & UPDATE_FULL) {
+		return zone_flag_txn_get(zone, args, CTL_FLAG_ADD);
+	}
+
+	send_ctx_t *ctx = create_send_ctx(zone->name, args);
+	if (ctx == NULL) {
+		return KNOT_ENOMEM;
+	}
+
+	int ret = send_changeset(&zone->control_update->change, ctx);
+	mm_free(&args->mm, ctx);
+	return ret;
+}
+
+static int get_ttl(zone_t *zone, ctl_args_t *args, uint32_t *ttl)
+{
+	uint8_t owner[KNOT_DNAME_MAXLEN];
+
+	int ret = get_owner(owner, sizeof(owner), zone->name, args);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	const zone_node_t *node = zone_update_get_node(zone->control_update, owner);
+	if (node == NULL) {
+		return KNOT_ETTL;
+	}
+
+	uint16_t type;
+	if (knot_rrtype_from_string(args->data[KNOT_CTL_IDX_TYPE], &type) != 0) {
+		return KNOT_EINVAL;
+	}
+
+	knot_rdataset_t *rdataset = node_rdataset(node, type);
+	if (rdataset == NULL) {
+		return KNOT_ETTL;
+	}
+
+	*ttl = knot_rdataset_ttl(rdataset);
+
+	return KNOT_EOK;
+}
+
+static int create_rrset(knot_rrset_t **rrset, zone_t *zone, ctl_args_t *args,
+                        bool need_ttl)
+{
+	char origin_buff[KNOT_DNAME_TXT_MAXLEN + 1];
+	char *origin = knot_dname_to_str(origin_buff, zone->name, sizeof(origin_buff));
+	if (origin == NULL) {
+		return KNOT_EINVAL;
+	}
+
+	const char *owner = args->data[KNOT_CTL_IDX_OWNER];
+	const char *type  = args->data[KNOT_CTL_IDX_TYPE];
+	const char *data  = args->data[KNOT_CTL_IDX_DATA];
+	const char *ttl   = need_ttl ? args->data[KNOT_CTL_IDX_TTL] : NULL;
+
+	// Prepare a buffer for a reconstructed record.
+	const size_t buff_len = sizeof(((send_ctx_t *)0)->owner) +
+	                        sizeof(((send_ctx_t *)0)->ttl) +
+	                        sizeof(((send_ctx_t *)0)->type) +
+	                        sizeof(((send_ctx_t *)0)->rdata);
+	char *buff = mm_alloc(&args->mm, buff_len);
+	if (buff == NULL) {
+		return KNOT_ENOMEM;
+	}
+
+	uint32_t default_ttl = 0;
+	if (ttl == NULL) {
+		int ret = get_ttl(zone, args, &default_ttl);
+		if (need_ttl && ret != KNOT_EOK) {
+			mm_free(&args->mm, buff);
+			return ret;
+		}
+	}
+
+	// Reconstruct the record.
+	int ret = snprintf(buff, buff_len, "%s %s %s %s\n",
+	                   (owner != NULL ? owner : ""),
+	                   (ttl   != NULL ? ttl   : ""),
+	                   (type  != NULL ? type  : ""),
+	                   (data  != NULL ? data  : ""));
+	if (ret <= 0 || ret >= buff_len) {
+		mm_free(&args->mm, buff);
+		return KNOT_ESPACE;
+	}
+	size_t rdata_len = ret;
+
+	// Initialize RR parser.
+	zs_scanner_t *scanner = mm_alloc(&args->mm, sizeof(*scanner));
+	if (scanner == NULL) {
+		ret = KNOT_ENOMEM;
+		goto parser_failed;
+	}
+
+	// Parse the record.
+	if (zs_init(scanner, origin, KNOT_CLASS_IN, default_ttl) != 0 ||
+	    zs_set_input_string(scanner, buff, rdata_len) != 0 ||
+	    zs_parse_record(scanner) != 0 ||
+	    scanner->state != ZS_STATE_DATA) {
+		ret = KNOT_EPARSEFAIL;
+		goto parser_failed;
+	}
+
+	// Create output rrset.
+	*rrset = knot_rrset_new(scanner->r_owner, scanner->r_type,
+	                        scanner->r_class, NULL);
+	if (*rrset == NULL) {
+		ret = KNOT_ENOMEM;
+		goto parser_failed;
+	}
+
+	ret = knot_rrset_add_rdata(*rrset, scanner->r_data, scanner->r_data_length,
+	                           scanner->r_ttl, NULL);
+parser_failed:
+	zs_deinit(scanner);
+	mm_free(&args->mm, scanner);
+	mm_free(&args->mm, buff);
+
+	return ret;
+}
+
+static int zone_txn_set(zone_t *zone, ctl_args_t *args)
+{
+	if (zone->control_update == NULL) {
+		return KNOT_TXN_ENOTEXISTS;
+	}
+
+	if (args->data[KNOT_CTL_IDX_OWNER] == NULL ||
+	    args->data[KNOT_CTL_IDX_TYPE]  == NULL) {
+		return KNOT_EINVAL;
+	}
+
+	knot_rrset_t *rrset;
+	int ret = create_rrset(&rrset, zone, args, true);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	ret = zone_update_add(zone->control_update, rrset);
+	knot_rrset_free(&rrset, NULL);
+
+	// Silently update TTL.
+	if (ret == KNOT_ETTL) {
+		ret = KNOT_EOK;
+	}
+
+	return ret;
+}
+
+static int zone_txn_unset(zone_t *zone, ctl_args_t *args)
+{
+	if (zone->control_update == NULL) {
+		return KNOT_TXN_ENOTEXISTS;
+	}
+
+	if (args->data[KNOT_CTL_IDX_OWNER] == NULL) {
+		return KNOT_EINVAL;
+	}
+
+	// Remove specific record.
+	if (args->data[KNOT_CTL_IDX_DATA] != NULL) {
+		if (args->data[KNOT_CTL_IDX_TYPE] == NULL) {
+			return KNOT_EINVAL;
+		}
+
+		knot_rrset_t *rrset;
+		int ret = create_rrset(&rrset, zone, args, false);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+
+		ret = zone_update_remove(zone->control_update, rrset);
+		knot_rrset_free(&rrset, NULL);
+		return ret;
+	} else {
+		uint8_t owner[KNOT_DNAME_MAXLEN];
+
+		int ret = get_owner(owner, sizeof(owner), zone->name, args);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+
+		// Remove whole rrset.
+		if (args->data[KNOT_CTL_IDX_TYPE] != NULL) {
+			uint16_t type;
+			if (knot_rrtype_from_string(args->data[KNOT_CTL_IDX_TYPE],
+			                            &type) != 0) {
+				return KNOT_EINVAL;
+			}
+
+			return zone_update_remove_rrset(zone->control_update, owner, type);
+		// Remove whole node.
+		} else {
+			return zone_update_remove_node(zone->control_update, owner);
+		}
+	}
+
+}
+
 static int ctl_zone(ctl_args_t *args, ctl_cmd_t cmd)
 {
 	switch (cmd) {
@@ -312,6 +910,22 @@ static int ctl_zone(ctl_args_t *args, ctl_cmd_t cmd)
 		return zones_apply(args, zone_flush);
 	case CTL_ZONE_SIGN:
 		return zones_apply(args, zone_sign);
+	case CTL_ZONE_READ:
+		return zones_apply(args, zone_read);
+	case CTL_ZONE_BEGIN:
+		return zones_apply(args, zone_txn_begin);
+	case CTL_ZONE_COMMIT:
+		return zones_apply(args, zone_txn_commit);
+	case CTL_ZONE_ABORT:
+		return zones_apply(args, zone_txn_abort);
+	case CTL_ZONE_DIFF:
+		return zones_apply(args, zone_txn_diff);
+	case CTL_ZONE_GET:
+		return zones_apply(args, zone_txn_get);
+	case CTL_ZONE_SET:
+		return zones_apply(args, zone_txn_set);
+	case CTL_ZONE_UNSET:
+		return zones_apply(args, zone_txn_unset);
 	default:
 		assert(0);
 		return KNOT_EINVAL;
@@ -612,6 +1226,15 @@ static const desc_t cmd_table[] = {
 	[CTL_ZONE_FLUSH]      = { "zone-flush",      ctl_zone },
 	[CTL_ZONE_SIGN]       = { "zone-sign",       ctl_zone },
 
+	[CTL_ZONE_READ]       = { "zone-read",       ctl_zone },
+	[CTL_ZONE_BEGIN]      = { "zone-begin",      ctl_zone },
+	[CTL_ZONE_COMMIT]     = { "zone-commit",     ctl_zone },
+	[CTL_ZONE_ABORT]      = { "zone-abort",      ctl_zone },
+	[CTL_ZONE_DIFF]       = { "zone-diff",       ctl_zone },
+	[CTL_ZONE_GET]        = { "zone-get",        ctl_zone },
+	[CTL_ZONE_SET]        = { "zone-set",        ctl_zone },
+	[CTL_ZONE_UNSET]      = { "zone-unset",      ctl_zone },
+
 	[CTL_CONF_LIST]       = { "conf-list",       ctl_conf_read },
 	[CTL_CONF_READ]       = { "conf-read",       ctl_conf_read },
 	[CTL_CONF_BEGIN]      = { "conf-begin",      ctl_conf_txn },
diff --git a/src/knot/ctl/commands.h b/src/knot/ctl/commands.h
index 43b068552d..f601a20403 100644
--- a/src/knot/ctl/commands.h
+++ b/src/knot/ctl/commands.h
@@ -46,6 +46,15 @@ typedef enum {
 	CTL_ZONE_FLUSH,
 	CTL_ZONE_SIGN,
 
+	CTL_ZONE_READ,
+	CTL_ZONE_BEGIN,
+	CTL_ZONE_COMMIT,
+	CTL_ZONE_ABORT,
+	CTL_ZONE_DIFF,
+	CTL_ZONE_GET,
+	CTL_ZONE_SET,
+	CTL_ZONE_UNSET,
+
 	CTL_CONF_LIST,
 	CTL_CONF_READ,
 	CTL_CONF_BEGIN,
diff --git a/src/knot/events/handlers/load.c b/src/knot/events/handlers/load.c
index b2137c026b..0507ed5c58 100644
--- a/src/knot/events/handlers/load.c
+++ b/src/knot/events/handlers/load.c
@@ -120,6 +120,11 @@ int event_load(conf_t *conf, zone_t *zone)
 		log_zone_info(zone->name, "loaded, serial %u", current_serial);
 	}
 
+	if (zone->control_update != NULL) {
+		log_zone_warning(zone->name, "control transaction aborted");
+		zone_control_clear(zone);
+	}
+
 	return KNOT_EOK;
 
 fail:
diff --git a/src/knot/zone/zone.c b/src/knot/zone/zone.c
index 684b5ff5c0..60a809b1ce 100644
--- a/src/knot/zone/zone.c
+++ b/src/knot/zone/zone.c
@@ -22,6 +22,7 @@
 #include "knot/common/log.h"
 #include "knot/nameserver/process_query.h"
 #include "knot/query/requestor.h"
+#include "knot/updates/zone-update.h"
 #include "knot/zone/contents.h"
 #include "knot/zone/serial.h"
 #include "knot/zone/zone.h"
@@ -77,6 +78,17 @@ zone_t* zone_new(const knot_dname_t *name)
 	return zone;
 }
 
+void zone_control_clear(zone_t *zone)
+{
+	if (zone == NULL) {
+		return;
+	}
+
+	zone_update_clear(zone->control_update);
+	free(zone->control_update);
+	zone->control_update = NULL;
+}
+
 void zone_free(zone_t **zone_ptr)
 {
 	if (zone_ptr == NULL || *zone_ptr == NULL) {
@@ -93,6 +105,9 @@ void zone_free(zone_t **zone_ptr)
 	pthread_mutex_destroy(&zone->ddns_lock);
 	pthread_mutex_destroy(&zone->journal_lock);
 
+	/* Control update. */
+	zone_control_clear(zone);
+
 	/* 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 b501acbce4..cde7f3c34e 100644
--- a/src/knot/zone/zone.h
+++ b/src/knot/zone/zone.h
@@ -36,6 +36,7 @@
 #include "libknot/packet/pkt.h"
 
 struct process_query_param;
+struct zone_update;
 
 /*!
  * \brief Zone flags.
@@ -72,6 +73,9 @@ typedef struct zone
 	size_t ddns_queue_size;
 	list_t ddns_queue;
 
+	/*! \brief Control update context. */
+	struct zone_update *control_update;
+
 	/*! \brief Journal access lock. */
 	pthread_mutex_t journal_lock;
 
@@ -103,6 +107,13 @@ zone_t* zone_new(const knot_dname_t *name);
  */
 void zone_free(zone_t **zone_ptr);
 
+/*!
+ * \brief Clears possible control update transaction.
+ *
+ * \param zone Zone to be cleared.
+ */
+void zone_control_clear(zone_t *zone);
+
 /*!
  * \note Zone change API below, subject to change.
  * \ref #223 New zone API
diff --git a/src/knot/zone/zonedb-load.c b/src/knot/zone/zonedb-load.c
index d17cf24264..efb4d330bb 100644
--- a/src/knot/zone/zonedb-load.c
+++ b/src/knot/zone/zonedb-load.c
@@ -157,6 +157,11 @@ static zone_t *create_zone_reload(conf_t *conf, const knot_dname_t *name,
 		assert(0);
 	}
 
+	if (old_zone->control_update != NULL) {
+		log_zone_warning(old_zone->name, "control transaction aborted");
+		zone_control_clear(old_zone);
+	}
+
 	return zone;
 }
 
diff --git a/src/utils/knotc/commands.c b/src/utils/knotc/commands.c
index c8245e1a62..8e4a8fcb69 100644
--- a/src/utils/knotc/commands.c
+++ b/src/utils/knotc/commands.c
@@ -27,6 +27,7 @@
 #include "knot/zone/zone-load.h"
 #include "contrib/macros.h"
 #include "contrib/string.h"
+#include "contrib/openbsd/strlcat.h"
 #include "utils/knotc/commands.h"
 #include "utils/knotc/estimator.h"
 
@@ -45,6 +46,15 @@
 #define CMD_ZONE_FLUSH		"zone-flush"
 #define CMD_ZONE_SIGN		"zone-sign"
 
+#define CMD_ZONE_READ		"zone-read"
+#define CMD_ZONE_BEGIN		"zone-begin"
+#define CMD_ZONE_COMMIT		"zone-commit"
+#define CMD_ZONE_ABORT		"zone-abort"
+#define CMD_ZONE_DIFF		"zone-diff"
+#define CMD_ZONE_GET		"zone-get"
+#define CMD_ZONE_SET		"zone-set"
+#define CMD_ZONE_UNSET		"zone-unset"
+
 #define CMD_CONF_INIT		"conf-init"
 #define CMD_CONF_CHECK		"conf-check"
 #define CMD_CONF_IMPORT		"conf-import"
@@ -59,15 +69,25 @@
 #define CMD_CONF_SET		"conf-set"
 #define CMD_CONF_UNSET		"conf-unset"
 
-static int check_args(cmd_args_t *args, unsigned count)
+#define CTL_LOG_STR		"failed to control"
+
+static int check_args(cmd_args_t *args, int min, int max)
 {
-	if (args->argc == count) {
-		return KNOT_EOK;
+	if (max == 0 && args->argc > 0) {
+		log_error("command doesn't take arguments");
+		return KNOT_EINVAL;
+	} else if (min == max && args->argc != min) {
+		log_error("command requires %i arguments", min);
+		return KNOT_EINVAL;
+	} else if (args->argc < min) {
+		log_error("command requires at least %i arguments", min);
+		return KNOT_EINVAL;
+	} else if (max > 0 && args->argc > max) {
+		log_error("command takes at most %i arguments", max);
+		return KNOT_EINVAL;
 	}
 
-	log_error("command requires %u arguments", count);
-
-	return KNOT_EINVAL;
+	return KNOT_EOK;
 }
 
 static int check_conf_args(cmd_args_t *args)
@@ -166,16 +186,25 @@ static void format_data(ctl_cmd_t cmd, knot_ctl_type_t data_type,
 	const char *key1  = (*data)[KNOT_CTL_IDX_ITEM];
 	const char *id    = (*data)[KNOT_CTL_IDX_ID];
 	const char *zone  = (*data)[KNOT_CTL_IDX_ZONE];
+	const char *owner = (*data)[KNOT_CTL_IDX_OWNER];
+	const char *ttl   = (*data)[KNOT_CTL_IDX_TTL];
 	const char *type  = (*data)[KNOT_CTL_IDX_TYPE];
 	const char *value = (*data)[KNOT_CTL_IDX_DATA];
 
+	const char *sign = NULL;
+	if (ctl_has_flag(flags, CTL_FLAG_ADD)) {
+		sign = CTL_FLAG_ADD;
+	} else if (ctl_has_flag(flags, CTL_FLAG_REM)) {
+		sign = CTL_FLAG_REM;
+	}
+
 	switch (cmd) {
 	case CTL_STATUS:
 	case CTL_STOP:
 	case CTL_RELOAD:
 	case CTL_CONF_BEGIN:
-	case CTL_CONF_ABORT:
 	case CTL_CONF_COMMIT:
+	case CTL_CONF_ABORT:
 		// Only error message is expected here.
 		if (error != NULL) {
 			printf("error: (%s)", error);
@@ -187,6 +216,9 @@ static void format_data(ctl_cmd_t cmd, knot_ctl_type_t data_type,
 	case CTL_ZONE_RETRANSFER:
 	case CTL_ZONE_FLUSH:
 	case CTL_ZONE_SIGN:
+	case CTL_ZONE_BEGIN:
+	case CTL_ZONE_COMMIT:
+	case CTL_ZONE_ABORT:
 		if (data_type == KNOT_CTL_TYPE_DATA) {
 			printf("%s%s%s%s%s%s%s%s",
 			       (!(*empty)     ? "\n"      : ""),
@@ -212,13 +244,6 @@ static void format_data(ctl_cmd_t cmd, knot_ctl_type_t data_type,
 	case CTL_CONF_SET:
 	case CTL_CONF_UNSET:
 		if (data_type == KNOT_CTL_TYPE_DATA) {
-			const char *sign = NULL;
-			if (ctl_has_flag(flags, CTL_FLAG_ADD)) {
-				sign = CTL_FLAG_ADD;
-			} else if (ctl_has_flag(flags, CTL_FLAG_REM)) {
-				sign = CTL_FLAG_REM;
-			}
-
 			printf("%s%s%s%s%s%s%s%s%s%s%s%s",
 			       (!(*empty)     ? "\n"       : ""),
 			       (error != NULL ? "error: (" : ""),
@@ -238,6 +263,32 @@ static void format_data(ctl_cmd_t cmd, knot_ctl_type_t data_type,
 			printf(" %s", value);
 		}
 		break;
+	case CTL_ZONE_READ:
+	case CTL_ZONE_DIFF:
+	case CTL_ZONE_GET:
+	case CTL_ZONE_SET:
+	case CTL_ZONE_UNSET:
+		if (data_type == KNOT_CTL_TYPE_DATA) {
+			printf("%s%s%s%s%s%s%s%s%s%s%s%s%s",
+			       (!(*empty)     ? "\n"       : ""),
+			       (error != NULL ? "error: (" : ""),
+			       (error != NULL ? error      : ""),
+			       (error != NULL ? ") "       : ""),
+			       (zone  != NULL ? "["        : ""),
+			       (zone  != NULL ? zone       : ""),
+			       (zone  != NULL ? "] "       : ""),
+			       (sign  != NULL ? sign       : ""),
+			       (owner != NULL ? owner      : ""),
+			       (ttl   != NULL ? " "        : ""),
+			       (ttl   != NULL ? ttl        : ""),
+			       (type  != NULL ? " "        : ""),
+			       (type  != NULL ? type       : ""));
+			*empty = false;
+		}
+		if (value != NULL) {
+			printf(" %s", value);
+		}
+		break;
 	default:
 		assert(0);
 	}
@@ -256,8 +307,8 @@ static void format_block(ctl_cmd_t cmd, bool failed, bool empty)
 		printf("%s\n", failed ? "" : "Reloaded");
 		break;
 	case CTL_CONF_BEGIN:
-	case CTL_CONF_ABORT:
 	case CTL_CONF_COMMIT:
+	case CTL_CONF_ABORT:
 	case CTL_CONF_SET:
 	case CTL_CONF_UNSET:
 	case CTL_ZONE_RELOAD:
@@ -265,9 +316,17 @@ static void format_block(ctl_cmd_t cmd, bool failed, bool empty)
 	case CTL_ZONE_RETRANSFER:
 	case CTL_ZONE_FLUSH:
 	case CTL_ZONE_SIGN:
+	case CTL_ZONE_BEGIN:
+	case CTL_ZONE_COMMIT:
+	case CTL_ZONE_ABORT:
+	case CTL_ZONE_SET:
+	case CTL_ZONE_UNSET:
 		printf("%s\n", failed ? "" : "OK");
 		break;
 	case CTL_ZONE_STATUS:
+	case CTL_ZONE_READ:
+	case CTL_ZONE_DIFF:
+	case CTL_ZONE_GET:
 	case CTL_CONF_LIST:
 	case CTL_CONF_READ:
 	case CTL_CONF_DIFF:
@@ -290,13 +349,13 @@ static int ctl_receive(cmd_args_t *args)
 
 		int ret = knot_ctl_receive(args->ctl, &type, &data);
 		if (ret != KNOT_EOK) {
-			log_error("failed to control (%s)", knot_strerror(ret));
+			log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 			return ret;
 		}
 
 		switch (type) {
 		case KNOT_CTL_TYPE_END:
-			log_error("failed to control (%s)", knot_strerror(KNOT_EMALF));
+			log_error(CTL_LOG_STR" (%s)", knot_strerror(KNOT_EMALF));
 			return KNOT_EMALF;
 		case KNOT_CTL_TYPE_BLOCK:
 			format_block(args->desc->cmd, failed, empty);
@@ -307,6 +366,7 @@ static int ctl_receive(cmd_args_t *args)
 			break;
 		default:
 			assert(0);
+			return KNOT_EINVAL;
 		}
 
 		if (data[KNOT_CTL_IDX_ERROR] != NULL) {
@@ -319,27 +379,27 @@ static int ctl_receive(cmd_args_t *args)
 
 static int cmd_ctl(cmd_args_t *args)
 {
-	int ret = check_args(args, 0);
+	int ret = check_args(args, 0, 0);
 	if (ret != KNOT_EOK) {
 		return ret;
 	}
 
 	knot_ctl_data_t data = {
 		[KNOT_CTL_IDX_CMD] = ctl_cmd_to_str(args->desc->cmd),
-		[KNOT_CTL_IDX_FLAGS] = args->force ? CTL_FLAG_FORCE : ""
+		[KNOT_CTL_IDX_FLAGS] = args->force ? CTL_FLAG_FORCE : NULL
 	};
 
 	// Send the command.
 	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
 	if (ret != KNOT_EOK) {
-		log_error("failed to control (%s)", knot_strerror(ret));
+		log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 		return ret;
 	}
 
 	// Finish the input block.
 	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_BLOCK, NULL);
 	if (ret != KNOT_EOK) {
-		log_error("failed to control (%s)", knot_strerror(ret));
+		log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 		return ret;
 	}
 
@@ -490,13 +550,24 @@ static int cmd_zone_ctl(cmd_args_t *args)
 {
 	knot_ctl_data_t data = {
 		[KNOT_CTL_IDX_CMD] = ctl_cmd_to_str(args->desc->cmd),
-		[KNOT_CTL_IDX_FLAGS] = args->force ? CTL_FLAG_FORCE : ""
+		[KNOT_CTL_IDX_FLAGS] = args->force ? CTL_FLAG_FORCE : NULL
 	};
 
+	// Check the number of arguments.
+	int ret = check_args(args, (args->desc->flags & CMD_FREQ_ZONE) ? 1 : 0, -1);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	// Ignore all zones argument.
+	if (args->argc == 1 && strcmp(args->argv[0], "--") == 0) {
+		args->argc = 0;
+	}
+
 	if (args->argc == 0) {
 		int ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
 		if (ret != KNOT_EOK) {
-			log_error("failed to control (%s)", knot_strerror(ret));
+			log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 			return ret;
 		}
 	}
@@ -505,15 +576,131 @@ static int cmd_zone_ctl(cmd_args_t *args)
 
 		int ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
 		if (ret != KNOT_EOK) {
-			log_error("failed to control (%s)", knot_strerror(ret));
+			log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
+			return ret;
+		}
+	}
+
+	// Finish the input block.
+	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_BLOCK, NULL);
+	if (ret != KNOT_EOK) {
+		log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
+		return ret;
+	}
+
+	return ctl_receive(args);
+}
+
+static int set_rdata(cmd_args_t *args, int pos, char *rdata, size_t rdata_len)
+{
+	rdata[0] = '\0';
+
+	for (int i = pos; i < args->argc; i++) {
+		if (i > pos && strlcat(rdata, " ", rdata_len) >= rdata_len) {
+			return KNOT_ESPACE;
+		}
+		if (strlcat(rdata, args->argv[i], rdata_len) >= rdata_len) {
+			return KNOT_ESPACE;
+		}
+	}
+
+	return KNOT_EOK;
+}
+
+static int set_node_items(cmd_args_t *args, knot_ctl_data_t *data, char *rdata,
+                          size_t rdata_len)
+{
+	int min_args, max_args;
+	switch (args->desc->cmd) {
+	case CTL_ZONE_READ:
+	case CTL_ZONE_GET:   min_args = 1; max_args =  3; break;
+	case CTL_ZONE_DIFF:  min_args = 1; max_args =  1; break;
+	case CTL_ZONE_SET:   min_args = 3; max_args = -1; break;
+	case CTL_ZONE_UNSET: min_args = 2; max_args = -1; break;
+	default:
+		assert(0);
+		return KNOT_EINVAL;
+	}
+
+	// Check the number of arguments.
+	int ret = check_args(args, min_args, max_args);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	int idx = 0;
+
+	// Set ZONE name.
+	assert(args->argc > idx);
+	if (strcmp(args->argv[idx], "--") != 0) {
+		(*data)[KNOT_CTL_IDX_ZONE] = args->argv[idx];
+	}
+	idx++;
+
+	// Set OWNER name if specified.
+	if (args->argc > idx) {
+		(*data)[KNOT_CTL_IDX_OWNER] = args->argv[idx];
+		idx++;
+	}
+
+	// Set TTL only with an editing operation.
+	if (args->argc > idx) {
+		uint16_t type;
+		if (knot_rrtype_from_string(args->argv[idx], &type) != 0) {
+			switch (args->desc->cmd) {
+			case CTL_ZONE_SET:
+			case CTL_ZONE_UNSET:
+				(*data)[KNOT_CTL_IDX_TTL] = args->argv[idx];
+				idx++;
+				break;
+			default:
+				return KNOT_EINVAL;
+			}
+		}
+	}
+
+	// Set record TYPE if specified.
+	if (args->argc > idx) {
+		(*data)[KNOT_CTL_IDX_TYPE] = args->argv[idx];
+		idx++;
+	}
+
+	// Set record DATA if specified.
+	if (args->argc > idx) {
+		ret = set_rdata(args, idx, rdata, rdata_len);
+		if (ret != KNOT_EOK) {
 			return ret;
 		}
+		(*data)[KNOT_CTL_IDX_DATA] = rdata;
+	}
+
+	return KNOT_EOK;
+}
+
+static int cmd_zone_node_ctl(cmd_args_t *args)
+{
+	knot_ctl_data_t data = {
+		[KNOT_CTL_IDX_CMD] = ctl_cmd_to_str(args->desc->cmd),
+		[KNOT_CTL_IDX_FLAGS] = args->force ? CTL_FLAG_FORCE : NULL
+	};
+
+	char rdata[65536]; // Maximum item size in libknot control interface.
+
+	int ret = set_node_items(args, &data, rdata, sizeof(rdata));
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
+	if (ret != KNOT_EOK) {
+		log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
+		return ret;
 	}
 
 	// Finish the input block.
-	int ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_BLOCK, NULL);
+	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_BLOCK, NULL);
 	if (ret != KNOT_EOK) {
-		log_error("failed to control (%s)", knot_strerror(ret));
+		log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 		return ret;
 	}
 
@@ -522,7 +709,7 @@ static int cmd_zone_ctl(cmd_args_t *args)
 
 static int cmd_conf_init(cmd_args_t *args)
 {
-	int ret = check_args(args, 0);
+	int ret = check_args(args, 0, 0);
 	if (ret != KNOT_EOK) {
 		return ret;
 	}
@@ -549,7 +736,7 @@ static int cmd_conf_init(cmd_args_t *args)
 
 static int cmd_conf_check(cmd_args_t *args)
 {
-	int ret = check_args(args, 0);
+	int ret = check_args(args, 0, 0);
 	if (ret != KNOT_EOK) {
 		return ret;
 	}
@@ -561,7 +748,7 @@ static int cmd_conf_check(cmd_args_t *args)
 
 static int cmd_conf_import(cmd_args_t *args)
 {
-	int ret = check_args(args, 1);
+	int ret = check_args(args, 1, 1);
 	if (ret != KNOT_EOK) {
 		return ret;
 	}
@@ -590,7 +777,7 @@ static int cmd_conf_import(cmd_args_t *args)
 
 static int cmd_conf_export(cmd_args_t *args)
 {
-	int ret = check_args(args, 1);
+	int ret = check_args(args, 1, 1);
 	if (ret != KNOT_EOK) {
 		return ret;
 	}
@@ -618,14 +805,14 @@ static int cmd_conf_ctl(cmd_args_t *args)
 
 	knot_ctl_data_t data = {
 		[KNOT_CTL_IDX_CMD] = ctl_cmd_to_str(args->desc->cmd),
-		[KNOT_CTL_IDX_FLAGS] = args->force ? CTL_FLAG_FORCE : ""
+		[KNOT_CTL_IDX_FLAGS] = args->force ? CTL_FLAG_FORCE : NULL
 	};
 
 	// Send the command without parameters.
 	if (args->argc == 0) {
 		ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
 		if (ret != KNOT_EOK) {
-			log_error("failed to control (%s)", knot_strerror(ret));
+			log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 			return ret;
 		}
 	// Set the first item argument.
@@ -639,7 +826,7 @@ static int cmd_conf_ctl(cmd_args_t *args)
 		if (args->argc == 1 || !(args->desc->flags & CMD_FOPT_DATA)) {
 			ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
 			if (ret != KNOT_EOK) {
-				log_error("failed to control (%s)", knot_strerror(ret));
+				log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 				return ret;
 			}
 		}
@@ -658,7 +845,7 @@ static int cmd_conf_ctl(cmd_args_t *args)
 
 		ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
 		if (ret != KNOT_EOK) {
-			log_error("failed to control (%s)", knot_strerror(ret));
+			log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 			return ret;
 		}
 	}
@@ -666,7 +853,7 @@ static int cmd_conf_ctl(cmd_args_t *args)
 	// Finish the input block.
 	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_BLOCK, NULL);
 	if (ret != KNOT_EOK) {
-		log_error("failed to control (%s)", knot_strerror(ret));
+		log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
 		return ret;
 	}
 
@@ -689,6 +876,15 @@ const cmd_desc_t cmd_table[] = {
 	{ CMD_ZONE_FLUSH,      cmd_zone_ctl,      CTL_ZONE_FLUSH,      CMD_FOPT_ZONE },
 	{ CMD_ZONE_SIGN,       cmd_zone_ctl,      CTL_ZONE_SIGN,       CMD_FOPT_ZONE },
 
+	{ CMD_ZONE_READ,       cmd_zone_node_ctl, CTL_ZONE_READ,       CMD_FREQ_ZONE },
+	{ CMD_ZONE_BEGIN,      cmd_zone_ctl,      CTL_ZONE_BEGIN,      CMD_FREQ_ZONE | CMD_FOPT_ZONE },
+	{ CMD_ZONE_COMMIT,     cmd_zone_ctl,      CTL_ZONE_COMMIT,     CMD_FREQ_ZONE | CMD_FOPT_ZONE },
+	{ CMD_ZONE_ABORT,      cmd_zone_ctl,      CTL_ZONE_ABORT,      CMD_FREQ_ZONE | CMD_FOPT_ZONE },
+	{ CMD_ZONE_DIFF,       cmd_zone_node_ctl, CTL_ZONE_DIFF,       CMD_FREQ_ZONE },
+	{ CMD_ZONE_GET,        cmd_zone_node_ctl, CTL_ZONE_GET,        CMD_FREQ_ZONE },
+	{ CMD_ZONE_SET,        cmd_zone_node_ctl, CTL_ZONE_SET,        CMD_FREQ_ZONE },
+	{ CMD_ZONE_UNSET,      cmd_zone_node_ctl, CTL_ZONE_UNSET,      CMD_FREQ_ZONE },
+
 	{ CMD_CONF_INIT,       cmd_conf_init,     CTL_NONE,            CMD_FWRITE },
 	{ CMD_CONF_CHECK,      cmd_conf_check,    CTL_NONE,            CMD_FREAD },
 	{ CMD_CONF_IMPORT,     cmd_conf_import,   CTL_NONE,            CMD_FWRITE },
@@ -706,34 +902,43 @@ const cmd_desc_t cmd_table[] = {
 };
 
 static const cmd_help_t cmd_help_table[] = {
-	{ CMD_EXIT,            "",                     "Exit interactive mode." },
-	{ "",                  "",                     "" },
-	{ CMD_STATUS,          "",                     "Check if the server is running." },
-	{ CMD_STOP,            "",                     "Stop the server if running." },
-	{ CMD_RELOAD,          "",                     "Reload the server configuration and modified zones." },
-	{ "",                  "",                     "" },
-	{ CMD_ZONE_CHECK,      "[<zone>...]",          "Check if the zone can be loaded. (*)" },
-	{ CMD_ZONE_MEMSTATS,   "[<zone>...]",          "Estimate memory use for the zone. (*)" },
-	{ CMD_ZONE_STATUS,     "[<zone>...]",          "Show the zone status." },
-	{ CMD_ZONE_RELOAD,     "[<zone>...]",          "Reload a zone from a disk." },
-	{ CMD_ZONE_REFRESH,    "[<zone>...]",          "Force slave zone refresh." },
-	{ CMD_ZONE_RETRANSFER, "[<zone>...]",          "Force slave zone retransfer (no serial check)." },
-	{ CMD_ZONE_FLUSH,      "[<zone>...]",          "Flush zone journal into the zone file." },
-	{ CMD_ZONE_SIGN,       "[<zone>...]",          "Re-sign the automatically signed zone." },
-	{ "",                  "",                     "" },
-	{ CMD_CONF_INIT,       "",                     "Initialize the confdb. (*)" },
-	{ CMD_CONF_CHECK,      "",                     "Check the server configuration. (*)" },
-	{ CMD_CONF_IMPORT,     "<filename>",           "Import a config file into the confdb. (*)" },
-	{ CMD_CONF_EXPORT,     "<filename>",           "Export the confdb into a config file. (*)" },
-	{ CMD_CONF_LIST,       "[<item>...]",          "List the confdb sections or section items." },
-	{ CMD_CONF_READ,       "[<item>...]",          "Read the item from the active confdb." },
-	{ CMD_CONF_BEGIN,      "",                     "Begin a writing confdb transaction." },
-	{ CMD_CONF_COMMIT,     "",                     "Commit the confdb transaction." },
-	{ CMD_CONF_ABORT,      "",                     "Rollback the confdb transaction." },
-	{ CMD_CONF_DIFF,       "[<item>...]",          "Get the item difference in the transaction." },
-	{ CMD_CONF_GET,        "[<item>...]",          "Get the item data from the transaction." },
-	{ CMD_CONF_SET,        " <item>  [<data>...]", "Set the item data in the transaction." },
-	{ CMD_CONF_UNSET,      "[<item>] [<data>...]", "Unset the item data in the transaction." },
+	{ CMD_EXIT,            "",                                       "Exit interactive mode." },
+	{ "",                  "",                                       "" },
+	{ CMD_STATUS,          "",                                       "Check if the server is running." },
+	{ CMD_STOP,            "",                                       "Stop the server if running." },
+	{ CMD_RELOAD,          "",                                       "Reload the server configuration and modified zones." },
+	{ "",                  "",                                       "" },
+	{ CMD_ZONE_CHECK,      "[<zone>...]",                            "Check if the zone can be loaded. (*)" },
+	{ CMD_ZONE_MEMSTATS,   "[<zone>...]",                            "Estimate memory use for the zone. (*)" },
+	{ CMD_ZONE_STATUS,     "[<zone>...]",                            "Show the zone status." },
+	{ CMD_ZONE_RELOAD,     "[<zone>...]",                            "Reload a zone from a disk." },
+	{ CMD_ZONE_REFRESH,    "[<zone>...]",                            "Force slave zone refresh." },
+	{ CMD_ZONE_RETRANSFER, "[<zone>...]",                            "Force slave zone retransfer (no serial check)." },
+	{ CMD_ZONE_FLUSH,      "[<zone>...]",                            "Flush zone journal into the zone file." },
+	{ CMD_ZONE_SIGN,       "[<zone>...]",                            "Re-sign the automatically signed zone." },
+	{ "",                  "",                                       "" },
+	{ CMD_ZONE_READ,       "<zone> [<owner> [<type>]]",              "Get zone data that are currently being presented." },
+	{ CMD_ZONE_BEGIN,      "<zone>...",                              "Begin a zone transaction." },
+	{ CMD_ZONE_COMMIT,     "<zone>...",                              "Commit the zone transaction." },
+	{ CMD_ZONE_ABORT,      "<zone>...",                              "Abort the zone transaction." },
+	{ CMD_ZONE_DIFF,       "<zone>",                                 "Get zone changes within the transaction." },
+	{ CMD_ZONE_GET,        "<zone> [<owner> [<type>]]",              "Get zone data within the transaction." },
+	{ CMD_ZONE_SET,        "<zone>  <owner> [<ttl>] <type> <rdata>", "Add zone record within the transaction." },
+	{ CMD_ZONE_UNSET,      "<zone>  <owner> [<type> [<rdata>]]",     "Remove zone data within the transaction." },
+	{ "",                  "",                                       "" },
+	{ CMD_CONF_INIT,       "",                                       "Initialize the confdb. (*)" },
+	{ CMD_CONF_CHECK,      "",                                       "Check the server configuration. (*)" },
+	{ CMD_CONF_IMPORT,     "<filename>",                             "Import a config file into the confdb. (*)" },
+	{ CMD_CONF_EXPORT,     "<filename>",                             "Export the confdb into a config file. (*)" },
+	{ CMD_CONF_LIST,       "[<item>...]",                            "List the confdb sections or section items." },
+	{ CMD_CONF_READ,       "[<item>...]",                            "Get the item from the active confdb." },
+	{ CMD_CONF_BEGIN,      "",                                       "Begin a writing confdb transaction." },
+	{ CMD_CONF_COMMIT,     "",                                       "Commit the confdb transaction." },
+	{ CMD_CONF_ABORT,      "",                                       "Rollback the confdb transaction." },
+	{ CMD_CONF_DIFF,       "[<item>...]",                            "Get the item difference within the transaction." },
+	{ CMD_CONF_GET,        "[<item>...]",                            "Get the item data within the transaction." },
+	{ CMD_CONF_SET,        " <item>  [<data>...]",                   "Set the item data within the transaction." },
+	{ CMD_CONF_UNSET,      "[<item>] [<data>...]",                   "Unset the item data within the transaction." },
 	{ NULL }
 };
 
@@ -742,12 +947,13 @@ void print_commands(void)
 	printf("\nActions:\n");
 
 	for (const cmd_help_t *cmd = cmd_help_table; cmd->name != NULL; cmd++) {
-		printf(" %-15s %-20s %s\n", cmd->name, cmd->params, cmd->desc);
+		printf(" %-15s %-38s %s\n", cmd->name, cmd->params, cmd->desc);
 	}
 
 	printf("\n"
 	       "Note:\n"
-	       " Empty <zone> parameter means all zones.\n"
+	       " Use @ owner to denote the zone name.\n"
+	       " Empty or '--' <zone> parameter means all zones or all zones with a transaction.\n"
 	       " Type <item> parameter in the form of <section>[<identifier>].<name>.\n"
 	       " (*) indicates a local operation which requires a configuration.\n");
 }
diff --git a/src/utils/knotc/commands.h b/src/utils/knotc/commands.h
index 15e7f6c19a..c25dd3e448 100644
--- a/src/utils/knotc/commands.h
+++ b/src/utils/knotc/commands.h
@@ -36,7 +36,8 @@ typedef enum {
 	CMD_FREQ_ITEM  = 1 << 3, /*!< Required item argument. */
 	CMD_FOPT_DATA  = 1 << 4, /*!< Optional item data argument. */
 	CMD_FOPT_ZONE  = 1 << 5, /*!< Optional zone name argument. */
-	CMD_FREQ_TXN   = 1 << 6, /*!< Required open confdb transaction. */
+	CMD_FREQ_ZONE  = 1 << 6, /*!< Required zone name argument. */
+	CMD_FREQ_TXN   = 1 << 7, /*!< Required open confdb transaction. */
 } cmd_flag_t;
 
 struct cmd_desc;
diff --git a/src/utils/knotc/interactive.c b/src/utils/knotc/interactive.c
index 451c7f4f05..2f38c375e3 100644
--- a/src/utils/knotc/interactive.c
+++ b/src/utils/knotc/interactive.c
@@ -325,7 +325,11 @@ static unsigned char complete(EditLine *el, int ch)
 	}
 
 	// Complete the zone name.
-	if (desc->flags & CMD_FOPT_ZONE) {
+	if (desc->flags & (CMD_FREQ_ZONE | CMD_FOPT_ZONE)) {
+		if (token > 1 && !(desc->flags & CMD_FOPT_ZONE)) {
+			goto complete_exit;
+		}
+
 		if (desc->flags & CMD_FREAD) {
 			local_zones_lookup(el, argv[token], pos);
 		} else {
-- 
GitLab