cli: add OpenWrt CLI (work in progress)
authorFelix Fietkau <nbd@nbd.name>
Mon, 13 Jan 2025 21:46:05 +0000 (22:46 +0100)
committerFelix Fietkau <nbd@nbd.name>
Mon, 27 Jan 2025 20:30:45 +0000 (21:30 +0100)
Signed-off-by: Felix Fietkau <nbd@nbd.name>
package/utils/cli/Makefile [new file with mode: 0644]
package/utils/cli/files/usr/sbin/cli [new file with mode: 0755]
package/utils/cli/files/usr/share/ucode/cli/cache.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/color.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/context-call.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/context.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/datamodel.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/modules/network.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/modules/service.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/modules/unet.uc [new file with mode: 0644]
package/utils/cli/files/usr/share/ucode/cli/types.uc [new file with mode: 0644]

diff --git a/package/utils/cli/Makefile b/package/utils/cli/Makefile
new file mode 100644 (file)
index 0000000..db2c94f
--- /dev/null
@@ -0,0 +1,33 @@
+#
+# Copyright (C) 2025 OpenWrt.org
+#
+# This is free software, licensed under the GNU General Public License v2.
+# See /LICENSE for more information.
+#
+
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=cli
+PKG_VERSION:=1
+
+PKG_LICENSE:=GPL-2.0
+PKG_MAINTAINER:=Felix Fietkau <nbd@nbd.name>
+
+include $(INCLUDE_DIR)/package.mk
+
+define Package/cli
+  SECTION:=utils
+  CATEGORY:=Utilities
+  TITLE:=OpenWrt CLI
+  DEPENDS:=+ucode +ucode-mod-ubus +ucode-mod-uloop +ucode-mod-uline
+endef
+
+define Build/Compile
+       :
+endef
+
+define Package/cli/install
+       $(CP) ./files/* $(1)/
+endef
+
+$(eval $(call BuildPackage,cli))
diff --git a/package/utils/cli/files/usr/sbin/cli b/package/utils/cli/files/usr/sbin/cli
new file mode 100755 (executable)
index 0000000..b16a09a
--- /dev/null
@@ -0,0 +1,502 @@
+#!/usr/bin/env ucode
+'use strict';
+import * as datamodel from "cli.datamodel";
+import { bold, color_fg } from "cli.color";
+import * as uline from "uline";
+
+let history = [];
+let history_edit;
+let history_idx = -1;
+let cur_line;
+let interactive;
+
+let el;
+let model = datamodel.new({
+       getpass: uline.getpass,
+       poll_key: (timeout) => el.poll_key(timeout),
+       status_msg: (msg) => {
+               el.hide_prompt();
+               warn(msg + "\n");
+               el.refresh_prompt();
+       },
+});
+let uloop = model.uloop;
+model.add_modules();
+let ctx = model.context();
+let parser = uline.arg_parser({
+       line_separator: ";"
+});
+
+model.add_nodes({
+       Root: {
+               exit: {
+                       help: "Exit the CLI",
+                       call: function(ctx) {
+                               el.close();
+                               uloop.end();
+                               interactive = false;
+                       }
+               }
+       }
+});
+
+model.init();
+
+function update_prompt() {
+       el.set_state({
+               prompt: bold(join(" ", [ "cli", ...ctx.prompt ]) + "> "),
+       });
+}
+
+let cur_completion, tab_prefix, tab_suffix, tab_prefix_len, tab_quote, tab_ctx;
+
+function max_len(list, len)
+{
+       for (let entry in list)
+               if (length(entry) > len)
+                       len = length(entry);
+       return len + 3;
+}
+
+function helptext(cur) {
+       if (!cur) {
+               el.set_hint(`\n  No help information available\n`);
+               return true;
+       }
+
+       let str = `${cur.help}`;
+       let data = cur.value;
+       if (type(data) != "array") {
+               str += "\n";
+       } else if (length(data) > 0) {
+               str += ":\n";
+               let len = max_len(map(data, (v) => v.name), 10);
+               for (let val in data) {
+                       let name = val.name;
+                       let help = val.help ?? "";
+                       let extra = [];
+                       if (val.multiple)
+                               push(extra, "multiple");
+                       if (val.required)
+                               push(extra, "required");
+                       if (val.default)
+                               push(extra, "default: " + val.default);
+                       if (length(extra) > 0)
+                               help += " (" + join(", ", extra) + ")";
+                       if (length(help) > 0)
+                               name += ":";
+                       str += sprintf("  %-" + len + "s %s\n", name, help);
+               }
+       } else {
+               str += " (no match)\n";
+       }
+       el.set_hint(str);
+       return true;
+}
+
+function completion_ctx(arg_info)
+{
+       let cur_ctx = ctx;
+       for (let args in arg_info.args) {
+               let sel = cur_ctx.select(args, true);
+               if (!length(args))
+                       cur_ctx = sel;
+               if (!sel)
+                       return;
+       }
+
+       return cur_ctx;
+}
+
+function completion_check_prefix(data)
+{
+       let prefix = data[0].name;
+       let prefix_len = length(prefix);
+
+       for (let entry in data) {
+               entry = entry.name;
+               if (prefix_len > length(entry))
+                       prefix_len = length(entry);
+       }
+       prefix = substr(prefix, 0, prefix_len);
+
+       for (let entry in data) {
+               entry = substr(entry.name, 0, prefix_len);
+               while (entry != prefix) {
+                       prefix_len--;
+                       prefix = substr(prefix, 0, prefix_len);
+                       entry = substr(entry, 0, prefix_len);
+               }
+       }
+
+       tab_prefix += substr(prefix, tab_prefix_len);
+       tab_prefix_len = prefix_len;
+
+       let line = tab_prefix + tab_quote;
+       let pos = length(line);
+       line += tab_suffix;
+       el.set_state({ line, pos });
+}
+
+function completion(count) {
+       if (count < 2) {
+               let line_data = el.get_line();
+               let line = line_data.line;
+               let pos = line_data.pos;
+               tab_suffix = substr(line, pos);
+               if (length(tab_suffix) > 0 &&
+                       substr(tab_suffix, 0, 1) != " ") {
+                       let idx = index(tab_suffix, " ");
+                       if (idx < 0 || !idx)
+                               pos += length(tab_suffix);
+                       else
+                               pos += idx;
+
+                       tab_suffix = substr(line, pos);
+               }
+               tab_prefix = substr(line, 0, pos);
+
+               let arg_info = parser.parse(tab_prefix);
+               let is_open = arg_info.missing != null;
+               if (arg_info.missing == "\\\"")
+                       tab_quote = "\"";
+               else
+                       tab_quote = arg_info.missing ?? "";
+               let args = pop(arg_info.args);
+
+               if (!is_open && substr(tab_prefix, -1) == " ")
+                       push(args, "");
+               let last = args[length(args) - 1];
+               tab_prefix_len = length(last);
+               tab_ctx = completion_ctx(arg_info);
+               if (!tab_ctx)
+                       return;
+
+               cur_completion = tab_ctx.complete([...args]);
+       }
+
+       if (!tab_ctx)
+               return;
+
+       if (count < 0)
+               return helptext(cur_completion);
+
+       let cur = cur_completion;
+       if (!cur || !cur.value) {
+               el.set_hint("");
+               return;
+       }
+
+       let data = cur.value;
+       if (length(data) == 0) {
+               el.set_hint(`  (no match)`);
+               return;
+       }
+
+       if (length(data) == 1) {
+               let line = tab_prefix + substr(data[0].name, tab_prefix_len) + tab_quote + " ";
+               let pos = length(line);
+               line += tab_suffix;
+               el.set_state({ line, pos });
+               el.set_hint("");
+               el.reset_key_input();
+               return;
+       }
+
+       if (count == 1)
+               completion_check_prefix(data);
+
+       if (count > 1) {
+               let idx = (count - 2) % length(data);
+               let line = tab_prefix + substr(data[idx].name, tab_prefix_len) + tab_quote;
+               let pos = length(line);
+               line += tab_suffix;
+               el.set_state({ line, pos });
+       }
+
+       let win = el.get_window();
+       let str = "";
+       let x = 0;
+
+       let len = max_len(map(data, (v) => v.name));
+       for (let entry in data) {
+               let add = sprintf("    %-"+len+"s", entry.name);
+
+               str += add;
+               x += length(add);
+
+               if (x + length(add) < win.x)
+                       continue;
+
+               str += "\n";
+               x = 0;
+       }
+       el.set_hint(str);
+}
+
+function format_result(res)
+{
+       if (!res) {
+               warn(color_fg("red", "Unknown command") + "\n");
+               return;
+       }
+       if (!res.ok) {
+               for (let err in res.errors) {
+                       warn(color_fg("red", "Error: "+ err.msg) + "\n");
+               }
+               if (!res.errors)
+                       warn(color_fg("red", "Failed") + "\n");
+               return;
+       }
+
+       if (res.status_msg)
+               warn(color_fg("green", res.status_msg) + "\n");
+
+       if (res.name)
+               warn(res.name + ": ");
+
+       switch (res.type) {
+       case "table":
+               let len = max_len(res.data, 8);
+               warn("\n");
+               for (let name, val in res.data) {
+                       if (type(val) == "bool")
+                               val = val ? "yes" : "no";
+                       val = replace(val, /\n/g, "\n    ");
+                       warn(sprintf("    %-" + len + "s %s\n", name + ":", val));
+               }
+               break;
+       case "list":
+               warn("\n");
+               for (let entry in res.data)
+                       warn("  - " + entry + "\n");
+               break;
+       case "string":
+               warn(res.data + "\n");
+               break;
+       }
+}
+
+function line_history_reset()
+{
+       history_idx = -1;
+       history_edit = null;
+       cur_line = null;
+}
+
+function line_history(dir)
+{
+       let min_idx = cur_line == null ? 0 : -1;
+       let new_idx = history_idx + dir;
+
+       if (new_idx < min_idx || new_idx >= length(history))
+               return;
+
+       let line = el.get_line().line;
+       let cur_history = history_edit ?? history;
+       if (history_idx == -1)
+               cur_line = line;
+       else if (cur_history[history_idx] != line) {
+               history_edit ??= [ ...history ];
+               history_edit[history_idx] = line;
+               cur_history = history_edit;
+       }
+
+       history_idx = new_idx;
+       if (history_idx < 0)
+               line = cur_line;
+       else
+               line = cur_history[history_idx];
+       let pos = length(line);
+       el.set_state({ line, pos });
+
+}
+let rev_search, rev_search_results, rev_search_index;
+
+function reverse_search_update(line)
+{
+       if (line) {
+               rev_search = line;
+               rev_search_results = filter(history, (l) => index(l, line) >= 0);
+               rev_search_index = 0;
+       }
+
+       let prompt = "reverse-search: ";
+       if (line && !length(rev_search_results))
+               prompt = "failing " + prompt;
+
+       el.set_state({
+               line2_prompt: prompt,
+       });
+
+       if (line && length(rev_search_results)) {
+               line = rev_search_results[0];
+               let pos = length(line);
+               el.set_state({ line, pos });
+       }
+}
+
+function reverse_search_reset() {
+       if (rev_search == null)
+               return;
+       rev_search = null;
+       rev_search_results = null;
+       rev_search_index = 0;
+       el.set_state({
+               line2_prompt: null
+       });
+}
+
+function reverse_search()
+{
+       if (rev_search == null) {
+               reverse_search_update("");
+               return;
+       }
+
+       if (!length(rev_search_results))
+               return;
+
+       rev_search_index = (rev_search_index + 1) % length(rev_search_results);
+       let line = rev_search_results[rev_search_index];
+       let pos = length(line);
+       el.set_state({ line, pos });
+}
+
+function line_cb(line)
+{
+       reverse_search_reset();
+       line_history_reset();
+       unshift(history, line);
+
+       let arg_info = parser.parse(line);
+       for (let cmd in arg_info.args) {
+               let orig_cmd = [ ...cmd ];
+               let cur_ctx = ctx.select(cmd);
+               if (!cur_ctx)
+                       break;
+
+               if (!length(cmd)) {
+                       ctx = cur_ctx;
+                       update_prompt();
+                       continue;
+               }
+
+               try {
+                       let res = cur_ctx.call(cmd);
+                       format_result(res);
+               } catch (e) {
+                       model.exception(e);
+               }
+       }
+}
+
+const cb = {
+       eof: () => { uloop.end(); },
+       line_check: (line) => parser.check(line) == null,
+       line2_cursor: () => {
+               reverse_search_reset();
+               return false;
+       },
+       line2_update: reverse_search_update,
+       key_input: (c, count) => {
+               try {
+                       switch(c) {
+                       case "?":
+                               if (parser.check(el.get_line().line) != null)
+                                       return false;
+                               completion(-1);
+                               return true;
+                       case "\t":
+                               reverse_search_reset();
+                               completion(count);
+                               return true;
+                       case '\x03':
+                               if (count < 2) {
+                                       el.set_state({ line: "", pos: 0 });
+                               } else if (ctx.prev) {
+                                       warn(`\n`);
+                                       let cur_ctx = ctx.select([ "main" ]);
+                                       if (cur_ctx)
+                                               ctx = cur_ctx;
+                                       update_prompt();
+                               } else {
+                                       warn(`\n`);
+                                       el.poll_stop();
+                                       uloop.end();
+                               }
+                               return true;
+                       case "\x12":
+                               reverse_search();
+                               return true;
+                       }
+               } catch (e) {
+                       warn(`${e}\n${e.stacktrace[0].context}`);
+               }
+       },
+       cursor_up: () => {
+               try {
+                       line_history(1);
+               } catch (e) {
+                       el.set_hint(`${e}\n${e.stacktrace[0].context}`);
+               }
+       },
+       cursor_down: () => {
+               try {
+                       line_history(-1);
+               } catch (e) {
+                       el.set_hint(`${e}\n${e.stacktrace[0].context}`);
+               }
+       },
+};
+el = uline.new({
+       utf8: true,
+       cb,
+       key_input_list: [ "?", "\t", "\x03", "\x12" ]
+});
+
+while (length(ARGV) > 0) {
+       let cmd = ARGV[0];
+       if (substr(cmd, 0, 1) != "-")
+               break;
+
+       shift(ARGV);
+       switch (cmd) {
+       case '-i':
+               interactive = true;
+               break;
+       }
+}
+
+while (length(ARGV) > 0) {
+       let cmd = ARGV;
+       let idx = index(ARGV, ":");
+       if (idx >= 0) {
+               cmd = slice(ARGV, 0, idx);
+               ARGV = slice(ARGV, idx + 1);
+       } else {
+               ARGV = [];
+       }
+       interactive ??= false;
+
+       let orig_cmd = [ ...cmd ];
+       let cur_ctx = ctx.select(cmd);
+       if (!cur_ctx)
+               break;
+
+       if (!length(cmd)) {
+               ctx = cur_ctx;
+               continue;
+       }
+
+       let res = cur_ctx.call(cmd);
+       format_result(res);
+}
+
+if (interactive != false) {
+       warn("Welcome to the OpenWrt CLI. Press '?' for help on commands/arguments\n");
+       update_prompt();
+       el.set_uloop(line_cb);
+       uloop.run();
+       exit(0);
+}
diff --git a/package/utils/cli/files/usr/share/ucode/cli/cache.uc b/package/utils/cli/files/usr/share/ucode/cli/cache.uc
new file mode 100644 (file)
index 0000000..57f482e
--- /dev/null
@@ -0,0 +1,58 @@
+'use strict';
+
+const CACHE_DEFAULT_TIMEOUT = 5;
+
+function cache_get(key, fn, timeout)
+{
+       let now = time();
+       let entry = this.entries[key];
+       if (entry) {
+               if (now < entry.timeout)
+                       return entry.data;
+
+               if (!fn)
+                       delete this.entries[key];
+       }
+
+       if (!fn)
+               return;
+
+       let data = fn();
+       if (!entry)
+               this.entries[key] = entry = {};
+       timeout ??= CACHE_DEFAULT_TIMEOUT;
+       entry.timeout = now + timeout;
+       entry.data = data;
+
+       return data;
+}
+
+function cache_remove(key)
+{
+       delete this.entries[key];
+}
+
+function cache_gc() {
+       let now = time();
+       for (let key, entry in this.entries)
+               if (now > entry.timeout)
+                       delete this.entries[key];
+}
+
+const cache_proto = {
+       get: cache_get,
+       remove: cache_remove,
+       gc: cache_gc,
+};
+
+export function new(model) {
+       model.cache_proto ??= { model, ...cache_proto };
+       let cache = proto({
+               entries: {},
+       }, model.cache_proto);
+       cache.gc_interval = model.uloop.interval(10000, () => {
+               cache.gc();
+       });
+
+       return cache;
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/color.uc b/package/utils/cli/files/usr/share/ucode/cli/color.uc
new file mode 100644 (file)
index 0000000..2a24085
--- /dev/null
@@ -0,0 +1,63 @@
+'use strict';
+
+const color_codes = {
+       black: 30,
+       red: 31,
+       green: 32,
+       yellow: 33,
+       blue: 34,
+       magenta: 35,
+       cyan: 36,
+       white: 37,
+       default: 39
+};
+
+function color_str(n)
+{
+       return "\e["+n+"m";
+}
+
+function color_code(str)
+{
+       let n = 0;
+       if (substr(str, 0, 7) == "bright_") {
+               str = substr(str, 7);
+               n += 60;
+       }
+       if (!color_codes[str])
+               return;
+
+       n += color_codes[str];
+       return n;
+}
+
+export function color_fg(name, str)
+{
+       let n = color_code(name);
+       if (!n)
+               return str;
+
+       let ret = color_str(n);
+       if (str != null)
+               ret += str + color_str(39);
+
+       return ret;
+};
+
+export function color_bg(name, str)
+{
+       let n = color_code(name);
+       if (!n)
+               return str;
+
+       let ret = color_str(n + 10);
+       if (str != null)
+               ret += str + color_str(49);
+
+       return ret;
+};
+
+export function bold(str)
+{
+       return color_str(1) + str + color_str(0);
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/context-call.uc b/package/utils/cli/files/usr/share/ucode/cli/context-call.uc
new file mode 100644 (file)
index 0000000..a8417dd
--- /dev/null
@@ -0,0 +1,86 @@
+'use strict';
+
+function call_ok(msg)
+{
+       this.result.ok = true;
+       if (msg)
+               this.result.status_msg = msg;
+       return true;
+}
+
+function call_error(code, msg, ...args)
+{
+       msg ??= "Unknown error";
+       msg = sprintf(msg, ...args);
+       let error = {
+               code, msg, args
+       };
+       push(this.result.errors, error);
+}
+
+function call_generic(ctx, name, type, val)
+{
+       ctx.result.type = type;
+       ctx.result.name = name;
+       ctx.result.data = val;
+       return ctx.ok();
+}
+
+function call_table(name, val)
+{
+       return call_generic(this, name, "table", val);
+}
+
+function call_list(name, val)
+{
+       return call_generic(this, name, "list", val);
+}
+
+function call_string(name, val)
+{
+       return call_generic(this, name, "string", val);
+}
+
+function call_apply_defaults(named_args, args)
+{
+       let entry = this.entry;
+       named_args ??= entry.named_args;
+       args ??= this.named_args;
+       for (let name, arg in named_args)
+               if (arg.default != null && !(name in args))
+                       args[name] ??= arg.default;
+}
+
+const callctx_proto = {
+       apply_defaults: call_apply_defaults,
+       ok: call_ok,
+       list: call_list,
+       table: call_table,
+       string: call_string,
+
+       error: call_error,
+       missing_argument: function(msg, ...args) {
+               return this.error("MISSING_ARGUMENT", msg ?? "Missing argument", ...args);
+       },
+       invalid_argument: function(msg, ...args) {
+               return this.error("INVALID_ARGUMENT", msg ?? "Invalid argument", ...args);
+       },
+       unknown_error: function(msg, ...args) {
+               return this.error("UNKNOWN_ERROR", msg ?? "Unknown error", ...args);
+       },
+       not_found: function(msg, ...args) {
+               return this.error("NOT_FOUND", msg ?? "Not found", ...args);
+       },
+       command_failed: function(msg, ...args) {
+               return this.error("Command failed", msg ?? "Command failed", ...args);
+       },
+};
+
+export function new(model, data) {
+       model.callctx_proto ??= { model, ...callctx_proto };
+       let result = {
+               errors: [],
+               ok: false
+       };
+       return proto({ data, result }, model.callctx_proto);
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/context.uc b/package/utils/cli/files/usr/share/ucode/cli/context.uc
new file mode 100644 (file)
index 0000000..cb9663d
--- /dev/null
@@ -0,0 +1,577 @@
+'use strict';
+
+import * as callctx from "cli.context-call";
+
+function prefix_match(prefix, str)
+{
+       return substr(str, 0, length(prefix)) == prefix;
+}
+
+function context_clone()
+{
+       let ret = { ...this };
+       ret.prompt = [ ...ret.prompt ];
+       ret.data = { ...ret.data };
+       ret.hooks = {};
+       return proto(ret, proto(this));
+}
+
+function context_entries()
+{
+       return keys(this.node)
+}
+
+function context_help(entry)
+{
+       if (entry)
+               return this.node[entry].help;
+
+       let ret = {};
+       for (let name, val in this.node)
+               ret[name] = val.help ?? "";
+
+       return ret;
+}
+
+function context_add_hook(type, cb)
+{
+       this.hooks[type] ??= [];
+       push(this.hooks[type], cb);
+}
+
+function context_set(node, prompt, data)
+{
+       let new_node = this.model.node[node];
+       if (!new_node)
+               return;
+
+       this.node = new_node;
+       if (prompt)
+               this.cur_prompt = prompt;
+       if (data)
+               this.data = { ...this.data, ...data };
+       return true;
+}
+
+const context_select_proto = {
+       add_hook: context_add_hook,
+       set: context_set
+};
+
+function __context_select(ctx, name, args)
+{
+       let entry = ctx.node[name];
+       if (!entry || !entry.select)
+               return;
+
+       let ret = proto(ctx.clone(), ctx.model.context_select_proto);
+       ret.cur_prompt = name;
+       try {
+               if (!call(entry.select, entry, ctx.model.scope, ret, args))
+                       return;
+       } catch (e) {
+               ctx.model.exception(e);
+               return;
+       }
+
+       push(ret.prompt, ret.cur_prompt);
+       ret.prev = ctx;
+       proto(ret, proto(ctx));
+
+       return ret;
+}
+
+function context_run_hooks(ctx, name)
+{
+       try {
+               while (length(ctx.hooks[name]) > 0) {
+                       let hook = ctx.hooks[name][0];
+
+                       let ret = call(hook, ctx, ctx.model.scope);
+                       if (!ret)
+                               return false;
+
+                       shift(ctx.hooks.exit);
+               }
+       } catch (e) {
+               ctx.model.exception(e);
+               return false;
+       }
+
+       return true;
+}
+
+function context_prev(ctx, skip_hooks)
+{
+       if (!skip_hooks && !context_run_hooks(ctx, "exit"))
+               return;
+       return ctx.prev;
+}
+
+function context_top(ctx, skip_hooks)
+{
+       while (ctx && ctx.prev)
+               ctx = context_prev(ctx, skip_hooks);
+       return ctx;
+}
+
+function prepare_spec(e, ctx, spec, argv, named_args)
+{
+       if (type(spec) != "object")
+               return spec;
+
+       let t = ctx.model.types[spec.type];
+       if (t)
+               spec = { ...t, ...spec };
+       else
+               spec = { ...spec };
+
+       if (type(spec.value) == "function")
+               spec.value = call(spec.value, e, ctx.model.scope, ctx, argv, named_args);
+
+       return spec;
+}
+
+function parse_arg(ctx, name, spec, val)
+{
+       let t;
+
+       if (val == null) {
+               ctx.invalid_argument("Missing argument %s", name);
+               return;
+       }
+
+       if (type(spec) == "object" && spec.type)
+               t = ctx.model.types[spec.type];
+       if (!t) {
+               ctx.invalid_argument("Invalid type in argument: %s", name);
+               return;
+       }
+
+       if (!t.parse)
+               return val;
+
+       return call(t.parse, spec, ctx.model.scope, ctx, name, val);
+}
+
+const context_defaults = {
+       up: [ "Return to previous node", context_prev ],
+       exit: [ "Return to previous node", context_prev ],
+       main: [ "Return to main node", context_top ],
+};
+
+const context_default_order = [ "up", "exit", "main" ];
+
+function context_select(args, completion)
+{
+       let ctx = this;
+
+       while (length(args) > completion ? 1 : 0) {
+               let name = args[0];
+               let entry = ctx.node[name];
+
+               if (!entry) {
+                       let e = context_defaults[name];
+                       if (!e)
+                               return ctx;
+
+                       shift(args);
+                       ctx = e[1](ctx, completion);
+                       if (!ctx)
+                               return;
+
+                       continue;
+               }
+
+               if (!entry.select)
+                       return ctx;
+
+               let num_args = length(entry.args);
+               if (completion && num_args + 1 >= length(args))
+                       return ctx;
+
+               shift(args);
+               let argv = [];
+               if (num_args > 0) {
+                       let cur_argv = slice(args, 0, num_args);
+                       let parse_ctx = callctx.new(this.model, ctx.data);
+                       for (let i = 0; i < num_args; i++) {
+                               let arg = shift(args);
+                               let spec = entry.args[i];
+
+                               spec = prepare_spec(entry, ctx, spec, cur_argv, {});
+                               if (arg != null)
+                                       arg = parse_arg(parse_ctx, spec.name, spec, arg);
+
+                               if (arg != null)
+                                       push(argv, arg);
+                       }
+
+                       if (length(parse_ctx.result.errors) > 0)
+                               return;
+               }
+
+               if (entry.no_subcommands && length(args) > 0)
+                       return;
+
+               ctx = __context_select(ctx, name, argv);
+               if (!ctx)
+                       return;
+       }
+
+       return ctx;
+}
+
+function complete_named_params(entry, obj, name)
+{
+       let data = [];
+       let empty = "";
+
+       if (substr(name, 0, 1) == "-") {
+               empty = "-";
+               name = substr(name, 1);
+       }
+
+       for (let cur_name in sort(keys(obj))) {
+               let val = obj[cur_name];
+
+               if (!prefix_match(name, cur_name) || val.no_complete)
+                       continue;
+
+               if (empty && !(val.allow_empty ?? entry.allow_empty))
+                       continue;
+
+               val = {
+                       name: empty + cur_name,
+                       ...val,
+               };
+               push(data, val);
+       }
+
+       return {
+               type: "keywords",
+               name: "parameter",
+               help: "Parameter name",
+               value: data
+       };
+}
+
+function complete_param(e, ctx, cur, val, args, named_args)
+{
+       cur = prepare_spec(e, ctx, cur, args, named_args);
+
+       if (type(cur.value) == "object") {
+               let ret = [];
+               for (let key in sort(keys(cur.value)))
+                       if (prefix_match(val, key))
+                               push(ret, {
+                                       name: key,
+                                       help: cur.value[key]
+                               });
+
+               cur.value = ret;
+       } else if (type(cur.value) == "array") {
+               cur.value = map(sort(filter(cur.value, (v) => prefix_match(val, v))), (v) => ({ name: v }));
+       }
+
+       return cur;
+}
+
+function complete_arg_list(e, ctx, arg_info, args, base_args, named_args)
+{
+       let cur_idx = length(args) - 1;
+       let cur = arg_info[cur_idx];
+       let val;
+
+       for (let i = 0; i <= cur_idx; i++)
+               val = shift(args);
+
+       return complete_param(e, ctx, cur, val, base_args, named_args);
+}
+
+function handle_empty_param(entry, name, named_args)
+{
+       if (substr(name, 0, 1) != "-")
+               return;
+
+       name = substr(name, 1);
+       let cur = entry.named_args[name];
+       if (!cur || !(cur.allow_empty ?? entry.allow_empty))
+               return;
+
+       if (cur.required)
+               named_args[name] = cur.default;
+       else
+               named_args[name] = null;
+       return true;
+}
+
+
+function default_complete(ctx, args)
+{
+       let num_args = length(this.args);
+       let named_args = {};
+       let cur_args;
+
+       if (length(args) <= num_args)
+               return complete_arg_list(this, ctx, this.args, args, [ ...args ], named_args);
+
+       let spec = this.named_args;
+       if (!spec)
+               return;
+
+       let base_args = slice(args, 0, num_args);
+       for (let i = 0; i < num_args; i++)
+               shift(args);
+
+       while (length(args) > 0) {
+               let name = args[0];
+
+               if (length(args) == 1)
+                       return complete_named_params(this, spec, name);
+
+               shift(args);
+               let cur = spec[name];
+               if (!cur) {
+                       if (handle_empty_param(this, name, named_args))
+                               continue;
+                       return;
+               }
+
+               if (!cur.args) {
+                       named_args[name] = true;
+                       continue;
+               }
+
+               let val;
+               let cur_spec = cur.args;
+               if (type(cur_spec) != "array") {
+                       cur_spec = [{
+                               name,
+                               help: cur.help,
+                               ...cur_spec
+                       }];
+                       named_args[name] = shift(args);
+                       val = [ named_args[name] ];
+               } else {
+                       let num_args = length(cur_spec);
+                       let val = [];
+                       for (let i = 0; i < num_args; i++)
+                               push(val, shift(args));
+                       named_args[name] = val;
+               }
+
+               if (!length(args))
+                       return complete_arg_list(this, ctx, cur_spec, val, base_args, named_args);
+       }
+}
+
+function context_complete(args)
+{
+       let ctx = this.select(args, true);
+       if (!ctx)
+               return;
+
+       if (ctx != this) {
+               ctx = ctx.clone();
+               ctx.skip_default_complete = true;
+       }
+
+       if (length(args) > 1) {
+               let name = shift(args);
+               let entry = ctx.node[name];
+               if (!entry)
+                       return;
+
+               try {
+                       if (!entry.available || call(entry.available, entry, ctx.model.scope, ctx, args))
+                               return call(entry.complete ?? default_complete, entry, ctx.model.scope, ctx, args);
+               } catch (e) {
+                       this.model.exception(e);
+               }
+               return;
+       }
+
+       let name = shift(args) ?? "";
+       let prefix_len = length(name);
+       let data = [];
+       let default_data = {};
+       for (let cur_name in sort(keys(ctx.node))) {
+               let val = ctx.node[cur_name];
+
+               if (substr(cur_name, 0, prefix_len) != name)
+                       continue;
+
+               if (val.available && !call(val.available, val, ctx.model.scope, ctx, args))
+                       continue;
+
+               let cur = {
+                       name: cur_name,
+                       help: val.help
+               };
+               if (context_defaults[cur_name])
+                       default_data[cur_name] = cur;
+               else
+                       push(data, cur);
+       }
+
+       for (let cur_name in context_default_order) {
+               if (substr(cur_name, 0, prefix_len) != name)
+                       continue;
+
+               let val = default_data[cur_name];
+               if (!val) {
+                       if (!ctx.prev || ctx.skip_default_complete)
+                               continue;
+                       val = {
+                               name: cur_name,
+                               help: context_defaults[cur_name][0]
+                       };
+               }
+
+               push(data, val);
+       }
+
+       return {
+               type: "enum",
+               name: "command",
+               help: "Command",
+               value: data
+       };
+}
+
+function context_call(args)
+{
+       let ctx = this.select(args);
+       if (!ctx || !length(args))
+               return;
+
+       let name = shift(args);
+       let entry = ctx.node[name];
+       if (!entry)
+               return;
+
+       if (!entry.call)
+               return;
+
+       let named_args = {};
+       let num_args = length(entry.args);
+       let cur_argv = slice(args, 0, num_args);
+       let argv = [];
+
+       ctx = callctx.new(this.model, ctx.data);
+
+       for (let i = 0; i < num_args; i++) {
+               let arg = shift(args);
+               let spec = entry.args[i];
+
+               spec = prepare_spec(entry, ctx, spec, cur_argv, named_args);
+               if (arg != null)
+                       arg = parse_arg(ctx, spec.name, spec, arg);
+
+               if (spec.required && !length(arg)) {
+                       if (spec.default)
+                               arg = spec.default;
+                       else
+                               ctx.missing_argument("Missing argument %d: %s", i, spec.name);
+               }
+
+               if (arg != null)
+                       push(argv, arg);
+       }
+
+       while (length(args) > 0) {
+               let name = shift(args);
+               let cur = entry.named_args[name];
+               if (!cur) {
+                       if (handle_empty_param(entry, name, named_args))
+                               continue;
+                       ctx.invalid_argument("Invalid argument: %s", name);
+                       return ctx.result;
+               }
+
+               if (!cur.args) {
+                       named_args[name] = true;
+                       continue;
+               }
+
+               let val;
+               let cur_spec = cur.args;
+               if (type(cur.args) == "array") {
+                       val = [];
+                       for (let spec in cur.args) {
+                               spec = prepare_spec(entry, ctx, spec, argv, named_args);
+                               let cur = parse_arg(ctx, name, spec, shift(args));
+                               if (cur == null)
+                                       return ctx.result;
+
+                               push(val, cur);
+                       }
+               } else {
+                       let spec = prepare_spec(entry, ctx, cur.args, argv, named_args);
+                       val = parse_arg(ctx, name, spec, shift(args));
+                       if (val == null)
+                               return ctx.result;
+               }
+               if (cur.multiple) {
+                       named_args[name] ??= [];
+                       push(named_args[name], val);
+               } else {
+                       named_args[name] = val;
+               }
+       }
+
+       for (let name, arg in entry.named_args) {
+               if (arg.required && named_args[name] == null) {
+                       if (arg.default)
+                               named_args[name] = arg.default;
+                       else
+                               ctx.missing_argument("Missing argument: %s", name);
+               }
+       }
+
+       if (length(ctx.result.errors) > 0)
+               return ctx.result;
+
+       ctx.entry = entry;
+       ctx.named_args = named_args;
+
+       if (entry.available && !call(entry.available, entry, ctx.model.scope, ctx))
+               return ctx.result;
+
+       try {
+               if (!entry.validate || call(entry.validate, entry, ctx.model.scope, ctx, argv, named_args))
+                       call(entry.call, entry, ctx.model.scope, ctx, argv, named_args);
+       } catch (e) {
+               this.model.exception(e);
+               return;
+       }
+       return ctx.result;
+}
+
+const context_proto = {
+       clone: context_clone,
+       entries: context_entries,
+       help: context_help,
+       select: context_select,
+       call: context_call,
+       complete: context_complete,
+       add_hook: context_add_hook,
+};
+
+export function new(model) {
+       model.context_proto ??= {
+               model,
+               ...context_proto
+       };
+       model.context_select_proto ??= {
+               model,
+               ...context_select_proto
+       };
+       return proto({
+               prompt: [],
+               node: model.node.Root,
+               hooks: {},
+               data: {}
+       }, model.context_proto);
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc b/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc
new file mode 100644 (file)
index 0000000..f18f394
--- /dev/null
@@ -0,0 +1,142 @@
+'use strict';
+
+import * as context from "cli.context";
+import * as cache from "cli.cache";
+import * as libubus from "ubus";
+import * as uloop from "uloop";
+import { glob, dirname } from "fs";
+let types = require("cli.types");
+
+uloop.init();
+let ubus = libubus.connect();
+
+function poll_key(keys, prompt) {
+       if (!model.cb.poll_key)
+               return;
+
+       if (prompt)
+               warn(prompt);
+
+    while (1) {
+        let key = lc(model.cb.poll_key());
+               if (!key || key == "\x03")
+                       return;
+
+               if (index(keys, key) >= 0)
+                       return key;
+       }
+}
+
+function merge_object(obj, add)
+{
+       for (let name, entry in add)
+               obj[name] = entry;
+}
+
+function merge_nodes(obj, add)
+{
+       for (let name, val in add) {
+               if (obj[name])
+                       merge_object(obj[name], val);
+               else
+                       obj[name] = { ...val };
+       }
+}
+
+function add_nodes(add)
+{
+       merge_nodes(this.node, add);
+}
+
+function merge_hooks(obj, add)
+{
+       for (let name, val in add) {
+               if (type(val) == "function")
+                       val = [ val ];
+               obj[name] ??= [];
+               push(obj[name], ...val);
+       }
+}
+
+function add_module(path)
+{
+       if (substr(path, 0, 1) != "/")
+               path = dirname(sourcepath()) + "/modules/" + path;
+
+       let mod;
+       try {
+               let fn = loadfile(path);
+               mod = call(fn, this, this.scope);
+       } catch (e) {
+               this.warn(`${e}\n${e.stacktrace[0].context}\nFailed to open module ${path}.\n`);
+               return;
+       }
+
+       merge_hooks(this.hooks, mod.hooks);
+       this.add_nodes(mod.nodes);
+       merge_object(this.warnings, mod.warnings);
+       merge_object(this.types, mod.types);
+}
+
+function add_modules(path)
+{
+       path ??= "*.uc";
+       if (substr(path, 0, 1) != "/")
+               path = dirname(sourcepath()) + "/modules/" + path;
+
+       for (let mod in glob(path))
+               this.add_module(mod);
+}
+
+function run_hook(name, ...args)
+{
+       let hooks = this.hooks[name];
+       if (!hooks)
+               return;
+
+       for (let hook in hooks)
+               call(hook, this, {}, ...args);
+}
+
+function init()
+{
+       this.run_hook("init");
+}
+
+function context_new()
+{
+       return context.new(this);
+}
+
+function exception(e)
+{
+       this.warn(`${e}\n${e.stacktrace[0].context}`);
+}
+
+const data_proto = {
+       warn, exception,
+       poll_key,
+       add_modules,
+       add_module,
+       add_nodes,
+       run_hook,
+       init,
+       context: context_new,
+};
+
+export function new(cb) {
+       cb ??= {};
+       let model = proto({
+               libubus, ubus, uloop,
+               cb,
+               hooks: {},
+               node: {
+                       Root: {}
+               },
+               warnings: {},
+               types: { ...types },
+       }, data_proto);
+       model.scope = proto({ model }, global);
+       model.cache = cache.new(model);
+       return model;
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/modules/network.uc b/package/utils/cli/files/usr/share/ucode/cli/modules/network.uc
new file mode 100644 (file)
index 0000000..a5403ac
--- /dev/null
@@ -0,0 +1,159 @@
+function get_interfaces()
+{
+       let data = model.ubus.call("network.interface", "dump");
+       if (!data)
+               return {};
+
+       let ret = {};
+       for (let iface in data.interface)
+               ret[iface.interface] = iface;
+
+       return ret;
+}
+
+function interface_validate(ctx, argv)
+{
+       let name = argv[0];
+       if (!name)
+               return ctx.missing_argument("Missing argument: %s", "name");
+
+       if (index(get_interfaces(), name) < 0)
+               return ctx.not_found("Interface not found: %s", name);
+
+       return true;
+}
+
+const interface_args = [
+       {
+               name: "interface",
+               help: "Interface name",
+               type: "enum",
+               value: (ctx) => keys(get_interfaces())
+       }
+];
+
+function interface_status(data)
+{
+       if (data.up)
+               return "up";
+       if (!data.autostart)
+               return "down";
+       if (!data.available)
+               return "unavailable";
+       return "pending";
+}
+
+function time_format(val)
+{
+       let ret = `${val % 60}s`;
+
+       val /= 60;
+       if (!val)
+               return ret;
+
+       ret = `${val % 60}m ${ret}`;
+
+       val /= 60;
+       if (!val)
+               return ret;
+
+       ret = `${val % 24 }h ${ret}`;
+
+       val /= 24;
+       if (!val)
+               return ret;
+
+       return `${val}d ${ret}`;
+}
+
+const Network = {
+       list: {
+               help: "List interfaces",
+               call: function(ctx, argv) {
+                       return ctx.list("Interfaces", keys(get_interfaces()));
+               }
+       },
+       reload: {
+               help: "Reload network config",
+               call: function(ctx, argv) {
+                       model.ubus.call("network", "reload");
+                       return ctx.ok("Configuration reloaded");
+               }
+       },
+       restart: {
+               help: "Restart interface",
+               validate: interface_validate,
+               args: interface_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       model.ubus.call("network.interface."+name, "down");
+                       model.ubus.call("network.interface."+name, "up");
+                       return ctx.ok("Interface restarted");
+               }
+       },
+       start: {
+               help: "Start interface",
+               validate: interface_validate,
+               args: interface_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       model.ubus.call("network.interface."+name, "up");
+                       return ctx.ok("Interface started");
+               }
+       },
+       stop: {
+               help: "Stop interface",
+               validate: interface_validate,
+               args: interface_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       model.ubus.call("network.interface."+name, "down");
+                       return ctx.ok("Interface stopped");
+               }
+       },
+       status: {
+               help: "Interface status",
+               args: interface_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       let status = get_interfaces();
+                       if (!name) {
+                               let data = {};
+                               for (let iface, ifdata in status)
+                                       data[iface] = interface_status(ifdata);
+
+                               return ctx.table("Status", data);
+                       }
+
+                       let ifdata = status[name];
+                       let data = {
+                               Status: interface_status(ifdata),
+                       };
+                       if (ifdata.up)
+                               data.Uptime = time_format(ifdata.uptime);
+
+                       if (length(ifdata["ipv4-address"]) > 0)
+                               data.IPv4 = join(", ", map(ifdata["ipv4-address"], (v) => v.address + "/" + v.mask));
+                       if (length(ifdata["ipv6-address"]) > 0)
+                               data.IPv6 = join(", ", map(ifdata["ipv6-address"], (v) => v.address + "/" + v.mask));
+                       if (length(ifdata["dns-server"]) > 0)
+                               data.DNS = join(", ", ifdata["dns-server"]);
+                       if (length(ifdata["route"]) > 0)
+                               data.Routes = join(", ", map(ifdata["route"], (v) => (v.mask == 0 ? "Default" : `${v.target}/${v.mask}`) + ": " + v.nexthop));
+                       return ctx.table("Status", data);
+               }
+       }
+};
+
+const Root = {
+       network: {
+               help: "Network interface configuration",
+               select: function(ctx, argv) {
+                       return ctx.set("Network");
+               },
+       }
+};
+
+return {
+       nodes: { Root, Network }
+}
diff --git a/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc b/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc
new file mode 100644 (file)
index 0000000..59dacd4
--- /dev/null
@@ -0,0 +1,174 @@
+import { glob, access, basename } from "fs";
+
+function get_services()
+{
+       return model.cache.get("init_service_list", () => {
+               let services = glob("/etc/init.d/*");
+               services = filter(services, (v) => !system([ "grep", "-q", "start_service()", v ]));
+               services = map(services, basename);
+               return sort(services);
+       });
+}
+
+function get_service_status( name)
+{
+       return model.ubus.call("service", "list", (name ? { name } : null));
+}
+
+function service_running(name)
+{
+       let status = get_service_status(name);
+       return !!(status && status[name]);
+}
+
+function __service_cmd(name, cmd)
+{
+       return system([ "/etc/init.d/" + name, cmd ]) == 0;
+}
+
+function service_cmd(ctx, name, cmd, msg)
+{
+       if (__service_cmd(name, cmd))
+               return ctx.ok(msg);
+       else
+               return ctx.command_failed("Command failed");
+}
+
+function service_validate(ctx, argv)
+{
+       let name = argv[0];
+       if (!name)
+               return ctx.missing_argument("Missing argument: %s", "name");
+
+       if (index(get_services(), name) < 0)
+               return ctx.not_found("Service not found: %s", name);
+
+       return true;
+}
+
+const service_args = [
+       {
+               name: "name",
+               help: "Service name",
+               type: "enum",
+               value: (ctx) => get_services()
+       }
+];
+
+const service_settings = {
+       enabled: {
+               help: "Service enabled at system boot",
+       },
+       disabled: {
+               help: "Service disabled at system boot",
+       }
+};
+
+const Service = {
+       list: {
+               help: "List services",
+               call: function(ctx, argv) {
+                       return ctx.list("Services", get_services());
+               }
+       },
+       reload: {
+               help: "Reload service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       return service_cmd(ctx, shift(argv), "reload", "Service reloaded");
+               }
+       },
+       restart: {
+               help: "Restart service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       return service_cmd(ctx, shift(argv), "restart", "Service restarted");
+               }
+       },
+       set: {
+               help: "Change service settings",
+               validate: service_validate,
+               args: service_args,
+               named_args: service_settings,
+               call: function(ctx, argv, param) {
+                       if (!length(param))
+                               return ctx.invalid_argument("No settings provided");
+
+                       if (param.enabled && param.disabled)
+                               return ctx.invalid_argument("enabled and disabled cannot be set at the same time");
+
+                       if (param.enabled && !__service_cmd(name, "enable"))
+                               ctx.command_failed("Command failed: %s", "enable");
+
+                       if (param.disabled && !__service_cmd(name, "disable"))
+                               ctx.command_failed("Command failed: %s", "disable");
+
+                       return ctx.ok("Settings changed");
+               }
+       },
+       start: {
+               help: "Start service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+
+                       if (service_running(name))
+                               return ctx.invalid_argument("Service already running", name);
+
+                       return service_cmd(ctx, name, "start", "Service started");
+               }
+       },
+       stop: {
+               help: "Stop service",
+               validate: service_validate,
+               args: service_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+
+                       if (!service_running(name))
+                               return ctx.invalid_argument("Service not running", name);
+
+                       return service_cmd(ctx, name, "stop", "Service stopped");
+               }
+       },
+       status: {
+               help: "Service status",
+               args: service_args,
+               call: function(ctx, argv) {
+                       let name = shift(argv);
+                       if (!name) {
+                               let data = {};
+                               for (let service in get_services()) {
+                                       let running = service_running(service);
+                                       data[service] = running ? "running" : "not running";
+                               }
+                               return ctx.table("Status", data);
+                       }
+
+                       if (index(get_services(), name) < 0)
+                               return ctx.not_found("Service not found: %s", name);
+
+                       let data = {
+                               "Running": service_running(name),
+                               "Enabled": __service_cmd(name, "enabled"),
+                       };
+                       return ctx.table("Status", data);
+               }
+       }
+};
+
+const Root = {
+       service: {
+               help: "System service configuration",
+               select: function(ctx, argv) {
+                       return ctx.set("Service");
+               },
+       }
+};
+
+return {
+       nodes: { Root, Service }
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/modules/unet.uc b/package/utils/cli/files/usr/share/ucode/cli/modules/unet.uc
new file mode 100644 (file)
index 0000000..dd339a9
--- /dev/null
@@ -0,0 +1,817 @@
+import { readfile, writefile, mkstemp, mkdir, unlink } from "fs";
+import * as rtnl from "rtnl";
+import * as uci from "uci";
+
+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)
+               return ctx.invalid_argument("Could not get network config password");
+
+       let pw = model.cb.getpass("Network config password: ");
+       if (length(pw) < 12)
+               return ctx.invalid_argument("Password must be at least 12 characters long");
+
+       named.password = pw;
+       if (!confirm)
+               return true;
+
+       pw2 = model.cb.getpass("Confirm config password: ");
+       if (pw != pw2)
+               return ctx.invalid_argument("Password mismatch");
+
+       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_set_config_values(config, named)
+{
+       let config_map = {
+               port: "port",
+               "peer-exchange-port": "pex-port",
+               keepalive: "keepalive",
+       };
+       let ret = false;
+
+       for (let name, val in config_map) {
+               val = named[val];
+               if (val == null || config[name] == val)
+                       continue;
+
+               config[name] = val;
+               ret = true;
+       }
+
+       return ret;
+}
+
+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");
+}
+
+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: {},
+       };
+       network_set_config_values(network.config, named);
+
+       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",
+               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_set_config(ctx, argv, named)
+{
+       let netdata = ctx.data.netdata;
+       let data = netdata.json;
+
+       if (!network_set_config_values(data.config, named))
+               return ctx.ok("No changes");
+
+       netdata.changed = true;
+       return ctx.ok("Configuration changed");
+}
+
+function network_apply(ctx, argv, named)
+{
+       let name = 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);
+       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 ctx.ok("Changes applied");
+}
+
+function __network_cancel_invite(model, ctx)
+{
+       let name = ctx.data.network;
+       let netdata = ctx.data.netdata;
+       let invite = netdata.invite;
+
+       if (!invite)
+               return false;
+
+       invite.sub.remove();
+       model.ubus.call("unetd", "enroll_stop");
+       delete netdata.invite;
+
+       return true;
+}
+
+function network_invite_peer_update(model, ctx, msg)
+{
+       let name = ctx.data.network;
+       let netdata = ctx.data.netdata;
+       let invite = netdata.invite;
+
+       let data = msg.data;
+       let peer = invite.peers[data.session];
+
+       if (!peer && model.cb.status_msg)
+               model.cb.status_msg("New device detected at " + data.address + ", session id " + data.session);
+
+       peer ??= {};
+       if (data.accepted && !peer.accepted)
+               model.cb.status_msg("Accepted peer at " + data.address + ", session id " + data.session);
+
+       if (data.confirmed && !peer.confirmed) {
+               model.cb.status_msg("Confirmed peer at " + data.address + ", session id " + data.session);
+               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.cb.status_msg("Updated configuration");
+               }
+
+               return;
+       }
+
+       invite.peers[data.session] = data;
+}
+
+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_cancel_invite(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);
+       netdata.invite = invite;
+
+       return ctx.ok("Invite started");
+}
+
+function __network_cancel_join(ctx)
+{
+       let req = ctx.data.join;
+       if (!req)
+               return;
+
+       req.sub.remove();
+       delete ctx.data.join;
+}
+
+function network_join_peer_update(model, ctx, msg)
+{
+       let joinreq = ctx.data.join;
+       let name = joinreq.name;
+
+       let data = msg.data;
+       let peer = joinreq.peers[data.session];
+
+       if (!peer && model.cb.status_msg)
+               model.cb.status_msg("New device detected at " + data.address + ", session id " + data.session);
+
+       peer ??= {};
+       if (data.accepted && !peer.accepted)
+               model.cb.status_msg("Accepted peer at " + data.address + ", session id " + data.session);
+
+       if (data.confirmed && !peer.confirmed) {
+               model.cb.status_msg("Confirmed peer at " + data.address + ", session id " + data.session);
+
+               let iface = {
+                       proto: "unet",
+                       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.cb.status_msg("Configuration added for interface " + name);
+
+               __network_cancel_join(ctx);
+               return;
+       }
+
+       joinreq.peers[data.session] = data;
+}
+
+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_cancel_join(ctx);
+       ctx.apply_defaults();
+
+       let data = {
+               name: named.network,
+               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_cancel_join(ctx.model, ctx);
+       });
+       data.sub.subscribe("unetd");
+       model.ubus.call("unetd", "enroll_start", req);
+
+       ctx.data.join = data;
+
+       return ctx.ok("Join request started");
+}
+
+function network_cancel_join(ctx, argv, named)
+{
+       __network_cancel_join(ctx);
+       return ctx.ok("Join request cancelled");
+}
+
+function network_cancel_invite(ctx, argv, named)
+{
+       __network_cancel_invite(model, ctx);
+       return ctx.ok("Invite stopped");
+}
+
+function network_edit_exit_hook()
+{
+       let ctx = this;
+       let netdata = ctx.data.netdata;
+
+       __network_cancel_invite(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]) {
+                       warn(`Error: No network provided\n`);
+                       return;
+               }
+       }
+
+       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,
+               changed: false,
+       };
+
+       ctx.add_hook("exit", network_edit_exit_hook);
+
+       return ctx.set("UnetEdit", 'edit "' + network + '"', {
+               network, netdata,
+       });
+}
+
+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 = {
+       port: {
+               help: "wireguard port",
+               default: 51830,
+               args: {
+                       type: "int",
+                       min: 1,
+                       max: 65535,
+               }
+       },
+       "pex-port": {
+               help: "peer exchange port",
+               default: 51831,
+               args: {
+                       type: "int",
+                       min: 1,
+                       max: 65535,
+               }
+       },
+       keepalive: {
+               help: "keepalive interval (seconds)",
+               default: 10,
+               args: {
+                       type: "int",
+                       min: 0,
+               }
+       },
+};
+
+const network_local_args = {
+       network: {
+               help: "network name",
+               default: "unet",
+               required: true,
+               args: {
+                       type: "string",
+               }
+       },
+       zone: {
+               help: "Firewall zone",
+               default: "lan",
+               args: {
+                       type: "string",
+               }
+       },
+       domain: {
+               help: "Local DNS domain for unet hosts",
+               default: "unet",
+               args: {
+                       type: "string"
+               }
+       },
+       "local-network": {
+               help: "Local network interface for discovering peers",
+               default: [ "lan" ],
+               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_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 network_join_args = {
+       ...network_enroll_args,
+       ...network_local_args,
+};
+
+const network_invite_args = {
+       ...network_enroll_args,
+       ...network_sign_args,
+};
+
+const UnetEdit = {
+       set: {
+               help: "Set configuration parameters",
+               named_args: network_config_args,
+               call: network_set_config,
+       },
+       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.netdata.invite,
+               call: network_cancel_invite,
+       },
+       dump: {
+               help: "Show network json data",
+               call: function(ctx) {
+                       return ctx.string(null, sprintf("%.J", ctx.data.netdata.json));
+               }
+       },
+       apply: {
+               help: "Apply changes",
+               named_args: network_sign_args,
+               call: function(ctx, argv, named) {
+                       let netdata = ctx.data.netdata;
+                       if (!netdata.changed)
+                               return ctx.ok("No changes");
+
+                       return network_apply(ctx, argv, named);
+               }
+       }
+};
+
+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();
+               }
+       },
+       join: {
+               help: "Join existing network",
+               named_args: network_join_args,
+               call: network_join,
+       },
+       cancel: {
+               help: "Cancel join request",
+               available: (ctx) => ctx.data.join,
+               call: network_cancel_join,
+       },
+       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: network_edit,
+       },
+};
+
+const Root = {
+       unet: {
+               help: "unetd network management",
+               select: function(ctx, argv) {
+                       return ctx.set("Unet");
+               },
+       }
+};
+
+return {
+       nodes: { Root, Unet, UnetEdit }
+};
diff --git a/package/utils/cli/files/usr/share/ucode/cli/types.uc b/package/utils/cli/files/usr/share/ucode/cli/types.uc
new file mode 100644 (file)
index 0000000..98a863d
--- /dev/null
@@ -0,0 +1,60 @@
+const types = {
+       bool: {
+               value: [ "0", "1" ],
+               parse: function(ctx, name, val) {
+                       if (val == "1")
+                               return true;
+                       if (val == "0")
+                               return false;
+                       ctx.invalid_argument("value for %s must be 0 or 1", name);
+                       return;
+               },
+       },
+       int: {
+               parse: function(ctx, name, strval) {
+                       let val = +strval;
+                       if (substr(strval, 0, 1) == "-")
+                               strval = substr(strval, 1);
+                       if (match(strval, /[^0-9]/)) {
+                               ctx.invalid_argument("value for %s is not a number", name);
+                               return;
+                       }
+                       if ((this.min == null || val >= this.min) &&
+                           (this.max == null || val <= this.max))
+                               return val;
+                       if (this.min != null && this.max != null)
+                               ctx.invalid_argument(`value for %s must be between ${this.min} and ${this.max}`, name);
+                       else if (this.min != null)
+                               ctx.invalid_argument(`value for %s must be at least ${this.min}`, name);
+                       else
+                               ctx.invalid_argument(`value for %s must not be bigger than ${this.max}`, name);
+                       return;
+               }
+       },
+       string: {
+               parse: function(ctx, name, val) {
+                       let len = length(val);
+                       if ((this.min == null || len >= this.min) &&
+                           (this.max == null || len <= this.max))
+                               return val;
+                       if (this.min != null && this.max != null)
+                               ctx.invalid_argument(`String value %s must be between ${this.min} and ${this.max} characters`, name);
+                       else if (this.min != null)
+                               ctx.invalid_argument(`String value %s must be at least ${this.min} characters long`, name);
+                       else
+                               ctx.invalid_argument(`String value %s must not be longer than ${this.max} characters`, name);
+                       return;
+               }
+       },
+       enum: {
+               parse: function(ctx, name, val) {
+                       if (index(this.value, val) < 0) {
+                               ctx.invalid_argument("Invalid value for %s", name);
+                               return;
+                       }
+                       return val;
+               }
+       }
+};
+
+return types;