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