From 63297b6a1ad1e0900165d67c4bafdd841dbe0aed Mon Sep 17 00:00:00 2001 From: Felix Fietkau Date: Mon, 13 Jan 2025 22:46:05 +0100 Subject: [PATCH] cli: add OpenWrt CLI (work in progress) Signed-off-by: Felix Fietkau --- package/utils/cli/Makefile | 33 + package/utils/cli/files/usr/sbin/cli | 502 +++++++++++ .../cli/files/usr/share/ucode/cli/cache.uc | 58 ++ .../cli/files/usr/share/ucode/cli/color.uc | 63 ++ .../files/usr/share/ucode/cli/context-call.uc | 86 ++ .../cli/files/usr/share/ucode/cli/context.uc | 577 +++++++++++++ .../files/usr/share/ucode/cli/datamodel.uc | 142 +++ .../usr/share/ucode/cli/modules/network.uc | 159 ++++ .../usr/share/ucode/cli/modules/service.uc | 174 ++++ .../files/usr/share/ucode/cli/modules/unet.uc | 817 ++++++++++++++++++ .../cli/files/usr/share/ucode/cli/types.uc | 60 ++ 11 files changed, 2671 insertions(+) create mode 100644 package/utils/cli/Makefile create mode 100755 package/utils/cli/files/usr/sbin/cli create mode 100644 package/utils/cli/files/usr/share/ucode/cli/cache.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/color.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/context-call.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/context.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/datamodel.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/modules/network.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/modules/service.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/modules/unet.uc create mode 100644 package/utils/cli/files/usr/share/ucode/cli/types.uc diff --git a/package/utils/cli/Makefile b/package/utils/cli/Makefile new file mode 100644 index 0000000000..db2c94f48d --- /dev/null +++ b/package/utils/cli/Makefile @@ -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 + +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 index 0000000000..b16a09a271 --- /dev/null +++ b/package/utils/cli/files/usr/sbin/cli @@ -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 index 0000000000..57f482e3f0 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/cache.uc @@ -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 index 0000000000..2a240856d3 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/color.uc @@ -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 index 0000000000..a8417dde47 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/context-call.uc @@ -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 index 0000000000..cb9663ddb5 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/context.uc @@ -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 index 0000000000..f18f394549 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/datamodel.uc @@ -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 index 0000000000..a5403ac58b --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/modules/network.uc @@ -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 index 0000000000..59dacd4869 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/modules/service.uc @@ -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 index 0000000000..dd339a9977 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/modules/unet.uc @@ -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 index 0000000000..98a863d2c5 --- /dev/null +++ b/package/utils/cli/files/usr/share/ucode/cli/types.uc @@ -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; -- 2.30.2