diff options
Diffstat (limited to 'applications/luci-app-commands/ucode')
3 files changed, 483 insertions, 0 deletions
diff --git a/applications/luci-app-commands/ucode/controller/commands.uc b/applications/luci-app-commands/ucode/controller/commands.uc new file mode 100644 index 0000000000..9126d59eb0 --- /dev/null +++ b/applications/luci-app-commands/ucode/controller/commands.uc @@ -0,0 +1,256 @@ +// Copyright 2012-2022 Jo-Philipp Wich <jow@openwrt.org> +// Licensed to the public under the Apache License 2.0. + +'use strict'; + +import { basename, mkstemp, popen } from 'fs'; +import { urldecode } from 'luci.http'; + +// Decode a given string into arguments following shell quoting rules +// [[abc\ def "foo\"bar" abc'def']] -> [[abc def]] [[foo"bar]] [[abcdef]] +function parse_args(str) { + let args = []; + + function isspace(c) { + if (c == 9 || c == 10 || c == 11 || c == 12 || c == 13 || c == 32) + return c; + } + + function isquote(c) { + if (c == 34 || c == 39 || c == 96) + return c; + } + + function isescape(c) { + if (c == 92) + return c; + } + + function ismeta(c) { + if (c == 36 || c == 92 || c == 96) + return c; + } + + // Scan substring defined by the indexes [s, e] of the string "str", + // perform unquoting and de-escaping on the fly and store the result + function unquote(start, end) { + let esc, quote, res = []; + + for (let off = start; off < end; off++) { + const byte = ord(str, off); + const q = isquote(byte); + const e = isescape(byte); + const m = ismeta(byte); + + if (esc) { + if (!m) + push(res, 92); + + push(res, byte); + esc = false; + } + else if (e && quote != 39) { + esc = true; + } + else if (q && quote && q == quote) { + quote = null; + } + else if (q && !quote) { + quote = q; + } + else { + push(res, byte); + } + } + + push(args, chr(...res)); + } + + // Find substring boundaries in "str". Ignore escaped or quoted + // whitespace, pass found start- and end-index for each substring + // to unquote() + let esc, start, quote; + + for (let off = 0; off <= length(str); off++) { + const byte = ord(str, off); + const q = isquote(byte); + const s = isspace(byte) ?? (byte === null); + const e = isescape(byte); + + if (esc) { + esc = false; + } + else if (e && quote != 39) { + esc = true; + start ??= off; + } + else if (q && quote && q == quote) { + quote = null; + } + else if (q && !quote) { + start ??= off; + quote = q; + } + else if (s && !quote) { + if (start !== null) { + unquote(start, off); + start = null; + } + } + else { + start ??= off; + } + } + + // If the "quote" is still set we encountered an unfinished string + if (quote) + unquote(start, length(str)); + + return args; +} + +function test_binary(str) { + for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off)) + if (byte <= 8 || (byte >= 14 && byte <= 31)) + return true; + + return false; +} + +function parse_cmdline(cmdid, args) { + if (uci.get('luci', cmdid) == 'command') { + let cmd = uci.get_all('luci', cmdid); + let argv = parse_args(cmd?.command); + + if (cmd?.param == '1') { + if (length(args)) + push(argv, ...(parse_args(urldecode(args)) ?? [])); + else if (length(args = http.formvalue('args'))) + push(argv, ...(parse_args(args) ?? [])); + } + + return map(argv, v => match(v, /[^\w.\/|-]/) ? `'${replace(v, "'", "'\\''")}'` : v); + } +} + +function execute_command(callback, ...args) { + let argv = parse_cmdline(...args); + + if (argv) { + let outfd = mkstemp(); + let errfd = mkstemp(); + + const exitcode = system(`${join(' ', argv)} >&${outfd.fileno()} 2>&${errfd.fileno()}`); + + outfd.seek(0); + errfd.seek(0); + + const stdout = outfd.read(1024 * 512) ?? ''; + const stderr = errfd.read(1024 * 512) ?? ''; + + outfd.close(); + errfd.close(); + + const binary = test_binary(stdout); + + callback({ + ok: true, + command: join(' ', argv), + stdout: binary ? null : stdout, + stderr, + exitcode, + binary + }); + } + else { + callback({ + ok: false, + code: 404, + reason: "No such command" + }); + } +} + +function return_json(result) { + if (result.ok) { + http.prepare_content('application/json'); + http.write_json(result); + } + else { + http.status(result.code, result.reason); + } +} + + +function return_html(result) { + if (result.ok) { + include('commands_public', result); + } + else { + http.status(result.code, result.reason); + } +} + +return { + action_run: function(...args) { + execute_command(return_json, ...args); + }, + + action_download: function(...args) { + const argv = parse_cmdline(...args); + + if (argv) { + const fd = popen(`${join(' ', argv)} 2>/dev/null`); + + if (fd) { + let filename = replace(basename(argv[0]), /\W+/g, '.'); + let chunk = fd.read(4096) ?? ''; + let name; + + if (test_binary(chunk)) { + http.header("Content-Disposition", `attachment; filename=${filename}.bin`); + http.prepare_content("application/octet-stream"); + } + else { + http.header("Content-Disposition", `attachment; filename=${filename}.txt`); + http.prepare_content("text/plain"); + } + + while (length(chunk)) { + http.write(chunk); + chunk = fd.read(4096); + } + + fd.close(); + } + else { + http.status(500, "Failed to execute command"); + } + } + else { + http.status(404, "No such command"); + } + }, + + action_public: function(cmdid, ...args) { + let disp = false; + + if (substr(cmdid, -1) == "s") { + disp = true; + cmdid = substr(cmdid, 0, -1); + } + + if (cmdid && + uci.get('luci', cmdid) == 'command' && + uci.get('luci', cmdid, 'public') == '1') + { + if (disp) + execute_command(return_html, cmdid, ...args); + else + this.action_download(cmdid, args); + } + else { + http.status(403, "Access to command denied"); + } + } +}; diff --git a/applications/luci-app-commands/ucode/template/commands.ut b/applications/luci-app-commands/ucode/template/commands.ut new file mode 100644 index 0000000000..8e5ce0b486 --- /dev/null +++ b/applications/luci-app-commands/ucode/template/commands.ut @@ -0,0 +1,179 @@ +{# + Copyright 2012-2022 Jo-Philipp Wich <jo@mein.io> + Licensed to the public under the Apache License 2.0. +-#} + +{% + include('header', { css: ` + .commands { + display: flex; + flex-wrap: wrap; + } + + .commandbox { + flex: 0 0 30%; + margin: .5em; + display: flex; + flex-direction: column; + } + + .commandbox > p, + .commandbox > p > * { + display: block; + } + + .commandbox div { + margin-top: auto; + } + ` }); +-%} + +<script type="text/javascript">//<![CDATA[ + var stxhr = new XHR(); + + function command_run(ev, id) + { + var args; + var field = document.getElementById(id); + if (field) + args = encodeURIComponent(field.value); + + var legend = document.getElementById('command-rc-legend'); + var output = document.getElementById('command-rc-output'); + + if (legend && output) + { + output.innerHTML = + '<img src="{{ resource }}/icons/loading.gif" alt="{{ _('Loading') }}" style="vertical-align:middle" /> ' + + '{{ _('Waiting for command to complete...') }}' + ; + + legend.parentNode.style.display = 'block'; + legend.style.display = 'inline'; + + stxhr.get('{{ dispatcher.build_url('admin/system/commands/run') }}/' + id + (args ? '?args=' + args : ''), null, + function(x, st) + { + if (st) + { + if (st.binary) + st.stdout = '[{{ _('Binary data not displayed, download instead.') }}]'; + + legend.style.display = 'none'; + output.innerHTML = String.format( + '<pre><strong># %h\n</strong>%h<span style="color:red">%h</span></pre>' + + '<div class="alert-message warning">%s ({{ _('Code:') }} %d)</div>', + st.command, st.stdout, st.stderr, + (st.exitcode == 0) ? '{{ _('Command successful') }}' : '{{ _('Command failed') }}', + st.exitcode); + } + else + { + legend.style.display = 'none'; + output.innerHTML = '<span class="error">{{ _('Failed to execute command!') }}</span>'; + } + + location.hash = '#output'; + } + ); + } + + ev.preventDefault(); + } + + function command_download(ev, id) + { + var args; + var field = document.getElementById(id); + if (field) + args = encodeURIComponent(field.value); + + location.href = '{{ dispatcher.build_url('admin/system/commands/download') }}/' + id + (args ? '/' + args : ''); + + ev.preventDefault(); + } + + function command_link(ev, id) + { + var legend = document.getElementById('command-rc-legend'); + var output = document.getElementById('command-rc-output'); + + var args; + var field = document.getElementById(id); + if (field) + args = encodeURIComponent(field.value); + + if (legend && output) + { + var prefix = location.protocol + '//' + location.host + '{{ dispatcher.build_url('command') }}/'; + var suffix = (args ? '?args=' + args : ''); + + var link = prefix + id + suffix; + var link_nodownload = prefix + id + "s" + suffix; + + legend.style.display = 'none'; + output.parentNode.style.display = 'block'; + output.innerHTML = String.format( + '<div class="alert-message"><p>{{ _('Download execution result') }} <a href="%s">%s</a></p><p>{{ _('Or display result') }} <a href="%s">%s</a></p></div>', + link, link, link_nodownload, link_nodownload + ); + + location.hash = '#output'; + } + + ev.preventDefault(); + } + +//]]></script> + +{% + const commands = []; + + uci.foreach('luci', 'command', s => push(commands, s)); +-%} + +<form method="get" action="{{ entityencode(FULL_REQUEST_URI) }}"> + <div class="cbi-map"> + <h2 name="content">{{ _('Custom Commands') }}</h2> + + {% if (length(commands) == 0): %} + <div class="cbi-section"> + <div class="table cbi-section-table"> + <div class="tr cbi-section-table-row"> + <p> + <em>{{ _('This section contains no values yet') }}</em> + </p> + </div> + </div> + </div> + {% else %} + <div class="commands"> + {% for (let command in commands): %} + <div class="commandbox"> + <h3>{{ entityencode(command.name) }}</h3> + <p>{{ _('Command:') }} <code>{{ entityencode(command.command) }}</code></p> + {% if (command.param == "1"): %} + <p>{{ _('Arguments:') }} <input type="text" id="{{ command['.name'] }}" /></p> + {% endif %} + <div> + <button class="cbi-button cbi-button-apply" onclick="command_run(event, '{{ command['.name'] }}')">{{ _('Run') }}</button> + <button class="cbi-button cbi-button-download" onclick="command_download(event, '{{ command['.name'] }}')">{{ _('Download') }}</button> + {% if (command.public == "1"): %} + <button class="cbi-button cbi-button-link" onclick="command_link(event, '{{ command['.name'] }}')">{{ _('Link') }}</button> + {% endif %} + </div> + </div> + {% endfor %} + + <a name="output"></a> + </div> + {% endif %} + </div> + + <fieldset class="cbi-section" style="display:none"> + <legend id="command-rc-legend">{{ _('Collecting data...') }}</legend> + <span id="command-rc-output"></span> + </fieldset> +</form> + +{% include('footer') %} diff --git a/applications/luci-app-commands/ucode/template/commands_public.ut b/applications/luci-app-commands/ucode/template/commands_public.ut new file mode 100644 index 0000000000..aef072f802 --- /dev/null +++ b/applications/luci-app-commands/ucode/template/commands_public.ut @@ -0,0 +1,48 @@ +{# + Copyright 2016 t123yh <t123yh@outlook.com> + Copyright 2022 Jo-Philipp Wich <jo@mein.io> + Licensed to the public under the Apache License 2.0. +-#} + +{% + include('header', { blank_page: true, css: ` + .alert-success { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; + } + + .alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; + } + + .alert-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; + } + ` }); +-%} + +<div class="alert alert-success" role="alert"> + {% if (exitcode == 0): %} + {{ _('Command executed successfully.') }} + {% else %} + {{ sprintf(_('Command exited with status code %d'), exitcode) }} + {% endif %} +</div> + +{% if (length(stdout)): %} + <h3>{{ _('Standard Output') }}</h3> + <pre>{{ entityencode(stdout) }}</pre> +{% endif %} + +{% if (length(stderr)): %} + <h3>{{ _('Standard Error') }}</h3> + <pre>{{ entityencode(stderr) }}</pre> +{% endif %} + +{% include('footer', { blank_page: true }) %} |