diff options
Diffstat (limited to 'modules/luci-base/ucode')
-rw-r--r-- | modules/luci-base/ucode/controller/admin/index.uc | 160 | ||||
-rw-r--r-- | modules/luci-base/ucode/controller/admin/uci.uc | 150 | ||||
-rw-r--r-- | modules/luci-base/ucode/dispatcher.uc | 1010 | ||||
-rw-r--r-- | modules/luci-base/ucode/http.uc | 574 | ||||
-rw-r--r-- | modules/luci-base/ucode/runtime.uc | 185 | ||||
-rw-r--r-- | modules/luci-base/ucode/sys.uc | 157 | ||||
-rw-r--r-- | modules/luci-base/ucode/template/csrftoken.ut | 24 | ||||
-rw-r--r-- | modules/luci-base/ucode/template/error404.ut | 14 | ||||
-rw-r--r-- | modules/luci-base/ucode/template/error500.ut | 67 | ||||
-rw-r--r-- | modules/luci-base/ucode/template/footer.ut | 42 | ||||
-rw-r--r-- | modules/luci-base/ucode/template/header.ut | 32 | ||||
-rw-r--r-- | modules/luci-base/ucode/template/sysauth.ut | 74 | ||||
-rw-r--r-- | modules/luci-base/ucode/template/view.ut | 12 | ||||
-rw-r--r-- | modules/luci-base/ucode/uhttpd.uc | 12 | ||||
-rw-r--r-- | modules/luci-base/ucode/zoneinfo.uc | 449 |
15 files changed, 2962 insertions, 0 deletions
diff --git a/modules/luci-base/ucode/controller/admin/index.uc b/modules/luci-base/ucode/controller/admin/index.uc new file mode 100644 index 0000000000..f0f7c7fd4d --- /dev/null +++ b/modules/luci-base/ucode/controller/admin/index.uc @@ -0,0 +1,160 @@ +// 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 if (data != null) + reply.result = [ code, data ]; + else + reply.result = [ code ]; + + 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/controller/admin/uci.uc b/modules/luci-base/ucode/controller/admin/uci.uc new file mode 100644 index 0000000000..c38a42b10b --- /dev/null +++ b/modules/luci-base/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/dispatcher.uc b/modules/luci-base/ucode/dispatcher.uc new file mode 100644 index 0000000000..8717385be2 --- /dev/null +++ b/modules/luci-base/ucode/dispatcher.uc @@ -0,0 +1,1010 @@ +// 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(msg)}</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'; + else + lang = replace(lang, '_', '-'); + + 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; + } + else if (kind == 'absent') { + if (stat(path) != null) + 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]] ??= { satisfied: true }; + 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 apply_tree_acls(node, acl) { + for (let name, spec in node?.children) + apply_tree_acls(spec, acl); + + if (node?.depends?.acl) { + switch (check_acl_depends(node.depends.acl, acl["access-group"])) { + case null: node.satisfied = false; break; + case false: node.readonly = true; break; + } + } +} + +function menu_json(acl) { + tree ??= build_pagetree(); + + if (acl) + apply_tree_acls(tree, acl); + + 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 render_action(fn) { + const data = render(fn); + + http.write_headers(); + http.output(data); +} + +function run_action(request_path, lang, tree, resolved, action) { + switch ((type(action) == 'object') ? action.type : 'none') { + case 'template': + if (runtime.is_ucode_template(action.path)) + runtime.render(action.path, {}); + else + render_action(() => { + runtime.call('luci.dispatcher', 'render_lua_template', action.path); + }); + break; + + case 'view': + runtime.render('view', { view: action.path }); + break; + + case 'call': + render_action(() => { + 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}'`); + + render_action(() => { + call(mod[action.function], mod, runtime.env, + ...(action.parameters ?? []), + ...resolved.ctx.request_args + ); + }); + break; + + case 'cbi': + render_action(() => { + runtime.call('luci.dispatcher', 'invoke_cbi_action', + action.path, null, + ...resolved.ctx.request_args + ); + }); + break; + + case 'form': + render_action(() => { + runtime.call('luci.dispatcher', 'invoke_form_action', + action.path, + ...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."); + break; + } + + /* fall through */ + + case 'none': + error404(`No page is registered at '/${entityencode(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 = 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) ?? (args[0] == 1 ? args[1] : args[2]), + }); + + try { + let menu = menu_json(); + + path ??= map(match(http.getenv('PATH_INFO'), /[^\/]+/g), m => urldecode(m[0])); + + let resolved = resolve_page(menu, path); + + runtime.env.ctx = resolved.ctx; + runtime.env.dispatched = resolved.node; + runtime.env.requested ??= 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 }; + let theme_sysauth = `themes/${basename(runtime.env.media)}/sysauth`; + + if (runtime.is_ucode_template(theme_sysauth) || runtime.is_lua_template(theme_sysauth)) { + try { + return runtime.render(theme_sysauth, scope); + } + catch (e) { + runtime.env.media_error = `${e}`; + } + } + + return runtime.render('sysauth', scope); + } + + 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; + + /* In case the Lua runtime was already initialized, e.g. by probing legacy + * theme header templates, make sure to update the session ID of the uci + * module. */ + if (runtime.L) { + runtime.L.invoke('require', 'luci.model.uci'); + runtime.L.get('luci', 'model', 'uci').invoke('set_session_id', session.sid); + } + } + + 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/http.uc b/modules/luci-base/ucode/http.uc new file mode 100644 index 0000000000..e7f64ae6e9 --- /dev/null +++ b/modules/luci-base/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 err, 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 = (length(data) == 0); + + this.filehandler(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.closed) { + 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/runtime.uc b/modules/luci-base/ucode/runtime.uc new file mode 100644 index 0000000000..f14bf74480 --- /dev/null +++ b/modules/luci-base/ucode/runtime.uc @@ -0,0 +1,185 @@ +// 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(optional) { + if (!this.L) { + let bridge = this.env.dispatcher.load_luabridge(optional); + + if (bridge) { + let http = this.env.http; + + this.L = bridge.create(); + this.L.set('L', proto({ write: (...args) => http.closed || print(...args) }, this.env)); + this.L.invoke('require', 'luci.ucodebridge'); + + this.env.lua_active = true; + } + } + + return this.L; + }, + + is_ucode_template: function(path) { + return access(`${template_directory}/${path}.ut`); + }, + + is_lua_template: function(path) { + let vm = this.init_lua(true); + + return vm && access(`${vm.get('_G', 'luci', 'template', 'viewdir')}/${path}.htm`); + }, + + render_ucode: function(path, scope) { + let tmplfunc = loadfile(path, { raw_mode: false }); + + if (this.env.http.closed) + render(call, tmplfunc, null, scope ?? {}); + else + 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(true); + + if (vm) + vm.get('_G', 'luci', 'ucodebridge', 'compile').call(path); + else + return `Unable to compile '${path}' as Lua template: Unable to load Lua runtime`; + } + 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; + self.env.media_error = status; + + 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) + env.dispatcher.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/sys.uc b/modules/luci-base/ucode/sys.uc new file mode 100644 index 0000000000..305499c797 --- /dev/null +++ b/modules/luci-base/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') !== -1) + 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)}`, 2048); + 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/template/csrftoken.ut b/modules/luci-base/ucode/template/csrftoken.ut new file mode 100644 index 0000000000..4e96eebe90 --- /dev/null +++ b/modules/luci-base/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/template/error404.ut b/modules/luci-base/ucode/template/error404.ut new file mode 100644 index 0000000000..90c3d3784b --- /dev/null +++ b/modules/luci-base/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/template/error500.ut b/modules/luci-base/ucode/template/error500.ut new file mode 100644 index 0000000000..39a0eec678 --- /dev/null +++ b/modules/luci-base/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/template/footer.ut b/modules/luci-base/ucode/template/footer.ut new file mode 100644 index 0000000000..d0978594f8 --- /dev/null +++ b/modules/luci-base/ucode/template/footer.ut @@ -0,0 +1,42 @@ +{# + 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 %} + +{% if (media_error): %} + <script type="text/javascript"> + L.require('ui').then(function(ui) { + ui.showIndicator('media_error', _('Theme fallback'), function(ev) { + ui.showModal(_('Error loading theme'), [ + E('p', [ + _('A fallback is used since the configured theme failed to load with the error below.') + ]), + E('hr'), + E('div', { 'style': 'white-space:pre-line' }, {{ sprintf('%J', trim(media_error)) }}), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn cbi-button', 'click': ui.hideModal }, _('Dismiss')) + ]) + ]); + }); + }); + </script> +{% endif %} + +{% include(`themes/${theme}/footer`) %} + +<!-- Lua compatibility mode active: {{ lua_active ? 'yes' : 'no' }} --> diff --git a/modules/luci-base/ucode/template/header.ut b/modules/luci-base/ucode/template/header.ut new file mode 100644 index 0000000000..7dc3742a9d --- /dev/null +++ b/modules/luci-base/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({{ replace(`${ { + 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 : dispatched, + 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/template/sysauth.ut b/modules/luci-base/ucode/template/sysauth.ut new file mode 100644 index 0000000000..3c580949bb --- /dev/null +++ b/modules/luci-base/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="{{ _('Log in') }}" 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/template/view.ut b/modules/luci-base/ucode/template/view.ut new file mode 100644 index 0000000000..11ac824290 --- /dev/null +++ b/modules/luci-base/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/uhttpd.uc b/modules/luci-base/ucode/uhttpd.uc new file mode 100644 index 0000000000..df1ecc7865 --- /dev/null +++ b/modules/luci-base/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/zoneinfo.uc b/modules/luci-base/ucode/zoneinfo.uc new file mode 100644 index 0000000000..cbf2f7dce9 --- /dev/null +++ b/modules/luci-base/ucode/zoneinfo.uc @@ -0,0 +1,449 @@ +// 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-2EEST,M4.5.5/0,M10.5.4/24', + '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': 'CST6', + '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': 'CST6', + 'America/Ciudad Juarez': 'MST7MDT,M3.2.0,M11.1.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': 'MST7', + 'America/Menominee': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Merida': 'CST6', + 'America/Metlakatla': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Mexico City': 'CST6', + 'America/Miquelon': '<-03>3<-02>,M3.2.0,M11.1.0', + 'America/Moncton': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Monterrey': 'CST6', + '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/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': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0', + 'America/Ojinaga': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Panama': 'EST5', + '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/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/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', + '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': 'MSK-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': 'MSK-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', + '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', +}; |