unetd: add cli module
authorFelix Fietkau <nbd@nbd.name>
Wed, 12 Feb 2025 19:01:09 +0000 (20:01 +0100)
committerFelix Fietkau <nbd@nbd.name>
Thu, 13 Feb 2025 18:00:30 +0000 (19:00 +0100)
This vastly simplifies creating and managing unet networks.
It also adds support for the unetd protocol for onboarding new nodes
over the network.

Signed-off-by: Felix Fietkau <nbd@nbd.name>
package/network/services/unetd/Makefile
package/network/services/unetd/files/unet.uc [new file with mode: 0644]

index 6923c688738a25f45836e45fcf8c28d9a6a29f62..94ae13dc48df2d1b599244e65468efabe77f2968 100644 (file)
@@ -80,6 +80,7 @@ endef
 
 define Package/unetd/install
        $(INSTALL_DIR) \
+               $(1)/usr/share/ucode/cli/modules \
                $(1)/etc/unetd \
                $(1)/lib/bpf \
                $(1)/etc/init.d \
@@ -92,6 +93,7 @@ define Package/unetd/install
                $(PKG_INSTALL_DIR)/usr/sbin/unet-tool \
                $(1)/usr/sbin/
        $(if $(CONFIG_UNETD_VXLAN_SUPPORT),$(INSTALL_DATA) $(PKG_BUILD_DIR)/mss-bpf.o $(1)/lib/bpf/mss.o)
+       $(INSTALL_DATA) ./files/unet.uc $(1)/usr/share/ucode/cli/modules
        $(INSTALL_BIN) ./files/unetd.init $(1)/etc/init.d/unetd
        $(INSTALL_BIN) ./files/unetd.sh $(1)/lib/netifd/proto
 endef
