update 2026-11-01 17:09:57

This commit is contained in:
action committed 2026-11-01 17:09:57 +08:00
1 parent fd8da734bf
commit a71748a807
210 files changed
+11401 -15592

No files matched your search

@@ -0,0 +1,798 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require rpc';
'require view';
'require view.honk.rpc as honk';
'require view.honk.converter as converter';
'require ui';
const DAE_LIMIT = 2 * 1024 * 1024;
const PASTE_LIMIT = 2 * 1024 * 1024;
function utf8Length(value) {
return new TextEncoder().encode(value).length;
}
function isHttpUrl(value) {
try {
const url = new URL(value);
return (url.protocol === 'http:' || url.protocol === 'https:') && !!url.hostname;
}
catch (e) {
return false;
}
}
function loadYamlParser() {
if (window.jsyaml && window.jsyaml.load)
return Promise.resolve(window.jsyaml);
return new Promise(function(resolve, reject) {
const script = document.createElement('script');
script.src = L.resource('view/honk/vendor/js-yaml.min.js');
script.onload = function() {
if (window.jsyaml && window.jsyaml.load)
resolve(window.jsyaml);
else
reject(new Error(_('YAML parser failed to load')));
};
script.onerror = function() { reject(new Error(_('YAML parser failed to load'))); };
document.head.appendChild(script);
});
}
function parsePasted(content) {
let text = String(content || '').trim();
if (!text)
return Promise.resolve({ links: [], rejected: 1 });
if (!converter.looksLikeNodeList(text)) {
const decoded = converter.tryBase64Decode(text);
if (decoded && (converter.looksLikeNodeList(decoded) ||
converter.looksLikeClashYaml(decoded) || converter.looksLikeSurge(decoded)))
text = decoded;
}
if (converter.looksLikeMihomoJson(text))
return Promise.resolve(converter.parseMihomoJson(text));
if (converter.looksLikeClashYaml(text)) {
return loadYamlParser().then(function(yaml) {
const doc = yaml.load(text);
const proxies = (doc && doc.proxies) || [];
const links = [];
let rejected = 0;
proxies.forEach(function(node) {
if (converter.isMetadataProxy(node)) {
rejected++;
return;
}
const result = converter.convertProxy(node);
if (result.ok) links.push(result.link);
else rejected++;
});
return { links: links, rejected: rejected };
});
}
if (converter.looksLikeSurge(text))
return Promise.resolve(converter.parseSurgeProxies(text));
return Promise.resolve(converter.parseUriList(text));
}
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
render: function() {
honk.installStyles();
const page = E('div', { 'class': 'cbi-map honk-page' });
page.appendChild(E('div', { 'class': 'honk-header' }, [
E('h2', {}, _('Configuration')),
E('p', { 'class': 'honk-header-sub' }, _('Honk reads its nodes, subscriptions and routing from /etc/honk/config.dae.'))
]));
const modeText = E('span', {}, '');
const modeNote = E('p', { 'class': 'honk-note honk-mode-note' }, [ modeText ]);
page.appendChild(modeNote);
/* --- Add subscription --- */
const subStatus = E('p', { 'class': 'honk-note', 'role': 'status', 'aria-live': 'polite' }, '');
const nameInput = E('input', {
'class': 'cbi-input-text', 'name': 'sub_name',
'placeholder': _('Name, e.g. MyAirport'), 'maxlength': '256'
});
const urlInput = E('input', {
'class': 'cbi-input-text', 'name': 'sub_url',
'placeholder': 'https://…', 'inputmode': 'url'
});
const subBtn = E('button', { 'class': 'cbi-button cbi-button-apply', 'type': 'button' }, [ _('Add subscription') ]);
subBtn.addEventListener('click', function(ev) {
ev.preventDefault();
const name = nameInput.value.trim();
const url = urlInput.value.trim();
if (!name) { subStatus.textContent = _('Name is required.'); nameInput.focus(); return; }
if (!isHttpUrl(url)) { subStatus.textContent = _('A valid HTTP or HTTPS URL is required.'); urlInput.focus(); return; }
subBtn.disabled = true;
preflightSubscription(url, subStatus).then(function(proceed) {
if (!proceed)
return null;
subStatus.textContent = _('Adding subscription…');
return withLongTimeout(function() {
return honk.addSubscription(name, url).then(honk.ensureOk);
}).then(function(result) {
subStatus.textContent = honk.resultMessage(result, _('Subscription saved with usable nodes.'));
nameInput.value = '';
urlInput.value = '';
}).catch(function(error) {
subStatus.textContent = honk.errorMessage(error, _('Could not add the subscription.'));
});
}).finally(function() { subBtn.disabled = false; });
});
const subModeHint = E('p', { 'class': 'honk-note honk-mode-hint' }, _('Manual mode: this tool is disabled until you restore managed mode (link at the top).'));
subModeHint.hidden = true;
const subSection = E('section', { 'class': 'honk-card' }, [
E('p', { 'class': 'honk-note' }, _('Enter a name and subscription URL. Honk checks for usable nodes before saving.')),
subModeHint,
E('div', { 'class': 'honk-subscription-add' }, [ nameInput, urlInput, subBtn ]),
subStatus
]);
/* LuCI 的 rpc 默认 20s 超时;核心校验节点、重启服务都可能超过它,
期间客户端一断,服务端处理器会被杀掉(留下半完成状态)。 */
function withLongTimeout(fn) {
const previous = L.env.rpctimeout;
L.env.rpctimeout = 180;
return Promise.resolve().then(fn).finally(function() {
L.env.rpctimeout = previous;
});
}
/* honk 内核支持的节点类型(见 core 的 clash.rs protocol_from_name):
ssr / snell / 带插件的 ss / 小众协议都会被内核判为不支持。 */
const SUPPORTED_PROTOCOLS = {
'socks': 1, 'socks5': 1, 'ss': 1, 'shadowsocks': 1, 'trojan': 1,
'vmess': 1, 'vless': 1, 'hysteria': 1, 'hysteria2': 1, 'hy2': 1,
'tuic': 1, 'juicity': 1, 'anytls': 1
};
/* JSON 订阅里 outbounds 会混进这些非节点类型,不能算作节点。 */
const NON_NODE_TYPES = {
'direct': 1, 'block': 1, 'dns': 1, 'selector': 1, 'urltest': 1,
'url-test': 1, 'fallback': 1, 'load-balance': 1, 'relay': 1, 'reject': 1
};
function analyzeProbe(probe) {
const counts = {};
let total = 0;
let usable = 0;
let unsupported = 0;
const bump = function(type) {
const name = String(type || '').toLowerCase();
if (!name || NON_NODE_TYPES[name])
return;
counts[name] = (counts[name] || 0) + 1;
total++;
if (SUPPORTED_PROTOCOLS[name])
usable++;
else
unsupported++;
};
if (probe.format === 'base64' && probe.content) {
const decoded = converter.tryBase64Decode(probe.content) || '';
(decoded.match(/^[A-Za-z0-9+.-]+:\/\//gim) || []).forEach(function(scheme) {
bump(scheme.replace(/:\/\/$/, ''));
});
}
else if (probe.types) {
String(probe.types).trim().split(/\s+/).forEach(function(pair) {
const parts = pair.split(':');
if (parts.length === 2) {
const n = parseInt(parts[1], 10);
for (let i = 0; i < (isNaN(n) ? 0 : n); i++)
bump(parts[0]);
}
});
}
const labels = {
clash: 'Clash / mihomo YAML',
json: 'JSON(sing-box 等)',
records: 'Surge / Loon 记录式 INI',
uri: 'URI 列表',
base64: 'Base64'
};
const label = labels[probe.format] || _('unknown format');
const detail = Object.keys(counts).sort().map(function(type) {
return type + ' ×' + counts[type] + (SUPPORTED_PROTOCOLS[type] ? '' : ' ' + _('unsupported'));
}).join(', ');
const text = total
? _('Detected %s · %d node(s): %s').format(label, total, detail)
: _('Detected %s.').format(label);
return { text: text, total: total, usable: usable, unsupported: unsupported };
}
/* 保存前预检:能全部用就直接保存;全不可用就拦下;部分可用问一句。
预检本身失败(网络抖动等)不拦保存,退回原来的行为。 */
function preflightSubscription(url, status) {
status.textContent = _('Checking the subscription before saving…');
return honk.probeSubscription(url).then(function(probe) {
const verdict = analyzeProbe(probe);
if (!verdict.total)
return true;
status.textContent = verdict.text;
if (verdict.usable === 0) {
status.textContent = verdict.text + ' ' + _('This core cannot use any of these nodes, so the subscription was not saved.');
return false;
}
if (verdict.unsupported && !confirm(verdict.text + '\n\n' + _('Save the subscription anyway?')))
return false;
return true;
}).catch(function() {
return true;
});
}
/* --- Paste nodes --- */
const pasteStatus = E('p', { 'class': 'honk-note', 'role': 'status', 'aria-live': 'polite' }, '');
const pasteInput = E('textarea', {
'class': 'cbi-input-textarea', 'name': 'paste',
'rows': '10', 'spellcheck': 'false',
'placeholder': _('Paste Clash YAML, Surge INI, Base64 or share links.')
});
const pasteBtn = E('button', { 'class': 'cbi-button cbi-button-apply', 'type': 'button' }, [ _('Import nodes') ]);
pasteBtn.addEventListener('click', function(ev) {
ev.preventDefault();
const content = pasteInput.value.trim();
if (!content) { pasteStatus.textContent = _('Paste nodes or subscription content first.'); pasteInput.focus(); return; }
if (utf8Length(content) > PASTE_LIMIT) { pasteStatus.textContent = _('The pasted content is too large.'); return; }
pasteBtn.disabled = true;
pasteStatus.textContent = _('Parsing nodes…');
parsePasted(content).then(function(parsed) {
const links = (parsed && parsed.links) || [];
if (!links.length) {
pasteStatus.textContent = _('No nodes could be parsed from the input.');
return;
}
pasteStatus.textContent = _('Importing %d node(s)…').format(links.length);
return honk.addNodes(links.join('\n')).then(honk.ensureOk).then(function(result) {
pasteStatus.textContent = honk.resultMessage(result, _('%d node(s) imported.').format(links.length));
pasteInput.value = '';
});
}).catch(function(error) {
pasteStatus.textContent = honk.errorMessage(error, _('Could not import nodes.'));
}).finally(function() { pasteBtn.disabled = false; });
});
const pasteModeHint = E('p', { 'class': 'honk-note honk-mode-hint' }, _('Manual mode: this tool is disabled until you restore managed mode (link at the top).'));
pasteModeHint.hidden = true;
const pasteSection = E('section', { 'class': 'honk-card' }, [
E('p', { 'class': 'honk-note' }, _('Paste Clash YAML, Surge INI, Base64 or share links. They are parsed locally into nodes and appended to /etc/honk/config.dae.')),
pasteModeHint,
pasteInput,
E('div', { 'class': 'honk-actions' }, [ pasteBtn ]),
pasteStatus
]);
/* --- Import a complete configuration and start Honk with it --- */
const importStatus = E('p', { 'class': 'honk-note', 'role': 'status', 'aria-live': 'polite' }, '');
/* 原生 file 控件在各主题下都被拉成一个大盒子、内嵌按钮贴着左上角,
和右边的按钮对不齐。改成隐藏原生控件、用 label 当按钮。 */
const fileInput = E('input', { 'type': 'file', 'name': 'dae', 'accept': '.dae,text/plain', 'hidden': 'hidden' });
const fileName = E('span', { 'class': 'honk-file-name' }, _('No file chosen'));
const filePick = E('label', { 'class': 'cbi-button honk-file-pick' }, [ _('Choose file'), fileInput ]);
fileInput.addEventListener('change', function() {
const picked = fileInput.files && fileInput.files[0];
fileName.textContent = picked ? picked.name : _('No file chosen');
});
const importBtn = E('button', { 'class': 'cbi-button cbi-button-apply', 'type': 'button' }, [ _('Import and start') ]);
const importSection = E('section', { 'class': 'honk-card' }, [
E('p', { 'class': 'honk-note' }, _('Upload a complete dae file (subscriptions, nodes and routing included) and Honk starts with it: the file is validated, written to /etc/honk/config.dae and the service is restarted. To hand-edit or paste instead, use the editor below.')),
E('div', { 'class': 'honk-file-row' }, [ filePick, fileName, importBtn ]),
importStatus
]);
/* --- Direct configuration --- */
const editorStatus = E('p', { 'class': 'honk-note', 'role': 'status', 'aria-live': 'polite' }, '');
const editor = E('textarea', {
'class': 'cbi-input-textarea honk-config-editor', 'rows': '26',
'spellcheck': 'false', 'wrap': 'off', 'aria-label': _('Full dae configuration')
});
const saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'button' }, [ _('Save and apply') ]);
const reloadBtn = E('button', { 'class': 'cbi-button', 'type': 'button' }, [ _('Reload from disk') ]);
const validateBtn = E('button', { 'class': 'cbi-button cbi-button-link', 'type': 'button' }, [ _('Check configuration') ]);
const managedBtn = E('a', { 'href': '#', 'class': 'honk-inline-link', 'role': 'button' }, [ _('Restore managed mode') ]);
managedBtn.hidden = true;
modeNote.appendChild(managedBtn);
let editorBaseline = '';
function setEditorContent(content, message) {
editor.value = content || '';
editorBaseline = editor.value;
editorStatus.textContent = message || '';
}
function confirmEditorReplace() {
return editor.value === editorBaseline || confirm(_('Discard the unsaved editor changes?'));
}
function isOk(result) {
return !!result && (result.ok === true || result.ok === 1);
}
/* Validation result -> one readable line. Valid configs also report the
runtime facts the core derived from the file, which is what users
actually want to confirm before touching a live router. */
function validationText(result) {
if (isOk(result)) {
const runtime = result.runtime || {};
const facts = [];
if (Array.isArray(runtime.lan_interfaces) && runtime.lan_interfaces.length)
facts.push(_('LAN %s').format(runtime.lan_interfaces.join(', ')));
if (runtime.native_api_enabled)
facts.push(_('Doona %s').format(runtime.native_api_listen || '—'));
if (runtime.clash_api_listen)
facts.push(_('Clash API %s').format(runtime.clash_api_listen));
return facts.length
? _('Configuration is valid: %s').format(facts.join(' · '))
: _('Configuration is valid.');
}
const diagnostics = Array.isArray(result && result.diagnostics) ? result.diagnostics : [];
if (!diagnostics.length)
return honk.resultMessage(result, _('Configuration validation failed.'));
return diagnostics.map(function(item) {
const line = Number.isInteger(item.line) ? _('Line %d').format(item.line) : '';
const setting = item.setting ? String(item.setting) : String(item.code || '');
const message = String(item.message || '');
return [ line, setting, message ].filter(Boolean).join(' · ');
}).join(' | ');
}
/* 本机事实:LuCI 的 Doona 端口、活跃 L3 候选(含所属 netifd 网络)。取一次复用。 */
let runtimeFacts = null;
function runtimeContext() {
if (!runtimeFacts)
runtimeFacts = Promise.all([
honk.status().catch(function() { return {}; }),
honk.lanCandidates().catch(function() { return {}; })
]).then(function(results) {
const status = results[0] || {};
const candidates = ((results[1] && results[1].candidates) || []).filter(function(item) {
return item && item.l3_device;
});
return {
listenPort: String(status.listen_port || 9527),
lanNetwork: status.lan_network || 'lan',
candidates: candidates,
devices: candidates.map(function(item) { return item.l3_device; })
};
});
return runtimeFacts;
}
/* 只在 native_api 块里改 ui,而且值必须**正好**是 embedded(embedded-custom 不算)。 */
function fixNativeUi(text) {
const lines = text.split('\n');
const start = lines.findIndex(function(line) { return /^[ \t]*native_api[ \t]*\{/.test(line); });
if (start < 0)
return null;
const baseIndent = lines[start].match(/^[ \t]*/)[0].length;
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i];
if (!line.trim())
continue;
const indent = line.match(/^[ \t]*/)[0].length;
if (indent <= baseIndent && /^[ \t]*\}/.test(line))
break;
const match = /^([ \t]*ui[ \t]*:[ \t]*)(['"]?)embedded\2[ \t]*(#.*)?$/.exec(line);
if (match) {
lines[i] = match[1] + "'/usr/share/doona'" + (match[3] ? ' ' + match[3] : '');
return { text: lines.join('\n') };
}
}
return null;
}
function locateLanInterface(text) {
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const match = /^([ \t]*)lan_interface[ \t]*:[ \t]*([^\n#]+?)[ \t]*(#.*)?$/.exec(lines[i]);
if (match)
return { index: i, indent: match[1], value: match[2], comment: match[3] };
}
return null;
}
/* 优先 netifd 里配置的 LAN 网络;不做「取第一个活跃设备」,那可能是 WAN / VPN。 */
function preferredLanDevice(ctx) {
const order = [ ctx.lanNetwork, 'lan' ];
for (let i = 0; i < order.length; i++) {
const hit = ctx.candidates.find(function(item) { return item.network === order[i]; });
if (hit)
return { device: hit.l3_device, source: hit.network };
}
return null;
}
/* 多候选且无法确定时让用户挑;取消返回 null(表示不改这一项)。
ui.showModal() 返回的是 DOM 元素、不是 Promise,所以只能靠按钮回调
resolve;Esc 会被 LuCI 转成点击 .right 里第一个按钮(即取消)。 */
function chooseLanDevice(ctx) {
return new Promise(function(resolve) {
const select = E('select', { 'class': 'cbi-input-select' }, ctx.candidates.map(function(item) {
const label = [ item.network, item.l3_device, item.ipv4 ].filter(Boolean).join(' · ');
return E('option', { 'value': item.l3_device }, label);
}));
ui.showModal(_('Choose the LAN interface'), [
E('div', {}, [
E('p', {}, _('None of the active interfaces belongs to the configured LAN network. Pick the one Honk should see as LAN:')),
select
]),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': function(ev) {
ev.preventDefault();
ui.hideModal();
resolve(null);
} }, [ _('Cancel') ]),
' ',
E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function(ev) {
ev.preventDefault();
ui.hideModal();
resolve(select.value || null);
} }, [ _('Use this interface') ])
])
]);
});
}
/* 本机适配计划:算出「要改哪几处」和「改完的文本」,但**不落盘**。
allowPrompt = true 时才会弹选择框(校验时不要弹)。 */
function planAdaptations(text, allowPrompt) {
return runtimeContext().then(function(ctx) {
const notes = [];
const warnings = [];
let next = text;
const uiFix = fixNativeUi(next);
if (uiFix) {
next = uiFix.text;
notes.push("native_api.ui: embedded → '/usr/share/doona'");
}
const lan = locateLanInterface(next);
if (!lan)
return { notes: notes, warnings: warnings, text: next };
const named = lan.value.split(',').map(function(item) {
return item.trim().replace(/^['"]|['"]$/g, '');
}).filter(Boolean);
if (!named.length || !ctx.devices.length || named.some(function(name) { return ctx.devices.indexOf(name) >= 0; }))
return { notes: notes, warnings: warnings, text: next };
const preferred = preferredLanDevice(ctx);
let pick;
if (preferred)
pick = Promise.resolve(preferred.device);
else if (ctx.candidates.length === 1)
pick = Promise.resolve(ctx.candidates[0].l3_device);
else if (allowPrompt)
pick = chooseLanDevice(ctx);
else
pick = Promise.resolve(null);
return pick.then(function(device) {
if (!device) {
/* 用户在选择框里按了取消:直接中止这次保存,不要再问一遍。 */
if (allowPrompt && ctx.candidates.length > 1)
return { notes: notes, warnings: warnings, text: next, cancelled: true };
warnings.push(_('lan_interface: cannot tell which interface is the LAN here (candidates: %s); set it yourself').format(ctx.devices.join(', ') || '—'));
return { notes: notes, warnings: warnings, text: next };
}
const lines = next.split('\n');
lines[lan.index] = lan.indent + "lan_interface: '" + device + "'" + (lan.comment ? ' ' + lan.comment : '');
notes.push('lan_interface: ' + named.join(',') + ' → ' + device);
return { notes: notes, warnings: warnings, text: lines.join('\n') };
});
});
}
/* 保存前把适配写进编辑器。返回 false 表示用户拒绝或内容已变,调用方应中止。 */
function adaptEditor() {
const snapshot = editor.value;
return planAdaptations(snapshot, true).then(function(plan) {
if (plan.cancelled) {
editorStatus.textContent = _('Save cancelled.');
return false;
}
if (!plan.notes.length && !plan.warnings.length)
return true;
const blocks = [];
if (plan.notes.length)
blocks.push(_('These lines will be changed for this router:') + '\n' + plan.notes.join('\n'));
if (plan.warnings.length)
blocks.push(_('Needs your attention:') + '\n' + plan.warnings.join('\n'));
blocks.push(_('Apply them in the editor now?'));
if (!confirm(blocks.join('\n\n')))
return false;
/* 等设备查询 / 用户选择期间编辑器可能被改过,别用旧结果覆盖。 */
if (editor.value !== snapshot) {
editorStatus.textContent = _('The editor changed while checking. Nothing was applied; check again.');
return false;
}
if (plan.notes.length) {
editor.value = plan.text;
editor.dispatchEvent(new Event('input'));
editorStatus.textContent = _('Adapted in the editor: %s').format(plan.notes.join('; '));
}
return true;
});
}
function withNotes(text) {
return planAdaptations(editor.value, false).then(function(plan) {
const parts = [];
if (plan.notes.length)
parts.push(_('Saving will adapt for this router: %s').format(plan.notes.join(', ')));
if (plan.warnings.length)
parts.push(plan.warnings.join(' '));
return parts.length ? text + ' ' + parts.join(' ') : text;
});
}
function reportValidation(result, target) {
return withNotes(validationText(result)).then(function(text) {
target.textContent = text;
});
}
function pollStatus() {
return honk.status().catch(function() { return null; });
}
/* Validate -> confirm -> write -> start, shared by the file import and the
editor. `target` is the status line the caller wants the outcome in. */
function applyContent(content, target) {
target.textContent = _('Checking the configuration…');
return honk.configValidate(content).then(function(result) {
if (!isOk(result)) {
target.textContent = validationText(result);
return;
}
return Promise.resolve().then(function() {
const question = _('Write this configuration to /etc/honk/config.dae and start Honk? The previous file is restored if startup fails.');
if (!confirm(question))
return;
return commitContent(content, target);
});
}).catch(function(error) {
target.textContent = honk.errorMessage(error, _('Configuration validation failed.'));
});
}
function commitContent(content, target) {
target.textContent = _('Writing the configuration…');
return withLongTimeout(function() { return honk.configSave(content); }).then(function(result) {
if (!isOk(result)) {
target.textContent = validationText(result);
return;
}
if (content === editor.value)
editorBaseline = editor.value;
target.textContent = _('Starting Honk…');
return withLongTimeout(function() { return honk.configApply().then(honk.ensureOk); }).then(function(applied) {
target.textContent = honk.resultMessage(applied, _('Configuration applied.'));
return pollStatus().then(function(status) {
if (status && status.running)
target.textContent = _('Configuration applied and Honk is running.');
else
target.textContent = _('Configuration was written, but Honk is not running: %s').format(
((status && status.errors) || []).map(honk.statusIssue).join('; ') || _('unknown reason'));
});
}).catch(function(error) {
target.textContent = honk.errorMessage(error, _('Could not apply the configuration.'));
});
}).catch(function(error) {
target.textContent = honk.errorMessage(error, _('Could not save the configuration.'));
}).then(function() {
/* A failed apply rolls the file back, so reload what is really on
disk — but keep the outcome message the user needs to read. */
const outcome = target.textContent;
return refreshMode().then(function() {
return loadCurrent(outcome === editorStatus.textContent ? outcome : '', true).catch(function() {});
});
});
}
function refreshMode() {
return honk.status().then(function(status) {
const manual = !!status && status.config_mode === 'manual';
managedBtn.hidden = !manual;
const tabState = page._honkTabs;
if (tabState) {
tabState.tabs.forEach(function(tab) {
const disabled = manual && tab.managedOnly;
tab.button.disabled = disabled;
tab.panel.hidden = tab.id !== tabState.getActive();
});
if (manual && ['sub', 'nodes'].indexOf(tabState.getActive()) >= 0)
tabState.select('edit');
}
subModeHint.hidden = !manual;
pasteModeHint.hidden = !manual;
subBtn.disabled = manual;
pasteBtn.disabled = manual;
nameInput.disabled = manual;
urlInput.disabled = manual;
pasteInput.disabled = manual;
modeText.textContent = manual
? _('Manual mode: the file below owns nodes, groups, routing and DNS.')
: _('Managed mode: the subscription and paste-nodes tools are active. Saving here switches Honk to manual mode.');
return status;
}).catch(function() {
modeText.textContent = '';
});
}
/* force = false 用于页面初次载入:如果用户已经抢先开始输入,不要用磁盘内容
把他的编辑冲掉(异步回填比手速快)。apply 之后要的是磁盘真身,用 force。 */
function loadCurrent(message, force) {
return honk.configRead().then(honk.ensureOk).then(function(result) {
if (!force && editor.value !== editorBaseline && editor.value !== '')
return result;
setEditorContent(result.content || '', message || _('Current configuration loaded from /etc/honk/config.dae.'));
return result;
});
}
importBtn.addEventListener('click', function(ev) {
ev.preventDefault();
const file = fileInput.files && fileInput.files[0];
if (!file) { importStatus.textContent = _('Choose a .dae file first.'); return; }
if (file.size > DAE_LIMIT) { importStatus.textContent = _('The dae file exceeds the 2 MiB limit.'); return; }
importBtn.disabled = true;
importStatus.textContent = _('Reading %s…').format(file.name);
file.text().then(function(content) {
if (!String(content || '').trim())
throw new Error(_('The file is empty.'));
fileInput.value = '';
setEditorContent(content, _('Loaded %s into the editor.').format(file.name));
const snapshot = editor.value;
return planAdaptations(content, true).then(function(plan) {
if (plan.cancelled) {
importStatus.textContent = _('Import cancelled.');
return null;
}
const blocks = [];
if (plan.notes.length)
blocks.push(_('These lines will be changed for this router:') + '\n' + plan.notes.join('\n'));
if (plan.warnings.length)
blocks.push(_('Needs your attention:') + '\n' + plan.warnings.join('\n'));
if (blocks.length) {
blocks.push(_('Apply them in the editor now?'));
if (!confirm(blocks.join('\n\n')))
return null;
}
if (editor.value !== snapshot) {
importStatus.textContent = _('The editor changed while checking. Nothing was applied; import the file again.');
return null;
}
if (plan.notes.length)
setEditorContent(plan.text, _('Adapted in the editor: %s').format(plan.notes.join('; ')));
return applyContent(editor.value, importStatus).then(function() {
if (importStatus.textContent === _('Configuration applied and Honk is running.'))
importStatus.textContent = _('%s imported and Honk is running.').format(file.name);
});
});
}).catch(function(error) {
importStatus.textContent = honk.errorMessage(error, _('Could not read the configuration file.'));
}).finally(function() { importBtn.disabled = false; });
});
reloadBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (!confirmEditorReplace()) return;
reloadBtn.disabled = true;
loadCurrent(null, true).catch(function(error) {
editorStatus.textContent = honk.errorMessage(error, _('Could not read the saved configuration.'));
}).finally(function() { reloadBtn.disabled = false; });
});
validateBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (!editor.value.trim()) { editorStatus.textContent = _('The editor is empty.'); return; }
validateBtn.disabled = true;
editorStatus.textContent = _('Checking the configuration…');
honk.configValidate(editor.value).then(function(result) {
return reportValidation(result, editorStatus);
}).catch(function(error) {
editorStatus.textContent = honk.errorMessage(error, _('Configuration validation failed.'));
}).finally(function() { validateBtn.disabled = false; });
});
saveBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (!editor.value.trim()) { editorStatus.textContent = _('The editor is empty.'); return; }
saveBtn.disabled = true;
adaptEditor().then(function(proceed) {
if (!proceed) { editorStatus.textContent = _('Save cancelled.'); return null; }
return applyContent(editor.value, editorStatus);
}).finally(function() { saveBtn.disabled = false; });
});
managedBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (!confirm(_('Restore the managed configuration and return to the subscription tools? Honk restarts if it is running.'))) return;
managedBtn.disabled = true;
editorStatus.textContent = _('Restoring the managed configuration…');
withLongTimeout(function() { return honk.configUseManaged().then(honk.ensureOk); }).then(function(result) {
editorStatus.textContent = honk.resultMessage(result, _('Managed mode restored.'));
return refreshMode().then(function() { return loadCurrent(null, true); });
}).catch(function(error) {
editorStatus.textContent = honk.errorMessage(error, _('Could not restore managed mode.'));
}).finally(function() { managedBtn.disabled = false; });
});
const editorSection = E('section', { 'class': 'honk-card' }, [
E('p', { 'class': 'honk-note' }, _('Edit or paste a complete dae file. Check configuration only parses the text; Save and apply writes /etc/honk/config.dae, restarts Honk, and rolls back if startup fails.')),
E('div', { 'class': 'honk-subscription-add' }, [ saveBtn, reloadBtn, validateBtn ]),
editor,
editorStatus
]);
loadCurrent().catch(function(error) {
editorStatus.textContent = honk.errorMessage(error, _('Could not read the saved configuration.'));
});
refreshMode();
/* 一排标签页,只显示当前板块:四张卡片竖着堆太占屏,也不好一眼看出"有几种做法"。 */
const tabs = [
{ id: 'sub', label: _('Add subscription'), panel: subSection, managedOnly: true },
{ id: 'nodes', label: _('Add nodes'), panel: pasteSection, managedOnly: true },
{ id: 'import', label: _('Import config'), panel: importSection, managedOnly: false },
{ id: 'edit', label: _('Edit config'), panel: editorSection, managedOnly: false }
];
let activeTab = 'sub';
const tabBar = E('div', { 'class': 'honk-seg honk-tabs', 'role': 'tablist' });
function selectTab(id) {
activeTab = id;
tabs.forEach(function(tab) {
const active = tab.id === id;
tab.panel.hidden = !active;
tab.button.setAttribute('aria-selected', active ? 'true' : 'false');
tab.button.classList.toggle('is-active', active);
});
}
tabs.forEach(function(tab) {
tab.button = E('button', {
'class': 'honk-seg-btn', 'type': 'button', 'role': 'tab',
'aria-selected': 'false'
}, [ tab.label ]);
tab.button.addEventListener('click', function(ev) {
ev.preventDefault();
if (tab.button.disabled)
return;
selectTab(tab.id);
});
tab.panel.hidden = true;
tabBar.appendChild(tab.button);
});
page.appendChild(E('div', { 'class': 'honk-tabs-wrap' }, [ tabBar ]));
tabs.forEach(function(tab) { page.appendChild(tab.panel); });
selectTab(activeTab);
/* 曝光给 refreshMode:手动模式下订阅/节点两个标签不可用,自动切到编辑配置。 */
page._honkTabs = { tabs: tabs, select: selectTab, getActive: function() { return activeTab; } };
return page;
}
});
@@ -0,0 +1,501 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require baseclass';
function utf8Base64(value) {
const bytes = new TextEncoder().encode(String(value));
let binary = '';
for (let i = 0; i < bytes.length; i++)
binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function utf8FromBase64(value) {
const binary = atob(value);
const bytes = Uint8Array.from(binary, function(ch) { return ch.charCodeAt(0); });
return new TextDecoder('utf-8').decode(bytes);
}
function base64Url(value) {
return utf8Base64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function endpoint(server, port) {
const host = String(server).indexOf(':') >= 0 && String(server)[0] !== '['
? '[' + server + ']'
: server;
return host + ':' + port;
}
function requireFields(node, fields) {
for (let i = 0; i < fields.length; i++) {
const key = fields[i];
if (node[key] === undefined || node[key] === null || node[key] === '')
throw new Error('Missing required field: ' + key);
}
}
function setIf(params, key, value) {
if (value !== undefined && value !== null && value !== '')
params.set(key, String(value));
}
function arrayValue(value) {
return Array.isArray(value) ? value.join(',') : value;
}
function addTransport(params, node) {
const network = node.network || 'tcp';
params.set('type', network);
if (network === 'ws') {
const opts = node['ws-opts'] || {};
setIf(params, 'path', opts.path);
setIf(params, 'host', opts.headers && (opts.headers.Host || opts.headers.host));
} else if (network === 'grpc') {
const opts = node['grpc-opts'] || {};
setIf(params, 'serviceName', opts['grpc-service-name'] || opts.serviceName);
} else if (network === 'http' || network === 'h2') {
const opts = node['h2-opts'] || node['http-opts'] || {};
setIf(params, 'path', Array.isArray(opts.path) ? opts.path[0] : opts.path);
setIf(params, 'host', arrayValue(opts.host));
}
}
function convertSs(node) {
requireFields(node, [ 'server', 'port', 'cipher', 'password' ]);
const auth = base64Url(node.cipher + ':' + node.password);
let query = '';
if (node.plugin) {
const fields = [ String(node.plugin) ];
const opts = node['plugin-opts'] || {};
Object.keys(opts).forEach(function(key) {
const value = opts[key];
if (value === true)
fields.push(key);
else if (value !== false && value !== undefined && value !== null && value !== '')
fields.push(key + '=' + value);
});
const params = new URLSearchParams();
params.set('plugin', fields.join(';'));
query = '?' + params.toString();
}
return 'ss://' + auth + '@' + endpoint(node.server, node.port) + query + '#' + encodeURIComponent(node.name || 'Node');
}
function convertSsr(node) {
requireFields(node, [ 'server', 'port', 'cipher', 'password', 'protocol', 'obfs' ]);
const query = [];
query.push('remarks=' + base64Url(node.name || 'Node'));
if (node['protocol-param'])
query.push('protoparam=' + base64Url(node['protocol-param']));
if (node['obfs-param'])
query.push('obfsparam=' + base64Url(node['obfs-param']));
const payload = [
node.server,
node.port,
node.protocol,
node.cipher,
node.obfs,
base64Url(node.password)
].join(':') + '/?' + query.join('&');
return 'ssr://' + base64Url(payload);
}
function convertVmess(node) {
requireFields(node, [ 'server', 'port', 'uuid' ]);
const network = node.network || 'tcp';
const ws = node['ws-opts'] || {};
const grpc = node['grpc-opts'] || {};
const h2 = node['h2-opts'] || {};
const payload = {
v: '2',
ps: String(node.name || 'Node'),
add: String(node.server),
port: String(node.port),
id: String(node.uuid),
aid: Number(node.alterId || node.alter_id || 0),
scy: String(node.cipher || 'auto'),
net: network,
type: 'none',
host: String((ws.headers && (ws.headers.Host || ws.headers.host)) || arrayValue(h2.host) || ''),
path: String(ws.path || grpc['grpc-service-name'] || (Array.isArray(h2.path) ? h2.path[0] : h2.path) || ''),
tls: node.tls ? 'tls' : 'none',
sni: String(node.servername || node.sni || ''),
alpn: String(arrayValue(node.alpn) || ''),
fp: String(node.fingerprint || node['client-fingerprint'] || '')
};
return 'vmess://' + utf8Base64(JSON.stringify(payload));
}
function convertVless(node) {
requireFields(node, [ 'server', 'port', 'uuid' ]);
const params = new URLSearchParams();
addTransport(params, node);
params.set('encryption', String(node.encryption || 'none'));
setIf(params, 'flow', node.flow);
const reality = node['reality-opts'];
if (reality || node.tls) {
params.set('security', reality ? 'reality' : 'tls');
setIf(params, 'sni', node.servername || node.sni);
setIf(params, 'fp', node.fingerprint || node['client-fingerprint']);
setIf(params, 'alpn', arrayValue(node.alpn));
if (node['skip-cert-verify'])
params.set('allowInsecure', '1');
if (reality) {
setIf(params, 'pbk', reality['public-key']);
setIf(params, 'sid', reality['short-id']);
setIf(params, 'spx', reality['spider-x']);
}
}
return 'vless://' + encodeURIComponent(node.uuid) + '@' + endpoint(node.server, node.port) +
'?' + params.toString() + '#' + encodeURIComponent(node.name || 'Node');
}
function convertTrojan(node) {
requireFields(node, [ 'server', 'port', 'password' ]);
const params = new URLSearchParams();
addTransport(params, node);
setIf(params, 'sni', node.sni || node.servername);
setIf(params, 'fp', node.fingerprint || node['client-fingerprint']);
setIf(params, 'alpn', arrayValue(node.alpn));
if (node['skip-cert-verify'])
params.set('allowInsecure', '1');
return 'trojan://' + encodeURIComponent(node.password) + '@' + endpoint(node.server, node.port) +
'?' + params.toString() + '#' + encodeURIComponent(node.name || 'Node');
}
function convertTuic(node) {
requireFields(node, [ 'server', 'port', 'uuid', 'password' ]);
const params = new URLSearchParams();
setIf(params, 'sni', node.sni);
setIf(params, 'alpn', arrayValue(node.alpn));
setIf(params, 'congestion_control', node['congestion-controller'] || node['congestion-control']);
setIf(params, 'udp_relay_mode', node['udp-relay-mode']);
if (node['skip-cert-verify'])
params.set('allow_insecure', '1');
return 'tuic://' + encodeURIComponent(node.uuid) + ':' + encodeURIComponent(node.password) + '@' +
endpoint(node.server, node.port) + (params.toString() ? '?' + params.toString() : '') +
'#' + encodeURIComponent(node.name || 'Node');
}
function convertHysteria2(node) {
requireFields(node, [ 'server', 'port', 'password' ]);
const params = new URLSearchParams();
setIf(params, 'sni', node.sni);
setIf(params, 'alpn', arrayValue(node.alpn));
setIf(params, 'obfs', node.obfs);
setIf(params, 'obfs-password', node['obfs-password']);
if (node['skip-cert-verify'])
params.set('insecure', '1');
return 'hysteria2://' + encodeURIComponent(node.password) + '@' + endpoint(node.server, node.port) +
(params.toString() ? '?' + params.toString() : '') + '#' + encodeURIComponent(node.name || 'Node');
}
function convertAnytls(node) {
requireFields(node, [ 'server', 'port', 'password' ]);
const params = new URLSearchParams();
setIf(params, 'sni', node.sni || node.servername);
setIf(params, 'alpn', arrayValue(node.alpn));
setIf(params, 'client-fingerprint', node['client-fingerprint'] || node.fingerprint);
if (node['skip-cert-verify'])
params.set('insecure', '1');
if (node.udp)
params.set('udp', '1');
return 'anytls://' + encodeURIComponent(node.password) + '@' + endpoint(node.server, node.port) +
(params.toString() ? '?' + params.toString() : '') + '#' + encodeURIComponent(node.name || 'Node');
}
function isMetadataProxy(node) {
const name = String(node && node.name || '').trim();
if (!name)
return false;
return /(?:官网|QQ|流量|续费|应急|重置|到期|过期|剩余|套餐)/i.test(name) ||
/^(?:traffic|bandwidth|expire|expiry|expiration|subscription(?:\s+info)?|official\s+website|website)\s*[::|]/i.test(name) ||
/^(?:(?:剩余|可用|已用|总)?流量|套餐到期|到期时间|到期日|过期时间|有效期|订阅信息)\s*[::|]/i.test(name) ||
/^(?:官方网站|官网地址|官方网址|网站地址)\s*[::|]/i.test(name) ||
/^(?:加入|联系|客服)?\s*QQ\s*(?:群|交流群|客服|联系)\s*[::|]/i.test(name);
}
function convertProxy(node) {
const type = String(node && node.type || '').toLowerCase();
const converters = {
ss: convertSs,
ssr: convertSsr,
vmess: convertVmess,
vless: convertVless,
trojan: convertTrojan,
tuic: convertTuic,
hysteria2: convertHysteria2,
hy2: convertHysteria2,
anytls: convertAnytls
};
try {
if (!node || typeof node !== 'object')
throw new Error('Node must be an object');
if (!converters[type])
throw new Error('Unsupported protocol: ' + (type || 'unknown'));
return {
ok: true,
name: String(node.name || 'Node'),
type: type,
link: converters[type](node),
error: ''
};
} catch (e) {
return {
ok: false,
name: String(node && node.name || 'Node'),
type: type || 'unknown',
link: '',
error: String(e && e.message || e)
};
}
}
function convertProxies(proxies) {
return Array.isArray(proxies) ? proxies.map(convertProxy) : [];
}
// --- Paste-import helpers -------------------------------------------------
const NODE_SCHEMES = /^(ss|ssr|vmess|vless|trojan|tuic|hysteria2?|hy2|anytls|socks5|socks4|juicity|snell):\/\//i;
function looksLikeNodeList(text) {
const lines = String(text || '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line && NODE_SCHEMES.test(line))
return true;
}
return false;
}
function looksLikeClashYaml(text) {
return /\bproxies\s*:/.test(String(text || ''));
}
function looksLikeSurge(text) {
return /^\s*\[Proxy\]\s*$/m.test(String(text || ''));
}
function tryBase64Decode(text) {
const compact = String(text || '').replace(/\s+/g, '');
if (compact.length < 4 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(compact))
return '';
let normalized = compact.replace(/-/g, '+').replace(/_/g, '/');
while (normalized.length % 4)
normalized += '=';
try {
return utf8FromBase64(normalized);
} catch (e) {
return '';
}
}
function parseUriList(text) {
const links = [];
let rejected = 0;
String(text || '').split('\n').forEach(function(raw) {
const line = raw.trim();
if (!line || line[0] === '#' || line[0] === ';' || line.indexOf('//') === 0)
return;
if (NODE_SCHEMES.test(line))
links.push(line);
else
rejected++;
});
return { links: links, rejected: rejected };
}
function splitSurgeFields(value) {
const fields = [];
let current = '';
let quote = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (ch === '"' || ch === '\'') {
if (quote === ch) quote = '';
else if (quote === '') quote = ch;
current += ch;
} else if (ch === ',' && quote === '') {
fields.push(surgeScalar(current));
current = '';
} else {
current += ch;
}
}
fields.push(surgeScalar(current));
return fields;
}
function surgeScalar(value) {
let t = String(value || '').trim();
if (t.length >= 2 && (t[0] === '"' || t[0] === '\'') && t[0] === t[t.length - 1])
t = t.slice(1, -1);
try { return decodeURIComponent(t); } catch (e) { return t; }
}
function firstNonEmpty() {
for (let i = 0; i < arguments.length; i++)
if (arguments[i] !== undefined && arguments[i] !== null && arguments[i] !== '')
return arguments[i];
return '';
}
// Parse a Surge/Shadowrocket [Proxy] section. Only Shadowsocks lines are
// understood, matching the upstream parser; other protocols are rejected so an
// ordinary URL in the file is never mistaken for a node.
function parseSurgeProxies(text) {
const links = [];
let rejected = 0;
let inside = false;
String(text || '').split('\n').forEach(function(raw) {
const line = raw.trim();
if (!line || line[0] === '#' || line[0] === ';' || line.indexOf('//') === 0)
return;
if (line[0] === '[' && line[line.length - 1] === ']' && line.indexOf('=') < 0) {
inside = line.toLowerCase() === '[proxy]';
return;
}
if (!inside)
return;
const sep = line.indexOf('=');
if (sep < 0)
return;
const name = line.slice(0, sep).trim();
const fields = splitSurgeFields(line.slice(sep + 1));
if (!name || fields.length < 3 || fields[0].toLowerCase() !== 'ss' || !fields[1]) {
rejected++;
return;
}
const port = Number(fields[2]);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
rejected++;
return;
}
const options = {};
for (let i = 3; i < fields.length; i++) {
const eq = fields[i].indexOf('=');
if (eq < 0)
continue;
options[fields[i].slice(0, eq).trim().toLowerCase()] = fields[i].slice(eq + 1).trim();
}
const node = {
name: name,
type: 'ss',
server: fields[1],
port: port,
cipher: firstNonEmpty(options['encrypt-method'], options.method, options.cipher),
password: options.password,
plugin: options.plugin || options['obfs'],
'plugin-opts': {}
};
if (node.plugin) {
node['plugin-opts'].mode = options['obfs'] || 'http';
if (options['obfs-host'])
node['plugin-opts'].host = options['obfs-host'];
}
if (!node.cipher || !node.password) {
rejected++;
return;
}
const result = convertProxy(node);
if (result.ok) links.push(result.link);
else rejected++;
});
if (!links.length && !rejected)
rejected = 1;
return { links: links, rejected: rejected };
}
// Mihomo/mihomo-format JSON keeps nodes in `outbounds` and names them with
// `tag` instead of `name`, with nested `tls`/`transport` objects. Normalize to
// the Clash shape so the existing convertProxy converters work unchanged.
function looksLikeMihomoJson(text) {
const t = String(text || '').trim();
if (!t || t[0] !== '{')
return false;
try {
const doc = JSON.parse(t);
return !!(doc && Array.isArray(doc.outbounds));
}
catch (e) {
return false;
}
}
function normalizeMihomoNode(node) {
const n = Object.assign({}, node);
if (node.tag !== undefined && node.name === undefined) n.name = node.tag;
if (node.server_port !== undefined && node.port === undefined) n.port = node.server_port;
const tls = node.tls;
if (tls && typeof tls === 'object') {
if (tls.server_name !== undefined && n.servername === undefined) n.servername = tls.server_name;
if (tls.insecure && n['skip-cert-verify'] === undefined) n['skip-cert-verify'] = true;
if (Array.isArray(tls.alpn) && n.alpn === undefined) n.alpn = tls.alpn;
if (tls.utls && tls.utls.fingerprint !== undefined && n['client-fingerprint'] === undefined)
n['client-fingerprint'] = tls.utls.fingerprint;
if (tls.reality && typeof tls.reality === 'object') {
if (!n['reality-opts']) n['reality-opts'] = {};
if (tls.reality.public_key !== undefined) n['reality-opts']['public-key'] = tls.reality.public_key;
if (tls.reality.short_id !== undefined) n['reality-opts']['short-id'] = tls.reality.short_id;
}
}
const transport = node.transport;
if (transport && typeof transport === 'object') {
if (transport.type && n.network === undefined) n.network = transport.type;
if (transport.type === 'ws' && transport.path && !n['ws-opts']) {
n['ws-opts'] = { path: transport.path };
if (transport.headers && transport.headers.Host)
n['ws-opts'].headers = { Host: transport.headers.Host };
}
}
return n;
}
function parseMihomoJson(text) {
let doc;
try {
doc = JSON.parse(String(text || ''));
}
catch (e) {
return { links: [], rejected: 1 };
}
const outbounds = (doc && doc.outbounds) || [];
const links = [];
let rejected = 0;
outbounds.forEach(function(node) {
if (isMetadataProxy(node)) {
rejected++;
return;
}
const result = convertProxy(normalizeMihomoNode(node));
if (result.ok) links.push(result.link);
else rejected++;
});
return { links: links, rejected: rejected };
}
return baseclass.extend({
convertProxy: convertProxy,
convertProxies: convertProxies,
isMetadataProxy: isMetadataProxy,
looksLikeNodeList: looksLikeNodeList,
looksLikeClashYaml: looksLikeClashYaml,
looksLikeSurge: looksLikeSurge,
looksLikeMihomoJson: looksLikeMihomoJson,
tryBase64Decode: tryBase64Decode,
parseUriList: parseUriList,
parseSurgeProxies: parseSurgeProxies,
parseMihomoJson: parseMihomoJson,
normalizeMihomoNode: normalizeMihomoNode
});
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require view';
'require view.honk.rpc as honk';
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function() {
return honk.status().then(function(status) {
return { status: status };
}).catch(function() {
return { error: true };
});
},
render: function(data) {
honk.installStyles();
const page = E('div', { 'class': 'cbi-map honk-page' });
function renderInitialized(status) {
const section = E('div', { 'class': 'honk-card' });
const webUi = honk.localWebUi(status);
section.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Doona')));
if (webUi) {
section.appendChild(E('a', {
'class': 'cbi-button cbi-button-action',
'href': webUi,
'target': '_blank',
'rel': 'noopener noreferrer'
}, _('Open Doona')));
}
else if (!status.running) {
section.appendChild(E('p', {}, _('Honk is stopped. Start it from the overview to open Doona.')));
section.appendChild(E('a', {
'class': 'cbi-button cbi-button-action',
'href': L.url('admin/services/honk/overview')
}, _('Go to overview')));
}
else {
section.appendChild(E('p', {}, _('Doona is not ready yet. Check the overview for status.')));
section.appendChild(E('a', {
'class': 'cbi-button cbi-button-action',
'href': L.url('admin/services/honk/overview')
}, _('Go to overview')));
}
page.appendChild(section);
}
if (data.error) {
page.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read Honk status.')));
return page;
}
if (data.status.initialized === true) {
renderInitialized(data.status);
return page;
}
const form = E('form', { 'class': 'honk-card' });
const username = E('input', {
'type': 'text',
'name': 'username',
'autocomplete': 'username',
'class': 'cbi-input-text'
});
const password = E('input', {
'type': 'password',
'name': 'password',
'autocomplete': 'new-password',
'class': 'cbi-input-password'
});
const message = E('p', { 'role': 'status' }, '');
const submit = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Initialize Honk'));
form.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Initial setup')));
form.appendChild(E('p', {}, _('Create the Honk administrator account. The password is sent only for this request and is not saved by this page.')));
form.appendChild(E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'for': 'honk-init-username' }, _('Username')),
E('div', { 'class': 'cbi-value-field' }, username)
]));
username.id = 'honk-init-username';
form.appendChild(E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'for': 'honk-init-password' }, _('Password')),
E('div', { 'class': 'cbi-value-field' }, password)
]));
password.id = 'honk-init-password';
form.appendChild(E('div', { 'class': 'cbi-page-actions' }, submit));
form.appendChild(message);
form.addEventListener('submit', function(ev) {
ev.preventDefault();
const user = username.value.trim();
const secret = password.value;
if (!user || !secret) {
message.textContent = _('Enter both a username and password.');
return;
}
submit.disabled = true;
message.textContent = _('Initializing…');
honk.initialize(user, secret).then(honk.ensureOk).then(function(result) {
username.value = '';
password.value = '';
message.textContent = honk.resultMessage(result, _('Honk initialized.'));
form.remove();
page.appendChild(E('p', { 'role': 'status' }, _('Refreshing status…')));
return honk.status().then(function(status) {
while (page.firstChild)
page.removeChild(page.firstChild);
renderInitialized(status);
}).catch(function() {
while (page.firstChild)
page.removeChild(page.firstChild);
page.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read Honk status.')));
});
}).catch(function() {
password.value = '';
message.textContent = _('Initialization failed. Check the system status.');
}).finally(function() {
submit.disabled = false;
});
});
page.appendChild(form);
return page;
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require view';
'require view.honk.rpc as honk';
function detectLogLevel(line) {
// honk-core tracing output (e.g. " ERROR honk_core::...")
if (/\s(?:ERROR|FATAL|PANIC)\s/.test(line)) return 'error';
if (/\sWARN(?:ING)?\s/.test(line)) return 'warn';
if (/\sDEBUG\s/.test(line)) return 'debug';
if (/\sTRACE\s/.test(line)) return 'trace';
if (/\sINFO\s/.test(line)) return 'info';
// syslog facility.level (e.g. "daemon.err")
if (/daemon\.(?:err|crit|alert|emerg)\b/.test(line)) return 'error';
if (/daemon\.(?:warning|warn|notice)\b/.test(line)) return 'warn';
if (/daemon\.debug\b/.test(line)) return 'debug';
if (/daemon\.info\b/.test(line)) return 'info';
return '';
}
function formatLogTs(raw) {
// logread timestamp: "Wed Oct 7 18:58:09 2026" -> "2026-10-07 18:58:09"
const m = String(raw || '').match(/^[A-Z][a-z]{2}\s+([A-Z][a-z]{2})\s+(\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+(\d{4})$/);
if (!m) return raw;
const months = { Jan: 1, Feb: 2, Mar: 3, Apr: 4, May: 5, Jun: 6, Jul: 7, Aug: 8, Sep: 9, Oct: 10, Nov: 11, Dec: 12 };
const month = months[m[1]];
if (!month) return raw;
return m[4] + '-' + String(month).padStart(2, '0') + '-' + String(Number(m[2])).padStart(2, '0') + ' ' + m[3];
}
function lvlShort(level) {
const u = (level || '').toUpperCase();
if (u === 'WARNING') return 'WARN';
return u || '-';
}
function lvlClass(level) {
const u = (level || '').toUpperCase();
if (u === 'TRACE') return 'honk-log-lvl-trace';
if (u === 'DEBUG') return 'honk-log-lvl-debug';
if (u === 'INFO') return 'honk-log-lvl-info';
if (u === 'WARN' || u === 'WARNING') return 'honk-log-lvl-warn';
return 'honk-log-lvl-error';
}
function parseLogLine(line) {
// "Wed Oct 7 18:58:09 2026 daemon.info honk-core[31377]: INFO honk_core::..."
const m = line.match(/^([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+\S+\s+[^:]+:\s*(.*)$/);
if (!m) return { ts: '', lvl: '', msg: line };
let body = m[2] || '';
const lvlMatch = body.match(/^\s*(TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\s+(.*)$/i);
let lvl = '';
if (lvlMatch) {
lvl = lvlMatch[1].toUpperCase();
body = lvlMatch[2];
}
return { ts: formatLogTs(m[1]), lvl: lvl, msg: body };
}
function buildLogLine(line) {
const level = detectLogLevel(line);
const parsed = parseLogLine(line);
const parts = [];
if (parsed.ts)
parts.push(E('span', { 'class': 'honk-log-ts' }, parsed.ts));
if (parsed.lvl)
parts.push(E('span', { 'class': 'honk-log-lvl ' + lvlClass(parsed.lvl) }, lvlShort(parsed.lvl)));
parts.push(E('span', { 'class': 'honk-log-msg' }, parsed.msg));
return E('span', { 'class': 'honk-log-line' + (level ? ' honk-log-' + level : '') }, parts);
}
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
_timer: null,
handleUnload: function() {
if (this._timer != null)
clearInterval(this._timer);
this._timer = null;
},
render: function() {
honk.installStyles();
const source = E('select', { 'class': 'cbi-input-select' }, [
E('option', { 'value': 'core' }, _('Core')),
E('option', { 'value': 'service' }, _('Service')),
E('option', { 'value': 'maintenance' }, _('Maintenance'))
]);
const level = E('select', { 'class': 'cbi-input-select' }, [
E('option', { 'value': 'all' }, _('All levels')),
E('option', { 'value': 'debug' }, _('Debug and above')),
E('option', { 'value': 'info' }, _('Info and above')),
E('option', { 'value': 'warn' }, _('Warnings and errors')),
E('option', { 'value': 'error' }, _('Errors only'))
]);
const output = E('pre', {
'class': 'honk-log-output',
'aria-live': 'polite'
}, _('Loading logs…'));
const status = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const pause = E('button', { 'class': 'cbi-button', 'type': 'button' }, _('Pause'));
const refresh = E('button', { 'class': 'cbi-button', 'type': 'button' }, _('Refresh now'));
const download = E('button', { 'class': 'cbi-button', 'type': 'button' }, _('Download redacted log'));
let paused = false;
let pendingRequests = 0;
let requestSequence = 0;
let latest = [];
download.disabled = true;
function loadLogs(force) {
if (paused && !force || pendingRequests && !force)
return Promise.resolve();
const requestId = ++requestSequence;
const requestedSource = source.value;
const requestedLevel = level.value;
pendingRequests++;
download.disabled = true;
return honk.logs(requestedSource, requestedLevel, 200).then(honk.ensureOk).then(function(result) {
if (requestId !== requestSequence)
return;
latest = Array.isArray(result.lines) ? result.lines.map(String) : [];
output.replaceChildren();
if (latest.length) {
latest.forEach(function(line) {
output.appendChild(buildLogLine(line));
});
}
else {
output.textContent = _('No matching Honk log entries.');
}
status.textContent = result.capped ? _('Output was capped; showing the latest %d entries.').format(latest.length) : _('Showing the latest %d matching entries.').format(latest.length);
}).catch(function(error) {
if (requestId === requestSequence)
status.textContent = honk.errorMessage(error, _('Could not read Honk logs.'));
}).finally(function() {
pendingRequests--;
download.disabled = pendingRequests > 0;
});
}
pause.addEventListener('click', function(ev) {
ev.preventDefault();
paused = !paused;
pause.textContent = paused ? _('Resume') : _('Pause');
status.textContent = paused ? _('Log polling is paused.') : _('Log polling resumed.');
if (!paused)
loadLogs(true);
});
refresh.addEventListener('click', function(ev) {
ev.preventDefault();
loadLogs(true);
});
source.addEventListener('change', function() { loadLogs(true); });
level.addEventListener('change', function() { loadLogs(true); });
download.addEventListener('click', function(ev) {
ev.preventDefault();
const blob = new Blob([ latest.join('\n') + (latest.length ? '\n' : '') ], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = E('a', { 'href': url, 'download': 'honk-redacted.log' });
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
});
source.id = 'honk-log-source';
level.id = 'honk-log-level';
this._timer = setInterval(function() { loadLogs(false); }, 5000);
loadLogs(true);
return E('div', { 'class': 'cbi-map honk-page' }, [
E('div', { 'class': 'honk-header' }, [
E('h2', {}, _('Logs')),
E('p', { 'class': 'honk-header-sub' }, _('Shows up to 200 Honk-specific lines, capped at 32 KiB, and refreshes every 5 seconds while live. Sensitive URLs and credentials are redacted before display.'))
]),
E('section', { 'class': 'honk-card' }, [
E('h3', { 'class': 'honk-card-title' }, _('Live log')),
E('div', { 'class': 'honk-log-toolbar' }, [
E('label', { 'for': source.id }, [ _('Log source'), source ]),
E('label', { 'for': level.id }, [ _('Minimum level'), level ]),
E('div', { 'class': 'honk-actions' }, [ pause, refresh, download ])
]),
status,
output
])
]);
}
});
@@ -0,0 +1,555 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require fs';
'require poll';
'require uci';
'require ui';
'require view';
'require view.honk.rpc as honk';
const BACKUP_PATH = '/tmp/honk-backup.tar.gz';
const RESTORE_PATH = '/tmp/honk-maintenance/restore.tar.gz';
const PKG_INFO = '/usr/share/luci-app-honk/pkg-info.sh';
const UPDATE_PKG = '/usr/share/luci-app-honk/update-pkg.sh';
const REFRESH_INDEX = '/usr/share/luci-app-honk/refresh-index.sh';
const UPDATE_GEO = '/usr/share/luci-app-honk/update-geo.sh';
const GEO_CRON = '/usr/share/luci-app-honk/geo-cron.sh';
const PKGS = [ 'honk', 'luci-app-honk' ];
const PKG_NAMES = { honk: 'Honk', 'luci-app-honk': 'luci-app-honk' };
const DATA_PATHS = {
geoip: '/usr/share/v2ray/geoip.dat',
geosite: '/usr/share/v2ray/geosite.dat'
};
// Geo data source presets. `loyalsoldier` keeps both URLs empty so the actual
// default lives in one place — update-geo.sh — avoiding UI/script drift.
const GEO_PRESETS = {
loyalsoldier: { geoip: '', geosite: '' },
v2fly: {
geoip: 'https://github.com/v2fly/geoip/releases/latest/download/geoip.dat',
geosite: 'https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat'
}
};
function fmtBytes(n) {
if (!n && n !== 0) return '-';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
function fmtMtime(epoch) {
if (!epoch) return '';
const d = new Date(epoch * 1000);
return d.toISOString().slice(0, 10);
}
// Compare two version strings like `sort -V`. Returns <0 / 0 / >0.
function cmpVer(a, b) {
const ax = String(a).match(/(\d+|\D+)/g) || [];
const bx = String(b).match(/(\d+|\D+)/g) || [];
const n = Math.max(ax.length, bx.length);
for (let i = 0; i < n; i++) {
const as = ax[i], bs = bx[i];
if (as === undefined) return -1;
if (bs === undefined) return 1;
if (/^\d+$/.test(as) && /^\d+$/.test(bs)) {
const d = parseInt(as, 10) - parseInt(bs, 10);
if (d !== 0) return d < 0 ? -1 : 1;
} else if (as !== bs) {
return as < bs ? -1 : 1;
}
}
return 0;
}
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function() {
return uci.load('honk').catch(function() {});
},
render: function() {
honk.installStyles();
const page = E('div', { 'class': 'cbi-map honk-page' });
const recoveryMessage = E('p', { 'class': 'alert-message', 'role': 'status' }, '');
recoveryMessage.hidden = true;
const writeControls = [];
let recoveryRequired = false;
function phaseMessage(job, fallback) {
const messages = {
preparing: _('Preparing maintenance…'),
stopping: _('Stopping Honk…'),
applying: _('Applying the staged data…'),
validating: _('Validating the new state…'),
starting: _('Starting Honk…'),
rolling_back: _('Restoring the previous state…'),
committing: _('Finishing the transaction…'),
recovery: _('Recovery is required before another write operation.')
};
return messages[job.phase] || fallback;
}
function blockWrites() {
recoveryRequired = true;
writeControls.forEach(function(button) { button.disabled = true; });
}
/* Shared log pane and row helper for both data and package updates. */
const updateLog = E('pre', { 'class': 'honk-up-log', 'hidden': true }, '');
function mkPkgRow(icon, iconCls, name, meta, btn) {
return E('div', { 'class': 'honk-pkg-row' }, [
E('span', { 'class': 'honk-pkg-icon ' + iconCls }, icon),
E('span', { 'class': 'honk-pkg-name' }, name),
E('span', { 'class': 'honk-pkg-meta', 'title': meta }, meta),
E('span', {}, ''),
btn || E('span', {}, '')
]);
}
function probeFile(path) {
return fs.stat(path).then(function(st) {
return { exists: true, size: st.size, mtime: st.mtime };
}).catch(function() {
return { exists: false, size: 0, mtime: 0 };
});
}
/* --- Geo data card --- */
const dataSection = E('section', { 'class': 'honk-card' });
const dataBody = E('div', { 'id': 'honk-geo' }, E('em', {}, _('Probing…')));
function updateGeo(kind, btn) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
let tries = 0;
const pollLog = function() {
return fs.read_direct('/tmp/luci-app-honk.' + kind + '.log', 'text').then(function(c) {
if (c) {
updateLog.textContent = c;
updateLog.hidden = false;
}
if (/[✓✗]/.test(c)) { refreshData(); return; }
if (tries++ > 90) return;
return new Promise(function(r) { setTimeout(r, 2000); }).then(pollLog);
}).catch(function() {});
};
return fs.exec(UPDATE_GEO, [ kind ]).then(function(res) {
if (res.code === 0) return pollLog();
}).catch(function() {}).finally(function() {
btn.disabled = false;
btn.textContent = orig;
});
}
function refreshData() {
return Promise.all([ probeFile(DATA_PATHS.geoip), probeFile(DATA_PATHS.geosite) ]).then(function(r) {
while (dataBody.firstChild)
dataBody.removeChild(dataBody.firstChild);
[
{ k: 'geoip', name: 'GeoIP', r: r[0] },
{ k: 'geosite', name: 'GeoSite', r: r[1] }
].forEach(function(entry) {
const btn = E('button', { 'class': 'cbi-button cbi-button-action', 'type': 'button' }, _('Update'));
btn.addEventListener('click', function(ev) { ev.preventDefault(); updateGeo(entry.k, btn); });
const meta = entry.r.exists
? fmtBytes(entry.r.size) + (entry.r.mtime ? ' · ' + fmtMtime(entry.r.mtime) : '')
: _('missing — click Update to fetch');
dataBody.appendChild(mkPkgRow(
entry.r.exists ? '✓' : '✗',
entry.r.exists ? 'honk-pkg-ok' : 'honk-pkg-err',
entry.name,
meta,
btn
));
});
});
}
function geoField(label, input) {
return E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'for': input.id }, label),
E('div', { 'class': 'cbi-value-field' }, input)
]);
}
const geoSettings = (function() {
const gi0 = uci.get('honk', 'main', 'geoip_url') || '';
const gs0 = uci.get('honk', 'main', 'geosite_url') || '';
const auto0 = uci.get('honk', 'main', 'geo_auto') === '1';
const freq0 = uci.get('honk', 'main', 'geo_auto_freq') || 'daily';
const preset0 = (!gi0 && !gs0) ? 'loyalsoldier' :
(gi0 === GEO_PRESETS.v2fly.geoip && gs0 === GEO_PRESETS.v2fly.geosite) ? 'v2fly' : 'custom';
const presetSel = E('select', { 'class': 'cbi-input-select', 'id': 'honk-geo-preset' }, [
E('option', { 'value': 'loyalsoldier' }, 'Loyalsoldier'),
E('option', { 'value': 'v2fly' }, 'v2fly'),
E('option', { 'value': 'custom' }, _('Custom'))
]);
presetSel.value = preset0;
const giInput = E('input', { 'class': 'cbi-input-text', 'type': 'text', 'id': 'honk-geo-ip', 'placeholder': 'https://…/geoip.dat' });
const gsInput = E('input', { 'class': 'cbi-input-text', 'type': 'text', 'id': 'honk-geo-site', 'placeholder': 'https://…/geosite.dat' });
giInput.value = preset0 === 'custom' ? gi0 : '';
gsInput.value = preset0 === 'custom' ? gs0 : '';
const customRows = E('div', {}, [
geoField(_('GeoIP URL'), giInput),
geoField(_('GeoSite URL'), gsInput)
]);
const syncCustom = function() { customRows.style.display = presetSel.value === 'custom' ? '' : 'none'; };
presetSel.addEventListener('change', syncCustom);
syncCustom();
const autoSel = E('select', { 'class': 'cbi-input-select', 'id': 'honk-geo-auto' }, [
E('option', { 'value': 'off' }, _('Disabled')),
E('option', { 'value': 'daily' }, _('Daily')),
E('option', { 'value': 'weekly' }, _('Weekly'))
]);
autoSel.value = auto0 ? freq0 : 'off';
const saveBtn = E('button', { 'class': 'cbi-button cbi-button-action', 'type': 'button' }, _('Save'));
saveBtn.addEventListener('click', function() {
const p = presetSel.value;
let gi = '', gs = '';
const geoAuto = autoSel.value !== 'off';
const geoFreq = autoSel.value === 'weekly' ? 'weekly' : 'daily';
if (p === 'v2fly') { gi = GEO_PRESETS.v2fly.geoip; gs = GEO_PRESETS.v2fly.geosite; }
else if (p === 'custom') {
gi = giInput.value.trim(); gs = gsInput.value.trim();
for (const [label, url] of [['GeoIP URL', gi], ['GeoSite URL', gs]]) {
if (url) {
try {
const u = new URL(url);
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad protocol');
} catch (e) {
document.getElementById('honk-geo-msg').textContent = _('Invalid %s: must be an http(s) URL.').format(label);
return;
}
}
}
}
uci.set('honk', 'main', 'geoip_url', gi);
uci.set('honk', 'main', 'geosite_url', gs);
uci.set('honk', 'main', 'geo_auto', geoAuto ? '1' : '0');
uci.set('honk', 'main', 'geo_auto_freq', geoFreq);
const orig = saveBtn.textContent;
saveBtn.disabled = true; saveBtn.textContent = '…';
uci.save().then(function() {
return uci.changes();
}).then(function(changes) {
if (changes && Object.keys(changes).length)
return uci.apply();
}).then(function() {
return fs.exec(GEO_CRON, [ geoAuto ? 'enable' : 'disable' ]);
}).then(function() {
updateLog.textContent = _('Geo data source saved.');
updateLog.hidden = false;
ui.changes.init();
}).catch(function(e) {
updateLog.textContent = _('Save failed') + ': ' + (e && e.message ? e.message : e);
updateLog.hidden = false;
}).finally(function() {
saveBtn.disabled = false; saveBtn.textContent = orig;
});
});
return E('details', {}, [
E('summary', {}, _('Data source and auto-update')),
E('div', {}, [
geoField(_('Source'), presetSel),
customRows,
geoField(_('Auto-update'), autoSel),
E('div', { 'class': 'honk-actions' }, [ saveBtn ]),
E('p', { 'class': 'honk-note', 'id': 'honk-geo-msg' })
])
]);
})();
dataSection.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Geo data')));
dataSection.appendChild(E('p', { 'class': 'honk-note' }, _('GeoIP and GeoSite routing data can be updated here. Updates run in the background and reload Honk when done.')));
dataSection.appendChild(dataBody);
dataSection.appendChild(geoSettings);
/* --- Software package updates card --- */
const updateSection = E('section', { 'class': 'honk-card' });
const pkgBody = E('div', { 'id': 'honk-pkg' }, E('em', {}, _('Probing…')));
function probePkg(pkg) {
return fs.exec(PKG_INFO, [ pkg ]).then(function(res) {
const out = (res.stdout || '').trim().split('\t');
return { installed: out[0] || '', latest: out[1] || '' };
}).catch(function() {
return { installed: '', latest: '' };
});
}
function upgradePkg(pkg, btn) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
let tries = 0;
const pollLog = function() {
return fs.read_direct('/tmp/luci-app-honk.pkg-' + pkg + '.log', 'text').then(function(c) {
if (c) {
updateLog.textContent = c;
updateLog.hidden = false;
}
if (/[✓✗]/.test(c)) { refreshPkgs(); return; }
if (tries++ > 90) return;
return new Promise(function(r) { setTimeout(r, 2000); }).then(pollLog);
}).catch(function() {});
};
return fs.exec(UPDATE_PKG, [ pkg ]).then(function(res) {
if (res.code === 0) return pollLog();
}).catch(function() {}).finally(function() {
btn.disabled = false;
btn.textContent = orig;
});
}
function refreshPkgs() {
const probes = PKGS.map(probePkg);
return Promise.all(probes).then(function(infos) {
while (pkgBody.firstChild)
pkgBody.removeChild(pkgBody.firstChild);
PKGS.forEach(function(pkg, i) {
const r = infos[i];
const btn = E('button', { 'class': 'cbi-button cbi-button-action', 'type': 'button' }, _('Upgrade'));
btn.addEventListener('click', function(ev) { ev.preventDefault(); upgradePkg(pkg, btn); });
const cmp = (r.installed && r.latest) ? cmpVer(r.latest, r.installed) : null;
const updatable = cmp !== null && cmp > 0;
let meta;
if (!r.installed) {
meta = _('not installed via package manager');
btn.disabled = true;
btn.textContent = _('Unavailable');
} else if (!r.latest) {
meta = _('installed') + ': ' + r.installed + ' · ' + _('latest version unknown');
btn.disabled = true;
} else if (updatable) {
meta = _('installed') + ': ' + r.installed + ' → ' + _('latest') + ': ' + r.latest;
} else {
meta = _('installed') + ': ' + r.installed + ' · ' + _('up to date');
btn.disabled = true;
}
pkgBody.appendChild(mkPkgRow(
updatable ? '↑' : (r.installed ? '✓' : '✗'),
updatable ? 'honk-pkg-new' : (r.installed ? 'honk-pkg-ok' : 'honk-pkg-err'),
PKG_NAMES[pkg],
meta,
btn
));
});
});
}
updateSection.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Package updates')));
updateSection.appendChild(E('p', { 'class': 'honk-note' }, _('Honk core and LuCI can be upgraded here without leaving the management page. The package index refreshes in the background.')));
updateSection.appendChild(pkgBody);
/* --- Config backup card --- */
const backupSection = E('section', { 'class': 'honk-card' });
const backupMessage = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const exportBtn = E('button', { 'class': 'cbi-button cbi-button-action', 'type': 'button' }, _('Export'));
const importBtn = E('button', { 'class': 'cbi-button', 'type': 'button' }, _('Import'));
const restoreCfgBtn = E('button', { 'class': 'cbi-button cbi-button-negative', 'type': 'button' }, _('Restore config'));
/* 忘记密码用:只清管理员记录,不动订阅和规则 */
const resetAdminBtn = E('button', { 'class': 'cbi-button', 'type': 'button' }, _('Reset administrator'));
const fileInput = E('input', { 'type': 'file', 'accept': '.tar.gz,.gz,application/gzip', 'style': 'display:none' });
let backupBusy = false;
writeControls.push(exportBtn, importBtn, restoreCfgBtn, resetAdminBtn);
function setBackupBusy(busy) {
backupBusy = busy;
[exportBtn, importBtn, restoreCfgBtn].forEach(function(b) {
b.disabled = busy || recoveryRequired;
});
}
exportBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (backupBusy) return;
setBackupBusy(true);
backupMessage.textContent = _('Preparing backup…');
honk.backup().then(honk.ensureOk).then(function(result) {
return honk.waitJob(result.job_id, function(job) {
backupMessage.textContent = job.state === 'running' ? _('Creating backup…') : _('Preparing download…');
});
}).then(function() {
return fs.read_direct(BACKUP_PATH, 'blob');
}).then(function(blob) {
const url = URL.createObjectURL(blob);
const link = E('a', { 'href': url, 'download': 'honk-backup.tar.gz' });
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
backupMessage.textContent = _('Backup downloaded.');
}).catch(function(error) {
if (error && error.recoveryRequired)
blockWrites();
backupMessage.textContent = honk.errorMessage(error, _('Backup failed.'));
}).finally(function() { setBackupBusy(false); });
});
importBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (backupBusy) return;
fileInput.click();
});
fileInput.addEventListener('change', function(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
if (!confirm(_('Stop Honk before restoring. Restoring replaces Honk user data. Continue?'))) {
fileInput.value = '';
return;
}
setBackupBusy(true);
backupMessage.textContent = _('Restoring…');
honk.restorePrepare().then(honk.ensureOk).then(function() {
return ui.uploadFile(RESTORE_PATH);
}).then(function() {
return honk.restore();
}).then(honk.ensureOk).then(function(result) {
return honk.waitJob(result.job_id, function(job) {
backupMessage.textContent = phaseMessage(job, _('Restoring Honk data…'));
});
}).then(function(result) {
backupMessage.textContent = honk.resultMessage(result, _('Backup restored.'));
}).catch(function(error) {
if (error && error.recoveryRequired)
blockWrites();
backupMessage.textContent = honk.errorMessage(error, _('Restore failed.'));
}).finally(function() {
setBackupBusy(false);
fileInput.value = '';
});
});
resetAdminBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (!confirm(_('Clear the Honk administrator account so the first-setup page opens again? Subscriptions, nodes and routing are kept. Honk will be stopped.')))
return;
setBackupBusy(true);
resetAdminBtn.disabled = true;
backupMessage.textContent = _('Resetting the administrator…');
honk.resetAdmin().then(honk.ensureOk).then(function(result) {
backupMessage.textContent = honk.resultMessage(result, _('Administrator cleared. Open the Overview page and run the first setup again.'));
window.setTimeout(function() { window.location.href = L.url('admin/services/honk/overview'); }, 1200);
}).catch(function(error) {
backupMessage.textContent = honk.errorMessage(error, _('Could not reset the administrator.'));
}).finally(function() {
setBackupBusy(false);
resetAdminBtn.disabled = false;
});
});
restoreCfgBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (!confirm(_('This permanently clears Honk subscriptions, nodes, policies, DNS data, history, and cache, then restores the default configuration. The administrator account is kept, and Honk will be stopped. Continue?')))
return;
setBackupBusy(true);
backupMessage.textContent = _('Restoring default config…');
honk.reset().then(honk.ensureOk).then(function(result) {
return honk.waitJob(result.job_id, function(job) {
backupMessage.textContent = phaseMessage(job, _('Restoring default config…'));
});
}).then(function(result) {
backupMessage.textContent = honk.resultMessage(result, _('Honk data reset. The service is stopped.'));
}).catch(function(error) {
if (error && error.recoveryRequired)
blockWrites();
backupMessage.textContent = honk.errorMessage(error, _('Reset failed.'));
}).finally(function() { setBackupBusy(false); });
});
backupSection.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Config backup')));
backupSection.appendChild(E('p', { 'class': 'honk-note' }, _('Export a backup archive, import one from your computer, or restore Honk to its default configuration.')));
backupSection.appendChild(E('div', { 'class': 'honk-actions' }, [ exportBtn, importBtn, restoreCfgBtn, resetAdminBtn ]));
backupSection.appendChild(fileInput);
backupSection.appendChild(backupMessage);
/* --- System check and repair card --- */
const health = E('section', { 'class': 'honk-card' });
const healthMessage = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const check = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Run system check'));
check.addEventListener('click', function(ev) {
ev.preventDefault();
check.disabled = true;
healthMessage.textContent = _('Checking…');
honk.check().then(function(result) {
if (result && result.ok === true) {
healthMessage.textContent = _('System check passed.');
}
else if (Array.isArray(result && result.errors) && result.errors.length) {
healthMessage.textContent = result.errors.map(honk.statusIssue).join(';');
}
else {
healthMessage.textContent = _('The backend did not confirm the check.');
}
}).catch(function(error) {
healthMessage.textContent = _('System check failed.');
}).finally(function() { check.disabled = false; });
});
const repairMessage = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const repair = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Repair'));
writeControls.push(repair);
repair.addEventListener('click', function(ev) {
ev.preventDefault();
repair.disabled = true;
repairMessage.textContent = _('Repairing…');
honk.repair().then(honk.ensureOk).then(function(result) {
repairMessage.textContent = honk.resultMessage(result, _('Repair completed.'));
}).catch(function(error) {
if (error && error.recoveryRequired)
blockWrites();
repairMessage.textContent = honk.errorMessage(error, _('Repair failed.'));
}).finally(function() { repair.disabled = recoveryRequired; });
});
health.appendChild(E('h3', { 'class': 'honk-card-title' }, _('System check and repair')));
health.appendChild(E('p', { 'class': 'honk-note' }, _('Checks Honk system components and reports detected issues.')));
health.appendChild(E('div', { 'class': 'honk-actions' }, [ check, repair ]));
health.appendChild(healthMessage);
health.appendChild(repairMessage);
health.appendChild(E('p', { 'class': 'honk-note' }, _('Repair replaces missing or damaged Honk system files and does not overwrite user configuration.')));
page.appendChild(E('div', { 'class': 'honk-header' }, [
E('h2', {}, _('Maintenance')),
E('p', { 'class': 'honk-header-sub' }, _('Package updates, config backup and system recovery for the Honk installation.'))
]));
page.appendChild(recoveryMessage);
page.appendChild(dataSection);
page.appendChild(updateSection);
page.appendChild(backupSection);
page.appendChild(health);
page.appendChild(updateLog);
// Background index refresh so new versions show on the next poll.
fs.exec(REFRESH_INDEX, []).catch(function() {});
refreshData();
refreshPkgs();
poll.add(refreshData, 15);
poll.add(refreshPkgs, 15);
honk.status().then(function(result) {
const errors = Array.isArray(result && result.errors) ? result.errors : [];
const recoveryToken = errors.indexOf('update_recovery_required') >= 0 ? 'update_recovery_required' :
errors.indexOf('maintenance_recovery_required') >= 0 ? 'maintenance_recovery_required' : null;
if (recoveryToken) {
blockWrites();
recoveryMessage.textContent = honk.statusIssue(recoveryToken);
recoveryMessage.hidden = false;
}
}).catch(function() {});
return page;
}
});
@@ -0,0 +1,424 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require network';
'require poll';
'require view';
'require view.honk.rpc as honk';
'require uci';
function getNetworkNames(networks) {
if (!networks)
return [];
const list = Array.isArray(networks) ? networks : Object.keys(networks).map(function(key) { return networks[key]; });
return list.map(function(net) {
if (net && typeof net.getName === 'function')
return net.getName();
if (net && typeof net.name === 'string')
return net.name;
return typeof net === 'string' ? net : '';
}).filter(function(name, index, names) {
return !!name && names.indexOf(name) === index;
}).sort();
}
function metaItem(label, value) {
return E('span', { 'class': 'honk-meta-item' }, [
E('span', { 'class': 'honk-meta-label' }, label),
value == null || value === '' ? '—' : String(value)
]);
}
function field(label, input, help) {
/* 说明文字放进控件列下方,两栏布局时才不会挤到右边去 */
const cell = E('div', { 'class': 'cbi-value-field' }, [ input ]);
if (help)
cell.appendChild(E('div', { 'class': 'cbi-value-description honk-note' }, help));
return E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'for': input.id }, label),
cell
]);
}
function captureFields(root) {
const values = {};
Array.prototype.forEach.call(root.querySelectorAll('input, select, textarea'), function(input) {
const key = input.id || input.name;
if (!key)
return;
values[key] = {
value: input.value,
checked: input.checked,
focused: document.activeElement === input,
selectionStart: typeof input.selectionStart === 'number' ? input.selectionStart : null,
selectionEnd: typeof input.selectionEnd === 'number' ? input.selectionEnd : null
};
});
return values;
}
function restoreFields(root, values) {
Object.keys(values).forEach(function(key) {
const input = root.querySelector('#%s'.format(key));
if (!input || input.type === 'file')
return;
input.value = values[key].value;
input.checked = values[key].checked;
if (values[key].focused) {
input.focus();
if (values[key].selectionStart !== null && typeof input.setSelectionRange === 'function')
input.setSelectionRange(values[key].selectionStart, values[key].selectionEnd);
}
});
}
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function() {
return Promise.all([
honk.status().then(function(status) { return { status: status }; }).catch(function(error) { return { error: error.message || String(error) }; }),
network.getNetworks().then(getNetworkNames).then(function(networks) { return { networks: networks }; }).catch(function() { return { networks: [] }; }),
uci.load('honk').then(function() { return { clash_secret: uci.get('honk', 'main', 'clash_api_secret') || '' }; }).catch(function() { return { clash_secret: '' }; })
]).then(function(results) {
return Object.assign({}, results[0], results[1], results[2]);
});
},
render: function(data) {
honk.installStyles();
const page = E('div', { 'class': 'cbi-map honk-page' });
const statusArea = E('div');
const actionMessage = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const setupArea = E('div');
let currentStatus = data.status;
const clashSecret = data.clash_secret || '';
let busy = false;
let setupBusy = false;
let settingsBusy = false;
let setupState = null;
function renderStatus(status, error) {
const fields = captureFields(statusArea);
const focusedAction = statusArea.contains(document.activeElement) && document.activeElement.dataset.action;
while (statusArea.firstChild)
statusArea.removeChild(statusArea.firstChild);
if (error) {
statusArea.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read Honk status.')));
restoreFields(statusArea, fields);
return;
}
currentStatus = status;
const running = status.running === true;
/* Status card */
const badge = E('span', {
'class': 'honk-badge %s'.format(running ? 'honk-badge--run' : 'honk-badge--stop')
}, running ? _('Running') : _('Stopped'));
const webUi = honk.localWebUi(status);
const openDoona = E(webUi ? 'a' : 'button', {
'class': 'cbi-button cbi-button-action',
'href': webUi || null,
'target': webUi ? '_blank' : null,
'rel': webUi ? 'noopener noreferrer' : null,
'type': webUi ? null : 'button',
'disabled': webUi ? null : true
}, _('Open Doona'));
const clashUi = honk.localClashUi(status, clashSecret);
const openClash = E(clashUi ? 'a' : 'button', {
'class': 'cbi-button cbi-button-action',
'href': clashUi || null,
'target': clashUi ? '_blank' : null,
'rel': clashUi ? 'noopener noreferrer' : null,
'type': clashUi ? null : 'button',
'disabled': clashUi ? null : true
}, _('Open Clash panel'));
const toggle = E('button', {
'class': 'cbi-button cbi-button-action',
'type': 'button',
'data-action': running ? 'stop' : 'start'
}, running ? _('Stop') : _('Start'));
toggle.disabled = busy || !status.initialized;
if (!status.initialized)
toggle.title = _('Finish the first setup first: Honk does not start without an administrator.');
toggle.addEventListener('click', function(ev) {
ev.preventDefault();
runAction(running ? 'stop' : 'start');
});
const actions = [ openDoona, openClash, toggle ];
if (running) {
const restart = E('button', { 'class': 'cbi-button', 'type': 'button', 'data-action': 'restart' }, _('Restart'));
restart.disabled = busy;
restart.addEventListener('click', function(ev) {
ev.preventDefault();
runAction('restart');
});
actions.push(restart);
}
const statusCard = E('section', { 'class': 'honk-card' }, [
E('div', { 'class': 'honk-card-head' }, [
E('div', {}, [
E('h3', { 'class': 'honk-card-title' }, _('Status')),
badge
]),
E('div', { 'class': 'honk-actions' }, actions)
]),
E('div', { 'class': 'honk-meta' }, [
metaItem(_('Core version'), status.core_version),
metaItem(_('Doona version'), status.doona_version),
metaItem(_('Kernel'), status.kernel)
])
]);
if (status.clash_api_enabled && running && !clashUi)
statusCard.appendChild(E('p', { 'class': 'honk-note', 'role': 'status' }, status.clash_api_ready ? _('Zashboard is still downloading or unavailable.') : _('The Clash API listener is unavailable. Check the service logs.')));
const errors = (Array.isArray(status.errors) ? status.errors : []).filter(function(token) {
return token !== 'disabled' && token !== 'not_initialized' && token !== 'service_not_running';
});
if (errors.length)
statusCard.appendChild(E('ul', { 'class': 'alert-message' }, errors.map(function(token) {
return E('li', {}, honk.statusIssue(token));
})));
if (!status.initialized)
statusCard.appendChild(E('p', { 'class': 'honk-note', 'role': 'status' }, _('Not initialized yet. Finish the first setup below, then Honk can be started.')));
statusArea.appendChild(statusCard);
statusArea.appendChild(renderServiceSettings(status));
if (focusedAction) {
const replacement = statusArea.querySelector('[data-action="%s"]'.format(focusedAction));
if (replacement)
replacement.focus();
}
restoreFields(statusArea, fields);
}
function renderServiceSettings(status) {
const card = E('section', { 'class': 'honk-card' });
const form = E('form');
const networkSelect = E('select', { 'class': 'cbi-input-select', 'name': 'lan_network', 'id': 'honk-lan-network' });
const portInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'listen_port', 'id': 'honk-listen-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
const message = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const save = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Save service settings'));
(data.networks || []).forEach(function(name) {
networkSelect.appendChild(E('option', { 'value': name }, name));
});
if (status.lan_network && !(data.networks || []).includes(status.lan_network))
networkSelect.appendChild(E('option', { 'value': status.lan_network }, status.lan_network));
if (status.lan_network)
networkSelect.value = status.lan_network;
portInput.value = status.listen_port ? String(status.listen_port) : '9527';
clashEnabledInput.checked = status.clash_api_enabled === true;
clashPortInput.value = status.clash_api_port ? String(status.clash_api_port) : '9090';
card.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Service settings')));
/* Manual mode owns the whole runtime from config.dae, so the backend
rejects these settings. Disable them here instead of failing on save. */
const manualMode = status.config_mode === 'manual';
if (manualMode) {
[ networkSelect, portInput, clashEnabledInput, clashPortInput, clashSecretInput ].forEach(function(input) {
input.disabled = true;
});
save.disabled = true;
card.appendChild(E('p', { 'class': 'honk-note honk-mode-hint' }, _('Manual mode: these settings belong to config.dae, so they are read-only here. Restore managed mode on the Configuration page to change them.')));
}
if (!(data.networks || []).length)
networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found')));
form.appendChild(field(_('LAN network'), networkSelect));
form.appendChild(field(_('Listen port'), portInput));
form.appendChild(field(_('Enable Clash API'), clashEnabledInput));
form.appendChild(field(_('Clash API port'), clashPortInput));
form.appendChild(field(_('Clash API secret'), clashSecretInput, status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it.') : _('Required when enabling. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
form.appendChild(E('p', { 'class': 'honk-note' }, _('Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting.')));
form.appendChild(E('div', { 'class': 'cbi-page-actions' }, save));
form.appendChild(message);
form.addEventListener('submit', function(ev) {
ev.preventDefault();
const port = Number(portInput.value);
const clashPort = Number(clashPortInput.value);
const clashSecret = clashSecretInput.value;
if (!networkSelect.value || !Number.isInteger(port) || port < 1024 || port > 65535) {
message.textContent = _('Choose a LAN network and a port from 1024 to 65535.');
return;
}
if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535) {
message.textContent = _('Clash API port must be between 1024 and 65535.');
return;
}
if (clashEnabledInput.checked && clashPort === port) {
message.textContent = _('The Clash API port must differ from the listen port.');
return;
}
if (clashEnabledInput.checked && !status.clash_api_secret_set && !clashSecret) {
message.textContent = _('A secret is required when enabling Clash API.');
return;
}
if (clashEnabledInput.checked && clashSecret && !/^[A-Za-z0-9._-]{6,128}$/.test(clashSecret)) {
message.textContent = _('The secret must be 6-128 letters, numbers, dots, underscores or hyphens.');
return;
}
save.disabled = true;
settingsBusy = true;
message.textContent = _('Saving…');
honk.settings(networkSelect.value, port, clashEnabledInput.checked, clashPort, clashSecret).then(honk.ensureOk).then(function(result) {
clashSecretInput.value = '';
message.textContent = honk.resultMessage(result, _('Service settings saved.'));
}).catch(function(error) {
message.textContent = honk.errorMessage(error, _('Settings could not be applied.'));
}).finally(function() {
settingsBusy = false;
save.disabled = false;
refresh();
});
});
card.appendChild(form);
return card;
}
function renderSetup(status) {
if (setupBusy)
return;
if (!status)
return;
const initialized = status.initialized === true;
if (setupState === initialized)
return;
setupState = initialized;
const fields = captureFields(setupArea);
while (setupArea.firstChild)
setupArea.removeChild(setupArea.firstChild);
if (initialized)
return;
const form = E('form', { 'class': 'honk-card' });
const username = E('input', { 'type': 'text', 'name': 'username', 'id': 'honk-init-username', 'autocomplete': 'username', 'required': true, 'class': 'cbi-input-text' });
const password = E('input', { 'type': 'password', 'name': 'password', 'id': 'honk-init-password', 'autocomplete': 'new-password', 'required': true, 'class': 'cbi-input-password' });
const message = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const submit = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Initialize Honk'));
form.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Initial setup')));
form.appendChild(E('p', { 'class': 'honk-note' }, _('Create the Honk administrator account. The password is sent only for this request and is not saved by this page.')));
form.appendChild(E('p', { 'class': 'honk-note' }, _('Username: letters, digits, underscore, dot or hyphen (max 64 characters). Password: 8 to 128 characters.')));
form.appendChild(field(_('Username'), username));
form.appendChild(field(_('Password'), password));
form.appendChild(E('div', { 'class': 'cbi-page-actions' }, submit));
form.appendChild(message);
form.addEventListener('submit', function(ev) {
ev.preventDefault();
const user = username.value.trim();
const secret = password.value;
if (!user || !secret) {
message.textContent = _('Enter both a username and password.');
return;
}
/* 和后端 read_credentials 的规则保持一致,避免"看着合法却提交失败" */
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(user)) {
message.textContent = _('Username may only contain letters, digits, underscore, dot or hyphen (max 64 characters).');
return;
}
/* 内核 auth.rs 规定密码 8–128 个字符(按 Unicode 码点计),这里先拦一道 */
const secretLength = Array.from(secret).length;
if (secretLength < 8 || secretLength > 128) {
message.textContent = _('The password must be between 8 and 128 characters.');
return;
}
submit.disabled = true;
setupBusy = true;
message.textContent = _('Initializing…');
honk.initialize(user, secret).then(honk.ensureOk).then(function(result) {
username.value = '';
password.value = '';
message.textContent = honk.resultMessage(result, _('Honk initialized.'));
return honk.status();
}).then(function(updatedStatus) {
setupBusy = false;
currentStatus = updatedStatus;
renderSetup(updatedStatus);
renderStatus(updatedStatus, null);
}).catch(function(error) {
password.value = '';
/* 以前这里把错误整个吞掉,用户只看到「初始化失败」。现在先给出
具体错误码的中文说明,再顺手跑一遍系统检查,把缺什么列出来。 */
message.textContent = honk.errorMessage(error, _('Initialization failed.'));
/* 三态:查出问题 / 检查通过 / 检查根本没跑完。以前 check() 失败被当成"没问题"。 */
return honk.check().then(function(check) {
const issues = (check && Array.isArray(check.errors)) ? check.errors : [];
if (issues.length)
message.textContent += ' ' + _('System check: %s').format(issues.map(honk.statusIssue).join('; '));
else if (check && check.ok === true)
message.textContent += ' ' + _('The system check passed. If it keeps failing, check the core log under Maintenance → Logs and report the code above.');
else
message.textContent += ' ' + _('The system check did not finish. Retry, or check the core log under Maintenance → Logs.');
}, function() {
message.textContent += ' ' + _('The system check did not finish (interface timeout or error). Retry, or check the core log under Maintenance → Logs.');
});
}).finally(function() {
setupBusy = false;
submit.disabled = false;
});
});
setupArea.appendChild(form);
restoreFields(setupArea, fields);
}
function refresh() {
if (busy || setupBusy || settingsBusy)
return Promise.resolve();
return honk.status().then(function(status) {
renderStatus(status, null);
renderSetup(status);
}).catch(function() {
renderStatus(null, true);
});
}
function runAction(method) {
if (busy)
return;
busy = true;
actionMessage.textContent = _('Working…');
renderStatus(currentStatus, null);
honk[method]().then(honk.ensureOk).then(function(result) {
actionMessage.textContent = honk.resultMessage(result, _('Operation completed.'));
}).catch(function(error) {
actionMessage.textContent = honk.errorMessage(error, _('Operation failed. Check Honk status.'));
}).finally(function() {
busy = false;
refresh();
});
}
page.appendChild(E('div', { 'class': 'honk-header' }, [
E('h2', {}, _('Honk')),
E('p', { 'class': 'honk-header-sub' }, _('Transparent proxy core for OpenWrt. Start, stop and manage the Honk service.'))
]));
page.appendChild(statusArea);
page.appendChild(actionMessage);
page.appendChild(setupArea);
renderStatus(data.status, data.error);
if (data.status)
renderSetup(data.status);
poll.add(refresh, 5);
return page;
}
});
@@ -0,0 +1,489 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require baseclass';
'require rpc';
const callStatus = rpc.declare({
object: 'honk',
method: 'status',
expect: { '': {} }
});
const callCheck = rpc.declare({
object: 'honk',
method: 'check',
expect: { '': {} }
});
const callInitialize = rpc.declare({
object: 'honk',
method: 'initialize',
params: [ 'username', 'password' ],
expect: { '': {} }
});
const callStart = rpc.declare({ object: 'honk', method: 'start', expect: { '': {} } });
const callStop = rpc.declare({ object: 'honk', method: 'stop', expect: { '': {} } });
const callRestart = rpc.declare({ object: 'honk', method: 'restart', expect: { '': {} } });
const callSettings = rpc.declare({
object: 'honk',
method: 'settings',
params: [ 'lan_network', 'listen_port', 'clash_api_enabled', 'clash_api_port', 'clash_api_secret' ],
expect: { '': {} }
});
const callProbeSubscription = rpc.declare({
object: 'honk',
method: 'probe_subscription',
params: [ 'url' ],
expect: { '': {} }
});
const callAddSubscription = rpc.declare({
object: 'honk',
method: 'add_subscription',
params: [ 'name', 'url' ],
expect: { '': {} }
});
const callAddNodes = rpc.declare({
object: 'honk',
method: 'add_nodes',
params: [ 'links' ],
expect: { '': {} }
});
const callImportDae = rpc.declare({
object: 'honk',
method: 'import_dae',
expect: { '': {} }
});
const callConfigRead = rpc.declare({ object: 'honk', method: 'config_read', expect: { '': {} } });
const callConfigDraftRead = rpc.declare({ object: 'honk', method: 'config_draft_read', expect: { '': {} } });
const callLanCandidates = rpc.declare({ object: 'honk', method: 'lan_candidates', expect: { '': {} } });
const callConfigValidate = rpc.declare({ object: 'honk', method: 'config_validate', params: [ 'content' ], expect: { '': {} } });
const callConfigSave = rpc.declare({ object: 'honk', method: 'config_save', params: [ 'content' ], expect: { '': {} } });
const callConfigApply = rpc.declare({ object: 'honk', method: 'config_apply', expect: { '': {} } });
const callConfigUseManaged = rpc.declare({ object: 'honk', method: 'config_use_managed', expect: { '': {} } });
const callRepair = rpc.declare({ object: 'honk', method: 'repair', expect: { '': {} } });
const callBackup = rpc.declare({ object: 'honk', method: 'backup', expect: { '': {} } });
const callRestore = rpc.declare({ object: 'honk', method: 'restore', expect: { '': {} } });
const callRestorePrepare = rpc.declare({ object: 'honk', method: 'restore_prepare', expect: { '': {} } });
const callResetAdmin = rpc.declare({ object: 'honk', method: 'reset_admin', expect: { '': {} } });
const callReset = rpc.declare({ object: 'honk', method: 'reset', expect: { '': {} } });
const callJobStatus = rpc.declare({
object: 'honk',
method: 'job_status',
params: [ 'job_id' ],
expect: { '': {} }
});
const callLogs = rpc.declare({
object: 'honk',
method: 'logs',
params: [ 'source', 'level', 'limit' ],
expect: { '': {} }
});
const callUpdateCheck = rpc.declare({ object: 'honk', method: 'update_check', expect: { '': {} } });
const callUpdateApply = rpc.declare({ object: 'honk', method: 'update_apply', expect: { '': {} } });
let pageStylesInstalled = false;
function installStyles() {
if (pageStylesInstalled || document.getElementById('honk-page-styles'))
return;
const stylesheet = document.createElement('link');
stylesheet.id = 'honk-page-styles';
stylesheet.rel = 'stylesheet';
stylesheet.href = L.resource('view/honk/honk.css');
document.head.appendChild(stylesheet);
pageStylesInstalled = true;
}
/* A core bound to 0.0.0.0 (or ::) reports a wildcard authority. A browser
cannot open that, but the router is still reachable at the host LuCI was
loaded from, so substitute that instead of hiding the panel button. */
function wildcardHost(hostname) {
const name = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
return name === '0.0.0.0' || name === '::' || name === '';
}
function urlHost(hostname) {
const name = String(hostname || '');
return name.indexOf(':') >= 0 ? '[' + name.replace(/^\[|\]$/g, '') + ']' : name;
}
function localWebUi(status) {
if (!status || !status.initialized || !status.running || !status.api_ready || !status.api_url)
return null;
try {
const raw = String(status.api_url);
const authority = raw.match(/^https?:\/\/([^/?#]+)/i);
if (!authority || authority[1].indexOf('@') >= 0)
return null;
const portMatch = authority[1].match(/:(\d+)$/);
const url = new URL(raw);
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const port = portMatch ? Number(portMatch[1]) : 0;
if (!portMatch || !Number.isInteger(port) || port < 1 || port > 65535 ||
url.protocol !== 'http:' && url.protocol !== 'https:' ||
url.username || url.password || url.search || url.hash)
return null;
if (wildcardHost(hostname) && window.location.hostname)
return url.protocol + '//' + urlHost(window.location.hostname) + ':' + port + '/ui/';
const octets = hostname.split('.');
let localIPv4 = false;
if (octets.length === 4 && octets.every(function(part) { return /^\d{1,3}$/.test(part) && Number(part) <= 255; })) {
const ip = octets.map(Number);
localIPv4 = ip[0] === 10 || ip[0] === 127 || ip[0] === 192 && ip[1] === 168 ||
ip[0] === 172 && ip[1] >= 16 && ip[1] <= 31 || ip[0] === 169 && ip[1] === 254;
}
const localIPv6 = hostname === '::1' || /^f[cd][0-9a-f:]*$/.test(hostname) || /^fe[89ab][0-9a-f:]*$/.test(hostname);
if (!localIPv4 && !localIPv6)
return null;
return url.origin.replace(/\/$/, '') + '/ui/';
}
catch (e) {
return null;
}
}
function localClashUi(status, secret) {
if (!status || !status.clash_api_enabled || !status.clash_api_ready || !status.clash_ui_ready)
return null;
const base = localWebUi(Object.assign({}, status, { api_url: status.clash_api_url, api_ready: true }));
if (!base)
return null;
try {
const url = new URL(status.clash_api_url);
const host = wildcardHost(url.hostname)
? String(window.location.hostname || '').replace(/^\[|\]$/g, '').toLowerCase()
: url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const port = status.clash_api_port || url.port;
const query = [
'host=' + encodeURIComponent(host),
'hostname=' + encodeURIComponent(host),
'port=' + encodeURIComponent(port),
'secret=' + encodeURIComponent(secret || '')
].join('&');
return base + '?' + query + '#/proxies';
}
catch (e) {
return null;
}
}
function tokenMessage(token) {
const messages = {
disabled: _('Honk is disabled.'),
not_initialized: _('Complete initial setup before starting Honk.'),
service_not_running: _('Honk is enabled but currently stopped.'),
api_unavailable: _('Honk is running, but its management interface is not ready.'),
operation_in_progress: _('Another Honk operation is in progress.'),
stale_operation_lock: _('A previous operation did not finish cleanly.'),
started: _('Honk started.'),
already_running: _('Honk is already running.'),
stopped: _('Honk stopped.'),
restarted: _('Honk restarted.'),
settings_saved: _('Settings saved and applied.'),
initialized: _('Honk initialized.'),
administrator_initialized: _('Honk initialized.'),
system_config_healthy: _('System check passed.'),
system_config_repaired: _('Repair completed.'),
backup_created: _('Backup created.'),
restored: _('Backup restored.'),
data_reset: _('Honk data reset. The service is stopped.'),
worker_lost: _('The maintenance worker stopped unexpectedly; check system recovery status before retrying.'),
operation_failed: _('The maintenance operation failed.'),
job_timeout: _('The maintenance operation timed out.'),
maintenance_recovery_required: _('Recovery is required. Run system recovery before starting another maintenance operation.'),
update_recovery_required: _('The signed update did not finish. Keep Honk stopped and reinstall a complete verified package set before retrying.'),
rollback_tuple_unavailable: _('A compatible update is available, but a complete verified rollback package set is not available yet.'),
trusted_feed_unavailable: _('No trusted update source is configured.'),
manifest_incompatible: _('The signed update manifest does not match this device.'),
signed_manifest_unavailable: _('The signed update manifest could not be fetched or verified.'),
update_available: _('A compatible update is available.'),
no_update: _('No compatible update is available.'),
already_current: _('Honk is already up to date.'),
update_apply_failed: _('The update could not be installed.'),
update_check_failed: _('The signed update could not be verified for this device.'),
signed_manifest_missing: _('Check for updates again to refresh the signed manifest.'),
signed_manifest_expired: _('The signed manifest expired. Check for updates again.'),
reset_failed: _('Reset failed; the previous service state was restored.'),
reset_failed_rollback_failed: _('Reset failed and automatic recovery did not finish. Run system recovery before retrying.'),
restore_failed_rollback_failed: _('Restore failed and automatic recovery did not finish. Run system recovery before retrying.'),
logs_unavailable: _('Honk logs are unavailable.'),
unsupported_core: _('This installed core does not support the requested import operation.'),
invalid_request: _('The import request is incomplete or invalid.'),
import_failed: _('The core could not prepare this import preview.'),
share_links_too_large: _('Share links exceed the 16 KiB per request limit.'),
share_links_invalid_or_conflicting: _('A share link is invalid or conflicts with an existing node. Review the input and try again.'),
subscription_invalid: _('The subscription could not be added: the name must be 1-64 characters without single quotes, and the URL must be a valid http(s) link.'),
subscription_invalid_or_conflicting: _('The subscription is invalid or its name already exists.'),
subscription_no_nodes: _('The subscription returned no usable nodes. The configuration was not changed.'),
subscription_added: _('Subscription saved with usable nodes.'),
nodes_added: _('Nodes saved.'),
reload_failed_rolled_back: _('Honk could not restart. The previous configuration and service were restored.'),
reload_and_rollback_failed: _('Honk could not restart or restore the previous service. Check its status and backup.'),
dae_imported: _('Configuration imported and Honk reloaded.'),
config_missing: _('The configuration file is missing.'),
config_write_failed: _('Could not write the configuration file.'),
dae_too_large: _('The dae file exceeds the 2 MiB limit.'),
dae_invalid_or_unsupported: _('The dae file contains invalid sections or unsupported includes.'),
candidate_invalid: _('Honk could not load the proposed configuration.'),
candidate_rejected: _('The imported configuration failed core validation.'),
source_changed: _('Honk or Doona changed the configuration during this operation. Try again.'),
preview_expired: _('The preview expired. Create a new preview.'),
preview_corrupt: _('The saved preview is damaged. Create a new preview.'),
dae_upload_unavailable: _('The dae upload is missing or unavailable. Upload the file again.'),
dae_upload_invalid: _('The uploaded dae file is invalid.'),
dae_upload_changed: _('The uploaded dae file changed. Upload it again.'),
upload_failed: _('Honk could not prepare the upload location. Check the upload directory permissions and try again.'),
upload_directory_unavailable: _('Honk could not prepare a private configuration upload directory.'),
upload_target_unavailable: _('The temporary configuration upload is unavailable. Try again.'),
config_too_large: _('The configuration file exceeds the 2 MiB limit.'),
config_applied: _('Configuration applied and Honk is running.'),
config_apply_failed_rolled_back: _('Honk could not start with this configuration. The previous file was restored.'),
config_apply_rollback_failed_snapshot_retained: _('Startup failed and the automatic rollback did not finish. The snapshot was kept; run system recovery.'),
config_changed_concurrently: _('The configuration changed during the operation. Nothing was applied.'),
config_write_failed_snapshot_retained: _('Could not write the configuration. The snapshot was kept.'),
service_stop_unconfirmed_snapshot_retained: _('Could not confirm that Honk stopped. The operation was aborted and the snapshot was kept.'),
manual_mode_required: _('Manual mode is active. Restore managed mode before using the subscription tools.'),
managed_edit_unavailable: _('Manual mode is active, so this tool cannot edit the configuration. Select Restore managed mode first.'),
administrator_exists_reuse_credentials_or_reset_state: _('已经初始化过了,请用原来的账号登录 Doona。忘记密码:到「维护 → 重置管理员」清掉管理员记录(不动订阅和规则),再回到本页重新初始化。'),
administrator_setup_failed: _('创建 Doona 管理员失败,请到「维护 → 日志」查看核心日志。'),
invalid_credentials: _('用户名只能包含字母、数字、下划线、点、连字符(不超过 64 个字符);密码长度必须是 8 到 128 个字符,可以包含空格。'),
disable_service_before_initialization: _('请先停止 Honk 服务再初始化。'),
honk_resources_already_exist: _('系统里还留着 dae0 / daens 等运行痕迹,请先停止服务或执行系统恢复。'),
core_missing: _('找不到 /usr/bin/honk-core,请重新安装 honk 包。'),
core_already_running: _('honk-core 已经在运行,请先停止服务。'),
jshn_unavailable: _('缺少 libubox 组件(/usr/share/libubox/jshn.sh),请检查固件是否完整。'),
temporary_storage_unavailable: _('临时目录 /tmp 不可写或空间不足。'),
system_config_backup_failed: _('备份现有 system.dae 失败,请检查磁盘空间。'),
system_snapshot_failed: _('备份系统配置失败,请检查磁盘空间。'),
uci_snapshot_failed: _('备份 UCI 配置失败,请检查磁盘空间。'),
loopback_listener_write_failed: _('写入 system.dae 的监听地址失败,请检查文件权限。'),
main_config_unavailable: _('/etc/honk/config.dae 不存在或不是普通文件。'),
main_config_changed_concurrently: _('操作期间 config.dae 被其他进程改动,已中止。'),
invalid_package_config: _('luci-app-honk 的软件包配置不完整,请重新安装。'),
invalid_config_path: _('配置路径不是 /etc/honk/config.dae,拒绝操作。'),
manual_config_valid: _('手动模式配置有效。'),
manual_config_invalid: _('手动模式配置无效,请先修正 config.dae。'),
required_system_include_missing_manual_repair: _('手动模式的配置缺少 include { system.dae },无法修复托管设置。'),
native_api_setup_state_unavailable: _('读不到核心的 API 状态,请查看日志。'),
ui_assets_missing: _('缺少 Doona 静态文件(/usr/share/doona),请重新安装 luci-app-honk。'),
mock_core_start_failed: _('测试用核心启动失败:通常是内核缺 BTF(/sys/kernel/btf/vmlinux)或 eBPF 依赖模块。'),
mock_core_stop_failed: _('测试用核心无法正常停止,请稍后重试。'),
mock_core_restart_failed: _('测试用核心重启失败,请查看日志。'),
mock_core_stop_failed_restored_loopback: _('测试核心停止失败,已恢复原来的监听配置。'),
generated_config_invalid_restored_loopback: _('生成的配置校验不通过,已恢复原来的监听配置。'),
uci_commit_failed_restored_loopback: _('写入 UCI 失败,已恢复原来的监听配置。'),
lan_api_verification_failed_restored_loopback: _('用局域网地址访问 API 验证失败(网卡或 IP 可能对不上),已恢复。'),
persistence_login_failed: _('初始化后登录验证失败,请重试或查看日志。'),
persistence_verification_failed: _('初始化结果没有落盘(可能磁盘空间不足)。'),
setup_verification_failed: _('初始化后的自检没通过,请查看日志。'),
lan_ipv4_unavailable: _('选中的 LAN 网络没有 IPv4 地址。'),
mkdir_failed: _('创建目录失败,请检查磁盘空间与权限。'),
settings_unavailable_in_manual_mode: _('手动模式下这些设置由 config.dae 决定,无法保存。'),
settings_too_large: _('提交的设置数据过大。'),
invalid_settings_json: _('设置的请求格式不正确。'),
invalid_boolean_settings: _('开关类设置取值不合法。'),
invalid_log_level: _('日志级别不合法。'),
invalid_log_limit: _('日志行数不合法。'),
invalid_log_source: _('日志来源不合法。'),
invalid_operation: _('未知的操作类型。'),
unknown_method: _('未知的接口方法。'),
settings_changed_concurrently: _('操作期间设置被改动,已中止。'),
lan_network_changed_during_settings: _('操作期间 LAN 网络被改动,已中止。'),
lan_network_changed_during_repair: _('操作期间 LAN 网络被改动,已中止。'),
service_state_changed_concurrently: _('操作期间服务状态发生变化,已中止。'),
settings_commit_failed_rolled_back: _('设置写入失败,已回滚到原值。'),
settings_commit_and_rollback_failed_snapshot_retained: _('设置写入失败且回滚未完成,快照已保留,请执行系统恢复。'),
service_apply_and_rollback_failed_snapshot_retained: _('应用设置失败且回滚未完成,快照已保留,请执行系统恢复。'),
service_stop_failed_rolled_back: _('停止服务失败,已回滚。'),
service_stop_and_rollback_failed_snapshot_retained: _('停止服务失败且回滚未完成,快照已保留。'),
system_config_write_failed: _('写入 system.dae 失败。'),
system_config_write_failed_rolled_back: _('写入 system.dae 失败,已回滚。'),
system_config_write_and_rollback_failed_snapshot_retained: _('写入 system.dae 失败且回滚未完成,快照已保留。'),
system_config_repair_failed_rolled_back: _('修复 system.dae 失败,已回滚。'),
system_config_repair_rollback_failed_snapshot_retained: _('修复 system.dae 失败且回滚未完成,快照已保留。'),
system_config_not_regular_file: _('system.dae 不是普通文件(可能是软链),拒绝操作。'),
boot_link_update_failed_rolled_back: _('更新开机自启链接失败,已回滚。'),
boot_link_update_and_rollback_failed_snapshot_retained: _('更新开机自启链接失败且回滚未完成,快照已保留。'),
repair_restart_failed_rolled_back: _('修复后重启失败,已回滚。'),
repair_restart_and_rollback_failed_snapshot_retained: _('修复后重启失败且回滚未完成,快照已保留。'),
subscription_unreachable: _('抓不到这个订阅地址:网络不通、需要代理,或链接已失效。'),
subscription_empty: _('订阅内容是空的。'),
subscription_too_large: _('订阅内容超过 4 MiB,拒绝处理。'),
configuration_validation_failed: _('配置没有通过静态校验。'),
config_changed_concurrently_restart_failed_snapshot_retained: _('操作期间配置被改动且重启失败,快照已保留。'),
invalid_job_id: _('任务编号不合法。'),
job_id_unavailable: _('拿不到任务编号。'),
job_not_found: _('找不到该任务。'),
job_status_unavailable: _('读不到任务状态。'),
job_storage_unavailable: _('任务存储不可用(/tmp 空间不足?)。'),
prepared: _('上传目录已准备好。'),
probe_failed: _('预检失败,请稍后重试。'),
managed_switch_failed_rolled_back: _('Could not switch back to managed mode. The previous configuration was restored.'),
managed_switch_rollback_failed_snapshot_retained: _('Switching back to managed mode failed and the rollback did not finish. The snapshot was kept.'),
managed_snapshot_invalid: _('The saved managed configuration is not a valid regular file.'),
config_draft_missing: _('There is no saved draft to apply.'),
config_draft_invalid: _('The saved draft is not a valid regular file.'),
managed_mode_restored: _('Managed mode restored.'),
managed_snapshot_missing: _('There is no saved managed configuration to restore.'),
lan_candidates_unavailable: _('Could not read the list of network interfaces.'),
config_read_failed: _('Honk could not read the current configuration.'),
config_request_invalid: _('The configuration request is empty or invalid.'),
config_validation_failed: _('The configuration did not pass static validation.'),
config_validation_unavailable: _('The core validator did not return a result.'),
config_saved_pending_apply: _('Configuration saved. The running service is unchanged until you apply it.'),
config_loaded: _('Configuration loaded.'),
dae_loaded: _('Configuration file loaded into the editor.'),
upload_ready: _('Upload location ready.'),
upload_cleared: _('Temporary upload cleared.'),
upload_unavailable: _('A private upload location could not be prepared.'),
settings_write_failed: _('Could not save Honk settings.'),
curl_missing: _('The curl command is missing. Install the curl package and try again.'),
start_failed: _('Honk could not start.'),
stop_failed: _('Honk could not stop.'),
restart_failed: _('Honk could not restart.'),
managed_service_unhealthy: _('The managed Honk service is not healthy.'),
unmanaged_core_running: _('An unmanaged Honk core process is running.'),
invalid_lan_network: _('The selected LAN network is invalid or unavailable.'),
lan_network_unavailable: _('The selected LAN network is unavailable.'),
listen_port_occupied: _('The listen port is already in use.'),
clash_api_port_occupied: _('The Clash API port is already in use.'),
invalid_clash_api_settings: _('Enter a different Clash API port and a secret of 6–128 letters, numbers, dots, underscores or hyphens.'),
invalid_listen_port: _('The listen port is invalid.'),
initialize_before_enabling_service: _('Complete initial setup before enabling Honk.'),
system_config_unavailable: _('Honk system configuration is unavailable.'),
package_config_unavailable: _('Honk configuration is unavailable.'),
service_apply_failed_rolled_back: _('Settings could not be applied. The previous settings were restored.')
};
return messages[token] || null;
}
function statusIssue(token) {
return tokenMessage(token) || _('Honk reported a system issue: %s').format(token);
}
function resultMessage(result, fallback) {
return result && tokenMessage(result.message) || fallback;
}
function errorMessage(error, fallback) {
const token = error && error.message;
const mapped = tokenMessage(token);
if (mapped)
return mapped;
/* 没映射过就把错误码原样带出来,否则用户只能看到「操作失败」,没法反馈 */
if (token && /^[a-z0-9_]+$/.test(token))
return _('%s (code: %s)').format(fallback || _('The operation failed.'), token);
return fallback;
}
function jobPhaseMessage(phase) {
const messages = {
queued: _('Waiting for the operation to start…'),
checking_feed: _('Checking the signed update source…'),
downloading: _('Downloading the signed package set…'),
preparing: _('Preparing configuration apply…'),
validating_candidate: _('Validating the candidate in an isolated runtime…'),
stopping: _('Stopping Honk safely…'),
applying: _('Applying the reviewed configuration…'),
validating: _('Checking the updated configuration…'),
starting: _('Restoring the previous service state…'),
rolling_back: _('Restoring the previous configuration…'),
committing: _('Finishing the transaction…'),
recovery: _('Recovery is required before another operation.')
};
return messages[phase] || _('Applying configuration…');
}
function ensureOk(result) {
if (!result || result.ok !== true)
throw new Error(result && result.message ? result.message : _('The backend did not confirm the operation.'));
return result;
}
function waitJob(jobId, onProgress) {
if (!/^[0-9a-f]{32}$/.test(jobId || ''))
return Promise.reject(new Error('invalid_job_id'));
let attempts = 0;
return new Promise(function(resolve, reject) {
function poll() {
callJobStatus(jobId).then(function(result) {
if (!result || typeof result.state !== 'string')
throw new Error('invalid_job_status');
if (onProgress)
onProgress(result);
if (result.state === 'succeeded') {
resolve(result);
return;
}
if (result.state === 'failed' || result.state === 'rollback_required') {
const error = new Error(result.state === 'rollback_required' ? 'maintenance_recovery_required' : result.message || 'operation_failed');
error.recoveryRequired = result.state === 'rollback_required';
reject(error);
return;
}
if (result.state !== 'queued' && result.state !== 'running') {
reject(new Error('invalid_job_status'));
return;
}
if (++attempts >= 1800) {
reject(new Error('job_timeout'));
return;
}
setTimeout(poll, 1000);
}).catch(reject);
}
poll();
});
}
return baseclass.extend({
status: callStatus,
check: callCheck,
initialize: callInitialize,
start: callStart,
stop: callStop,
restart: callRestart,
settings: callSettings,
probeSubscription: callProbeSubscription,
addSubscription: callAddSubscription,
addNodes: callAddNodes,
importDae: callImportDae,
configRead: callConfigRead,
configDraftRead: callConfigDraftRead,
lanCandidates: callLanCandidates,
configValidate: callConfigValidate,
configSave: callConfigSave,
configApply: callConfigApply,
configUseManaged: callConfigUseManaged,
repair: callRepair,
backup: callBackup,
restore: callRestore,
restorePrepare: callRestorePrepare,
resetAdmin: callResetAdmin,
reset: callReset,
logs: callLogs,
updateCheck: callUpdateCheck,
updateApply: callUpdateApply,
waitJob: waitJob,
ensureOk: ensureOk,
localWebUi: localWebUi,
localClashUi: localClashUi,
statusIssue: statusIssue,
resultMessage: resultMessage,
errorMessage: errorMessage,
jobPhaseMessage: jobPhaseMessage,
installStyles: installStyles
});
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require network';
'require view';
'require view.honk.rpc as honk';
function getNetworkNames(networks) {
if (!networks)
return [];
const list = Array.isArray(networks) ? networks : Object.keys(networks).map(function(key) { return networks[key]; });
return list.map(function(net) {
if (net && typeof net.getName === 'function')
return net.getName();
if (net && typeof net.name === 'string')
return net.name;
return typeof net === 'string' ? net : '';
}).filter(function(name, index, names) {
return !!name && names.indexOf(name) === index;
}).sort();
}
function field(label, input) {
return E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'for': input.id }, label),
E('div', { 'class': 'cbi-value-field' }, input)
]);
}
return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function() {
return Promise.all([
honk.status().then(function(status) { return { status: status }; }).catch(function(error) { return { statusError: error.message || String(error) }; }),
network.getNetworks().then(getNetworkNames).then(function(names) { return { networks: names }; }).catch(function(error) { return { networkError: error.message || String(error), networks: [] }; })
]).then(function(results) {
return Object.assign({}, results[0], results[1]);
});
},
render: function(data) {
honk.installStyles();
const page = E('div', { 'class': 'cbi-map honk-page' });
const section = E('form', { 'class': 'honk-card' });
const networkSelect = E('select', { 'class': 'cbi-input-select', 'name': 'lan_network', 'id': 'honk-lan-network' });
const portInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'listen_port', 'id': 'honk-listen-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
const message = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const save = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Save settings'));
save.disabled = !!data.statusError;
(data.networks || []).forEach(function(name) {
networkSelect.appendChild(E('option', { 'value': name }, name));
});
if (data.status && data.status.lan_network && !(data.networks || []).includes(data.status.lan_network))
networkSelect.appendChild(E('option', { 'value': data.status.lan_network }, data.status.lan_network));
if (data.status && data.status.lan_network)
networkSelect.value = data.status.lan_network;
portInput.value = data.status && data.status.listen_port ? String(data.status.listen_port) : '9527';
clashEnabledInput.checked = !!(data.status && data.status.clash_api_enabled === true);
clashPortInput.value = data.status && data.status.clash_api_port ? String(data.status.clash_api_port) : '9090';
section.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Honk settings')));
/* In manual mode the whole runtime comes from config.dae, so the backend
refuses these settings. Disable them instead of letting the save fail
with an error the user cannot act on. */
const manualMode = !!(data.status && data.status.config_mode === 'manual');
if (manualMode) {
[ networkSelect, portInput, clashEnabledInput, clashPortInput, clashSecretInput ].forEach(function(input) {
input.disabled = true;
});
save.disabled = true;
section.appendChild(E('p', { 'class': 'honk-note honk-mode-hint' }, _('Manual mode: these settings belong to config.dae, so they are read-only here. Restore managed mode on the Configuration page to change them.')));
}
if (data.statusError)
section.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read current settings: %s').format(data.statusError)));
if (data.networkError)
section.appendChild(E('div', { 'class': 'alert-message' }, _('Could not load LAN networks: %s').format(data.networkError)));
if (!(data.networks || []).length)
networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found')));
section.appendChild(field(_('LAN network'), networkSelect));
section.appendChild(field(_('Listen port'), portInput));
section.appendChild(field(_('Enable Clash API'), clashEnabledInput));
section.appendChild(field(_('Clash API port'), clashPortInput));
section.appendChild(field(_('Clash API secret'), clashSecretInput));
section.appendChild(E('p', { 'class': 'honk-note' }, data.status && data.status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it. Use your existing HTTPS reverse proxy for external access.') : _('A secret is required when enabling Clash API. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
section.appendChild(E('div', { 'class': 'cbi-page-actions' }, save));
section.appendChild(message);
section.addEventListener('submit', function(ev) {
ev.preventDefault();
const port = Number(portInput.value);
const clashPort = Number(clashPortInput.value);
const clashSecret = clashSecretInput.value;
if (!networkSelect.value || !Number.isInteger(port) || port < 1024 || port > 65535) {
message.textContent = _('Choose a LAN network and a port from 1024 to 65535.');
return;
}
if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535) {
message.textContent = _('Clash API port must be between 1024 and 65535.');
return;
}
if (clashEnabledInput.checked && clashPort === port) {
message.textContent = _('The Clash API port must differ from the listen port.');
return;
}
if (clashEnabledInput.checked && !(data.status && data.status.clash_api_secret_set) && !clashSecret) {
message.textContent = _('A secret is required when enabling Clash API.');
return;
}
if (clashEnabledInput.checked && clashSecret && !/^[A-Za-z0-9._-]{6,128}$/.test(clashSecret)) {
message.textContent = _('The secret must be 6-128 letters, numbers, dots, underscores or hyphens.');
return;
}
save.disabled = true;
message.textContent = _('Saving…');
honk.settings(networkSelect.value, port, clashEnabledInput.checked, clashPort, clashSecret).then(honk.ensureOk).then(function(result) {
clashSecretInput.value = '';
message.textContent = honk.resultMessage(result, _('Settings saved and applied.'));
}).catch(function(error) {
message.textContent = honk.errorMessage(error, _('Save failed. Check the settings and system status.'));
}).finally(function() {
save.disabled = false;
});
});
page.appendChild(E('div', { 'class': 'honk-header' }, [
E('h2', {}, _('Settings')),
E('p', { 'class': 'honk-header-sub' }, _('Network interface, listen port and boot behaviour for the Honk service.'))
]));
page.appendChild(section);
return page;
}
});
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-3.0-only
'use strict';
'require baseclass';
function sha256(input) {
const bytes = new Uint8Array(input);
const bitLength = bytes.length * 8;
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64;
const padded = new Uint8Array(paddedLength);
padded.set(bytes);
padded[bytes.length] = 0x80;
const lowBits = bitLength >>> 0;
const highBits = Math.floor(bitLength / 0x100000000) >>> 0;
for (let i = 0; i < 4; i++) {
padded[paddedLength - 8 + i] = highBits >>> (24 - i * 8);
padded[paddedLength - 4 + i] = lowBits >>> (24 - i * 8);
}
const constants = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
const state = [ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 ];
const words = new Uint32Array(64);
const rotateRight = function(value, count) { return (value >>> count) | (value << (32 - count)); };
for (let offset = 0; offset < paddedLength; offset += 64) {
for (let i = 0; i < 16; i++) {
const at = offset + i * 4;
words[i] = ((padded[at] << 24) | (padded[at + 1] << 16) | (padded[at + 2] << 8) | padded[at + 3]) >>> 0;
}
for (let i = 16; i < 64; i++) {
const x = words[i - 15];
const y = words[i - 2];
const sigma0 = rotateRight(x, 7) ^ rotateRight(x, 18) ^ (x >>> 3);
const sigma1 = rotateRight(y, 17) ^ rotateRight(y, 19) ^ (y >>> 10);
words[i] = (words[i - 16] + sigma0 + words[i - 7] + sigma1) >>> 0;
}
let a = state[0], b = state[1], c = state[2], d = state[3];
let e = state[4], f = state[5], g = state[6], h = state[7];
for (let i = 0; i < 64; i++) {
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
const choice = (e & f) ^ (~e & g);
const temp1 = (h + sum1 + choice + constants[i] + words[i]) >>> 0;
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
const majority = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (sum0 + majority) >>> 0;
h = g; g = f; f = e; e = (d + temp1) >>> 0;
d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;
}
state[0] = (state[0] + a) >>> 0;
state[1] = (state[1] + b) >>> 0;
state[2] = (state[2] + c) >>> 0;
state[3] = (state[3] + d) >>> 0;
state[4] = (state[4] + e) >>> 0;
state[5] = (state[5] + f) >>> 0;
state[6] = (state[6] + g) >>> 0;
state[7] = (state[7] + h) >>> 0;
}
return state.map(function(value) { return value.toString(16).padStart(8, '0'); }).join('');
}
return baseclass.extend({ sha256: sha256 });
@@ -0,0 +1,21 @@
(The MIT License)
Copyright (C) 2011-2015 by Vitaly Puzrin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,33 @@
(function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports):typeof define==`function`&&define.amd?define([`exports`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.jsyaml={}))})(this,function(e){Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});var t=Object.create,n=Object.defineProperty,r=Object.getOwnPropertyDescriptor,i=Object.getOwnPropertyNames,a=Object.getPrototypeOf,o=Object.prototype.hasOwnProperty,s=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),c=(e,t,a,s)=>{if(t&&typeof t==`object`||typeof t==`function`)for(var c=i(t),l=0,u=c.length,d;l<u;l++)d=c[l],!o.call(e,d)&&d!==a&&n(e,d,{get:(e=>t[e]).bind(null,d),enumerable:!(s=r(t,d))||s.enumerable});return e},l=(e,r,i)=>(i=e==null?{}:t(a(e)),c(r||!e||!e.__esModule?n(i,`default`,{value:e,enumerable:!0}):i,e)),u=s(((e,t)=>{function n(e){return e==null}function r(e){return typeof e==`object`&&!!e}function i(e){return Array.isArray(e)?e:n(e)?[]:[e]}function a(e,t){if(t){let n=Object.keys(t);for(let r=0,i=n.length;r<i;r+=1){let i=n[r];e[i]=t[i]}}return e}function o(e,t){let n=``;for(let r=0;r<t;r+=1)n+=e;return n}function s(e){return e===0&&1/e==-1/0}t.exports.isNothing=n,t.exports.isObject=r,t.exports.toArray=i,t.exports.repeat=o,t.exports.isNegativeZero=s,t.exports.extend=a})),d=s(((e,t)=>{function n(e,t){let n=``,r=e.reason||`(unknown reason)`;return e.mark?(e.mark.name&&(n+=`in "`+e.mark.name+`" `),n+=`(`+(e.mark.line+1)+`:`+(e.mark.column+1)+`)`,!t&&e.mark.snippet&&(n+=`
`+e.mark.snippet),r+` `+n):r}function r(e,t){Error.call(this),this.name=`YAMLException`,this.reason=e,this.mark=t,this.message=n(this,!1),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=Error().stack||``}r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r.prototype.toString=function(e){return this.name+`: `+n(this,e)},t.exports=r})),f=s(((e,t)=>{var n=u();function r(e,t,n,r,i){let a=``,o=``,s=Math.floor(i/2)-1;return r-t>s&&(a=` ... `,t=r-s+a.length),n-r>s&&(o=` ...`,n=r+s-o.length),{str:a+e.slice(t,n).replace(/\t/g,`→`)+o,pos:r-t+a.length}}function i(e,t){return n.repeat(` `,t-e.length)+e}function a(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),typeof t.indent!=`number`&&(t.indent=1),typeof t.linesBefore!=`number`&&(t.linesBefore=3),typeof t.linesAfter!=`number`&&(t.linesAfter=2);let a=/\r?\n|\r|\0/g,o=[0],s=[],c,l=-1;for(;c=a.exec(e.buffer);)s.push(c.index),o.push(c.index+c[0].length),e.position<=c.index&&l<0&&(l=o.length-2);l<0&&(l=o.length-1);let u=``,d=Math.min(e.line+t.linesAfter,s.length).toString().length,f=t.maxLength-(t.indent+d+3);for(let a=1;a<=t.linesBefore&&!(l-a<0);a++){let c=r(e.buffer,o[l-a],s[l-a],e.position-(o[l]-o[l-a]),f);u=n.repeat(` `,t.indent)+i((e.line-a+1).toString(),d)+` | `+c.str+`
`+u}let p=r(e.buffer,o[l],s[l],e.position,f);u+=n.repeat(` `,t.indent)+i((e.line+1).toString(),d)+` | `+p.str+`
`,u+=n.repeat(`-`,t.indent+d+3+p.pos)+`^
`;for(let a=1;a<=t.linesAfter&&!(l+a>=s.length);a++){let c=r(e.buffer,o[l+a],s[l+a],e.position-(o[l]-o[l+a]),f);u+=n.repeat(` `,t.indent)+i((e.line+a+1).toString(),d)+` | `+c.str+`
`}return u.replace(/\n$/,``)}t.exports=a})),p=s(((e,t)=>{var n=d(),r=[`kind`,`multi`,`resolve`,`construct`,`instanceOf`,`predicate`,`represent`,`representName`,`defaultStyle`,`styleAliases`],i=[`scalar`,`sequence`,`mapping`];function a(e){let t={};return e!==null&&Object.keys(e).forEach(function(n){e[n].forEach(function(e){t[String(e)]=n})}),t}function o(e,t){if(t=t||{},Object.keys(t).forEach(function(t){if(r.indexOf(t)===-1)throw new n(`Unknown option "`+t+`" is met in definition of "`+e+`" YAML type.`)}),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=a(t.styleAliases||null),i.indexOf(this.kind)===-1)throw new n(`Unknown kind "`+this.kind+`" is specified for "`+e+`" YAML type.`)}t.exports=o})),m=s(((e,t)=>{var n=d(),r=p();function i(e,t){let n=[];return e[t].forEach(function(e){let t=n.length;n.forEach(function(n,r){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=r)}),n[t]=e}),n}function a(){let e={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function t(t){t.multi?(e.multi[t.kind].push(t),e.multi.fallback.push(t)):e[t.kind][t.tag]=e.fallback[t.tag]=t}for(let e=0,n=arguments.length;e<n;e+=1)arguments[e].forEach(t);return e}function o(e){return this.extend(e)}o.prototype.extend=function(e){let t=[],s=[];if(e instanceof r)s.push(e);else if(Array.isArray(e))s=s.concat(e);else if(e&&(Array.isArray(e.implicit)||Array.isArray(e.explicit)))e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(s=s.concat(e.explicit));else throw new n(`Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })`);t.forEach(function(e){if(!(e instanceof r))throw new n(`Specified list of YAML types (or a single Type object) contains a non-Type object.`);if(e.loadKind&&e.loadKind!==`scalar`)throw new n(`There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.`);if(e.multi)throw new n(`There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.`)}),s.forEach(function(e){if(!(e instanceof r))throw new n(`Specified list of YAML types (or a single Type object) contains a non-Type object.`)});let c=Object.create(o.prototype);return c.implicit=(this.implicit||[]).concat(t),c.explicit=(this.explicit||[]).concat(s),c.compiledImplicit=i(c,`implicit`),c.compiledExplicit=i(c,`explicit`),c.compiledTypeMap=a(c.compiledImplicit,c.compiledExplicit),c},t.exports=o})),h=s(((e,t)=>{t.exports=new(p())(`tag:yaml.org,2002:str`,{kind:`scalar`,construct:function(e){return e===null?``:e}})})),g=s(((e,t)=>{t.exports=new(p())(`tag:yaml.org,2002:seq`,{kind:`sequence`,construct:function(e){return e===null?[]:e}})})),_=s(((e,t)=>{t.exports=new(p())(`tag:yaml.org,2002:map`,{kind:`mapping`,construct:function(e){return e===null?{}:e}})})),v=s(((e,t)=>{t.exports=new(m())({explicit:[h(),g(),_()]})})),y=s(((e,t)=>{var n=p();function r(e){if(e===null)return!0;let t=e.length;return t===1&&e===`~`||t===4&&(e===`null`||e===`Null`||e===`NULL`)}function i(){return null}function a(e){return e===null}t.exports=new n(`tag:yaml.org,2002:null`,{kind:`scalar`,resolve:r,construct:i,predicate:a,represent:{canonical:function(){return`~`},lowercase:function(){return`null`},uppercase:function(){return`NULL`},camelcase:function(){return`Null`},empty:function(){return``}},defaultStyle:`lowercase`})})),b=s(((e,t)=>{var n=p();function r(e){if(e===null)return!1;let t=e.length;return t===4&&(e===`true`||e===`True`||e===`TRUE`)||t===5&&(e===`false`||e===`False`||e===`FALSE`)}function i(e){return e===`true`||e===`True`||e===`TRUE`}function a(e){return Object.prototype.toString.call(e)===`[object Boolean]`}t.exports=new n(`tag:yaml.org,2002:bool`,{kind:`scalar`,resolve:r,construct:i,predicate:a,represent:{lowercase:function(e){return e?`true`:`false`},uppercase:function(e){return e?`TRUE`:`FALSE`},camelcase:function(e){return e?`True`:`False`}},defaultStyle:`lowercase`})})),x=s(((e,t)=>{var n=u(),r=p();function i(e){return e>=48&&e<=57||e>=65&&e<=70||e>=97&&e<=102}function a(e){return e>=48&&e<=55}function o(e){return e>=48&&e<=57}function s(e){if(e===null)return!1;let t=e.length,n=0,r=!1;if(!t)return!1;let s=e[n];if((s===`-`||s===`+`)&&(s=e[++n]),s===`0`){if(n+1===t)return!0;if(s=e[++n],s===`b`){for(n++;n<t;n++){if(s=e[n],s!==`0`&&s!==`1`)return!1;r=!0}return r&&Number.isFinite(c(e))}if(s===`x`){for(n++;n<t;n++){if(!i(e.charCodeAt(n)))return!1;r=!0}return r&&Number.isFinite(c(e))}if(s===`o`){for(n++;n<t;n++){if(!a(e.charCodeAt(n)))return!1;r=!0}return r&&Number.isFinite(c(e))}}for(;n<t;n++){if(!o(e.charCodeAt(n)))return!1;r=!0}return r?Number.isFinite(c(e)):!1}function c(e){let t=e Line truncated
\r`;function i(e){if(e===null)return!1;let t=0,n=e.length,i=r;for(let r=0;r<n;r++){let n=i.indexOf(e.charAt(r));if(!(n>64)){if(n<0)return!1;t+=6}}return t%8==0}function a(e){let t=e.replace(/[\r\n=]/g,``),n=t.length,i=r,a=0,o=[];for(let e=0;e<n;e++)e%4==0&&e&&(o.push(a>>16&255),o.push(a>>8&255),o.push(a&255)),a=a<<6|i.indexOf(t.charAt(e));let s=n%4*6;return s===0?(o.push(a>>16&255),o.push(a>>8&255),o.push(a&255)):s===18?(o.push(a>>10&255),o.push(a>>2&255)):s===12&&o.push(a>>4&255),new Uint8Array(o)}function o(e){let t=``,n=0,i=e.length,a=r;for(let r=0;r<i;r++)r%3==0&&r&&(t+=a[n>>18&63],t+=a[n>>12&63],t+=a[n>>6&63],t+=a[n&63]),n=(n<<8)+e[r];let o=i%3;return o===0?(t+=a[n>>18&63],t+=a[n>>12&63],t+=a[n>>6&63],t+=a[n&63]):o===2?(t+=a[n>>10&63],t+=a[n>>4&63],t+=a[n<<2&63],t+=a[64]):o===1&&(t+=a[n>>2&63],t+=a[n<<4&63],t+=a[64],t+=a[64]),t}function s(e){return Object.prototype.toString.call(e)===`[object Uint8Array]`}t.exports=new n(`tag:yaml.org,2002:binary`,{kind:`scalar`,resolve:i,construct:a,predicate:s,represent:o})})),O=s(((e,t)=>{var n=p(),r=Object.prototype.hasOwnProperty,i=Object.prototype.toString;function a(e){if(e===null)return!0;let t=[],n=e;for(let e=0,a=n.length;e<a;e+=1){let a=n[e],o=!1;if(i.call(a)!==`[object Object]`)return!1;let s;for(s in a)if(r.call(a,s))if(!o)o=!0;else return!1;if(!o)return!1;if(t.indexOf(s)===-1)t.push(s);else return!1}return!0}function o(e){return e===null?[]:e}t.exports=new n(`tag:yaml.org,2002:omap`,{kind:`sequence`,resolve:a,construct:o})})),k=s(((e,t)=>{var n=p(),r=Object.prototype.toString;function i(e){if(e===null)return!0;let t=e,n=Array(t.length);for(let e=0,i=t.length;e<i;e+=1){let i=t[e];if(r.call(i)!==`[object Object]`)return!1;let a=Object.keys(i);if(a.length!==1)return!1;n[e]=[a[0],i[a[0]]]}return!0}function a(e){if(e===null)return[];let t=e,n=Array(t.length);for(let e=0,r=t.length;e<r;e+=1){let r=t[e],i=Object.keys(r);n[e]=[i[0],r[i[0]]]}return n}t.exports=new n(`tag:yaml.org,2002:pairs`,{kind:`sequence`,resolve:i,construct:a})})),A=s(((e,t)=>{var n=p(),r=Object.prototype.hasOwnProperty;function i(e){if(e===null)return!0;let t=e;for(let e in t)if(r.call(t,e)&&t[e]!==null)return!1;return!0}function a(e){return e===null?{}:e}t.exports=new n(`tag:yaml.org,2002:set`,{kind:`mapping`,resolve:i,construct:a})})),ee=s(((e,t)=>{t.exports=w().extend({implicit:[T(),E()],explicit:[D(),O(),k(),A()]})})),j=s(((e,t)=>{var n=u(),r=d(),i=f(),a=ee(),o=Object.prototype.hasOwnProperty,s=1,c=2,l=3,p=4,m=1,h=2,g=3,_=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,v=/[\x85\u2028\u2029]/,y=/[,\[\]{}]/,b=/^(?:!|!!|![0-9A-Za-z-]+!)$/,x=/^(?:!|[^,\[\]{}])(?:%[0-9a-f]{2}|[0-9a-z\-#;/?:@&=+$,_.!~*'()\[\]])*$/i;function S(e){return Object.prototype.toString.call(e)}function C(e){return e===10||e===13}function w(e){return e===9||e===32}function T(e){return e===9||e===32||e===10||e===13}function E(e){return e===44||e===91||e===93||e===123||e===125}function D(e){if(e>=48&&e<=57)return e-48;let t=e|32;return t>=97&&t<=102?t-97+10:-1}function O(e){return e===120?2:e===117?4:e===85?8:0}function k(e){return e>=48&&e<=57?e-48:-1}function A(e){switch(e){case 48:return`\0`;case 97:return`\x07`;case 98:return`\b`;case 116:return` `;case 9:return` `;case 110:return`
`;case 118:return`\v`;case 102:return`\f`;case 114:return`\r`;case 101:return`\x1B`;case 32:return` `;case 34:return`"`;case 47:return`/`;case 92:return`\\`;case 78:return`…`;case 95:return`\xA0`;case 76:return`\u2028`;case 80:return`\u2029`;default:return``}}function j(e){return e<=65535?String.fromCharCode(e):String.fromCharCode((e-65536>>10)+55296,(e-65536&1023)+56320)}function te(e,t,n){t===`__proto__`?Object.defineProperty(e,t,{configurable:!0,enumerable:!0,writable:!0,value:n}):e[t]=n}var M=Array(256),N=Array(256);for(let e=0;e<256;e++)M[e]=+!!A(e),N[e]=A(e);function ne(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||a,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.maxDepth=typeof t.maxDepth==`number`?t.maxDepth:100,this.maxMergeSeqLength=typeof t.maxMergeSeqLength==`number`?t.maxMergeSeqLength:20,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.depth=0,this.firstTabInLine=-1,this.documents=[],this.anchorMapTransactions=[]}function P(e,t){let n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=i(n),new r(t,n)}function F(e,t){throw P(e,t)}function I(e,t){e.onWarning&&e.onWarning.call(null,P(e,t))}function L(e,t,n){let r=e.anchorMapTransactions;if(r.length!==0){let n=r[r.length-1];o.call(n,t)||(n[t]={existed:o.call(e.anchorMap,t),value:e.anchorMap[t]})}e.anchorMap[t]=n}function R(e){e.anchorMapTransactions.push(Object.create(null))}function re(e){let t=e.anchorMapTransactions.pop(),n=e.anchorMapTransactions;if(n.length===0)return;let r=n[n.length-1],i=Object.keys(t);for(let e=0,n=i.length;e<n;e+=1){let n=i[e];o.call(r,n)||(r[n]=t[n])}}function z(e){let t=e.anchorMapTransactions.pop(),n=Object.keys(t);for(let r=n.length-1;r>=0;--r){let i=t[n[r]];i.existed?e.anchorMap[n[r]]=i.value:delete e.anchorMap[n[r]]}}function B(e){return{position:e.position,line:e.line,lineStart:e.lineStart,lineIndent:e.lineIndent,firstTabInLine:e.firstTabInLine,tag:e.tag,anchor:e.anchor,kind:e.kind,result:e.result}}function V(e,t){e.position=t.position,e.line=t.line,e.lineStart=t.lineStart,e.lineIndent=t.lineIndent,e.firstTabInLine=t.firstTabInLine,e.tag=t.tag,e.anchor=t.anchor,e.kind=t.kind,e.result=t.result}var H={YAML:function(e,t,n){e.version!==null&&F(e,`duplication of %YAML directive`),n.length!==1&&F(e,`YAML directive accepts exactly one argument`);let r=/^([0-9]+)\.([0-9]+)$/.exec(n[0]);r===null&&F(e,`ill-formed argument of the YAML directive`);let i=parseInt(r[1],10),a=parseInt(r[2],10);i!==1&&F(e,`unacceptable YAML version of the document`),e.version=n[0],e.checkLineBreaks=a<2,a!==1&&a!==2&&I(e,`unsupported YAML version of the document`)},TAG:function(e,t,n){let r;n.length!==2&&F(e,`TAG directive accepts exactly two arguments`);let i=n[0];r=n[1],b.test(i)||F(e,`ill-formed tag handle (first argument) of the TAG directive`),o.call(e.tagMap,i)&&F(e,`there is a previously declared suffix for "`+i+`" tag handle`),x.test(r)||F(e,`ill-formed tag prefix (second argument) of the TAG directive`);try{r=decodeURIComponent(r)}catch(t){F(e,`tag prefix is malformed: `+r)}e.tagMap[i]=r}};function U(e,t,n,r){if(t<n){let i=e.input.slice(t,n);if(r)for(let t=0,n=i.length;t<n;t+=1){let n=i.charCodeAt(t);n===9||n>=32&&n<=1114111||F(e,`expected valid JSON character`)}else _.test(i)&&F(e,`the stream contains non-printable characters`);e.result+=i}}function W(e,t,r,i){n.isObject(r)||F(e,`cannot merge mappings; the provided source object is unacceptable`);let a=Object.keys(r);for(let e=0,n=a.length;e<n;e+=1){let n=a[e];o.call(t,n)||(te(t,n,r[n]),i[n]=!0)}}function G(e,t,n,r,i,a,s,c,l){if(Array.isArray(i)){i=Array.prototype.slice.call(i);for(let t=0,n=i.length;t<n;t+=1)Array.isArray(i[t])&&F(e,`nested arrays are not supported inside keys`),typeof i==`object`&&S(i[t])===`[object Object]`&&(i[t]=`[object Object]`)}if(typeof i==`object`&&S(i)===`[object Object]`&&(i=`[object Object]`),i=String(i),t===null&&(t={}),r===`tag:yaml.org,2002:merge`)if(Array.isArray(a)){a.length>e.maxMergeSeqLength&&F(e,`merge sequence length exceeded maxMergeSeqLength (`+e.maxMergeSeqLength+`)`);let r=new Set;for(let i=0,o=a.length;i<o;i+=1){let o=a[i];r.has(o)||(r.add(o),W(e,t,o,n))}}else W(e,t,a,n);else !e.json&&!o.call(n,i)&&o.call(t,i)&&(e.line=s||e.line,e.lineStart=c||e.lineStart,e.position=l||e.position,F(e,`duplicated mapping key`)),te(t,i,a),delete n[i];return t}function K(e){let t=e.input.charCodeAt(e.position);t===10?e.position++:t===13?(e.position++,e.input.charCodeAt(e.position)===10&&e.position++):F(e,`a line break is expected`),e.line+=1,e.lineStart=e.position,e.firstTabInLine=-1}function q(e,t,n){let r=0,i=e.input.charCodeAt(e.position);for(;i!==0;){for(;w(i);)i===9&&e.firstTabInLine===-1&&(e.firstTabInLine=e.position),i=e.input.charCodeAt(++e.position);if(t&&i===35)d Line truncated
`,t-1))}function ie(e,t,n){let r,i,a,o,s,c,l=e.kind,u=e.result,d=e.input.charCodeAt(e.position);if(T(d)||E(d)||d===35||d===38||d===42||d===33||d===124||d===62||d===39||d===34||d===37||d===64||d===96)return!1;if(d===63||d===45){let t=e.input.charCodeAt(e.position+1);if(T(t)||n&&E(t))return!1}for(e.kind=`scalar`,e.result=``,r=i=e.position,a=!1;d!==0;){if(d===58){let t=e.input.charCodeAt(e.position+1);if(T(t)||n&&E(t))break}else if(d===35){if(T(e.input.charCodeAt(e.position-1)))break}else if(e.position===e.lineStart&&J(e)||n&&E(d))break;else if(C(d))if(o=e.line,s=e.lineStart,c=e.lineIndent,q(e,!1,-1),e.lineIndent>=t){a=!0,d=e.input.charCodeAt(e.position);continue}else{e.position=i,e.line=o,e.lineStart=s,e.lineIndent=c;break}a&&(U(e,r,i,!1),Y(e,e.line-o),r=i=e.position,a=!1),w(d)||(i=e.position+1),d=e.input.charCodeAt(++e.position)}return U(e,r,i,!1),e.result?!0:(e.kind=l,e.result=u,!1)}function X(e,t){let n,r,i=e.input.charCodeAt(e.position);if(i!==39)return!1;for(e.kind=`scalar`,e.result=``,e.position++,n=r=e.position;(i=e.input.charCodeAt(e.position))!==0;)if(i===39)if(U(e,n,e.position,!0),i=e.input.charCodeAt(++e.position),i===39)n=e.position,e.position++,r=e.position;else return!0;else C(i)?(U(e,n,r,!0),Y(e,q(e,!1,t)),n=r=e.position):e.position===e.lineStart&&J(e)?F(e,`unexpected end of the document within a single quoted scalar`):(e.position++,w(i)||(r=e.position));F(e,`unexpected end of the stream within a single quoted scalar`)}function ae(e,t){let n,r,i,a=e.input.charCodeAt(e.position);if(a!==34)return!1;for(e.kind=`scalar`,e.result=``,e.position++,n=r=e.position;(a=e.input.charCodeAt(e.position))!==0;)if(a===34)return U(e,n,e.position,!0),e.position++,!0;else if(a===92){if(U(e,n,e.position,!0),a=e.input.charCodeAt(++e.position),C(a))q(e,!1,t);else if(a<256&&M[a])e.result+=N[a],e.position++;else if((i=O(a))>0){let t=i,n=0;for(;t>0;t--)a=e.input.charCodeAt(++e.position),(i=D(a))>=0?n=(n<<4)+i:F(e,`expected hexadecimal character`);e.result+=j(n),e.position++}else F(e,`unknown escape sequence`);n=r=e.position}else C(a)?(U(e,n,r,!0),Y(e,q(e,!1,t)),n=r=e.position):e.position===e.lineStart&&J(e)?F(e,`unexpected end of the document within a double quoted scalar`):(e.position++,w(a)||(r=e.position));F(e,`unexpected end of the stream within a double quoted scalar`)}function oe(e,t){let n=!0,r,i,a,o=e.tag,c,l=e.anchor,u,d,f,p,m=Object.create(null),h,g,_,v=e.input.charCodeAt(e.position);if(v===91)u=93,p=!1,c=[];else if(v===123)u=125,p=!0,c={};else return!1;for(e.anchor!==null&&L(e,e.anchor,c),v=e.input.charCodeAt(++e.position);v!==0;){if(q(e,!0,t),v=e.input.charCodeAt(e.position),v===u)return e.position++,e.tag=o,e.anchor=l,e.kind=p?`mapping`:`sequence`,e.result=c,!0;n?v===44&&F(e,`expected the node content, but found ','`):F(e,`missed comma between flow collection entries`),g=h=_=null,d=f=!1,v===63&&T(e.input.charCodeAt(e.position+1))&&(d=f=!0,e.position++,q(e,!0,t)),r=e.line,i=e.lineStart,a=e.position,Q(e,t,s,!1,!0),g=e.tag,h=e.result,q(e,!0,t),v=e.input.charCodeAt(e.position),(f||e.line===r)&&v===58&&(d=!0,v=e.input.charCodeAt(++e.position),q(e,!0,t),Q(e,t,s,!1,!0),_=e.result),p?G(e,c,m,g,h,_,r,i,a):d?c.push(G(e,null,m,g,h,_,r,i,a)):c.push(h),q(e,!0,t),v=e.input.charCodeAt(e.position),v===44?(n=!0,v=e.input.charCodeAt(++e.position)):n=!1}F(e,`unexpected end of the stream within a flow collection`)}function Z(e,t){let r,i=m,a=!1,o=!1,s=t,c=0,l=!1,u,d=e.input.charCodeAt(e.position);if(d===124)r=!1;else if(d===62)r=!0;else return!1;for(e.kind=`scalar`,e.result=``;d!==0;)if(d=e.input.charCodeAt(++e.position),d===43||d===45)m===i?i=d===43?g:h:F(e,`repeat of a chomping mode identifier`);else if((u=k(d))>=0)u===0?F(e,`bad explicit indentation width of a block scalar; it cannot be less than one`):o?F(e,`repeat of an indentation width identifier`):(s=t+u-1,o=!0);else break;if(w(d)){do d=e.input.charCodeAt(++e.position);while(w(d));if(d===35)do d=e.input.charCodeAt(++e.position);while(!C(d)&&d!==0)}for(;d!==0;){for(K(e),e.lineIndent=0,d=e.input.charCodeAt(e.position);(!o||e.lineIndent<s)&&d===32;)e.lineIndent++,d=e.input.charCodeAt(++e.position);if(!o&&e.lineIndent>s&&(s=e.lineIndent),C(d)){c++;continue}if(!o&&s===0&&F(e,`missing indentation for block scalar`),e.lineIndent<s){i===g?e.result+=n.repeat(`
`,a?1+c:c):i===m&&a&&(e.result+=`
`);break}r?w(d)?(l=!0,e.result+=n.repeat(`
`,a?1+c:c)):l?(l=!1,e.result+=n.repeat(`
`,c+1)):c===0?a&&(e.result+=` `):e.result+=n.repeat(`
`,c):e.result+=n.repeat(`
`,a?1+c:c),a=!0,o=!0,c=0;let t=e.position;for(;!C(d)&&d!==0;)d=e.input.charCodeAt(++e.position);U(e,t,e.position,!1)}return!0}function se(e,t){let n=e.tag,r=e.anchor,i=[],a=!1;if(e.firstTabInLine!==-1)return!1;e.anchor!==null&&L(e,e.anchor,i);let o=e.input.charCodeAt(e.position);for(;o!==0&&(e.firstTabInLine!==-1&&(e.position=e.firstTabInLine,F(e,`tab characters must not be used in indentation`)),!(o!==45||!T(e.input.charCodeAt(e.position+1))));){if(a=!0,e.position++,q(e,!0,-1)&&e.lineIndent<=t){i.push(null),o=e.input.charCodeAt(e.position);continue}let n=e.line;if(Q(e,t,l,!1,!0),i.push(e.result),q(e,!0,-1),o=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&o!==0)F(e,`bad indentation of a sequence entry`);else if(e.lineIndent<t)break}return a?(e.tag=n,e.anchor=r,e.kind=`sequence`,e.result=i,!0):!1}function ce(e,t,n){let r,i,a,o,s=e.tag,l=e.anchor,u={},d=Object.create(null),f=null,m=null,h=null,g=!1,_=!1;if(e.firstTabInLine!==-1)return!1;e.anchor!==null&&L(e,e.anchor,u);let v=e.input.charCodeAt(e.position);for(;v!==0;){!g&&e.firstTabInLine!==-1&&(e.position=e.firstTabInLine,F(e,`tab characters must not be used in indentation`));let y=e.input.charCodeAt(e.position+1),b=e.line;if((v===63||v===58)&&T(y))v===63?(g&&(G(e,u,d,f,m,null,i,a,o),f=m=h=null),_=!0,g=!0,r=!0):g?(g=!1,r=!0):F(e,`incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line`),e.position+=1,v=y;else{if(i=e.line,a=e.lineStart,o=e.position,!Q(e,n,c,!1,!0))break;if(e.line===b){for(v=e.input.charCodeAt(e.position);w(v);)v=e.input.charCodeAt(++e.position);if(v===58)v=e.input.charCodeAt(++e.position),T(v)||F(e,`a whitespace character is expected after the key-value separator within a block mapping`),g&&(G(e,u,d,f,m,null,i,a,o),f=m=h=null),_=!0,g=!1,r=!1,f=e.tag,m=e.result;else if(_)F(e,`can not read an implicit mapping pair; a colon is missed`);else return e.tag=s,e.anchor=l,!0}else if(_)F(e,`can not read a block mapping entry; a multiline key may not be an implicit key`);else return e.tag=s,e.anchor=l,!0}if((e.line===b||e.lineIndent>t)&&(g&&(i=e.line,a=e.lineStart,o=e.position),Q(e,t,p,!0,r)&&(g?m=e.result:h=e.result),g||(G(e,u,d,f,m,h,i,a,o),f=m=h=null),q(e,!0,-1),v=e.input.charCodeAt(e.position)),(e.line===b||e.lineIndent>t)&&v!==0)F(e,`bad indentation of a mapping entry`);else if(e.lineIndent<t)break}return g&&G(e,u,d,f,m,null,i,a,o),_&&(e.tag=s,e.anchor=l,e.kind=`mapping`,e.result=u),_}function le(e){let t=!1,n=!1,r,i,a=e.input.charCodeAt(e.position);if(a!==33)return!1;e.tag!==null&&F(e,`duplication of a tag property`),a=e.input.charCodeAt(++e.position),a===60?(t=!0,a=e.input.charCodeAt(++e.position)):a===33?(n=!0,r=`!!`,a=e.input.charCodeAt(++e.position)):r=`!`;let s=e.position;if(t){do a=e.input.charCodeAt(++e.position);while(a!==0&&a!==62);e.position<e.length?(i=e.input.slice(s,e.position),a=e.input.charCodeAt(++e.position)):F(e,`unexpected end of the stream within a verbatim tag`)}else{for(;a!==0&&!T(a);)a===33&&(n?F(e,`tag suffix cannot contain exclamation marks`):(r=e.input.slice(s-1,e.position+1),b.test(r)||F(e,`named tag handle cannot contain such characters`),n=!0,s=e.position+1)),a=e.input.charCodeAt(++e.position);i=e.input.slice(s,e.position),y.test(i)&&F(e,`tag suffix cannot contain flow indicator characters`)}i&&!x.test(i)&&F(e,`tag name cannot contain such characters: `+i);try{i=decodeURIComponent(i)}catch(t){F(e,`tag name is malformed: `+i)}return t?e.tag=i:o.call(e.tagMap,r)?e.tag=e.tagMap[r]+i:r===`!`?e.tag=`!`+i:r===`!!`?e.tag=`tag:yaml.org,2002:`+i:F(e,`undeclared tag handle "`+r+`"`),!0}function ue(e){let t=e.input.charCodeAt(e.position);if(t!==38)return!1;e.anchor!==null&&F(e,`duplication of an anchor property`),t=e.input.charCodeAt(++e.position);let n=e.position;for(;t!==0&&!T(t)&&!E(t);)t=e.input.charCodeAt(++e.position);return e.position===n&&F(e,`name of an anchor node must contain at least one character`),e.anchor=e.input.slice(n,e.position),!0}function de(e){let t=e.input.charCodeAt(e.position);if(t!==42)return!1;t=e.input.charCodeAt(++e.position);let n=e.position;for(;t!==0&&!T(t)&&!E(t);)t=e.input.charCodeAt(++e.position);e.position===n&&F(e,`name of an alias node must contain at least one character`);let r=e.input.slice(n,e.position);return o.call(e.anchorMap,r)||F(e,`unidentified alias "`+r+`"`),e.result=e.anchorMap[r],q(e,!0,-1),!0}function fe(e,t,n,r){let i=B(e);return R(e),V(e,t),e.tag=null,e.anchor=null,e.kind=null,e.result=null,ce(e,n,r)&&e.kind===`mapping`?(re(e),!0):(z(e),V(e,i),!1)}function Q(e,t,n,r,i){let a,u,d=1,f=!1,m=!1,h=null,g,_,v;e.depth>=e.maxDepth&&F(e,`nesting exceeded maxDepth (`+e.maxDepth+`)`),e.depth+=1,e.listener!==null&&e.listener(`open`,e),e.tag=null,e.anchor=null,e.kind=null,e.result=null;let y=a=u=p===n||l===n;if(r&&q(e,!0,-1)&&(f=!0,e.lineIndent>t?d=1:e.lineIndent===t?d=0:e.lineIndent<t&&(d=-1)),d===1)for(;;){let n=e.input.charCodeAt(e.position),r=B(e);if(f&&(n===33&&e.tag!==null||n===38&&e.anchor!==null)||!le(e)&&!ue(e))break;h===nul Line truncated
`),e.charCodeAt(0)===65279&&(e=e.slice(1)));let n=new ne(e,t),r=e.indexOf(`\0`);for(r!==-1&&(n.position=r,F(n,`null byte is not allowed in input`)),n.input+=`\0`;n.input.charCodeAt(n.position)===32;)n.lineIndent+=1,n.position+=1;for(;n.position<n.length-1;)pe(n);return n.documents}function he(e,t,n){typeof t==`object`&&t&&n===void 0&&(n=t,t=null);let r=me(e,n);if(typeof t!=`function`)return r;for(let e=0,n=r.length;e<n;e+=1)t(r[e])}function ge(e,t){let n=me(e,t);if(n.length!==0){if(n.length===1)return n[0];throw new r(`expected a single document in the stream, but found more`)}}t.exports.loadAll=he,t.exports.load=ge})),te=s(((e,t)=>{var n=u(),r=d(),i=ee(),a=Object.prototype.toString,o=Object.prototype.hasOwnProperty,s=65279,c=9,l=10,f=13,p=32,m=33,h=34,g=35,_=37,v=38,y=39,b=42,x=44,S=45,C=58,w=61,T=62,E=63,D=64,O=91,k=93,A=96,j=123,te=124,M=125,N={};N[0]=`\\0`,N[7]=`\\a`,N[8]=`\\b`,N[9]=`\\t`,N[10]=`\\n`,N[11]=`\\v`,N[12]=`\\f`,N[13]=`\\r`,N[27]=`\\e`,N[34]=`\\"`,N[92]=`\\\\`,N[133]=`\\N`,N[160]=`\\_`,N[8232]=`\\L`,N[8233]=`\\P`;var ne=[`y`,`Y`,`yes`,`Yes`,`YES`,`on`,`On`,`ON`,`n`,`N`,`no`,`No`,`NO`,`off`,`Off`,`OFF`],P=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/;function F(e,t){if(t===null)return{};let n={},r=Object.keys(t);for(let i=0,a=r.length;i<a;i+=1){let a=r[i],s=String(t[a]);a.slice(0,2)===`!!`&&(a=`tag:yaml.org,2002:`+a.slice(2));let c=e.compiledTypeMap.fallback[a];c&&o.call(c.styleAliases,s)&&(s=c.styleAliases[s]),n[a]=s}return n}function I(e){let t,i,a=e.toString(16).toUpperCase();if(e<=255)t=`x`,i=2;else if(e<=65535)t=`u`,i=4;else if(e<=4294967295)t=`U`,i=8;else throw new r(`code point within a string may not be greater than 0xFFFFFFFF`);return`\\`+t+n.repeat(`0`,i-a.length)+a}var L=1,R=2;function re(e){this.schema=e.schema||i,this.indent=Math.max(1,e.indent||2),this.noArrayIndent=e.noArrayIndent||!1,this.skipInvalid=e.skipInvalid||!1,this.flowLevel=n.isNothing(e.flowLevel)?-1:e.flowLevel,this.styleMap=F(this.schema,e.styles||null),this.sortKeys=e.sortKeys||!1,this.lineWidth=e.lineWidth||80,this.noRefs=e.noRefs||!1,this.noCompatMode=e.noCompatMode||!1,this.condenseFlow=e.condenseFlow||!1,this.quotingType=e.quotingType===`"`?R:L,this.forceQuotes=e.forceQuotes||!1,this.replacer=typeof e.replacer==`function`?e.replacer:null,this.implicitTypes=this.schema.compiledImplicit,this.explicitTypes=this.schema.compiledExplicit,this.tag=null,this.result=``,this.duplicates=[],this.usedDuplicates=null}function z(e,t){let r=n.repeat(` `,t),i=0,a=``,o=e.length;for(;i<o;){let t,n=e.indexOf(`
`,i);n===-1?(t=e.slice(i),i=o):(t=e.slice(i,n+1),i=n+1),t.length&&t!==`
`&&(a+=r),a+=t}return a}function B(e,t){return`
`+n.repeat(` `,e.indent*t)}function V(e,t){for(let n=0,r=e.implicitTypes.length;n<r;n+=1)if(e.implicitTypes[n].resolve(t))return!0;return!1}function H(e){return e===p||e===c}function U(e){return e>=32&&e<=126||e>=161&&e<=55295&&e!==8232&&e!==8233||e>=57344&&e<=65533&&e!==s||e>=65536&&e<=1114111}function W(e){return U(e)&&e!==s&&e!==f&&e!==l}function G(e,t,n){let r=W(e),i=r&&!H(e);return(n?r:r&&e!==x&&e!==O&&e!==k&&e!==j&&e!==M)&&e!==g&&!(t===C&&!i)||W(t)&&!H(t)&&e===g||t===C&&i}function K(e){return U(e)&&e!==s&&!H(e)&&e!==S&&e!==E&&e!==C&&e!==x&&e!==O&&e!==k&&e!==j&&e!==M&&e!==g&&e!==v&&e!==b&&e!==m&&e!==te&&e!==w&&e!==T&&e!==y&&e!==h&&e!==_&&e!==D&&e!==A}function q(e){return!H(e)&&e!==C}function J(e,t){let n=e.charCodeAt(t),r;return n>=55296&&n<=56319&&t+1<e.length&&(r=e.charCodeAt(t+1),r>=56320&&r<=57343)?(n-55296)*1024+r-56320+65536:n}function Y(e){return/^\n* /.test(e)}var ie=1,X=2,ae=3,oe=4,Z=5;function se(e,t,n,r,i,a,o,s){let c,u=0,d=null,f=!1,p=!1,m=r!==-1,h=-1,g=K(J(e,0))&&q(J(e,e.length-1));if(t||o)for(c=0;c<e.length;u>=65536?c+=2:c++){if(u=J(e,c),!U(u))return Z;g=g&&G(u,d,s),d=u}else{for(c=0;c<e.length;u>=65536?c+=2:c++){if(u=J(e,c),u===l)f=!0,m&&(p=p||c-h-1>r&&e[h+1]!==` `,h=c);else if(!U(u))return Z;g=g&&G(u,d,s),d=u}p=p||m&&c-h-1>r&&e[h+1]!==` `}return!f&&!p?g&&!o&&!i(e)?ie:a===R?Z:X:n>9&&Y(e)?Z:o?a===R?Z:X:p?oe:ae}function ce(e,t,n,i,a){e.dump=function(){if(t.length===0)return e.quotingType===R?`""`:`''`;if(!e.noCompatMode&&(ne.indexOf(t)!==-1||P.test(t)))return e.quotingType===R?`"`+t+`"`:`'`+t+`'`;let o=e.indent*Math.max(1,n),s=e.lineWidth===-1?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-o),c=i||e.flowLevel>-1&&n>=e.flowLevel;function l(t){return V(e,t)}switch(se(t,c,e.indent,s,l,e.quotingType,e.forceQuotes&&!i,a)){case ie:return t;case X:return`'`+t.replace(/'/g,`''`)+`'`;case ae:return`|`+le(t,e.indent)+ue(z(t,o));case oe:return`>`+le(t,e.indent)+ue(z(de(t,s),o));case Z:return`"`+Q(t,s)+`"`;default:throw new r(`impossible error: invalid scalar style`)}}()}function le(e,t){let n=Y(e)?String(t):``,r=e[e.length-1]===`
`;return n+(r&&(e[e.length-2]===`
`||e===`
`)?`+`:r?``:`-`)+`
`}function ue(e){return e[e.length-1]===`
`?e.slice(0,-1):e}function de(e,t){let n=/(\n+)([^\n]*)/g,r=function(){let r=e.indexOf(`
`);return r=r===-1?e.length:r,n.lastIndex=r,fe(e.slice(0,r),t)}(),i=e[0]===`
`||e[0]===` `,a,o;for(;o=n.exec(e);){let e=o[1],n=o[2];a=n[0]===` `,r+=e+(!i&&!a&&n!==``?`
`:``)+fe(n,t),i=a}return r}function fe(e,t){if(e===``||e[0]===` `)return e;let n=/ [^ ]/g,r,i=0,a,o=0,s=0,c=``;for(;r=n.exec(e);)s=r.index,s-i>t&&(a=o>i?o:s,c+=`
`+e.slice(i,a),i=a+1),o=s;return c+=`
`,e.length-i>t&&o>i?c+=e.slice(i,o)+`
`+e.slice(o+1):c+=e.slice(i),c.slice(1)}function Q(e){let t=``,n=0;for(let r=0;r<e.length;n>=65536?r+=2:r++){n=J(e,r);let i=N[n];!i&&U(n)?(t+=e[r],n>=65536&&(t+=e[r+1])):t+=i||I(n)}return t}function pe(e,t,n){let r=``,i=e.tag;for(let i=0,a=n.length;i<a;i+=1){let a=n[i];e.replacer&&(a=e.replacer.call(n,String(i),a)),($(e,t,a,!1,!1)||a===void 0&&$(e,t,null,!1,!1))&&(r!==``&&(r+=`,`+(e.condenseFlow?``:` `)),r+=e.dump)}e.tag=i,e.dump=`[`+r+`]`}function me(e,t,n,r){let i=``,a=e.tag;for(let a=0,o=n.length;a<o;a+=1){let o=n[a];e.replacer&&(o=e.replacer.call(n,String(a),o)),($(e,t+1,o,!0,!0,!1,!0)||o===void 0&&$(e,t+1,null,!0,!0,!1,!0))&&((!r||i!==``)&&(i+=B(e,t)),e.dump&&l===e.dump.charCodeAt(0)?i+=`-`:i+=`- `,i+=e.dump)}e.tag=a,e.dump=i||`[]`}function he(e,t,n){let r=``,i=e.tag,a=Object.keys(n);for(let i=0,o=a.length;i<o;i+=1){let o=``;r!==``&&(o+=`, `),e.condenseFlow&&(o+=`"`);let s=a[i],c=n[s];e.replacer&&(c=e.replacer.call(n,s,c)),$(e,t,s,!1,!1)&&(e.dump.length>1024&&(o+=`? `),o+=e.dump+(e.condenseFlow?`"`:``)+`:`+(e.condenseFlow?``:` `),$(e,t,c,!1,!1)&&(o+=e.dump,r+=o))}e.tag=i,e.dump=`{`+r+`}`}function ge(e,t,n,i){let a=``,o=e.tag,s=Object.keys(n);if(e.sortKeys===!0)s.sort();else if(typeof e.sortKeys==`function`)s.sort(e.sortKeys);else if(e.sortKeys)throw new r(`sortKeys must be a boolean or a function`);for(let r=0,o=s.length;r<o;r+=1){let o=``;(!i||a!==``)&&(o+=B(e,t));let c=s[r],u=n[c];if(e.replacer&&(u=e.replacer.call(n,c,u)),!$(e,t+1,c,!0,!0,!0))continue;let d=e.tag!==null&&e.tag!==`?`||e.dump&&e.dump.length>1024;d&&(e.dump&&l===e.dump.charCodeAt(0)?o+=`?`:o+=`? `),o+=e.dump,d&&(o+=B(e,t)),$(e,t+1,u,!0,d)&&(e.dump&&l===e.dump.charCodeAt(0)?o+=`:`:o+=`: `,o+=e.dump,a+=o)}e.tag=o,e.dump=a||`{}`}function _e(e,t,n){let i=n?e.explicitTypes:e.implicitTypes;for(let s=0,c=i.length;s<c;s+=1){let c=i[s];if((c.instanceOf||c.predicate)&&(!c.instanceOf||typeof t==`object`&&t instanceof c.instanceOf)&&(!c.predicate||c.predicate(t))){if(n?c.multi&&c.representName?e.tag=c.representName(t):e.tag=c.tag:e.tag=`?`,c.represent){let n=e.styleMap[c.tag]||c.defaultStyle,i;if(a.call(c.represent)===`[object Function]`)i=c.represent(t,n);else if(o.call(c.represent,n))i=c.represent[n](t,n);else throw new r(`!<`+c.tag+`> tag resolver accepts not "`+n+`" style`);e.dump=i}return!0}}return!1}function $(e,t,n,i,o,s,c){e.tag=null,e.dump=n,_e(e,n,!1)||_e(e,n,!0);let l=a.call(e.dump),u=i;i&&(i=e.flowLevel<0||e.flowLevel>t);let d=l===`[object Object]`||l===`[object Array]`,f,p;if(d&&(f=e.duplicates.indexOf(n),p=f!==-1),(e.tag!==null&&e.tag!==`?`||p||e.indent!==2&&t>0)&&(o=!1),p&&e.usedDuplicates[f])e.dump=`*ref_`+f;else{if(d&&p&&!e.usedDuplicates[f]&&(e.usedDuplicates[f]=!0),l===`[object Object]`)i&&Object.keys(e.dump).length!==0?(ge(e,t,e.dump,o),p&&(e.dump=`&ref_`+f+e.dump)):(he(e,t,e.dump),p&&(e.dump=`&ref_`+f+` `+e.dump));else if(l===`[object Array]`)i&&e.dump.length!==0?(e.noArrayIndent&&!c&&t>0?me(e,t-1,e.dump,o):me(e,t,e.dump,o),p&&(e.dump=`&ref_`+f+e.dump)):(pe(e,t,e.dump),p&&(e.dump=`&ref_`+f+` `+e.dump));else if(l===`[object String]`)e.tag!==`?`&&ce(e,e.dump,t,s,u);else if(l===`[object Undefined]`)return!1;else{if(e.skipInvalid)return!1;throw new r(`unacceptable kind of an object to dump `+l)}if(e.tag!==null&&e.tag!==`?`){let t=encodeURI(e.tag[0]===`!`?e.tag.slice(1):e.tag).replace(/!/g,`%21`);t=e.tag[0]===`!`?`!`+t:t.slice(0,18)===`tag:yaml.org,2002:`?`!!`+t.slice(18):`!<`+t+`>`,e.dump=t+` `+e.dump}}return!0}function ve(e,t){let n=[],r=[];ye(e,n,r);let i=r.length;for(let e=0;e<i;e+=1)t.duplicates.push(n[r[e]]);t.usedDuplicates=Array(i)}function ye(e,t,n){if(typeof e==`object`&&e){let r=t.indexOf(e);if(r!==-1)n.indexOf(r)===-1&&n.push(r);else if(t.push(e),Array.isArray(e))for(let r=0,i=e.length;r<i;r+=1)ye(e[r],t,n);else{let r=Object.keys(e);for(let i=0,a=r.length;i<a;i+=1)ye(e[r[i]],t,n)}}}function be(e,t){t=t||{};let n=new re(t);n.noRefs||ve(e,n);let r=e;return n.replacer&&(r=n.replacer.call({"":r},``,r)),$(n,0,r,!0,!0)?n.dump+`
`:``}t.exports.dump=be})),M=l(s(((e,t)=>{var n=j(),r=te();function i(e,t){return function(){throw Error(`Function yaml.`+e+` is removed in js-yaml 4. Use yaml.`+t+` instead, which is now safe by default.`)}}t.exports.Type=p(),t.exports.Schema=m(),t.exports.FAILSAFE_SCHEMA=v(),t.exports.JSON_SCHEMA=C(),t.exports.CORE_SCHEMA=w(),t.exports.DEFAULT_SCHEMA=ee(),t.exports.load=n.load,t.exports.loadAll=n.loadAll,t.exports.dump=r.dump,t.exports.YAMLException=d(),t.exports.types={binary:D(),float:S(),map:_(),null:y(),pairs:k(),set:A(),timestamp:T(),bool:b(),int:x(),merge:E(),omap:O(),seq:g(),str:h()},t.exports.safeLoad=i(`safeLoad`,`load`),t.exports.safeLoadAll=i(`safeLoadAll`,`loadAll`),t.exports.safeDump=i(`safeDump`,`dump`)}))(),1),{Type:N,Schema:ne,FAILSAFE_SCHEMA:P,JSON_SCHEMA:F,CORE_SCHEMA:I,DEFAULT_SCHEMA:L,load:R,loadAll:re,dump:z,YAMLException:B,types:V,safeLoad:H,safeLoadAll:U,safeDump:W}=M.default,G=M.default;e.CORE_SCHEMA=I,e.DEFAULT_SCHEMA=L,e.FAILSAFE_SCHEMA=P,e.JSON_SCHEMA=F,e.Schema=ne,e.Type=N,e.YAMLException=B,e.default=G,e.dump=z,e.load=R,e.loadAll=re,e.safeDump=W,e.safeLoad=H,e.safeLoadAll=U,e.types=V});
//# sourceMappingURL=js-yaml.min.js.map