Felix Fietkau 5f54aa602e unetd: cli: bypass override directory
Useful for better integration with uconfig, which overrides uci with generated files

Signed-off-by: Felix Fietkau <nbd@nbd.name>
2025-03-17 13:16:08 +01:00

1237 lines
25 KiB
Ucode

// 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(model, name, iface)
{
let cur = uci.cursor(null, null, "");
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(ctx.model, 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(null, null, "");
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(null, null, "");
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(model, 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";
}
function get_config_object(ctx, spec, obj, argv)
{
obj.config ??= {};
return obj.config;
}
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,
get_object: get_config_object,
args: {
type: "int",
min: 0,
max: (1 << 24) - 1,
}
},
"vxlan-port": {
help: "VXLAN port",
attribute: "port",
available: is_vxlan_service,
get_object: get_config_object,
args: {
type: "int",
min: 1,
max: 65535,
}
},
"vxlan-mtu": {
help: "VXLAN tunnel MTU",
attribute: "mtu",
available: is_vxlan_service,
get_object: get_config_object,
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,
get_object: get_config_object,
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 });