diff --git a/package/network/services/unetd/files/unet.uc b/package/network/services/unetd/files/unet.uc
new file mode 100644 (file)
index 0000000..b884e23
--- /dev/null
@@ -0,0 +1,1226 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
+'use strict';
+
+import { readfile, writefile, mkstemp, mkdir, unlink } from "fs";
+import { time_format } from "cli.utils";
+import * as editor from "cli.object-editor";
+import * as rtnl from "rtnl";
+import * as uci from "uci";
+
+const supported_service_types = [
+       "vxlan", "uconfig", "unetacl",
+];
+
+function get_networks()
+{
+       let ret = [];
+
+       uci.cursor().foreach("network", "interface", (s) => {
+               if (s.proto != "unet")
+                       return;
+               push(ret, s[".name"]);
+       });
+
+       return ret;
+}
+
+function get_network_status()
+{
+       let data = model.ubus.call("unetd", "network_get");
+       if (!data)
+               return {};
+
+       return data.networks;
+}
+
+function network_get_string_file(str)
+{
+       let f = mkstemp();
+       f.write(str);
+       f.flush();
+       return f;
+}
+
+function network_get_file_string(f)
+{
+       f.seek();
+       let str = trim(f.read("all"));
+       f.close();
+       return str;
+}
+
+function __network_get_pubkey(pw_file, salt, rounds)
+{
+       pw_file.seek();
+
+       let pubkey_file = mkstemp();
+       if (system(`unet-tool -P -s ${rounds},${salt} <&${pw_file.fileno()} >&${pubkey_file.fileno()}`))
+               return ctx.command_failed("Failed to generate public key");
+
+       pubkey_file.seek();
+       let pubkey = trim(pubkey_file.read("all"));
+       pubkey_file.close();
+
+       return pubkey;
+}
+
+function network_get_pubkey(pw_file, network)
+{
+       return __network_get_pubkey(pw_file, network.config.salt, network.config.rounds);
+}
+
+function __network_fetch_password(ctx, named, confirm)
+{
+       if (named.password)
+               return true;
+
+       if (!model.cb.getpass) {
+               if (ctx.invalid_argument)
+                       ctx.invalid_argument("Could not get network config password");
+               return;
+       }
+
+       let pw = model.cb.getpass("Network config password: ");
+       if (length(pw) < 12) {
+               if (ctx.invalid_argument)
+                       ctx.invalid_argument("Password must be at least 12 characters long");
+               return;
+       }
+
+       if (confirm) {
+               let pw2 = model.cb.getpass("Confirm config password: ");
+               if (pw != pw2) {
+                       if (ctx.invalid_argument)
+                               ctx.invalid_argument("Password mismatch");
+                       return;
+               }
+       }
+
+       named.password = pw;
+
+       return true;
+}
+
+function network_fetch_password(ctx, named, confirm)
+{
+       if (ctx.data.netdata)
+               named.password ??= ctx.data.netdata.password;
+
+       if (!__network_fetch_password(ctx, named, confirm))
+               return;
+
+       let pw_file = network_get_string_file(named.password);
+
+       return pw_file;
+}
+
+function network_sign_data(ctx, name, network, pw_file, upload)
+{
+       let rounds = network.config.rounds;
+       let salt = network.config.salt;
+
+       mkdir("/etc/unetd", 0700);
+       let json_file = "/etc/unetd/" + name + ".json";
+       let bin_file = "/etc/unetd/" + name + ".bin";
+       if (upload)
+               bin_file += "." + time();
+       writefile(json_file, sprintf("%.J\n", network));
+
+       pw_file.seek();
+       let ret = system(`unet-tool -S -s ${rounds},${salt} -o "${bin_file}" "${json_file}" <&${pw_file.fileno()}`);
+       unlink(json_file);
+       if (ret) {
+               if (ctx.command_failed)
+                       ctx.command_failed("Failed to sign network configuration");
+               return false;
+       }
+
+       if (!upload)
+               return true;
+
+       ret = system(`unet-tool -U 127.0.0.1 "${bin_file}"`);
+       unlink(bin_file);
+       if (ret) {
+               if (ctx.command_failed)
+                       ctx.command_failed("Failed to upload network configuration");
+               return false;
+       }
+
+       pw_file.close();
+       return true;
+}
+
+function network_create_uci(name, iface)
+{
+       let cur = uci.cursor();
+       cur.set("network", name, "interface");
+       for (let key, val in iface)
+               cur.set("network", name, key, val);
+       cur.commit();
+
+       system("reload_config");
+}
+
+const config_editor = {
+       change_cb: function(ctx, argv) {
+               ctx.data.netdata.changed = true;
+       },
+       add: {
+               help: "Add configuration parameter value",
+       },
+       set: {
+               help: "Set configuration parameters",
+       },
+       remove: {
+               help: "Remove configuration parameter value",
+       },
+       named_args: {
+               port: {
+                       help: "wireguard port",
+                       default: 51830,
+                       required: true,
+                       args: {
+                               type: "int",
+                               min: 1,
+                               max: 65535,
+                       }
+               },
+               "unet-port": {
+                       help: "unet protocol port",
+                       default: 51831,
+                       required: true,
+                       attribute: "peer-exchange-port",
+                       args: {
+                               type: "int",
+                               min: 1,
+                               max: 65535,
+                       }
+               },
+               keepalive: {
+                       help: "keepalive interval (seconds)",
+                       default: 10,
+                       args: {
+                               type: "int",
+                               min: 0,
+                       }
+               },
+               "stun-server": {
+                       help: "STUN server",
+                       multiple: true,
+                       args: {
+                               type: "host",
+                       }
+               }
+       }
+};
+
+const UnetConfigEdit = editor.new(config_editor);
+
+const iface_editor = {
+       change_cb: function(ctx, argv) {
+               ctx.data.netdata.iface_changed = true;
+       },
+       add: {
+               help: "Add interface parameter value",
+       },
+       set: {
+               help: "Set interface parameters",
+       },
+       remove: {
+               help: "Remove interface parameter value",
+       },
+       named_args: {
+               metric: {
+                       help: "Interface metric",
+                       allow_empty: true,
+                       default: 100,
+                       args: {
+                               type: "int",
+                       }
+               },
+               zone: {
+                       help: "Firewall zone",
+                       allow_empty: true,
+                       default: "lan",
+                       args: {
+                               type: "string",
+                       }
+               },
+               domain: {
+                       help: "Local DNS domain for unet hosts",
+                       default: "unet",
+                       allow_empty: true,
+                       args: {
+                               type: "host"
+                       }
+               },
+               "local-network": {
+                       help: "Local network interface for discovering peers",
+                       default: [ "lan" ],
+                       attribute: "local_network",
+                       allow_empty: true,
+                       multiple: true,
+                       args: {
+                               type: "string",
+                       }
+               },
+               connect: {
+                       help: "Connect to remote IP or broadcast address",
+                       allow_empty: true,
+                       multiple: true,
+                       args: {
+                               type: "string",
+                       },
+               },
+       },
+};
+
+const network_local_args = {
+       ...iface_editor.named_args,
+       network: {
+               help: "network name",
+               default: "unet",
+               required: true,
+               args: {
+                       type: "string",
+               }
+       },
+};
+
+const UnetIfaceEdit = editor.new(iface_editor);
+
+function network_create(ctx, argv, named) {
+       ctx.apply_defaults();
+
+       if (!named.network || index(named.network, "/") >= 0)
+               return ctx.error("Invalid network name: %s", named.network);
+
+       let pw_file = network_fetch_password(ctx, named, true);
+       if (!pw_file)
+               return;
+
+       let salt = readfile("/dev/urandom", 16);
+       if (length(salt) != 16)
+               return ctx.unknown_error();
+
+       salt = map(split(salt, ""), (v) => ord(v));
+       salt = join("", map(salt, (v) => sprintf("%02x", v)));
+       let rounds = 10000;
+
+       let network = {
+               config: {
+                       salt, rounds,
+               },
+               hosts: {},
+       };
+       for (let name, spec in config_editor.named_args) {
+               let val = named[name];
+               if (val == null)
+                       continue;
+               name = spec.attribute ?? name;
+               network.config[name] = val;
+       }
+
+       let pubkey = network_get_pubkey(pw_file, network);
+
+       let hostkey_file = mkstemp();
+       if (system(`unet-tool -G >&${hostkey_file.fileno()}`))
+               return ctx.command_failed("Failed to generate host key");
+
+       hostkey_file.seek();
+       let host_pubkey_file = mkstemp();
+       if (system(`unet-tool -H -K - <&${hostkey_file.fileno()} >&${host_pubkey_file.fileno()}`))
+               return ctx.command_failed("Failed to generate host public key");
+
+       let host_key = network_get_file_string(hostkey_file);
+       let host_pubkey = network_get_file_string(host_pubkey_file);
+       network.config.id = pubkey;
+
+       network.hosts[named.host] = {
+               key: host_pubkey,
+       };
+
+       if (!network_sign_data(ctx, named.network, network, pw_file))
+               return;
+
+       network_create_uci(named.network, {
+               proto: "unet",
+               metric: named.metric,
+               zone: named.zone,
+               domain: named.domain,
+               key: host_key,
+               auth_key: pubkey,
+               local_network: named["local-network"],
+               connect: named["connect"],
+       });
+
+       return ctx.ok("Created network "+ named.network);
+}
+
+function network_delete(ctx, argv) {
+       let name = argv[0];
+       let cur = uci.cursor();
+       if (!cur.delete("network", name))
+               return ctx.command_failed("Command failed");
+
+       cur.commit();
+       system("reload_config");
+       return ctx.ok("Network deleted");
+}
+
+function network_iface_save(ctx)
+{
+       let netdata = ctx.data.netdata;
+       let network = ctx.data.network;
+       let changed;
+
+       if (!netdata.iface_changed)
+               return;
+
+       let cur = uci.cursor();
+       let iface_orig = cur.get_all("network", network);
+       for (let name, val in netdata.iface) {
+               if (iface_orig[name] == val)
+                       continue;
+
+               if (val == null)
+                       cur.delete("network", network, name);
+               else
+                       cur.set("network", network, name, val);
+               changed = true;
+       }
+
+       if (changed)
+               cur.commit();
+
+       netdata.iface_changed = false;
+
+       return changed;
+}
+
+function network_apply(ctx, argv, named)
+{
+       let name = ctx.data.network;
+       let netdata = ctx.data.netdata;
+       let data = netdata.json;
+
+       if (!netdata.changed)
+               return;
+
+       let pw_file = network_fetch_password(ctx, named);
+       if (!pw_file)
+               return;
+
+       let id = network_get_pubkey(pw_file, data);
+       if (id != data.config.id) {
+               pw_file.close();
+               return ctx.invalid_argument("Invalid password");
+       }
+
+       if (!network_sign_data(ctx, name, data, pw_file, true))
+               return;
+
+       netdata.changed = false;
+       return true;
+}
+
+function __network_enroll_cancel(model, ctx)
+{
+       let req = ctx.data.enroll;
+       if (!req)
+               return false;
+
+       req.sub.remove();
+       model.ubus.call("unetd", "enroll_stop");
+       delete ctx.data.enroll;
+       return true;
+}
+
+function network_enroll_accept(ctx, argv, named)
+{
+       let req = ctx.data.enroll;
+       let id = argv[0];
+       if (!req || !id)
+               return ctx.invalid_argument();
+
+       let peer = req.peers[id];
+       if (!peer)
+               return ctx.invalid_argument("Session not found: %s", id);
+
+       model.ubus.call("unetd", "enroll_accept", {
+               session: id
+       });
+
+       return ctx.ok("Network peer accepted");
+}
+
+function network_handle_enroll_update(model, ctx, msg)
+{
+       let invite = ctx.data.enroll;
+       if (!invite)
+               return;
+
+       let data = msg.data;
+       let peer = invite.peers[data.session];
+       let ret;
+
+       if (!peer)
+               model.status_msg("New device detected at " + data.address + ", session id " + data.session);
+
+       peer ??= {};
+       if (data.accepted && !peer.accepted)
+               model.status_msg("Accepted peer at " + data.address + ", session id " + data.session);
+       if (!data.accepted)
+               data.confirmed = false;
+
+       if (data.confirmed && !peer.confirmed) {
+               model.status_msg("Confirmed peer at " + data.address + ", session id " + data.session);
+               ret = data;
+       }
+
+       invite.peers[data.session] = data;
+
+       return ret;
+}
+
+function network_invite_peer_update(model, ctx, msg)
+{
+       let name = ctx.data.network;
+       let netdata = ctx.data.netdata;
+       let invite = ctx.data.enroll;
+       if (!invite)
+               return;
+
+       let data = network_handle_enroll_update(model, ctx, msg);
+       if (!data)
+               return;
+
+       netdata.json.hosts[invite.name] ??= {};
+       netdata.json.hosts[invite.name].key = data.enroll_key;
+       netdata.changed = true;
+
+       let pw_file = network_get_string_file(netdata.password);
+       if (network_sign_data(ctx, name, netdata.json, pw_file, true)) {
+               netdata.changed = false;
+               model.status_msg("Updated configuration");
+       }
+
+       __network_enroll_cancel(model, ctx);
+}
+
+function network_invite(ctx, argv, named)
+{
+       let network = ctx.data.network;
+       let netdata = ctx.data.netdata;
+       let data = netdata.json;
+
+       let pw_file = network_fetch_password(ctx, named);
+       if (!pw_file)
+               return;
+
+       let id = network_get_pubkey(pw_file, data);
+       pw_file.close();
+       if (id != data.config.id)
+               return ctx.invalid_argument("Invalid password");
+
+       netdata.password = named.password;
+       let invite = {
+               name: argv[0],
+               peers: {},
+       };
+
+       invite.sub = model.ubus.subscriber((msg) => {
+               if (msg.type == "enroll_peer_update")
+                       network_invite_peer_update(ctx.model, ctx, msg);
+               else if (msg.type == "enroll_timeout")
+                       __network_enroll_cancel(ctx.model, ctx);
+       });
+
+       let req = {
+               network,
+               timeout: named.timeout,
+       };
+
+       if (named["access-key"]) {
+               req.enroll_secret = named["access-key"];
+               req.enroll_auto = true;
+       }
+
+       if (named.connect)
+               req.connect = named.connect;
+
+       invite.sub.subscribe("unetd");
+       model.ubus.call("unetd", "enroll_start", req);
+       ctx.data.enroll = invite;
+
+       return ctx.ok("Invite started");
+}
+
+function network_join_peer_update(model, ctx, msg)
+{
+       let joinreq = ctx.data.enroll;
+       let name = joinreq.name;
+
+       let data = network_handle_enroll_update(model, ctx, msg);
+       if (!data)
+               return;
+
+       let iface = {
+               proto: "unet",
+               metric: joinreq.metric,
+               zone: joinreq.zone,
+               domain: joinreq.domain,
+               connect: joinreq.connect,
+               local_network: joinreq.local_network,
+               key: data.local_key,
+               auth_key: data.enroll_key,
+       };
+
+       if (joinreq.connect)
+               iface.connect = joinreq.connect;
+
+       network_create_uci(name, iface);
+
+       model.status_msg("Configuration added for interface " + name);
+
+       __network_enroll_cancel(model, ctx);
+}
+
+function resolve_network_broadcast_addr(list, net)
+{
+       let data = model.ubus.call("network.interface." + net, "status");
+       if (!data)
+               return;
+
+       let dev = data.l3_device;
+       if (!dev)
+               return;
+
+       let req = rtnl.request(rtnl.const.RTM_GETADDR, rtnl.const.NLM_F_DUMP);
+       for (let addr in req)
+               if (addr.family == 2 && addr.dev == dev && addr.broadcast)
+                       push(list, addr.broadcast);
+}
+
+function network_join(ctx, argv, named)
+{
+       __network_enroll_cancel(model, ctx);
+       ctx.apply_defaults();
+
+       let data = {
+               name: named.network,
+               metric: named.metric,
+               zone: named.zone,
+               domain: named.domain,
+               connect: named.connect,
+               local_network: named["local-network"],
+               peers: {},
+       };
+
+       let req = {
+               timeout: named.timeout,
+       };
+
+       if (named["access-key"]) {
+               req.enroll_secret = named["access-key"];
+               req.enroll_auto = true;
+       }
+
+       if (data.connect)
+               req.connect = [ ...data.connect ];
+       if (length(data.local_network) > 0) {
+               req.connect ??= [];
+               for (let net in data.local_network)
+                       resolve_network_broadcast_addr(req.connect, net);
+       }
+
+       data.sub = model.ubus.subscriber((msg) => {
+               if (msg.type == "enroll_peer_update")
+                       network_join_peer_update(ctx.model, ctx, msg);
+               else if (msg.type == "enroll_timeout")
+                       __network_enroll_cancel(ctx.model, ctx);
+       });
+       data.sub.subscribe("unetd");
+       model.ubus.call("unetd", "enroll_start", req);
+
+       ctx.data.enroll = data;
+
+       return ctx.ok("Join request started");
+}
+
+function network_edit_exit_hook()
+{
+       let ctx = this;
+       let netdata = ctx.data.netdata;
+
+       network_iface_save(ctx);
+       __network_enroll_cancel(model, ctx);
+       if (!netdata.changed)
+               return true;
+
+       if (!model.cb.poll_key)
+               return true;
+
+       let key = model.poll_key(['c', 'r', 'a'], `You have uncommitted changes. [a]pply, [r]evert or [c]ancel? `);
+       if (!key)
+               return true;
+
+       switch (key) {
+       case 'c':
+               warn("cancel\n");
+               return false;
+       case 'r':
+               warn("revert\n");
+               return true;
+       case 'a':
+               warn("apply\n");
+               break;
+       }
+
+       let name = ctx.data.network;
+       let data = netdata.json;
+
+       let pw_file = network_fetch_password(ctx, {});
+       if (!pw_file)
+               return;
+
+       let id = network_get_pubkey(pw_file, data);
+       if (id != data.config.id) {
+               warn("Invalid password\n");
+               return false;
+       }
+
+       if (!network_sign_data(ctx, name, data, pw_file, true)) {
+               warn("Failed to apply network configuration\n");
+               return false;
+       }
+
+       return true;
+}
+
+function network_edit(ctx, argv) {
+       let network = argv[0];
+       if (!network) {
+               network = "unet";
+               if (!get_network_status()[network])
+                       return ctx.invalid_argument('no valid network name provided');
+       }
+
+       let iface_data = uci.cursor().get_all("network", network);
+       for (let name in keys(iface_data))
+               if (substr(name, 0, 1) == ".")
+                       delete iface_data[name];
+
+       let json_file = mkstemp();
+       if (system(`unet-tool -T -b /etc/unetd/${network}.bin >&${json_file.fileno()}`))
+               return;
+
+       let json_data;
+       try {
+               json_data = network_get_file_string(json_file);
+               json_data = json(json_data);
+       } catch (e) {
+               json_data = null;
+       }
+
+       if (!json_data)
+               return;
+
+       let netdata = {
+               json: json_data,
+               iface: iface_data,
+               changed: false,
+       };
+
+       json_data.hosts ??= {};
+       json_data.services ??= {};
+
+       ctx.add_hook("exit", network_edit_exit_hook);
+
+       return ctx.set('edit "' + network + '"', {
+               network, netdata,
+               object_edit: json_data,
+       });
+}
+
+const network_args = [
+       {
+               name: "network",
+               help: "Network name",
+               type: "enum",
+               value: () => get_networks(),
+               required: true,
+       }
+];
+
+const network_status_args = [
+       {
+               name: "network",
+               help: "Network name",
+               type: "enum",
+               value: () => keys(get_network_status())
+       }
+];
+
+const network_sign_args = {
+       password: {
+               help: "Network configuration password",
+               no_complete: true,
+               args: {
+                       type: "string",
+                       min: 12,
+               }
+       },
+};
+
+const network_config_args = editor.object_create_params(UnetConfigEdit);
+
+const network_create_args = {
+       ...network_sign_args,
+       ...network_config_args,
+       ...network_local_args,
+       host: {
+               help: "local host name",
+               default: "main",
+               required: true,
+               args: {
+                       type: "string",
+               }
+       },
+};
+
+const network_invite_name_arg = [
+       {
+               name: "name",
+               help: "Name of the invited device",
+               type: "string",
+       }
+];
+
+const network_enroll_args = {
+       "access-key": {
+               help: "Access key for allowing the device into the network",
+               args: {
+                       type: "string",
+               }
+       },
+       timeout: {
+               help: "Timeout for invite",
+               required: true,
+               default: 120,
+               args: {
+                       type: "int",
+               }
+       },
+};
+
+const enroll_accept_arg = [{
+       name: "session_id",
+       help: "Session id of the network peer",
+       type: "string",
+       required: true,
+       type: "enum",
+       value: (ctx) => keys(ctx.data.enroll.peers),
+}];
+
+const network_join_args = {
+       ...network_enroll_args,
+       ...network_local_args,
+};
+
+const network_invite_args = {
+       ...network_enroll_args,
+       ...network_sign_args,
+};
+
+const host_editor = {
+       change_cb: function(ctx, argv) {
+               ctx.data.netdata.changed = true;
+       },
+       named_args: {
+               name: {
+                       help: "Host name",
+                       get: (ctx) => ctx.data.name,
+                       set: (ctx, val) => {
+                               let name = ctx.data.name;
+                               let hosts = ctx.data.netdata.json.hosts;
+                               hosts[val] = hosts[name];
+                               delete hosts[name];
+                               ctx.data.name = val;
+                       },
+                       change_only: true,
+                       args: {
+                               type: "string",
+                       }
+               },
+               key: {
+                       help: "Wireguard key",
+                       required: true,
+                       args: {
+                               type: "string",
+                       }
+               },
+               port: {
+                       help: "Wireguard port",
+                       args: {
+                               type: "int",
+                               min: 1,
+                               max: 65535,
+                       }
+               },
+               "unet-port": {
+                       help: "unet protocol port (0: wireguard only)",
+                       args: {
+                               type: "int",
+                               min: 0,
+                               max: 65535,
+                       }
+               },
+               endpoint: {
+                       help: "Wireguard endpoint IP address",
+                       args: {
+                               type: "string",
+                       }
+               },
+               ipaddr: {
+                       help: "IP address",
+                       multiple: true,
+                       args: {
+                               type: "ipv4",
+                       }
+               },
+               subnet: {
+                       help: "IP subnet",
+                       multiple: true,
+                       args: {
+                               type: "cidr4",
+                       }
+               },
+               gateway: {
+                       help: "Other host to be used as gateway",
+                       args: {
+                               type: "enum",
+                               value: function(ctx, argv) {
+                                       return filter(keys(ctx.data.netdata.json.hosts),
+                                                     (v) => v != ctx.data.name);
+                               }
+                       }
+               },
+               group: {
+                       help: "Host group membership",
+                       attribute: "groups",
+                       multiple: true,
+                       args: {
+                               type: "enum",
+                               no_validate: true,
+                               value: function(ctx) {
+                                       let groups = {};
+                                       for (let name, host in ctx.data.netdata.json.hosts)
+                                               for (let group in host.groups)
+                                                       groups[group] = true;
+                                       return keys(groups);
+                               }
+                       }
+               }
+       },
+};
+
+const UnetHostEdit = editor.new(host_editor);
+
+function is_vxlan_service(ctx, argv, named, spec)
+{
+       let type = named.type;
+       if (ctx.data.edit)
+               type ??= ctx.data.edit.type;
+
+       return type == "vxlan";
+}
+
+const service_editor = {
+       change_cb: function(ctx, argv) {
+               ctx.data.netdata.changed = true;
+       },
+       named_args: {
+               type: {
+                       help: "Service type",
+                       required: true,
+                       args: {
+                               type: "enum",
+                               no_validate: true,
+                               value: supported_service_types,
+                       }
+               },
+               member: {
+                       help: "Service member",
+                       attribute: "members",
+                       multiple: true,
+                       args: {
+                               type: "enum",
+                               value: (ctx) => [ "@all", ...keys(ctx.data.netdata.json.hosts) ]
+                       }
+               },
+               "vxlan-id": {
+                       help: "VXLAN ID",
+                       attribute: "id",
+                       available: is_vxlan_service,
+                       args: {
+                               type: "int",
+                               min: 0,
+                               max: (1 << 24) - 1,
+                       }
+               },
+               "vxlan-port": {
+                       help: "VXLAN port",
+                       attribute: "port",
+                       available: is_vxlan_service,
+                       args: {
+                               type: "int",
+                               min: 1,
+                               max: 65535,
+                       }
+               },
+               "vxlan-mtu": {
+                       help: "VXLAN tunnel MTU",
+                       attribute: "mtu",
+                       available: is_vxlan_service,
+                       args: {
+                               type: "int",
+                               min: 1280,
+                               max: 9000,
+                       }
+               },
+               "vxlan-forwarding-port": {
+                       help: "Member allowed to receive broad-/multicast and unknown unicast",
+                       attribute: "forward_ports",
+                       available: is_vxlan_service,
+                       multiple: true,
+                       args: {
+                               type: "enum",
+                               value: (ctx) => keys(ctx.data.netdata.json.hosts)
+                       }
+               },
+       }
+};
+
+const UnetServiceEdit = editor.new(service_editor);
+
+const edit_create_destroy = {
+       change_cb: function(ctx, argv) {
+               ctx.data.netdata.changed = true;
+       },
+       types: {
+               host: {
+                       node_name: "UnetHostEdit",
+                       node: UnetHostEdit,
+                       object: "hosts",
+               },
+               service: {
+                       node_name: "UnetServiceEdit",
+                       node: UnetServiceEdit,
+                       object: "services",
+               },
+       },
+};
+
+let UnetEdit = {
+       config: {
+               help: "Edit network global configuration",
+               select_node: "UnetConfigEdit",
+               select: function(ctx) {
+                       return ctx.set("config", {
+                               edit: ctx.data.object_edit.config,
+                       });
+               }
+       },
+       iface: {
+               help: "Edit interface configuration",
+               select_node: "UnetIfaceEdit",
+               select: function(ctx) {
+                       return ctx.set("iface", {
+                               edit: ctx.data.netdata.iface,
+                       });
+               }
+       },
+       accept: {
+               help: "Accept invited network peer",
+               args: enroll_accept_arg,
+               available: (ctx) => ctx.data.enroll && length(ctx.data.enroll.peers) > 0,
+               call: network_enroll_accept,
+       },
+       invite: {
+               help: "Invite another device to the network",
+               args: network_invite_name_arg,
+               named_args: network_invite_args,
+               call: network_invite,
+       },
+       cancel: {
+               help: "Cancel device invitation",
+               available: (ctx) => ctx.data.enroll,
+               call: function(ctx) {
+                       __network_enroll_cancel(model, ctx);
+                       return ctx.ok("Invitation cancelled");
+               }
+       },
+       dump: {
+               help: "Show network json data",
+               call: function(ctx) {
+                       return ctx.json("Network data", ctx.data.netdata.json);
+               }
+       },
+       save: {
+               help: "Save network data to json file",
+               args: [
+                       {
+                               name: "file",
+                               help: "Destination path",
+                               type: "path",
+                               required: true,
+                               new_path: true,
+                       },
+               ],
+               call: function(ctx, argv) {
+                       if (!writefile(argv[0], sprintf("%.J\n", ctx.data.netdata.json)))
+                               return ctx.command_failed("Could not write to %s", argv[0]);
+
+                       return ctx.ok("Configuration saved to "+argv[0]);
+               }
+       },
+       restore: {
+               help: "Restore network data from json file",
+               args: [
+                       {
+                               name: "file",
+                               help: "Source path",
+                               type: "path",
+                               required: true,
+                       },
+               ],
+               call: function(ctx, argv) {
+                       let config, data;
+                       try {
+                               data = json(readfile(argv[0]));
+                               config = data.config;
+                       } catch (e) {
+                               return ctx.command_failed("Could not read JSON data from %s", argv[0]);
+                       }
+
+                       if (!config)
+                               return ctx.command_failed("Invalid network json file");
+
+                       let json = ctx.data.netdata.json;
+                       let prev_config = {};
+                       for (let field in [ "salt", "rounds", "id" ]) {
+                               prev_config[field] = json.config[field];
+                               delete config[field];
+                       }
+
+                       ctx.data.netdata.changed = true;
+                       data.config = { ...prev_config, ...config };
+                       ctx.data.netdata.json = data;
+
+                       return ctx.ok("Configuration restored from "+argv[0]);
+               }
+       },
+       apply: {
+               help: "Apply changes",
+               named_args: network_sign_args,
+               call: function(ctx, argv, named) {
+                       let netdata = ctx.data.netdata;
+
+                       let changed = network_iface_save(ctx);
+                       if (network_apply(ctx, argv, named))
+                               changed = true;
+
+                       if (!changed)
+                               return ctx.ok("No changes");
+
+                       return ctx.ok("Changes applied");
+               }
+       }
+};
+editor.edit_create_destroy(edit_create_destroy, UnetEdit);
+
+const Unet = {
+       status: {
+               help: "Show unet network information",
+               args: network_status_args,
+               call: function(ctx, argv) {
+                       let name = argv[0];
+                       let status = get_network_status();
+                       if (!status)
+                               return ctx.command_failed();
+
+                       if (!name)
+                               return ctx.list("Networks", keys(status));
+
+                       status = status[name];
+                       if (!status)
+                               return ctx.not_found();
+
+                       let data = {};
+                       for (let name, host in status.peers) {
+                               let cur = [];
+
+                               data[`Host '${name}'`] = cur;
+                               push(cur, [ "State", host.connected ? "connected" : "disconnected" ]);
+                               if (!host.connected)
+                                       continue;
+
+                               if (host.endpoint)
+                                       push(cur, [ "IP address", host.endpoint ]);
+
+                               push(cur, [ "Idle time", time_format(host.idle) ]);
+                               push(cur, [ "Sent bytes", host.tx_bytes ]);
+                               push(cur, [ "Received bytes", host.rx_bytes ]);
+                               push(cur, [ "Last handshake", time_format(host.last_handshake_sec) + " ago" ]);
+                       }
+                       return ctx.multi_table("Status of network " + name, data);
+               }
+       },
+       join: {
+               help: "Join existing network",
+               named_args: network_join_args,
+               call: network_join,
+       },
+       accept: {
+               help: "Accept network peer",
+               args: enroll_accept_arg,
+               available: (ctx) => ctx.data.enroll && length(ctx.data.enroll.peers) > 0,
+               call: network_enroll_accept,
+       },
+       cancel: {
+               help: "Cancel join request",
+               available: (ctx) => ctx.data.enroll,
+               call: function(ctx) {
+                       __network_enroll_cancel(model, ctx);
+                       return ctx.ok("Join request cancelled");
+               },
+       },
+       create: {
+               help: "Create network",
+               named_args: network_create_args,
+               call: network_create,
+       },
+       delete: {
+               help: "Delete network",
+               args: network_args,
+               call: network_delete,
+       },
+       edit: {
+               help: "Edit network",
+               args: network_status_args,
+               no_subcommands: true,
+               select_node: "UnetEdit",
+               select: network_edit,
+       },
+};
+
+const Root = {
+       unet: {
+               help: "unetd network management",
+               select_node: "Unet",
+       }
+};
+
+model.add_nodes({ Root, Unet, UnetEdit, UnetConfigEdit, UnetIfaceEdit, UnetHostEdit, UnetServiceEdit });