diff options
author | Jo-Philipp Wich <jo@mein.io> | 2022-08-29 17:09:22 +0200 |
---|---|---|
committer | Jo-Philipp Wich <jo@mein.io> | 2022-10-25 01:03:37 +0200 |
commit | ded8ccf93ec5163be35c41501869110e5dab30d1 (patch) | |
tree | 7802274633e7936d9cb6f7a5ce7914a748bd0913 /modules/luci-base-ucode/ucode | |
parent | f2133059e1d6695e6a1b572ae6aded9d7dd3db35 (diff) |
luci-base-ucode: add initial ucode based LuCI runtime
This commits introduces an initial ucode based LuCI runtime. It supports
JSON menu files as used by Lua based LuCI and the template, call, view and
alias dispatch targets.
It is able to render a basic LuCI installation without errors. An embedded
Lua VM is lazily loaded when Lua based resources are encountered, such as
`*.htm` templates or server side Lua call targets.
When a template is requested, the ucode runtime first tries to render an
`/usr/share/ucode/luci/template/${path}.uc` ucode template and falls back
to rendering the corresponding `/usr/lib/lua/luci/view/${path}.htm` Lua
template in case no suitable ucode replacement is found. This allows for
gradual migration of existing Lua based tmeplates to ucode.
Furthermore, a set of stripped down LuCI libraries is shipped in the
`/usr/lib/lua/luci/ucodebridge/` directory. Those libraries provide
compatibility shims for the current Lua API towards Lua templates and Lua
based server side actions while utilizing the ucode request runtime state
internally.
Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Diffstat (limited to 'modules/luci-base-ucode/ucode')
-rw-r--r-- | modules/luci-base-ucode/ucode/controller/admin/index.uc | 158 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/controller/admin/uci.uc | 150 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/dispatcher.uc | 942 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/http.uc | 574 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/runtime.uc | 163 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/sys.uc | 157 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/template/csrftoken.ut | 24 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/template/error404.ut | 14 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/template/error500.ut | 67 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/template/footer.ut | 23 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/template/header.ut | 32 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/template/sysauth.ut | 74 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/template/view.ut | 12 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/uhttpd.uc | 12 | ||||
-rw-r--r-- | modules/luci-base-ucode/ucode/zoneinfo.uc | 453 |
15 files changed, 2855 insertions, 0 deletions
diff --git a/modules/luci-base-ucode/ucode/controller/admin/index.uc b/modules/luci-base-ucode/ucode/controller/admin/index.uc new file mode 100644 index 0000000000..16a74abc46 --- /dev/null +++ b/modules/luci-base-ucode/ucode/controller/admin/index.uc @@ -0,0 +1,158 @@ +// Copyright 2022 Jo-Philipp Wich <jo@mein.io> +// Licensed to the public under the Apache License 2.0. + +import { load_catalog, change_catalog, get_translations } from 'luci.core'; + +const ubus_types = [ + null, + 'array', + 'object', + 'string', + null, // INT64 + 'number', + null, // INT16, + 'boolean', + 'double' +]; + + +function ubus_reply(id, data, code, errmsg) { + const reply = { jsonrpc: '2.0', id }; + + if (errmsg) + reply.error = { code, message: errmsg }; + else if (type(code) == 'object') + reply.result = code; + else + reply.result = [ code, data ]; + + return reply; +} + +function ubus_access(sid, obj, fun) { + return (ubus.call('session', 'access', { + ubus_rpc_session: sid, + scope: 'ubus', + object: obj, + function: fun + })?.access == true); +} + +function ubus_request(req) { + if (type(req?.method) != 'string' || req?.jsonrpc != '2.0' || req?.id == null) + return ubus_reply(null, null, -32600, 'Invalid request'); + + if (req.method == 'call') { + if (type(req?.params) != 'array' || length(req.params) < 3) + return ubus_reply(null, null, -32600, 'Invalid parameters'); + + let sid = req.params[0], + obj = req.params[1], + fun = req.params[2], + arg = req.params[3] ?? {}; + + if (type(arg) != 'object' || exists(arg, 'ubus_rpc_session')) + return ubus_reply(req.id, null, -32602, 'Invalid parameters'); + + if (sid == '00000000000000000000000000000000' && ctx.authsession) + sid = ctx.authsession; + + if (!ubus_access(sid, obj, fun)) + return ubus_reply(req.id, null, -32002, 'Access denied'); + + arg.ubus_rpc_session = sid; + + + // clear error + ubus.error(); + + const res = ubus.call(obj, fun, arg); + + return ubus_reply(req.id, res, ubus.error(true) ?? 0); + } + + if (req.method == 'list') { + if (req?.params == null || (type(req.params) == 'array' && length(req.params) == 0)) { + return ubus_reply(req.id, null, ubus.list()); + } + else if (type(req.params) == 'array') { + const rv = {}; + + for (let param in req.params) { + if (type(param) != 'string') + return ubus_reply(req.id, null, -32602, 'Invalid parameters'); + + for (let m, p in ubus.list(param)?.[0]) { + for (let pn, pt in p) { + rv[param] ??= {}; + rv[param][m] ??= {}; + rv[param][m][pn] = ubus_types[pt] ?? 'unknown'; + } + } + } + + return ubus_reply(req.id, null, rv); + } + else { + return ubus_reply(req.id, null, -32602, 'Invalid parameters') + } + } + + return ubus_reply(req.id, null, -32601, 'Method not found') +} + + +return { + action_ubus: function() { + let request; + + try { request = json(http.content()); } + catch { request = null; } + + http.prepare_content('application/json; charset=UTF-8'); + + if (type(request) == 'object') + http.write_json(ubus_request(request)); + else if (type(request) == 'array') + http.write_json(map(request, ubus_request)); + else + http.write_json(ubus_reply(null, null, -32700, 'Parse error')) + }, + + action_translations: function(reqlang) { + if (reqlang != null && reqlang != dispatcher.lang) { + load_catalog(reqlang, '/usr/lib/lua/luci/i18n'); + change_catalog(reqlang); + } + + http.prepare_content('application/javascript; charset=UTF-8'); + http.write('window.TR={'); + + get_translations((key, val) => http.write(sprintf('"%08x":%J,', key, val))); + + http.write('};'); + }, + + action_logout: function() { + const url = dispatcher.build_url(); + + if (ctx.authsession) { + ubus.call('session', 'destroy', { ubus_rpc_session: ctx.authsession }); + + if (http.getenv('HTTPS') == 'on') + http.header('Set-Cookie', `sysauth_https=; expires=Thu, 01 Jan 1970 01:00:00 GMT; path=${url}`); + + http.header('Set-Cookie', `sysauth_http=; expires=Thu, 01 Jan 1970 01:00:00 GMT; path=${url}`); + } + + http.redirect(url); + }, + + action_menu: function() { + const session = dispatcher.is_authenticated({ methods: [ 'cookie:sysauth_https', 'cookie:sysauth_http' ] }); + const menu = dispatcher.menu_json(session?.acls ?? {}) ?? {}; + + http.prepare_content('application/json; charset=UTF-8'); + http.write_json(menu); + } +}; diff --git a/modules/luci-base-ucode/ucode/controller/admin/uci.uc b/modules/luci-base-ucode/ucode/controller/admin/uci.uc new file mode 100644 index 0000000000..c38a42b10b --- /dev/null +++ b/modules/luci-base-ucode/ucode/controller/admin/uci.uc @@ -0,0 +1,150 @@ +// Copyright 2022 Jo-Philipp Wich <jo@mein.io> +// Licensed to the public under the Apache License 2.0. + +import { STATUS_NO_DATA, STATUS_PERMISSION_DENIED } from 'ubus'; + +let last_ubus_error; + +const ubus_error_map = [ + 200, 'OK', + 400, 'Invalid command', + 400, 'Invalid argument', + 404, 'Method not found', + 404, 'Not found', + 204, 'No data', + 403, 'Permission denied', + 504, 'Timeout', + 500, 'Not supported', + 500, 'Unknown error', + 503, 'Connection failed', + 500, 'Out of memory', + 400, 'Parse error', + 500, 'System error', +]; + +function ubus_call(object, method, args) { + ubus.error(); // clear previous error + + let res = ubus.call(object, method, args); + + last_ubus_error = ubus.error(true); + + return res ?? !last_ubus_error; +} + +function ubus_state_to_http(err) { + let code = ubus_error_map[(err << 1) + 0] ?? 200; + let msg = ubus_error_map[(err << 1) + 1] ?? 'OK'; + + http.status(code, msg); + + if (code != 204) { + http.prepare_content('text/plain'); + http.write(msg); + } +} + +function uci_apply(rollback) { + if (rollback) { + const timeout = +(config?.apply?.rollback ?? 90) || 0; + const success = ubus_call('uci', 'apply', { + ubus_rpc_session: ctx.authsession, + timeout: max(timeout, 90), + rollback: true + }); + + if (success) { + const token = dispatcher.randomid(16); + + ubus.call('session', 'set', { + ubus_rpc_session: '00000000000000000000000000000000', + values: { + rollback: { + token, + session: ctx.authsession, + timeout: time() + timeout + } + } + }); + + return token; + } + + return null; + } + else { + let changes = ubus_call('uci', 'changes', { ubus_rpc_session: ctx.authsession })?.changes; + + for (let config in changes) + if (!ubus_call('uci', 'commit', { ubus_rpc_session: ctx.authsession, config })) + return false; + + return ubus_call('uci', 'apply', { + ubus_rpc_session: ctx.authsession, + rollback: false + }); + } +} + +function uci_confirm(token) { + const data = ubus.call('session', 'get', { + ubus_rpc_session: '00000000000000000000000000000000', + keys: [ 'rollback' ] + })?.values?.rollback; + + if (type(data?.token) != 'string' || type(data?.session) != 'string' || + type(data?.timeout) != 'int' || data.timeout < time()) { + last_ubus_error = STATUS_NO_DATA; + + return false; + } + + if (token != data.token) { + last_ubus_error = STATUS_PERMISSION_DENIED; + + return false; + } + + if (!ubus_call('uci', 'confirm', { ubus_rpc_session: data.session })) + return false; + + ubus_call('session', 'set', { + ubus_rpc_session: '00000000000000000000000000000000', + values: { rollback: {} } + }); + + return true; +} + + +return { + action_apply_rollback: function() { + const token = uci_apply(true); + + if (token) { + http.prepare_content('application/json; charset=UTF-8'); + http.write_json({ token }); + } + else { + ubus_state_to_http(last_ubus_error); + } + }, + + action_apply_unchecked: function() { + uci_apply(false); + ubus_state_to_http(last_ubus_error); + }, + + action_confirm: function() { + uci_confirm(http.formvalue('token')); + ubus_state_to_http(last_ubus_error); + }, + + action_revert: function() { + for (let config in ubus_call('uci', 'changes', { ubus_rpc_session: ctx.authsession })?.changes) + if (!ubus_call('uci', 'revert', { ubus_rpc_session: ctx.authsession, config })) + break; + + ubus_state_to_http(last_ubus_error); + } +}; diff --git a/modules/luci-base-ucode/ucode/dispatcher.uc b/modules/luci-base-ucode/ucode/dispatcher.uc new file mode 100644 index 0000000000..84eff71d3a --- /dev/null +++ b/modules/luci-base-ucode/ucode/dispatcher.uc @@ -0,0 +1,942 @@ +// Copyright 2022 Jo-Philipp Wich <jo@mein.io> +// Licensed to the public under the Apache License 2.0. + +import { open, stat, glob, lsdir, unlink, basename } from 'fs'; +import { striptags, entityencode } from 'html'; +import { connect } from 'ubus'; +import { cursor } from 'uci'; +import { rand } from 'math'; + +import { hash, load_catalog, change_catalog, translate, ntranslate, getuid } from 'luci.core'; +import { revision as luciversion, branch as luciname } from 'luci.version'; +import { default as LuCIRuntime } from 'luci.runtime'; +import { urldecode } from 'luci.http'; + +let ubus = connect(); +let uci = cursor(); + +let indexcache = "/tmp/luci-indexcache"; + +let http, runtime, tree, luabridge; + +function error404(msg) { + http.status(404, 'Not Found'); + + try { + runtime.render('error404', { message: msg ?? 'Not found' }); + } + catch { + http.header('Content-Type', 'text/plain; charset=UTF-8'); + http.write(msg ?? 'Not found'); + } + + return false; +} + +function error500(msg, ex) { + if (!http.eoh) { + http.status(500, 'Internal Server Error'); + http.header('Content-Type', 'text/html; charset=UTF-8'); + } + + try { + runtime.render('error500', { + title: ex?.type ?? 'Runtime exception', + message: replace( + msg, + /(\s)((\/[A-Za-z0-9_.-]+)+:\d+|\[string "[^"]+"\]:\d+)/g, + '$1<code>$2</code>' + ), + exception: ex + }); + } + catch { + http.write('<!--]]>--><!--\'>--><!--">-->\n'); + http.write(`<p>${trim(ex)}</p>\n`); + + if (ex) { + http.write(`<p>${trim(ex.message)}</p>\n`); + http.write(`<pre>${trim(ex.stacktrace[0].context)}</pre>\n`); + } + } + + exit(0); +} + +function load_luabridge(optional) { + if (luabridge == null) { + try { + luabridge = require('lua'); + } + catch (ex) { + luabridge = false; + + if (!optional) + error500('No Lua runtime installed'); + } + } + + return luabridge; +} + +function determine_request_language() { + let lang = uci.get('luci', 'main', 'lang') || 'auto'; + + if (lang == 'auto') { + for (let tag in split(http.getenv('HTTP_ACCEPT_LANGUAGE'), ',')) { + tag = split(trim(split(tag, ';')?.[0]), '-'); + + if (tag) { + let cc = tag[1] ? `${tag[0]}_${lc(tag[1])}` : null; + + if (cc && uci.get('luci', 'languages', cc)) { + lang = cc; + break; + } + else if (uci.get('luci', 'languages', tag[0])) { + lang = tag[0]; + break; + } + } + } + } + + if (lang == 'auto') + lang = 'en'; + + if (load_catalog(lang, '/usr/lib/lua/luci/i18n')) + change_catalog(lang); + + return lang; +} + +function determine_version() { + let res = { luciname, luciversion }; + + for (let f = open("/etc/os-release"), l = f?.read?.("line"); l; l = f.read?.("line")) { + let kv = split(l, '=', 2); + + switch (kv[0]) { + case 'NAME': + res.distname = trim(kv[1], '"\' \n'); + break; + + case 'VERSION': + res.distversion = trim(kv[1], '"\' \n'); + break; + + case 'HOME_URL': + res.disturl = trim(kv[1], '"\' \n'); + break; + + case 'BUILD_ID': + res.distrevision = trim(kv[1], '"\' \n'); + break; + } + } + + return res; +} + +function read_jsonfile(path, defval) { + let rv; + + try { + rv = json(open(path, "r")); + } + catch (e) { + rv = defval; + } + + return rv; +} + +function read_cachefile(file, reader) { + let euid = getuid(), + fstat = stat(file), + fuid = fstat?.uid, + perm = fstat?.perm; + + if (euid != fuid || + perm?.group_read || perm?.group_write || perm?.group_exec || + perm?.other_read || perm?.other_write || perm?.other_exec) + return null; + + return reader(file); +} + +function check_fs_depends(spec) { + for (let path, kind in spec) { + if (kind == 'directory') { + if (!length(lsdir(path))) + return false; + } + else if (kind == 'executable') { + let fstat = stat(path); + + if (fstat?.type != 'file' || fstat?.user_exec == false) + return false; + } + else if (kind == 'file') { + let fstat = stat(path); + + if (fstat?.type != 'file') + return false; + } + } + + return true; +} + +function check_uci_depends_options(conf, s, opts) { + if (type(opts) == 'string') { + return (s['.type'] == opts); + } + else if (opts === true) { + for (let option, value in s) + if (ord(option) != 46) + return true; + } + else if (type(opts) == 'object') { + for (let option, value in opts) { + let sval = s[option]; + + if (type(sval) == 'array') { + if (!(value in sval)) + return false; + } + else if (value === true) { + if (sval == null) + return false; + } + else { + if (sval != value) + return false; + } + } + } + + return true; +} + +function check_uci_depends_section(conf, sect) { + for (let section, options in sect) { + let stype = match(section, /^@([A-Za-z0-9_-]+)$/); + + if (stype) { + let found = false; + + uci.load(conf); + uci.foreach(conf, stype[1], (s) => { + if (check_uci_depends_options(conf, s, options)) { + found = true; + return false; + } + }); + + if (!found) + return false; + } + else { + let s = uci.get_all(conf, section); + + if (!s || !check_uci_depends_options(conf, s, options)) + return false; + } + } + + return true; +} + +function check_uci_depends(conf) { + for (let config, values in conf) { + if (values == true) { + let found = false; + + uci.load(config); + uci.foreach(config, null, () => { found = true }); + + if (!found) + return false; + } + else if (type(values) == 'object') { + if (!check_uci_depends_section(config, values)) + return false; + } + } + + return true; +} + +function check_depends(spec) { + if (type(spec?.depends?.fs) in ['array', 'object']) { + let satisfied = false; + let alternatives = (type(spec.depends.fs) == 'array') ? spec.depends.fs : [ spec.depends.fs ]; + + for (let alternative in alternatives) { + if (check_fs_depends(alternative)) { + satisfied = true; + break; + } + } + + if (!satisfied) + return false; + } + + if (type(spec?.depends?.uci) in ['array', 'object']) { + let satisfied = false; + let alternatives = (type(spec.depends.uci) == 'array') ? spec.depends.uci : [ spec.depends.uci ]; + + for (let alternative in alternatives) { + if (check_uci_depends(alternative)) { + satisfied = true; + break; + } + } + + if (!satisfied) + return false; + } + + return true; +} + +function check_acl_depends(require_groups, groups) { + if (length(require_groups)) { + let writable = false; + + for (let group in require_groups) { + let read = ('read' in groups?.[group]); + let write = ('write' in groups?.[group]); + + if (!read && !write) + return null; + + if (write) + writable = true; + } + + return writable; + } + + return true; +} + +function hash_filelist(files) { + let hashval = 0x1b756362; + + for (let file in files) { + let st = stat(file); + + if (st) + hashval = hash(sprintf("%x|%x|%x", st.ino, st.mtime, st.size), hashval); + } + + return hashval; +} + +function build_pagetree() { + let tree = { action: { type: 'firstchild' } }; + + let schema = { + action: 'object', + auth: 'object', + cors: 'bool', + depends: 'object', + order: 'int', + setgroup: 'string', + setuser: 'string', + title: 'string', + wildcard: 'bool', + firstchild_ineligible: 'bool' + }; + + let files = glob('/usr/share/luci/menu.d/*.json', '/usr/lib/lua/luci/controller/*.lua', '/usr/lib/lua/luci/controller/*/*.lua'); + let cachefile; + + if (indexcache) { + cachefile = sprintf('%s.%08x.json', indexcache, hash_filelist(files)); + + let res = read_cachefile(cachefile, read_jsonfile); + + if (res) + return res; + + for (let path in glob(indexcache + '.*.json')) + unlink(path); + } + + for (let file in files) { + let data; + + if (substr(file, -5) == '.json') + data = read_jsonfile(file); + else if (load_luabridge(true)) + data = runtime.call('luci.dispatcher', 'process_lua_controller', file); + else + warn(`Lua controller ${file} present but no Lua runtime installed.\n`); + + if (type(data) == 'object') { + for (let path, spec in data) { + if (type(spec) == 'object') { + let node = tree; + + for (let s in match(path, /[^\/]+/g)) { + if (s[0] == '*') { + node.wildcard = true; + break; + } + + node.children ??= {}; + node.children[s[0]] ??= {}; + node = node.children[s[0]]; + } + + if (node !== tree) { + for (let k, t in schema) + if (type(spec[k]) == t) + node[k] = spec[k]; + + node.satisfied = check_depends(spec); + } + } + } + } + } + + if (cachefile) { + let fd = open(cachefile, 'w', 0600); + + if (fd) { + fd.write(tree); + fd.close(); + } + } + + return tree; +} + +function menu_json(acl) { + tree ??= build_pagetree(); + + return tree; +} + +function ctx_append(ctx, name, node) { + ctx.path ??= []; + push(ctx.path, name); + + ctx.acls ??= []; + push(ctx.acls, ...(node?.depends?.acl || [])); + + ctx.auth = node.auth || ctx.auth; + ctx.cors = node.cors || ctx.cors; + ctx.suid = node.setuser || ctx.suid; + ctx.sgid = node.setgroup || ctx.sgid; + + return ctx; +} + +function session_retrieve(sid, allowed_users) { + let sdat = ubus.call("session", "get", { ubus_rpc_session: sid }); + let sacl = ubus.call("session", "access", { ubus_rpc_session: sid }); + + if (type(sdat?.values?.token) == 'string' && + (!length(allowed_users) || sdat?.values?.username in allowed_users)) { + // uci:set_session_id(sid) + return { + sid, + data: sdat.values, + acls: length(sacl) ? sacl : {} + }; + } + + return null; +} + +function randomid(num_bytes) { + let bytes = []; + + while (num_bytes-- > 0) + push(bytes, sprintf('%02x', rand() % 256)); + + return join('', bytes); +} + +function syslog(prio, msg) { + warn(sprintf("[%s] %s\n", prio, msg)); +} + +function session_setup(user, pass, path) { + let timeout = uci.get('luci', 'sauth', 'sessiontime'); + let login = ubus.call("session", "login", { + username: user, + password: pass, + timeout: timeout ? +timeout : null + }); + + if (type(login?.ubus_rpc_session) == 'string') { + ubus.call("session", "set", { + ubus_rpc_session: login.ubus_rpc_session, + values: { token: randomid(16) } + }); + syslog("info", sprintf("luci: accepted login on /%s for %s from %s", + join('/', path), user || "?", http.getenv("REMOTE_ADDR") || "?")); + + return session_retrieve(login.ubus_rpc_session); + } + + syslog("info", sprintf("luci: failed login on /%s for %s from %s", + join('/', path), user || "?", http.getenv("REMOTE_ADDR") || "?")); +} + +function check_authentication(method) { + let m = match(method, /^([[:alpha:]]+):(.+)$/); + let sid; + + switch (m?.[1]) { + case 'cookie': + sid = http.getcookie(m[2]); + break; + + case 'param': + sid = http.formvalue(m[2]); + break; + + case 'query': + sid = http.formvalue(m[2], true); + break; + } + + return sid ? session_retrieve(sid) : null; +} + +function is_authenticated(auth) { + for (let method in auth?.methods) { + let session = check_authentication(method); + + if (session) + return session; + } + + return null; +} + +function node_weight(node) { + let weight = min(node.order ?? 9999, 9999); + + if (node.auth?.login) + weight += 10000; + + return weight; +} + +function clone(src) { + switch (type(src)) { + case 'array': + return map(src, clone); + + case 'object': + let dest = {}; + + for (let k, v in src) + dest[k] = clone(v); + + return dest; + + default: + return src; + } +} + +function resolve_firstchild(node, session, login_allowed, ctx) { + let candidate, candidate_ctx; + + for (let name, child in node.children) { + if (!child.satisfied) + continue; + + if (!session) + session = is_authenticated(node.auth); + + let cacl = child.depends?.acl; + let login = login_allowed || child.auth?.login; + + if (login || check_acl_depends(cacl, session?.acls?.["access-group"]) != null) { + if (child.title && type(child.action) == "object") { + let child_ctx = ctx_append(clone(ctx), name, child); + if (child.action.type == "firstchild") { + if (!candidate || node_weight(candidate) > node_weight(child)) { + let have_grandchild = resolve_firstchild(child, session, login, child_ctx); + if (have_grandchild) { + candidate = child; + candidate_ctx = child_ctx; + } + } + } + else if (!child.firstchild_ineligible) { + if (!candidate || node_weight(candidate) > node_weight(child)) { + candidate = child; + candidate_ctx = child_ctx; + } + } + } + } + } + + if (!candidate) + return false; + + for (let k, v in candidate_ctx) + ctx[k] = v; + + return true; +} + +function resolve_page(tree, request_path) { + let node = tree; + let login = false; + let session = null; + let ctx = {}; + + for (let i, s in request_path) { + node = node.children?.[s]; + + if (!node?.satisfied) + break; + + ctx_append(ctx, s, node); + + if (!session) + session = is_authenticated(node.auth); + + if (!login && node.auth?.login) + login = true; + + if (node.wildcard) { + ctx.request_args = []; + ctx.request_path = ctx.path ? [ ...ctx.path ] : []; + + while (++i < length(request_path)) { + push(ctx.request_path, request_path[i]); + push(ctx.request_args, request_path[i]); + } + + break; + } + } + + if (node?.action?.type == 'firstchild') + resolve_firstchild(node, session, login, ctx); + + ctx.acls ??= {}; + ctx.path ??= []; + ctx.request_args ??= []; + ctx.request_path ??= request_path ? [ ...request_path ] : []; + + ctx.authsession = session?.sid; + ctx.authtoken = session?.data?.token; + ctx.authuser = session?.data?.username; + ctx.authacl = session?.acls; + + node = tree; + + for (let s in ctx.path) { + node = node.children[s]; + assert(node, "Internal node resolve error"); + } + + return { node, ctx, session }; +} + +function require_post_security(target, args) { + if (target?.type == 'arcombine') + return require_post_security(length(args) ? target?.targets?.[1] : target?.targets?.[0], args); + + if (type(target?.post) == 'object') { + for (let param_name, required_val in target.post) { + let request_val = http.formvalue(param_name); + + if ((type(required_val) == 'string' && request_val != required_val) || + (required_val == true && request_val == null)) + return false; + } + + return true; + } + + return (target?.post == true); +} + +function test_post_security(authtoken) { + if (http.getenv("REQUEST_METHOD") != "POST") { + http.status(405, "Method Not Allowed"); + http.header("Allow", "POST"); + + return false; + } + + if (http.formvalue("token") != authtoken) { + http.status(403, "Forbidden"); + runtime.render("csrftoken"); + + return false; + } + + return true; +} + +function build_url(...path) { + let url = [ http.getenv('SCRIPT_NAME') ?? '' ]; + + for (let p in path) + if (match(p, /^[A-Za-z0-9_%.\/,;-]+$/)) + push(url, '/', p); + + if (length(url) == 1) + push(url, '/'); + + return join('', url); +} + +function lookup(...segments) { + let node = menu_json(); + let path = []; + + for (let segment in segments) + for (let name in split(segment, '/')) + push(path, name); + + for (let name in path) { + node = node.children[name]; + + if (!node) + return null; + + if (node.leaf) + break; + } + + return { node, url: build_url(...path) }; +} + +function rollback_pending() { + const now = time(); + const rv = ubus.call('session', 'get', { + ubus_rpc_session: '00000000000000000000000000000000', + keys: [ 'rollback' ] + }); + + if (type(rv?.values?.rollback?.token) != 'string' || + type(rv?.values?.rollback?.session) != 'string' || + type(rv?.values?.rollback?.timeout) != 'int' || + rv.values.rollback.timeout <= now) + return false; + + return { + remaining: rv.values.rollback.timeout - now, + session: rv.values.rollback.session, + token: rv.values.rollback.token + }; +} + +let dispatch; + +function run_action(request_path, lang, tree, resolved, action) { + switch (action?.type) { + case 'template': + runtime.render(action.path, {}); + break; + + case 'view': + runtime.render('view', { view: action.path }); + break; + + case 'call': + http.write(render(() => { + runtime.call(action.module, action.function, + ...(action.parameters ?? []), + ...resolved.ctx.request_args + ); + })); + break; + + case 'function': + const mod = require(action.module); + + assert(type(mod[action.function]) == 'function', + `Module '${action.module}' does not export function '${action.function}'`); + + http.write(render(() => { + call(mod[action.function], mod, runtime.env, + ...(action.parameters ?? []), + ...resolved.ctx.request_args + ); + })); + break; + + case 'alias': + dispatch(http, [ ...split(action.path, '/'), ...resolved.ctx.request_args ]); + break; + + case 'rewrite': + dispatch(http, [ + ...splice([ ...request_path ], 0, action.remove), + ...split(action.path, '/'), + ...resolved.ctx.request_args + ]); + break; + + case 'firstchild': + if (!length(tree.children)) + error404("No root node was registered, this usually happens if no module was installed.\n" + + "Install luci-mod-admin-full and retry. " + + "If the module is already installed, try removing the /tmp/luci-indexcache file."); + else + error404(`No page is registered at '/${join("/", resolved.ctx.request_path)}'.\n` + + "If this url belongs to an extension, make sure it is properly installed.\n" + + "If the extension was recently installed, try removing the /tmp/luci-indexcache file."); + break; + + default: + error500(`Unhandled action type ${action?.type ?? '?'}`); + } +} + +dispatch = function(_http, path) { + http = _http; + + let version = determine_version(); + let lang = determine_request_language(); + + runtime = LuCIRuntime({ + http, + ubus, + uci, + ctx: {}, + version, + config: { + main: uci.get_all('luci', 'main') ?? {}, + apply: uci.get_all('luci', 'apply') ?? {} + }, + dispatcher: { + rollback_pending, + is_authenticated, + load_luabridge, + lookup, + menu_json, + build_url, + randomid, + error404, + error500, + lang + }, + striptags, + entityencode, + _: (...args) => translate(...args) ?? args[0], + N_: (...args) => ntranslate(...args) ?? (n[0] == 1 ? n[1] : n[2]), + }); + + try { + let menu = menu_json(); + + path ??= map(match(http.getenv('PATH_INFO'), /[^\/]+/g), m => m[0]); + + let resolved = resolve_page(menu, path); + + runtime.env.ctx = resolved.ctx; + runtime.env.node = resolved.node; + + if (length(resolved.ctx.auth)) { + let session = is_authenticated(resolved.ctx.auth); + + if (!session && resolved.ctx.auth.login) { + let user = http.getenv('HTTP_AUTH_USER'); + let pass = http.getenv('HTTP_AUTH_PASS'); + + if (user == null && pass == null) { + user = http.formvalue('luci_username'); + pass = http.formvalue('luci_password'); + } + + if (user != null && pass != null) + session = session_setup(user, pass, resolved.ctx.request_path); + + if (!session) { + resolved.ctx.path = []; + + http.status(403, 'Forbidden'); + http.header('X-LuCI-Login-Required', 'yes'); + + let scope = { duser: 'root', fuser: user }; + + try { + runtime.render(`themes/${basename(runtime.env.media)}/sysauth`, scope); + } + catch (e) { + runtime.render('sysauth', scope); + } + + return; + } + + let cookie_name = (http.getenv('HTTPS') == 'on') ? 'sysauth_https' : 'sysauth_http', + cookie_secure = (http.getenv('HTTPS') == 'on') ? '; secure' : ''; + + http.header('Set-Cookie', `${cookie_name}=${session.sid}; path=${build_url()}; SameSite=strict; HttpOnly${cookie_secure}`); + http.redirect(build_url(...resolved.ctx.request_path)); + + return; + } + + if (!session) { + http.status(403, 'Forbidden'); + http.header('X-LuCI-Login-Required', 'yes'); + + return; + } + + resolved.ctx.authsession ??= session.sid; + resolved.ctx.authtoken ??= session.data?.token; + resolved.ctx.authuser ??= session.data?.username; + resolved.ctx.authacl ??= session.acls; + } + + if (length(resolved.ctx.acls)) { + let perm = check_acl_depends(resolved.ctx.acls, resolved.ctx.authacl?.['access-group']); + + if (perm == null) { + http.status(403, 'Forbidden'); + + return; + } + + if (resolved.node) + resolved.node.readonly = !perm; + } + + let action = resolved.node.action; + + if (action?.type == 'arcombine') + action = length(resolved.ctx.request_args) ? action.targets?.[1] : action.targets?.[0]; + + if (resolved.ctx.cors && http.getenv('REQUEST_METHOD') == 'OPTIONS') { + http.status(200, 'OK'); + http.header('Access-Control-Allow-Origin', http.getenv('HTTP_ORIGIN') ?? '*'); + http.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + + return; + } + + if (require_post_security(action) && !test_post_security(resolved.ctx.authtoken)) + return; + + run_action(path, lang, menu, resolved, action); + } + catch (ex) { + error500('Unhandled exception during request dispatching', ex); + } +}; + +export default dispatch; diff --git a/modules/luci-base-ucode/ucode/http.uc b/modules/luci-base-ucode/ucode/http.uc new file mode 100644 index 0000000000..b464497eac --- /dev/null +++ b/modules/luci-base-ucode/ucode/http.uc @@ -0,0 +1,574 @@ +// Copyright 2022 Jo-Philipp Wich <jo@mein.io> +// Licensed to the public under the Apache License 2.0. + +import { + urlencode as _urlencode, + urldecode as _urldecode, + urlencoded_parser, multipart_parser, header_attribute, + ENCODE_IF_NEEDED, ENCODE_FULL, DECODE_IF_NEEDED, DECODE_PLUS +} from 'lucihttp'; + +import { + error as fserror, + stdin, stdout, mkstemp +} from 'fs'; + +// luci.http module scope +export let HTTP_MAX_CONTENT = 1024*100; // 100 kB maximum content size + +// Decode a mime encoded http message body with multipart/form-data +// Content-Type. Stores all extracted data associated with its parameter name +// in the params table within the given message object. Multiple parameter +// values are stored as tables, ordinary ones as strings. +// If an optional file callback function is given then it is fed with the +// file contents chunk by chunk and only the extracted file name is stored +// within the params table. The callback function will be called subsequently +// with three arguments: +// o Table containing decoded (name, file) and raw (headers) mime header data +// o String value containing a chunk of the file data +// o Boolean which indicates whether the current chunk is the last one (eof) +export function mimedecode_message_body(src, msg, file_cb) { + let len = 0, maxlen = +msg.env.CONTENT_LENGTH; + let header, field, parser; + + parser = multipart_parser(msg.env.CONTENT_TYPE, function(what, buffer, length) { + if (what == parser.PART_INIT) { + field = {}; + } + else if (what == parser.HEADER_NAME) { + header = lc(buffer); + } + else if (what == parser.HEADER_VALUE && header) { + if (lc(header) == 'content-disposition' && + header_attribute(buffer, null) == 'form-data') { + field.name = header_attribute(buffer, 'name'); + field.file = header_attribute(buffer, 'filename'); + field[1] = field.file; + } + + field.headers = field.headers || {}; + field.headers[header] = buffer; + } + else if (what == parser.PART_BEGIN) { + return !field.file; + } + else if (what == parser.PART_DATA && field.name && length > 0) { + if (field.file) { + if (file_cb) { + file_cb(field, buffer, false); + + msg.params[field.name] = msg.params[field.name] || field; + } + else { + if (!field.fd) + field.fd = mkstemp(field.name); + + if (field.fd) { + field.fd.write(buffer); + msg.params[field.name] = msg.params[field.name] || field; + } + } + } + else { + field.value = buffer; + } + } + else if (what == parser.PART_END && field.name) { + if (field.file && msg.params[field.name]) { + if (file_cb) + file_cb(field, '', true); + else if (field.fd) + field.fd.seek(0); + } + else { + let val = msg.params[field.name]; + + if (type(val) == 'array') + push(val, field.value || ''); + else if (val != null) + msg.params[field.name] = [ val, field.value || '' ]; + else + msg.params[field.name] = field.value || ''; + } + + field = null; + } + else if (what == parser.ERROR) { + err = buffer; + } + + return true; + }, HTTP_MAX_CONTENT); + + while (true) { + let chunk = src(); + + len += length(chunk); + + if (maxlen && len > maxlen + 2) + die('Message body size exceeds Content-Length'); + + if (!parser.parse(chunk)) + die(err); + + if (chunk == null) + break; + } +}; + +// Decode an urlencoded http message body with application/x-www-urlencoded +// Content-Type. Stores all extracted data associated with its parameter name +// in the params table within the given message object. Multiple parameter +// values are stored as tables, ordinary ones as strings. +export function urldecode_message_body(src, msg) { + let len = 0, maxlen = +msg.env.CONTENT_LENGTH; + let err, name, value, parser; + + parser = urlencoded_parser(function (what, buffer, length) { + if (what == parser.TUPLE) { + name = null; + value = null; + } + else if (what == parser.NAME) { + name = _urldecode(buffer, DECODE_PLUS); + } + else if (what == parser.VALUE && name) { + let val = msg.params[name]; + + if (type(val) == 'array') + push(val, _urldecode(buffer, DECODE_PLUS) || ''); + else if (val != null) + msg.params[name] = [ val, _urldecode(buffer, DECODE_PLUS) || '' ]; + else + msg.params[name] = _urldecode(buffer, DECODE_PLUS) || ''; + } + else if (what == parser.ERROR) { + err = buffer; + } + + return true; + }, HTTP_MAX_CONTENT); + + while (true) { + let chunk = src(); + + len += length(chunk); + + if (maxlen && len > maxlen + 2) + die('Message body size exceeds Content-Length'); + + if (!parser.parse(chunk)) + die(err); + + if (chunk == null) + break; + } +}; + +// This function will examine the Content-Type within the given message object +// to select the appropriate content decoder. +// Currently the application/x-www-urlencoded and application/form-data +// mime types are supported. If the encountered content encoding can't be +// handled then the whole message body will be stored unaltered as 'content' +// property within the given message object. +export function parse_message_body(src, msg, filecb) { + if (msg.env.CONTENT_LENGTH || msg.env.REQUEST_METHOD == 'POST') { + let ctype = header_attribute(msg.env.CONTENT_TYPE, null); + + // Is it multipart/mime ? + if (ctype == 'multipart/form-data') + return mimedecode_message_body(src, msg, filecb); + + // Is it application/x-www-form-urlencoded ? + else if (ctype == 'application/x-www-form-urlencoded') + return urldecode_message_body(src, msg); + + // Unhandled encoding + // If a file callback is given then feed it chunk by chunk, else + // store whole buffer in message.content + let sink; + + // If we have a file callback then feed it + if (type(filecb) == 'function') { + let meta = { + name: 'raw', + encoding: msg.env.CONTENT_TYPE + }; + + sink = (chunk) => { + if (chunk != null) + return filecb(meta, chunk, false); + else + return filecb(meta, null, true); + }; + } + + // ... else append to .content + else { + let chunks = [], len = 0; + + sink = (chunk) => { + len += length(chunk); + + if (len > HTTP_MAX_CONTENT) + die('POST data exceeds maximum allowed length'); + + if (chunk != null) { + push(chunks, chunk); + } + else { + msg.content = join('', chunks); + msg.content_length = len; + } + }; + } + + // Pump data... + while (true) { + let chunk = src(); + + sink(chunk); + + if (chunk == null) + break; + } + + return true; + } + + return false; +}; + +export function build_querystring(q) { + let s = []; + + for (let k, v in q) { + push(s, + length(s) ? '&' : '?', + _urlencode(k, ENCODE_IF_NEEDED | ENCODE_FULL) || k, + '=', + _urlencode(v, ENCODE_IF_NEEDED | ENCODE_FULL) || v + ); + } + + return join('', s); +}; + +export function urlencode(value) { + if (value == null) + return null; + + value = '' + value; + + return _urlencode(value, ENCODE_IF_NEEDED | ENCODE_FULL) || value; +}; + +export function urldecode(value, decode_plus) { + if (value == null) + return null; + + value = '' + value; + + return _urldecode(value, DECODE_IF_NEEDED | (decode_plus ? DECODE_PLUS : 0)) || value; +}; + +// Extract and split urlencoded data pairs, separated bei either "&" or ";" +// from given url or string. Returns a table with urldecoded values. +// Simple parameters are stored as string values associated with the parameter +// name within the table. Parameters with multiple values are stored as array +// containing the corresponding values. +export function urldecode_params(url, tbl) { + let parser, name, value; + let params = tbl || {}; + + parser = urlencoded_parser(function(what, buffer, length) { + if (what == parser.TUPLE) { + name = null; + value = null; + } + else if (what == parser.NAME) { + name = _urldecode(buffer); + } + else if (what == parser.VALUE && name) { + params[name] = _urldecode(buffer) || ''; + } + + return true; + }); + + if (parser) { + let m = match(('' + (url || '')), /[^?]*$/); + + parser.parse(m ? m[0] : ''); + parser.parse(null); + } + + return params; +}; + +// Encode each key-value-pair in given table to x-www-urlencoded format, +// separated by '&'. Tables are encoded as parameters with multiple values by +// repeating the parameter name with each value. +export function urlencode_params(tbl) { + let enc = []; + + for (let k, v in tbl) { + if (type(v) == 'array') { + for (let v2 in v) { + if (length(enc)) + push(enc, '&'); + + push(enc, + _urlencode(k), + '=', + _urlencode('' + v2)); + } + } + else { + if (length(enc)) + push(enc, '&'); + + push(enc, + _urlencode(k), + '=', + _urlencode('' + v)); + } + } + + return join(enc, ''); +}; + + +// Default IO routines suitable for CGI invocation +let avail_len = +getenv('CONTENT_LENGTH'); + +const default_source = () => { + let rlen = min(avail_len, 4096); + + if (rlen == 0) { + stdin.close(); + + return null; + } + + let chunk = stdin.read(rlen); + + if (chunk == null) + die(`Input read error: ${fserror()}`); + + avail_len -= length(chunk); + + return chunk; +}; + +const default_sink = (...chunks) => { + for (let chunk in chunks) + stdout.write(chunk); + + stdout.flush(); +}; + +const Class = { + formvalue: function(name, noparse) { + if (!noparse && !this.parsed_input) + this._parse_input(); + + if (name != null) + return this.message.params[name]; + else + return this.message.params; + }, + + formvaluetable: function(prefix) { + let vals = {}; + + prefix = (prefix || '') + '.'; + + if (!this.parsed_input) + this._parse_input(); + + for (let k, v in this.message.params) + if (index(k, prefix) == 0) + vals[substr(k, length(prefix))] = '' + v; + + return vals; + }, + + content: function() { + if (!this.parsed_input) + this._parse_input(); + + return this.message.content; + }, + + getcookie: function(name) { + return header_attribute(`cookie; ${this.getenv('HTTP_COOKIE') ?? ''}`, name); + }, + + getenv: function(name) { + if (name != null) + return this.message.env[name]; + else + return this.message.env; + }, + + setfilehandler: function(callback) { + if (type(callback) == 'resource' && type(callback.call) == 'function') + this.filehandler = (...args) => callback.call(...args); + else if (type(callback) == 'function') + this.filehandler = callback; + else + die('Invalid callback argument for setfilehandler()'); + + if (!this.parsed_input) + return; + + // If input has already been parsed then uploads are stored as unlinked + // temporary files pointed to by open file handles in the parameter + // value table. Loop all params, and invoke the file callback for any + // param with an open file handle. + for (let name, value in this.message.params) { + while (value?.fd) { + let data = value.fd.read(1024); + let eof = (data == null || data == ''); + + callback(value, data, eof); + + if (eof) { + value.fd.close(); + value.fd = null; + } + } + } + }, + + _parse_input: function() { + parse_message_body( + this.input, + this.message, + this.filehandler + ); + + this.parsed_input = true; + }, + + close: function() { + this.write_headers(); + this.closed = true; + }, + + header: function(key, value) { + this.headers ??= {}; + this.headers[lc(key)] = value; + }, + + prepare_content: function(mime) { + if (!this.headers?.['content-type']) { + if (mime == 'application/xhtml+xml') { + if (index(this.getenv('HTTP_ACCEPT'), mime) == -1) { + mime = 'text/html; charset=UTF-8'; + this.header('Vary', 'Accept'); + } + } + + this.header('Content-Type', mime); + } + }, + + status: function(code, message) { + this.status_code = code ?? 200; + this.status_message = message ?? 'OK'; + }, + + write_headers: function() { + if (this.eoh) + return; + + if (!this.status_code) + this.status(); + + if (!this.headers?.['content-type']) + this.header('Content-Type', 'text/html; charset=UTF-8'); + + if (!this.headers?.['cache-control']) { + this.header('Cache-Control', 'no-cache'); + this.header('Expires', '0'); + } + + if (!this.headers?.['x-frame-options']) + this.header('X-Frame-Options', 'SAMEORIGIN'); + + if (!this.headers?.['x-xss-protection']) + this.header('X-XSS-Protection', '1; mode=block'); + + if (!this.headers?.['x-content-type-options']) + this.header('X-Content-Type-Options', 'nosniff'); + + this.output('Status: '); + this.output(this.status_code); + this.output(' '); + this.output(this.status_message); + this.output('\r\n'); + + for (let k, v in this.headers) { + this.output(k); + this.output(': '); + this.output(v); + this.output('\r\n'); + } + + this.output('\r\n'); + + this.eoh = true; + }, + + // If the content chunk is nil this function will automatically invoke close. + write: function(content) { + if (content != null) { + this.write_headers(); + this.output(content); + + return true; + } + else { + this.close(); + } + }, + + redirect: function(url) { + this.status(302, 'Found'); + this.header('Location', url ?? '/'); + this.close(); + }, + + write_json: function(value) { + this.write(sprintf('%.J', value)); + }, + + urlencode, + urlencode_params, + + urldecode, + urldecode_params, + + build_querystring +}; + +export default function(env, sourcein, sinkout) { + return proto({ + input: sourcein ?? default_source, + output: sinkout ?? default_sink, + + // File handler nil by default to let .content() work + file: null, + + // HTTP-Message table + message: { + env, + headers: {}, + params: urldecode_params(env?.QUERY_STRING ?? '') + }, + + parsed_input: false + }, Class); +}; diff --git a/modules/luci-base-ucode/ucode/runtime.uc b/modules/luci-base-ucode/ucode/runtime.uc new file mode 100644 index 0000000000..ee0756efc3 --- /dev/null +++ b/modules/luci-base-ucode/ucode/runtime.uc @@ -0,0 +1,163 @@ +// Copyright 2022 Jo-Philipp Wich <jo@mein.io> +// Licensed to the public under the Apache License 2.0. + +import { access, basename } from 'fs'; +import { cursor } from 'uci'; + +const template_directory = '/usr/share/ucode/luci/template'; + +function cut_message(msg) { + return trim(replace(msg, /\n--\n.*$/, '')); +} + +function format_nested_exception(ex) { + let msg = replace(cut_message(ex.message), /(\n+( \|[^\n]*(\n|$))+)/, (m, m1) => { + m1 = replace(m1, /(^|\n) \| ?/g, '$1'); + m = match(m1, /^(.+?)\n(In.*line \d+, byte \d+:.+)$/); + + return ` + <div class="exception"> + <div class="message">${cut_message(m ? m[1] : m1)}</div> + ${m ? `<pre class="context">${trim(m[2])}</pre>` : ''} + </div> + `; + }); + + return ` + <div class="exception"> + <div class="message">${cut_message(msg)}</div> + <pre class="context">${trim(ex.stacktrace[0].context)}</pre> + </div> + `; +} + +function format_lua_exception(ex) { + let m = match(ex.message, /^(.+)\nstack traceback:\n(.+)$/); + + return ` + <div class="exception"> + <div class="message">${cut_message(m ? m[1] : ex.message)}</div> + <pre class="context">${m ? trim(replace(m[2], /(^|\n)\t/g, '$1')) : ex.stacktrace[0].context}</pre> + </div> + `; +} + +const Class = { + init_lua: function() { + if (!this.L) { + this.L = this.env.dispatcher.load_luabridge().create(); + this.L.set('L', proto({ write: print }, this.env)); + this.L.eval('package.path = "/usr/lib/lua/luci/ucodebridge/?.lua;" .. package.path'); + this.L.invoke('require', 'luci.ucodebridge'); + + this.env.lua_active = true; + } + + return this.L; + }, + + render_ucode: function(path, scope) { + let tmplfunc = loadfile(path, { raw_mode: false }); + call(tmplfunc, null, scope ?? {}); + }, + + render_lua: function(path, scope) { + let vm = this.init_lua(); + let render = vm.get('_G', 'luci', 'ucodebridge', 'render'); + + render.call(path, scope ?? {}); + }, + + trycompile: function(path) { + let ucode_path = `${template_directory}/${path}.ut`; + + if (access(ucode_path)) { + try { + loadfile(ucode_path, { raw_mode: false }); + } + catch (ucode_err) { + return `Unable to compile '${path}' as ucode template: ${format_nested_exception(ucode_err)}`; + } + } + else { + try { + let vm = this.init_lua(); + let compile = vm.get('_G', 'luci', 'ucodebridge', 'compile'); + + compile.call(path); + } + catch (lua_err) { + return `Unable to compile '${path}' as Lua template: ${format_lua_exception(lua_err)}`; + } + } + + return true; + }, + + render_any: function(path, scope) { + let ucode_path = `${template_directory}/${path}.ut`; + + scope = proto(scope ?? {}, this.scopes[-1]); + + push(this.scopes, scope); + + try { + if (access(ucode_path)) + this.render_ucode(ucode_path, scope); + else + this.render_lua(path, scope); + } + catch (ex) { + pop(this.scopes); + die(ex); + } + + pop(this.scopes); + }, + + render: function(path, scope) { + let self = this; + this.env.http.write(render(() => self.render_any(path, scope))); + }, + + call: function(modname, method, ...args) { + let vm = this.init_lua(); + let lcall = vm.get('_G', 'luci', 'ucodebridge', 'call'); + + return lcall.call(modname, method, ...args); + } +}; + +export default function(env) { + const self = proto({ env: env ??= {}, scopes: [ proto(env, global) ], global }, Class); + const uci = cursor(); + + // determine theme + let media = uci.get('luci', 'main', 'mediaurlbase'); + let status = self.trycompile(`themes/${basename(media)}/header`); + + if (status !== true) { + media = null; + + for (let k, v in uci.get_all('luci', 'themes')) { + if (substr(k, 0, 1) != '.') { + status = self.trycompile(`themes/${basename(v)}/header`); + + if (status === true) { + media = v; + break; + } + } + } + + if (!media) + error500(`Unable to render any theme header template, last error was:\n${status}`); + } + + self.env.media = media; + self.env.theme = basename(media); + self.env.resource = uci.get('luci', 'main', 'resourcebase'); + self.env.include = (...args) => self.render_any(...args); + + return self; +}; diff --git a/modules/luci-base-ucode/ucode/sys.uc b/modules/luci-base-ucode/ucode/sys.uc new file mode 100644 index 0000000000..d4db91a9b9 --- /dev/null +++ b/modules/luci-base-ucode/ucode/sys.uc @@ -0,0 +1,157 @@ +// Copyright 2022 Jo-Philipp Wich <jo@mein.io> +// Licensed to the public under the Apache License 2.0. + +import { basename, readlink, readfile, open, popen, stat, glob } from 'fs'; + +export function process_list() { + const top = popen('/bin/busybox top -bn1'); + let line, list = []; + + for (let line = top.read('line'); length(line); line = top.read('line')) { + let m = match(trim(line), /^([0-9]+) +([0-9]+) +(.+) +([RSDZTWI][<NW ][<N ]) +([0-9]+m?) +([0-9]+%) +([0-9]+%) +(.+)$/); + + if (m && m[8] != '/bin/busybox top -bn1') { + push(list, { + PID: m[1], + PPID: m[2], + USER: trim(m[3]), + STAT: m[4], + VSZ: m[5], + '%MEM': m[6], + '%CPU': m[7], + COMMAND: m[8] + }); + } + } + + top.close(); + + return list; +}; + +export function conntrack_list(callback) { + const etcpr = open('/etc/protocols'); + const protos = {}; + + if (etcpr) { + for (let line = etcpr.read('line'); length(line); line = etcpr.read('line')) { + const m = match(line, /^([^# \t\n]+)\s+([0-9]+)\s+/); + + if (m) + protos[m[2]] = m[1]; + } + + etcpr.close(); + } + + const nfct = open('/proc/net/nf_conntrack', 'r'); + let connt; + + if (nfct) { + for (let line = nfct.read('line'); length(line); line = nfct.read('line')) { + let m = match(line, /^(ipv[46]) +([0-9]+) +\S+ +([0-9]+) +(.+)\n$/); + + if (!m) + continue; + + let fam = m[1]; + let l3 = m[2]; + let l4 = m[3]; + let tuples = m[4]; + let timeout = null; + + m = match(tuples, /^([0-9]+) (.+)$/); + + if (m) { + timeout = m[1]; + tuples = m[2]; + } + + if (index(tuples, 'TIME_WAIT ') === 0) + continue; + + let e = { + bytes: 0, + packets: 0, + layer3: fam, + layer4: protos[l4] ?? 'unknown', + timeout: +timeout + }; + + for (let kv in match(tuples, / (\w+)=(\S+)/g)) { + switch (kv[1]) { + case 'bytes': + case 'packets': + e[kv[1]] += +kv[2]; + break; + + case 'src': + case 'dst': + e[kv[1]] ??= arrtoip(iptoarr(kv[2])); + break; + + case 'sport': + case 'dport': + e[kv[1]] ??= +kv[2]; + break; + + default: + e[kv[1]] = kv[2]; + } + } + + if (callback) + callback(e); + else + push(connt ??= [], e); + } + + nfct.close(); + } + + return callback ? true : (connt ?? []); +}; + +export function init_list() { + return map(filter(glob('/etc/init.d/*'), path => { + const s = stat(path); + + return s?.type == 'file' && s?.perm?.user_exec; + }), basename); +}; + +export function init_index(name) { + const src = readfile(`/etc/init.d/${basename(name)}`, 1024); + const idx = []; + + for (let m in match(src, /^[[:space:]]*(START|STOP)=('[0-9][0-9]'|"[0-9][0-9]"|[0-9][0-9])[[:space:]]*$/gs)) { + switch (m[1]) { + case 'START': idx[0] = +trim(m[2], '"\''); break; + case 'STOP': idx[1] = +trim(m[2], '"\''); break; + } + } + + return length(idx) ? idx : null; +}; + +export function init_enabled(name) { + for (let path in glob(`/etc/rc.d/[SK][0-9][0-9]${basename(name)}`)) { + const ln = readlink(path); + const s1 = stat(index(ln, '/') == 0 ? ln : `/etc/rc.d/${ln}`); + const s2 = stat(`/etc/init.d/${basename(name)}`); + + if (s1?.inode == s2?.inode && s1?.type == 'file' && s1?.perm?.user_exec) + return true; + } + + return false; +}; + +export function init_action(name, action) { + const s = stat(`/etc/init.d/${basename(name)}`); + + if (s?.type != 'file' || s?.user_exec == false) + return false; + + return system(`env -i /etc/init.d/${basename(name)} ${action} >/dev/null`); +}; diff --git a/modules/luci-base-ucode/ucode/template/csrftoken.ut b/modules/luci-base-ucode/ucode/template/csrftoken.ut new file mode 100644 index 0000000000..4e96eebe90 --- /dev/null +++ b/modules/luci-base-ucode/ucode/template/csrftoken.ut @@ -0,0 +1,24 @@ +{# + Copyright 2015-2022 Jo-Philipp Wich <jo@mein.io> + Licensed to the public under the Apache License 2.0. +-#} + +{% include('header') %} + +<h2 name="content">{{ _('Form token mismatch') }}</h2> +<br /> + +<p class="alert-message">{{ _('The submitted security token is invalid or already expired!') }}</p> + +<p>{{ _(` + In order to prevent unauthorized access to the system, your request has + been blocked. Click "Continue »" below to return to the previous page. +`) }}</p> + +<hr /> + +<p class="right"> + <strong><a href="#" onclick="window.history.back();">Continue »</a></strong> +</p> + +{% include('footer') %} diff --git a/modules/luci-base-ucode/ucode/template/error404.ut b/modules/luci-base-ucode/ucode/template/error404.ut new file mode 100644 index 0000000000..90c3d3784b --- /dev/null +++ b/modules/luci-base-ucode/ucode/template/error404.ut @@ -0,0 +1,14 @@ +{# + Copyright 2008 Steven Barth <steven@midlink.org> + Copyright 2008-2022 Jo-Philipp Wich <jo@mein.io> + Licensed to the public under the Apache License 2.0. +-#} + +{% include('header') %} + +<h2 name="content">404 {{ _('Not Found') }}</h2> +<p>{{ _('Sorry, the object you requested was not found.') }}</p> +<p>{{ message }}</p> +<tt>{{ _('Unable to dispatch') }}: {{ dispatcher.build_url(...ctx.request_path) }}</tt> + +{% include('footer') %} diff --git a/modules/luci-base-ucode/ucode/template/error500.ut b/modules/luci-base-ucode/ucode/template/error500.ut new file mode 100644 index 0000000000..39a0eec678 --- /dev/null +++ b/modules/luci-base-ucode/ucode/template/error500.ut @@ -0,0 +1,67 @@ +{# + Copyright 2022 Jo-Philipp Wich <jo@mein.io> + Licensed to the public under the Apache License 2.0. +-#} + +<!--]]>--><!--'>--><!--">--> +<style type="text/css"> + body { + line-height: 1.5; + font-size: 14px; + font-family: sans-serif; + } + + .error500 * { + margin: 0; + padding: 0; + color: inherit; + } + + .error500 { + box-sizing: border-box; + position: fixed; + z-index: 999999; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + overflow: auto; + background: #ffe; + color: #f00 !important; + padding: 1em; + } + + .error500 h1 { + margin-bottom: .5em; + } + + .error500 .exception { + font-weight: normal; + white-space: normal; + margin: .25em; + padding: .5em; + border: 1px solid #f00; + background: rgba(204, 204, 204, .2); + } + + .error500 .message { + font-weight: bold; + white-space: pre-line; + } + + .error500 .context { + margin-top: 2em; + } +</style> + +<div class="error500"> + <h1>{{ title }}</h1> + <div class="message">{{ message }}</div> + + {% if (exception): %} + <div class="exception"> + <div class="message">{{ exception.message }}</div> + <pre class="context">{{ exception.stacktrace[0].context }}</pre> + </div> + {% endif %} +</div> diff --git a/modules/luci-base-ucode/ucode/template/footer.ut b/modules/luci-base-ucode/ucode/template/footer.ut new file mode 100644 index 0000000000..22d4f136f0 --- /dev/null +++ b/modules/luci-base-ucode/ucode/template/footer.ut @@ -0,0 +1,23 @@ +{# + Copyright 2022 Jo-Philipp Wich <jo@mein.io> + Licensed to the public under the Apache License 2.0. +-#} + +{% const rollback = dispatcher.rollback_pending() %} +{% if (rollback || trigger_apply || trigger_revert): %} + <script type="text/javascript"> + document.addEventListener("luci-loaded", function() { + {% if (trigger_apply): %} + L.ui.changes.apply(true); + {% elif (trigger_revert): %} + L.ui.changes.revert(); + {% else %} + L.ui.changes.confirm(true, Date.now() + {{rollback.remaining * 1000}}, {{sprintf('%J', rollback.token)}}); + {% endif %} + }); + </script> +{% endif %} + +{% include(`themes/${theme}/footer`) %} + +<!-- Lua compatibility mode active: {{ lua_active ? 'yes' : 'no' }} --> diff --git a/modules/luci-base-ucode/ucode/template/header.ut b/modules/luci-base-ucode/ucode/template/header.ut new file mode 100644 index 0000000000..fb61da5146 --- /dev/null +++ b/modules/luci-base-ucode/ucode/template/header.ut @@ -0,0 +1,32 @@ +{# + Copyright 2022 Jo-Philipp Wich <jo@mein.io> + Licensed to the public under the Apache License 2.0. +-#} + +{% + include(`themes/${theme}/header`); +-%} + +<script type="text/javascript" src="{{ resource }}/promis.min.js"></script> +<script type="text/javascript" src="{{ resource }}/luci.js"></script> +<script type="text/javascript"> + L = new LuCI({{ { + media : media, + resource : resource, + scriptname : http.getenv("SCRIPT_NAME"), + pathinfo : http.getenv("PATH_INFO"), + documentroot : http.getenv("DOCUMENT_ROOT"), + requestpath : ctx.request_path, + dispatchpath : ctx.path, + pollinterval : +config.main.pollinterval || 5, + ubuspath : config.main.ubuspath || '/ubus/', + sessionid : ctx.authsession, + token : ctx.authtoken, + nodespec : node, + apply_rollback : max(+config.apply.rollback || 90, 90), + apply_holdoff : max(+config.apply.holdoff || 4, 1), + apply_timeout : max(+config.apply.timeout || 5, 1), + apply_display : max(+config.apply.display || 1.5, 1), + rollback_token : rollback_token + } }}); +</script> diff --git a/modules/luci-base-ucode/ucode/template/sysauth.ut b/modules/luci-base-ucode/ucode/template/sysauth.ut new file mode 100644 index 0000000000..0fe873d440 --- /dev/null +++ b/modules/luci-base-ucode/ucode/template/sysauth.ut @@ -0,0 +1,74 @@ +{# + Copyright 2008 Steven Barth <steven@midlink.org> + Copyright 2008-2012 Jo-Philipp Wich <jow@openwrt.org> + Licensed to the public under the Apache License 2.0. +-#} + +{% include('header') %} + +<form method="post"> + {% if (fuser): %} + <div class="alert-message warning"> + <p>{{ _('Invalid username and/or password! Please try again.') }}</p> + </div> + {% endif %} + + <div class="cbi-map"> + <h2 name="content">{{ _('Authorization Required') }}</h2> + <div class="cbi-map-descr"> + {{ _('Please enter your username and password.') }} + </div> + <div class="cbi-section"><div class="cbi-section-node"> + <div class="cbi-value"> + <label class="cbi-value-title">{{ _('Username') }}</label> + <div class="cbi-value-field"> + <input class="cbi-input-text" type="text" name="luci_username" value="{{ entityencode(duser, true) }}" /> + </div> + </div> + <div class="cbi-value cbi-value-last"> + <label class="cbi-value-title">{{ _('Password') }}</label> + <div class="cbi-value-field"> + <input class="cbi-input-text" type="password" name="luci_password" /> + </div> + </div> + </div></div> + </div> + + <div class="cbi-page-actions"> + <input type="submit" value="{{ _('Login') }}" class="btn cbi-button cbi-button-apply" /> + <input type="reset" value="{{ _('Reset') }}" class="btn cbi-button cbi-button-reset" /> + </div> +</form> + +{% + let https_ports = uci.get('uhttpd', 'main', 'listen_https') ?? []; + + https_ports = uniq(filter( + map( + (type(https_ports) == 'string') ? split(https_port, /\s+/) : https_ports, + e => +match(e, /\d+$/)?.[0] + ), + p => (p >= 0 && p <= 65535) + )); +%} + +<script type="text/javascript">//<![CDATA[ + var input = document.getElementsByName('luci_password')[0]; + + if (input) + input.focus(); + + if (document.location.protocol != 'https:') { + {{ https_ports }}.forEach(function(port) { + var url = 'https://' + window.location.hostname + ':' + port + window.location.pathname; + var img = new Image(); + + img.onload = function() { window.location = url }; + img.src = 'https://' + window.location.hostname + ':' + port + '{{ resource }}/icons/loading.gif?' + Math.random(); + + setTimeout(function() { img.src = '' }, 5000); + }); + } +//]]></script> + +{% include('footer') %} diff --git a/modules/luci-base-ucode/ucode/template/view.ut b/modules/luci-base-ucode/ucode/template/view.ut new file mode 100644 index 0000000000..11ac824290 --- /dev/null +++ b/modules/luci-base-ucode/ucode/template/view.ut @@ -0,0 +1,12 @@ +{% include('header') %} + +<div id="view"> + <div class="spinning">{{ _('Loading view…') }}</div> + <script type="text/javascript"> + L.require('ui').then(function(ui) { + ui.instantiateView('{{ view }}'); + }); + </script> +</div> + +{% include('footer') %} diff --git a/modules/luci-base-ucode/ucode/uhttpd.uc b/modules/luci-base-ucode/ucode/uhttpd.uc new file mode 100644 index 0000000000..df1ecc7865 --- /dev/null +++ b/modules/luci-base-ucode/ucode/uhttpd.uc @@ -0,0 +1,12 @@ +{% + +import dispatch from 'luci.dispatcher'; +import request from 'luci.http'; + +global.handle_request = function(env) { + let req = request(env, uhttpd.recv, uhttpd.send); + + dispatch(req); + + req.close(); +}; diff --git a/modules/luci-base-ucode/ucode/zoneinfo.uc b/modules/luci-base-ucode/ucode/zoneinfo.uc new file mode 100644 index 0000000000..c5e588dd6a --- /dev/null +++ b/modules/luci-base-ucode/ucode/zoneinfo.uc @@ -0,0 +1,453 @@ +// Autogenerated by zoneinfo2ucode.pl + +export default { + 'Africa/Abidjan': 'GMT0', + 'Africa/Accra': 'GMT0', + 'Africa/Addis Ababa': 'EAT-3', + 'Africa/Algiers': 'CET-1', + 'Africa/Asmara': 'EAT-3', + 'Africa/Bamako': 'GMT0', + 'Africa/Bangui': 'WAT-1', + 'Africa/Banjul': 'GMT0', + 'Africa/Bissau': 'GMT0', + 'Africa/Blantyre': 'CAT-2', + 'Africa/Brazzaville': 'WAT-1', + 'Africa/Bujumbura': 'CAT-2', + 'Africa/Cairo': 'EET-2', + 'Africa/Casablanca': '<+01>-1', + 'Africa/Ceuta': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Africa/Conakry': 'GMT0', + 'Africa/Dakar': 'GMT0', + 'Africa/Dar es Salaam': 'EAT-3', + 'Africa/Djibouti': 'EAT-3', + 'Africa/Douala': 'WAT-1', + 'Africa/El Aaiun': '<+01>-1', + 'Africa/Freetown': 'GMT0', + 'Africa/Gaborone': 'CAT-2', + 'Africa/Harare': 'CAT-2', + 'Africa/Johannesburg': 'SAST-2', + 'Africa/Juba': 'CAT-2', + 'Africa/Kampala': 'EAT-3', + 'Africa/Khartoum': 'CAT-2', + 'Africa/Kigali': 'CAT-2', + 'Africa/Kinshasa': 'WAT-1', + 'Africa/Lagos': 'WAT-1', + 'Africa/Libreville': 'WAT-1', + 'Africa/Lome': 'GMT0', + 'Africa/Luanda': 'WAT-1', + 'Africa/Lubumbashi': 'CAT-2', + 'Africa/Lusaka': 'CAT-2', + 'Africa/Malabo': 'WAT-1', + 'Africa/Maputo': 'CAT-2', + 'Africa/Maseru': 'SAST-2', + 'Africa/Mbabane': 'SAST-2', + 'Africa/Mogadishu': 'EAT-3', + 'Africa/Monrovia': 'GMT0', + 'Africa/Nairobi': 'EAT-3', + 'Africa/Ndjamena': 'WAT-1', + 'Africa/Niamey': 'WAT-1', + 'Africa/Nouakchott': 'GMT0', + 'Africa/Ouagadougou': 'GMT0', + 'Africa/Porto-Novo': 'WAT-1', + 'Africa/Sao Tome': 'GMT0', + 'Africa/Tripoli': 'EET-2', + 'Africa/Tunis': 'CET-1', + 'Africa/Windhoek': 'CAT-2', + 'America/Adak': 'HST10HDT,M3.2.0,M11.1.0', + 'America/Anchorage': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Anguilla': 'AST4', + 'America/Antigua': 'AST4', + 'America/Araguaina': '<-03>3', + 'America/Argentina/Buenos Aires': '<-03>3', + 'America/Argentina/Catamarca': '<-03>3', + 'America/Argentina/Cordoba': '<-03>3', + 'America/Argentina/Jujuy': '<-03>3', + 'America/Argentina/La Rioja': '<-03>3', + 'America/Argentina/Mendoza': '<-03>3', + 'America/Argentina/Rio Gallegos': '<-03>3', + 'America/Argentina/Salta': '<-03>3', + 'America/Argentina/San Juan': '<-03>3', + 'America/Argentina/San Luis': '<-03>3', + 'America/Argentina/Tucuman': '<-03>3', + 'America/Argentina/Ushuaia': '<-03>3', + 'America/Aruba': 'AST4', + 'America/Asuncion': '<-04>4<-03>,M10.1.0/0,M3.4.0/0', + 'America/Atikokan': 'EST5', + 'America/Bahia': '<-03>3', + 'America/Bahia Banderas': 'CST6CDT,M4.1.0,M10.5.0', + 'America/Barbados': 'AST4', + 'America/Belem': '<-03>3', + 'America/Belize': 'CST6', + 'America/Blanc-Sablon': 'AST4', + 'America/Boa Vista': '<-04>4', + 'America/Bogota': '<-05>5', + 'America/Boise': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Cambridge Bay': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Campo Grande': '<-04>4', + 'America/Cancun': 'EST5', + 'America/Caracas': '<-04>4', + 'America/Cayenne': '<-03>3', + 'America/Cayman': 'EST5', + 'America/Chicago': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Chihuahua': 'MST7MDT,M4.1.0,M10.5.0', + 'America/Costa Rica': 'CST6', + 'America/Creston': 'MST7', + 'America/Cuiaba': '<-04>4', + 'America/Curacao': 'AST4', + 'America/Danmarkshavn': 'GMT0', + 'America/Dawson': 'MST7', + 'America/Dawson Creek': 'MST7', + 'America/Denver': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Detroit': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Dominica': 'AST4', + 'America/Edmonton': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Eirunepe': '<-05>5', + 'America/El Salvador': 'CST6', + 'America/Fort Nelson': 'MST7', + 'America/Fortaleza': '<-03>3', + 'America/Glace Bay': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Goose Bay': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Grand Turk': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Grenada': 'AST4', + 'America/Guadeloupe': 'AST4', + 'America/Guatemala': 'CST6', + 'America/Guayaquil': '<-05>5', + 'America/Guyana': '<-04>4', + 'America/Halifax': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Havana': 'CST5CDT,M3.2.0/0,M11.1.0/1', + 'America/Hermosillo': 'MST7', + 'America/Indiana/Indianapolis': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Knox': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Indiana/Marengo': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Petersburg': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Tell City': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Indiana/Vevay': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Vincennes': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Winamac': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Inuvik': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Iqaluit': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Jamaica': 'EST5', + 'America/Juneau': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Kentucky/Louisville': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Kentucky/Monticello': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Kralendijk': 'AST4', + 'America/La Paz': '<-04>4', + 'America/Lima': '<-05>5', + 'America/Los Angeles': 'PST8PDT,M3.2.0,M11.1.0', + 'America/Lower Princes': 'AST4', + 'America/Maceio': '<-03>3', + 'America/Managua': 'CST6', + 'America/Manaus': '<-04>4', + 'America/Marigot': 'AST4', + 'America/Martinique': 'AST4', + 'America/Matamoros': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Mazatlan': 'MST7MDT,M4.1.0,M10.5.0', + 'America/Menominee': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Merida': 'CST6CDT,M4.1.0,M10.5.0', + 'America/Metlakatla': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Mexico City': 'CST6CDT,M4.1.0,M10.5.0', + 'America/Miquelon': '<-03>3<-02>,M3.2.0,M11.1.0', + 'America/Moncton': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Monterrey': 'CST6CDT,M4.1.0,M10.5.0', + 'America/Montevideo': '<-03>3', + 'America/Montserrat': 'AST4', + 'America/Nassau': 'EST5EDT,M3.2.0,M11.1.0', + 'America/New York': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Nipigon': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Nome': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Noronha': '<-02>2', + 'America/North Dakota/Beulah': 'CST6CDT,M3.2.0,M11.1.0', + 'America/North Dakota/Center': 'CST6CDT,M3.2.0,M11.1.0', + 'America/North Dakota/New Salem': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Nuuk': '<-03>3<-02>,M3.5.0/-2,M10.5.0/-1', + 'America/Ojinaga': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Panama': 'EST5', + 'America/Pangnirtung': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Paramaribo': '<-03>3', + 'America/Phoenix': 'MST7', + 'America/Port of Spain': 'AST4', + 'America/Port-au-Prince': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Porto Velho': '<-04>4', + 'America/Puerto Rico': 'AST4', + 'America/Punta Arenas': '<-03>3', + 'America/Rainy River': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Rankin Inlet': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Recife': '<-03>3', + 'America/Regina': 'CST6', + 'America/Resolute': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Rio Branco': '<-05>5', + 'America/Santarem': '<-03>3', + 'America/Santiago': '<-04>4<-03>,M9.1.6/24,M4.1.6/24', + 'America/Santo Domingo': 'AST4', + 'America/Sao Paulo': '<-03>3', + 'America/Scoresbysund': '<-01>1<+00>,M3.5.0/0,M10.5.0/1', + 'America/Sitka': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/St Barthelemy': 'AST4', + 'America/St Johns': 'NST3:30NDT,M3.2.0,M11.1.0', + 'America/St Kitts': 'AST4', + 'America/St Lucia': 'AST4', + 'America/St Thomas': 'AST4', + 'America/St Vincent': 'AST4', + 'America/Swift Current': 'CST6', + 'America/Tegucigalpa': 'CST6', + 'America/Thule': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Thunder Bay': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Tijuana': 'PST8PDT,M3.2.0,M11.1.0', + 'America/Toronto': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Tortola': 'AST4', + 'America/Vancouver': 'PST8PDT,M3.2.0,M11.1.0', + 'America/Whitehorse': 'MST7', + 'America/Winnipeg': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Yakutat': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Yellowknife': 'MST7MDT,M3.2.0,M11.1.0', + 'Antarctica/Casey': '<+11>-11', + 'Antarctica/Davis': '<+07>-7', + 'Antarctica/DumontDUrville': '<+10>-10', + 'Antarctica/Macquarie': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Antarctica/Mawson': '<+05>-5', + 'Antarctica/McMurdo': 'NZST-12NZDT,M9.5.0,M4.1.0/3', + 'Antarctica/Palmer': '<-03>3', + 'Antarctica/Rothera': '<-03>3', + 'Antarctica/Syowa': '<+03>-3', + 'Antarctica/Troll': '<+00>0<+02>-2,M3.5.0/1,M10.5.0/3', + 'Antarctica/Vostok': '<+06>-6', + 'Arctic/Longyearbyen': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Asia/Aden': '<+03>-3', + 'Asia/Almaty': '<+06>-6', + 'Asia/Amman': '<+03>-3', + 'Asia/Anadyr': '<+12>-12', + 'Asia/Aqtau': '<+05>-5', + 'Asia/Aqtobe': '<+05>-5', + 'Asia/Ashgabat': '<+05>-5', + 'Asia/Atyrau': '<+05>-5', + 'Asia/Baghdad': '<+03>-3', + 'Asia/Bahrain': '<+03>-3', + 'Asia/Baku': '<+04>-4', + 'Asia/Bangkok': '<+07>-7', + 'Asia/Barnaul': '<+07>-7', + 'Asia/Beirut': 'EET-2EEST,M3.5.0/0,M10.5.0/0', + 'Asia/Bishkek': '<+06>-6', + 'Asia/Brunei': '<+08>-8', + 'Asia/Chita': '<+09>-9', + 'Asia/Choibalsan': '<+08>-8', + 'Asia/Colombo': '<+0530>-5:30', + 'Asia/Damascus': '<+03>-3', + 'Asia/Dhaka': '<+06>-6', + 'Asia/Dili': '<+09>-9', + 'Asia/Dubai': '<+04>-4', + 'Asia/Dushanbe': '<+05>-5', + 'Asia/Famagusta': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Asia/Gaza': 'EET-2EEST,M3.4.4/50,M10.4.4/50', + 'Asia/Hebron': 'EET-2EEST,M3.4.4/50,M10.4.4/50', + 'Asia/Ho Chi Minh': '<+07>-7', + 'Asia/Hong Kong': 'HKT-8', + 'Asia/Hovd': '<+07>-7', + 'Asia/Irkutsk': '<+08>-8', + 'Asia/Jakarta': 'WIB-7', + 'Asia/Jayapura': 'WIT-9', + 'Asia/Jerusalem': 'IST-2IDT,M3.4.4/26,M10.5.0', + 'Asia/Kabul': '<+0430>-4:30', + 'Asia/Kamchatka': '<+12>-12', + 'Asia/Karachi': 'PKT-5', + 'Asia/Kathmandu': '<+0545>-5:45', + 'Asia/Khandyga': '<+09>-9', + 'Asia/Kolkata': 'IST-5:30', + 'Asia/Krasnoyarsk': '<+07>-7', + 'Asia/Kuala Lumpur': '<+08>-8', + 'Asia/Kuching': '<+08>-8', + 'Asia/Kuwait': '<+03>-3', + 'Asia/Macau': 'CST-8', + 'Asia/Magadan': '<+11>-11', + 'Asia/Makassar': 'WITA-8', + 'Asia/Manila': 'PST-8', + 'Asia/Muscat': '<+04>-4', + 'Asia/Nicosia': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Asia/Novokuznetsk': '<+07>-7', + 'Asia/Novosibirsk': '<+07>-7', + 'Asia/Omsk': '<+06>-6', + 'Asia/Oral': '<+05>-5', + 'Asia/Phnom Penh': '<+07>-7', + 'Asia/Pontianak': 'WIB-7', + 'Asia/Pyongyang': 'KST-9', + 'Asia/Qatar': '<+03>-3', + 'Asia/Qostanay': '<+06>-6', + 'Asia/Qyzylorda': '<+05>-5', + 'Asia/Riyadh': '<+03>-3', + 'Asia/Sakhalin': '<+11>-11', + 'Asia/Samarkand': '<+05>-5', + 'Asia/Seoul': 'KST-9', + 'Asia/Shanghai': 'CST-8', + 'Asia/Singapore': '<+08>-8', + 'Asia/Srednekolymsk': '<+11>-11', + 'Asia/Taipei': 'CST-8', + 'Asia/Tashkent': '<+05>-5', + 'Asia/Tbilisi': '<+04>-4', + 'Asia/Tehran': '<+0330>-3:30', + 'Asia/Thimphu': '<+06>-6', + 'Asia/Tokyo': 'JST-9', + 'Asia/Tomsk': '<+07>-7', + 'Asia/Ulaanbaatar': '<+08>-8', + 'Asia/Urumqi': '<+06>-6', + 'Asia/Ust-Nera': '<+10>-10', + 'Asia/Vientiane': '<+07>-7', + 'Asia/Vladivostok': '<+10>-10', + 'Asia/Yakutsk': '<+09>-9', + 'Asia/Yangon': '<+0630>-6:30', + 'Asia/Yekaterinburg': '<+05>-5', + 'Asia/Yerevan': '<+04>-4', + 'Atlantic/Azores': '<-01>1<+00>,M3.5.0/0,M10.5.0/1', + 'Atlantic/Bermuda': 'AST4ADT,M3.2.0,M11.1.0', + 'Atlantic/Canary': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Atlantic/Cape Verde': '<-01>1', + 'Atlantic/Faroe': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Atlantic/Madeira': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Atlantic/Reykjavik': 'GMT0', + 'Atlantic/South Georgia': '<-02>2', + 'Atlantic/St Helena': 'GMT0', + 'Atlantic/Stanley': '<-03>3', + 'Australia/Adelaide': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3', + 'Australia/Brisbane': 'AEST-10', + 'Australia/Broken Hill': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3', + 'Australia/Darwin': 'ACST-9:30', + 'Australia/Eucla': '<+0845>-8:45', + 'Australia/Hobart': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/Lindeman': 'AEST-10', + 'Australia/Lord Howe': '<+1030>-10:30<+11>-11,M10.1.0,M4.1.0', + 'Australia/Melbourne': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/Perth': 'AWST-8', + 'Australia/Sydney': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Etc/GMT': 'GMT0', + 'Etc/GMT+1': '<-01>1', + 'Etc/GMT+10': '<-10>10', + 'Etc/GMT+11': '<-11>11', + 'Etc/GMT+12': '<-12>12', + 'Etc/GMT+2': '<-02>2', + 'Etc/GMT+3': '<-03>3', + 'Etc/GMT+4': '<-04>4', + 'Etc/GMT+5': '<-05>5', + 'Etc/GMT+6': '<-06>6', + 'Etc/GMT+7': '<-07>7', + 'Etc/GMT+8': '<-08>8', + 'Etc/GMT+9': '<-09>9', + 'Etc/GMT-1': '<+01>-1', + 'Etc/GMT-10': '<+10>-10', + 'Etc/GMT-11': '<+11>-11', + 'Etc/GMT-12': '<+12>-12', + 'Etc/GMT-13': '<+13>-13', + 'Etc/GMT-14': '<+14>-14', + 'Etc/GMT-2': '<+02>-2', + 'Etc/GMT-3': '<+03>-3', + 'Etc/GMT-4': '<+04>-4', + 'Etc/GMT-5': '<+05>-5', + 'Etc/GMT-6': '<+06>-6', + 'Etc/GMT-7': '<+07>-7', + 'Etc/GMT-8': '<+08>-8', + 'Etc/GMT-9': '<+09>-9', + 'Europe/Amsterdam': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Andorra': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Astrakhan': '<+04>-4', + 'Europe/Athens': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Belgrade': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Berlin': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Bratislava': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Brussels': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Bucharest': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Budapest': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Busingen': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Chisinau': 'EET-2EEST,M3.5.0,M10.5.0/3', + 'Europe/Copenhagen': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Dublin': 'IST-1GMT0,M10.5.0,M3.5.0/1', + 'Europe/Gibraltar': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Guernsey': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Helsinki': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Isle of Man': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Istanbul': '<+03>-3', + 'Europe/Jersey': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Kaliningrad': 'EET-2', + 'Europe/Kirov': '<+03>-3', + 'Europe/Kyiv': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Lisbon': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Europe/Ljubljana': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/London': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Luxembourg': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Madrid': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Malta': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Mariehamn': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Minsk': '<+03>-3', + 'Europe/Monaco': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Moscow': 'MSK-3', + 'Europe/Oslo': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Paris': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Podgorica': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Prague': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Riga': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Rome': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Samara': '<+04>-4', + 'Europe/San Marino': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Sarajevo': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Saratov': '<+04>-4', + 'Europe/Simferopol': 'MSK-3', + 'Europe/Skopje': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Sofia': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Stockholm': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Tallinn': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Tirane': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Ulyanovsk': '<+04>-4', + 'Europe/Vaduz': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Vatican': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Vienna': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Vilnius': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Volgograd': '<+03>-3', + 'Europe/Warsaw': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Zagreb': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Zurich': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Indian/Antananarivo': 'EAT-3', + 'Indian/Chagos': '<+06>-6', + 'Indian/Christmas': '<+07>-7', + 'Indian/Cocos': '<+0630>-6:30', + 'Indian/Comoro': 'EAT-3', + 'Indian/Kerguelen': '<+05>-5', + 'Indian/Mahe': '<+04>-4', + 'Indian/Maldives': '<+05>-5', + 'Indian/Mauritius': '<+04>-4', + 'Indian/Mayotte': 'EAT-3', + 'Indian/Reunion': '<+04>-4', + 'Pacific/Apia': '<+13>-13', + 'Pacific/Auckland': 'NZST-12NZDT,M9.5.0,M4.1.0/3', + 'Pacific/Bougainville': '<+11>-11', + 'Pacific/Chatham': '<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45', + 'Pacific/Chuuk': '<+10>-10', + 'Pacific/Easter': '<-06>6<-05>,M9.1.6/22,M4.1.6/22', + 'Pacific/Efate': '<+11>-11', + 'Pacific/Fakaofo': '<+13>-13', + 'Pacific/Fiji': '<+12>-12<+13>,M11.2.0,M1.2.3/99', + 'Pacific/Funafuti': '<+12>-12', + 'Pacific/Galapagos': '<-06>6', + 'Pacific/Gambier': '<-09>9', + 'Pacific/Guadalcanal': '<+11>-11', + 'Pacific/Guam': 'ChST-10', + 'Pacific/Honolulu': 'HST10', + 'Pacific/Kanton': '<+13>-13', + 'Pacific/Kiritimati': '<+14>-14', + 'Pacific/Kosrae': '<+11>-11', + 'Pacific/Kwajalein': '<+12>-12', + 'Pacific/Majuro': '<+12>-12', + 'Pacific/Marquesas': '<-0930>9:30', + 'Pacific/Midway': 'SST11', + 'Pacific/Nauru': '<+12>-12', + 'Pacific/Niue': '<-11>11', + 'Pacific/Norfolk': '<+11>-11<+12>,M10.1.0,M4.1.0/3', + 'Pacific/Noumea': '<+11>-11', + 'Pacific/Pago Pago': 'SST11', + 'Pacific/Palau': '<+09>-9', + 'Pacific/Pitcairn': '<-08>8', + 'Pacific/Pohnpei': '<+11>-11', + 'Pacific/Port Moresby': '<+10>-10', + 'Pacific/Rarotonga': '<-10>10', + 'Pacific/Saipan': 'ChST-10', + 'Pacific/Tahiti': '<-10>10', + 'Pacific/Tarawa': '<+12>-12', + 'Pacific/Tongatapu': '<+13>-13', + 'Pacific/Wake': '<+12>-12', + 'Pacific/Wallis': '<+12>-12', +}; |