--- /dev/null
+// 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 });