update 2026-09-24 16:56:26

This commit is contained in:
action committed 2026-09-24 16:56:26 +08:00
1 parent b31a5c3878
commit e4cee331e9
6885 files changed
+3240523

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
# SPDX-License-Identifier: Apache-2.0
#
# Copyright (C) 2023 ImmortalWrt.org
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede
PKG_VERSION:=1.15
PKG_RELEASE:=3
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)/config
choice
prompt "Backend dependency"
default PACKAGE_$(PKG_NAME)_daed
help
Select which backend package luci-app-daede should pull in by default.
config PACKAGE_$(PKG_NAME)_daed
bool "daed backend"
config PACKAGE_$(PKG_NAME)_dae
bool "dae backend"
endchoice
endef
define Package/$(PKG_NAME)
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=LuCI app for daede dashboard
DEPENDS:=+luci-base +PACKAGE_$(PKG_NAME)_dae:dae +PACKAGE_$(PKG_NAME)_daed:daed
PKGARCH:=all
MAINTAINER:=kenzok8
endef
define Package/$(PKG_NAME)/description
LuCI web interface for managing dae or daed backends.
endef
define Build/Prepare
mkdir -p $(PKG_BUILD_DIR)
po2lmo ./po/zh-cn/daede.po $(PKG_BUILD_DIR)/daede.zh-cn.lmo
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/$(PKG_NAME)/conffiles
/etc/config/daede
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_DATA) ./root/etc/config/daede $(1)/etc/config/daede
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./root/etc/uci-defaults/90-luci-app-daede-init $(1)/etc/uci-defaults/90-luci-app-daede-init
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-daede.json $(1)/usr/share/luci/menu.d/luci-app-daede.json
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-daede.json $(1)/usr/share/rpcd/acl.d/luci-app-daede.json
$(INSTALL_DIR) $(1)/usr/share/luci-app-daede
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/pkg-info.sh $(1)/usr/share/luci-app-daede/pkg-info.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/update-geo.sh $(1)/usr/share/luci-app-daede/update-geo.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/update-pkg.sh $(1)/usr/share/luci-app-daede/update-pkg.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/gen-dae-config.sh $(1)/usr/share/luci-app-daede/gen-dae-config.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/proxy-check.sh $(1)/usr/share/luci-app-daede/proxy-check.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/fetch-clash-yaml.sh $(1)/usr/share/luci-app-daede/fetch-clash-yaml.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/config-backup.sh $(1)/usr/share/luci-app-daede/config-backup.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/snapshot-file.sh $(1)/usr/share/luci-app-daede/snapshot-file.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/config-defaults.sh $(1)/usr/share/luci-app-daede/config-defaults.sh
$(INSTALL_DIR) $(1)/usr/share/luci-app-daede/defaults
$(INSTALL_DATA) $(CURDIR)/../dae/files/dae.config $(1)/usr/share/luci-app-daede/defaults/dae
$(INSTALL_DATA) $(CURDIR)/../daed/files/daed.config $(1)/usr/share/luci-app-daede/defaults/daed
$(INSTALL_DATA) ./root/etc/config/daede $(1)/usr/share/luci-app-daede/defaults/daede
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/refresh-index.sh $(1)/usr/share/luci-app-daede/refresh-index.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/geo-cron.sh $(1)/usr/share/luci-app-daede/geo-cron.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/daed-sub-update.sh $(1)/usr/share/luci-app-daede/daed-sub-update.sh
$(INSTALL_BIN) ./root/usr/share/luci-app-daede/daed-sub-cron.sh $(1)/usr/share/luci-app-daede/daed-sub-cron.sh
$(INSTALL_DIR) $(1)/www/cgi-bin
$(INSTALL_BIN) ./root/www/cgi-bin/daede-sub $(1)/www/cgi-bin/daede-sub
$(INSTALL_BIN) ./root/www/cgi-bin/daede-graphql $(1)/www/cgi-bin/daede-graphql
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/daede
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/daede/*.js $(1)/www/luci-static/resources/view/daede/
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/daede/vendor
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/daede/vendor/* $(1)/www/luci-static/resources/view/daede/vendor/
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n
$(INSTALL_DATA) $(PKG_BUILD_DIR)/daede.zh-cn.lmo $(1)/usr/lib/lua/luci/i18n/daede.zh-cn.lmo
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
if [ -x /etc/uci-defaults/90-luci-app-daede-init ]; then
( . /etc/uci-defaults/90-luci-app-daede-init )
rm -f /etc/uci-defaults/90-luci-app-daede-init
fi
rm -f /usr/lib/lua/luci/i18n/daed.zh-cn.lmo
rm -rf /tmp/luci-indexcache /tmp/luci-indexcache.* /tmp/luci-modulecache
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
/etc/init.d/uhttpd restart >/dev/null 2>&1 || true
fi
exit 0
endef
define Package/$(PKG_NAME)/postrm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -rf /tmp/luci-indexcache /tmp/luci-indexcache.* /tmp/luci-modulecache
fi
exit 0
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
function deriveAirportName(value) {
try {
const url = new URL(String(value || ''));
const filename = url.searchParams.get('filename');
if (filename) {
const name = filename.replace(/\.(?:ya?ml)$/i, '').trim();
if (name)
return name;
}
// Use the registrable label, not the whole hostname: sub.ssr.sh -> ssr.
// Dropping the TLD (and any sub.* prefix) keeps the default group name
// clean and avoids leaking the provider's full domain.
const labels = (url.hostname || '').split('.').filter(Boolean);
if (labels.length >= 2)
return labels[labels.length - 2];
return labels[0] || '机场_1';
} catch (e) {
return '机场_1';
}
}
function nextPastedName(names) {
const used = {};
(names || []).forEach(function(name) { used[String(name)] = true; });
let index = 1;
while (used['机场_' + index])
index++;
return '机场_' + index;
}
function makeAirportId() {
return 'airport_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
}
function backendId(id) {
return String(id || '').replace(/[^A-Za-z0-9]/g, '') || 'airport';
}
function backendGroupName(name, backend, id) {
const value = String(name || '').trim();
if (isGroupNameValid(value, backend))
return value || backendId(id);
return backendId(id);
}
function isGroupNameValid(name, backend) {
const value = String(name || '').trim();
return !!value && (backend !== 'dae' || /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value));
}
// Coerce an auto-derived name (URL hostname/filename) into a name the third
// step accepts, so the group name is pre-filled valid and needs no manual fix.
// daed allows any non-empty name (incl. CJK); dae requires
// /^[A-Za-z_][A-Za-z0-9_-]*$/, so collapse illegal chars to '_' and drop a
// leading non-letter/underscore run.
function sanitizeGroupName(name, backend) {
const value = String(name || '').trim();
if (!value || backend !== 'dae')
return value;
const cleaned = value
.replace(/[^A-Za-z0-9_-]+/g, '_')
.replace(/_{2,}/g, '_')
.replace(/^[^A-Za-z_]+/, '')
.replace(/[-_]+$/, '');
return cleaned || 'airport';
}
function hashSource(value) {
// LuCI is commonly served over plain HTTP, where Web Crypto is unavailable.
// This stable opaque key is only used to match a previously managed source.
let h1 = 0x811c9dc5, h2 = 0x9e3779b9, h3 = 0x85ebca6b, h4 = 0xc2b2ae35;
const text = unescape(encodeURIComponent(String(value || '')));
for (let index = 0; index < text.length; index++) {
const code = text.charCodeAt(index);
h1 = Math.imul(h1 ^ code, 0x01000193);
h2 = Math.imul(h2 ^ code, 0x5bd1e995);
h3 = Math.imul(h3 ^ code, 0x27d4eb2d);
h4 = Math.imul(h4 ^ code, 0x165667b1);
}
const words = [ h1, h2, h3, h4, h1 ^ h3, h2 ^ h4, h1 ^ h2 ^ h4, h1 ^ h2 ^ h3 ^ h4 ];
return Promise.resolve(words.map(function(word) {
return (word >>> 0).toString(16).padStart(8, '0');
}).join(''));
}
function list(value) {
if (Array.isArray(value))
return value.filter(Boolean).map(String);
return value ? [ String(value) ] : [];
}
function parseAirportSection(section) {
if (!section)
return null;
return {
sid: section['.name'],
id: String(section.id || ''),
backend: String(section.backend || ''),
name: String(section.name || ''),
source_hash: String(section.source_hash || ''),
group_id: String(section.group_id || ''),
subscription_id: String(section.subscription_id || ''),
node_ids: list(section.node_id)
};
}
function airportSectionValues(record) {
return {
id: String(record.id || ''),
backend: String(record.backend || ''),
name: String(record.name || ''),
source_hash: String(record.sourceHash || record.source_hash || ''),
group_id: String(record.groupId || record.group_id || ''),
subscription_id: String(record.subscriptionId || record.subscription_id || ''),
node_id: list(record.nodeIds || record.node_ids)
};
}
function matchAirport(records, candidate) {
const sameBackend = (records || []).filter(function(record) {
return record.backend === candidate.backend;
});
if (candidate.selectedId) {
const selected = sameBackend.find(function(record) { return record.id === candidate.selectedId; });
if (selected)
return selected;
}
if (candidate.sourceHash) {
const bySource = sameBackend.find(function(record) { return record.source_hash === candidate.sourceHash; });
if (bySource)
return bySource;
}
return sameBackend.find(function(record) { return record.name === candidate.name; }) || null;
}
function safeOldNodeIds(oldIds, newIds, otherAirportNodeIds, otherGroupNodeIds) {
const keep = {};
list(newIds).forEach(function(id) { keep[id] = true; });
(otherAirportNodeIds || []).forEach(function(ids) { list(ids).forEach(function(id) { keep[id] = true; }); });
(otherGroupNodeIds || []).forEach(function(ids) { list(ids).forEach(function(id) { keep[id] = true; }); });
return list(oldIds).filter(function(id) { return !keep[id]; });
}
function findManagedGroup(groups, record) {
if (!record || !record.group_id)
return null;
return (groups || []).find(function(group) {
return group.id === record.group_id && group.name === record.name;
}) || null;
}
function findGroupByName(groups, name) {
return (groups || []).find(function(group) { return group.name === name; }) || null;
}
const api = {
deriveAirportName: deriveAirportName,
nextPastedName: nextPastedName,
makeAirportId: makeAirportId,
backendId: backendId,
backendGroupName: backendGroupName,
isGroupNameValid: isGroupNameValid,
sanitizeGroupName: sanitizeGroupName,
hashSource: hashSource,
parseAirportSection: parseAirportSection,
airportSectionValues: airportSectionValues,
matchAirport: matchAirport,
findManagedGroup: findManagedGroup,
findGroupByName: findGroupByName,
safeOldNodeIds: safeOldNodeIds
};
if (typeof module !== 'undefined' && module.exports)
module.exports = api;
if (typeof baseclass !== 'undefined')
return baseclass.extend(api);
return api;
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
'require fs';
'require rpc';
'require uci';
const callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: ['name'],
expect: { '': {} }
});
const BACKENDS = {
daed: {
name: 'daed',
label: 'daed',
uci: 'daed',
initd: '/etc/init.d/daed',
log: '/var/log/daed/daed.log',
pkg: 'daed',
hasWebUI: true,
useNetns: true,
defaultListen: '0.0.0.0:2023'
},
dae: {
name: 'dae',
label: 'dae',
uci: 'dae',
initd: '/etc/init.d/dae',
log: '/var/log/dae/dae.log',
pkg: 'dae',
config: '/etc/dae/config.dae',
hasWebUI: false,
useNetns: false
}
};
function execOk(cmd, args) {
return L.resolveDefault(fs.exec(cmd, args || []), { code: 1 }).then(function(res) {
return res && res.code === 0;
});
}
function exists(path) {
return L.resolveDefault(fs.stat(path), null).then(function(st) {
return !!st;
});
}
function serviceStatus(name) {
return L.resolveDefault(callServiceList(name), {}).then(function(svc) {
let pid = 0, running = false;
try {
const inst = svc[name].instances[name];
running = !!inst.running;
pid = inst.pid || 0;
} catch (e) {}
return { running: running, pid: pid };
});
}
function detectInstalledBackends() {
return Promise.all([
exists(BACKENDS.dae.initd),
exists(BACKENDS.daed.initd)
]).then(function(r) {
return { dae: r[0], daed: r[1] };
});
}
function detectRunning() {
/* pidof matches the program name exactly and, unlike busybox `pgrep -x`
(which compares the full cmdline `/usr/bin/dae run …` and never hits),
reliably tells dae from daed without substring false positives. */
return Promise.all([
execOk('/bin/pidof', ['dae']),
execOk('/bin/pidof', ['daed'])
]).then(function(r) {
return { dae: r[0], daed: r[1] };
});
}
function loadActiveBackend() {
return L.resolveDefault(uci.load('daede'), null).then(function() {
const active = uci.get('daede', 'config', 'active_backend');
return BACKENDS[active] ? active : '';
});
}
function setActiveBackend(name) {
if (!BACKENDS[name])
return Promise.reject(new Error('invalid backend: ' + name));
return exists('/etc/config/daede').then(function(ok) {
if (!ok)
return fs.write('/etc/config/daede', '');
}).then(function() { return fs.exec('/sbin/uci', ['set', 'daede.config=daede']); })
.then(function() { return fs.exec('/sbin/uci', ['set', 'daede.config.active_backend=' + name]); })
.then(function() { return fs.exec('/sbin/uci', ['commit', 'daede']); })
.then(function() { uci.set('daede', 'config', 'active_backend', name); });
}
function detectBackend() {
return Promise.all([
detectInstalledBackends(),
detectRunning(),
loadActiveBackend()
]).then(function(r) {
const installed = r[0];
const running = r[1];
const preferred = r[2];
let name = preferred;
if (!name || !installed[name])
name = installed.daed ? 'daed' : (installed.dae ? 'dae' : 'daed');
return {
name: name,
backend: BACKENDS[name],
installed: installed,
running: running,
preferred: preferred,
bothInstalled: !!(installed.dae && installed.daed)
};
});
}
return baseclass.extend({
BACKENDS: BACKENDS,
detectInstalledBackends: detectInstalledBackends,
detectRunning: detectRunning,
detectBackend: detectBackend,
setActiveBackend: setActiveBackend,
serviceStatus: serviceStatus
});
@@ -0,0 +1,295 @@
// SPDX-License-Identifier: Apache-2.0
'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.replace(/-/g, '+').replace(/_/g, '/'));
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 makeResult(node, link) {
return {
ok: true,
name: String(node.name || 'Node'),
type: String(node.type || '').toLowerCase(),
link: link,
error: ''
};
}
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 makeResult(node, converters[type](node));
} 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) : [];
}
function normalizeLink(link) {
const value = String(link || '').trim();
if (value.indexOf('vmess://') === 0) {
try {
const payload = JSON.parse(utf8FromBase64(value.slice(8)));
delete payload.ps;
return 'vmess://' + utf8Base64(JSON.stringify(payload));
} catch (e) {}
}
return value.replace(/#.*$/, '');
}
const api = {
convertProxy: convertProxy,
convertProxies: convertProxies,
isMetadataProxy: isMetadataProxy,
normalizeLink: normalizeLink
};
if (typeof module !== 'undefined' && module.exports)
module.exports = api;
if (typeof baseclass !== 'undefined')
return baseclass.extend(api);
return api;
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require uci';
'require network';
'require view';
'require view.daede.backend as backend';
'require view.daede.styles as styles';
'require view.daede.widgets as widgets';
'require view.daede.dae as daeView';
'require view.daede.daed as daedView';
return view.extend({
_loadContext: function() {
return backend.detectBackend().then(function(ctx) {
return uci.load(ctx.backend.uci).catch(function() {}).then(function() {
/* enumerate L3 devices so the dae LAN/WAN fields offer a dropdown
(still free-text, so undetectable WANs can be typed) */
return network.getDevices().catch(function() { return []; }).then(function(devs) {
ctx.netDevs = (devs || []).map(function(d) { return d.getName(); })
.filter(function(n) { return n && n !== 'dae0'; }).sort();
return ctx;
});
});
});
},
load: function() {
return this._loadContext();
},
render: function(ctx) {
const self = this;
const themeHref = Array.prototype.map.call(document.styleSheets, function(sheet) { return sheet.href || ''; }).join(' ');
document.documentElement.setAttribute('data-daede-theme', /\/argon\//.test(themeHref) ? 'argon' : 'bootstrap');
/* themes signal dark differently — BootstrapDark sets data-darkmode,
Argon just loads a dark stylesheet with no flag. Read the first opaque
background up the tree; a transparent body (CSS not applied yet) must
NOT count as black, else argon light is misdetected as dark. */
try {
const probe = [document.body, document.documentElement];
for (let i = 0; i < probe.length; i++) {
const m = getComputedStyle(probe[i]).backgroundColor.match(/[\d.]+/g);
if (!m) continue;
const a = m.length >= 4 ? parseFloat(m[3]) : 1;
if (a < 0.1) continue; // transparent — keep looking
if (0.299 * m[0] + 0.587 * m[1] + 0.114 * m[2] < 128)
document.documentElement.setAttribute('data-darkmode', 'true');
break; // first opaque background decides
}
} catch (e) {}
const redrawBackend = function(name, message) {
self._backendHint = message;
return self._loadContext()
.then(function(nextCtx) { return self.render(nextCtx); })
.then(function(nextRoot) {
const current = document.querySelector('.dd-config-page');
if (!current) return;
Array.prototype.forEach.call(current.querySelectorAll('.dd-status-card'), function(card) {
if (card._ddCleanup) card._ddCleanup();
});
current.replaceWith(nextRoot);
if (self._backendHintTimer) clearTimeout(self._backendHintTimer);
self._backendHintTimer = setTimeout(function() {
self._backendHint = '';
const hint = document.querySelector('.dd-config-page .dd-backend-help');
if (hint) hint.textContent = '';
}, 3500);
});
};
const listenAddr = uci.get('daed', 'config', 'listen_addr') || backend.BACKENDS.daed.defaultListen;
const children = [
E('style', {}, styles.CSS),
widgets.renderStatusCard(ctx, listenAddr),
widgets.renderBackendSwitcher(ctx, redrawBackend, self._backendHint)
].filter(function(node) { return !!node; });
if (!ctx.installed[ctx.name]) {
children.push(E('div', { 'class': 'dd-card dd-warning' }, _('Selected backend is not installed. Install dae or daed from the package feed first.')));
} else if (ctx.name === 'dae') {
children.push(daeView.renderDaeImportBanner());
children.push(daeView.renderDaeForms(ctx));
children.push(daeView.renderDaeEditor());
} else {
children.push(daedView.renderDaedSettings());
}
return Promise.all(children.map(function(child) {
return child && child.then ? child : Promise.resolve(child);
})).then(function(nodes) {
return E('div', { 'class': 'dd-wrap dd-config-page' }, nodes.filter(function(n) { return !!n; }));
});
},
/* this view drives its own save buttons (dae form / daed settings / manual
editor) — suppress LuCI's global Save/Apply/Reset footer so there is one
unambiguous primary action per backend */
handleSave: null,
handleSaveApply: null,
handleReset: null
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,792 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
'require form';
'require fs';
'require uci';
'require ui';
'require view.daede.backend as backend';
/* dae 示例里的占位订阅特征 —— example.com、relative/path/to 或中文占位文本 */
const RE_PLACEHOLDER_URL = /(['"])(?:(?:https?|https-file|file):\/\/[^'"]*?(?:example\.com|relative\/path\/to)[^'"]*|你的订阅链接或者机场链接)\1/;
/* Minimal ready-to-run default: gen-dae-config.sh defaults + a placeholder subscription to swap. */
const DEFAULT_TEMPLATE =
'# luci-app-daede 默认配置:把 subscription 里的占位链接换成你的机场订阅,保存即可运行。\n' +
'global {\n' +
' # 端口与网卡:lan_interface 填内网桥(通常 br-lan),wan_interface 保持 auto 即可。\n' +
' tproxy_port: 12345\n' +
' tproxy_port_protect: true\n' +
' so_mark_from_dae: 0\n' +
' log_level: info\n' +
' lan_interface: br-lan\n' +
' wan_interface: auto\n' +
' auto_config_kernel_parameter: true\n' +
' dial_mode: domain\n' +
" tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111'\n" +
" udp_check_dns: 'dns.google:53,8.8.8.8,2001:4860:4860::8888'\n" +
' check_interval: 30s\n' +
' check_tolerance: 50ms\n' +
'}\n' +
'\n' +
'subscription {\n' +
' # ⚠ 把下面这行的 URL 换成你机场的订阅链接,然后保存。\n' +
" my_airport: 'https://example.com/sub'\n" +
'}\n' +
'\n' +
'dns {\n' +
' # 国内域名走 cndns 解析,其余走 fallbackdns,避免 DNS 污染。\n' +
' ipversion_prefer: 4\n' +
' response_ttl: 0\n' +
' upstream {\n' +
" cndns: 'udp://dns.alidns.com:53'\n" +
" fallbackdns: 'tcp+udp://dns.google:53'\n" +
' }\n' +
' routing {\n' +
' request {\n' +
' qname(geosite:cn) -> cndns\n' +
' fallback: fallbackdns\n' +
' }\n' +
' }\n' +
'}\n' +
'\n' +
'group {\n' +
' # 节点分组:filter 决定用订阅里的哪些节点,policy 决定怎么选(min_moving_avg = 自动挑最快)。\n' +
' proxy {\n' +
' filter: subtag(my_airport)\n' +
' policy: min_moving_avg\n' +
' }\n' +
'}\n' +
'\n' +
'routing {\n' +
' # 分流规则:从上往下匹配,命中即停,最后由 fallback 兜底。\n' +
' # 想让某个网站直连,在 fallback 之前加一行,例如:domain(example.com) -> direct\n' +
' pname(NetworkManager) -> direct\n' +
' dip(224.0.0.0/3) -> direct\n' +
' dip(255.255.255.255/32) -> direct\n' +
" dip('ff00::/8') -> direct\n" +
' dip(geoip:private) -> direct\n' +
' l4proto(udp) && dport(123) -> direct\n' +
' domain(connectivitycheck.gstatic.com) -> direct\n' +
' domain(msftconnecttest.com) -> direct\n' +
' dip(geoip:cn) -> direct\n' +
' domain(geosite:cn) -> direct\n' +
' fallback: proxy\n' +
'}\n';
function detectPlaceholders(text) {
if (!text) return [];
const lines = text.split('\n');
const out = [];
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
const stripped = ln.replace(/^\s*#.*$/, '');
if (!stripped) continue;
const m = stripped.match(RE_PLACEHOLDER_URL);
if (m) out.push({ line: i + 1, url: m[2], raw: ln.trim() });
}
return out;
}
const GEN = '/usr/share/luci-app-daede/gen-dae-config.sh';
/* Table cell renderer: truncate long links so rows stay compact; full value
stays visible in the edit dialog and on hover. */
function ellipsisCell(section_id) {
const v = this.cfgvalue(section_id) || '';
/* middle-ellipsis keeps the host head + tail visible (the meaningful parts
of a sub link) while staying short enough for one line on a phone */
const short = v.length > 30 ? v.slice(0, 18) + '…' + v.slice(-9) : v;
return E('span', { 'title': v, 'style': 'font-family:ui-monospace,Menlo,monospace;font-size:12px' }, short);
}
/* Tag is auto-generated and rarely relevant to beginners — render it muted. */
function mutedCell(section_id) {
const v = this.cfgvalue(section_id) || '';
return E('span', { 'class': 'dd-meta', 'style': 'opacity:.55;font-size:11.5px' }, v);
}
/* Fill blank tags of a section type with unique <prefix>_<n> values so dae's
config generator always has a stable identifier. Runs after m.save() wrote
the form values into the uci session, before uci.save(). */
function autofillTags(stype, prefix) {
const secs = uci.sections('dae', stype) || [];
const used = {};
secs.forEach(function(s) { if (s.tag) used[s.tag] = 1; });
let n = 1;
secs.forEach(function(s) {
if (s.tag) return;
while (used[prefix + '_' + n]) n++;
const t = prefix + '_' + n;
uci.set('dae', s['.name'], 'tag', t);
used[t] = 1; n++;
});
}
/* Wrap every form section in a collapsible accordion. openTitles lists the
section titles that should start expanded; the rest start collapsed. */
function accordionizeSections(mapNode, openTitles) {
const secs = mapNode.querySelectorAll('.cbi-section');
for (let i = 0; i < secs.length; i++) {
const sec = secs[i];
const h = sec.querySelector('h3');
const title = h ? h.textContent.trim() : _('Section');
const open = (openTitles || []).indexOf(title) >= 0;
const body = E('div', { 'class': 'dd-adv-body' });
while (sec.firstChild) body.appendChild(sec.firstChild);
/* drop the moved <h3> so it doesn't duplicate the accordion bar title */
const dupTitle = body.querySelector('h3');
if (dupTitle) dupTitle.parentNode.removeChild(dupTitle);
const wrap = E('div', { 'class': 'dd-adv' + (open ? '' : ' dd-closed') }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, title),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
body
]);
wrap.firstChild.addEventListener('click', function() { wrap.classList.toggle('dd-closed'); });
sec.appendChild(wrap);
}
}
function organizeDaeSections(mapNode, openTitles) {
accordionizeSections(mapNode, openTitles);
const advTitles = [ _('Groups'), _('Routing'), _('DNS'), _('Logging') ];
const advWraps = [];
mapNode.querySelectorAll('.dd-adv').forEach(function(w) {
const t = w.querySelector('.dd-adv-bar span');
if (t && advTitles.indexOf(t.textContent.trim()) >= 0) advWraps.push(w);
});
if (!advWraps.length) return;
const outerOpen = (openTitles || []).indexOf(_('Advanced settings')) >= 0;
const outerBody = E('div', { 'class': 'dd-adv-body' });
const outer = E('div', { 'class': 'dd-adv' + (outerOpen ? '' : ' dd-closed') }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, _('Advanced settings')),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
outerBody
]);
outer.firstChild.addEventListener('click', function() { outer.classList.toggle('dd-closed'); });
advWraps[0].parentNode.insertBefore(outer, advWraps[0]);
advWraps.forEach(function(w) { outerBody.appendChild(w); });
}
function captureAccordionState(mapNode) {
if (!mapNode) return [];
return Array.prototype.map.call(
mapNode.querySelectorAll('.dd-adv:not(.dd-closed) > .dd-adv-bar span:first-child'),
function(node) { return node.textContent.trim(); }
);
}
function restoreAccordionState(mapNode, openTitles) {
if (!mapNode || mapNode.querySelector('.dd-adv')) return;
organizeDaeSections(mapNode, openTitles || []);
}
/* Friendly form UI for the dae backend. Form is the source of truth: on save we
commit the `dae` UCI package, then gen-dae-config.sh renders config.dae,
validates it and hot-reloads. */
function renderDaeForms(ctx) {
let m, s, o;
m = new form.Map('dae', null, null);
/* Subscriptions */
s = m.section(form.GridSection, 'subscription', _('Subscriptions'), null);
s.addremove = true;
s.anonymous = true;
s.sortable = false;
o = s.option(form.Value, 'tag', _('Tag'));
o.placeholder = _('auto-generated');
o.textvalue = mutedCell;
o = s.option(form.Value, 'url', _('Subscription URL'));
o.rmempty = false;
o.placeholder = 'https://example.com/sub';
o.textvalue = ellipsisCell; /* keep long links from blowing up the row */
o.validate = function(sid, v) {
if (!v) return _('URL is required');
if (!/^(https?|https-file|file):\/\//.test(v)) return _('Must start with http(s):// or file://');
return true;
};
o = s.option(form.Flag, 'enabled', _('Enabled'));
o.default = '1';
o.editable = true;
/* Manual nodes (share links) */
s = m.section(form.GridSection, 'node', _('Manual nodes'),
_('One node share link per line — ss, vmess, vless, trojan, tuic, hysteria2, socks5.'));
s.addremove = true;
s.anonymous = true;
s.sortable = false;
o = s.option(form.Value, 'tag', _('Tag'));
o.placeholder = _('auto-generated');
o.textvalue = mutedCell;
o = s.option(form.Value, 'link', _('Share Link'));
o.rmempty = false;
o.placeholder = 'vmess://...';
o.textvalue = ellipsisCell;
o = s.option(form.Flag, 'enabled', _('Enabled'));
o.default = '1';
o.editable = true;
/* Subscription / manual-node tags for the group filter dropdowns */
const subTags = (uci.sections('dae', 'subscription') || [])
.map(function(x) { return x.tag; }).filter(function(x) { return !!x; });
const nodeTags = (uci.sections('dae', 'node') || [])
.map(function(x) { return x.tag; }).filter(function(x) { return !!x; });
/* Groups (outbound) */
s = m.section(form.TypedSection, 'group', _('Groups'),
_('Outbound groups. Leave the source empty to use all nodes. Set the route fallback to one of these.'));
s.addremove = true;
s.anonymous = true;
o = s.option(form.Value, 'name', _('Name'));
o.rmempty = false;
o.default = 'proxy'; /* real default value, editable — a bright placeholder looked filled and tripped the required check */
o.placeholder = 'proxy';
o.validate = function(sid, v) {
if (!v) return _('Name is required');
/* dae fatals on duplicate outbound names — keep group names unique */
const dup = (uci.sections('dae', 'group') || []).some(function(g) {
return g['.name'] !== sid && g.name === v;
});
return dup ? _('Group name must be unique') : true;
};
o = s.option(form.ListValue, 'policy', _('Policy'));
o.value('min_moving_avg', _('Min moving average latency'));
o.value('min', _('Min last latency'));
o.value('min_avg10', _('Min average of last 10'));
o.value('random', _('Random'));
o.value('fixed(0)', _('Fixed (first node)'));
o.default = 'min_moving_avg';
o = s.option(form.DynamicList, 'source', _('Source'),
_('Subscriptions and nodes that feed this group. Pick from the list or type a name. Leave empty to use all nodes.'));
subTags.forEach(function(t) { o.value(t, t + ' (' + _('subscription') + ')'); });
nodeTags.forEach(function(t) { o.value(t, t + ' (' + _('node') + ')'); });
o = s.option(form.DynamicList, 'name_filter', _('Name filter'),
_('Optional. Narrows subscriptions in the source to nodes whose name matches — e.g. 香港 or 新加坡|日本. Nodes you picked by name are always kept.'));
o.placeholder = '新加坡|日本';
/* Routing presets */
const groupNames = (uci.sections('dae', 'group') || [])
.map(function(x) { return x.name; }).filter(function(x) { return !!x; });
s = m.section(form.NamedSection, 'routing', 'routing', _('Routing'));
s.addremove = false;
o = s.option(form.Flag, 'private_direct', _('Private IPs direct'),
_('LAN / private addresses bypass the proxy.'));
o.default = '1';
o = s.option(form.Flag, 'cn_direct', _('China direct'),
_('China mainland IPs and domains bypass the proxy.'));
o.default = '1';
o = s.option(form.Flag, 'block_ads', _('Block ads'),
_('Drop traffic matching the ad domain list.'));
o.default = '0';
o = s.option(form.Value, 'fallback', _('Fallback group'),
_('Default outbound for everything else.'));
o.default = 'proxy'; /* matches the default proxy group so the box works out of the box */
if (groupNames.length)
groupNames.forEach(function(n) { o.value(n, n); });
else
o.value('proxy', 'proxy');
o = s.option(form.DynamicList, 'custom', _('Custom rules'),
_('Raw dae routing lines, evaluated before fallback. Target must be a group name above (or direct/block).'));
o.placeholder = 'dip(geoip:jp) -> proxy';
/* DNS presets */
s = m.section(form.NamedSection, 'dns', 'dns', _('DNS'));
s.addremove = false;
o = s.option(form.Value, 'cn_upstream', _('China upstream'),
_('Resolves China-mainland domains.'));
o.default = 'udp://dns.alidns.com:53';
o.placeholder = 'udp://dns.alidns.com:53';
o = s.option(form.Value, 'fallback_upstream', _('Fallback upstream'),
_('Resolves everything else.'));
o.default = 'tcp+udp://dns.google:53';
o.placeholder = 'tcp+udp://dns.google:53';
o = s.option(form.Value, 'response_ttl', _('DNS response TTL'),
_('TTL returned to LAN DNS clients. 0 keeps dae default behavior.'));
o.datatype = 'uinteger';
o.default = '0';
o.placeholder = '60';
/* Logging — folded into the main form so the single Save button covers it
too (no separate native save bar) */
/* Network interfaces — override dae auto WAN detect, fails on legacy swconfig (#15).
Combobox: dropdown of real devices but still free-text for undetectable WANs */
const netDevs = (ctx && ctx.netDevs) || [];
s = m.section(form.NamedSection, 'config', 'dae', _('Network interfaces'));
s.addremove = false;
o = s.option(form.Value, 'lan_interface', _('LAN interface'),
_('Interface to proxy the LAN from. Usually br-lan.'));
o.default = 'br-lan';
o.placeholder = 'br-lan';
netDevs.forEach(function(d) { o.value(d); });
o = s.option(form.Value, 'wan_interface', _('WAN interface'),
_('Uplink. auto = detect by default route; on legacy swconfig or multi-WAN set it, e.g. eth0.2.'));
o.default = 'auto';
o.placeholder = 'auto';
o.value('auto', 'auto');
netDevs.forEach(function(d) { o.value(d); });
s = m.section(form.NamedSection, 'config', 'dae', _('Logging'));
s.addremove = false;
o = s.option(form.ListValue, 'log_level', _('Log level'),
_('Debug and trace show per-connection routing details, but increase CPU usage and log volume. Enable them only while troubleshooting.'));
o.value('error', 'ERROR');
o.value('warn', 'WARN');
o.value('info', 'INFO');
o.value('debug', 'DEBUG');
o.value('trace', 'TRACE');
o.default = 'info';
o.rmempty = false;
o = s.option(form.Value, 'log_maxsize', _('Max Log Size (MB)'),
_('Rotate the log file once it grows past this many megabytes.'));
o.datatype = 'uinteger';
o.default = '1';
o = s.option(form.Value, 'log_maxbackups', _('Max Log Backups'),
_('Number of rotated log files to keep.'));
o.datatype = 'uinteger';
o.default = '1';
const status = E('span', { 'class': 'dd-editor-status' }, '');
let renderSaveActions = function() {};
let statusTimer = null;
function flash(text, kind, hold) {
status.textContent = text;
status.classList.remove('ok', 'err');
if (kind) status.classList.add(kind);
status.classList.add('show');
if (statusTimer) clearTimeout(statusTimer);
statusTimer = setTimeout(function() { status.classList.remove('show'); }, hold || 3000);
}
/* one save pipeline, parameterised by intent:
- opts.start: after generate, enable + start dae (stopped → "Save and Start")
- otherwise just save + generate; generate hot-reloads itself when dae runs */
function doSave(opts, btns) {
const settingsCard = status.closest('.dd-settings-card');
const openTitles = captureAccordionState(settingsCard && settingsCard.querySelector('.cbi-map'));
const restoreLiveAccordions = function() {
const liveCard = status.closest('.dd-settings-card');
restoreAccordionState(liveCard && liveCard.querySelector('.cbi-map'), openTitles);
};
btns.forEach(function(b) { b.disabled = true; });
flash(_('Saving…'));
/* ensure the singleton sections exist before parsing the form —
on installs where uci-defaults never seeded them, writing an
option to a missing named section fails with ubus NOT_FOUND (4) */
if (!uci.get('dae', 'routing')) uci.add('dae', 'routing', 'routing');
if (!uci.get('dae', 'dns')) uci.add('dae', 'dns', 'dns');
if (!uci.get('dae', 'config')) uci.add('dae', 'dae', 'config');
return m.save(null, true)
.then(function() {
restoreLiveAccordions();
/* assign unique tags to any rows the user left blank */
autofillTags('subscription', 'subscription');
autofillTags('node', 'node');
})
.then(function() { return uci.save(); })
.then(function() {
/* apply flushes the rpc session to /etc/config so the
generator (reads /etc/config/dae) sees the form data;
skip it when nothing changed — apply on an empty
changeset throws ubus NO_DATA (code 5). */
return uci.changes().then(function(ch) {
return (ch && Object.keys(ch).length) ? uci.apply() : null;
});
})
.then(function() { return fs.exec(GEN, ['generate']); })
.then(function(res) {
if (res && res.code !== 0) {
const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0];
flash(_('Apply failed: %s').format(err), 'err', 9000);
throw new Error('gen-failed');
}
if (!opts.start) {
/* generate already hot-reloaded if dae was running */
flash(opts.okMsg || _('Saved'), 'ok');
return;
}
flash(_('Starting…'));
return fs.exec('/sbin/uci', ['set', 'dae.config.enabled=1'])
.then(function() { return fs.exec('/sbin/uci', ['commit', 'dae']); })
.then(function() { return fs.exec(backend.BACKENDS.dae.initd, ['enable']); })
.then(function() { return fs.exec(backend.BACKENDS.dae.initd, ['start']); })
.then(function(r2) {
if (r2 && r2.code !== 0)
flash(_('Start failed: %s').format(r2.stderr || r2.stdout || ('exit ' + r2.code)), 'err', 9000);
else {
flash(_('Saved · started'), 'ok');
renderSaveActions(true);
}
});
})
.catch(function(e) {
if (e && e.message === 'gen-failed') return;
if (e && e.name === 'CBIValidationError') {
flash(_('Please fix the highlighted fields.'), 'err', 6000);
return;
}
flash(_('Save failed: %s').format(e.message || e), 'err', 9000);
})
.finally(function() {
restoreLiveAccordions();
btns.forEach(function(b) { b.disabled = false; });
window.setTimeout(function() { ui.changes.init(); }, 1800);
});
}
return m.render().then(function(mapNode) {
/* beginner default: only Subscriptions starts open; nodes/groups/routing/dns/logging collapse */
organizeDaeSections(mapNode, [ _('Subscriptions') ]);
const cardChildren = [ E('h4', { 'class': 'dd-card-title' }, _('dae Configuration')) ];
cardChildren.push(mapNode);
/* state-aware primary action: running → one "Save and Apply"; stopped →
"Save config" (secondary) + "Save and Start" (primary). Starting the
proxy is an explicit choice, never an implicit side effect of saving. */
return backend.detectRunning().then(function(rr) {
const running = rr && rr.dae;
/* "Update all subscriptions" only helps while dae runs (hot reload
re-pulls them). A stopped dae re-fetches every subscription on
start, so there is nothing to update — hide the button then. */
if (running) {
const upBtn = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Update all subscriptions'));
const upMsg = E('span', { 'class': 'dd-meta', 'style': 'margin-left:8px;display:none' }, '');
const setUp = function(text, kind) {
upMsg.textContent = text;
upMsg.style.display = '';
upMsg.style.color = (kind === 'err') ? 'var(--error-color, #d33)' : 'var(--success-color, #2a8)';
if (kind !== 'err')
setTimeout(function() { if (upMsg.textContent === text) upMsg.style.display = 'none'; }, 5000);
};
upBtn.addEventListener('click', function(ev) {
ev.preventDefault();
upBtn.disabled = true;
setUp(_('Updating…'));
fs.exec(backend.BACKENDS.dae.initd, ['hot_reload']).then(function(res) {
if (res && res.code !== 0)
setUp(_('Update failed: %s').format(res.stderr || res.stdout || ('exit ' + res.code)), 'err');
else
setUp(_('Subscriptions updated (dae reloaded)'), 'ok');
}).catch(function(e) {
setUp(_('Update failed: %s').format(e.message || e), 'err');
}).finally(function() { upBtn.disabled = false; });
});
const upWrap = E('div', { 'class': 'dd-actions', 'style': 'margin:0 0 8px' }, [ upBtn, upMsg ]);
const advs = mapNode.querySelectorAll('.dd-adv');
for (let i = 0; i < advs.length; i++) {
const t = advs[i].querySelector('.dd-adv-bar span');
if (t && t.textContent.trim() === _('Subscriptions')) {
const body = advs[i].querySelector('.dd-adv-body');
const tbl = body && body.querySelector('.cbi-section-table');
if (tbl) tbl.parentNode.insertBefore(upWrap, tbl);
else if (body) body.insertBefore(upWrap, body.firstChild);
break;
}
}
}
const actionsWrap = E('div', { 'class': 'dd-editor-actions' });
renderSaveActions = function(isRunning) {
while (actionsWrap.firstChild) actionsWrap.removeChild(actionsWrap.firstChild);
let btns;
if (isRunning) {
const apply = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save and Apply'));
btns = [ apply ];
apply.addEventListener('click', function(ev) { ev.preventDefault(); doSave({ okMsg: _('Saved · applied') }, btns); });
actionsWrap.appendChild(apply);
} else {
const saveOnly = E('button', { 'class': 'cbi-button' }, _('Save config'));
const saveStart = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save and Start'));
btns = [ saveOnly, saveStart ];
saveOnly.addEventListener('click', function(ev) { ev.preventDefault(); doSave({ okMsg: _('Saved') }, btns); });
saveStart.addEventListener('click', function(ev) { ev.preventDefault(); doSave({ start: true }, btns); });
actionsWrap.appendChild(saveOnly);
actionsWrap.appendChild(saveStart);
}
actionsWrap.appendChild(status);
};
renderSaveActions(running);
cardChildren.push(actionsWrap);
return E('div', { 'class': 'dd-card dd-settings-card' }, cardChildren);
});
});
}
/* Import banner: offer to pull subscription/node from an existing hand-written
config.dae into the form when the form is still empty. */
function renderDaeImportBanner() {
const hasForms = (uci.sections('dae', 'subscription') || []).length
|| (uci.sections('dae', 'node') || []).length;
if (hasForms)
return Promise.resolve(null);
/* stat first (ubus): avoid a 404 when config.dae doesn't exist yet */
return fs.stat(backend.BACKENDS.dae.config).then(function() {
return fs.read_direct(backend.BACKENDS.dae.config, 'text');
}).then(function(content) {
if (!content || !/\b(subscription|node)\s*\{/.test(content))
return null;
const btn = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Import from existing config'));
const note = E('span', { 'class': 'dd-meta', style: 'margin-left:8px' }, '');
btn.addEventListener('click', function(ev) {
ev.preventDefault();
btn.disabled = true;
note.textContent = _('Importing…');
fs.exec(GEN, ['import']).then(function(res) {
if (res && res.code === 0) {
note.textContent = _('Imported, reloading…');
setTimeout(function() { window.location.reload(); }, 600);
} else {
note.textContent = _('Import failed');
btn.disabled = false;
}
}).catch(function() { note.textContent = _('Import failed'); btn.disabled = false; });
});
return E('div', { 'class': 'dd-card', style: 'border-color:rgba(217,158,0,.4)' }, [
E('div', { 'class': 'dd-ph-warn-title' }, _('Existing config detected')),
E('div', { 'class': 'dd-settings-descr', style: 'margin:4px 0 8px' },
_('You have a hand-written config.dae. Import its subscriptions and nodes into the form, or keep editing manually below. Saving the form overwrites config.dae.')),
E('div', { 'class': 'dd-actions' }, [ btn, note ])
]);
}).catch(function() { return null; });
}
/* Lightweight dae-DSL syntax highlighter: tokenises into spans for a <pre>
layer drawn behind a transparent textarea (no editor library needed). */
function highlightDae(src) {
const re = /(#[^\n]*)|('[^'\n]*')|\b(global|subscription|node|group|routing|dns|include|upstream|request|response)\b|\b([A-Za-z_][\w-]*)(?=\s*\()|(->|&&|\|\|)|\b(geoip|geosite)(?=:)/g;
let out = '', last = 0, m;
const esc = function(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); };
while ((m = re.exec(src)) !== null) {
out += esc(src.slice(last, m.index));
const cls = m[1] ? 'c' : m[2] ? 's' : m[3] ? 'k' : m[4] ? 'f' : m[5] ? 'o' : 'g';
out += '<span class="dh-' + cls + '">' + esc(m[0]) + '</span>';
last = m.index + m[0].length;
}
out += esc(src.slice(last));
return out;
}
function renderDaeEditor() {
const textarea = E('textarea', {
'class': 'dd-editor dd-editor-hl',
'spellcheck': 'false',
'wrap': 'off',
'placeholder': _('dae config file is empty. Click "Load default config" to start, or paste your config here.')
}, '');
const hlCode = E('code', {});
const hlPre = E('pre', { 'class': 'dd-hl', 'aria-hidden': 'true' }, hlCode);
const editWrap = E('div', { 'class': 'dd-edit-wrap' }, [ hlPre, textarea ]);
function syncHL() {
hlCode.innerHTML = highlightDae(textarea.value) + '\n';
hlPre.scrollTop = textarea.scrollTop;
hlPre.scrollLeft = textarea.scrollLeft;
}
textarea.addEventListener('scroll', function() {
hlPre.scrollTop = textarea.scrollTop;
hlPre.scrollLeft = textarea.scrollLeft;
});
const save = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save manual config'));
const init = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Load default config'));
const status = E('span', { 'class': 'dd-editor-status' }, '');
const phWarn = E('div', { 'class': 'dd-ph-warn' }, [
E('div', { 'class': 'dd-ph-warn-title' }, ''),
E('div', { 'class': 'dd-ph-howto' }, ''),
E('div', { 'class': 'dd-ph-list-label' }, _('Placeholder lines (click line number to jump):')),
E('ul', { 'class': 'dd-ph-list' })
]);
/* Footer:dae version · 行数 · 占位剩余 */
const fbVer = E('span', { 'class': 'dd-fb-item' }, [ E('span', { 'class': 'dd-fb-key' }, 'dae'), E('span', {}, '—') ]);
const fbLine = E('span', { 'class': 'dd-fb-item' }, [ E('span', { 'class': 'dd-fb-key' }, _('lines')), E('span', {}, '0') ]);
const fbStat = E('span', { 'class': 'dd-fb-item dd-fb-ok' }, '✓ ready');
const footer = E('div', { 'class': 'dd-editor-footer' }, [ fbVer, fbLine, fbStat ]);
let statusTimer = null;
function flashStatus(text, kind, holdMs) {
status.textContent = text;
status.classList.remove('ok', 'err');
if (kind) status.classList.add(kind);
status.classList.add('show');
if (statusTimer) clearTimeout(statusTimer);
statusTimer = setTimeout(function() {
status.classList.remove('show');
}, holdMs || 3000);
}
function jumpToLine(lineNo) {
const lines = textarea.value.split('\n');
let offset = 0;
for (let i = 0; i < lineNo - 1 && i < lines.length; i++)
offset += lines[i].length + 1;
textarea.focus();
textarea.setSelectionRange(offset, offset + (lines[lineNo - 1] || '').length);
const lineHeight = 18;
textarea.scrollTop = Math.max(0, (lineNo - 4) * lineHeight);
}
function refreshFooter(phCount) {
const lines = textarea.value ? textarea.value.split('\n').length : 0;
fbLine.lastChild.textContent = String(lines);
while (fbStat.firstChild) fbStat.removeChild(fbStat.firstChild);
fbStat.classList.remove('dd-fb-ok', 'dd-fb-warn');
if (phCount > 0) {
fbStat.classList.add('dd-fb-warn');
fbStat.textContent = _('⚠ %d placeholder').format(phCount);
} else {
fbStat.classList.add('dd-fb-ok');
fbStat.textContent = lines > 0 ? '✓ ready' : '— empty';
}
}
function refreshPlaceholders() {
syncHL();
const hits = detectPlaceholders(textarea.value);
const titleEl = phWarn.querySelector('.dd-ph-warn-title');
const howtoEl = phWarn.querySelector('.dd-ph-howto');
const listEl = phWarn.querySelector('.dd-ph-list');
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
refreshFooter(hits.length);
if (!hits.length) {
phWarn.classList.remove('show');
return;
}
titleEl.textContent = _('⚠ Still needs a subscription link: %d placeholder URL line(s) detected').format(hits.length);
while (howtoEl.firstChild) howtoEl.removeChild(howtoEl.firstChild);
/* assemble rich text from DOM nodes so the code spans keep their styling */
howtoEl.appendChild(document.createTextNode(_('These ')));
const c1 = document.createElement('code'); c1.textContent = 'example.com'; howtoEl.appendChild(c1);
howtoEl.appendChild(document.createTextNode(' / '));
const c2 = document.createElement('code'); c2.textContent = 'relative/path/to'; howtoEl.appendChild(c2);
howtoEl.appendChild(document.createTextNode(_(' are placeholder addresses from dae\'s template, not real subscriptions. Replace the URL on any one line with your airport subscription link (delete the spare lines) and save.')));
hits.forEach(function(h) {
const lnSpan = E('span', { 'class': 'dd-ph-ln', 'title': _('Jump to line') }, 'L' + h.line);
lnSpan.addEventListener('click', function() { jumpToLine(h.line); });
const txt = E('span', { 'class': 'dd-ph-txt' }, h.url);
listEl.appendChild(E('li', {}, [ lnSpan, txt ]));
});
phWarn.classList.add('show');
}
textarea.addEventListener('input', refreshPlaceholders);
function loadConfig() {
/* stat first (ubus, no HTTP): a fresh install has no config.dae yet, and
reading a missing file via fs.read_direct logs a noisy 404 */
return fs.stat(backend.BACKENDS.dae.config).then(function() {
return fs.read_direct(backend.BACKENDS.dae.config, 'text');
}).then(function(content) {
textarea.value = content || '';
}).catch(function() {
textarea.value = '';
}).finally(refreshPlaceholders);
}
/* 校验流程:先写到 /tmp/dae-validate.dae 干测 → dae validate 通过才覆盖正式 config */
const VALIDATE_PATH = '/tmp/dae-validate.dae';
save.addEventListener('click', function(ev) {
ev.preventDefault();
save.disabled = true;
flashStatus(_('Validating…'));
fs.write(VALIDATE_PATH, textarea.value, 384)
.then(function() { return fs.exec('/usr/bin/dae', ['validate', '-c', VALIDATE_PATH]); })
.then(function(res) {
if (res && res.code !== 0) {
const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0];
flashStatus(_('Validate failed: %s').format(err), 'err', 8000);
throw new Error('validate-failed');
}
flashStatus(_('Saving…'));
return fs.write(backend.BACKENDS.dae.config, textarea.value, 384);
})
.then(function() { return backend.detectRunning(); })
.then(function(running) {
if (running && running.dae)
return fs.exec(backend.BACKENDS.dae.initd, ['hot_reload']).then(function(res) {
if (res && res.code !== 0)
flashStatus(_('Reload failed: %s').format(res.stderr || res.stdout || ('exit ' + res.code)), 'err', 8000);
else
flashStatus(_('Validated · saved · hot-reloaded'), 'ok');
});
flashStatus(_('Saved OK (start dae to apply)'), 'ok');
})
.catch(function(e) {
if (e && e.message === 'validate-failed') return;
flashStatus(_('Save failed: %s').format(e.message || e), 'err', 8000);
})
.finally(function() {
save.disabled = false;
fs.exec('/bin/rm', ['-f', VALIDATE_PATH]).catch(function() {});
});
});
init.addEventListener('click', function(ev) {
ev.preventDefault();
if (textarea.value.trim() && !confirm(_('This will replace your current config. Continue?')))
return;
textarea.value = DEFAULT_TEMPLATE;
refreshPlaceholders();
flashStatus(_('Default config loaded — replace the subscription link, then save'), 'ok');
});
/* 一次性探测 dae --version;失败就显示 unknown,不影响主流程 */
fs.exec('/usr/bin/dae', ['--version']).then(function(res) {
const out = (res && res.stdout || '').trim();
const m = out.match(/dae\s+version\s+(\S+)/);
fbVer.lastChild.textContent = m ? m[1].split('-')[0] : 'unknown';
}).catch(function() {
fbVer.lastChild.textContent = 'unknown';
});
loadConfig();
return E('div', { 'class': 'dd-card' }, [
E('h4', { 'class': 'dd-card-title' }, _('Advanced / Manual Mode')),
(function() {
var adv = E('div', { 'class': 'dd-adv dd-closed', style: 'margin-top:8px' }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, _('Configuration Editor')),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
E('div', { 'class': 'dd-adv-body' }, [
E('div', { 'class': 'dd-editor-hint', style: 'margin:0 0 10px' }, [
E('b', {}, _('Text-only mode.')),
' ',
_('Edit the dae DSL directly. "Load default config" gives a ready-to-run starting point. Use daed for a GUI.')
]),
phWarn,
editWrap,
footer,
E('div', { 'class': 'dd-editor-actions' }, [ save, init, status ])
])
]);
adv.firstChild.addEventListener('click', function() { adv.classList.toggle('dd-closed'); });
return adv;
})()
]);
}
return baseclass.extend({
renderDaeForms: renderDaeForms,
renderDaeImportBanner: renderDaeImportBanner,
renderDaeEditor: renderDaeEditor
});
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
const TOKEN_KEY = 'luci-app-daede.daed-token';
function clear(storage) {
try { storage.removeItem(TOKEN_KEY); } catch (e) {}
}
function tokenExpiry(token) {
try {
const part = String(token || '').split('.')[1];
if (!part)
return 0;
const normalized = part.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(normalized));
return Number(payload.exp) || 0;
} catch (e) {
return 0;
}
}
function load(storage, now) {
let token = '';
try { token = storage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; }
const current = now == null ? Date.now() / 1000 : Number(now);
if (!token || tokenExpiry(token) <= current + 30) {
clear(storage);
return '';
}
return token;
}
function save(storage, token) {
try { storage.setItem(TOKEN_KEY, String(token || '')); } catch (e) {}
}
function isAccessDenied(error) {
return /access denied|unauthori[sz]ed|invalid token/i.test(String(error && error.message || error || ''));
}
const api = {
TOKEN_KEY: TOKEN_KEY,
load: load,
save: save,
clear: clear,
isAccessDenied: isAccessDenied
};
if (typeof module !== 'undefined' && module.exports)
module.exports = api;
if (typeof baseclass !== 'undefined')
return baseclass.extend(api);
return api;
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
'require form';
'require fs';
'require ui';
'require uci';
'require view.daede.widgets as widgets';
function applyUciChanges() {
return uci.save().then(function() {
return uci.changes().then(function(ch) {
return (ch && Object.keys(ch).length) ? uci.apply() : null;
});
}).then(function() {
return new Promise(function(resolve) { window.setTimeout(resolve, 1800); });
}).then(function() {
return ui.changes.init();
});
}
function renderDaedSettings() {
let m, s, o;
m = new form.Map('daed', null, null);
s = m.section(form.NamedSection, 'config', 'daed');
s.addremove = false;
s.anonymous = true;
o = s.option(form.Value, 'listen_addr', _('Listen Address'));
o.datatype = 'ipaddrport(1)';
o.default = '0.0.0.0:2023';
o.rmempty = false;
o = s.option(form.Value, 'dashboard_username', _('daed Username'),
_('Used by LuCI to access daed (subscription updates and import). If daed is not initialized yet, this account is used to create the daed admin.'));
o.placeholder = 'admin';
o.rmempty = false;
o = s.option(form.Value, 'dashboard_password', _('daed Password'));
o.password = true;
o = s.option(form.Flag, 'subscribe_auto_update', _('Enable subscription auto-update'));
o.default = '0';
o = s.option(form.ListValue, 'subscribe_update_cycle', _('Update Cycle'));
o.value('daily', _('Daily'));
o.value('weekly', _('Weekly'));
o.default = 'daily';
o.depends('subscribe_auto_update', '1');
o = s.option(form.ListValue, 'subscribe_update_hour', _('Update Time'));
for (let h = 0; h < 24; h++) {
const hh = ('0' + h).slice(-2);
o.value(String(h), hh + ':00');
}
o.default = '4';
o.depends('subscribe_auto_update', '1');
o = s.option(form.Value, 'log_maxsize', _('Max Log Size (MB)'),
_('Rotate the log file once it grows past this many megabytes.'));
o.datatype = 'uinteger';
o.default = '5';
o = s.option(form.Value, 'log_maxbackups', _('Max Log Backups'),
_('Number of rotated log files to keep.'));
o.datatype = 'uinteger';
o.default = '1';
return widgets.wrapSettingsCard(
_('daede Settings'),
null,
m.render(),
_('Log Settings'),
['log_maxsize', 'log_maxbackups']
).then(function(card) {
/* native Save/Apply footer is suppressed view-wide, so daed carries its
own primary action */
const status = E('span', { 'class': 'dd-editor-status' }, '');
let statusTimer = null;
function flash(text, kind, hold) {
status.textContent = text;
status.classList.remove('ok', 'err');
if (kind) status.classList.add(kind);
status.classList.add('show');
if (statusTimer) clearTimeout(statusTimer);
statusTimer = setTimeout(function() { status.classList.remove('show'); }, hold || 3000);
}
const save = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save and Apply'));
const update = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Update subscriptions now'));
update.addEventListener('click', function(ev) {
ev.preventDefault();
update.disabled = true;
flash(_('Updating daed subscriptions…'));
fs.exec('/usr/share/luci-app-daede/daed-sub-update.sh', []).then(function(res) {
if (res && res.code !== 0) {
const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0];
flash(_('Subscription update failed: %s').format(err), 'err', 9000);
} else {
flash(_('Subscriptions updated and applied'), 'ok');
}
}).catch(function(e) {
flash(_('Subscription update failed: %s').format(e.message || e), 'err', 9000);
}).finally(function() { update.disabled = false; });
});
save.addEventListener('click', function(ev) {
ev.preventDefault();
save.disabled = true;
m.save(null, true)
.then(applyUciChanges)
.then(function() {
const enabled = uci.get('daed', 'config', 'subscribe_auto_update') === '1';
return fs.exec('/usr/share/luci-app-daede/daed-sub-cron.sh', [ enabled ? 'enable' : 'disable' ]);
})
.then(function(res) {
if (res && res.code !== 0)
throw new Error((res.stderr || res.stdout || ('exit ' + res.code)).trim());
})
.then(function() {
/* no success popup — the reload is feedback enough */
setTimeout(function() { window.location.reload(); }, 500);
})
.catch(function(e) {
if (e && e.name === 'CBIValidationError')
flash(_('Please fix the highlighted fields.'), 'err', 6000);
else
flash(_('Save failed: %s').format(e.message || e), 'err', 9000);
})
.finally(function() { save.disabled = false; });
});
card.appendChild(E('div', { 'class': 'dd-editor-actions' }, [ update, save, status ]));
return card;
});
}
return baseclass.extend({ renderDaedSettings: renderDaedSettings });
@@ -0,0 +1,413 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require fs';
'require poll';
'require ui';
'require view';
'require view.daede.backend as backend';
'require view.daede.styles as styles';
const MAX_LINES = 5000;
function execChecked(command, args) {
return fs.exec(command, args || []).then(function(res) {
if (res && res.code !== 0)
throw new Error((res.stderr || res.stdout || ('exit ' + res.code)).trim());
return res;
});
}
const CSS = [
'.dd-log-wrap{padding:4px 0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}',
'.dd-log-card{border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:10px 14px;margin-bottom:10px;box-shadow:0 2px 8px rgba(0,0,0,.03);background:rgba(255,255,255,.02)}',
'.dd-log-card-title{font-size:11px;font-weight:600;opacity:.55;margin:0 0 8px;letter-spacing:.3px;text-transform:uppercase}',
'.dd-log-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:0 0 8px;margin-bottom:8px;border-bottom:1px dashed rgba(128,128,128,.2)}',
'.dd-log-toolbar label{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;cursor:pointer;margin:0;opacity:.85}',
/* Argon shifts label>checkbox down by top:.4rem; reset so flex centers it with the text */
'.dd-log-toolbar input[type="checkbox"]{position:static;top:auto;right:auto;margin:0}',
'.dd-log-toolbar input[type="text"]{font-size:11.5px;padding:4px 8px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:inherit;min-width:160px}',
'.dd-log-toolbar .dd-log-btn{font-size:11.5px;line-height:1.4;min-height:0;height:auto;padding:4px 12px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:inherit;cursor:pointer}',
'.dd-log-toolbar .dd-log-btn:hover{background:rgba(128,128,128,.1)}',
'.dd-log-toolbar .dd-log-stop{color:#d9534f;border-color:rgba(217,83,79,.45)}',
'.dd-log-toolbar .dd-log-meta{margin-left:auto;font-size:10.5px;opacity:.55;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}',
'.dd-log-pane{height:60vh;min-height:360px;overflow:auto;padding:8px 10px;border:1px solid rgba(0,0,0,.08);border-radius:7px;background:#1a1d21;color:#d8dde6;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;font-size:11.5px;line-height:1.55;white-space:pre-wrap;word-break:break-all}',
'.dd-log-pane .dd-line{padding:1px 4px;border-radius:3px;display:block}',
'.dd-log-pane .dd-line.dd-info{color:#cfd6df}',
'.dd-log-pane .dd-line.dd-warn{color:#e8b95a}',
'.dd-log-pane .dd-line.dd-error{color:#ea7878;background:rgba(234,120,120,.06)}',
'.dd-log-pane .dd-line.dd-debug{color:#7a8290;opacity:.7}',
'.dd-log-pane .dd-line.dd-trace{color:#68717d;opacity:.62}',
'.dd-log-pane .dd-line.dd-hidden{display:none}',
'.dd-log-pane .dd-empty{opacity:.5;font-style:italic}',
/* 简化后的字段视觉 */
'.dd-log-pane .dd-ts{color:#6b7480;margin-right:8px;white-space:nowrap}',
'.dd-log-pane .dd-lvl{display:inline-block;min-width:38px;padding:0 5px;margin-right:8px;border-radius:3px;font-size:10px;font-weight:700;letter-spacing:.4px;text-align:center;vertical-align:1px}',
'.dd-log-pane .dd-lvl-info{color:#7fc7a8;background:rgba(127,199,168,.08)}',
'.dd-log-pane .dd-lvl-warn{color:#e8b95a;background:rgba(232,185,90,.10)}',
'.dd-log-pane .dd-lvl-error{color:#ea7878;background:rgba(234,120,120,.10)}',
'.dd-log-pane .dd-lvl-debug{color:#7a8290;background:rgba(122,130,144,.10)}',
'.dd-log-pane .dd-lvl-trace{color:#68717d;background:rgba(104,113,125,.10)}',
'.dd-log-pane .dd-msg{color:inherit}',
'.dd-log-pane .dd-kv{margin-left:6px;color:#6b7480;font-size:11px;opacity:.85}',
'body.dark .dd-log-card,html[data-theme="dark"] .dd-log-card,html[data-bs-theme="dark"] .dd-log-card{border-color:rgba(255,255,255,.08);background:rgba(255,255,255,.02)}',
'body.dark .dd-log-pane,html[data-theme="dark"] .dd-log-pane,html[data-bs-theme="dark"] .dd-log-pane{border-color:rgba(255,255,255,.1)}'
].join('');
/* 拆字段:time="May 25 07:04:59" level=info msg="..." key=val key="val with space" ... */
const RE_LINE = /^time="([^"]*)"\s+level=(\w+)\s+msg=(?:"((?:[^"\\]|\\.)*)"|(\S+))\s*(.*)$/;
const RE_PREFIXED_LINE = /^\[([^\]]+)\]\s+(TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\s*(.*)$/i;
const RE_PLAIN_LEVEL_LINE = /^\s*(TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\s+(.*)$/i;
function detectLevel(line) {
// daed/dae logs use lvl=info / [INFO] / level=warning style
const m = line.match(/\b(TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\b/i);
if (!m) return '';
const lvl = m[1].toUpperCase();
if (lvl === 'TRACE') return 'dd-trace';
if (lvl === 'DEBUG') return 'dd-debug';
if (lvl === 'INFO') return 'dd-info';
if (lvl.startsWith('WARN')) return 'dd-warn';
return 'dd-error';
}
function matchesFilter(line, filter) {
if (!filter)
return true;
if (filter === 'trace' || filter === 'debug' || filter === 'info' || filter === 'warn' || filter === 'error')
return detectLevel(line) === 'dd-' + filter;
if (filter === 'node_status')
return /\balive\b/i.test(line)
|| /\bgroup\b.*\bselects dialer\b/i.test(line)
|| /\bgroup\s+'[^']+'\s+\[[^\]]+\]:/i.test(line);
if (filter === 'proxy_traffic')
return line.indexOf('<->') !== -1;
return false;
}
/* 格式化时间戳并按北京时间显示:
* - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z"),转成北京时间
* - 新格式和旧 logrus 短格式无时区信息,保留原始完整日期时间
*/
function formatTs(raw) {
if (!raw) return raw;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(raw)) {
const d = new Date(raw);
if (!isNaN(d.getTime())) {
const beijing = new Date(d.getTime() + 8 * 60 * 60 * 1000);
const pad = n => String(n).padStart(2, '0');
return [
beijing.getUTCFullYear(),
pad(beijing.getUTCMonth() + 1),
pad(beijing.getUTCDate())
].join('-') + ' ' + [
pad(beijing.getUTCHours()),
pad(beijing.getUTCMinutes()),
pad(beijing.getUTCSeconds())
].join(':');
}
}
return raw;
}
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 'dd-lvl-trace';
if (u === 'DEBUG') return 'dd-lvl-debug';
if (u === 'INFO') return 'dd-lvl-info';
if (u === 'WARN' || u === 'WARNING') return 'dd-lvl-warn';
return 'dd-lvl-error';
}
function parseLine(line) {
let m = line.match(RE_LINE);
if (m) {
return {
ts: formatTs(m[1]),
lvl: m[2],
msg: m[3] !== undefined ? m[3] : (m[4] || ''),
kv: (m[5] || '').trim()
};
}
m = line.match(RE_PREFIXED_LINE);
let ts = '', lvl = '', body = '';
if (m) {
ts = formatTs(m[1]);
lvl = m[2];
body = m[3] || '';
} else {
m = line.match(RE_PLAIN_LEVEL_LINE);
if (!m) return null;
lvl = m[1];
body = m[2] || '';
}
const kvStart = body.search(/(?:^|\s)(?=[A-Za-z_][\w.-]*=)/);
return {
ts: ts,
lvl: lvl,
msg: kvStart === -1 ? body : body.slice(0, kvStart).trimEnd(),
kv: kvStart === -1 ? '' : body.slice(kvStart).trim()
};
}
function buildLine(ln) {
const cls = detectLevel(ln);
const parsed = parseLine(ln);
if (!parsed) {
return E('div', { 'class': 'dd-line ' + cls }, ln);
}
const parts = [];
if (parsed.ts) parts.push(E('span', { 'class': 'dd-ts' }, parsed.ts));
parts.push(E('span', { 'class': 'dd-lvl ' + lvlClass(parsed.lvl) }, lvlShort(parsed.lvl)));
parts.push(E('span', { 'class': 'dd-msg' }, parsed.msg));
if (parsed.kv) parts.push(E('span', { 'class': 'dd-kv' }, parsed.kv));
return E('div', { 'class': 'dd-line ' + cls }, parts);
}
return view.extend({
load: function() {
return backend.detectBackend();
},
render(ctx) {
const self = this;
const LOG_PATH = ctx.backend.log;
const state = {
lastContent: '',
lastSize: -1,
paused: false,
autoScroll: true,
filter: '',
userScrolled: false,
reading: false
};
const pane = E('div', { 'class': 'dd-log-pane', 'id': 'dd-log-pane' }, [
E('div', { 'class': 'dd-empty' }, _('Loading…'))
]);
// detect manual scroll → auto-pause auto-scroll
pane.addEventListener('scroll', function() {
const atBottom = pane.scrollHeight - pane.scrollTop - pane.clientHeight < 4;
state.userScrolled = !atBottom;
});
const meta = E('span', { 'class': 'dd-log-meta' }, '');
const btnAuto = E('button', { 'type': 'button', 'class': 'dd-log-btn dd-log-toggle', 'id': 'dd-log-auto' });
const btnPause = E('button', { 'type': 'button', 'class': 'dd-log-btn dd-log-toggle', 'id': 'dd-log-pause' });
const syncControls = function() {
btnAuto.textContent = state.autoScroll ? '✓ ' + _('Auto-scroll: On') : '○ ' + _('Auto-scroll: Off');
btnAuto.setAttribute('aria-pressed', String(state.autoScroll));
btnPause.textContent = state.paused ? '▶ ' + _('Resume') : 'Ⅱ ' + _('Pause');
btnPause.setAttribute('aria-pressed', String(state.paused));
meta.textContent = state.paused ? _('Paused') : '';
};
btnAuto.addEventListener('click', function() {
state.autoScroll = !state.autoScroll;
if (state.autoScroll) {
pane.scrollTop = pane.scrollHeight;
state.userScrolled = false;
}
syncControls();
});
btnPause.addEventListener('click', function() {
state.paused = !state.paused;
syncControls();
if (!state.paused) tick();
});
syncControls();
const selFilter = E('select', { 'class': 'dd-log-btn' }, [
E('option', { 'value': '' }, _('All')),
E('option', { 'value': 'trace' }, 'TRACE'),
E('option', { 'value': 'debug' }, 'DEBUG'),
E('option', { 'value': 'info' }, 'INFO'),
E('option', { 'value': 'warn' }, 'WARN'),
E('option', { 'value': 'error' }, 'ERROR'),
E('option', { 'value': 'node_status' }, _('Node Status')),
E('option', { 'value': 'proxy_traffic' }, _('Proxy Traffic'))
]);
selFilter.addEventListener('change', function() {
state.filter = selFilter.value;
applyFilter();
});
const btnClear = E('button', { 'class': 'dd-log-btn' }, _('Clear View'));
btnClear.addEventListener('click', function() {
while (pane.firstChild) pane.removeChild(pane.firstChild);
state.lastContent = '';
});
const btnDownload = E('button', { 'class': 'dd-log-btn' }, _('Download'));
btnDownload.addEventListener('click', function() {
const blob = new Blob([state.lastContent || ''], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = ctx.name + '-' + (new Date()).toISOString().replace(/[:.]/g, '-') + '.log';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
const btnTruncate = E('button', { 'class': 'dd-log-btn' }, _('Clear File'));
btnTruncate.addEventListener('click', function() {
if (!confirm(_('Truncate %s log on the router? This cannot be undone.').format(ctx.name)))
return;
fs.write(LOG_PATH, '').then(function() {
state.lastContent = '';
state.lastSize = 0;
}).catch(function() {});
});
let btnStop = null;
if (ctx.name === 'daed') {
btnStop = E('button', { 'class': 'dd-log-btn dd-log-stop' }, _('Stop daed'));
btnStop.addEventListener('click', function() {
if (!confirm(_('Stop daed now?')))
return;
btnStop.disabled = true;
execChecked('/sbin/uci', ['set', 'daed.config.enabled=0'])
.then(function() { return execChecked('/sbin/uci', ['commit', 'daed']); })
.then(function() { return execChecked('/etc/init.d/daed', ['disable']); })
.then(function() { return execChecked('/etc/init.d/daed', ['stop']); })
.then(function() { btnStop.textContent = _('daed stopped'); })
.catch(function(e) {
btnStop.disabled = false;
ui.addNotification(null, E('p', {}, _('Failed to stop daed: %s').format(e.message || e)), 'error');
});
});
}
function applyFilter() {
const f = state.filter;
pane.querySelectorAll('.dd-line').forEach(function(el) {
if (matchesFilter(el.textContent, f))
el.classList.remove('dd-hidden');
else
el.classList.add('dd-hidden');
});
}
function appendLines(text) {
const lines = text.split('\n');
const frag = document.createDocumentFragment();
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
if (!ln) continue;
const el = buildLine(ln);
if (!matchesFilter(ln, state.filter))
el.classList.add('dd-hidden');
frag.appendChild(el);
}
// drop empty-state placeholder if present
const empty = pane.querySelector('.dd-empty');
if (empty) pane.removeChild(empty);
pane.appendChild(frag);
// cap rendered rows
const overflow = pane.children.length - MAX_LINES;
if (overflow > 0) {
for (let i = 0; i < overflow; i++) pane.removeChild(pane.firstChild);
}
}
function renderEmpty(msg) {
while (pane.firstChild) pane.removeChild(pane.firstChild);
pane.appendChild(E('div', { 'class': 'dd-empty' }, msg));
}
function tick() {
if (state.paused || state.reading) return Promise.resolve();
state.reading = true;
return fs.stat(LOG_PATH).then(function(st) {
if (state.paused) return;
const size = st.size || 0;
if (size === state.lastSize) {
meta.textContent = '%d bytes · live'.format(size);
return;
}
// File rotated/truncated → full reload
const rotated = size < state.lastSize;
return fs.read_direct(LOG_PATH, 'text').then(function(content) {
if (state.paused) return;
content = content || '';
let delta;
if (rotated || state.lastContent === '') {
// full replace
while (pane.firstChild) pane.removeChild(pane.firstChild);
delta = content;
} else if (content.indexOf(state.lastContent) === 0) {
// pure append
delta = content.slice(state.lastContent.length);
} else {
// content changed mid-stream → full replace
while (pane.firstChild) pane.removeChild(pane.firstChild);
delta = content;
}
if (delta) appendLines(delta);
state.lastContent = content;
state.lastSize = size;
meta.textContent = '%d bytes · live'.format(size);
if (state.autoScroll && !state.userScrolled)
pane.scrollTop = pane.scrollHeight;
});
}).catch(function(e) {
if (state.paused) return;
const msg = String(e);
if (msg.indexOf('NotFoundError') !== -1 || msg.indexOf('No such') !== -1)
renderEmpty(_('Log file does not exist yet.'));
else
renderEmpty(_('Error reading log: %s').format(msg));
state.lastSize = -1;
state.lastContent = '';
meta.textContent = '';
}).finally(function() { state.reading = false; });
}
poll.add(tick);
tick();
const toolbarItems = [
btnAuto,
btnPause,
selFilter,
btnClear,
btnDownload,
btnTruncate
];
if (btnStop) toolbarItems.push(btnStop);
toolbarItems.push(meta);
const toolbar = E('div', { 'class': 'dd-log-toolbar' }, toolbarItems);
return E('div', { 'class': 'dd-log-wrap' }, [
E('style', {}, CSS + styles.CSS),
E('div', { 'class': 'dd-log-card' }, [
E('h4', { 'class': 'dd-log-card-title' }, _('%s Realtime Log').format(ctx.name)),
toolbar,
pane
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,262 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
const CSS = [
'.dd-log-toolbar .dd-log-toggle[aria-pressed="true"]{background:rgba(74,160,101,.16);border-color:#4aa065;box-shadow:inset 0 0 0 1px rgba(74,160,101,.2)}',
'.dd-log-toolbar .dd-log-toggle:focus-visible{outline:2px solid #4aa065;outline-offset:2px}',
'.dd-wrap{padding:4px 0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}',
'.dd-card{border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:9px 14px;margin-bottom:7px;box-shadow:0 2px 8px rgba(0,0,0,.03);background:rgba(255,255,255,.02)}',
/* padding:0 neutralizes Argon's h4{padding:.75rem 1.25rem}, which otherwise
indents the title 20px past the card body and looks misaligned */
'.dd-card-title{font-size:11px;font-weight:600;opacity:.55;margin:0 0 8px;padding:0;letter-spacing:.3px;text-transform:uppercase}',
'.dd-status-row{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:0}',
'.dd-status-row .dd-grow{flex:1 1 auto}',
'.dd-badge{display:inline-flex;align-items:center;gap:5px;padding:2px 10px;border-radius:999px;font-size:10.5px;font-weight:700;letter-spacing:.3px;border:1px solid transparent;line-height:1.3}',
'.dd-badge-run{color:#3da66a;border-color:rgba(61,166,106,.5)}',
'.dd-badge-stop{color:#d96d6d;border-color:rgba(217,109,109,.55)}',
'.dd-badge-dot{width:6px;height:6px;border-radius:50%;background:currentColor;display:inline-block}',
'.dd-meta{font-size:11.5px;opacity:.7;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}',
'.dd-meta-label{opacity:.55;margin-right:4px}',
'.dd-actions{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0}',
'.dd-actions .cbi-button{font-size:11.5px;padding:4px 12px;border-radius:5px}',
'.dd-actions a.cbi-button{display:inline-flex;align-items:center;gap:4px}',
'.dd-wrap .cbi-button{font-size:11px !important;line-height:1.4 !important;min-height:0 !important;height:auto !important;padding:4px 12px !important;border-radius:5px !important;border:1px solid rgba(128,128,128,.35) !important;background:transparent !important;color:inherit !important;box-shadow:none !important;cursor:pointer;white-space:nowrap}',
'.dd-wrap .cbi-button:hover{background:rgba(128,128,128,.12) !important}',
'.dd-wrap .cbi-button:disabled{opacity:.45;cursor:not-allowed}',
'.dd-wrap .cbi-button-action,.dd-wrap .cbi-button-positive,.dd-wrap .cbi-button-add,.dd-wrap .cbi-button-edit{border-color:#4aa065 !important;color:#4aa065 !important}',
'.dd-wrap .cbi-button-remove{border-color:#d96d6d !important;color:#d96d6d !important}',
'.dd-wrap .cbi-button-remove:hover{background:rgba(217,109,109,.12) !important}',
'.dd-switch{position:relative;width:42px;height:22px;border:0;border-radius:999px;background:rgba(128,128,128,.28);padding:0;cursor:pointer;transition:background .18s ease,opacity .18s ease;flex-shrink:0}',
'.dd-switch .dd-switch-knob{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.96);box-shadow:0 1px 4px rgba(0,0,0,.2);transition:transform .18s ease}',
'.dd-switch.is-on{background:rgba(74,160,101,.65)}',
'.dd-switch.is-on .dd-switch-knob{transform:translateX(20px)}',
'.dd-switch:disabled{opacity:.45;cursor:not-allowed}',
'.dd-switch-label{font-size:10.5px;font-weight:600;opacity:.62;letter-spacing:.3px}',
'.dd-switch-wrap{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}',
'.dd-backend-card{padding:10px 14px}',
'.dd-backend-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
'.dd-backend-label{flex:0 0 calc(200px - 10px);font-size:12px;font-weight:600;opacity:.72}',
'.dd-backend-segment{display:inline-flex;align-items:center;gap:2px;padding:2px;border-radius:7px;background:rgba(128,128,128,.10)}',
'.dd-backend-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-width:78px;height:24px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:inherit;font-size:11px;font-weight:500;opacity:.65;cursor:pointer;transition:background .18s ease,color .18s ease,opacity .18s ease}',
'.dd-backend-btn:hover{background:rgba(128,128,128,.10)}',
'.dd-backend-btn.is-active{background:linear-gradient(#3886a1,#2f7288);color:#fff;opacity:1;font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.12)}',
'.dd-backend-btn:disabled{opacity:.55;cursor:not-allowed}',
'.dd-backend-state{font-size:10.5px;font-weight:500;opacity:.70;margin-left:2px}',
'.dd-backend-btn.is-active .dd-backend-state{opacity:.85}',
'.dd-backend-help{margin:6px 0 0 200px;font-size:11.5px;line-height:1.45;opacity:.66}',
'.dd-backend-help:empty{display:none}',
'html[data-daede-theme="argon"] .dd-backend-label{flex-basis:calc(240px - 10px)}',
'html[data-daede-theme="argon"] .dd-backend-help{margin-left:240px}',
'html[data-daede-theme="argon"]:not([data-darkmode="true"]) .dd-config-page .dd-card{background:rgba(255,255,255,.78);border-color:rgba(50,50,93,.12);box-shadow:0 2px 8px rgba(50,50,93,.06)}',
'html[data-daede-theme="argon"]:not([data-darkmode="true"]) .dd-config-page .dd-card-title{opacity:.78}',
'html[data-daede-theme="argon"]:not([data-darkmode="true"]) .dd-config-page .dd-settings-card .cbi-value-title{opacity:1}',
'html[data-daede-theme="argon"]:not([data-darkmode="true"]) .dd-config-page .dd-settings-card .cbi-value-field input,html[data-daede-theme="argon"]:not([data-darkmode="true"]) .dd-config-page .dd-settings-card .cbi-value-field select,html[data-daede-theme="argon"]:not([data-darkmode="true"]) .dd-config-page .dd-settings-card .cbi-value-field textarea{background:#fff!important;border-color:rgba(50,50,93,.24)!important}',
'html[data-daede-theme="argon"]:not([data-darkmode="true"]) .dd-config-page .dd-adv-bar{background:rgba(50,50,93,.04);border-color:rgba(50,50,93,.14);opacity:.78}',
'@media (max-width:640px){.dd-backend-label{flex-basis:100%!important;width:100%}.dd-backend-segment{width:100%}.dd-backend-btn{flex:1;min-width:0}.dd-backend-help{margin-left:0!important}}',
/* daed/dae settings card —— LuCI form.Map 字体/边框对齐 dd 卡片体系 */
'.dd-settings-card{padding:10px 14px}',
'.dd-settings-card>h2,.dd-settings-card .cbi-map>h2,.dd-settings-card .cbi-section>h3{display:none}',
'.dd-settings-card .cbi-map>.cbi-map-descr,.dd-settings-card .cbi-section-descr{font-size:11.5px;opacity:.62;margin:0 0 8px;line-height:1.45}',
'.dd-settings-descr{font-size:11.5px;opacity:.62;margin:0 0 8px;line-height:1.45}',
'.dd-settings-card .cbi-section{margin:0;padding:0;background:transparent;border:0;box-shadow:none}',
'.dd-settings-card .cbi-value{padding:6px 0;border:0;min-height:0}',
/* Argon's .td.cbi-value-field padding + 40px .cbi-checkbox make table rows
too tall — tighten both for our compact subscription/node tables */
'.dd-settings-card .cbi-section-table-row>td{padding:6px 10px !important}',
'.dd-settings-card .cbi-section-table-row .cbi-checkbox{height:20px !important;min-height:0 !important}',
'.dd-settings-card .cbi-value-title{font-size:12.5px !important;font-weight:500;opacity:.85;padding:6px 12px 6px 0;min-width:140px}',
'.dd-settings-card .cbi-value-field input,.dd-settings-card .cbi-value-field select,.dd-settings-card .cbi-value-field textarea{font-size:12.5px !important;padding:5px 8px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:inherit}',
'.dd-settings-card .cbi-value-field input:focus,.dd-settings-card .cbi-value-field select:focus,.dd-settings-card .cbi-value-field textarea:focus{border-color:rgba(56,134,161,.7);outline:0;box-shadow:0 0 0 2px rgba(56,134,161,.15)}',
'.dd-settings-card .cbi-value-description,.dd-settings-card .cbi-value-helptext{font-size:11.5px !important;opacity:.6;line-height:1.45;padding-top:3px}',
'.dd-settings-card .cbi-section-remove{background:transparent !important;border:0 !important;box-shadow:none !important}',
'.dd-settings-card .cbi-section-remove .cbi-button{border-color:#d96d6d !important;color:#d96d6d !important}',
/* themes (esp. Argon) restyle checkboxes into faint, near-invisible boxes;
force native rendering + brand accent so the checkmark is always clear */
'.dd-settings-card input[type="checkbox"]{-webkit-appearance:checkbox !important;appearance:checkbox !important;width:16px;height:16px;background:none !important;border:0 !important;box-shadow:none !important;background-image:none !important}',
'.dd-settings-card input[type="checkbox"]:before,.dd-settings-card input[type="checkbox"]:after{content:none !important;display:none !important}',
/* line the Subscriptions and Nodes grid tables up column-for-column. fixed
layout + explicit per-column percentages make the widths authoritative
across themes (Argon's auto layout otherwise sizes the empty Nodes table
by content, and even ignores px cell widths, so they drift apart) */
'.dd-settings-card .cbi-section-table{table-layout:fixed !important;width:100%}',
/* flatten the table header: Argon tints header cells (#f6f9fc), which reads
as an off-colour band on the white card — keep it transparent */
'.dd-settings-card .cbi-section-table-titles,.dd-settings-card .cbi-section-table-titles .cbi-section-table-cell{background:transparent !important;background-image:none !important}',
'.dd-settings-card .cbi-section-table-cell:nth-child(1){width:14% !important}',
'.dd-settings-card .cbi-section-table-cell:nth-child(2){width:62% !important;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.dd-settings-card .cbi-section-table-cell:nth-child(3){width:10% !important}',
'.dd-settings-card .cbi-section-table-cell:nth-child(4){width:auto !important;min-width:0 !important;white-space:nowrap;text-align:right}',
/* icon row-action buttons stay narrow, never stretch to fill the cell */
'.dd-settings-card .cbi-section-actions .cbi-button-edit,.dd-settings-card .cbi-section-actions .cbi-button-remove{width:auto !important;min-width:0 !important;display:inline-block !important;flex:0 0 auto !important}',
/* proxy-test result coloring */
'.dd-meta.dd-ok{color:#3da66a;font-weight:600}',
'.dd-meta.dd-err{color:#d96d6d;font-weight:600}',
/* 手风琴折叠组 —— 视觉与 clashoo cl-component-adv 一致 */
'.dd-adv{margin-top:10px}',
'.dd-adv-bar{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;cursor:pointer;user-select:none;font-size:12px;font-weight:600;background:rgba(128,128,128,.06);border:1px solid rgba(128,128,128,.16);border-radius:7px;color:inherit;opacity:.55}',
'.dd-adv-bar:hover{background:rgba(56,134,161,.08);opacity:1}',
'.dd-adv-chevron{font-size:14px;font-weight:700;opacity:.55;transition:transform .2s}',
'.dd-adv:not(.dd-closed) .dd-adv-chevron{transform:rotate(90deg)}',
'.dd-adv-body{margin-top:8px;padding:2px 4px 4px}',
'.dd-adv.dd-closed .dd-adv-body{display:none}',
'.dd-editor{width:100%;min-height:460px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;font-size:12px;line-height:1.5;box-sizing:border-box;resize:vertical;border-radius:6px 6px 0 0}',
/* syntax-highlight overlay: a <pre> sits behind a transparent <textarea>.
both share identical box metrics so glyphs line up; the textarea stays the
real editor (caret, selection, insert-at-cursor, jump-to-line all native) */
'.dd-edit-wrap{position:relative}',
'.dd-edit-wrap .dd-editor,.dd-edit-wrap .dd-hl{margin:0;padding:10px 12px;border-width:1px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;font-size:12px;line-height:1.5;letter-spacing:0;tab-size:4;white-space:pre;word-break:normal;overflow-x:auto;box-sizing:border-box}',
'.dd-edit-wrap .dd-hl{position:absolute;inset:0;margin:0;overflow:hidden;border:1px solid transparent;border-radius:6px 6px 0 0;pointer-events:none;background:#f6f8fa;color:#3b4252;z-index:1}',
/* reset Argon's code{background:var(--lighter)} so the overlay inherits the
pre background uniformly — otherwise a dark pre shows as side bars.
color:inherit so plain text follows .dd-hl (light in dark mode) instead of
the theme's own code{color}, which is dark and vanishes on a dark pre */
'.dd-edit-wrap .dd-hl code{font:inherit;white-space:inherit;word-break:inherit;display:block;color:inherit !important;background:transparent !important}',
'.dd-edit-wrap .dd-editor-hl{position:relative;z-index:2;color:transparent !important;background:transparent !important;caret-color:#2f7288;border:1px solid rgba(128,128,128,.28)}',
'.dd-edit-wrap .dd-editor-hl::placeholder{color:rgba(128,128,128,.55)}',
/* keep selected text transparent too — else the browser force-colors it and
it ghosts over the highlight pre underneath */
'.dd-edit-wrap .dd-editor-hl::selection{background:rgba(56,134,161,.25);color:transparent}',
'@media screen and (max-device-width:600px){.dd-edit-wrap .dd-editor,.dd-edit-wrap .dd-hl,.dd-edit-wrap .dd-hl code{font-size:16px}}',
'.dh-c{color:#8a919a}',
'.dh-s{color:#2a8a4a}',
'.dh-k{color:#9a3fb5}',
'.dh-f{color:#2f7288}',
'.dh-o{color:#c2772a}',
'.dh-g{color:#b0226a}',
'html[data-darkmode="true"] .dd-edit-wrap .dd-hl,body.dark .dd-edit-wrap .dd-hl,html[data-bs-theme="dark"] .dd-edit-wrap .dd-hl{color:#c8cdd6;background:#1e2228}',
'html[data-darkmode="true"] .dd-edit-wrap .dd-editor-hl,body.dark .dd-edit-wrap .dd-editor-hl,html[data-bs-theme="dark"] .dd-edit-wrap .dd-editor-hl{caret-color:#7fd0e8}',
'html[data-darkmode="true"] .dh-c,body.dark .dh-c{color:#6b7280}',
'html[data-darkmode="true"] .dh-s,body.dark .dh-s{color:#7ec699}',
'html[data-darkmode="true"] .dh-k,body.dark .dh-k{color:#c792ea}',
'html[data-darkmode="true"] .dh-f,body.dark .dh-f{color:#82c4dd}',
'html[data-darkmode="true"] .dh-o,body.dark .dh-o{color:#e0a35a}',
'html[data-darkmode="true"] .dh-g,body.dark .dh-g{color:#ec6fa8}',
'.dd-editor-footer{display:flex;flex-wrap:wrap;align-items:center;gap:14px;padding:5px 10px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:10.5px;background:rgba(128,128,128,.06);border:1px solid rgba(0,0,0,.06);border-top:0;border-radius:0 0 6px 6px;color:inherit;opacity:.78}',
'.dd-editor-footer .dd-fb-item{display:inline-flex;align-items:center;gap:5px}',
'.dd-editor-footer .dd-fb-key{opacity:.55}',
'.dd-editor-footer .dd-fb-warn{color:#b07d00;font-weight:600}',
'.dd-editor-footer .dd-fb-ok{color:#3da66a;font-weight:600}',
'body.dark .dd-editor-footer,html[data-theme="dark"] .dd-editor-footer,html[data-bs-theme="dark"] .dd-editor-footer,html[data-darkmode="true"] .dd-editor-footer{border-color:rgba(255,255,255,.1);background:rgba(255,255,255,.04)}',
'body.dark .dd-editor-footer .dd-fb-warn,html[data-theme="dark"] .dd-editor-footer .dd-fb-warn,html[data-bs-theme="dark"] .dd-editor-footer .dd-fb-warn,html[data-darkmode="true"] .dd-editor-footer .dd-fb-warn{color:#e0b34a}',
/* match the action buttons' size (h25 / 11px / 5px radius) for a consistent row */
'.dd-insert-select{font-size:11px;line-height:1.4;height:25px;padding:0 10px;border-radius:5px;border:1px solid rgba(128,128,128,.35);background:transparent;color:inherit;cursor:pointer;box-sizing:border-box}',
'.dd-insert-select:hover{border-color:rgba(56,134,161,.55)}',
'.dd-editor-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px}',
'.dd-editor-status{margin-left:auto;font-size:11.5px;opacity:0;transition:opacity .25s ease;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}',
'.dd-editor-status.show{opacity:1}',
'.dd-editor-status.ok{color:#3da66a}',
'.dd-editor-status.err{color:#d96d6d}',
'.dd-editor-hint{font-size:11.5px;opacity:.62;line-height:1.5;margin:0 0 8px;padding:7px 10px;border-radius:6px;background:rgba(56,134,161,.06)}',
'.dd-editor-hint b{font-weight:600;opacity:.85}',
'.dd-sub-card{margin:0 0 10px;padding:10px 14px;border:0;border-radius:8px;background:transparent}',
'.dd-sub-card-title{font-size:11px;font-weight:600;opacity:.55;margin:0 0 8px;letter-spacing:.3px;text-transform:uppercase}',
'.dd-sub-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
'.dd-sub-row input{flex:1;min-width:200px;font-size:12px;padding:6px 10px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:var(--text-color,inherit)}',
'.dd-sub-row input:focus{border-color:rgba(74,160,101,.55);outline:0;box-shadow:0 0 0 2px rgba(74,160,101,.12)}.dd-sub-row input::placeholder{opacity:.45;color:rgba(92,102,120,.8)}',
'.dd-sub-apply{font-size:11px;padding:5px 14px;border-radius:5px;border:1px solid #4aa065;color:#4aa065;background:transparent;cursor:pointer;white-space:nowrap;font-weight:600}',
'.dd-sub-apply:hover{background:rgba(74,160,101,.12)}',
'.dd-sub-apply:disabled{opacity:.45;cursor:not-allowed}',
'.dd-sub-status{font-size:11px;margin-left:8px;opacity:0;transition:opacity .2s;white-space:nowrap}',
'.dd-sub-status.show{opacity:1}',
'.dd-ph-warn{display:none;font-size:12px;line-height:1.6;margin:0 0 8px;padding:10px 12px;border-radius:6px;background:rgba(217,158,0,.08);color:inherit}',
'.dd-ph-warn.show{display:block}',
'.dd-ph-warn-title{font-weight:600;margin-bottom:6px;color:#b07d00;font-size:12.5px}',
'body.dark .dd-ph-warn-title,html[data-theme="dark"] .dd-ph-warn-title,html[data-bs-theme="dark"] .dd-ph-warn-title,html[data-darkmode="true"] .dd-ph-warn-title{color:#e0b34a}',
'.dd-ph-howto{margin:4px 0 8px;opacity:.85}',
'.dd-ph-howto code{padding:1px 5px;border-radius:3px;background:rgba(128,128,128,.18);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}',
'.dd-ph-list{margin:6px 0 0;padding:6px 0 0;list-style:none;border-top:1px dashed rgba(217,158,0,.3)}',
'.dd-ph-list-label{font-size:11px;opacity:.6;margin:0 0 4px}',
'.dd-ph-list li{padding:2px 0;display:flex;gap:8px;align-items:baseline;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11px}',
'.dd-ph-list .dd-ph-ln{display:inline-block;min-width:38px;text-align:center;padding:1px 4px;border-radius:3px;background:rgba(217,158,0,.18);color:#8a6300;opacity:.95;cursor:pointer;font-weight:600;text-decoration:none}',
'.dd-ph-list .dd-ph-ln:hover{background:rgba(217,158,0,.32)}',
'body.dark .dd-ph-list .dd-ph-ln,html[data-theme="dark"] .dd-ph-list .dd-ph-ln,html[data-bs-theme="dark"] .dd-ph-list .dd-ph-ln,html[data-darkmode="true"] .dd-ph-list .dd-ph-ln{color:#f0c763;background:rgba(217,158,0,.22)}',
'.dd-ph-list .dd-ph-txt{opacity:.7;word-break:break-all}',
'body.dark .dd-card,html[data-theme="dark"] .dd-card,html[data-bs-theme="dark"] .dd-card,html[data-darkmode="true"] .dd-card{border-color:rgba(255,255,255,.08);background:rgba(255,255,255,.02)}',
'body.dark .dd-adv-bar,html[data-theme="dark"] .dd-adv-bar,html[data-bs-theme="dark"] .dd-adv-bar,html[data-darkmode="true"] .dd-adv-bar{background:rgba(255,255,255,.04);border-color:rgba(255,255,255,.10)}',
'body.dark .dd-settings-card .cbi-value-field input,body.dark .dd-settings-card .cbi-value-field select,body.dark .dd-settings-card .cbi-value-field textarea,html[data-theme="dark"] .dd-settings-card .cbi-value-field input,html[data-theme="dark"] .dd-settings-card .cbi-value-field select,html[data-theme="dark"] .dd-settings-card .cbi-value-field textarea,html[data-bs-theme="dark"] .dd-settings-card .cbi-value-field input,html[data-bs-theme="dark"] .dd-settings-card .cbi-value-field select,html[data-bs-theme="dark"] .dd-settings-card .cbi-value-field textarea,html[data-darkmode="true"] .dd-settings-card .cbi-value-field input,html[data-darkmode="true"] .dd-settings-card .cbi-value-field select,html[data-darkmode="true"] .dd-settings-card .cbi-value-field textarea{border-color:rgba(255,255,255,.18)}',
/* No prefers-color-scheme dark block: OS dark mode must NOT force a dark
editor when the user picked a LIGHT theme. Dark styling is driven solely
by data-darkmode (config.js reads the real page background). */
/* status card line-2 meta (backend · pid), below the badge+toggle line */
'.dd-status-meta{display:flex;flex-wrap:wrap;gap:10px;margin-top:6px}',
/* compact icon-only row actions — the 编辑/删除 text buttons are too wide
(esp. Bootstrap); show a pencil / trash glyph instead, both themes */
'.dd-settings-card .cbi-section-actions .cbi-button-edit,.dd-settings-card .cbi-section-actions .cbi-button-remove{font-size:0 !important;padding:4px 7px !important;min-width:0 !important}',
'.dd-settings-card .cbi-section-actions .cbi-button-edit::before{content:"\\270E";font-size:13px;line-height:1}',
'.dd-settings-card .cbi-section-actions .cbi-button-remove::before{content:"\\1F5D1\\FE0E";font-size:13px;line-height:1}',
/* phone: keep each subscription/node row on ONE line; the URL cell
middle-ellipsizes (via ellipsisCell) and flex-shrinks to fit the screen */
'@media (max-width:600px){',
'.dd-settings-card .cbi-section-table-titles{display:none !important}',
/* drop table layout so flex rows are constrained to the card width and the
URL cell can actually shrink (a flex <tr> inside table-layout:fixed grows
past the table and overflows the screen on narrow/Argon) */
'.dd-settings-card .cbi-section-table{display:block !important;table-layout:auto !important;width:auto !important;overflow:visible !important}',
'.dd-settings-card .cbi-section-table>tbody,.dd-settings-card .cbi-section-table>div{display:block !important;width:auto !important}',
'.dd-settings-card .cbi-section-table-row{display:flex !important;flex-wrap:nowrap !important;width:auto !important;align-items:center;gap:6px;padding:5px 0;margin:0;border:0;border-bottom:1px solid rgba(128,128,128,.15);background:transparent}',
/* target the real cells (td.cbi-value-field / actions) — LuCI forces them
block+full-width at mobile, so override on the direct children */
'.dd-settings-card .cbi-section-table-row>td{display:inline-block !important;align-self:center !important;width:auto !important;padding:0 !important;border:0 !important;background:transparent !important;box-shadow:none !important;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0 !important}',
'.dd-settings-card .cbi-section-table-row>td::before{content:none !important;display:none !important}',
'.dd-settings-card .cbi-section-table-row>td input{vertical-align:middle !important;margin:0 !important}',
'.dd-settings-card .cbi-section-table-row>td:nth-child(1){flex:0 0 auto !important;max-width:60px;opacity:.6;font-size:11px}',
'.dd-settings-card .cbi-section-table-row>td:nth-child(2){flex:1 1 auto !important}',
'.dd-settings-card .cbi-section-table-row>td:nth-child(3){flex:0 0 auto !important;text-align:center}',
'.dd-settings-card .cbi-section-table-row>td.cbi-section-actions{flex:0 0 auto !important;display:flex;gap:4px}',
/* clear the floated title so a Flag description gets the full row width
instead of wrapping around it (e.g. "局域网 / 私有地址不走代理。") */
'.dd-settings-card .cbi-value-description,.dd-settings-card .cbi-value-helptext{clear:both;width:100%}',
'}',
'.dd-converter{width:100%!important;max-width:1180px;margin:0 auto}',
'.dd-conv-card{padding:18px 20px;margin-bottom:12px}',
'.dd-conv-url-row{display:grid;grid-template-columns:minmax(280px,1fr) 230px max-content;align-items:center;gap:8px}',
'.dd-conv-import,.dd-conv-toolbar,.dd-conv-heading{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
'.dd-converter input,.dd-converter select,.dd-converter .cbi-button{box-sizing:border-box!important;height:32px!important;min-height:32px!important;margin:0!important;font-size:12px!important;line-height:22px!important}',
'.dd-converter input,.dd-converter select{padding:4px 8px!important;border:1px solid rgba(128,128,128,.28)!important;border-radius:6px!important;background-color:transparent;color:inherit;box-shadow:none!important}',
'.dd-converter input::placeholder,.dd-conv-yaml::placeholder,.dd-conv-filter::placeholder,.dd-conv-airport-name::placeholder{color:inherit;opacity:.55}',
'.dd-converter select{padding-right:28px!important}',
'.dd-converter .cbi-button{padding:4px 12px!important}',
'.dd-conv-url-row input,.dd-conv-filter,.dd-conv-target,.dd-conv-ua,.dd-conv-yaml{box-sizing:border-box;border:1px solid rgba(128,128,128,.28);border-radius:6px;background:transparent;color:inherit;font-size:12px;padding:7px 9px}',
'.dd-conv-url-row input{width:100%;min-width:0}',
'.dd-conv-ua{width:230px;max-width:230px}',
'.dd-conv-yaml{width:100%;min-height:150px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.5}',
'.dd-conv-or{text-align:center;font-size:11px;opacity:.5;margin:7px 0}',
'.dd-conv-heading{justify-content:space-between;gap:10px;margin-bottom:10px}',
'.dd-conv-heading .dd-card-title{margin:0}',
'.dd-conv-summary{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px 10px;font-size:11px;font-variant-numeric:tabular-nums}',
'.dd-conv-summary-item{display:inline-flex;align-items:baseline;gap:4px;white-space:nowrap;color:inherit;opacity:.62}',
'.dd-conv-summary-item strong{font-size:12px;font-weight:650;opacity:1;color:inherit}',
'.dd-conv-summary-label{font-size:10.5px}',
'.dd-conv-toolbar{margin-bottom:8px}',
'.dd-conv-filter{margin-left:auto;min-width:220px}',
'.dd-conv-results{border:1px solid rgba(128,128,128,.18);border-radius:7px;max-height:420px;overflow:auto}',
'.dd-conv-empty{display:flex;align-items:center;justify-content:center;min-height:72px;padding:16px;text-align:center;font-size:11.5px;opacity:.48}',
'.dd-conv-result{display:grid;grid-template-columns:22px minmax(100px,1fr) 90px minmax(130px,1.3fr);gap:8px;align-items:center;padding:7px 9px;border-bottom:1px solid rgba(128,128,128,.13);font-size:12px}',
'.dd-conv-result:last-child{border-bottom:0}',
'.dd-conv-result input{appearance:checkbox !important;-webkit-appearance:checkbox !important;accent-color:#4aa065;width:15px;height:15px}',
'.dd-conv-name,.dd-conv-result-state{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.dd-conv-proto{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11px;opacity:.7}',
'.dd-conv-result-state.good,.dd-conv-status.ok{color:#3da66a}',
'.dd-conv-result-state.dup{opacity:.55}',
'.dd-conv-result-state.bad,.dd-conv-status.err{color:#d96d6d}',
'.dd-conv-import label{font-size:12px;font-weight:600;opacity:.72}',
'.dd-conv-airport{display:grid;grid-template-columns:max-content minmax(240px,420px) max-content 130px;gap:8px;align-items:center;margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid rgba(128,128,128,.15)}',
'.dd-conv-airport .dd-conv-target,.dd-conv-airport-name{box-sizing:border-box;width:100% !important;min-width:0 !important;height:32px !important;min-height:32px !important;margin:0 !important;padding:4px 8px !important;border:1px solid rgba(128,128,128,.28);border-radius:6px;background-color:transparent;color:inherit;font-size:12px !important;line-height:22px !important;box-shadow:none}',
'.dd-conv-airport-name{max-width:420px}',
'.dd-conv-group-summary{margin:2px 0 10px;padding:9px 11px;border-left:3px solid rgba(74,160,101,.65);border-radius:3px;background:rgba(74,160,101,.07);font-size:12px;line-height:1.5}',
'.dd-conv-group-summary.err{border-left-color:#d96d6d;background:rgba(217,109,109,.07);color:#d96d6d}',
'.dd-conv-submit{justify-content:flex-end}',
'.dd-conv-import .cbi-button{margin-left:auto}',
'.dd-conv-status{margin-top:9px;font-size:11.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;min-height:16px}',
'.dd-daed-login{box-sizing:border-box;width:min(560px,100%);margin:0 auto}',
'.dd-daed-login .cbi-value{display:grid!important;grid-template-columns:110px minmax(0,1fr)!important;gap:12px;align-items:center;padding:6px 0!important}',
'.dd-daed-login .cbi-value-title,.dd-daed-login .cbi-value-field{box-sizing:border-box;width:auto!important;min-width:0!important;margin:0!important;padding:0!important}',
'.dd-daed-login .cbi-value-field input{box-sizing:border-box;width:100%!important;max-width:none!important;min-width:0!important;height:36px!important;margin:0!important;padding:6px 10px!important}',
'.dd-daed-login-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}',
'@media(max-width:640px){.dd-conv-card{padding:14px}.dd-conv-heading{align-items:flex-start}.dd-conv-heading .dd-card-title{width:100%}.dd-conv-summary{justify-content:flex-start;width:100%;gap:5px 12px;padding-top:2px}.dd-conv-summary-item{min-width:74px}.dd-conv-filter{margin-left:0;width:100%}.dd-conv-result{grid-template-columns:20px minmax(90px,1fr) 68px}.dd-conv-result-state{grid-column:2/4;font-size:11px}.dd-conv-submit .cbi-button{width:100%!important}.dd-conv-airport{grid-template-columns:82px minmax(0,1fr)}.dd-conv-airport>*{max-width:none !important}.dd-conv-url-row{grid-template-columns:1fr}.dd-conv-url-row input,.dd-conv-url-row .dd-conv-ua,.dd-conv-url-row .cbi-button{width:100%;max-width:none}.dd-daed-login .cbi-value{grid-template-columns:1fr!important;gap:4px}.dd-daed-login-actions .cbi-button{flex:1 1 0}}'
].join('');
return baseclass.extend({ CSS: CSS });
@@ -0,0 +1,608 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require fs';
'require poll';
'require uci';
'require ui';
'require view';
'require view.daede.backend as backend';
'require view.daede.daed-session as daedSession';
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 currentPreset(gi, gs) {
if (!gi && !gs) return 'loyalsoldier';
if (gi === GEO_PRESETS.v2fly.geoip && gs === GEO_PRESETS.v2fly.geosite) return 'v2fly';
return 'custom';
}
const HEALTH_PATHS = {
btf: '/sys/kernel/btf/vmlinux',
btfDetached: '/usr/lib/debug/boot/vmlinux',
netns: '/run/netns/daens'
};
const CSS = [
'.dd-up-wrap{padding:6px 0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}',
'.dd-card{border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:14px 16px;margin-bottom:14px;box-shadow:0 2px 8px rgba(0,0,0,.03);background:rgba(255,255,255,.02)}',
'.dd-card-title{font-size:12px;font-weight:600;opacity:.55;margin:0 0 10px;letter-spacing:.3px;text-transform:uppercase}',
'.dd-up-row{display:grid;grid-template-columns:24px 130px 1fr auto auto;gap:10px;align-items:center;font-size:12px;padding:7px 0;border-top:1px dashed rgba(128,128,128,.18)}',
'.dd-up-row:first-of-type{border-top:0}',
'.dd-up-icon{font-size:14px;text-align:center;line-height:1}',
'.dd-up-ok{color:#3da66a}',
'.dd-up-warn{color:#d39e00}',
'.dd-up-new{color:#4a8cff}',
'.dd-up-err{color:#d96d6d}',
'.dd-up-name{font-weight:600;opacity:.85}',
'.dd-up-meta{opacity:.7;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.dd-up-version{font-size:11px;opacity:.55;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:nowrap}',
'.dd-up-btn{font-size:11px;line-height:1.4;min-height:0;height:auto;padding:4px 12px;border-radius:5px;border:1px solid rgba(128,128,128,.35);background:transparent;color:inherit;cursor:pointer;white-space:nowrap}',
'.dd-up-btn:hover{background:rgba(128,128,128,.12)}',
'.dd-up-btn:disabled{opacity:.45;cursor:not-allowed}',
'.dd-up-btn-primary{border-color:#4aa065;color:#4aa065}',
'.dd-backup-buttons{display:flex;align-items:center;gap:8px}',
'.dd-backup-reset{border-color:rgba(217,109,109,.55);color:#d96d6d}',
'@media(max-width:640px){.dd-backup-row{grid-template-columns:24px minmax(0,1fr)}.dd-backup-row .dd-up-meta{grid-column:2;white-space:normal}.dd-backup-row>span:nth-child(4){display:none}.dd-backup-buttons{grid-column:2;flex-wrap:wrap}}',
'.dd-geo-row{display:grid;grid-template-columns:96px 1fr;gap:10px;align-items:center;font-size:12px;padding:6px 0}',
'.dd-geo-row label{opacity:.75;font-weight:600}',
'.dd-geo-row input[type=text],.dd-geo-row select{font-size:12px;padding:4px 8px;border:1px solid rgba(128,128,128,.35);border-radius:5px;background:transparent;color:inherit;width:100%}',
/* selects cap at 200px on wide screens, shrink to the column on mobile (no overflow) */
'.dd-geo-row select{width:100%!important;max-width:200px;box-sizing:border-box}',
'.dd-geo-actions{margin-top:10px;display:flex;gap:10px;align-items:center}',
'.dd-up-log{margin-top:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11px;padding:10px;border:1px solid rgba(128,128,128,.14);border-radius:8px;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-all;display:none;background:inherit;color:#4a8c63}',
'.dd-up-log.show{display:block}',
'body.dark .dd-up-log,html[data-theme="dark"] .dd-up-log,html[data-bs-theme="dark"] .dd-up-log{color:#a3d9ad}',
/* accordion — same structure as config.js dd-adv */
'.dd-adv{margin-bottom:14px}',
'.dd-adv-bar{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;cursor:pointer;user-select:none;font-size:11.5px;font-weight:600;background:rgba(128,128,128,.04);border:1px solid rgba(0,0,0,.06);border-radius:10px;color:inherit;letter-spacing:.3px;text-transform:uppercase;opacity:.7;box-shadow:0 2px 8px rgba(0,0,0,.03)}',
'.dd-adv-bar:hover{background:rgba(56,134,161,.06);opacity:.95}',
'.dd-adv-chevron{font-size:14px;font-weight:700;opacity:.55;transition:transform .2s;text-transform:none}',
'.dd-adv:not(.dd-closed) .dd-adv-chevron{transform:rotate(90deg)}',
'.dd-adv-body{margin-top:8px;padding:10px 14px;border:1px solid rgba(0,0,0,.06);border-radius:10px;background:rgba(255,255,255,.02);box-shadow:0 2px 8px rgba(0,0,0,.03)}',
'.dd-adv.dd-closed{margin-bottom:14px}',
'.dd-adv.dd-closed .dd-adv-body{display:none}',
'body.dark .dd-card,html[data-theme="dark"] .dd-card,html[data-bs-theme="dark"] .dd-card,body.dark .dd-adv-bar,body.dark .dd-adv-body,html[data-theme="dark"] .dd-adv-bar,html[data-theme="dark"] .dd-adv-body,html[data-bs-theme="dark"] .dd-adv-bar,html[data-bs-theme="dark"] .dd-adv-body{border-color:rgba(255,255,255,.08);background:rgba(255,255,255,.02)}'
].join('');
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`: split into numeric / non-numeric
// chunks and compare chunk by chunk (numeric chunks numerically). Returns
// <0 if a<b, 0 if equal, >0 if a>b. Used so a stale third-party feed that
// happens to provide an OLDER build (e.g. compile-jell) is never offered as an
// "upgrade" over a newer locally installed package.
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;
}
function probeFile(path) {
return L.resolveDefault(fs.stat(path), null).then(function(st) {
return { exists: !!st, size: st ? st.size : 0, mtime: st ? st.mtime : 0 };
});
}
function probePkg(pkg) {
return fs.exec('/usr/share/luci-app-daede/pkg-info.sh', [pkg]).then(function(res) {
const out = (res.stdout || '').trim().split('\t');
return { installed: out[0] || '', latest: out[1] || '' };
}).catch(function() {
return { installed: '', latest: '' };
});
}
// YYYYMMDD-HHMM stamp for backup filenames
function stamp() {
const d = new Date(), p = n => (n < 10 ? '0' : '') + n;
return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes());
}
return view.extend({
load: function() {
// background apk update so new versions show on next poll
fs.exec('/usr/share/luci-app-daede/refresh-index.sh', []).catch(function() {});
return Promise.all([
backend.detectBackend(),
uci.load('daed').catch(function() {}),
uci.load('daede').catch(function() {})
]).then(function(r) {
return r[0];
});
},
render: function(ctx) {
const logPane = E('pre', { 'class': 'dd-up-log', 'id': 'dd-up-log' }, '');
const corePkgs = [];
const addCorePkg = function(name) {
if (corePkgs.indexOf(name) === -1)
corePkgs.push(name);
};
addCorePkg(ctx.name);
addCorePkg('daed');
addCorePkg('dae');
const dataBody = E('div', { 'id': 'dd-up-data' }, E('em', {}, _('Probing…')));
const pkgBody = E('div', { 'id': 'dd-up-pkg' }, E('em', {}, _('Probing…')));
const healthBody = E('div', { 'id': 'dd-up-health' }, E('em', {}, _('Probing…')));
const mkRow = function(icon, iconCls, name, meta, btn) {
return E('div', { 'class': 'dd-up-row' }, [
E('span', { 'class': 'dd-up-icon ' + iconCls }, icon),
E('span', { 'class': 'dd-up-name' }, name),
E('span', { 'class': 'dd-up-meta', 'title': meta }, meta),
E('span', {}, ''),
btn || E('span', {}, '')
]);
};
// run a backgrounded script and stream its log into the log pane until it
// logs a final ✓/✗ status line; then refresh the badges. Inline, no popup.
const runJob = function(script, arg, btn, logPath) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
let tries = 0;
const poll = function() {
return L.resolveDefault(fs.read_direct(logPath, 'text'), '').then(function(c) {
if (c) { logPane.textContent = c; logPane.classList.add('show'); }
if (/[✓✗]/.test(c)) { refresh(); return; }
if (tries++ > 90) return;
return new Promise(function(r) { setTimeout(r, 2000); }).then(poll);
});
};
return fs.exec('/usr/share/luci-app-daede/' + script, [arg]).then(function(res) {
if (res.code === 0) return poll();
}).catch(function() {}).finally(function() {
btn.disabled = false;
btn.textContent = orig;
});
};
// === Data updates (GeoIP / GeoSite) ===
const updateGeo = function(kind, btn) {
return runJob('update-geo.sh', kind, btn, '/tmp/luci-app-daede.' + kind + '.log');
};
// === Package updates (dae|daed / luci-app-daede) ===
const upgradePkg = function(pkg, btn) {
return runJob('update-pkg.sh', pkg, btn, '/tmp/luci-app-daede.pkg-' + pkg + '.log');
};
// Configuration operations share a backend lock and one UI busy state.
const backupScript = '/usr/share/luci-app-daede/config-backup.sh';
const exportBtn = E('button', { 'class': 'dd-up-btn', 'type': 'button' }, _('Export'));
const importBtn = E('button', { 'class': 'dd-up-btn', 'type': 'button' }, _('Import'));
const resetBtn = E('button', { 'class': 'dd-up-btn dd-backup-reset', 'type': 'button' }, _('Restore Defaults'));
const fileInput = E('input', { 'type': 'file', 'accept': '.tar.gz,.gz,application/gzip', 'style': 'display:none' });
let backupBusy = false;
const showBackupError = function(e) {
ui.addNotification(null, E('p', {}, e.message || String(e)), 'error');
};
const configOperation = function(button, operation) {
if (backupBusy) return Promise.resolve();
backupBusy = true;
const label = button.textContent;
[exportBtn, importBtn, resetBtn].forEach(function(b) { b.disabled = true; });
button.textContent = _('Working…');
return uci.changes().then(function(changes) {
if (['dae', 'daed', 'daede'].some(function(name) { return changes && changes[name] && changes[name].length; }))
throw new Error(_('Apply or discard pending daede changes first.'));
return operation();
}).catch(showBackupError).finally(function() {
backupBusy = false;
[exportBtn, importBtn, resetBtn].forEach(function(b) { b.disabled = false; });
button.textContent = label;
fileInput.value = '';
});
};
const confirmBackup = function(message) {
return new Promise(function(resolve) {
const answer = function(value) { ui.hideModal(); resolve(value); };
ui.showModal(_('Confirm configuration operation'), [
E('p', {}, message),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': function() { answer(false); } }, _('Cancel')),
' ',
E('button', { 'class': 'cbi-button cbi-button-negative', 'click': function() { answer(true); } }, _('Continue'))
])
]);
});
};
const execBackup = function(args) {
return fs.exec(backupScript, args).then(function(res) {
if (!res || res.code !== 0)
throw new Error(String(res && (res.stderr || res.stdout) || _('Configuration operation failed.')).trim());
return res;
});
};
const waitBackup = function() {
let tries = 0;
const check = function() {
return fs.read_direct('/tmp/luci-app-daede.backup.log', 'text').then(function(content) {
logPane.textContent = content;
logPane.classList.add('show');
if (/^✗/m.test(content)) throw new Error(_('Configuration operation failed. See the log below.'));
if (/^✓/m.test(content)) return;
if (++tries > 90) throw new Error(_('Operation is still running. Check the log before retrying.'));
return new Promise(function(resolve) { setTimeout(resolve, 2000); }).then(check);
});
};
return check();
};
const reloadConfig = function(message) {
daedSession.clear(window.localStorage);
['dae', 'daed', 'daede'].forEach(function(name) { uci.unload(name); });
ui.showModal(_('Configuration restored'), [
E('p', {}, message),
E('div', { 'class': 'right' }, E('button', {
'class': 'cbi-button cbi-button-positive', 'click': function() { window.location.reload(); }
}, _('Reload Page')))
]);
};
exportBtn.addEventListener('click', async function() {
if (!await confirmBackup(_('Export briefly stops running dae/daed services to back up the database safely, then resumes them. Continue?'))) return;
configOperation(exportBtn, function() {
return execBackup(['export']).then(function(res) {
const bin = atob((res.stdout || '').trim());
if (!bin) throw new Error(_('Export failed: empty archive'));
const bytes = Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });
const url = URL.createObjectURL(new Blob([bytes], { 'type': 'application/gzip' }));
const a = E('a', { 'href': url, 'download': 'daede-config-' + stamp() + '.tar.gz' });
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
});
});
});
importBtn.addEventListener('click', function() { fileInput.click(); });
fileInput.addEventListener('change', async function(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
if (file.size > 184320) {
showBackupError(new Error(_('Backup exceeds the 180 KiB limit. Use System Backup for larger files.')));
fileInput.value = ''; return;
}
if (!await confirmBackup(_('Import replaces daede settings, nodes, subscriptions and dashboard data. Services stop first; the saved active backend starts only if enabled in the backup. Continue?'))) {
fileInput.value = ''; return;
}
configOperation(importBtn, function() {
return new Promise(function(resolve, reject) {
const reader = new FileReader();
reader.onerror = function() { reject(new Error(_('Unable to read backup file.'))); };
reader.onload = function() { resolve(String(reader.result).split(',')[1] || ''); };
reader.readAsDataURL(file);
}).then(function(b64) {
// Per-tab uploads cannot overwrite each other before the backend locks.
const token = Array.from(window.crypto.getRandomValues(new Uint8Array(16)), function(n) { return n.toString(16).padStart(2, '0'); }).join('');
const upload = '/tmp/daede-import.' + token + '.b64';
return fs.write(upload, b64).then(function() { return execBackup(['import', upload]); })
.finally(function() { return fs.remove(upload).catch(function() {}); });
}).then(waitBackup).then(function() {
reloadConfig(_('Backup restored. The active backend follows the saved enabled setting.'));
});
});
});
resetBtn.addEventListener('click', async function() {
if (!await confirmBackup(_('Restore ALL daede defaults? This clears dae/daed settings, nodes, subscriptions and daed dashboard accounts/data. Export a backup first if needed. Both backends will remain disabled. Installed programs and GeoData are kept.'))) return;
configOperation(resetBtn, function() {
return execBackup(['reset']).then(waitBackup).then(function() {
reloadConfig(_('All defaults restored. Both backends are disabled. Configure your nodes and routing before starting manually.'));
});
});
});
const refresh = function() {
const probes = [
probeFile(DATA_PATHS.geoip),
probeFile(DATA_PATHS.geosite),
probeFile(HEALTH_PATHS.btf),
probeFile(HEALTH_PATHS.btfDetached),
backend.detectRunning(),
backend.serviceStatus(ctx.name)
];
corePkgs.forEach(function(pkg) {
probes.push(probePkg(pkg));
});
probes.push(probePkg('luci-app-daede'));
if (ctx.backend.useNetns)
probes.push(probeFile(HEALTH_PATHS.netns));
return Promise.all(probes).then(function(r) {
const geoip = r[0], geosite = r[1], btf = r[2], btfDetached = r[3], running = r[4], service = r[5];
const pkgOffset = 6;
const coreInfo = {};
corePkgs.forEach(function(pkg, i) {
coreInfo[pkg] = r[pkgOffset + i];
});
const luci = r[pkgOffset + corePkgs.length];
const ns = r[pkgOffset + corePkgs.length + 1];
// data rows
while (dataBody.firstChild) dataBody.removeChild(dataBody.firstChild);
[
{ k: 'geoip', name: 'GeoIP', r: geoip },
{ k: 'geosite', name: 'GeoSite', r: geosite }
].forEach(function(entry) {
const btn = E('button', { 'class': 'dd-up-btn dd-up-btn-primary' }, _('Update'));
btn.addEventListener('click', function() { 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(mkRow(
entry.r.exists ? '✓' : '✗',
entry.r.exists ? 'dd-up-ok' : 'dd-up-err',
entry.name,
meta,
btn
));
});
// pkg rows
while (pkgBody.firstChild) pkgBody.removeChild(pkgBody.firstChild);
corePkgs.map(function(pkg) {
const label = _('%s binary').format(pkg) + (ctx.name === pkg ? ' · ' + _('active') : '');
return { k: pkg, name: label, r: coreInfo[pkg] };
}).concat([
{ k: 'luci-app-daede', name: 'luci-app-daede', r: luci }
]).forEach(function(entry) {
const btn = E('button', { 'class': 'dd-up-btn dd-up-btn-primary' }, _('Upgrade'));
btn.addEventListener('click', function() { upgradePkg(entry.k, btn); });
// Only treat it as upgradable when the feed version is STRICTLY
// higher than what's installed. A lower/equal feed version (e.g.
// a stale compile-jell build) must never be offered as an upgrade.
const cmp = (entry.r.installed && entry.r.latest)
? cmpVer(entry.r.latest, entry.r.installed) : null;
const updatable = cmp !== null && cmp > 0;
let meta;
if (!entry.r.installed) {
meta = _('not installed via package manager');
btn.disabled = true;
btn.textContent = _('Unavailable');
} else if (!entry.r.latest) {
// latest unknown (registry unreachable) - nothing to upgrade to
meta = _('installed') + ': ' + entry.r.installed + ' · ' + _('latest version unknown');
btn.disabled = true;
} else if (updatable) {
meta = _('installed') + ': ' + entry.r.installed + ' → ' + _('latest') + ': ' + entry.r.latest;
} else {
// installed >= feed: already up to date (feed may even be older)
meta = _('installed') + ': ' + entry.r.installed + ' · ' + _('up to date');
btn.disabled = true;
}
pkgBody.appendChild(mkRow(
updatable ? '↑' : (entry.r.installed ? '✓' : '✗'),
updatable ? 'dd-up-new' : (entry.r.installed ? 'dd-up-ok' : 'dd-up-err'),
entry.name,
meta,
btn
));
});
// health rows
while (healthBody.firstChild) healthBody.removeChild(healthBody.firstChild);
healthBody.appendChild(mkRow(
running[ctx.name] ? '✓' : '✗',
running[ctx.name] ? 'dd-up-ok' : 'dd-up-err',
_('%s process').format(ctx.name),
running[ctx.name] ? _('pgrep reports the backend is running') : _('backend process is not running')
));
if (btf.exists)
healthBody.appendChild(mkRow('✓', 'dd-up-ok', _('Kernel BTF'), HEALTH_PATHS.btf + ' · ' + fmtBytes(btf.size)));
else if (btfDetached.exists)
healthBody.appendChild(mkRow('✓', 'dd-up-ok', _('Kernel BTF'), HEALTH_PATHS.btfDetached + ' · ' + fmtBytes(btfDetached.size)));
else
healthBody.appendChild(mkRow('✗', 'dd-up-err', _('Kernel BTF'), _('not available — eBPF needs CONFIG_DEBUG_INFO_BTF or vmlinux-btf')));
if (ctx.backend.hasWebUI) {
const listenAddr = uci.get('daed', 'config', 'listen_addr') || ctx.backend.defaultListen;
const port = listenAddr.split(':').slice(-1)[0];
healthBody.appendChild(mkRow(
service.running ? '✓' : '✗',
service.running ? 'dd-up-ok' : 'dd-up-err',
_('WebUI/API'),
service.running ? _('service is running, expected on port %s').format(port) : _('service is stopped')
));
} else {
healthBody.appendChild(mkRow('✓', 'dd-up-ok', _('Hot reload'), _('/etc/init.d/dae hot_reload is available')));
}
if (ctx.backend.useNetns && ns && ns.exists) {
const btn = E('button', { 'class': 'dd-up-btn' }, _('Clean'));
btn.addEventListener('click', function() {
const daedRunning = !!(running && running[ctx.name]);
const msg = daedRunning
? _('%s is running. Deleting the daens netns now will break its networking until you restart it. Continue?').format(ctx.name)
: _('Delete the daens netns?');
if (!confirm(msg))
return;
btn.disabled = true;
fs.exec('/sbin/ip', ['netns', 'del', 'daens']).finally(function() { btn.disabled = false; });
});
healthBody.appendChild(mkRow('⚠', 'dd-up-warn', _('netns daens'), HEALTH_PATHS.netns + ' · ' + _('exists, may block daed start'), btn));
} else if (ctx.backend.useNetns) {
healthBody.appendChild(mkRow('✓', 'dd-up-ok', _('netns daens'), HEALTH_PATHS.netns + ' · ' + _('clean')));
}
});
};
poll.add(refresh);
refresh();
// === Geo data source (preset / custom URL + auto-update) ===
const geoSettings = (function() {
const gi0 = uci.get('daede', 'config', 'geoip_url') || '';
const gs0 = uci.get('daede', 'config', 'geosite_url') || '';
const auto0 = uci.get('daede', 'config', 'geo_auto') === '1';
const freq0 = uci.get('daede', 'config', 'geo_auto_freq') || 'daily';
const preset0 = currentPreset(gi0, gs0);
const presetSel = E('select', {}, [
E('option', { 'value': 'loyalsoldier' }, 'Loyalsoldier'),
E('option', { 'value': 'v2fly' }, 'v2fly'),
E('option', { 'value': 'custom' }, _('Custom'))
]);
presetSel.value = preset0;
const giInput = E('input', { 'type': 'text', 'placeholder': 'https://…/geoip.dat' });
const gsInput = E('input', { 'type': 'text', 'placeholder': 'https://…/geosite.dat' });
giInput.value = preset0 === 'custom' ? gi0 : '';
gsInput.value = preset0 === 'custom' ? gs0 : '';
const customRows = E('div', {}, [
E('div', { 'class': 'dd-geo-row' }, [ E('label', {}, 'GeoIP URL'), giInput ]),
E('div', { 'class': 'dd-geo-row' }, [ E('label', {}, 'GeoSite URL'), gsInput ])
]);
const syncCustom = function() {
customRows.style.display = presetSel.value === 'custom' ? '' : 'none';
};
presetSel.addEventListener('change', syncCustom);
syncCustom();
const autoSel = E('select', {}, [
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': 'dd-up-btn dd-up-btn-primary' }, _('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(); }
uci.set('daede', 'config', 'geoip_url', gi);
uci.set('daede', 'config', 'geosite_url', gs);
uci.set('daede', 'config', 'geo_auto', geoAuto ? '1' : '0');
uci.set('daede', 'config', 'geo_auto_freq', geoFreq);
const orig = saveBtn.textContent;
saveBtn.disabled = true; saveBtn.textContent = '...';
uci.save().then(function() {
return uci.changes();
}).then(function(changes) {
// apply only when changed; empty changeset fails apply (#16)
if (changes && Object.keys(changes).length)
return uci.apply();
}).then(function() {
return fs.exec('/usr/share/luci-app-daede/geo-cron.sh', [geoAuto ? 'enable' : 'disable']);
}).then(function() {
logPane.textContent = _('Geo data source saved.');
logPane.classList.add('show');
ui.changes.init();
}).catch(function(e) {
logPane.textContent = _('Save failed') + ': ' + (e && e.message ? e.message : e);
logPane.classList.add('show');
}).finally(function() {
saveBtn.disabled = false; saveBtn.textContent = orig;
});
});
const adv = E('div', { 'class': 'dd-adv dd-closed' }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, _('Data Source')),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
E('div', { 'class': 'dd-adv-body' }, [
E('div', { 'class': 'dd-geo-row' }, [ E('label', {}, _('Source')), presetSel ]),
customRows,
E('div', { 'class': 'dd-geo-row' }, [
E('label', {}, _('Auto-update')),
autoSel
]),
E('div', { 'class': 'dd-geo-actions' }, [ saveBtn ])
])
]);
adv.firstChild.addEventListener('click', function() {
adv.classList.toggle('dd-closed');
});
return adv;
})();
return E('div', { 'class': 'dd-up-wrap' }, [
E('style', {}, CSS),
E('div', { 'class': 'dd-card' }, [
E('h4', { 'class': 'dd-card-title' }, _('Data Updates')),
dataBody,
geoSettings
]),
E('div', { 'class': 'dd-card' }, [
E('h4', { 'class': 'dd-card-title' }, _('Package Updates')),
pkgBody
]),
E('div', { 'class': 'dd-card' }, [
E('h4', { 'class': 'dd-card-title' }, _('Config Backup')),
E('div', { 'class': 'dd-up-row dd-backup-row' }, [
E('span', { 'class': 'dd-up-icon dd-up-new' }, '⇩'),
E('span', { 'class': 'dd-up-name' }, _('dae + daed')),
E('span', {
'class': 'dd-up-meta',
'title': _('Backups contain account and subscription credentials. Store them securely.')
}, _('Back up / restore the whole daede config (kernels excluded)')),
E('span', {}, ''),
E('div', { 'class': 'dd-backup-buttons' }, [exportBtn, importBtn, resetBtn])
]),
fileInput
]),
(function() {
const adv = E('div', { 'class': 'dd-adv dd-closed' }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, _('System Health')),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
E('div', { 'class': 'dd-adv-body' }, [ healthBody ])
]);
adv.firstChild.addEventListener('click', function() {
adv.classList.toggle('dd-closed');
});
return adv;
})(),
logPane
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -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
@@ -0,0 +1,392 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
'require fs';
'require poll';
'require view.daede.backend as backend';
function notifyAction() {}
function execChecked(command, args) {
return fs.exec(command, args || []).then(function(res) {
if (res && res.code !== 0)
throw new Error((res.stderr || res.stdout || ('exit ' + res.code)).trim());
return res;
});
}
function execInit(be, action) {
return fs.exec(be.initd, [action]).then(function(res) {
notifyAction(action, res);
}).catch(function() {});
}
function restartService(be) {
return execChecked(be.initd, ['restart'])
.then(function() { return waitForService(be, true, 80); });
}
function rejectIfOtherRunning(be, running) {
const other = be.name === 'dae' ? 'daed' : 'dae';
if (!running || !running[other])
return Promise.resolve();
return Promise.reject(new Error(_('%s is already running. Stop %s before starting %s because both backends share the same eBPF/cgroup attachment.').format(other, other, be.name)));
}
function toggleService(be, turnOn) {
const enabled = turnOn ? '1' : '0';
const action = turnOn ? 'start' : 'stop';
// Only guard on start: two backends share the eBPF/cgroup attachment, so
// starting one while the other runs is unsafe. Stopping is always allowed.
const guard = turnOn
? backend.detectRunning().then(function(running) { return rejectIfOtherRunning(be, running); })
: Promise.resolve();
return guard
.then(function() { return execChecked('/sbin/uci', ['set', be.uci + '.config.enabled=' + enabled]); })
.then(function() { return execChecked('/sbin/uci', ['commit', be.uci]); })
.then(function() {
if (turnOn)
return execChecked(be.initd, ['enable']);
return execChecked(be.initd, ['disable']);
})
.then(function() {
if (turnOn && be.useNetns)
return fs.exec('/sbin/ip', ['netns', 'del', 'daens']).catch(function() {});
})
.then(function() { return execChecked(be.initd, [action]); });
}
function waitForService(be, turnOn, attempts) {
return backend.serviceStatus(be.name).then(function(state) {
if (!!state.running === turnOn)
return state;
if (attempts <= 0)
throw new Error(turnOn ? _('Service did not start in time.') : _('Service did not stop in time.'));
return new Promise(function(resolve) { setTimeout(resolve, 250); })
.then(function() { return waitForService(be, turnOn, attempts - 1); });
});
}
function renderBackendSwitcher(ctx, onSwitched, initialHint) {
if (!ctx.installed.dae && !ctx.installed.daed)
return null;
const wrap = E('div', { 'class': 'dd-card dd-backend-card' }, [
E('h4', { 'class': 'dd-card-title' }, _('Backend')),
E('div', { 'class': 'dd-backend-row' }, [
E('span', { 'class': 'dd-backend-label' }, _('Active backend')),
E('div', { 'class': 'dd-backend-segment' })
])
]);
const segment = wrap.querySelector('.dd-backend-segment');
const hint = E('div', { 'class': 'dd-backend-help' }, initialHint || '');
let busy = false;
const showHint = function(msg) {
hint.textContent = msg;
};
const stopIfRunning = function(running) {
const stops = [];
['dae', 'daed'].forEach(function(name) {
if (running && running[name])
stops.push(execChecked(backend.BACKENDS[name].initd, ['stop'])
.then(function() { return waitForService(backend.BACKENDS[name], false, 40); }));
});
if (stops.length)
showHint(_('Stopping current backend…'));
return Promise.all(stops);
};
['daed', 'dae'].forEach(function(name) {
const active = ctx.name === name;
const installed = !!ctx.installed[name];
const btn = E('button', {
'class': 'dd-backend-btn' + (active ? ' is-active' : ''),
'type': 'button',
'disabled': null,
'title': installed ? _('Switch to %s').format(name) : _('%s is not installed').format(name)
}, [
E('span', {}, name),
E('span', { 'class': 'dd-backend-state' }, active ? _('Active') : (installed ? '' : _('Not installed')))
]);
btn.addEventListener('click', function(ev) {
ev.preventDefault();
if (busy || active)
return;
if (!installed) {
showHint(_('%s is not installed. Install it first, then switch backend.').format(name));
return;
}
busy = true;
Array.prototype.forEach.call(segment.querySelectorAll('button'), function(b) { b.disabled = true; });
showHint(_('Switching to %s…').format(name));
backend.detectRunning()
.then(stopIfRunning)
.then(function() { return backend.setActiveBackend(name); })
.then(function() {
const message = _('Switched to %s. Click start when you want it to run.').format(name);
showHint(message);
return onSwitched ? onSwitched(name, message) : null;
})
.catch(function(e) {
busy = false;
Array.prototype.forEach.call(segment.querySelectorAll('button'), function(b) { b.disabled = false; });
showHint(_('Switch failed.'));
});
});
segment.appendChild(btn);
});
wrap.appendChild(hint);
return wrap;
}
function renderStatusCard(ctx, listenAddr) {
const be = ctx.backend;
let busy = false;
let lastError = '';
let refreshGeneration = 0;
const probeState = {
running: false,
text: '',
kind: '',
visibleUntil: 0,
hideTimer: null
};
const body = E('div', { 'id': 'dd-status-body' }, E('em', {}, _('Collecting data…')));
const card = E('div', { 'class': 'dd-card dd-status-card' }, [
E('h4', { 'class': 'dd-card-title' }, _('Service Status')),
body
]);
const render = function(state) {
while (body.firstChild) body.removeChild(body.firstChild);
const badge = state.running
? E('span', { 'class': 'dd-badge dd-badge-run' }, [ E('span', { 'class': 'dd-badge-dot' }), _('RUNNING') ])
: E('span', { 'class': 'dd-badge dd-badge-stop' }, [ E('span', { 'class': 'dd-badge-dot' }), _('STOPPED') ]);
const meta = [
E('span', { 'class': 'dd-meta' }, [ E('span', { 'class': 'dd-meta-label' }, _('Backend')), be.name ])
];
if (state.running && state.pid)
meta.push(E('span', { 'class': 'dd-meta' }, [ E('span', { 'class': 'dd-meta-label' }, 'PID'), state.pid ]));
const swErr = E('span', { 'class': 'dd-meta dd-err', 'style': lastError ? '' : 'display:none' }, lastError);
const sw = E('button', { 'class': 'dd-switch' + (state.running ? ' is-on' : ''), 'type': 'button', 'aria-label': _('Toggle service') }, [
E('span', { 'class': 'dd-switch-knob' })
]);
sw.addEventListener('click', function(ev) {
ev.preventDefault();
if (busy) return;
refreshGeneration++;
busy = true;
sw.disabled = true;
lastError = '';
swErr.style.display = 'none';
const turnOn = !state.running;
/* instant optimistic feedback — the start/stop chain (esp. dae's eBPF
load) takes a few seconds; flip the switch and show a pending label
right away instead of looking frozen */
sw.classList.toggle('is-on', turnOn);
const lbl = sw.parentNode && sw.parentNode.querySelector('.dd-switch-label');
if (lbl) lbl.textContent = '…';
toggleService(be, turnOn)
.then(function() { return waitForService(be, turnOn, 40); })
.then(function() {
busy = false;
return refresh(true);
})
.catch(function(e) {
busy = false;
lastError = _('Toggle failed: %s').format(e.message || e);
return refresh(true);
});
});
/* line 1: badge + toggle (always aligned, never wraps); line 2: meta */
const row = E('div', { 'class': 'dd-status-row' }, [
badge,
E('span', { 'class': 'dd-grow' }),
swErr,
E('span', { 'class': 'dd-switch-wrap' }, [
E('span', { 'class': 'dd-switch-label' }, state.running ? 'ON' : 'OFF'),
sw
])
]);
body.appendChild(row);
if (meta.length)
body.appendChild(E('div', { 'class': 'dd-status-meta' }, meta));
const actions = [];
if (be.hasWebUI && state.running) {
const port = (listenAddr || be.defaultListen).split(':').slice(-1)[0];
actions.push(E('a', {
'class': 'cbi-button cbi-button-action',
'href': 'http://%s:%s'.format(window.location.hostname, port),
'target': '_blank',
'rel': 'noreferrer noopener'
}, _('Open WebUI')));
}
if (state.running) {
const restart = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Restart'));
restart.addEventListener('click', function(ev) {
ev.preventDefault();
if (busy) return;
busy = true;
restart.disabled = true;
lastError = '';
swErr.style.display = 'none';
restartService(be)
.then(function() {
busy = false;
return refresh(true);
})
.catch(function(e) {
busy = false;
lastError = _('Restart failed: %s').format(e.message || e);
return refresh(true);
});
});
actions.push(restart);
}
if (state.running && be.name === 'dae') {
const hot = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Hot Reload'));
hot.addEventListener('click', function(ev) {
ev.preventDefault();
hot.disabled = true;
execInit(be, 'hot_reload').finally(function() { hot.disabled = false; });
});
actions.push(hot);
}
if (state.running && be.name === 'dae') {
const ckBtn = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Test YouTube'));
const probeVisible = probeState.running || probeState.visibleUntil > Date.now();
const ckRes = E('span', {
'class': 'dd-meta' + (probeState.kind ? ' ' + probeState.kind : ''),
style: 'margin-left:8px;' + (probeVisible ? '' : 'display:none')
}, probeState.text);
ckBtn.disabled = probeState.running;
ckBtn.addEventListener('click', function(ev) {
ev.preventDefault();
if (probeState.running)
return;
if (probeState.hideTimer) {
clearTimeout(probeState.hideTimer);
probeState.hideTimer = null;
}
probeState.running = true;
probeState.text = _('Testing…');
probeState.kind = '';
probeState.visibleUntil = 0;
ckBtn.disabled = true;
ckRes.style.display = 'inline';
ckRes.classList.remove('dd-ok', 'dd-err');
ckRes.textContent = probeState.text;
/* actually probe YouTube through dae's transparent proxy */
fs.exec('/usr/share/luci-app-daede/proxy-check.sh', []).then(function(r) {
const out = (r && r.stdout) || '';
const ok = /\bok=1\b/.test(out);
const code = (out.match(/code=(\S+)/) || [])[1] || '000';
const ms = parseInt((out.match(/ms=(\d+)/) || [])[1] || '0');
if (ok) {
probeState.kind = 'dd-ok';
probeState.text = _('YouTube reachable · %d ms').format(ms);
} else {
probeState.kind = 'dd-err';
probeState.text = (code === '000')
? _('YouTube unreachable (timeout)')
: _('YouTube unreachable (HTTP %s)').format(code);
}
}).catch(function() {
probeState.kind = 'dd-err';
probeState.text = _('YouTube unreachable (timeout)');
}).finally(function() {
probeState.running = false;
probeState.visibleUntil = Date.now() + 8000;
refresh(true);
probeState.hideTimer = setTimeout(function() {
probeState.text = '';
probeState.kind = '';
probeState.visibleUntil = 0;
probeState.hideTimer = null;
refresh(true);
}, 8000);
});
});
actions.push(ckBtn);
actions.push(ckRes);
}
if (actions.length)
body.appendChild(E('div', { 'class': 'dd-actions' }, actions));
};
const refresh = function(force) {
if (busy && !force)
return Promise.resolve();
const generation = refreshGeneration;
return backend.serviceStatus(be.name).then(function(state) {
if (generation !== refreshGeneration || (busy && !force)) return;
render(state);
});
};
poll.add(refresh);
refresh();
card._ddCleanup = function() {
poll.remove(refresh);
if (probeState.hideTimer)
clearTimeout(probeState.hideTimer);
};
return card;
}
function wrapSettingsCard(title, descr, mapPromise, advTitle, advFields) {
return mapPromise.then(function(node) {
const advBody = E('div', { 'class': 'dd-adv-body' });
const advWrap = E('div', { 'class': 'dd-adv dd-closed' }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, advTitle),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
advBody
]);
advWrap.firstChild.addEventListener('click', function() {
advWrap.classList.toggle('dd-closed');
});
(advFields || []).forEach(function(name) {
const row = node.querySelector('[data-name="' + name + '"]');
if (row) advBody.appendChild(row);
});
const host = node.querySelector('.cbi-section') || node;
if (advBody.firstChild) host.appendChild(advWrap);
const children = [ E('h4', { 'class': 'dd-card-title' }, title) ];
if (descr) children.push(E('div', { 'class': 'dd-settings-descr' }, descr));
children.push(node);
return E('div', { 'class': 'dd-card dd-settings-card' }, children);
});
}
return baseclass.extend({
renderStatusCard: renderStatusCard,
renderBackendSwitcher: renderBackendSwitcher,
wrapSettingsCard: wrapSettingsCard
});
+899
View File
@@ -0,0 +1,899 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
msgid "Service Status"
msgstr ""
msgid "Collecting data…"
msgstr ""
msgid "RUNNING"
msgstr ""
msgid "STOPPED"
msgstr ""
msgid "Backend"
msgstr ""
msgid "Listen"
msgstr ""
msgid "Toggle service"
msgstr ""
msgid "Open WebUI"
msgstr ""
msgid "Restart"
msgstr ""
msgid "Hot Reload"
msgstr ""
msgid "Active backend"
msgstr ""
msgid "Active"
msgstr ""
msgid "Not installed"
msgstr ""
msgid "Switch to %s"
msgstr ""
msgid "%s is not installed"
msgstr ""
msgid "%s is not installed. Install it first, then switch backend."
msgstr ""
msgid "Stopping current backend…"
msgstr ""
msgid "Switching to %s…"
msgstr ""
msgid "Switched to %s. Click start when you want it to run."
msgstr ""
msgid "Backend switched to %s. Click start to run it."
msgstr ""
msgid "Switch failed."
msgstr ""
msgid "Failed to switch backend: %s"
msgstr ""
msgid "Action \"%s\" error: %s"
msgstr ""
msgid "Action \"%s\" failed: %s"
msgstr ""
msgid "Action \"%s\" succeeded."
msgstr ""
msgid "Toggle failed: %s"
msgstr ""
msgid "Service did not start in time."
msgstr ""
msgid "Service did not stop in time."
msgstr ""
msgid "%s is already running. Stop %s before starting %s because both backends share the same eBPF/cgroup attachment."
msgstr ""
msgid "daede Settings"
msgstr ""
msgid "Listen Address"
msgstr ""
msgid "Max Log Size (MB)"
msgstr ""
msgid "Rotate the log file once it grows past this many megabytes."
msgstr ""
msgid "Max Log Backups"
msgstr ""
msgid "Number of rotated log files to keep."
msgstr ""
msgid "daed Username"
msgstr ""
msgid "Used by LuCI to access daed (subscription updates and import). If daed is not initialized yet, this account is used to create the daed admin."
msgstr ""
msgid "daed Password"
msgstr ""
msgid "Enable subscription auto-update"
msgstr ""
msgid "Update Cycle"
msgstr ""
msgid "Update Time"
msgstr ""
msgid "Update subscriptions now"
msgstr ""
msgid "Updating daed subscriptions…"
msgstr ""
msgid "Subscriptions updated"
msgstr ""
msgid "Subscription update failed: %s"
msgstr ""
msgid "Log Advanced Settings"
msgstr ""
msgid "dae Settings"
msgstr ""
msgid "eBPF-based Linux high-performance transparent proxy solution."
msgstr ""
msgid "Configuration File"
msgstr ""
msgid "dae Configuration"
msgstr ""
msgid "Text-only mode."
msgstr ""
msgid "dae has no built-in WebUI. Edit the config DSL below by hand (subscriptions, nodes, routing, DNS). \"Initialize from example\" loads the bundled template — replace the placeholder subscription URL with your own before saving. For point-and-click management, switch backend to daed."
msgstr ""
msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here."
msgstr ""
msgid "Save and Hot Reload"
msgstr ""
msgid "Initialize from example"
msgstr ""
msgid "+ Insert Block…"
msgstr ""
msgid "Insert config block at cursor"
msgstr ""
msgid "lines"
msgstr ""
msgid "⚠ %d placeholder"
msgstr ""
msgid "⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL"
msgstr ""
msgid " / "
msgstr ""
msgid "这些 "
msgstr ""
msgid " 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。"
msgstr ""
msgid "Placeholder lines (click line number to jump):"
msgstr ""
msgid "Jump to line"
msgstr ""
msgid "Validating…"
msgstr ""
msgid "Validate failed: %s"
msgstr ""
msgid "Saving…"
msgstr ""
msgid "Reload failed: %s"
msgstr ""
msgid "Validated · saved · hot-reloaded"
msgstr ""
msgid "Save failed: %s"
msgstr ""
msgid "Loading example…"
msgstr ""
msgid "Example loaded"
msgstr ""
msgid "Init failed: %s"
msgstr ""
msgid "Selected backend is not installed. Install dae or daed from the package feed first."
msgstr ""
msgid "Loading…"
msgstr ""
msgid "Log file does not exist yet."
msgstr ""
msgid "Error reading log: %s"
msgstr ""
msgid "Log file cleared."
msgstr ""
msgid "Failed to clear log: %s"
msgstr ""
msgid "Truncate %s log on the router? This cannot be undone."
msgstr ""
msgid "Settings"
msgstr ""
msgid "Updates"
msgstr ""
msgid "Log"
msgstr ""
msgid "Data Updates"
msgstr ""
msgid "Package Updates"
msgstr ""
msgid "System Health"
msgstr ""
msgid "Update"
msgstr ""
msgid "Upgrade"
msgstr ""
msgid "Clean"
msgstr ""
msgid "missing — click Update to fetch"
msgstr ""
msgid "%s download started — will refresh in 1–3 min."
msgstr ""
msgid "%s update already running."
msgstr ""
msgid "%s update failed: %s"
msgstr ""
msgid "%s update error: %s"
msgstr ""
msgid "%s binary"
msgstr ""
msgid "active"
msgstr ""
msgid "installed"
msgstr ""
msgid "latest"
msgstr ""
msgid "up to date"
msgstr ""
msgid "not installed via package manager"
msgstr ""
msgid "Unavailable"
msgstr ""
msgid "Run \"apk upgrade %s\" now? This may restart the active backend."
msgstr ""
msgid "%s upgrade started — see log below."
msgstr ""
msgid "%s upgrade already running."
msgstr ""
msgid "%s upgrade failed: %s"
msgstr ""
msgid "%s upgrade error: %s"
msgstr ""
msgid "Probing…"
msgstr ""
msgid "%s process"
msgstr ""
msgid "Kernel BTF"
msgstr ""
msgid "WebUI/API"
msgstr ""
msgid "Hot reload"
msgstr ""
msgid "netns daens"
msgstr ""
msgid "pgrep reports the backend is running"
msgstr ""
msgid "backend process is not running"
msgstr ""
msgid "service is running, expected on port %s"
msgstr ""
msgid "service is stopped"
msgstr ""
msgid "not available — eBPF needs CONFIG_DEBUG_INFO_BTF or vmlinux-btf"
msgstr ""
msgid "/etc/init.d/dae hot_reload is available"
msgstr ""
msgid "netns daens removed."
msgstr ""
msgid "exists, may block daed start"
msgstr ""
msgid "clean"
msgstr ""
msgid "%s Realtime Log"
msgstr ""
msgid "Auto-scroll"
msgstr ""
msgid "Pause"
msgstr ""
msgid "Filter (substring)"
msgstr ""
msgid "Clear View"
msgstr ""
msgid "Clear File"
msgstr ""
msgid "Download"
msgstr ""
msgid "Stop daed"
msgstr ""
msgid "Stop daed now?"
msgstr ""
msgid "daed stopped"
msgstr ""
msgid "Failed to stop daed: %s"
msgstr ""
#: dae.js
msgid "One node share link per line — ss, vmess, vless, trojan, tuic, hysteria2, socks5."
msgstr ""
#: dae.js
msgid "⚠ Still needs a subscription link: %d placeholder URL line(s) detected"
msgstr ""
#: dae.js
msgid "These "
msgstr ""
#: dae.js
msgid " are placeholder addresses from dae's template, not real subscriptions. Replace the URL on any one line with your airport subscription link (delete the spare lines) and save."
msgstr ""
#: dae.js
msgid "Manual nodes"
msgstr ""
#: dae.js
msgid "Update all subscriptions"
msgstr ""
#: dae.js
msgid "Advanced settings"
msgstr ""
#: dae.js
msgid "auto-generated"
msgstr ""
#: dae.js
msgid "Save and Apply"
msgstr ""
#: dae.js
msgid "Save config"
msgstr ""
#: dae.js
msgid "Save and Start"
msgstr ""
#: dae.js
msgid "Saved · applied"
msgstr ""
#: dae.js
msgid "Saved"
msgstr ""
#: dae.js
msgid "Saved · started"
msgstr ""
#: dae.js
msgid "Starting…"
msgstr ""
#: dae.js
msgid "Name is required"
msgstr ""
#: dae.js
msgid "Group name must be unique"
msgstr ""
#: dae.js
msgid "Updating…"
msgstr ""
#: dae.js
msgid "Subscriptions updated (dae reloaded)"
msgstr ""
msgid "Subscriptions updated and applied"
msgstr ""
#: dae.js
msgid "Save manual config"
msgstr ""
#: dae.js
msgid "Start failed: %s"
msgstr ""
#: converter.js
msgid "Subscription Converter"
msgstr ""
#: converter.js
msgid "Convert Clash YAML into share links, preview the result, then import selected nodes into dae or daed. Inputs and credentials are not saved."
msgstr ""
#: converter.js
msgid "1. Input Clash YAML"
msgstr ""
#: converter.js
msgid "Fetch and parse"
msgstr ""
#: converter.js
msgid "Subscription User-Agent"
msgstr ""
#: converter.js
msgid "Auto User-Agent (recommended)"
msgstr ""
#: converter.js
msgid "Browser User-Agent"
msgstr ""
msgid "Fetcher returned an invalid response"
msgstr ""
msgid "Failed to read subscription data"
msgstr ""
#: converter.js
msgid "Or paste Clash YAML here"
msgstr ""
#: converter.js
msgid "Parse pasted YAML"
msgstr ""
#: converter.js
msgid "2. Conversion Preview"
msgstr ""
#: converter.js
msgid "Select compatible new nodes"
msgstr ""
#: converter.js
msgid "Clear selection"
msgstr ""
#: converter.js
msgid "Filter name or protocol"
msgstr ""
#: converter.js
msgid "3. Import Selected Nodes"
msgstr ""
#: converter.js
msgid "Target backend"
msgstr ""
#: converter.js
msgid "Import selected nodes (%d)"
msgstr ""
#: converter.js
msgid "Detected %d · compatible %d · duplicates %d · incompatible %d · ignored info %d"
msgstr ""
#: converter.js
msgid "Ready to import"
msgstr ""
#: converter.js
msgid "Already exists, skipped"
msgstr ""
#: converter.js
msgid "Sign in to daed"
msgstr ""
#: converter.js
msgid "The password is not saved. Sign-in is remembered in this browser for up to 30 days."
msgstr ""
#: converter.js
msgid "Initialize daed"
msgstr ""
#: converter.js
msgid "Initialize and import"
msgstr ""
#: converter.js
msgid "daed has not been initialized yet. The account below will be created as the daed admin (password needs at least 6 characters with both letters and numbers), then used to import."
msgstr ""
#: converter.js
msgid "daed is not initialized yet. Open the daed web panel (port 2023) to create an admin account, then import again."
msgstr ""
msgid "3. Import Subscription"
msgstr ""
msgid "Import selected nodes as one subscription and add it to the default proxy group."
msgstr ""
msgid "Subscription name"
msgstr ""
msgid "Enter a subscription name to import the selected nodes."
msgstr ""
msgid "Subscription \"%s\" is available on daed. Select nodes above to update it."
msgstr ""
msgid "Update subscription \"%s\" with %d selected nodes in default group \"proxy\"."
msgstr ""
msgid "Import %d selected nodes as subscription \"%s\" into default group \"proxy\"."
msgstr ""
msgid "Update subscription (%d)"
msgstr ""
msgid "Import subscription (%d)"
msgstr ""
#: converter.js
msgid "Sign in and import"
msgstr ""
#: converter.js
#: converter.js
msgid "YAML parser failed to load"
msgstr ""
#: converter.js
msgid "daed returned HTTP %s"
msgstr ""
#: converter.js
msgid "Username"
msgstr ""
#: converter.js
msgid "Password"
msgstr ""
#: converter.js
msgid "Cancel"
msgstr ""
#: converter.js
msgid "Import cancelled"
msgstr ""
#: converter.js
msgid "Import selected nodes"
msgstr ""
#: converter.js
msgid "No YAML parsed yet"
msgstr ""
#: converter.js
msgid "YAML parse failed: %s"
msgstr ""
#: converter.js
msgid "No top-level proxies list was found"
msgstr ""
#: converter.js
msgid "The server returned a Base64/share-link subscription, not Clash YAML. Try another subscription User-Agent or add the URL directly as a native subscription."
msgstr ""
#: converter.js
msgid "Conversion preview is ready"
msgstr ""
#: converter.js
msgid "Conversion completed, but no supported nodes were found"
msgstr ""
#: converter.js
msgid "Unsupported protocol: %s"
msgstr ""
#: converter.js
msgid "Enter an HTTP or HTTPS subscription URL"
msgstr ""
#: converter.js
msgid "Fetching subscription…"
msgstr ""
#: converter.js
msgid "Fetch failed"
msgstr ""
#: converter.js
msgid "Fetch failed: %s"
msgstr ""
#: converter.js
msgid "Paste Clash YAML first"
msgstr ""
#: converter.js
msgid "or"
msgstr ""
#: converter.js
msgid "Importing selected nodes…"
msgstr ""
#: converter.js
msgid "Import failed: %s"
msgstr ""
msgid "Import complete: added %d, duplicates %d, failed %d"
msgstr ""
msgid "Create new airport"
msgstr ""
msgid "Existing airport"
msgstr ""
msgid "Airport name"
msgstr ""
msgid "Sync airport"
msgstr ""
msgid "Sync airport (%d nodes)"
msgstr ""
msgid "Already exists, will be reused"
msgstr ""
msgid "Enter an airport name"
msgstr ""
msgid "No usable nodes were imported"
msgstr ""
msgid "Synchronizing airport…"
msgstr ""
msgid "Sync complete: added %d, reused %d, failed %d"
msgstr ""
msgid "Sync failed: %s"
msgstr ""
msgid "This will delete the old nodes in \"%s\" and import %d new nodes. Continue?"
msgstr ""
msgid "3. Sync Airport Group"
msgstr ""
msgid "The selected nodes are kept together as one airport group. Re-syncing an existing airport safely replaces its old managed nodes."
msgstr ""
msgid "Detected"
msgstr ""
msgid "Compatible"
msgstr ""
msgid "Duplicates"
msgstr ""
msgid "Incompatible"
msgstr ""
msgid "Ignored info"
msgstr ""
msgid "No nodes to preview yet. Fetch or paste Clash YAML above."
msgstr ""
msgid "Group name"
msgstr ""
msgid "Import node group"
msgstr ""
msgid "Import node group (%d)"
msgstr ""
msgid "Update node group (%d)"
msgstr ""
msgid "Enter a group name to import the selected nodes."
msgstr ""
msgid "dae group names may only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore."
msgstr ""
msgid "Update node group \"%s\": replace it with %d selected nodes on %s."
msgstr ""
msgid "Import %d selected nodes into group \"%s\" on %s."
msgstr ""
msgid "Enter a group name"
msgstr ""
msgid "Replace node group \"%s\" with %d selected nodes?"
msgstr ""
msgid "Importing node group…"
msgstr ""
msgid "Node group imported: added %d, reused %d, failed %d"
msgstr ""
msgid "Completed with warning: %s"
msgstr ""
msgid "Managed subscription no longer exists in daed"
msgstr ""
msgid "Node group import failed: %s"
msgstr ""
msgid "3. Import Node Group"
msgstr ""
msgid "Import the selected nodes as one named group, so large airport node lists stay easy to manage."
msgstr ""
msgid "Node group \"%s\" is available on %s. Select nodes above to update it."
msgstr ""
msgid "Config Backup"
msgstr ""
msgid "Export"
msgstr ""
msgid "Import"
msgstr ""
msgid "dae + daed"
msgstr ""
msgid "Back up / restore the whole daede config (kernels excluded)"
msgstr ""
msgid "Import overwrites the current daede config and restarts the backend. Continue?"
msgstr ""
msgid "Data Source"
msgstr ""
msgid "Custom"
msgstr ""
msgid "Auto-update"
msgstr ""
msgid "Disabled"
msgstr ""
msgid "Daily"
msgstr ""
msgid "Weekly"
msgstr ""
msgid "Save"
msgstr ""
msgid "Geo data source saved."
msgstr ""
msgid "Save failed"
msgstr ""
msgid "latest version unknown"
msgstr ""
msgid "Delete the daens netns?"
msgstr ""
msgid ""
"%s is running. Deleting the daens netns now will break its networking until "
"you restart it. Continue?"
msgstr ""
msgid "DNS response TTL"
msgstr ""
msgid "TTL returned to LAN DNS clients. 0 keeps dae default behavior."
msgstr ""
msgid "Log level"
msgstr ""
msgid "Debug and trace show per-connection routing details, but increase CPU usage and log volume. Enable them only while troubleshooting."
msgstr ""
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
config daede 'config'
option active_backend 'daed'
@@ -0,0 +1,108 @@
#!/bin/sh
# Run once on every (re-)install/upgrade of luci-app-daede: migrate legacy
# config, drop stale files from the old luci-app-daed package, and seed the
# default dae form sections. The netns cleanup that used to be patched into
# daed's init from here now lives directly in daed's own stop_service().
set -e
# luci-app-daede owns its own LuCI menu and view path. Remove stale files from
# the official luci-app-daed package and earlier daede builds that reused
# /view/daed.
rm -f /usr/share/luci/menu.d/luci-app-daed.json
rm -f /usr/share/rpcd/acl.d/luci-app-daed.json
rm -rf /usr/share/luci-app-daed
rm -rf /www/luci-static/resources/view/daed
rm -f /usr/lib/lua/luci/i18n/daed.zh-cn.lmo
rm -f /tmp/luci-indexcache.* 2>/dev/null || true
rm -rf /tmp/luci-modulecache/* 2>/dev/null || true
if [ -f /etc/config/daed_luci ]; then
old_backend="$(uci -q get daed_luci.config.active_backend || true)"
if ! uci -q get daede.config >/dev/null 2>&1; then
touch /etc/config/daede
uci -q set daede.config=daede
case "$old_backend" in
dae|daed) uci -q set daede.config.active_backend="$old_backend" ;;
esac
uci -q commit daede
elif ! uci -q get daede.config.active_backend >/dev/null 2>&1; then
case "$old_backend" in
dae|daed)
uci -q set daede.config.active_backend="$old_backend"
uci -q commit daede
;;
esac
fi
rm -f /etc/config/daed_luci
fi
if ! uci -q get daede.config >/dev/null 2>&1; then
touch /etc/config/daede
uci -q set daede.config=daede
if [ -f /etc/init.d/daed ]; then
uci -q set daede.config.active_backend='daed'
else
uci -q set daede.config.active_backend='dae'
fi
uci -q commit daede
fi
# Remove --disable-timestamp from dae init so log lines carry timestamps
DAE_INIT="/etc/init.d/dae"
if [ -f "$DAE_INIT" ]; then
sed -i 's/--disable-timestamp//' "$DAE_INIT"
sed -i '/procd_append_param command *$/d' "$DAE_INIT"
sed -i 's|procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray".*|procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"|' "$DAE_INIT"
fi
# dae expects geoip.dat / geosite.dat under /usr/share/dae/;
# v2ray-geoip installs them to /usr/share/v2ray/. Create symlinks.
if [ -f /usr/share/v2ray/geoip.dat ] && [ ! -f /usr/share/dae/geoip.dat ]; then
mkdir -p /usr/share/dae
ln -sf /usr/share/v2ray/geoip.dat /usr/share/dae/geoip.dat
fi
if [ -f /usr/share/v2ray/geosite.dat ] && [ ! -f /usr/share/dae/geosite.dat ]; then
mkdir -p /usr/share/dae
ln -sf /usr/share/v2ray/geosite.dat /usr/share/dae/geosite.dat
fi
if [ -f /usr/share/v2ray/geoip.dat ] && [ ! -f /usr/share/daed/geoip.dat ]; then
mkdir -p /usr/share/daed
ln -sf /usr/share/v2ray/geoip.dat /usr/share/daed/geoip.dat
fi
if [ -f /usr/share/v2ray/geosite.dat ] && [ ! -f /usr/share/daed/geosite.dat ]; then
mkdir -p /usr/share/daed
ln -sf /usr/share/v2ray/geosite.dat /usr/share/daed/geosite.dat
fi
# Seed default dae form sections (group/routing/dns) for the LuCI editor.
# Idempotent: only add what's missing. Lives in the `dae` UCI package,
# consumed by gen-dae-config.sh.
if [ -f /etc/config/dae ]; then
/usr/share/luci-app-daede/config-defaults.sh
# Migrate legacy group filters to the unified source / name_filter fields,
# then drop the old keys so the form shows real, editable values (the old
# fields used to re-populate the new ones and looked un-deletable).
# `uci get` joins a list into one space-separated line; the unquoted $() word-
# splits it back into items. dae tags/keywords are whitespace-free, so this is
# the intended split (do NOT set IFS=newline here — it would merge the items).
i=0
while uci -q get "dae.@group[$i]" >/dev/null 2>&1; do
if [ -z "$(uci -q get dae.@group[$i].source)" ]; then
for v in $(uci -q get dae.@group[$i].filter_sub 2>/dev/null); do
uci -q add_list dae.@group[$i].source="$v"
done
fi
if [ -z "$(uci -q get dae.@group[$i].name_filter)" ]; then
for v in $(uci -q get dae.@group[$i].filter_node 2>/dev/null); do
uci -q add_list dae.@group[$i].name_filter="$v"
done
fi
uci -q delete dae.@group[$i].filter_sub 2>/dev/null
uci -q delete dae.@group[$i].filter_node 2>/dev/null
i=$((i+1))
done
uci -q commit dae
fi
exit 0
@@ -0,0 +1,268 @@
#!/bin/sh
# Whole-plugin backup/import/reset. Only fixed configuration paths are managed.
set -eu
umask 077
ACTION="${1:-}"
UPLOAD="${2:-/tmp/daede-import.b64}"
LOG=/tmp/luci-app-daede.backup.log
LOCK=/tmp/luci-app-daede.config.lock
SHARE=/usr/share/luci-app-daede
MAX_TAR=184320 # Base64 must fit inside the ubus reply (~240 KiB).
MAX_RAW=16777216
FILES="etc/config/dae etc/config/daed etc/config/daede etc/dae/config.dae etc/daed/wing.db etc/daed/wing.db-wal etc/daed/wing.db-shm"
WORK=""
SNAPSHOT=0
MUTATING=0
KEEP=0
RESTART=""
fail() { echo "$*" >&2; exit 1; }
b64enc() { ucode -e 'let f=require("fs"); print(b64enc(f.open(ARGV[0],"r").read("all")));' -- "$1"; }
b64dec() { ucode -e 'let f=require("fs"); let r=b64dec(trim(f.open(ARGV[0],"r").read("all"))); if(!r)exit(1); let o=f.open(ARGV[1],"w"); if(!o || o.write(r)!=length(r))exit(1); o.close();' -- "$1" "$2"; }
# Do not merge pending CLI UCI deltas into snapshots/defaults.
config() { /sbin/uci -c /etc/config -t "$WORK/uci" "$@"; }
paths() {
printf '%s\n' $FILES
for sub in "$1"/etc/dae/subscriptions/*.sub; do
[ -e "$sub" ] || [ -L "$sub" ] || continue
name="${sub##*/}"
case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'unexpected subscription filename' ;; esac
printf 'etc/dae/subscriptions/%s\n' "$name"
done
for sub in "$1"/etc/daed/daede-sub-*; do
[ -e "$sub" ] || [ -L "$sub" ] || continue
name="${sub##*/daede-sub-}"
case "$name" in ''|*[!A-Za-z0-9]*) fail 'unexpected daed subscription filename' ;; esac
printf 'etc/daed/daede-sub-%s\n' "$name"
done
}
check_paths() {
for dir in /etc/config /etc/dae /etc/daed /etc/dae/subscriptions; do
[ ! -L "$dir" ] || fail "refusing symlink: $dir"
done
paths / > "$WORK/paths"
while IFS= read -r p; do
[ ! -L "/$p" ] || fail "refusing symlink: /$p"
[ ! -e "/$p" ] || [ -f "/$p" ] || fail "not a regular file: /$p"
done < "$WORK/paths"
}
cleanup_stages() {
for stage in /etc/daed/.daede-sub-stage-*; do
[ -e "$stage" ] || [ -L "$stage" ] || continue
[ ! -L "$stage" ] || fail "refusing symlink: $stage"
[ -f "$stage" ] || fail "not a regular file: $stage"
rm -f "$stage" || fail "failed to remove stale snapshot stage: $stage"
done
}
snapshot() {
mkdir -p "$WORK/before"
while IFS= read -r p; do
[ -f "/$p" ] || continue
mkdir -p "$WORK/before/${p%/*}"
cp -p "/$p" "$WORK/before/$p"
done < "$WORK/paths"
SNAPSHOT=1
}
stop_backends() {
for svc in dae daed; do
[ -x "/etc/init.d/$svc" ] || continue
if /etc/init.d/"$svc" running >/dev/null 2>&1; then RESTART="$RESTART $svc"; fi
/etc/init.d/"$svc" stop >/dev/null 2>&1 || fail "failed to stop $svc; configuration unchanged"
done
# procd termination can be asynchronous. Never copy a live SQLite database.
for attempt in 1 2 3 4 5; do
if ! pidof dae daed daed-guard >/dev/null 2>&1; then return; fi
sleep 1
done
fail 'backend still running; configuration unchanged'
}
disable_backends() {
for svc in dae daed; do
if [ -f "/etc/config/$svc" ]; then
config -q set "$svc.config=$svc" || return 1
config -q set "$svc.config.enabled=0" || return 1
config -q commit "$svc" || return 1
fi
if [ -x "/etc/init.d/$svc" ]; then
/etc/init.d/"$svc" disable >/dev/null 2>&1 || return 1
/etc/init.d/"$svc" stop >/dev/null 2>&1 || return 1
fi
done
}
restore_snapshot() {
# Remove files created by the failed operation as well as original files.
paths / > "$WORK/current" || return 1
while IFS= read -r p; do rm -f "/$p" || return 1; done < "$WORK/current"
while IFS= read -r p; do
[ -f "$WORK/before/$p" ] || continue
mkdir -p "/${p%/*}" || return 1
cp -p "$WORK/before/$p" "/$p" || return 1
done < "$WORK/paths"
}
sync_cron() {
geo=disable; sub=disable
[ "$(config -q get daede.config.geo_auto || :)" != 1 ] || geo=enable
[ "$(config -q get daed.config.subscribe_auto_update || :)" != 1 ] || sub=enable
"$SHARE/geo-cron.sh" "$geo" >/dev/null 2>&1
"$SHARE/daed-sub-cron.sh" "$sub" >/dev/null 2>&1
}
finish() {
rc=$?
trap - EXIT HUP INT TERM
if [ "$rc" -ne 0 ] && [ "$MUTATING" = 1 ]; then
disable_backends || true
if [ "$SNAPSHOT" = 1 ] && ! restore_snapshot; then
KEEP=1
echo "rollback failed; root-only recovery files retained at $WORK" >&2
else
echo 'previous configuration restored; backends remain disabled' >&2
fi
disable_backends || { KEEP=1; echo 'failed to disable backend; check service state' >&2; }
sync_cron || true
fi
if [ "$ACTION" = export ]; then
for svc in $RESTART; do
/etc/init.d/"$svc" start >/dev/null 2>&1 || { rc=1; echo "failed to resume $svc" >&2; }
done
fi
if [ "$ACTION" != export ]; then
if [ "$rc" = 0 ]; then echo '✓ 完成'; else echo '✗ 失败'; fi
fi
[ "$KEEP" = 1 ] || rm -rf "$WORK"
rmdir "$LOCK"
exit "$rc"
}
validate_archive() {
[ "$(wc -c < "$WORK/upload.b64")" -le 245760 ] || fail 'upload too large'
b64dec "$WORK/upload.b64" "$WORK/import.gz" || fail 'invalid base64 upload'
[ "$(wc -c < "$WORK/import.gz")" -le "$MAX_TAR" ] || fail 'archive too large'
gzip -t "$WORK/import.gz" 2>/dev/null || fail 'invalid gzip archive'
gzip -dc "$WORK/import.gz" | head -c 16777217 > "$WORK/import.tar"
[ "$(wc -c < "$WORK/import.tar")" -le "$MAX_RAW" ] || fail 'unpacked backup exceeds 16 MiB'
tar -tf "$WORK/import.tar" > "$WORK/entries" 2>/dev/null || fail 'invalid tar archive'
[ -s "$WORK/entries" ] || fail 'empty archive'
# Reject links, directories, devices and duplicate entries before extraction.
tar -tvf "$WORK/import.tar" > "$WORK/types" 2>/dev/null || fail 'invalid tar headers'
if grep -qv '^-' "$WORK/types"; then fail 'only regular files are allowed'; fi
[ -z "$(sort "$WORK/entries" | uniq -d)" ] || fail 'duplicate archive entry'
while IFS= read -r p; do
case "$p" in
etc/config/dae|etc/config/daed|etc/config/daede|etc/dae/config.dae|etc/daed/wing.db|etc/daed/wing.db-wal|etc/daed/wing.db-shm) ;;
etc/dae/subscriptions/*.sub)
name="${p#etc/dae/subscriptions/}"
case "${name%.sub}" in ''|*[!A-Za-z0-9_]*) fail 'invalid subscription path' ;; esac ;;
etc/daed/daede-sub-*)
name="${p#etc/daed/daede-sub-}"
case "$name" in ''|*[!A-Za-z0-9]*) fail 'invalid daed subscription path' ;; esac ;;
*) fail 'unexpected archive entry' ;;
esac
done < "$WORK/entries"
mkdir -p "$WORK/after"
tar -xf "$WORK/import.tar" -C "$WORK/after"
found=0
for svc in dae daed daede; do
[ -f "$WORK/after/etc/config/$svc" ] || continue
found=1
/sbin/uci -c "$WORK/after/etc/config" -t "$WORK/uci" -q export "$svc" >/dev/null || fail "invalid $svc configuration"
done
[ "$found" = 1 ] || fail 'backup has no UCI configuration'
if [ -f "$WORK/after/etc/daed/wing.db-wal" ]; then
[ -f "$WORK/after/etc/daed/wing.db" ] || fail 'database WAL has no database'
fi
ab="$(/sbin/uci -c "$WORK/after/etc/config" -t "$WORK/uci" -q get daede.config.active_backend || echo dae)"
case "$ab" in dae|daed) ;; *) fail 'invalid backend in backup' ;; esac
[ -x "/etc/init.d/$ab" ] || fail "backup backend $ab is not installed"
}
run_action() {
trap finish EXIT
trap 'exit 1' HUP INT TERM
check_paths
if [ "$ACTION" = import ]; then validate_archive; fi
if [ "$ACTION" = reset ]; then
mkdir -p "$WORK/after/etc/config"
for svc in dae daed daede; do
cp "$SHARE/defaults/$svc" "$WORK/after/etc/config/$svc"
done
"$SHARE/config-defaults.sh" "$WORK/after/etc/config"
if [ ! -x /etc/init.d/daed ]; then
/sbin/uci -c "$WORK/after/etc/config" -t "$WORK/uci" set daede.config.active_backend=dae
/sbin/uci -c "$WORK/after/etc/config" -t "$WORK/uci" commit daede
fi
fi
stop_backends
[ "$ACTION" = reset ] && cleanup_stages
snapshot
if [ "$ACTION" = export ]; then
paths "$WORK/before" > "$WORK/candidates"
: > "$WORK/entries"
while IFS= read -r p; do [ ! -f "$WORK/before/$p" ] || echo "$p" >> "$WORK/entries"; done < "$WORK/candidates"
[ -s "$WORK/entries" ] || fail 'no configuration to export'
tar -czf "$WORK/export.gz" -C "$WORK/before" -T "$WORK/entries"
[ "$(wc -c < "$WORK/export.gz")" -le "$MAX_TAR" ] || fail 'config too large; use System Backup instead'
b64enc "$WORK/export.gz"
return
fi
MUTATING=1
disable_backends
# Old backups may omit an uninstalled backend. Keep its UCI file, but remove
# all managed data first so stale WAL/SHM and local subscriptions cannot leak in.
while IFS= read -r p; do
case "$p" in etc/config/*) continue ;; esac
rm -f "/$p"
done < "$WORK/paths"
paths "$WORK/after" > "$WORK/replacement"
while IFS= read -r p; do
[ -f "$WORK/after/$p" ] || continue
mkdir -p "/${p%/*}"
cp "$WORK/after/$p" "/$p"
chmod 600 "/$p"
done < "$WORK/replacement"
if [ "$ACTION" = reset ]; then
disable_backends
sync_cron
echo 'All defaults restored. Backends are disabled; configure before starting manually.'
else
# Preserve the imported active backend's enabled flag. The other backend
# stays disabled even when the archive was made on a dual-backend router.
for svc in dae daed; do
[ "$svc" = "$ab" ] || [ ! -f "/etc/config/$svc" ] || { config -q set "$svc.config.enabled=0"; config -q commit "$svc"; }
done
sync_cron
if [ "$(config -q get "$ab.config.enabled" || :)" = 1 ]; then
/etc/init.d/"$ab" enable
/etc/init.d/"$ab" start
/etc/init.d/"$ab" running >/dev/null 2>&1 || fail 'restored backend failed to start'
else
/etc/init.d/"$ab" disable
fi
echo 'Backup restored. Active backend follows the saved enabled setting.'
fi
}
case "$ACTION" in export|import|reset) ;; *) fail "usage: $0 export|import [upload]|reset" ;; esac
# An atomic lock covers the complete operation, including background execution.
mkdir "$LOCK" 2>/dev/null || fail 'another configuration operation is running'
WORK="$(mktemp -d /tmp/daede-config.XXXXXX)" || { rmdir "$LOCK"; exit 1; }
mkdir -p "$WORK/uci"
if [ "$ACTION" = import ]; then
case "$UPLOAD" in
/tmp/daede-import.b64) ;;
/tmp/daede-import.*.b64)
token="${UPLOAD#/tmp/daede-import.}"; token="${token%.b64}"
case "$token" in ''|*[!a-f0-9]*) rm -rf "$WORK"; rmdir "$LOCK"; fail 'invalid upload path' ;; esac ;;
*) rm -rf "$WORK"; rmdir "$LOCK"; fail 'invalid upload path' ;;
esac
if [ ! -f "$UPLOAD" ] || [ -L "$UPLOAD" ]; then rm -rf "$WORK"; rmdir "$LOCK"; fail 'upload missing or unsafe'; fi
mv "$UPLOAD" "$WORK/upload.b64" || { rm -rf "$WORK"; rmdir "$LOCK"; exit 1; }
fi
if [ "$ACTION" = export ]; then
run_action
else
# Truncate before returning, so the frontend cannot mistake an earlier job
# for this one. The child owns the lock and all cleanup from here on.
: > "$LOG"
(run_action) </dev/null >"$LOG" 2>&1 &
echo "started in background, see $LOG"
fi
@@ -0,0 +1,31 @@
#!/bin/sh
# Shared installation/reset defaults. Optional directory is an isolated UCI tree.
set -eu
if [ "$#" -gt 0 ]; then
config_dir="$1"
mkdir -p "$config_dir/.uci"
uci() { /sbin/uci -c "$config_dir" -t "$config_dir/.uci" "$@"; }
fi
if ! uci -q get dae.config.lan_interface >/dev/null 2>&1; then
uci -q set dae.config.lan_interface='br-lan'
fi
if ! uci -q show dae | grep -q "=group$"; then
g="$(uci add dae group)"
uci -q set "dae.$g.name=proxy"
uci -q set "dae.$g.policy=min_moving_avg"
fi
if ! uci -q get dae.routing >/dev/null 2>&1; then
uci -q set dae.routing=routing
uci -q set dae.routing.private_direct=1
uci -q set dae.routing.cn_direct=1
uci -q set dae.routing.block_ads=0
uci -q set dae.routing.fallback=proxy
fi
if ! uci -q get dae.dns >/dev/null 2>&1; then
uci -q set dae.dns=dns
uci -q set dae.dns.cn_upstream='udp://dns.alidns.com:53'
uci -q set dae.dns.fallback_upstream='tcp+udp://dns.google:53'
uci -q set dae.dns.response_ttl='0'
fi
uci -q commit dae
@@ -0,0 +1,38 @@
#!/bin/sh
# Manage daed subscription auto-update cron entry.
ACTION="$1"
CRONTAB="/etc/crontabs/root"
TAG="# luci-app-daede daed-sub-update"
SCRIPT="/usr/share/luci-app-daede/daed-sub-update.sh"
clean() {
[ -f "$CRONTAB" ] || return 0
grep -vF -e "$TAG" -e "$SCRIPT" "$CRONTAB" > "$CRONTAB.tmp" 2>/dev/null
mv "$CRONTAB.tmp" "$CRONTAB"
}
clean
if [ "$ACTION" = "enable" ] && [ "$(uci -q get daed.config.subscribe_auto_update)" = "1" ]; then
hour="$(uci -q get daed.config.subscribe_update_hour)"
case "$hour" in
''|*[!0-9]*) hour=4 ;;
esac
[ "$hour" -ge 0 ] 2>/dev/null && [ "$hour" -le 23 ] 2>/dev/null || hour=4
case "$(uci -q get daed.config.subscribe_update_cycle)" in
weekly) sched="17 $hour * * 0" ;;
*) sched="17 $hour * * *" ;;
esac
mkdir -p /etc/crontabs
{
echo "$TAG"
echo "$sched $SCRIPT >/dev/null 2>&1"
} >> "$CRONTAB"
/etc/init.d/cron enable >/dev/null 2>&1
fi
/etc/init.d/cron restart >/dev/null 2>&1
exit 0
@@ -0,0 +1,148 @@
#!/bin/sh
# Update all daed subscriptions through dae-wing GraphQL.
LOG="/tmp/luci-app-daede.daed-sub-update.log"
LOCK="/tmp/luci-app-daede.daed-sub-update.lock"
log() {
echo "$(date '+%F %T') $*" >> "$LOG"
}
fail() {
local code="$1"; shift
log "$*"
echo "$*" >&2
exit "$code"
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
cleanup() {
[ -n "$TMPDIR" ] && rm -rf "$TMPDIR"
rmdir "$LOCK" 2>/dev/null
}
if ! mkdir "$LOCK" 2>/dev/null; then
fail 1 "daed subscription update already running"
fi
trap cleanup EXIT INT TERM
TMPDIR="$(mktemp -d /tmp/daed-sub-update.XXXXXX)" || exit 1
port="$(uci -q get daed.config.listen_addr | sed -n 's/.*:\([0-9]\{1,5\}\)$/\1/p')"
[ -n "$port" ] || port=2023
endpoint="http://127.0.0.1:$port/graphql"
username="$(uci -q get daed.config.dashboard_username)"
password="$(uci -q get daed.config.dashboard_password)"
if [ -z "$username" ] || [ -z "$password" ]; then
fail 2 "missing daed dashboard username or password"
fi
if ! /bin/pidof daed >/dev/null 2>&1; then
fail 3 "daed is not running"
fi
post_graphql() {
local body="$1" outfile="$2" auth="$3"
if [ -n "$auth" ]; then
uclient-fetch -q -O "$outfile" \
--header="Content-Type: application/json" \
--header="Authorization: Bearer $auth" \
--post-file="$body" \
"$endpoint" 2>>"$LOG"
else
uclient-fetch -q -O "$outfile" \
--header="Content-Type: application/json" \
--post-file="$body" \
"$endpoint" 2>>"$LOG"
fi
}
login_body="$TMPDIR/login.json"
login_resp="$TMPDIR/login.out"
printf '{"query":"query Login($username:String!,$password:String!){token(username:$username,password:$password)}","variables":{"username":"%s","password":"%s"}}' \
"$(json_escape "$username")" "$(json_escape "$password")" > "$login_body"
log "starting daed subscription update"
if ! post_graphql "$login_body" "$login_resp"; then
fail 4 "login request failed"
fi
token="$(sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$login_resp")"
if [ -z "$token" ]; then
fail 5 "login failed: $(cat "$login_resp" 2>/dev/null)"
fi
list_body="$TMPDIR/list.json"
list_resp="$TMPDIR/list.out"
printf '{"query":"query Subscriptions{subscriptions{id tag link}}","variables":{}}' > "$list_body"
if ! post_graphql "$list_body" "$list_resp" "$token"; then
fail 6 "subscription list request failed"
fi
if grep -q '"errors"' "$list_resp"; then
fail 7 "subscription list failed: $(cat "$list_resp" 2>/dev/null)"
fi
sed 's/"id"/\
"id"/g' "$list_resp" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' > "$TMPDIR/ids"
count="$(grep -c . "$TMPDIR/ids" 2>/dev/null || echo 0)"
if [ "${count:-0}" -eq 0 ]; then
log "no daed subscriptions to update"
exit 0
fi
ok=0
fail=0
while IFS= read -r id || [ -n "$id" ]; do
[ -n "$id" ] || continue
update_body="$TMPDIR/update.json"
update_resp="$TMPDIR/update.out"
printf '{"query":"mutation Update($id:ID!){updateSubscription(id:$id){id tag status}}","variables":{"id":"%s"}}' \
"$(json_escape "$id")" > "$update_body"
if post_graphql "$update_body" "$update_resp" "$token" && ! grep -q '"errors"' "$update_resp"; then
ok=$((ok + 1))
log "updated subscription $id"
else
fail=$((fail + 1))
log "failed subscription $id: $(cat "$update_resp" 2>/dev/null)"
fi
done < "$TMPDIR/ids"
log "finished daed subscription update: ok=$ok fail=$fail total=$count"
if [ "$fail" -ne 0 ]; then
exit 1
fi
if [ "$count" -gt 0 ] && [ "$ok" -gt 0 ] && /bin/pidof daed >/dev/null 2>&1; then
state_body="$TMPDIR/state.json"
state_resp="$TMPDIR/state.out"
printf '%s' '{"query":"query General{general{dae{running modified}}}","variables":{}}' > "$state_body"
if ! post_graphql "$state_body" "$state_resp" "$token" || grep -q '"errors"' "$state_resp"; then
fail 8 "failed to query daed runtime state: $(cat "$state_resp" 2>/dev/null)"
fi
if ! grep -q '"running"[[:space:]]*:[[:space:]]*true' "$state_resp"; then
log "daed proxy is stopped; skipping runtime apply"
exit 0
fi
if ! grep -q '"modified"[[:space:]]*:[[:space:]]*true' "$state_resp"; then
log "daed runtime is already up to date"
exit 0
fi
run_body="$TMPDIR/run.json"
run_resp="$TMPDIR/run.out"
printf '{"query":"mutation Run($dry:Boolean!){run(dry:$dry)}","variables":{"dry":false}}' > "$run_body"
log "applying updated subscriptions"
if ! post_graphql "$run_body" "$run_resp" "$token" || grep -q '"errors"' "$run_resp"; then
fail 9 "failed to apply updated subscriptions: $(cat "$run_resp" 2>/dev/null)"
fi
fi
exit 0
@@ -0,0 +1,134 @@
#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
set -eu
CHUNK_BYTES=16384
ACTION="${1:-}"
case "$ACTION" in
chunk)
TOKEN="${2:-}"
INDEX="${3:-}"
case "$TOKEN" in *[!A-Za-z0-9]*|'') exit 2 ;; esac
case "$INDEX" in *[!0-9]*|'') exit 2 ;; esac
FILE="/tmp/daede-clash-yaml-result.$TOKEN"
[ -f "$FILE" ] || exit 4
dd if="$FILE" bs="$CHUNK_BYTES" skip="$INDEX" count=1 2>/dev/null | hexdump -ve '1/1 "%02x"'
exit 0
;;
cleanup)
TOKEN="${2:-}"
case "$TOKEN" in *[!A-Za-z0-9]*|'') exit 2 ;; esac
rm -f "/tmp/daede-clash-yaml-result.$TOKEN"
exit 0
;;
esac
URL="${1:-}"
UA_MODE="${2:-auto}"
OUTPUT_MODE="${3:-stdout}"
MAX_BYTES=5242880
FETCH_BIN="${DAEDE_FETCH_BIN:-uclient-fetch}"
case "$URL" in
http://*|https://*) ;;
*)
echo "Subscription URL must use HTTP or HTTPS" >&2
exit 2
;;
esac
TMP="$(mktemp /tmp/daede-clash-yaml.XXXXXX)"
FIRST="$(mktemp /tmp/daede-clash-yaml-first.XXXXXX)"
ERR="$(mktemp /tmp/daede-clash-yaml-error.XXXXXX)"
trap 'rm -f "$TMP" "$FIRST" "$ERR"' EXIT INT TERM
chmod 600 "$TMP"
chmod 600 "$FIRST"
chmod 600 "$ERR"
case "$UA_MODE" in
auto|browser|ClashMeta|clash-verge/v2.4.2|ClashForWindows/0.20.39|Clash) ;;
*)
echo "Unsupported subscription User-Agent" >&2
exit 2
;;
esac
case "$OUTPUT_MODE" in
stdout|handle) ;;
*)
echo "Unsupported output mode" >&2
exit 2
;;
esac
LAST_ERROR="Failed to fetch subscription"
emit_result() {
local source="$1" result token size
if [ "$OUTPUT_MODE" = "stdout" ]; then
cat "$source"
return
fi
# Keep each RPC response small. The browser reads this root-only temporary
# file through the chunk action and removes it in a finally handler.
find /tmp -maxdepth 1 -name 'daede-clash-yaml-result.*' -mmin +10 -delete 2>/dev/null || true
result="$(mktemp /tmp/daede-clash-yaml-result.XXXXXX)"
chmod 600 "$result"
cp "$source" "$result"
token="${result##*.}"
size="$(wc -c <"$result" | tr -d ' ')"
printf '%s\t%s\n' "$token" "$size"
}
fetch_one() {
local ua="$1" size response_error
if [ "$ua" = "browser" ]; then
ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36"
fi
: >"$TMP"
: >"$ERR"
if ! "$FETCH_BIN" -q -T 20 -U "$ua" -O "$TMP" "$URL" 2>"$ERR"; then
response_error="$(head -c 512 "$TMP" | tr '\r\n\t' ' ')"
LAST_ERROR="${response_error:-$(cat "$ERR")}"
[ -n "$LAST_ERROR" ] || LAST_ERROR="Failed to fetch subscription"
return 1
fi
size="$(wc -c <"$TMP" | tr -d ' ')"
if [ "$size" -eq 0 ]; then
LAST_ERROR="Subscription response is empty"
return 1
fi
if [ "$size" -gt "$MAX_BYTES" ]; then
LAST_ERROR="Subscription response is too large (maximum 5 MiB)"
return 1
fi
return 0
}
if [ "$UA_MODE" = "auto" ]; then
for ua in ClashMeta clash-verge/v2.4.2 ClashForWindows/0.20.39 Clash browser; do
if fetch_one "$ua"; then
[ -s "$FIRST" ] || cp "$TMP" "$FIRST"
if grep -Eq '^[[:space:]]*proxies[[:space:]]*:' "$TMP"; then
emit_result "$TMP"
exit 0
fi
fi
done
if [ -s "$FIRST" ]; then
emit_result "$FIRST"
exit 0
fi
else
if fetch_one "$UA_MODE"; then
emit_result "$TMP"
exit 0
fi
fi
echo "$LAST_ERROR" >&2
exit 3
@@ -0,0 +1,422 @@
#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
#
# gen-dae-config.sh - build /etc/dae/config.dae from the dae UCI form sections,
# validate with `dae validate`, then overwrite + hot reload only on success.
#
# generate read UCI -> render config.dae -> validate -> overwrite + hot reload
# import parse existing config.dae subscription{}/node{} -> fill UCI form
#
# UCI model (package dae):
# config subscription tag/url, enabled
# config node tag/link, enabled
# config group name/policy, list source, list name_filter
# (old list filter_sub/filter_node still read as fallback)
# config routing 'routing' private_direct/cn_direct/block_ads/fallback, list custom
# config dns 'dns' cn_upstream/fallback_upstream/response_ttl
. /lib/functions.sh
CONFIG_DAE="/etc/dae/config.dae"
TMP_GEN="/tmp/dae-gen.dae"
DAE_BIN="/usr/bin/dae"
DAE_INITD="/etc/init.d/dae"
SUBS_DIR="/etc/dae/subscriptions"
SUB_STAGE="/tmp/daede-sub.txt"
# dae single-quoted strings have no escaping; strip quotes/newlines defensively.
sanitize() {
printf '%s' "$1" | tr -d "'" | tr -d '\n\r'
}
# dae subscription/node tags are bareword identifiers: only [A-Za-z0-9_] are
# valid tokens, so a CJK/spaced tag (e.g. 白月光) makes the parser choke. Map
# any such tag to a stable ASCII slug; pure-word tags pass through unchanged.
# Deterministic on the input, so a group's source reference resolves to the
# same slug as the subscription it points at. Empty stays empty (anonymous).
dae_tag() {
[ -n "$1" ] || return 0
case "$1" in
*[!A-Za-z0-9_]*) printf 's_%s' "$(printf '%s' "$1" | md5sum | cut -c1-10)" ;;
*) printf '%s' "$1" ;;
esac
}
# ===================== generate =====================
SUB_BUF=""
NODE_BUF=""
GROUP_BUF=""
FIRST_GROUP=""
emit_sub() {
local s="$1" tag url enabled
config_get tag "$s" tag ""
config_get url "$s" url ""
config_get_bool enabled "$s" enabled 1
[ "$enabled" = "1" ] || return 0
url="$(sanitize "$url")"
[ -n "$url" ] || return 0
tag="$(dae_tag "$(sanitize "$tag")")"
if [ -n "$tag" ]; then
SUB_BUF="${SUB_BUF} ${tag}: '${url}'
"
else
SUB_BUF="${SUB_BUF} '${url}'
"
fi
}
emit_node() {
local s="$1" tag link enabled
config_get tag "$s" tag ""
config_get link "$s" link ""
config_get_bool enabled "$s" enabled 1
[ "$enabled" = "1" ] || return 0
link="$(sanitize "$link")"
[ -n "$link" ] || return 0
tag="$(dae_tag "$(sanitize "$tag")")"
if [ -n "$tag" ]; then
NODE_BUF="${NODE_BUF} ${tag}: '${link}'
"
else
NODE_BUF="${NODE_BUF} '${link}'
"
fi
}
emit_group() {
local s="$1" name policy
config_get name "$s" name ""
config_get policy "$s" policy "min_moving_avg"
name="$(sanitize "$name")"
[ -n "$name" ] || return 0
# dae fatals on duplicate outbound names; keep the first, skip later dupes
case " $GROUP_NAMES_SEEN " in *" $name "*) return 0 ;; esac
GROUP_NAMES_SEEN="$GROUP_NAMES_SEEN $name"
[ -n "$FIRST_GROUP" ] || FIRST_GROUP="$name"
GROUP_BUF="${GROUP_BUF} ${name} {
"
# Build the source: 'source' entries that match a subscription tag become
# subtag(), the rest (node tags / keywords) become name(). Old filter_sub /
# filter_node configs still work as a fallback when 'source' is unset.
SUBS_ACC=""; NODES_ACC=""; KW_ACC=""
# source: new field, else old filter_sub (decoupled from the name filter so
# editing one never silently drops the other)
config_list_foreach "$s" source _collect_source
[ -z "$SUBS_ACC" ] && [ -z "$NODES_ACC" ] && config_list_foreach "$s" filter_sub _collect_source
# optional name filter narrows the subscription pool only (AND); hand-picked
# nodes are explicit choices and stay in unconditionally
config_list_foreach "$s" name_filter _collect_kw
[ -z "$KW_ACC" ] && config_list_foreach "$s" filter_node _collect_kw
local andclause=""
[ -n "$KW_ACC" ] && andclause=" && name(${KW_ACC})"
local emitted=0
# multiple filter lines = OR, so subtag and name lines union the sources
if [ -n "$SUBS_ACC" ]; then
GROUP_BUF="${GROUP_BUF} filter: subtag(${SUBS_ACC})${andclause}
"
emitted=1
fi
if [ -n "$NODES_ACC" ]; then
GROUP_BUF="${GROUP_BUF} filter: name(${NODES_ACC})
"
emitted=1
fi
# name filter with no source = filter the whole pool by name
if [ "$emitted" = 0 ] && [ -n "$KW_ACC" ]; then
GROUP_BUF="${GROUP_BUF} filter: name(${KW_ACC})
"
fi
GROUP_BUF="${GROUP_BUF} policy: ${policy}
}
"
SUBS_ACC=""; NODES_ACC=""; KW_ACC=""
}
SUBS_ACC=""
NODES_ACC=""
KW_ACC=""
# Subscription tags, space-padded for membership tests (" tag " match).
SUB_TAGS=""
collect_subtag() {
local t; config_get t "$1" tag ""
t="$(dae_tag "$(sanitize "$t")")"
[ -n "$t" ] && SUB_TAGS="${SUB_TAGS} ${t} "
}
is_subtag() {
case "$SUB_TAGS" in *" $1 "*) return 0 ;; *) return 1 ;; esac
}
# dae filter args are bare only for plain identifiers; anything with regex
# chars / CJK / spaces must be wrapped as regex: '...' or the lexer chokes.
filter_arg() {
case "$1" in
*[!A-Za-z0-9_]*) printf "regex: '%s'" "$1" ;;
*) printf '%s' "$1" ;;
esac
}
# name-filter args do substring matching: regex metachars -> regex:, else keyword:.
# Intentionally stricter than filter_arg: a plain CJK keyword here stays a
# substring `keyword:` match, whereas filter_arg wraps any non-word char as regex.
kw_arg() {
if printf '%s' "$1" | grep -q '[][|().*+?^$\\]'; then
printf "regex: '%s'" "$1"
else
printf "keyword: '%s'" "$1"
fi
}
# classify a source entry: subscription tag -> subtag bucket, else name bucket
_collect_source() {
local v="$(sanitize "$1")"
[ -n "$v" ] || return 0
# subscription refs resolve to the (ASCII) dae tag; node-name refs keep the
# original text (matched as a quoted regex against node names)
local dt="$(dae_tag "$v")"
if is_subtag "$dt"; then
SUBS_ACC="${SUBS_ACC:+$SUBS_ACC, }$(filter_arg "$dt")"
else
NODES_ACC="${NODES_ACC:+$NODES_ACC, }$(filter_arg "$v")"
fi
}
_collect_kw() {
local v="$(sanitize "$1")"
[ -n "$v" ] || return 0
KW_ACC="${KW_ACC:+$KW_ACC, }$(kw_arg "$v")"
}
generate() {
config_load dae
# global overridable knobs (fall back to defaults)
local dial_mode log_level wan_interface lan_interface
config_get dial_mode config dial_mode "domain"
config_get log_level config log_level "info"
config_get wan_interface config wan_interface "auto"
config_get lan_interface config lan_interface "br-lan"
SUB_BUF=""; NODE_BUF=""; GROUP_BUF=""; FIRST_GROUP=""; GROUP_NAMES_SEEN=""
SUB_TAGS=""
config_foreach collect_subtag subscription
config_foreach emit_sub subscription
config_foreach emit_node node
config_foreach emit_group group
# fall back to a default group when none defined
if [ -z "$GROUP_BUF" ]; then
GROUP_BUF=" proxy {
policy: min_moving_avg
}
"
FIRST_GROUP="proxy"
fi
# routing / dns singletons
local private_direct cn_direct block_ads fallback cn_up fb_up response_ttl
config_get_bool private_direct routing private_direct 1
config_get_bool cn_direct routing cn_direct 1
config_get_bool block_ads routing block_ads 0
config_get fallback routing fallback "$FIRST_GROUP"
[ -n "$fallback" ] || fallback="$FIRST_GROUP"
fallback="$(sanitize "$fallback")"
config_get cn_up dns cn_upstream "udp://dns.alidns.com:53"
config_get fb_up dns fallback_upstream "tcp+udp://dns.google:53"
config_get response_ttl dns response_ttl "0"
cn_up="$(sanitize "$cn_up")"
fb_up="$(sanitize "$fb_up")"
case "$response_ttl" in
''|*[!0-9]*) response_ttl=0 ;;
esac
{
echo "# 本配置由 luci-app-daede 表单自动生成,再次保存表单会覆盖你的手动修改。"
echo "global {"
echo " tproxy_port: 12345"
echo " tproxy_port_protect: true"
echo " so_mark_from_dae: 0"
echo " log_level: ${log_level}"
[ -n "$lan_interface" ] && echo " lan_interface: ${lan_interface}"
echo " wan_interface: ${wan_interface}"
echo " auto_config_kernel_parameter: true"
echo " dial_mode: ${dial_mode}"
echo " tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111'"
echo " udp_check_dns: 'dns.google:53,8.8.8.8,2001:4860:4860::8888'"
echo " check_interval: 30s"
echo " check_tolerance: 50ms"
echo "}"
echo ""
if [ -n "$SUB_BUF" ]; then
echo "subscription {"
printf '%s' "$SUB_BUF"
echo "}"
echo ""
fi
if [ -n "$NODE_BUF" ]; then
echo "node {"
printf '%s' "$NODE_BUF"
echo "}"
echo ""
fi
echo "dns {"
echo " ipversion_prefer: 4"
[ "$response_ttl" -gt 0 ] && echo " response_ttl: ${response_ttl}"
echo " upstream {"
echo " cndns: '${cn_up}'"
echo " fallbackdns: '${fb_up}'"
echo " }"
echo " routing {"
echo " request {"
[ "$block_ads" = "1" ] && echo " qname(geosite:category-ads-all) -> reject"
echo " qname(geosite:cn) -> cndns"
echo " fallback: fallbackdns"
echo " }"
echo " }"
echo "}"
echo ""
echo "group {"
printf '%s' "$GROUP_BUF"
echo "}"
echo ""
echo "routing {"
echo " pname(NetworkManager) -> direct"
# multicast / broadcast direct (geoip:private doesn't cover these)
echo " dip(224.0.0.0/3) -> direct"
echo " dip(255.255.255.255/32) -> direct"
echo " dip('ff00::/8') -> direct"
[ "$private_direct" = "1" ] && echo " dip(geoip:private) -> direct"
# NTP time sync direct
echo " l4proto(udp) && dport(123) -> direct"
# OS connectivity checks direct (avoid captive-portal false positives)
echo " domain(connectivitycheck.gstatic.com) -> direct"
echo " domain(msftconnecttest.com) -> direct"
if [ "$cn_direct" = "1" ]; then
echo " dip(geoip:cn) -> direct"
echo " domain(geosite:cn) -> direct"
fi
[ "$block_ads" = "1" ] && echo " domain(geosite:category-ads-all) -> block"
config_list_foreach routing custom _emit_custom
echo " fallback: ${fallback}"
echo "}"
} > "$TMP_GEN"
chmod 600 "$TMP_GEN" # dae rejects world/group-accessible config files
# overwrite live config only if validation passes
local out
out="$("$DAE_BIN" validate -c "$TMP_GEN" 2>&1)"
if [ $? -ne 0 ]; then
echo "validate failed: $(echo "$out" | head -1)" >&2
rm -f "$TMP_GEN"
return 1
fi
mkdir -p /etc/dae
cat "$TMP_GEN" > "$CONFIG_DAE"
chmod 600 "$CONFIG_DAE"
rm -f "$TMP_GEN"
if /usr/bin/pgrep -x dae >/dev/null 2>&1; then
"$DAE_INITD" hot_reload >/dev/null 2>&1
fi
echo "ok"
return 0
}
_emit_custom() {
local line="$(printf '%s' "$1" | tr -d '\n\r')"
[ -n "$line" ] || return 0
echo " ${line}"
}
# ===================== import =====================
# Fill the UCI form from an existing config.dae subscription{}/node{} block.
# Idempotent: wipe same-type sections first, then rebuild from file.
import_block() {
# $1=block name (subscription|node) $2=uci section type $3=link/url field
local block="$1" stype="$2" field="$3"
awk -v blk="$block" '
$0 ~ "^[[:space:]]*"blk"[[:space:]]*\\{" { inb=1; next }
inb && /^[[:space:]]*\}/ { inb=0 }
inb { print }
' "$CONFIG_DAE" 2>/dev/null | while IFS= read -r raw; do
# drop comment, trim
line="$(printf '%s' "$raw" | sed 's/#.*$//' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
[ -n "$line" ] || continue
tag=""; val=""
case "$line" in
\'*|\"*)
# starts with a quote -> untagged link
val="$line"
;;
[A-Za-z0-9_]*:*)
# identifier ':' link -> tagged (split on first colon)
tag="$(printf '%s' "$line" | sed "s/[[:space:]]*:.*//")"
val="$(printf '%s' "$line" | sed "s/^[^:]*:[[:space:]]*//")"
;;
*)
val="$line"
;;
esac
# strip surrounding quotes
val="$(printf '%s' "$val" | sed "s/^['\"]//; s/['\"]$//")"
[ -n "$val" ] || continue
# skip dae example placeholders (ss://LINK, server-ip:port, localhost:1080 ...)
case "$val" in
*://LINK|*LINK*|*server-ip*|*://localhost:*|*example.com*) continue ;;
esac
sid="$(uci add dae "$stype")"
[ -n "$tag" ] && uci set "dae.$sid.tag=$tag"
uci set "dae.$sid.$field=$val"
uci set "dae.$sid.enabled=1"
done
}
do_import() {
[ -f "$CONFIG_DAE" ] || { echo "no config.dae" >&2; return 1; }
while uci -q delete dae.@subscription[0] >/dev/null 2>&1; do :; done
while uci -q delete dae.@node[0] >/dev/null 2>&1; do :; done
import_block subscription subscription url
import_block node node link
uci commit dae
echo "ok"
}
# Converter writes a batch of share links to a local file that dae reads as a
# file:// subscription, so a converted airport is one subscription instead of
# hundreds of manual nodes. $1=id (airport id), reads links from SUB_STAGE.
# id is restricted to [A-Za-z0-9_] to keep the path inside SUBS_DIR.
do_write_sub() {
local id="$1"
case "$id" in ''|*[!A-Za-z0-9_]*) echo "bad id" >&2; return 2 ;; esac
[ -f "$SUB_STAGE" ] || { echo "no staged data" >&2; return 1; }
mkdir -p "$SUBS_DIR"
# dae rejects subscription/config files readable by group/other (needs <=0640)
cp "$SUB_STAGE" "$SUBS_DIR/$id.sub.tmp" || { echo "write failed" >&2; return 1; }
chmod 0600 "$SUBS_DIR/$id.sub.tmp"
mv "$SUBS_DIR/$id.sub.tmp" "$SUBS_DIR/$id.sub"
rm -f "$SUB_STAGE"
echo "ok"
}
do_delete_sub() {
local id="$1"
case "$id" in ''|*[!A-Za-z0-9_]*) return 0 ;; esac
rm -f "$SUBS_DIR/$id.sub"
echo "ok"
}
# ===================== entry =====================
case "$1" in
generate) generate ;;
import) do_import ;;
write-sub) do_write_sub "$2" ;;
delete-sub) do_delete_sub "$2" ;;
*) echo "usage: $0 {generate|import|write-sub <id>|delete-sub <id>}" >&2; exit 2 ;;
esac
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# geo-cron.sh enable|disable
# Manage the geo auto-update crontab entry. enable reads the schedule from
# daede.config.geo_auto_freq (daily|weekly); both actions are idempotent.
ACTION="$1"
CRONTAB="/etc/crontabs/root"
TAG="# luci-app-daede geo-update"
SCRIPT="/usr/share/luci-app-daede/update-geo.sh"
# Drop any previous daede geo entry (tag line + its command line).
clean() {
[ -f "$CRONTAB" ] || return 0
grep -vF -e "$TAG" -e "$SCRIPT" "$CRONTAB" > "$CRONTAB.tmp" 2>/dev/null
mv "$CRONTAB.tmp" "$CRONTAB"
}
clean
if [ "$ACTION" = "enable" ]; then
case "$(uci -q get daede.config.geo_auto_freq)" in
weekly) sched="17 4 * * 0" ;; # Sunday 04:17
*) sched="17 4 * * *" ;; # every day 04:17
esac
mkdir -p /etc/crontabs
{
echo "$TAG"
echo "$sched $SCRIPT geoip; $SCRIPT geosite"
} >> "$CRONTAB"
/etc/init.d/cron enable >/dev/null 2>&1
fi
/etc/init.d/cron restart >/dev/null 2>&1
exit 0
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# pkg-info.sh <package>
# Prints "<installed>\t<latest>" for the named package, where either field is
# empty when unknown. Used by the Updates view to decide whether to enable
# the [Upgrade] button.
PKG="$1"
case "$PKG" in
dae|daed|luci-app-daede) ;;
*) echo "" ; exit 64 ;;
esac
installed=""
latest=""
if command -v apk >/dev/null 2>&1; then
if apk info -e "$PKG" >/dev/null 2>&1; then
installed=$(apk list -I "$PKG" 2>/dev/null | awk -v p="$PKG" '
$1 ~ "^" p "-" {
sub("^" p "-", "", $1);
print $1;
exit
}
')
fi
# apk search treats ^ and $ as literal chars (not regex anchors), so the old
# `apk search "^pkg$"` matched nothing. Enumerate the exact-name package
# across all feeds via `apk list` and take the highest version. Restricting
# to "<pkg>-<digit>" avoids sibling names like <pkg>-geoip / luci-app-<pkg>.
latest=$(apk list "$PKG" 2>/dev/null | awk -v p="$PKG" '
$1 ~ "^" p "-[0-9]" {
v = $1; sub("^" p "-", "", v);
print v
}
' | sort -V | tail -1)
elif command -v opkg >/dev/null 2>&1; then
installed=$(opkg status "$PKG" 2>/dev/null | awk -F': ' '$1=="Version"{print $2; exit}')
latest=$(opkg info "$PKG" 2>/dev/null | awk -F': ' '$1=="Version"{print $2; exit}')
fi
case "$PKG" in
dae|daed)
case "$installed" in
20[0-9][0-9].[0-9]*) ;;
*) installed="" ;;
esac
;;
esac
printf '%s\t%s\n' "$installed" "$latest"
@@ -0,0 +1,27 @@
#!/bin/sh
# proxy-check.sh - test whether dae's transparent proxy can actually reach a
# blocked site (default YouTube). dae proxies the router's own WAN-bound traffic
# (wan_interface), so a plain request from here is routed through dae just like a
# LAN client would be. Prints one line: ok=<0|1> code=<http> ms=<n>
URL="${1:-https://www.youtube.com/generate_204}"
code=000
ms=0
if command -v curl >/dev/null 2>&1; then
out="$(curl -4 -sL --max-time 8 --connect-timeout 4 \
-o /dev/null -w '%{http_code} %{time_total}' "$URL" 2>/dev/null)"
code="$(printf '%s' "$out" | awk '{print $1}')"
t="$(printf '%s' "$out" | awk '{print $2}')"
[ -n "$code" ] || code=000
ms="$(awk -v x="${t:-0}" 'BEGIN{printf "%d", x*1000}')"
else
# busybox wget has no status/timing output — success is exit 0
if wget -q -T 8 -O /dev/null "$URL" 2>/dev/null; then code=204; else code=000; fi
fi
ok=0
case "$code" in 200|204|301|302) ok=1 ;; esac
echo "ok=$ok code=$code ms=$ms"
@@ -0,0 +1,21 @@
#!/bin/sh
# Background apk/opkg update for the Updates view. Returns at once; throttled.
LOCK=/tmp/luci-app-daede.idx.lock
if [ -f "$LOCK" ]; then
mtime=$(date -r "$LOCK" +%s 2>/dev/null || echo 0)
[ "$(( $(date +%s) - mtime ))" -lt 90 ] && exit 0
fi
: > "$LOCK"
(
# -n: skip if a package op holds the shared apk lock (no need to refresh then)
if command -v apk >/dev/null 2>&1; then
flock -n /tmp/luci-app-daede.apk.lock apk update
elif command -v opkg >/dev/null 2>&1; then
opkg update
fi
) >/dev/null 2>&1 </dev/null &
exit 0
@@ -0,0 +1,81 @@
#!/bin/sh
# Publish converter snapshots without exposing a partially written final file.
set -eu
DIR=/etc/daed
PREFIX=daede-sub
STAGE_PREFIX=.daede-sub-stage
fail() {
echo "$*" >&2
exit 1
}
valid_token() {
[ "$#" -eq 1 ] || return 1
case "$1" in
''|*[!A-Za-z0-9]*) return 1 ;;
esac
[ "${#1}" -le 64 ]
}
stage_path() { printf '%s/%s-%s\n' "$DIR" "$STAGE_PREFIX" "$1"; }
final_path() { printf '%s/%s-%s\n' "$DIR" "$PREFIX" "$1"; }
regular_file() {
[ -f "$1" ] && [ ! -L "$1" ]
}
safe_dir() {
[ -d "$DIR" ] && [ ! -L "$DIR" ] || fail "invalid snapshot directory"
}
publish() {
[ "$#" -eq 1 ] || fail 'publish expects one token'
valid_token "$1" || fail 'invalid snapshot token'
safe_dir
stage=$(stage_path "$1")
final=$(final_path "$1")
regular_file "$stage" || fail 'snapshot stage is not a regular file'
[ ! -e "$final" ] && [ ! -L "$final" ] || fail 'snapshot already exists'
chmod 600 "$stage" || fail 'cannot secure snapshot stage'
mv -f "$stage" "$final" || fail 'cannot publish snapshot'
}
replace() {
[ "$#" -eq 2 ] || fail 'replace expects existing and stage tokens'
valid_token "$1" || fail 'invalid existing snapshot token'
valid_token "$2" || fail 'invalid stage snapshot token'
safe_dir
[ "$1" != "$2" ] || fail 'existing and stage tokens must differ'
stage=$(stage_path "$2")
final=$(final_path "$1")
regular_file "$stage" || fail 'snapshot stage is not a regular file'
if [ -e "$final" ] || [ -L "$final" ]; then
regular_file "$final" || fail 'existing snapshot is not a regular file'
fi
chmod 600 "$stage" || fail 'cannot secure snapshot stage'
mv -f "$stage" "$final" || fail 'cannot replace snapshot'
}
discard() {
[ "$#" -eq 1 ] || fail 'discard expects one token'
valid_token "$1" || fail 'invalid snapshot token'
safe_dir
stage=$(stage_path "$1")
if [ -L "$stage" ]; then
fail 'refusing symlink snapshot stage'
fi
if [ -e "$stage" ]; then
[ -f "$stage" ] || fail 'snapshot stage is not a regular file'
rm -f "$stage" || fail 'cannot discard snapshot stage'
fi
}
case "${1:-}" in
publish) shift; publish "$@" ;;
replace) shift; replace "$@" ;;
discard) shift; discard "$@" ;;
*) fail 'unknown snapshot action' ;;
esac
+111
View File
@@ -0,0 +1,111 @@
#!/bin/sh
# update-geo.sh <geoip|geosite>
# Fork-and-detach so the LuCI RPC call returns immediately. The diagnostics
# card polls the .dat file's mtime/size to detect completion. A lock file
# in /tmp prevents concurrent runs.
TYPE="$1"
# Default source: Loyalsoldier/v2ray-rules-dat — daily-built, China-optimized,
# ships both geoip.dat and geosite.dat. Users can override per-type via
# daede.config.geoip_url / geosite_url (empty falls back to the default below).
case "$TYPE" in
geoip)
DEF_URL="https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat"
URL="$(uci -q get daede.config.geoip_url)"
DEST="/usr/share/v2ray/geoip.dat"
;;
geosite)
DEF_URL="https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"
URL="$(uci -q get daede.config.geosite_url)"
DEST="/usr/share/v2ray/geosite.dat"
;;
*)
echo "usage: $0 <geoip|geosite>" >&2
exit 64
;;
esac
[ -n "$URL" ] || URL="$DEF_URL"
LOCK="/tmp/luci-app-daede.${TYPE}.lock"
LOG="/tmp/luci-app-daede.${TYPE}.log"
RLOCK="/tmp/luci-app-daede.geo-reload.lock"
schedule_backend_reload() {
if [ -d "$RLOCK" ]; then
rmt=$(date -r "$RLOCK" +%s 2>/dev/null || echo 0)
[ $(( $(date +%s) - rmt )) -gt 300 ] && rmdir "$RLOCK" 2>/dev/null
fi
mkdir "$RLOCK" 2>/dev/null || return 0
(
sleep 25
ab="$(uci -q get daede.config.active_backend 2>/dev/null || echo daed)"
if [ "$ab" = dae ]; then
bin=dae; act=hot_reload
else
bin=daed; act=reload
fi
if /bin/pidof "$bin" >/dev/null 2>&1; then
if "/etc/init.d/$bin" "$act" >/dev/null 2>&1; then
echo "$(date '+%F %T') reloaded $bin"
else
echo "$(date '+%F %T') $bin reload failed"
fi
else
echo "$(date '+%F %T') $bin not running; skip reload"
fi
rmdir "$RLOCK" 2>/dev/null
) </dev/null >>"$LOG" 2>&1 &
}
if [ -f "$LOCK" ]; then
# Lock is fresh (< 5 min)? Refuse. Stale? Remove and proceed.
mtime=$(date -r "$LOCK" +%s 2>/dev/null || echo 0)
age=$(( $(date +%s) - mtime ))
if [ "$age" -lt 300 ]; then
echo "${TYPE} update already in progress (PID $(cat "$LOCK" 2>/dev/null), age ${age}s)" >&2
exit 75
fi
rm -f "$LOCK"
fi
if ! ( set -C; echo "$$" >"$LOCK" ) 2>/dev/null; then
echo "${TYPE} update already in progress" >&2
exit 75
fi
# Spawn detached worker — parent returns immediately to LuCI.
(
exec >"$LOG" 2>&1
trap 'rm -f "$LOCK"' EXIT INT TERM
rc=0
TMP="${DEST}.new"
mkdir -p "$(dirname "$DEST")"
echo "$(date '+%F %T') begin: $URL"
if ! curl -fsSL --connect-timeout 15 --max-time 240 -o "$TMP" "$URL"; then
echo "$(date '+%F %T') download failed"
rm -f "$TMP"
rc=1
else
size=$(wc -c < "$TMP" 2>/dev/null || echo 0)
magic=$(hexdump -n 1 -e '1/1 "%02x"' "$TMP" 2>/dev/null)
if [ "$size" -lt 1024 ]; then
echo "$(date '+%F %T') file too small ($size bytes)"
rm -f "$TMP"
rc=2
elif [ "$magic" != "0a" ]; then
echo "$(date '+%F %T') invalid geodata format"
rm -f "$TMP"
rc=3
else
mv "$TMP" "$DEST"
echo "$(date '+%F %T') updated $DEST ($size bytes)"
schedule_backend_reload
fi
fi
if [ "$rc" = 0 ]; then echo "$(date '+%F %T') ✓ 完成"; else echo "$(date '+%F %T') ✗ 失败 (rc=$rc)"; fi
) </dev/null >/dev/null 2>&1 &
echo "started in background, see $LOG"
exit 0
+105
View File
@@ -0,0 +1,105 @@
#!/bin/sh
# update-pkg.sh <dae|daed|luci-app-daede>
# Refresh package indexes and upgrade the named package via apk (25.12+) or
# opkg (24.10). Forks the work to background so the LuCI RPC call returns
# immediately; the result is streamed to /tmp/luci-app-daede.pkg.<name>.log.
PKG="$1"
case "$PKG" in
dae|daed|luci-app-daede) ;;
*)
echo "usage: $0 <dae|daed|luci-app-daede>" >&2
exit 64
;;
esac
# run from a /tmp copy so upgrading luci-app-daede (which replaces this script)
# can't corrupt the in-flight upgrade
case "$0" in
/tmp/.daede-upd-*) ;;
*)
_self="/tmp/.daede-upd-$$"
cp "$0" "$_self" 2>/dev/null && exec sh "$_self" "$@"
;;
esac
LOCK="/tmp/luci-app-daede.pkg-${PKG}.lock"
LOG="/tmp/luci-app-daede.pkg-${PKG}.log"
if [ -f "$LOCK" ]; then
mtime=$(date -r "$LOCK" +%s 2>/dev/null || echo 0)
age=$(( $(date +%s) - mtime ))
if [ "$age" -lt 300 ]; then
echo "${PKG} update already in progress (PID $(cat "$LOCK" 2>/dev/null), age ${age}s)" >&2
exit 75
fi
rm -f "$LOCK"
fi
if ! ( set -C; echo "$$" >"$LOCK" ) 2>/dev/null; then
echo "${PKG} update already in progress" >&2
exit 75
fi
(
exec >"$LOG" 2>&1
trap 'rm -f "$LOCK"; [ "${0#/tmp/.daede-upd-}" != "$0" ] && rm -f "$0"' EXIT INT TERM
echo "$(date '+%F %T') begin upgrade: $PKG"
if command -v apk >/dev/null 2>&1; then
# shared apk lock with the bg index refresh (avoid "Unable to lock database")
(
flock 9
apk update 2>&1
ver=$(apk list "$PKG" 2>/dev/null | awk -v p="$PKG" '$1 ~ "^" p "-[0-9]" { v=$1; sub("^" p "-", "", v); print v }' | sort -V | tail -1)
if [ -n "$ver" ]; then
constraint="$PKG=$ver"
else
echo "result: 软件源中没有找到 $PKG,请检查网络或软件源配置"
exit 1
fi
echo "--- apk add -s $constraint ---"
if ! apk add -s "$constraint" 2>&1; then
echo "note: apk 预检失败,通常是系统上其它软件包的问题,继续尝试升级"
fi
echo "--- apk add $constraint ---"
apk add "$constraint" 2>&1
exit 0
) 9>/tmp/luci-app-daede.apk.lock
rc=$?
if [ "$rc" != 0 ]; then
:
elif ! apk list --installed 2>/dev/null | grep -q "^${PKG}-"; then
echo "result: $PKG is not installed"
rc=1
elif apk list -u 2>/dev/null | grep -q "^${PKG}-"; then
echo "result: $PKG still has a pending upgrade"
rc=1
else
echo "result: $PKG is at the latest available version"
rc=0
fi
elif command -v opkg >/dev/null 2>&1; then
echo "--- opkg update ---"
opkg update 2>&1
echo "--- opkg upgrade $PKG ---"
opkg upgrade "$PKG" 2>&1
rc=$?
else
echo "no package manager found"
exit 3
fi
if [ "$rc" = 0 ]; then echo "$(date '+%F %T') ✓ 完成"; else echo "$(date '+%F %T') ✗ 失败 (rc=$rc)"; fi
# luci-app-daede upgrade replaces ACL JSON — reload rpcd so changes apply.
if [ "$PKG" = "luci-app-daede" ] && [ "$rc" = "0" ]; then
echo "reloading rpcd to pick up new ACL"
/etc/init.d/rpcd reload 2>&1
fi
) </dev/null >/dev/null 2>&1 &
echo "started in background, see $LOG"
exit 0
@@ -0,0 +1,44 @@
{
"admin/services/daede": {
"title": "daede",
"order": 20,
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-daede" ]
}
},
"admin/services/daede/config": {
"title": "Settings",
"order": 10,
"action": {
"type": "view",
"path": "daede/config"
}
},
"admin/services/daede/converter": {
"title": "Subscription Converter",
"order": 20,
"action": {
"type": "view",
"path": "daede/converter"
}
},
"admin/services/daede/updates": {
"title": "Updates",
"order": 30,
"action": {
"type": "view",
"path": "daede/updates"
}
},
"admin/services/daede/log": {
"title": "Log",
"order": 40,
"action": {
"type": "view",
"path": "daede/log"
}
}
}
@@ -0,0 +1,97 @@
{
"luci-app-daede": {
"description": "Grant access to dae/daed configuration",
"read": {
"file": {
"/etc/init.d/dae": [ "read" ],
"/etc/init.d/daed": [ "read" ],
"/etc/config/daede": [ "read" ],
"/etc/dae/config.dae": [ "read" ],
"/etc/dae/example.dae": [ "read" ],
"/var/log/dae/dae.log": [ "read" ],
"/var/log/daed/daed.log": [ "read" ],
"/usr/share/v2ray/geoip.dat": [ "read" ],
"/usr/share/v2ray/geosite.dat": [ "read" ],
"/sys/kernel/btf/vmlinux": [ "read" ],
"/usr/lib/debug/boot/vmlinux": [ "read" ],
"/run/netns/daens": [ "read" ],
"/tmp/luci-app-daede.geoip.log": [ "read" ],
"/tmp/luci-app-daede.geosite.log": [ "read" ],
"/tmp/luci-app-daede.pkg-dae.log": [ "read" ],
"/tmp/luci-app-daede.pkg-daed.log": [ "read" ],
"/tmp/luci-app-daede.pkg-luci-app-daede.log": [ "read" ],
"/tmp/luci-app-daede.backup.log": [ "read" ],
"/bin/pidof dae": [ "exec" ],
"/bin/pidof daed": [ "exec" ],
"/usr/bin/dae validate -c /tmp/dae-validate.dae": [ "exec" ],
"/usr/bin/dae --version": [ "exec" ],
"/bin/rm -f /tmp/dae-validate.dae": [ "exec" ],
"/usr/share/luci-app-daede/pkg-info.sh dae": [ "exec" ],
"/usr/share/luci-app-daede/pkg-info.sh daed": [ "exec" ],
"/usr/share/luci-app-daede/pkg-info.sh luci-app-daede": [ "exec" ],
"/usr/share/luci-app-daede/refresh-index.sh": [ "exec" ],
"/usr/share/luci-app-daede/proxy-check.sh": [ "exec" ]
},
"ubus": {
"service": [ "list" ]
},
"uci": [ "dae", "daed", "daede" ]
},
"write": {
"file": {
"/etc/dae/config.dae": [ "write" ],
"/tmp/dae-validate.dae": [ "write" ],
"/tmp/daede-import.b64": [ "write" ],
"/tmp/daede-sub.txt": [ "write" ],
"/etc/daed/.daede-sub-stage-*": [ "write" ],
"/etc/daed/daede-sub-*": [ "write" ],
"/usr/share/luci-app-daede/config-backup.sh import": [ "exec" ],
"/usr/share/luci-app-daede/snapshot-file.sh publish *": [ "exec" ],
"/usr/share/luci-app-daede/snapshot-file.sh replace * *": [ "exec" ],
"/usr/share/luci-app-daede/snapshot-file.sh discard *": [ "exec" ],
"/usr/share/luci-app-daede/gen-dae-config.sh write-sub *": [ "exec" ],
"/usr/share/luci-app-daede/gen-dae-config.sh delete-sub *": [ "exec" ],
"/etc/config/daede": [ "write" ],
"/var/log/dae/dae.log": [ "write" ],
"/var/log/daed/daed.log": [ "write" ],
"/etc/init.d/dae start": [ "exec" ],
"/etc/init.d/dae stop": [ "exec" ],
"/etc/init.d/dae restart": [ "exec" ],
"/etc/init.d/dae enable": [ "exec" ],
"/etc/init.d/dae disable": [ "exec" ],
"/etc/init.d/dae hot_reload": [ "exec" ],
"/etc/init.d/daed start": [ "exec" ],
"/etc/init.d/daed stop": [ "exec" ],
"/etc/init.d/daed restart": [ "exec" ],
"/etc/init.d/daed enable": [ "exec" ],
"/etc/init.d/daed disable": [ "exec" ],
"/sbin/uci set dae.config.enabled=*": [ "exec" ],
"/sbin/uci set daed.config.enabled=*": [ "exec" ],
"/sbin/uci set daede.config=daede": [ "exec" ],
"/sbin/uci set daede.config.active_backend=*": [ "exec" ],
"/sbin/uci commit dae": [ "exec" ],
"/sbin/uci commit daed": [ "exec" ],
"/sbin/uci commit daede": [ "exec" ],
"/sbin/ip netns del daens": [ "exec" ],
"/usr/share/luci-app-daede/update-geo.sh geoip": [ "exec" ],
"/usr/share/luci-app-daede/update-geo.sh geosite": [ "exec" ],
"/usr/share/luci-app-daede/geo-cron.sh enable": [ "exec" ],
"/usr/share/luci-app-daede/geo-cron.sh disable": [ "exec" ],
"/usr/share/luci-app-daede/update-pkg.sh dae": [ "exec" ],
"/usr/share/luci-app-daede/update-pkg.sh daed": [ "exec" ],
"/usr/share/luci-app-daede/update-pkg.sh luci-app-daede": [ "exec" ],
"/usr/share/luci-app-daede/fetch-clash-yaml.sh *": [ "exec" ],
"/usr/share/luci-app-daede/gen-dae-config.sh generate": [ "exec" ],
"/usr/share/luci-app-daede/gen-dae-config.sh import": [ "exec" ],
"/usr/share/luci-app-daede/daed-sub-update.sh": [ "exec" ],
"/usr/share/luci-app-daede/daed-sub-cron.sh enable": [ "exec" ],
"/usr/share/luci-app-daede/daed-sub-cron.sh disable": [ "exec" ],
"/usr/share/luci-app-daede/config-backup.sh export": [ "exec" ],
"/usr/share/luci-app-daede/config-backup.sh reset": [ "exec" ],
"/usr/share/luci-app-daede/config-backup.sh import /tmp/daede-import.*.b64": [ "exec" ],
"/tmp/daede-import.*.b64": [ "write" ]
},
"uci": [ "dae", "daed", "daede" ]
}
}
}
@@ -0,0 +1,39 @@
#!/bin/sh
# daede-graphql - same-origin GraphQL relay to local daed.
#
# HTTPS LuCI pages can't fetch daed's plain-HTTP :2023 (mixed content),
# so the converter posts here and we relay to 127.0.0.1 over loopback.
# daed still checks its own bearer token; no extra exposure. See #11.
reply() { printf 'Status: %s\r\nContent-Type: application/json\r\n\r\n%s\n' "$1" "$2"; exit 0; }
fail() { reply '502 Bad Gateway' '{"errors":[{"message":"daed relay failed"}]}'; }
[ "$REQUEST_METHOD" = "POST" ] || reply '405 Method Not Allowed' '{"errors":[{"message":"POST only"}]}'
# daed listen port from uci, default 2023
port=$(uci -q get daed.config.listen_addr | sed -n 's/.*:\([0-9]\{1,5\}\)$/\1/p')
[ -n "$port" ] || port=2023
len=${CONTENT_LENGTH:-0}
[ "$len" -gt 0 ] 2>/dev/null || fail
body=$(mktemp /tmp/daede-gql.XXXXXX) || fail
head -c "$len" > "$body"
if [ -n "$HTTP_AUTHORIZATION" ]; then
resp=$(uclient-fetch -q -O - \
--header="Content-Type: ${CONTENT_TYPE:-application/json}" \
--header="Authorization: $HTTP_AUTHORIZATION" \
--post-file="$body" \
"http://127.0.0.1:$port/graphql" 2>/dev/null)
else
resp=$(uclient-fetch -q -O - \
--header="Content-Type: ${CONTENT_TYPE:-application/json}" \
--post-file="$body" \
"http://127.0.0.1:$port/graphql" 2>/dev/null)
fi
rc=$?
rm -f "$body"
[ "$rc" = 0 ] || fail
reply '200 OK' "$resp"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# daede-sub - serve a converter's staged node list to the local daed only.
#
# daed (dae-wing) can only build a subscription by HTTP-fetching a link, and it
# does not read file:// like dae core. So the converter stores the base64 share
# links in /etc/daed/daede-sub-<token> and points daed's importSubscription at
# http://127.0.0.1/cgi-bin/daede-sub?t=<token>. This CGI is the read side.
#
# Locked to loopback (REMOTE_ADDR), so even though uhttpd listens on the LAN no
# remote client can pull the links. The snapshot remains available for daed's
# own refresh action and is included in daede configuration backups.
deny() { printf 'Status: 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nforbidden\n'; exit 0; }
[ "$REMOTE_ADDR" = "127.0.0.1" ] || deny
# token: only [A-Za-z0-9] so the path can't escape /tmp
t=$(printf '%s' "$QUERY_STRING" | sed -n 's/^.*\bt=\([A-Za-z0-9]\{1,64\}\).*$/\1/p')
[ -n "$t" ] || deny
f="/etc/daed/daede-sub-$t"
[ -f "$f" ] || deny
printf 'Content-Type: text/plain\r\n\r\n'
cat "$f"