⛄ Sync 2026-04-23 20:32:35

This commit is contained in:
github-actions[bot] committed 2026-04-23 20:32:35 +08:00
1 parent ac97aa196d
commit 618f49f6ca
206 files changed
+725671

No files matched your search

+160
View File
@@ -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}`);
else
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);
}
};
+150
View File
@@ -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);
}
};
File diff suppressed because it is too large. Load diff
+574
View File
@@ -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);
};
+186
View File
@@ -0,0 +1,186 @@
// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
// Licensed to the public under the Apache License 2.0.
import { access, basename, stat } 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.pkgs_update_time = stat('/lib/apk/db/installed')?.mtime ?? stat('/usr/lib/opkg/status')?.mtime ?? 0;
self.env.include = (...args) => self.render_any(...args);
return self;
};
+160
View File
@@ -0,0 +1,160 @@
// 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();
}
let nfct = open('/proc/net/nf_conntrack', 'r');
if (! nfct) {
nfct = popen('/usr/sbin/conntrack -L -o extended', '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`);
};
+24
View File
@@ -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') %}
+14
View File
@@ -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') %}
+67
View File
@@ -0,0 +1,67 @@
{#
Copyright 2022 Jo-Philipp Wich <jo@mein.io>
Licensed to the public under the Apache License 2.0.
-#}
<!--]]>--><!--'>--><!--">-->
<style>
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>
+42
View File
@@ -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>
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>
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' }} -->
+31
View File
@@ -0,0 +1,31 @@
{#
Copyright 2022 Jo-Philipp Wich <jo@mein.io>
Licensed to the public under the Apache License 2.0.
-#}
{%
include(`themes/${theme}/header`);
-%}
<script src="{{ resource }}/luci.js?v={# PKG_VERSION #}-{{ pkgs_update_time }}"></script>
<script>
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>
+73
View File
@@ -0,0 +1,73 @@
{#
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" />
</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>
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.svg?' + Math.random();
setTimeout(function() { img.src = '' }, 5000);
});
}
</script>
{% include('footer') %}
+12
View File
@@ -0,0 +1,12 @@
{% include('header') %}
<div id="view">
<div class="spinning">{{ _('Loading view…') }}</div>
<script>
L.require('ui').then(function(ui) {
ui.instantiateView('{{ view }}');
});
</script>
</div>
{% include('footer') %}
+12
View File
@@ -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();
};
+449
View File
@@ -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': '<-03>3',
'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/Coyhaique': '<-03>3',
'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-au-Prince': 'EST5EDT,M3.2.0,M11.1.0',
'America/Port_of_Spain': 'AST4',
'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': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0',
'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': '<+08>-8',
'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': '<+05>-5',
'Arctic/Longyearbyen': 'CET-1CEST,M3.5.0,M10.5.0/3',
'Asia/Aden': '<+03>-3',
'Asia/Almaty': '<+05>-5',
'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/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': '<+05>-5',
'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/3,M10.5.0/4',
'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',
};