Files
NeoTachyon/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js
T
Dushnilin 5539b9701f fix(singbox): prevent UDP routing to HTTP outbounds (#45) and improve dashboard metadata
- Ensure selector and urltest groups require all member outbounds to support UDP
- Add explicit reject rule for UDP on non-UDP outbounds to avoid sing-box router log spam
- Add http/s:// to connection URL description in LuCI settings and update localization
- Display transport protocol badges on dashboard outbound cards and modal rows
- Deduplicate and collapse UDP routing error lines in telegram logs and bug report
2026-09-07 22:50:10 +03:00

11383 lines
316 KiB
JavaScript

"use strict";
"require form";
"require baseclass";
"require fs";
"require network";
"require ui";
"require uci";
"require view.tachyon.local_devices as localDevices";
"require view.tachyon.main as main";
const UCI_PACKAGE = main.TACHYON_UCI_PACKAGE;
const ACTION_PROVIDERS_AVAILABILITY_EVENT =
main.TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT ||
"tachyon:action-providers-availability";
const RULE_SET_ITEM_SETTINGS_KEY = "rule_set_settings";
const ROUTING_ACTIONS = [
"connection",
"proxy",
"outbound",
"vpn",
"bypass",
"block",
"zapret",
"zapret2",
"byedpi",
"awg",
"warp",
"anytls",
"snell",
"mieru",
"sudoku",
"masque",
"openvpn",
"hosts",
];
const CONNECTIONS_BLOCKED_INTERFACES = [
"br-lan",
"eth0",
"eth1",
"wan",
"phy0-ap0",
"phy1-ap0",
"pppoe-wan",
"lan",
];
function valuesToText(values) {
if (!values) {
return "";
}
if (Array.isArray(values)) {
return values.filter(Boolean).join("\n");
}
return values ? `${values}` : "";
}
function normalizeOptionValues(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value
.filter(Boolean)
.map((item) => `${item}`.trim())
.filter(Boolean);
}
return `${value}`
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
}
function getUciSectionName(section) {
return section && section[".name"] ? section[".name"] : "";
}
function getUciSectionLabel(section) {
return (section && section.label) || getUciSectionName(section);
}
function isOutboundDetourTargetSection(section, currentSectionId) {
const sectionName = getUciSectionName(section);
const action = (section && section.action) || "";
return (
sectionName &&
sectionName !== currentSectionId &&
section.enabled !== "0" &&
[
"connection",
"proxy",
"outbound",
"vpn",
"awg",
"warp",
"anytls",
"snell",
"mieru",
"sudoku",
"masque",
"openvpn",
].includes(action)
);
}
function getOutboundDetourTargetSections(currentSectionId) {
return (uci.sections(UCI_PACKAGE, "section") || []).filter((section) =>
isOutboundDetourTargetSection(section, currentSectionId),
);
}
function getDefaultOutboundDetourSection(currentSectionId) {
const targetSections = getOutboundDetourTargetSections(currentSectionId);
return targetSections.length ? getUciSectionName(targetSections[0]) : "";
}
function refreshOutboundDetourSectionOptionValues(option, sectionId) {
option.keylist = [];
option.vallist = [];
getOutboundDetourTargetSections(sectionId).forEach((targetSection) => {
option.value(
getUciSectionName(targetSection),
getUciSectionLabel(targetSection),
);
});
}
function isDnsDetourTargetSection(section, currentSectionId) {
const sectionName = getUciSectionName(section);
const action = (section && section.action) || "";
if (
!sectionName ||
sectionName === currentSectionId ||
section.enabled === "0"
) {
return false;
}
if (
[
"connection",
"proxy",
"outbound",
"vpn",
"awg",
"warp",
"anytls",
"snell",
"mieru",
"sudoku",
"masque",
"openvpn",
].includes(action)
) {
return true;
}
if (action === "zapret") {
return isZapretInstalledForUi();
}
if (action === "zapret2") {
return isZapret2InstalledForUi();
}
return action === "byedpi" && isByedpiInstalledForUi();
}
function refreshDnsDetourSectionOptionValues(option, sectionId) {
option.keylist = [];
option.vallist = [];
(uci.sections(UCI_PACKAGE, "section") || [])
.filter((section) => isDnsDetourTargetSection(section, sectionId))
.forEach((targetSection) => {
option.value(
getUciSectionName(targetSection),
getUciSectionLabel(targetSection),
);
});
}
function dependsOnRoutingAction(option) {
ROUTING_ACTIONS.forEach((action) => option.depends("action", action));
return option;
}
function dependsOnRuleConditions(option) {
const routingConditions = [
"domain",
"ip_cidr",
"community_lists",
"rule_set",
"domain_ip_lists",
"ports",
];
ROUTING_ACTIONS.forEach((action) =>
routingConditions.forEach((condition) =>
option.depends({ action, [condition]: /\S/ }),
),
);
[
"domain",
"community_lists",
"_dns_rule_set",
"_dns_domain_ip_lists",
].forEach((condition) =>
option.depends({ action: "dns", [condition]: /\S/ }),
);
return option;
}
const ZAPRET_LEGACY_DEFAULT_NFQWS_OPT =
"--filter-tcp=80 <HOSTLIST> --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 <HOSTLIST_NOAUTO> --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 <HOSTLIST> --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1";
const ZAPRET_DEFAULT_NFQWS_OPT =
"--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin";
const ZAPRET2_DEFAULT_NFQWS2_OPT =
"--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6";
const BYEDPI_DEFAULT_CMD_OPTS = "-o 2 --auto=t,r,a,s -d 2";
const ANNOTATED_TEXTAREA_STYLE_ID = "fkp-annotated-textarea-styles";
const CONNECTIONS_DYNLIST_STYLE_ID = "fkp-connections-dynlist-styles";
const NFQWS_REMOTE_VALIDATION_DEBOUNCE_MS = 500;
const NFQWS_VALIDATION_COMMAND = "/usr/bin/tachyon";
const nfqwsRemoteValidationCache = new Map();
const nfqwsRemoteValidationInflight = new Map();
const nfqws2RemoteValidationCache = new Map();
const nfqws2RemoteValidationInflight = new Map();
const byedpiRemoteValidationCache = new Map();
const byedpiRemoteValidationInflight = new Map();
const BYEDPI_LONG_VALUE_OPTIONS = new Set([
"--max-conn",
"--conn-ip",
"--buf-size",
"--debug",
"--def-ttl",
"--auto",
"--auto-mode",
"--cache-ttl",
"--cache-dump",
"--timeout",
"--proto",
"--hosts",
"--ipset",
"--pf",
"--round",
"--split",
"--disorder",
"--oob",
"--disoob",
"--fake",
"--fake-sni",
"--ttl",
"--fake-offset",
"--fake-data",
"--fake-tls-mod",
"--oob-data",
"--mod-http",
"--tlsrec",
"--tlsminor",
"--udp-fake",
]);
const BYEDPI_LONG_FLAG_OPTIONS = new Set([
"--md5sig",
"--tfo",
"--drop-sack",
"--no-domain",
"--no-udp",
]);
const BYEDPI_SHORT_VALUE_OPTIONS = new Set([
"-c",
"-I",
"-b",
"-x",
"-g",
"-A",
"-L",
"-u",
"-y",
"-T",
"-K",
"-H",
"-j",
"-V",
"-R",
"-s",
"-d",
"-o",
"-q",
"-f",
"-n",
"-t",
"-O",
"-l",
"-Q",
"-e",
"-M",
"-r",
"-m",
"-a",
]);
const BYEDPI_SHORT_FLAG_OPTIONS = new Set(["-N", "-U", "-F", "-S", "-Y"]);
const NFQWS_OPTIONAL_ARG_OPTIONS = new Set([
"--comment",
"--ctrack-disable",
"--debug",
"--dpi-desync-any-protocol",
"--dpi-desync-autottl",
"--dpi-desync-autottl6",
"--dpi-desync-skip-nosni",
"--dpi-desync-tcp-flags-set",
"--dpi-desync-tcp-flags-unset",
"--dup-autottl",
"--dup-autottl6",
"--dup-replace",
"--dup-tcp-flags-set",
"--dup-tcp-flags-unset",
"--ipcache-hostname",
"--orig-autottl",
"--orig-autottl6",
"--orig-tcp-flags-set",
"--orig-tcp-flags-unset",
"--synack-split",
]);
const NFQWS_NO_ARG_OPTIONS = new Set([
"--bind-fix4",
"--bind-fix6",
"--daemon",
"--domcase",
"--dry-run",
"--hostcase",
"--hostnospace",
"--methodeol",
"--new",
"--skip",
"--version",
]);
const NFQWS_REQUIRED_ARG_OPTIONS = new Set([
"--ctrack-timeouts",
"--dpi-desync",
"--dpi-desync-badack-increment",
"--dpi-desync-badseq-increment",
"--dpi-desync-cutoff",
"--dpi-desync-fake-dht",
"--dpi-desync-fake-discord",
"--dpi-desync-fake-http",
"--dpi-desync-fake-quic",
"--dpi-desync-fake-stun",
"--dpi-desync-fake-syndata",
"--dpi-desync-fake-tcp-mod",
"--dpi-desync-fake-tls",
"--dpi-desync-fake-tls-mod",
"--dpi-desync-fake-unknown",
"--dpi-desync-fake-unknown-udp",
"--dpi-desync-fake-wireguard",
"--dpi-desync-fakedsplit-mod",
"--dpi-desync-fakedsplit-pattern",
"--dpi-desync-fooling",
"--dpi-desync-fwmark",
"--dpi-desync-hostfakesplit-midhost",
"--dpi-desync-hostfakesplit-mod",
"--dpi-desync-ipfrag-pos-tcp",
"--dpi-desync-ipfrag-pos-udp",
"--dpi-desync-repeats",
"--dpi-desync-split-http-req",
"--dpi-desync-split-pos",
"--dpi-desync-split-seqovl",
"--dpi-desync-split-seqovl-pattern",
"--dpi-desync-split-tls",
"--dpi-desync-start",
"--dpi-desync-ts-increment",
"--dpi-desync-ttl",
"--dpi-desync-ttl6",
"--dpi-desync-udplen-increment",
"--dpi-desync-udplen-pattern",
"--dup",
"--dup-badack-increment",
"--dup-badseq-increment",
"--dup-cutoff",
"--dup-fooling",
"--dup-ip-id",
"--dup-start",
"--dup-ts-increment",
"--dup-ttl",
"--dup-ttl6",
"--filter-l3",
"--filter-l7",
"--filter-tcp",
"--filter-udp",
"--hostlist",
"--hostlist-auto",
"--hostlist-auto-debug",
"--hostlist-auto-fail-threshold",
"--hostlist-auto-fail-time",
"--hostlist-auto-retrans-threshold",
"--hostlist-domains",
"--hostlist-exclude",
"--hostlist-exclude-domains",
"--hostspell",
"--ip-id",
"--ipcache-lifetime",
"--ipset",
"--ipset-exclude",
"--ipset-exclude-ip",
"--ipset-ip",
"--orig-mod-cutoff",
"--orig-mod-start",
"--orig-ttl",
"--orig-ttl6",
"--pidfile",
"--qnum",
"--uid",
"--user",
"--wsize",
"--wssize",
"--wssize-cutoff",
"--wssize-forced-cutoff",
]);
const NFQWS2_OPTIONAL_ARG_OPTIONS = new Set([
"--chdir",
"--comment",
"--ctrack-disable",
"--debug",
"--hostlist-auto-retrans-reset",
"--intercept",
"--ipcache-hostname",
"--new",
"--payload-disable",
"--reasm-disable",
"--server",
"--template",
"--writeable",
]);
const NFQWS2_NO_ARG_OPTIONS = new Set([
"--bind-fix4",
"--bind-fix6",
"--daemon",
"--dry-run",
"--skip",
"--version",
]);
const NFQWS2_REQUIRED_ARG_OPTIONS = new Set([
"--blob",
"--cookie",
"--ctrack-timeouts",
"--filter-l3",
"--filter-l7",
"--filter-tcp",
"--filter-udp",
"--fwmark",
"--fuzz",
"--hostlist",
"--hostlist-auto",
"--hostlist-auto-debug",
"--hostlist-auto-fail-threshold",
"--hostlist-auto-fail-time",
"--hostlist-auto-retrans-threshold",
"--hostlist-domains",
"--hostlist-exclude",
"--hostlist-exclude-domains",
"--import",
"--in-range",
"--ipcache-lifetime",
"--ipset",
"--ipset-exclude",
"--ipset-exclude-ip",
"--ipset-ip",
"--lua-gc",
"--lua-init",
"--lua-desync",
"--name",
"--out-range",
"--payload",
"--pidfile",
"--qnum",
"--uid",
"--user",
]);
const actionProvidersAvailabilityState = {
loaded: false,
zapretInstalled: false,
zapret2Installed: false,
byedpiInstalled: false,
torInstalled: false,
singBoxExtended: false,
};
let actionProvidersAvailabilityPromise = null;
let actionProvidersAvailabilityLoader = null;
const outboundNameChoicesCache = new Map();
const outboundNameChoicesInflight = new Map();
const outboundNameSourceOptions = new Map();
const sectionGroupSourceOptions = new Map();
const dashboardFilterChoiceRefreshers = new Map();
const perRuleDnsTypeState = new Map();
const perRuleDnsWidgets = new Map();
const SECTION_CACHE_DIR = "/var/run/tachyon/section-cache";
const COUNTRY_CODES =
"AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW XK".split(
" ",
);
const REGION_NAME_FALLBACKS = {
XK: "Kosovo",
};
let regionDisplayNamesCache = {};
function updateActionProvidersAvailabilityState(nextState) {
if (!nextState) {
return;
}
actionProvidersAvailabilityState.loaded = true;
if (typeof nextState.zapretInstalled !== "undefined") {
actionProvidersAvailabilityState.zapretInstalled = Boolean(
nextState.zapretInstalled,
);
}
if (typeof nextState.zapret2Installed !== "undefined") {
actionProvidersAvailabilityState.zapret2Installed = Boolean(
nextState.zapret2Installed,
);
}
if (typeof nextState.byedpiInstalled !== "undefined") {
actionProvidersAvailabilityState.byedpiInstalled = Boolean(
nextState.byedpiInstalled,
);
}
if (typeof nextState.singBoxExtended !== "undefined") {
actionProvidersAvailabilityState.singBoxExtended = Boolean(
nextState.singBoxExtended,
);
}
if (typeof nextState.torInstalled !== "undefined") {
actionProvidersAvailabilityState.torInstalled = Boolean(
nextState.torInstalled,
);
}
actionProvidersAvailabilityPromise = null;
}
function updateActionProvidersAvailabilityFromSystemInfo(systemInfo) {
if (!systemInfo || !systemInfo.providerInfoLoaded) {
return;
}
updateActionProvidersAvailabilityState({
zapretInstalled: Boolean(systemInfo.zapret_installed),
zapret2Installed: Boolean(systemInfo.zapret2_installed),
byedpiInstalled: Boolean(systemInfo.byedpi_installed),
singBoxExtended: Boolean(systemInfo.sing_box_extended),
torInstalled: Boolean(systemInfo.tor_installed),
});
}
function setActionProvidersAvailabilityLoader(loader) {
actionProvidersAvailabilityLoader =
typeof loader === "function" ? loader : null;
}
if (typeof window !== "undefined") {
window.addEventListener(ACTION_PROVIDERS_AVAILABILITY_EVENT, (event) => {
updateActionProvidersAvailabilityState(event.detail);
});
}
if (main.store && typeof main.store.subscribe === "function") {
main.store.subscribe((next, _prev, diff) => {
if (!diff || diff.diagnosticsSystemInfo) {
updateActionProvidersAvailabilityFromSystemInfo(
next.diagnosticsSystemInfo,
);
}
});
}
function getLuciLanguage() {
if (typeof L !== "undefined" && L.env && L.env.lang) {
return `${L.env.lang}`.replace("_", "-");
}
if (document.documentElement.lang) {
return document.documentElement.lang;
}
return navigator.language || "en";
}
function getRegionDisplayName(code) {
const normalizedCode = `${code || ""}`.toUpperCase();
const language = getLuciLanguage();
const cacheKey = `${language}:${normalizedCode}`;
if (regionDisplayNamesCache[cacheKey]) {
return regionDisplayNamesCache[cacheKey];
}
try {
if (typeof Intl !== "undefined" && Intl.DisplayNames) {
const displayNames = new Intl.DisplayNames([language, "en"], {
type: "region",
});
const displayName = displayNames.of(normalizedCode);
if (displayName && displayName !== normalizedCode) {
regionDisplayNamesCache[cacheKey] = displayName;
return displayName;
}
}
} catch (_error) {
// Fall through to the static fallback.
}
const fallback = REGION_NAME_FALLBACKS[normalizedCode] || normalizedCode;
regionDisplayNamesCache[cacheKey] = fallback;
return fallback;
}
function getCountryFlagEmoji(code) {
const normalizedCode = `${code || ""}`.toUpperCase();
if (!/^[A-Z]{2}$/.test(normalizedCode)) {
return "";
}
return String.fromCodePoint(
...normalizedCode
.split("")
.map((char) => 0x1f1e6 + char.charCodeAt(0) - 65),
);
}
function getCountryOptionLabel(code) {
return `${getCountryFlagEmoji(code)} ${getRegionDisplayName(code)}`;
}
function validateCountryCode(_section_id, value) {
const values = Array.isArray(value) ? value : [value];
const normalizedValues = values
.filter((item) => item && `${item}`.length)
.map((item) => `${item}`.toUpperCase());
if (!normalizedValues.length) {
return true;
}
return normalizedValues.every((item) => COUNTRY_CODES.includes(item))
? true
: _("Unknown country");
}
function plainObject(value) {
return value && typeof value === "object" && !Array.isArray(value)
? value
: {};
}
function safeCacheSectionName(section_id) {
return /^[A-Za-z0-9_-]+$/.test(`${section_id || ""}`);
}
function filteredOutboundMetadataFromCache(cache) {
const metadata = plainObject(plainObject(cache).outboundMetadata);
const names = plainObject(metadata.names);
const countries = plainObject(metadata.countries);
const candidateTags = Array.isArray(cache.urltestCandidateTags)
? cache.urltestCandidateTags
: [];
const groups = plainObject(cache.urltestGroups);
const result = {
names: {},
countries: {},
};
if (candidateTags.length > 0) {
candidateTags.forEach((tag) => {
tag = `${tag || ""}`;
if (!tag) {
return;
}
if (names[tag] != null) {
result.names[tag] = names[tag];
}
if (countries[tag] != null) {
result.countries[tag] = countries[tag];
}
});
return result;
}
Object.entries(names).forEach(([tag, name]) => {
if (!groups[tag]) {
result.names[tag] = name;
}
});
Object.entries(countries).forEach(([tag, country]) => {
if (!groups[tag]) {
result.countries[tag] = country;
}
});
return result;
}
function readOutboundMetadataFromSectionCache(section_id) {
const targetId = section_id || "Main";
if (!safeCacheSectionName(targetId)) {
return Promise.resolve({ names: {}, countries: {} });
}
return fs
.read(`${SECTION_CACHE_DIR}/${targetId}.json`)
.then((raw) => filteredOutboundMetadataFromCache(JSON.parse(raw || "{}")))
.catch(() => ({ names: {}, countries: {} }));
}
function loadOutboundNameChoices(section_id) {
const targetId = section_id || "Main";
if (outboundNameChoicesCache.has(targetId)) {
return Promise.resolve(outboundNameChoicesCache.get(targetId));
}
if (outboundNameChoicesInflight.has(targetId)) {
return outboundNameChoicesInflight.get(targetId);
}
const task = readOutboundMetadataFromSectionCache(targetId)
.then((metadata) => {
const names = Object.values(plainObject(metadata.names));
const choices = names
.filter(Boolean)
.filter((name, index, values) => values.indexOf(name) === index)
.sort((a, b) => `${a}`.localeCompare(`${b}`));
outboundNameChoicesCache.set(targetId, choices);
if (targetId !== "Main") {
outboundNameChoicesCache.set("Main", choices);
}
refreshDashboardFilterChoiceWidgets(targetId);
refreshDashboardFilterChoiceWidgets("Main");
return choices;
})
.catch(() => [])
.finally(() => {
outboundNameChoicesInflight.delete(targetId);
});
outboundNameChoicesInflight.set(targetId, task);
return task;
}
function normalizeDynamicListItems(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value
.map((item) => `${item || ""}`.trim())
.filter((item) => item.length);
}
const normalized = `${value}`.trim();
return normalized.length ? [normalized] : [];
}
function uniqueDynamicListItems(value) {
const seen = new Set();
const result = [];
normalizeDynamicListItems(value).forEach((item) => {
if (!seen.has(item)) {
seen.add(item);
result.push(item);
}
});
return result;
}
function readItemSettingsMap(section_id, settingsKey) {
const raw = uci.get(UCI_PACKAGE, section_id, settingsKey);
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(`${raw}`);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
} catch (_error) {
return {};
}
}
function compactItemSettings(values) {
const result = {};
Object.entries(values || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") {
return;
}
if (Array.isArray(value)) {
const items = value
.map((item) => `${item || ""}`.trim())
.filter((item) => item.length);
if (items.length) {
result[key] = items;
}
return;
}
result[key] = `${value}`;
});
return result;
}
function writeItemSettingsMap(section_id, settingsKey, map) {
const result = {};
Object.entries(map || {}).forEach(([item, settings]) => {
const compact = compactItemSettings(settings);
if (item && Object.keys(compact).length) {
result[item] = compact;
}
});
if (Object.keys(result).length) {
uci.set(UCI_PACKAGE, section_id, settingsKey, JSON.stringify(result));
} else {
uci.unset(UCI_PACKAGE, section_id, settingsKey);
}
}
function cleanupListItemSettings(section_id, settingsKey, values) {
if (!settingsKey) {
return;
}
const keep = new Set(normalizeDynamicListItems(values));
const map = readItemSettingsMap(section_id, settingsKey);
let changed = false;
Object.keys(map).forEach((item) => {
if (!keep.has(item)) {
delete map[item];
changed = true;
}
});
if (changed) {
writeItemSettingsMap(section_id, settingsKey, map);
}
}
function itemSettingsFlag(settings, key, defaultValue) {
const value = settings ? settings[key] : null;
if (value === undefined || value === null || value === "") {
return Boolean(defaultValue);
}
return value === true || `${value}` === "1";
}
function ensureConnectionsDynamicListStyles() {
if (document.getElementById(CONNECTIONS_DYNLIST_STYLE_ID)) {
return;
}
document.head.appendChild(
E(
"style",
{ id: CONNECTIONS_DYNLIST_STYLE_ID },
`
.fkp-connections-dynlist > .item {
--fkp-dynlist-action-width: 2em;
padding-right: calc(var(--fkp-dynlist-action-width) * 2);
position: relative;
}
.fkp-connections-dynlist > .item > .fkp-dynlist-settings {
align-items: center;
border: 1px solid var(--border-color-high, currentColor);
border-right: 0;
border-radius: 0;
bottom: -1px;
color: inherit;
cursor: pointer;
display: inline-flex;
font: inherit;
font-size: 0.9em;
justify-content: center;
line-height: 1;
min-height: 0;
padding: 0;
pointer-events: auto;
position: absolute;
right: calc(var(--fkp-dynlist-action-width) - 1px);
user-select: none;
text-decoration: none;
top: -1px;
width: var(--fkp-dynlist-action-width);
z-index: 1;
}
.fkp-connections-dynlist > .item > .fkp-dynlist-settings:hover,
.fkp-connections-dynlist > .item > .fkp-dynlist-settings:focus {
--focus-color-rgb: 82, 168, 236;
outline: 0;
border-color: rgba(var(--focus-color-rgb), 0.8) !important;
box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01), 0 0 8px rgba(var(--focus-color-rgb), 0.6);
text-decoration: none;
}
.fkp-connections-dynlist > .add-item > .cbi-dropdown {
width: 100%;
}
.fkp-interface-dynlist-label {
align-items: center;
display: inline-flex;
gap: 0.25em;
max-width: 100%;
vertical-align: middle;
}
.fkp-interface-dynlist-label > img {
flex: 0 0 auto;
height: 1.35em;
width: auto;
}
.fkp-interface-dynlist-label > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fkp-button-add-dynlist > .add-item {
align-items: stretch;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
display: flex;
margin-top: 4px;
max-width: 100%;
overflow: visible;
padding: 0;
width: var(--fkp-button-add-width, 210px);
}
.fkp-button-add-dynlist > .add-item > input[type="text"] {
display: none !important;
}
.fkp-button-add-dynlist > .add-item > .cbi-button-add {
align-items: center !important;
background: linear-gradient(var(--background-color-high) 0%, var(--border-color-low) 100%) !important;
border: 1px solid var(--border-color-high) !important;
border-radius: 3px !important;
box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01) !important;
box-sizing: border-box !important;
color: var(--text-color-medium) !important;
cursor: pointer !important;
display: flex !important;
font-size: 13px !important;
height: 30px !important;
justify-content: flex-start !important;
margin-left: 0 !important;
max-height: 30px !important;
min-height: 30px !important;
max-width: 100% !important;
overflow: hidden !important;
padding: 0 4px !important;
text-overflow: ellipsis !important;
transition: border linear .2s, box-shadow linear .2s !important;
white-space: nowrap !important;
width: 100% !important;
}
.fkp-button-add-dynlist > .add-item > .cbi-button-add:hover,
.fkp-button-add-dynlist > .add-item > .cbi-button-add:focus {
border-color: rgba(82, 168, 236, .8) !important;
box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01), 0 0 8px rgba(82, 168, 236, .6) !important;
outline: 0;
}
body.modal-overlay-active > #modal_overlay > .modal.cbi-modal > .cbi-map.flash {
animation: none !important;
}
`,
),
);
}
function findDynamicListItemByValue(dl, value) {
const stringValue = `${value}`;
const items = dl.querySelectorAll(".item");
for (let i = 0; i < items.length; i += 1) {
const hidden = items[i].querySelector('input[type="hidden"]');
if (
hidden &&
hidden.parentNode === items[i] &&
hidden.value === stringValue
) {
return items[i];
}
}
return null;
}
function dynamicListItemCurrentValue(item, fallback) {
const hidden = item ? item.querySelector('input[type="hidden"]') : null;
return hidden && hidden.parentNode === item ? hidden.value : fallback;
}
function dynamicListItemValues(dl) {
return Array.from(dl.querySelectorAll(".item"))
.map((item) => {
const hidden = item.querySelector('input[type="hidden"]');
return hidden && hidden.parentNode === item ? hidden.value : null;
})
.filter((value) => value != null);
}
function updateDynamicListItemLabel(item, label) {
const labelNode = item ? item.querySelector(".fkp-dynlist-label") : null;
if (labelNode) {
labelNode.textContent = `${label || ""}`;
}
}
function addDynamicListItem(widget, value, text) {
if (!widget || !widget.node) {
return null;
}
const normalized = `${value || ""}`.trim();
if (!normalized) {
return null;
}
if (
typeof widget.options.hasEquivalentValue === "function" &&
widget.options.hasEquivalentValue(normalized, widget.node)
) {
widget.dispatchCbiDynlistChange(widget.node, normalized);
return null;
}
widget.addItem(widget.node, normalized, text || null, false);
widget.dispatchCbiDynlistChange(widget.node, normalized);
updateButtonAddDynamicListLayout(widget.node, widget.options.addButtonLabel);
return findDynamicListItemByValue(widget.node, normalized);
}
function updateButtonAddDynamicListLayout(dl, label) {
const addButton = dl ? dl.querySelector(".add-item > .cbi-button-add") : null;
if (!addButton) {
return;
}
addButton.textContent = label || "+";
addButton.setAttribute("role", "button");
addButton.setAttribute("tabindex", "0");
dl.style.setProperty(
"--fkp-button-add-width",
dl.querySelector(".item") ? "100%" : "210px",
);
}
function setDynamicListItemValue(item, value) {
const hidden = item ? item.querySelector('input[type="hidden"]') : null;
if (hidden && hidden.parentNode === item) {
hidden.value = `${value || ""}`;
}
}
function parseOutboundJsonObject(value) {
try {
const parsed = JSON.parse(`${value || ""}`);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
} catch (_error) {
return null;
}
}
function outboundJsonDisplayTag(value) {
const parsed = parseOutboundJsonObject(value);
const tag = parsed && typeof parsed.tag === "string" ? parsed.tag.trim() : "";
return tag || "";
}
function outboundJsonListItemLabel(value) {
return E(
"span",
{ class: "fkp-dynlist-label" },
outboundJsonDisplayTag(value) || _("JSON outbound"),
);
}
function cleanFormSectionData(sectionData) {
const result = {};
Object.entries(sectionData || {}).forEach(([key, value]) => {
if (!key || key.charAt(0) === ".") {
return;
}
result[key] = Array.isArray(value) ? value.slice() : value;
});
return result;
}
const SettingsUIDynamicList = ui.DynamicList.extend({
render() {
ensureConnectionsDynamicListStyles();
const node = ui.DynamicList.prototype.render.apply(this, arguments);
node.classList.add("fkp-connections-dynlist");
return node;
},
addItem(dl, value, text, flash) {
if (
flash &&
typeof this.options.hasEquivalentValue === "function" &&
this.options.hasEquivalentValue(value, dl)
) {
this.dispatchCbiDynlistChange(dl, value);
return;
}
const itemText =
typeof this.options.itemLabel === "function"
? this.options.itemLabel(value, text)
: text;
ui.DynamicList.prototype.addItem.call(this, dl, value, itemText, flash);
const item = findDynamicListItemByValue(dl, value);
const hasSettings =
typeof this.options.hasSettings === "function"
? this.options.hasSettings(value)
: true;
if (!item || item.querySelector(".fkp-dynlist-settings") || !hasSettings) {
return;
}
item.appendChild(
E(
"span",
{
role: "button",
tabindex: this.options.disabled ? null : "0",
class: "fkp-dynlist-settings",
"aria-label": _("Settings"),
"aria-disabled": this.options.disabled ? "true" : null,
click: (event) => {
event.preventDefault();
event.stopPropagation();
if (this.options.disabled) {
return;
}
if (typeof this.options.settingsHandler === "function") {
this.options.settingsHandler(
dynamicListItemCurrentValue(item, value),
item,
this,
{},
);
}
},
keydown: (event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
event.stopPropagation();
if (
!this.options.disabled &&
typeof this.options.settingsHandler === "function"
) {
this.options.settingsHandler(
dynamicListItemCurrentValue(item, value),
item,
this,
{},
);
}
},
},
"\u2699",
),
);
},
handleClick(event) {
if (event.target.closest(".fkp-dynlist-settings")) {
return;
}
return ui.DynamicList.prototype.handleClick.apply(this, arguments);
},
});
const ButtonAddSettingsUIDynamicList = SettingsUIDynamicList.extend({
render() {
const node = SettingsUIDynamicList.prototype.render.apply(this, arguments);
node.classList.add("fkp-button-add-dynlist");
const input = node.querySelector('.add-item > input[type="text"]');
if (input) {
input.setAttribute("aria-hidden", "true");
input.setAttribute("tabindex", "-1");
}
updateButtonAddDynamicListLayout(node, this.options.addButtonLabel);
node.addEventListener("cbi-dynlist-change", () => {
updateButtonAddDynamicListLayout(node, this.options.addButtonLabel);
});
return node;
},
addButtonItem() {
if (typeof this.options.settingsHandler === "function") {
this.options.settingsHandler("", null, this, { adding: true });
}
},
handleClick(event) {
if (
!this.options.disabled &&
event.target.closest(".add-item > .cbi-button-add")
) {
event.preventDefault();
event.stopPropagation();
this.addButtonItem(event.currentTarget);
return;
}
return SettingsUIDynamicList.prototype.handleClick.apply(this, arguments);
},
handleKeydown(event) {
if (
!this.options.disabled &&
event.target.closest(".add-item > .cbi-button-add") &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
event.stopPropagation();
this.addButtonItem(event.currentTarget);
return;
}
return ui.DynamicList.prototype.handleKeydown.apply(this, arguments);
},
});
const SettingsDynamicList = form.DynamicList.extend({
childOwner(section_id) {
return typeof this.childOwnerId === "function"
? `${this.childOwnerId(section_id) || ""}`.trim()
: section_id;
},
parentSection(section_id) {
return typeof this.parentSectionId === "function"
? `${this.parentSectionId(section_id) || ""}`.trim()
: section_id;
},
load(section_id) {
if (this.childType) {
return getChildItemIds(
this.childOwner(section_id),
this.childType,
this.ownerOption,
);
}
return form.DynamicList.prototype.load.apply(this, arguments);
},
renderWidget(section_id, _option_index, cfgvalue) {
const value = cfgvalue != null ? cfgvalue : this.default;
const choices = this.transformChoices();
const WidgetClass = this.buttonAdd
? ButtonAddSettingsUIDynamicList
: SettingsUIDynamicList;
const widget = new WidgetClass(L.toArray(value), choices, {
id: this.cbid(section_id),
sort: this.keylist,
allowduplicates: this.allowduplicates,
optional: this.optional || this.rmempty,
datatype: this.datatype,
placeholder: this.placeholder,
validate: L.bind(this.validate, this, section_id),
disabled: this.readonly != null ? this.readonly : this.map.readonly,
addButtonLabel:
typeof this.addButtonLabel === "function"
? this.addButtonLabel(section_id)
: this.addButtonLabel,
settingsHandler: (itemValue, _item, widget, context) => {
if (typeof this.renderItemSettingsModal === "function") {
const ownerId = this.childOwner(section_id);
this.renderItemSettingsModal(
ownerId,
`${itemValue}`,
this,
widget,
_item,
Object.assign({}, context || {}, {
parentSectionId: this.parentSection(section_id),
ownerId,
}),
);
}
},
itemLabel: (itemValue, text) => {
if (typeof this.renderListItemLabel === "function") {
return this.renderListItemLabel(
this.childOwner(section_id),
`${itemValue}`,
text,
);
}
return text;
},
hasSettings: (itemValue) => {
if (typeof this.hasItemSettings === "function") {
return this.hasItemSettings(
this.childOwner(section_id),
`${itemValue}`,
);
}
if (this.childType) {
return isExistingChildItem(
this.childOwner(section_id),
`${itemValue}`,
this.childType,
this.ownerOption,
);
}
return true;
},
hasEquivalentValue: (itemValue, dl) => {
const inputValueForItem = (value) => {
const ownerId = this.childOwner(section_id);
if (typeof this.inputValueForItem === "function") {
return `${this.inputValueForItem(ownerId, `${value || ""}`) || ""}`.trim();
}
if (this.childType && this.childValueOption) {
return childItemInputValue(
ownerId,
`${value || ""}`,
this.childType,
this.childValueOption,
this.ownerOption,
);
}
return `${value || ""}`.trim();
};
const normalized = inputValueForItem(itemValue);
return Boolean(
normalized &&
dynamicListItemValues(dl).some(
(existingValue) =>
inputValueForItem(existingValue) === normalized,
),
);
},
});
const node = widget.render();
if (typeof this.onListChange === "function") {
node.addEventListener("cbi-dynlist-change", () => {
this.onListChange(section_id);
});
}
return node;
},
parse(section_id) {
if (
this.isActive(section_id) &&
typeof this.validateItemsOnSave === "function"
) {
const result = this.validateItemsOnSave(
this.childType ? this.childOwner(section_id) : section_id,
this.formvalue(section_id),
this,
section_id,
);
if (result !== true) {
const title = this.stripTags(this.title).trim();
return Promise.reject(
new TypeError(
`${_('Option "%s" contains an invalid input value.').format(title || this.option)} ${result}`,
),
);
}
}
return form.DynamicList.prototype.parse.apply(this, arguments);
},
write(section_id, value) {
if (this.childType) {
const ownerId = this.childOwner(section_id);
const itemIds = materializeChildItems(
ownerId,
{
typeName: this.childType,
valueOption: this.childValueOption,
ownerOption: this.ownerOption,
createId: this.createId,
defaults: this.childDefaults,
stagedSettings:
typeof this.stagedChildSettings === "function"
? (itemValue, itemId, created) =>
this.stagedChildSettings(ownerId, itemValue, itemId, created)
: null,
},
value,
);
cleanupRemovedChildItems(
ownerId,
this.childType,
itemIds,
this.ownerOption,
);
if (typeof this.afterMaterializeChildItems === "function") {
this.afterMaterializeChildItems(ownerId, itemIds);
}
uci.unset(UCI_PACKAGE, section_id, this.option);
cleanupListItemSettings(ownerId, this.settingsKey, itemIds);
if (typeof this.clearStagedChildSettings === "function") {
this.clearStagedChildSettings(ownerId);
}
return;
}
const result = form.DynamicList.prototype.write.apply(this, arguments);
cleanupListItemSettings(section_id, this.settingsKey, value);
return result;
},
remove(section_id) {
if (this.childType) {
cleanupRemovedChildItems(
this.childOwner(section_id),
this.childType,
[],
this.ownerOption,
);
uci.unset(UCI_PACKAGE, section_id, this.option);
return;
}
if (this.settingsKey) {
uci.unset(UCI_PACKAGE, section_id, this.settingsKey);
}
return form.DynamicList.prototype.remove.apply(this, arguments);
},
});
const ButtonAddSettingsDynamicList = SettingsDynamicList.extend({
buttonAdd: true,
});
function configureLiveDynamicListChoices(option, getChoices) {
option.renderWidget = function (section_id, _option_index, cfgvalue) {
const values = L.toArray(cfgvalue != null ? cfgvalue : this.default);
const choices = getChoices(section_id, values);
const labels = {};
choices.forEach((choice) => {
labels[choice.value] = choice.label;
});
refreshOptionChoices(this, choices);
let choiceSignature = JSON.stringify(
choices.map((choice) => [choice.value, choice.label]),
);
// form.DynamicList passes null when there are no choices, which permanently
// renders a plain input. An empty object keeps the stock combobox alive so
// later live choices can be added without reopening the modal.
const widget = new ui.DynamicList(values, labels, {
id: this.cbid(section_id),
sort: this.keylist,
allowduplicates: this.allowduplicates,
optional: this.optional || this.rmempty,
datatype: this.datatype,
placeholder: this.placeholder,
validate: L.bind(this.validate, this, section_id),
disabled: this.readonly != null ? this.readonly : this.map.readonly,
});
const node = widget.render();
const refreshChoices = () => {
if (!node.isConnected) {
return false;
}
const currentValues = widget.getValue();
const currentChoices = getChoices(section_id, currentValues);
const currentLabels = {};
currentChoices.forEach((choice) => {
currentLabels[choice.value] = choice.label;
});
const currentSignature = JSON.stringify(
currentChoices.map((choice) => [choice.value, choice.label]),
);
if (currentSignature === choiceSignature) {
return;
}
choiceSignature = currentSignature;
refreshOptionChoices(this, currentChoices);
widget.choices = currentLabels;
widget.clearChoices();
widget.addChoices(
currentChoices.map((choice) => choice.value),
currentLabels,
);
const cboxElem = node.querySelector(".cbi-dropdown");
if (
cboxElem &&
typeof dom !== "undefined" &&
typeof dom.callClassMethod === "function"
) {
try {
dom.callClassMethod(cboxElem, "clearChoices");
dom.callClassMethod(
cboxElem,
"addChoices",
currentChoices.map((choice) => choice.value),
currentLabels,
);
} catch (_err) {}
}
return true;
};
const refreshBeforeOpening = () => {
refreshChoices();
};
node.addEventListener("mousedown", refreshBeforeOpening, true);
node.addEventListener("focusin", refreshBeforeOpening, true);
node.addEventListener("pointerdown", refreshBeforeOpening, true);
if (!dashboardFilterChoiceRefreshers.has(section_id)) {
dashboardFilterChoiceRefreshers.set(section_id, new Set());
}
dashboardFilterChoiceRefreshers.get(section_id).add(refreshChoices);
const targetSectionId = parentSectionIdForItem(section_id) || "Main";
if (targetSectionId && targetSectionId !== section_id) {
if (!dashboardFilterChoiceRefreshers.has(targetSectionId)) {
dashboardFilterChoiceRefreshers.set(targetSectionId, new Set());
}
dashboardFilterChoiceRefreshers.get(targetSectionId).add(refreshChoices);
}
perRuleDnsWidgets.set(section_id, widget);
loadOutboundNameChoices(targetSectionId).then(() => {
refreshChoices();
});
return node;
};
}
function countryChoices() {
return COUNTRY_CODES.map((code) => ({
value: code,
label: getCountryOptionLabel(code),
})).sort((a, b) => a.label.localeCompare(b.label));
}
function currentOutboundNameChoices(section_id, values) {
if (!outboundNameChoicesCache.has(section_id)) {
loadOutboundNameChoices(section_id);
}
const seen = new Set();
const result = [];
const append = (name) => {
const value = `${name || ""}`.trim();
if (!value || seen.has(value)) {
return;
}
seen.add(value);
result.push({ value, label: value });
};
(outboundNameChoicesCache.get(section_id) || []).forEach(append);
currentDraftOutboundNames(section_id).forEach(append);
normalizeDynamicListItems(values).forEach(append);
return result.sort((a, b) => a.label.localeCompare(b.label));
}
function currentSourceOptionValues(section_id, optionName) {
const liveValues = currentLiveDynamicListValues(section_id, optionName);
if (liveValues != null) {
return liveValues;
}
const option = outboundNameSourceOptions.get(optionName);
if (option && typeof option.formvalue === "function") {
try {
const value = option.formvalue(section_id);
if (value != null) {
return normalizeDynamicListItems(value);
}
} catch (_error) {
// Fall back to the LuCI UCI cache when the section widget is not mounted.
}
}
return getConfigListValues(section_id, optionName);
}
function currentLiveDynamicListValues(section_id, optionName) {
if (typeof document === "undefined") {
return null;
}
const widget = document.getElementById(
`cbid.${UCI_PACKAGE}.${section_id}.${optionName}`,
);
if (!widget) {
return null;
}
const values = Array.from(
widget.querySelectorAll('.item > input[type="hidden"]'),
)
.map((input) => `${input.value || ""}`.trim())
.filter(Boolean);
const pendingInput = widget.querySelector('.add-item > input[type="text"]');
const pendingValue = `${(pendingInput && pendingInput.value) || ""}`.trim();
if (
pendingValue &&
!pendingInput.classList.contains("cbi-input-invalid") &&
!values.includes(pendingValue)
) {
values.push(pendingValue);
}
return values;
}
function currentDraftOutboundNames(section_id) {
const names = [];
currentSourceOptionValues(section_id, "selector_proxy_links").forEach(
(value, index) => {
const name = main.getProxyUrlName(`${value || ""}`);
names.push(name || `${section_id}-${index + 1}-out`);
},
);
currentSourceOptionValues(section_id, "interfaces").forEach((itemId) => {
const normalized = childItemInputValue(
section_id,
itemId,
"section_interface",
"name",
).trim();
if (normalized) {
names.push(normalized);
}
});
currentSourceOptionValues(section_id, "outbound_jsons").forEach((value) => {
const tag = outboundJsonDisplayTag(value);
if (tag) {
names.push(tag);
}
});
return names;
}
function currentSectionGroupValues(section_id, typeName) {
const liveValues = currentLiveDynamicListValues(section_id, typeName);
if (liveValues != null) {
return liveValues;
}
const option = sectionGroupSourceOptions.get(typeName);
if (option && typeof option.formvalue === "function") {
try {
const value = option.formvalue(section_id);
if (value != null) {
return normalizeDynamicListItems(value);
}
} catch (_error) {
// Fall back to the child sections when the widget is not mounted.
}
}
return getChildItemIds(section_id, typeName);
}
function sectionGroupDisplayName(section_id, typeName, value) {
const itemId = `${value || ""}`.trim();
if (isExistingChildItem(section_id, itemId, typeName)) {
return `${uci.get(UCI_PACKAGE, itemId, "name") || itemId}`.trim();
}
const option = sectionGroupSourceOptions.get(typeName);
const pending =
option && option.pendingChildSettings
? option.pendingChildSettings[section_id]
: null;
const settings = pending && pending[itemId];
return `${(settings && settings.name) || itemId}`.trim();
}
function currentSectionGroupChoices(section_id, selectedValues = []) {
const seen = new Set();
const result = [];
const append = (typeName, value) => {
value = `${value || ""}`.trim();
if (!value) {
return;
}
const name = sectionGroupDisplayName(section_id, typeName, value) || value;
if (seen.has(name)) {
return;
}
seen.add(name);
result.push({ value: name, label: name });
};
currentSectionGroupValues(section_id, "urltest").forEach((value) =>
append("urltest", value),
);
currentSectionGroupValues(section_id, "priority_group").forEach((value) =>
append("priority_group", value),
);
normalizeDynamicListItems(selectedValues).forEach((value) => {
value = `${value || ""}`.trim();
if (value && !seen.has(value)) {
seen.add(value);
result.push({ value, label: value });
}
});
return result.sort((left, right) => left.label.localeCompare(right.label));
}
function refreshDashboardFilterChoiceWidgets(section_id) {
const refreshers = dashboardFilterChoiceRefreshers.get(section_id);
if (!refreshers) {
return;
}
refreshers.forEach((refresh) => {
if (refresh() === false) {
refreshers.delete(refresh);
}
});
if (refreshers.size === 0) {
dashboardFilterChoiceRefreshers.delete(section_id);
}
}
function isDownloadThroughTargetSection(section, currentSectionId) {
const sectionName = getUciSectionName(section);
const action = (section && section.action) || "";
if (
!sectionName ||
sectionName === currentSectionId ||
section.enabled === "0"
) {
return false;
}
if (
[
"connection",
"proxy",
"outbound",
"vpn",
"awg",
"warp",
"anytls",
"snell",
"mieru",
"sudoku",
"masque",
"openvpn",
].includes(action)
) {
return true;
}
if (action === "zapret") {
return isZapretInstalledForUi();
}
if (action === "zapret2") {
return isZapret2InstalledForUi();
}
if (action === "byedpi") {
return isByedpiInstalledForUi();
}
return false;
}
function subscriptionDownloadTargetChoices(section_id) {
return (uci.sections(UCI_PACKAGE, "section") || [])
.filter((section) => isDownloadThroughTargetSection(section, section_id))
.map((section) => ({
value: getUciSectionName(section),
label: getUciSectionLabel(section),
}));
}
function dnsTypeChoices() {
return [
{ value: "doh", label: _("DNS over HTTPS (DoH)") },
{ value: "dot", label: _("DNS over TLS (DoT)") },
{ value: "doq", label: _("DNS over QUIC (DoQ)") },
{ value: "udp", label: "UDP" },
];
}
function optionListValues(option, section_id) {
const value = option.cfgvalue
? option.cfgvalue(section_id)
: uci.get(UCI_PACKAGE, section_id, option.option);
return L.toArray(value)
.map((item) => `${item || ""}`.trim())
.filter(Boolean);
}
function configureDnsList(option, choices, defaultValue) {
if (choices) {
Object.entries(choices).forEach(([key, label]) => {
option.value(key, _(label));
});
}
option.default = [defaultValue];
option.rmempty = false;
option.validate = function (_section_id, value) {
const normalized = `${value || ""}`.trim();
if (!normalized) {
return optionListValues(option, _section_id).length > 0
? true
: _("Add at least one DNS server");
}
const validation = main.validateDNS(normalized);
return validation.valid ? true : validation.message;
};
}
function isConnectionNetworkInterfaceAllowed(deviceName, device) {
if (CONNECTIONS_BLOCKED_INTERFACES.includes(deviceName)) {
return false;
}
if (!device) {
return true;
}
const type = device.getType();
const isWireless =
type === "wifi" || type === "wireless" || type.indexOf("wlan") >= 0;
return !isWireless;
}
function renderNetworkInterfaceChoice(device) {
const name = device.getName();
const type = device.getType();
return E([
E("img", {
title: device.getI18n(),
src: L.resource(
"icons/%s%s.svg".format(type, device.isUp() ? "" : "_disabled"),
),
}),
E("span", { class: "hide-open" }, [name]),
E("span", { class: "hide-close" }, [device.getI18n()]),
]);
}
function renderNetworkInterfaceListItem(device, fallbackName) {
const name = device ? device.getName() : fallbackName;
const type = device ? device.getType() : "ethernet";
const up = device ? device.isUp() : false;
return E("span", { class: "fkp-interface-dynlist-label" }, [
E("img", {
title: device ? device.getI18n() : _("Network Interface"),
src: L.resource("icons/%s%s.svg".format(type, up ? "" : "_disabled")),
}),
E("span", {}, [name]),
]);
}
function refreshNetworkInterfaceOptionValues(option) {
option.keylist = [];
option.vallist = [];
option.interfaceChoiceMap = {};
option.interfaceDeviceMap = {};
(option.devices || []).forEach((device) => {
const name = device.getName();
const type = device.getType();
if (
name === "lo" ||
type === "alias" ||
!isConnectionNetworkInterfaceAllowed(name, device)
) {
return;
}
option.value(name, renderNetworkInterfaceChoice(device));
option.interfaceChoiceMap[name] = true;
option.interfaceDeviceMap[name] = device;
});
}
const InterfaceSettingsDynamicList = SettingsDynamicList.extend({
load(section_id) {
return network.getDevices().then(
L.bind(function (devices) {
this.devices = devices || [];
refreshNetworkInterfaceOptionValues(this);
return this.super("load", section_id);
}, this),
);
},
validate(section_id, value) {
value = childItemInputValue(section_id, value, "section_interface", "name");
if (!value || value.length === 0) {
return true;
}
if (!this.interfaceChoiceMap || !this.interfaceChoiceMap[value]) {
return _("Select an existing network interface");
}
return true;
},
renderListItemLabel(section_id, value, text) {
value = childItemInputValue(section_id, value, "section_interface", "name");
return renderNetworkInterfaceListItem(
this.interfaceDeviceMap ? this.interfaceDeviceMap[value] : null,
value || text,
);
},
});
function urlTestFilterModeChoices() {
return [
{ value: "disabled", label: _("All servers") },
{ value: "exclude", label: _("All except selected") },
{ value: "include", label: _("Only selected") },
{ value: "mixed", label: _("Only selected except exclusions") },
];
}
function priorityLevelFilterModeChoices() {
return [
{ value: "disabled", label: _("All remaining servers") },
{ value: "include", label: _("Only selected") },
{ value: "exclude", label: _("All remaining except selected") },
{ value: "mixed", label: _("Only selected except exclusions") },
];
}
function serverCountryDetectionChoices() {
return [
{ value: "flag_emoji", label: _("By flag emoji from name") },
{ value: "country_is", label: _("Via country.is") },
];
}
function proxyProtocolChoices() {
return [
["vless", "VLESS"],
["vmess", "VMess"],
["trojan", "Trojan"],
["shadowsocks", "Shadowsocks"],
["socks", "SOCKS"],
["http", "HTTP"],
["hysteria2", "Hysteria2"],
["direct", "Direct"],
];
}
function proxyTransportChoices() {
return [
["tcp", "TCP"],
["ws", "WebSocket"],
["grpc", "gRPC"],
["http", "HTTP"],
["httpupgrade", "HTTPUpgrade"],
["xhttp", "XHTTP"],
];
}
function proxySecurityChoices() {
return [
["none", "None"],
["tls", "TLS"],
["reality", "Reality"],
];
}
function addProxyParameterFilterOptions(itemSection, options) {
const prefix = options.prefix;
const dependencies = options.dependencies;
let o = itemSection.option(
form.Flag,
`${prefix}_proxy_parameters`,
options.toggleLabel,
options.toggleDescription,
);
dependencies.forEach((dependency) => o.depends(dependency));
o.default = "0";
o.rmempty = false;
[
[
"protocols",
_("Protocol"),
options.protocolDescription,
proxyProtocolChoices(),
],
[
"transports",
_("Transport"),
options.transportDescription,
proxyTransportChoices(),
],
[
"securities",
_("Security"),
options.securityDescription,
proxySecurityChoices(),
],
].forEach(([suffix, label, description, choices]) => {
const list = itemSection.option(
form.DynamicList,
`${prefix}_${suffix}`,
label,
description,
);
dependencies.forEach((dependency) =>
list.depends(
Object.assign({}, dependency, {
[`${prefix}_proxy_parameters`]: "1",
}),
),
);
list.rmempty = true;
choices.forEach(([value, choiceLabel]) => list.value(value, choiceLabel));
list.placeholder = _("-- Select --");
});
}
function urlTestUrlChoices() {
return Array.isArray(main.LATENCY_TEST_URL_OPTIONS)
? main.LATENCY_TEST_URL_OPTIONS
: [main.DEFAULT_LATENCY_TEST_URL || "https://www.gstatic.com/generate_204"];
}
function validateUrlTestTolerance(value) {
if (!value || `${value}`.length === 0) {
return _("Must be a number in the range of 0 - 10000");
}
const normalized = `${value}`;
const parsed = parseFloat(normalized);
if (
/^[0-9]+$/.test(normalized) &&
!isNaN(parsed) &&
isFinite(parsed) &&
parsed >= 0 &&
parsed <= 10000
) {
return true;
}
return _("Must be a number in the range of 0 - 10000");
}
function validateUrlTestUrl(value) {
const validation = main.validateUrl(`${value || ""}`.trim());
return validation.valid ? true : validation.message;
}
function generateHwid16() {
const bytes = new Uint8Array(8);
if (window.crypto && window.crypto.getRandomValues) {
window.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function optionMapValue(option, section_id, key) {
const value =
option && option.map && option.map.data
? option.map.data.get(option.map.config, section_id, key)
: uci.get(UCI_PACKAGE, section_id, key);
return value == null ? "" : value;
}
function subscriptionUrlSettingsKeys() {
return [
"subscription_update_enabled",
"subscription_update_interval",
"download_via_proxy_enabled",
"download_via_proxy_section",
"auto_user_agent",
"user_agent",
"auto_hwid",
"hwid",
"custom_device_headers",
"device_os",
"ver_os",
"device_model",
"device_locale",
"app_version",
"accept_language",
"show_dashboard_metadata",
"prefix_nodes",
"node_prefix",
"include_urltest_groups",
"hide_urltest_group_outbounds",
"hide_detour_outbounds",
];
}
function defaultSubscriptionUrlSettings() {
return {
subscription_update_enabled: "1",
subscription_update_interval: "1h",
download_via_proxy_enabled: "0",
download_via_proxy_section: "",
auto_user_agent: "1",
user_agent: "",
auto_hwid: "1",
hwid: "",
custom_device_headers: "0",
device_os: "",
ver_os: "",
device_model: "",
device_locale: "",
app_version: "",
accept_language: "",
show_dashboard_metadata: "1",
prefix_nodes: "0",
node_prefix: "",
include_urltest_groups: "1",
hide_urltest_group_outbounds: "1",
hide_detour_outbounds: "1",
};
}
function interfaceSettingsKeys() {
return [
"domain_resolver_enabled",
"domain_resolver_dns_type",
"domain_resolver_dns_server",
];
}
function defaultInterfaceSettings() {
return {
domain_resolver_enabled: "0",
domain_resolver_dns_type: "udp",
domain_resolver_dns_server: "8.8.8.8",
};
}
function subscriptionUserAgentChoices() {
return [
"sing-box",
"Happ",
"v2rayN",
"v2rayNG",
"v2RayTun",
"Incy",
"Hiddify",
"HiddifyNext",
"Clash",
"Clash.Meta",
"ClashMetaForAndroid",
"Mihomo",
"NekoBox",
"Karing",
"Husi",
];
}
function urlTestSettingsKeys() {
return [
"name",
"check_interval",
"tolerance",
"testing_url",
"idle_timeout",
"interrupt_exist_connections",
"pin_dashboard",
"filter_mode",
"detect_server_country",
"include_countries",
"include_outbounds",
"include_regex",
"include_proxy_parameters",
"include_protocols",
"include_transports",
"include_securities",
"exclude_countries",
"exclude_outbounds",
"exclude_regex",
"exclude_proxy_parameters",
"exclude_protocols",
"exclude_transports",
"exclude_securities",
];
}
function defaultUrlTestSettings(name) {
return {
name: "",
check_interval: "3m",
tolerance: "50",
testing_url: "https://www.gstatic.com/generate_204",
idle_timeout: "30m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
filter_mode: "disabled",
detect_server_country: "flag_emoji",
};
}
function urlTestChildDefaults() {
return {
check_interval: "3m",
tolerance: "50",
testing_url: "https://www.gstatic.com/generate_204",
idle_timeout: "30m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
filter_mode: "disabled",
detect_server_country: "flag_emoji",
};
}
function priorityGroupSettingsKeys() {
return [
"name",
"health_url",
"active_check_interval",
"check_timeout",
"recovery_check_interval",
"pick_fastest",
"switch_to_faster_same_priority",
"fastest_check_interval",
"interrupt_exist_connections",
"pin_dashboard",
];
}
function defaultPriorityGroupSettings() {
return {
name: "",
health_url: "https://www.gstatic.com/generate_204",
active_check_interval: "5s",
check_timeout: "2s",
recovery_check_interval: "15s",
pick_fastest: "0",
switch_to_faster_same_priority: "0",
fastest_check_interval: "3m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
};
}
function priorityGroupChildDefaults() {
return {
health_url: "https://www.gstatic.com/generate_204",
active_check_interval: "5s",
check_timeout: "2s",
recovery_check_interval: "15s",
pick_fastest: "0",
switch_to_faster_same_priority: "0",
fastest_check_interval: "3m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
};
}
function priorityLevelSettingsKeys() {
return [
"name",
"order",
"direct",
"filter_mode",
"detect_server_country",
"country",
"server_name",
"regex",
"include_proxy_parameters",
"include_protocols",
"include_transports",
"include_securities",
"exclude_countries",
"exclude_outbounds",
"exclude_regex",
"exclude_proxy_parameters",
"exclude_protocols",
"exclude_transports",
"exclude_securities",
];
}
function defaultPriorityLevelSettings() {
return {
name: "",
order: "0",
direct: "0",
filter_mode: "include",
detect_server_country: "flag_emoji",
};
}
function randomPriorityGroupId() {
for (let i = 0; i < 100; i += 1) {
const value = Math.floor(Math.random() * 0xffffffff)
.toString(16)
.padStart(8, "0");
const id = `pg_${value}`;
if (!uci.get(UCI_PACKAGE, id)) {
return id;
}
}
return `pg_${Date.now().toString(16)}`;
}
function addSubscriptionUrlItemOptions(itemSection, options = {}) {
const parentSectionForItem =
typeof options.parentSectionId === "function"
? options.parentSectionId
: parentSectionIdForItem;
let o = itemSection.option(
form.Flag,
"subscription_update_enabled",
_("Subscription auto update"),
_("Update this subscription automatically"),
);
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Value,
"subscription_update_interval",
_("Subscription update interval"),
_("Use sing-box duration format like 1d, 12h or 30m"),
);
o.depends("subscription_update_enabled", "1");
o.placeholder = "1h";
o.validate = function (itemId, value) {
return optionMapValue(this, itemId, "subscription_update_enabled") === "1"
? validateRequiredSingBoxDuration(value)
: validateOptionalSingBoxDuration(value);
};
o = itemSection.option(
form.Flag,
"download_via_proxy_enabled",
_("Download subscription through a section"),
_("Download subscriptions via the selected section"),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
form.ListValue,
"download_via_proxy_section",
_("Download through"),
);
o.depends("download_via_proxy_enabled", "1");
o.load = function (itemId) {
const sectionId = parentSectionForItem(itemId);
refreshOptionChoices(this, subscriptionDownloadTargetChoices(sectionId));
return optionMapValue(this, itemId, "download_via_proxy_section") || "";
};
o.validate = function (itemId, value) {
const sectionId = parentSectionForItem(itemId);
if (optionMapValue(this, itemId, "download_via_proxy_enabled") !== "1") {
return true;
}
if (!value) {
return _("Select a section for downloading this subscription");
}
if (value === sectionId) {
return _("Current section cannot download its own subscription");
}
return true;
};
o = itemSection.option(
form.Flag,
"auto_user_agent",
_("Automatic User-Agent selection"),
_(
"Try compatible User-Agent profiles automatically when downloading this subscription",
),
);
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Value,
"user_agent",
_("User-Agent"),
_("Select a common client profile or enter a custom User-Agent"),
);
o.depends("auto_user_agent", "0");
o.placeholder = _("-- Select --");
o.rmempty = false;
subscriptionUserAgentChoices().forEach((choice) => o.value(choice));
o.load = function (itemId) {
return optionMapValue(this, itemId, "user_agent") || "";
};
o.validate = function (itemId, value) {
if (optionMapValue(this, itemId, "auto_user_agent") !== "0") {
return true;
}
return `${value || ""}`.trim() ? true : _("Select or enter a User-Agent");
};
o = itemSection.option(
form.Flag,
"auto_hwid",
_("Auto-generate HWID"),
_("Generate HWID from router hardware information for this subscription"),
);
o.default = "1";
o.rmempty = false;
const hwidOption = itemSection.option(
form.Value,
"hwid",
_("HWID"),
_("Enter the HWID sent with subscription requests"),
);
hwidOption.depends("auto_hwid", "0");
hwidOption.rmempty = false;
hwidOption.validate = function (itemId, value) {
if (optionMapValue(this, itemId, "auto_hwid") !== "0") {
return true;
}
return `${value || ""}`.trim() ? true : _("Enter HWID");
};
o = itemSection.option(
form.Button,
"_generate_hwid",
_("Generate HWID"),
_("Generate a random 16-character hexadecimal HWID"),
);
o.depends("auto_hwid", "0");
o.inputstyle = "action";
o.inputtitle = _("Generate HWID");
o.onclick = function (_event, itemId) {
hwidOption.getUIElement(itemId).setValue(generateHwid16());
};
o = itemSection.option(
form.Flag,
"custom_device_headers",
_("Custom device headers"),
_("Override the default OpenWrt device headers for this subscription"),
);
o.default = "0";
o.rmempty = false;
[
["device_os", "X-Device-OS"],
["ver_os", "X-Ver-OS"],
["device_model", "X-Device-Model"],
["device_locale", "X-Device-Locale"],
["app_version", "X-App-Version"],
["accept_language", "Accept-Language"],
].forEach(([key, label]) => {
o = itemSection.option(
form.Value,
key,
label,
_("Leave empty to omit this header"),
);
o.depends("custom_device_headers", "1");
o.rmempty = true;
o.retain = true;
});
o = itemSection.option(
form.Flag,
"show_dashboard_metadata",
_("Show metadata on dashboard"),
_("Show subscription metadata for this source on the dashboard"),
);
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"prefix_nodes",
_("Add prefix to nodes"),
_(
"Automatically add text to the name of each server from this subscription for convenient filtering.",
),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(form.Value, "node_prefix", _("Prefix text"));
o.depends("prefix_nodes", "1");
o.rmempty = false;
o = itemSection.option(
form.Flag,
"include_urltest_groups",
_("Import subscription URLTest groups"),
_("Import URLTest groups returned by this subscription provider"),
);
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"hide_urltest_group_outbounds",
_("Hide URLTest group nodes"),
_(
"Hide individual nodes that are already included in imported subscription URLTest groups",
),
);
o.depends("include_urltest_groups", "1");
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"hide_detour_outbounds",
_("Hide cascade connection nodes"),
_("Hide intermediate nodes used as detours by other subscription nodes"),
);
o.default = "1";
o.rmempty = false;
}
function addInterfaceItemOptions(itemSection) {
let o = itemSection.option(
form.Flag,
"domain_resolver_enabled",
_("Domain Resolver"),
_("Enable built-in DNS resolver for domains handled by this section"),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
form.ListValue,
"domain_resolver_dns_type",
_("DNS protocol"),
_("DNS protocol used by the resolver"),
);
o.depends("domain_resolver_enabled", "1");
dnsTypeChoices().forEach((choice) => o.value(choice.value, choice.label));
o.default = "udp";
o = itemSection.option(
form.Value,
"domain_resolver_dns_server",
_("DNS server"),
_("DNS server used by the resolver"),
);
o.depends("domain_resolver_enabled", "1");
o.default = "8.8.8.8";
o.validate = function (itemId, value) {
if (optionMapValue(this, itemId, "domain_resolver_enabled") !== "1") {
return true;
}
const validation = main.validateDNS(value);
return validation.valid ? true : validation.message;
};
}
function addUrlTestItemOptions(itemSection, options = {}) {
const parentSectionForItem =
typeof options.parentSectionId === "function"
? options.parentSectionId
: parentSectionIdForItem;
let o = itemSection.option(
form.Value,
"name",
_("Display name"),
_("Name displayed on the dashboard"),
);
o.rmempty = false;
o.load = function (itemId) {
return (
optionMapValue(this, itemId, "name") ||
optionMapValue(this, itemId, "display_name") ||
""
);
};
o.validate = function (_itemId, value) {
return `${value || ""}`.trim() ? true : _("Enter a display name");
};
o = itemSection.option(
form.Value,
"check_interval",
_("Check interval"),
_("Use sing-box duration format like 1d, 12h or 30m"),
);
o.default = "3m";
o.rmempty = false;
o.validate = function (_itemId, value) {
return validateRequiredSingBoxDuration(value);
};
o = itemSection.option(
form.Value,
"tolerance",
_("Tolerance"),
_(
"Minimum latency difference in milliseconds that triggers switching to a faster server.",
),
);
o.default = "50";
o.rmempty = false;
o.validate = function (_itemId, value) {
return validateUrlTestTolerance(value);
};
o = itemSection.option(
form.Value,
"testing_url",
_("Check URL"),
_("URL used to test server latency"),
);
o.default = "https://www.gstatic.com/generate_204";
o.rmempty = false;
urlTestUrlChoices().forEach((value) => o.value(value));
o.validate = function (_itemId, value) {
return validateUrlTestUrl(value);
};
o = itemSection.option(
form.Value,
"idle_timeout",
_("Idle timeout"),
_(
"Stop checking when URLTest group is not used. Use sing-box duration format like 1d, 12h or 30m.",
),
);
o.default = "30m";
o.rmempty = false;
o.validate = function (_itemId, value) {
return validateRequiredSingBoxDuration(value);
};
o = itemSection.option(
form.Flag,
"interrupt_exist_connections",
_("Interrupt connections"),
_("Interrupt connections when URLTest switches the selected server"),
);
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"pin_dashboard",
_("Pin on dashboard"),
_("Keep URLTest before latency-sorted servers"),
);
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.ListValue,
"filter_mode",
_("Server filtering"),
_("Allows limiting the list of servers for URLTest"),
);
urlTestFilterModeChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "disabled";
o = itemSection.option(
form.ListValue,
"detect_server_country",
_("Detect server country"),
);
o.depends("filter_mode", "exclude");
o.depends("filter_mode", "include");
o.depends("filter_mode", "mixed");
serverCountryDetectionChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "flag_emoji";
const includeProxyParameterOptions = {
prefix: "include",
toggleLabel: _("Include by proxy parameters"),
toggleDescription: _(
"Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters.",
),
dependencies: [{ filter_mode: "include" }, { filter_mode: "mixed" }],
protocolDescription: _(
"Test only servers with one of the selected protocols.",
),
transportDescription: _(
"Test only servers with one of the selected transports.",
),
securityDescription: _(
"Test only servers with one of the selected security types.",
),
};
const excludeProxyParameterOptions = {
prefix: "exclude",
toggleLabel: _("Exclude by proxy parameters"),
toggleDescription: _(
"Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters.",
),
dependencies: [{ filter_mode: "exclude" }, { filter_mode: "mixed" }],
protocolDescription: _(
"Do not test servers with one of the selected protocols.",
),
transportDescription: _(
"Do not test servers with one of the selected transports.",
),
securityDescription: _(
"Do not test servers with one of the selected security types.",
),
};
[
[
"include_countries",
_("Include countries"),
_("Test servers only from the specified countries."),
countryChoices(),
validateCountryCode,
["include", "mixed"],
],
[
"include_outbounds",
_("Include servers"),
_("Test only selected servers."),
null,
null,
["include", "mixed"],
],
[
"include_regex",
_("Include by regular expression"),
_("Test servers whose names match the expression."),
null,
validateRegex,
["include", "mixed"],
],
[
"exclude_countries",
_("Exclude countries"),
_("Do not test servers from these countries."),
countryChoices(),
validateCountryCode,
["exclude", "mixed"],
],
[
"exclude_outbounds",
_("Exclude servers"),
_("Do not test specified servers."),
null,
null,
["exclude", "mixed"],
],
[
"exclude_regex",
_("Exclude by regular expression"),
_("Do not test servers whose names match the expression."),
null,
validateRegex,
["exclude", "mixed"],
],
].forEach(([key, label, description, choices, validator, modes]) => {
const list = itemSection.option(form.DynamicList, key, label, description);
modes.forEach((mode) => list.depends("filter_mode", mode));
list.rmempty = true;
if (choices) {
choices.forEach((choice) => list.value(choice.value, choice.label));
list.placeholder = _("-- Select --");
}
if (key.endsWith("_outbounds")) {
list.load = function (itemId) {
const sectionId = parentSectionForItem(itemId) || "Main";
const values = normalizeOptionValues(optionMapValue(this, itemId, key));
return loadOutboundNameChoices(sectionId).then(() => {
refreshOptionChoices(
this,
currentOutboundNameChoices(sectionId, values),
);
return values;
});
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, (itemId, values) =>
currentOutboundNameChoices(
parentSectionForItem(itemId) || "Main",
values,
),
);
}
if (validator) {
list.validate = function (_itemId, value) {
return validator(null, value);
};
}
if (key === "include_regex") {
addProxyParameterFilterOptions(itemSection, includeProxyParameterOptions);
} else if (key === "exclude_regex") {
addProxyParameterFilterOptions(itemSection, excludeProxyParameterOptions);
}
});
}
function priorityLevelSettingsForValidation(groupId, levelId, option) {
const store = childPendingSettingsStore(option, groupId);
if (isExistingChildItem(groupId, levelId, "priority_level", "group")) {
return readChildSettings(
levelId,
priorityLevelSettingsKeys(),
defaultPriorityLevelSettings(),
);
}
return Object.assign({}, store[levelId] || {});
}
function validatePriorityLevelItemsBeforeSave(groupId, values, option) {
const normalizedValues = normalizeDynamicListItems(values);
for (const value of normalizedValues) {
const levelId = `${value || ""}`;
const settings = priorityLevelSettingsForValidation(
groupId,
levelId,
option,
);
if (!`${settings.name || ""}`.trim()) {
return _("Enter a level name");
}
}
return true;
}
function addPriorityLevelItemOptions(itemSection, options = {}) {
const parentSectionForItem =
typeof options.parentSectionId === "function"
? options.parentSectionId
: parentSectionIdForItem;
let o = itemSection.option(
form.Value,
"name",
_("Level name"),
_("Name shown in the priority level list"),
);
o.rmempty = false;
o.validate = function (_itemId, value) {
return `${value || ""}`.trim() ? true : _("Enter a level name");
};
o = itemSection.option(
form.Flag,
"direct",
_("Direct connection"),
_("Traffic for this level goes directly."),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
form.ListValue,
"filter_mode",
_("Server filtering"),
_(
"All remaining servers means every server not already assigned to a higher-priority level.",
),
);
priorityLevelFilterModeChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "include";
o.depends("direct", "0");
o = itemSection.option(
form.ListValue,
"detect_server_country",
_("Detect server country"),
);
["exclude", "include", "mixed"].forEach((mode) =>
o.depends({ direct: "0", filter_mode: mode }),
);
serverCountryDetectionChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "flag_emoji";
const includeProxyParameterOptions = {
prefix: "include",
toggleLabel: _("Include by proxy parameters"),
toggleDescription: _(
"Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters.",
),
dependencies: [
{ direct: "0", filter_mode: "include" },
{ direct: "0", filter_mode: "mixed" },
],
protocolDescription: _("Only servers with one of the selected protocols."),
transportDescription: _(
"Only servers with one of the selected transports.",
),
securityDescription: _(
"Only servers with one of the selected security types.",
),
};
const excludeProxyParameterOptions = {
prefix: "exclude",
toggleLabel: _("Exclude by proxy parameters"),
toggleDescription: _(
"Additionally exclude servers by protocol, transport, and security. Exclude only servers matching the specified parameters.",
),
dependencies: [
{ direct: "0", filter_mode: "exclude" },
{ direct: "0", filter_mode: "mixed" },
],
protocolDescription: _(
"Exclude servers with one of the selected protocols from this level.",
),
transportDescription: _(
"Exclude servers with one of the selected transports from this level.",
),
securityDescription: _(
"Exclude servers with one of the selected security types from this level.",
),
};
[
[
"country",
_("Include countries"),
_("Only from the specified countries."),
countryChoices(),
validateCountryCode,
["include", "mixed"],
],
[
"server_name",
_("Include servers"),
_("Only the specified servers."),
null,
null,
["include", "mixed"],
],
[
"regex",
_("Include by regular expression"),
_("Only servers whose names match the expression."),
null,
validateRegex,
["include", "mixed"],
],
[
"exclude_countries",
_("Exclude countries"),
_("Remove servers from the specified countries from this level."),
countryChoices(),
validateCountryCode,
["exclude", "mixed"],
],
[
"exclude_outbounds",
_("Exclude servers"),
_("Remove the specified servers from this level."),
null,
null,
["exclude", "mixed"],
],
[
"exclude_regex",
_("Exclude by regular expression"),
_("Remove servers whose names match the expression from this level."),
null,
validateRegex,
["exclude", "mixed"],
],
].forEach(([key, label, description, choices, validator, modes]) => {
const list = itemSection.option(form.DynamicList, key, label, description);
modes.forEach((mode) => list.depends({ direct: "0", filter_mode: mode }));
list.rmempty = true;
if (choices) {
choices.forEach((choice) => list.value(choice.value, choice.label));
list.placeholder = _("-- Select --");
}
if (key === "server_name" || key.endsWith("_outbounds")) {
list.load = function (itemId) {
const sectionId = parentSectionForItem(itemId) || "Main";
const values = normalizeOptionValues(optionMapValue(this, itemId, key));
return loadOutboundNameChoices(sectionId).then(() => {
refreshOptionChoices(
this,
currentOutboundNameChoices(sectionId, values),
);
return values;
});
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, (itemId, values) =>
currentOutboundNameChoices(
parentSectionForItem(itemId) || "Main",
values,
),
);
}
if (validator) {
list.validate = function (_itemId, value) {
return validator(null, value);
};
}
if (key === "regex") {
addProxyParameterFilterOptions(itemSection, includeProxyParameterOptions);
} else if (key === "exclude_regex") {
addProxyParameterFilterOptions(itemSection, excludeProxyParameterOptions);
}
});
}
function addPriorityGroupItemOptions(itemSection, options = {}) {
const parentSectionForGroup =
typeof options.parentSectionId === "function"
? options.parentSectionId
: parentSectionIdForItem;
const ownerId =
typeof options.ownerId === "function" ? options.ownerId : () => "";
let o = itemSection.option(
form.Value,
"name",
_("Display name"),
_("Name displayed on the dashboard"),
);
o.rmempty = false;
o.validate = function (_itemId, value) {
return `${value || ""}`.trim() ? true : _("Enter a display name");
};
o = itemSection.option(
form.Value,
"health_url",
_("Check URL"),
_("URL used to check whether a server is alive"),
);
o.default = "https://www.gstatic.com/generate_204";
o.rmempty = false;
urlTestUrlChoices().forEach((value) => o.value(value));
o.validate = function (_itemId, value) {
return validateUrlTestUrl(value);
};
o = itemSection.option(
form.Value,
"active_check_interval",
_("Check interval"),
_("How often the currently selected server is checked"),
);
o.default = "5s";
o.rmempty = false;
o.validate = function (_itemId, value) {
return validateRequiredSingBoxDuration(value);
};
o = itemSection.option(
form.Value,
"check_timeout",
_("Unavailability timeout"),
_("Check timeout after which the server is considered dead"),
);
o.default = "2s";
o.rmempty = false;
o.validate = function (_itemId, value) {
return validateRequiredSingBoxDuration(value);
};
o = itemSection.option(
form.Value,
"recovery_check_interval",
_("Higher-level check interval"),
_(
"How often higher priority levels are checked while a lower level is active",
),
);
o.default = "15s";
o.rmempty = false;
o.validate = function (_itemId, value) {
return validateRequiredSingBoxDuration(value);
};
o = itemSection.option(
form.Flag,
"pick_fastest",
_("Select the fastest node"),
_(
"When switching to another level, test every server and select the fastest instead of the first working one.",
),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"switch_to_faster_same_priority",
_("Automatically select the fastest node in the current level"),
_(
"Periodically check the current level and switch to a faster server even when the current one works.",
),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
form.Value,
"fastest_check_interval",
_("Faster server search interval"),
_("Use sing-box duration format like 1d, 12h or 30m"),
);
o.depends("switch_to_faster_same_priority", "1");
o.default = "3m";
o.rmempty = false;
o.validate = function (itemId, value) {
return optionMapValue(this, itemId, "switch_to_faster_same_priority") ===
"1"
? validateRequiredSingBoxDuration(value)
: true;
};
o = itemSection.option(
form.Flag,
"interrupt_exist_connections",
_("Interrupt connections"),
_("Interrupt connections when priority failover switches server"),
);
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"pin_dashboard",
_("Pin on dashboard"),
_("Keep Priority before latency-sorted servers"),
);
o.default = "1";
o.rmempty = false;
o = itemSection.option(
ButtonAddSettingsDynamicList,
"priority_level",
_("Priority levels"),
_("Top level has the highest priority; lower levels are used as fallback"),
);
o.rmempty = true;
o.modalonly = true;
o.addButtonLabel = _("+ Add level");
o.childType = "priority_level";
o.ownerOption = "group";
o.childOwnerId = ownerId;
o.parentSectionId = parentSectionForGroup;
o.childValueOption = "name";
o.childDefaults = defaultPriorityLevelSettings();
o.renderItemSettingsModal = showPriorityLevelSettingsModal;
o.validateItemsOnSave = function (groupId, values) {
return validatePriorityLevelItemsBeforeSave(groupId, values, this);
};
o.hasItemSettings = function (groupId, value) {
const normalized = `${value || ""}`.trim();
if (isExistingChildItem(groupId, normalized, "priority_level", "group")) {
return true;
}
return normalized.length > 0;
};
o.inputValueForItem = function (groupId, value) {
const inputValue = childItemInputValue(
groupId,
value,
"priority_level",
"name",
"group",
);
const store = childPendingSettingsStore(this, groupId);
return store[inputValue] && store[inputValue].name
? store[inputValue].name
: inputValue;
};
o.stagedChildSettings = function (groupId, value) {
const id = childItemInputValue(
groupId,
value,
"priority_level",
"name",
"group",
);
const store = childPendingSettingsStore(this, groupId);
return store[id] ? Object.assign({}, store[id]) : null;
};
o.clearStagedChildSettings = function (groupId) {
if (this.pendingChildSettings) {
delete this.pendingChildSettings[groupId];
}
};
o.afterMaterializeChildItems = function (_groupId, itemIds) {
itemIds.forEach((itemId, index) => {
uci.set(UCI_PACKAGE, itemId, "order", `${index}`);
});
};
o.renderListItemLabel = function (groupId, itemId) {
return E(
"span",
{ class: "fkp-dynlist-label" },
this.inputValueForItem(groupId, itemId),
);
};
}
function addDashboardGroupFilterOption(
optionSection,
key,
label,
description,
modes,
) {
const list = optionSection.option(form.DynamicList, key, label, description);
modes.forEach((mode) => {
list.depends({
action: "connection",
dashboard_filter_mode: mode,
urltest: /.+/,
});
list.depends({
action: "connection",
dashboard_filter_mode: mode,
priority_group: /.+/,
});
});
list.rmempty = true;
list.placeholder = _("-- Select --");
list.load = function (section_id) {
const values = getConfigListValues(section_id, key);
const choices = currentSectionGroupChoices(section_id, values);
refreshOptionChoices(this, choices);
return values;
};
list.validate = function (section_id, value) {
const selected = new Set(
currentSectionGroupChoices(section_id).map((choice) => choice.value),
);
return normalizeDynamicListItems(value).every((item) => selected.has(item))
? true
: _("Select an existing URLTest or Priority group");
};
configureLiveDynamicListChoices(list, currentSectionGroupChoices);
}
function addDashboardServerFilterOptions(section) {
const optionSection = {
option: (optionType, ...args) => {
const option = section.taboption("settings", optionType, ...args);
option.modalonly = true;
return option;
},
};
let o = optionSection.option(
form.ListValue,
"dashboard_filter_mode",
_("Servers on dashboard"),
_("Filter the servers that will be displayed on the dashboard."),
);
urlTestFilterModeChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "disabled";
o.depends("action", "connection");
o = optionSection.option(
form.ListValue,
"dashboard_detect_server_country",
_("Detect server country"),
);
["exclude", "include", "mixed"].forEach((mode) =>
o.depends({ action: "connection", dashboard_filter_mode: mode }),
);
serverCountryDetectionChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "flag_emoji";
const includeProxyParameterOptions = {
prefix: "dashboard_include",
toggleLabel: _("Include by proxy parameters"),
toggleDescription: _(
"Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters.",
),
dependencies: [
{ action: "connection", dashboard_filter_mode: "include" },
{ action: "connection", dashboard_filter_mode: "mixed" },
],
protocolDescription: _(
"Show only servers with one of the selected protocols.",
),
transportDescription: _(
"Show only servers with one of the selected transports.",
),
securityDescription: _(
"Show only servers with one of the selected security types.",
),
};
const excludeProxyParameterOptions = {
prefix: "dashboard_exclude",
toggleLabel: _("Exclude by proxy parameters"),
toggleDescription: _(
"Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters.",
),
dependencies: [
{ action: "connection", dashboard_filter_mode: "exclude" },
{ action: "connection", dashboard_filter_mode: "mixed" },
],
protocolDescription: _("Hide servers with one of the selected protocols."),
transportDescription: _(
"Hide servers with one of the selected transports.",
),
securityDescription: _(
"Hide servers with one of the selected security types.",
),
};
[
[
"dashboard_include_countries",
_("Include countries"),
_("Show servers only from the specified countries."),
countryChoices(),
validateCountryCode,
["include", "mixed"],
],
[
"dashboard_include_outbounds",
_("Include servers"),
_("Show only selected servers."),
null,
null,
["include", "mixed"],
],
[
"dashboard_include_regex",
_("Include by regular expression"),
_("Show servers whose names match the expression."),
null,
validateRegex,
["include", "mixed"],
],
[
"dashboard_exclude_countries",
_("Exclude countries"),
_("Hide servers from the specified countries."),
countryChoices(),
validateCountryCode,
["exclude", "mixed"],
],
[
"dashboard_exclude_outbounds",
_("Exclude servers"),
_("Hide selected servers."),
null,
null,
["exclude", "mixed"],
],
[
"dashboard_exclude_regex",
_("Exclude by regular expression"),
_("Hide servers whose names match the expression."),
null,
validateRegex,
["exclude", "mixed"],
],
].forEach(([key, label, description, choices, validator, modes]) => {
const list = optionSection.option(
form.DynamicList,
key,
label,
description,
);
modes.forEach((mode) =>
list.depends({ action: "connection", dashboard_filter_mode: mode }),
);
list.rmempty = true;
if (choices) {
choices.forEach((choice) => list.value(choice.value, choice.label));
list.placeholder = _("-- Select --");
}
if (key.endsWith("_outbounds")) {
list.load = function (section_id) {
const values = getConfigListValues(section_id, key);
loadOutboundNameChoices(section_id).then(() => {
refreshDashboardFilterChoiceWidgets(section_id);
});
return values;
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, currentOutboundNameChoices);
}
if (validator) {
list.validate = function (_section_id, value) {
return validator(null, value);
};
}
if (key === "dashboard_include_regex") {
addDashboardGroupFilterOption(
optionSection,
"dashboard_include_groups",
_("Add URLTest / Priority group"),
_(
"Show servers from selected groups without repeating their filtering criteria.",
),
["include", "mixed"],
);
addProxyParameterFilterOptions(
optionSection,
includeProxyParameterOptions,
);
} else if (key === "dashboard_exclude_regex") {
addDashboardGroupFilterOption(
optionSection,
"dashboard_exclude_groups",
_("Exclude URLTest / Priority group"),
_(
"Hide servers from selected groups without repeating their filtering criteria.",
),
["exclude", "mixed"],
);
addProxyParameterFilterOptions(
optionSection,
excludeProxyParameterOptions,
);
}
});
}
function settingValueEquals(left, right) {
const normalize = (value) => {
if (Array.isArray(value)) {
return JSON.stringify(value.map((item) => `${item || ""}`));
}
return value === undefined || value === null ? "" : `${value}`;
};
return normalize(left) === normalize(right);
}
function readChildSettings(itemId, keys, defaults) {
const result = Object.assign({}, defaults || {});
keys.forEach((key) => {
const value = uci.get(UCI_PACKAGE, itemId, key);
if (value !== null && value !== undefined) {
result[key] = Array.isArray(value) ? value.slice() : value;
}
});
return result;
}
function changedSettings(base, next, keys) {
const result = {};
keys.forEach((key) => {
if (!settingValueEquals(base ? base[key] : null, next ? next[key] : null)) {
result[key] = next ? next[key] : null;
}
});
return result;
}
function hasChangedSettings(settings) {
return Object.keys(settings || {}).length > 0;
}
function childPendingSettingsStore(option, section_id) {
if (!option.pendingChildSettings) {
option.pendingChildSettings = {};
}
if (!option.pendingChildSettings[section_id]) {
option.pendingChildSettings[section_id] = {};
}
return option.pendingChildSettings[section_id];
}
function pendingChildSettings(option, section_id, value, defaults) {
value = `${value || ""}`.trim();
const store = childPendingSettingsStore(option, section_id);
if (!store[value]) {
store[value] = Object.assign({}, defaults || {});
}
return store[value];
}
function renderStackedJsonSettingsModal(title, map, onSave) {
const modal = document.querySelector("#modal_overlay > .modal.cbi-modal");
const activeMap = modal ? modal.querySelector(".cbi-map:not(.hidden)") : null;
const buttonRow = modal ? modal.querySelector("div.button-row") : null;
const heading = modal ? modal.querySelector("h4") : null;
if (!modal || !activeMap || !buttonRow || !heading) {
return Promise.resolve();
}
return map
.load()
.then(() => map.render())
.then((nodes) => {
const titleNode = E("span", title ? ` » ${title}` : "");
const originalButtonClass = buttonRow.getAttribute("class") || "";
const originalButtonNodes = Array.from(buttonRow.childNodes);
let closed = false;
let saveButton;
let validationSummary;
const clearValidationSummary = () => {
if (validationSummary && validationSummary.parentNode) {
validationSummary.parentNode.removeChild(validationSummary);
}
validationSummary = null;
};
const showValidationSummary = (error) => {
clearValidationSummary();
const message = error?.message || "";
validationSummary = E(
"div",
{
class:
"alert-message warning fkp-stacked-settings-validation-summary",
},
[
E("strong", {}, _("Cannot save settings")),
E("div", {}, _("Fix the highlighted fields and save again.")),
message ? E("small", {}, message) : "",
],
);
buttonRow.parentNode.insertBefore(validationSummary, buttonRow);
const invalidInput = nodes.querySelector(".cbi-input-invalid");
if (invalidInput) {
invalidInput.scrollIntoView({ block: "center", behavior: "smooth" });
invalidInput.focus({ preventScroll: true });
}
};
const restoreButtonRow = () => {
buttonRow.textContent = "";
originalButtonNodes.forEach((node) => buttonRow.appendChild(node));
buttonRow.setAttribute("class", originalButtonClass);
};
const close = () => {
if (closed) {
return;
}
closed = true;
clearValidationSummary();
if (nodes.parentNode) {
nodes.parentNode.removeChild(nodes);
}
if (titleNode.parentNode) {
titleNode.parentNode.removeChild(titleNode);
}
activeMap.classList.remove("hidden");
restoreButtonRow();
};
const save = () => {
if (saveButton) {
saveButton.disabled = true;
}
clearValidationSummary();
return map
.parse()
.then(() => {
onSave(cleanFormSectionData(map.data.get(map.config, "settings")));
close();
})
.catch((error) => {
if (saveButton) {
saveButton.disabled = false;
}
showValidationSummary(error);
});
};
buttonRow.textContent = "";
buttonRow.append(
E(
"button",
{
class: "btn cbi-button",
click: close,
},
_("Close"),
),
" ",
(saveButton = E(
"button",
{
class: "btn cbi-button cbi-button-positive important",
click: save,
},
_("Save"),
)),
);
heading.appendChild(titleNode);
activeMap.classList.add("hidden");
activeMap.parentNode.insertBefore(nodes, activeMap.nextElementSibling);
});
}
function showChildItemSettingsModal(section_id, itemValue, option, settings) {
const value = `${itemValue || ""}`.trim();
const existing = isExistingChildItem(
section_id,
value,
settings.typeName,
settings.ownerOption,
);
const inputValue = childItemInputValue(
section_id,
value,
settings.typeName,
settings.valueOption,
settings.ownerOption,
);
const defaults =
typeof settings.defaults === "function"
? settings.defaults(inputValue)
: Object.assign({}, settings.defaults || {});
const initialSettings = existing
? readChildSettings(value, settings.keys, defaults)
: Object.assign(
{},
pendingChildSettings(option, section_id, inputValue, defaults),
);
const data = {
settings: Object.assign({}, initialSettings),
};
const map = new form.JSONMap(data);
const itemSection = map.section(form.NamedSection, "settings");
itemSection.anonymous = true;
itemSection.addremove = false;
settings.addOptions(itemSection, {
parentSectionId: () => section_id,
ownerId: () => value || section_id,
});
return renderStackedJsonSettingsModal(
settings.title(inputValue),
map,
(nextSettings) => {
if (existing) {
const diff = changedSettings(
initialSettings,
nextSettings,
settings.keys,
);
if (hasChangedSettings(diff)) {
applyChildItemSettings(value, diff);
}
if (typeof settings.afterSave === "function") {
settings.afterSave(value, inputValue, nextSettings, existing);
}
return;
}
const nextInputValue =
`${nextSettings[settings.valueOption] || inputValue || ""}`.trim();
const store = childPendingSettingsStore(option, section_id);
if (nextInputValue !== inputValue) {
delete store[inputValue];
}
store[nextInputValue] = nextSettings;
if (typeof settings.afterSave === "function") {
settings.afterSave(value, nextInputValue, nextSettings, existing);
}
},
);
}
function ruleSetIncludesSubnets(section_id, value) {
const settings = readItemSettingsMap(section_id, RULE_SET_ITEM_SETTINGS_KEY);
const itemSettings = settings && settings[value];
if (
itemSettings &&
typeof itemSettings === "object" &&
itemSettings.include_subnets != null
) {
return itemSettingsFlag(itemSettings, "include_subnets", false);
}
return getConfigListValues(section_id, "rule_set_with_subnets").includes(
value,
);
}
function showSubscriptionUrlSettingsModal(_section_id, itemValue, option) {
return showChildItemSettingsModal(_section_id, itemValue, option, {
typeName: "subscription_url",
valueOption: "url",
keys: subscriptionUrlSettingsKeys(),
defaults: defaultSubscriptionUrlSettings(),
addOptions: addSubscriptionUrlItemOptions,
title: () => _("Subscription URL settings"),
});
}
function showInterfaceSettingsModal(_section_id, itemValue, option) {
return showChildItemSettingsModal(_section_id, itemValue, option, {
typeName: "section_interface",
valueOption: "name",
keys: interfaceSettingsKeys(),
defaults: defaultInterfaceSettings(),
addOptions: addInterfaceItemOptions,
title: () => _("Network interface settings"),
});
}
function showUrlTestSettingsModal(
_section_id,
itemValue,
option,
widget,
itemNode,
context = {},
) {
const sectionId = _section_id || "Main";
// Pre-warm the cache BEFORE opening the modal.
// CBINamedSection.render() calls renderUCISection() directly without
// calling section.load() first, so list.load() (which would populate
// the outbound name cache) is never awaited before renderWidget().
// By loading the cache here the dropdown has choices on first render.
loadOutboundNameChoices(sectionId).then(() => {
showChildItemSettingsModal(_section_id, itemValue, option, {
typeName: "urltest",
valueOption: "name",
keys: urlTestSettingsKeys(),
defaults: defaultUrlTestSettings,
addOptions: addUrlTestItemOptions,
title: (name) => {
const normalized = `${name || ""}`.trim();
return !normalized || /^urltest-[a-z0-9]+-\d+$/.test(normalized)
? _("URLTest settings")
: `${_("URLTest settings")}: ${normalized}`;
},
afterSave: (itemId, inputValue, settings, existing) => {
const displayName = `${settings.name || inputValue || ""}`.trim();
if (existing) {
uci.unset(UCI_PACKAGE, itemId, "id");
uci.unset(UCI_PACKAGE, itemId, "display_name");
updateDynamicListItemLabel(itemNode, displayName);
return;
}
if (context.adding) {
addDynamicListItem(widget, displayName, displayName);
} else {
updateDynamicListItemLabel(itemNode, displayName);
}
},
});
});
}
function showPriorityLevelSettingsModal(
groupId,
itemValue,
option,
widget,
itemNode,
context = {},
) {
const sectionId = context.parentSectionId || groupId || "Main";
// Pre-warm the outbound name cache before opening the modal.
// CBINamedSection.render() never calls section.load(), so list.load()
// is never invoked before renderWidget() — cache would be empty otherwise.
loadOutboundNameChoices(sectionId).then(() => {
showChildItemSettingsModal(groupId, itemValue, option, {
typeName: "priority_level",
ownerOption: "group",
valueOption: "name",
keys: priorityLevelSettingsKeys(),
defaults: defaultPriorityLevelSettings(),
addOptions: (itemSection) =>
addPriorityLevelItemOptions(itemSection, {
parentSectionId: () => context.parentSectionId || "",
}),
title: (name) => {
const normalized = `${name || ""}`.trim();
return normalized
? `${_("Priority level settings")}: ${normalized}`
: _("Priority level settings");
},
afterSave: (itemId, inputValue, settings, existing) => {
const displayName = `${settings.name || inputValue || ""}`.trim();
if (existing) {
updateDynamicListItemLabel(itemNode, displayName);
return;
}
if (context.adding) {
addDynamicListItem(widget, displayName, displayName);
} else {
updateDynamicListItemLabel(itemNode, displayName);
}
},
});
});
}
function createPriorityGroupItem(section_id, groupId, settings) {
const created = uci.add(UCI_PACKAGE, "priority_group", groupId) || groupId;
uci.set(UCI_PACKAGE, created, "section", section_id);
Object.entries(priorityGroupChildDefaults()).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
uci.set(UCI_PACKAGE, created, key, `${value}`);
}
});
applyChildItemSettings(created, settings);
return created;
}
function showPriorityGroupSettingsModal(
section_id,
itemValue,
option,
widget,
itemNode,
context = {},
) {
const groupId = context.adding
? randomPriorityGroupId()
: `${itemValue || ""}`.trim();
if (!groupId) {
return null;
}
return showChildItemSettingsModal(section_id, groupId, option, {
typeName: "priority_group",
valueOption: "name",
keys: priorityGroupSettingsKeys(),
defaults: defaultPriorityGroupSettings(),
addOptions: addPriorityGroupItemOptions,
title: (name) => {
if (context.adding) {
return _("Priority settings");
}
const normalized = `${name || ""}`.trim();
return normalized
? `${_("Priority settings")}: ${normalized}`
: _("Priority settings");
},
afterSave: (itemId, inputValue, settings, existing) => {
const displayName = `${settings.name || inputValue || ""}`.trim();
if (!existing) {
const created = createPriorityGroupItem(section_id, groupId, settings);
const store = childPendingSettingsStore(option, section_id);
delete store[groupId];
delete store[inputValue];
delete store[displayName];
addDynamicListItem(widget, created, displayName);
return;
}
updateDynamicListItemLabel(itemNode, displayName);
},
});
}
function validateUrlTestItemsBeforeSave(section_id, values, option) {
const store = childPendingSettingsStore(option, section_id);
for (const value of normalizeDynamicListItems(values)) {
const itemId = `${value || ""}`;
let name = "";
if (isExistingChildItem(section_id, itemId, "urltest")) {
name =
uci.get(UCI_PACKAGE, itemId, "name") ||
uci.get(UCI_PACKAGE, itemId, "display_name") ||
"";
} else if (store[itemId]) {
name = store[itemId].name || "";
}
if (!`${name || ""}`.trim()) {
return _("Enter a display name");
}
}
return true;
}
function validatePriorityGroupItemsBeforeSave(section_id, values) {
for (const value of normalizeDynamicListItems(values)) {
const groupId = `${value || ""}`.trim();
if (!isExistingChildItem(section_id, groupId, "priority_group")) {
return _("Open priority settings and enter a display name");
}
if (!`${uci.get(UCI_PACKAGE, groupId, "name") || ""}`.trim()) {
return _("Enter a display name");
}
}
return true;
}
function showOutboundJsonSettingsModal(
_section_id,
itemValue,
_option,
widget,
itemNode,
context = {},
) {
const data = {
settings: {
outbound_json: `${itemValue || ""}`,
},
};
const map = new form.JSONMap(data);
const itemSection = map.section(form.NamedSection, "settings");
itemSection.anonymous = true;
itemSection.addremove = false;
const jsonOption = itemSection.option(
form.TextValue,
"outbound_json",
_("JSON outbound"),
_("Enter a complete sing-box outbound object"),
);
jsonOption.rows = 12;
jsonOption.wrap = "soft";
jsonOption.textarea = true;
jsonOption.modalonly = true;
jsonOption.rmempty = false;
jsonOption.validate = function (_itemId, value) {
const usedTags =
widget && widget.node
? Array.from(widget.node.querySelectorAll(".item"))
.filter((item) => item !== itemNode)
.map((item) =>
outboundJsonDisplayTag(dynamicListItemCurrentValue(item, "")),
)
.filter(Boolean)
: [];
const validation = main.validateOutboundJson(`${value || ""}`, usedTags);
return validation.valid ? true : validation.message;
};
configureTextareaOption(jsonOption);
return renderStackedJsonSettingsModal(
_("JSON outbound settings"),
map,
(settings) => {
const value = `${settings.outbound_json || ""}`.trim();
if (context.adding) {
addDynamicListItem(widget, value);
return;
}
setDynamicListItemValue(itemNode, value);
updateDynamicListItemLabel(itemNode, outboundJsonDisplayTag(value));
if (widget && widget.node) {
widget.dispatchCbiDynlistChange(widget.node, value);
}
},
);
}
function validateOutboundJsonItemsBeforeSave(_section_id, values) {
const items = Array.isArray(values) ? values : values ? [values] : [];
const tags = [];
for (const value of items) {
const validation = main.validateOutboundJson(`${value || ""}`, tags);
if (!validation.valid) {
const tag = outboundJsonDisplayTag(value);
return tag ? `${tag}: ${validation.message}` : validation.message;
}
const tag = outboundJsonDisplayTag(value);
tags.push(tag);
}
return true;
}
function showRuleSetSettingsModal(section_id, itemValue, option, widget) {
const data = {
settings: {
include_subnets: ruleSetIncludesSubnets(section_id, itemValue)
? "1"
: "0",
},
};
const map = new form.JSONMap(data);
const section = map.section(form.NamedSection, "settings");
section.anonymous = true;
section.addremove = false;
const includeSubnets = section.option(
form.Flag,
"include_subnets",
_("Include IP addresses and subnets"),
_("Subnets from the list will be extracted and added to nftables"),
);
includeSubnets.default = "0";
includeSubnets.rmempty = false;
return renderStackedJsonSettingsModal(
_("Rule set settings"),
map,
(settings) => {
const value = settings.include_subnets === "1";
const refs = uniqueDynamicListItems(
widget && typeof widget.getValue === "function"
? widget.getValue()
: getCustomRulesetReferences(section_id),
);
const subnets = new Set(
getConfigListValues(section_id, "rule_set_with_subnets").filter((ref) =>
refs.includes(ref),
),
);
if (value) {
subnets.add(itemValue);
} else {
subnets.delete(itemValue);
}
writeListOption(
section_id,
"rule_set",
refs.filter((ref) => !subnets.has(ref)),
);
writeListOption(section_id, "rule_set_with_subnets", [...subnets]);
if (option && typeof option.getUIElement === "function") {
option.getUIElement(section_id).setValue(refs);
}
},
);
}
function ensureActionProvidersAvailabilityLoaded() {
if (actionProvidersAvailabilityState.loaded) {
return Promise.resolve(actionProvidersAvailabilityState);
}
if (actionProvidersAvailabilityPromise) {
return actionProvidersAvailabilityPromise;
}
if (actionProvidersAvailabilityLoader) {
actionProvidersAvailabilityPromise = actionProvidersAvailabilityLoader()
.then((capabilities) => {
updateActionProvidersAvailabilityState({
zapretInstalled: Boolean(capabilities?.zapretInstalled),
zapret2Installed: Boolean(capabilities?.zapret2Installed),
byedpiInstalled: Boolean(capabilities?.byedpiInstalled),
singBoxExtended: Boolean(capabilities?.singBoxExtended),
});
return actionProvidersAvailabilityState;
})
.catch(() => {
actionProvidersAvailabilityLoader = null;
actionProvidersAvailabilityPromise = null;
return ensureActionProvidersAvailabilityLoaded();
})
.finally(() => {
actionProvidersAvailabilityPromise = null;
});
return actionProvidersAvailabilityPromise;
}
actionProvidersAvailabilityPromise = Promise.allSettled([
main.TachyonShellMethods.checkZapretRuntime(),
main.TachyonShellMethods.checkZapret2Runtime(),
main.TachyonShellMethods.checkByedpiRuntime(),
])
.then(([zapretResult, zapret2Result, byedpiResult]) => {
const zapret =
zapretResult && zapretResult.status === "fulfilled"
? zapretResult.value
: null;
const zapret2 =
zapret2Result && zapret2Result.status === "fulfilled"
? zapret2Result.value
: null;
const byedpi =
byedpiResult && byedpiResult.status === "fulfilled"
? byedpiResult.value
: null;
actionProvidersAvailabilityState.loaded = true;
actionProvidersAvailabilityState.zapretInstalled = Boolean(
zapret && zapret.success && zapret.data && zapret.data.zapret_installed,
);
actionProvidersAvailabilityState.zapret2Installed = Boolean(
zapret2 &&
zapret2.success &&
zapret2.data &&
zapret2.data.zapret2_installed,
);
actionProvidersAvailabilityState.byedpiInstalled = Boolean(
byedpi && byedpi.success && byedpi.data && byedpi.data.byedpi_installed,
);
return actionProvidersAvailabilityState;
})
.catch(() => {
actionProvidersAvailabilityState.loaded = true;
actionProvidersAvailabilityState.zapretInstalled = false;
actionProvidersAvailabilityState.zapret2Installed = false;
actionProvidersAvailabilityState.byedpiInstalled = false;
// singBoxExtended is set via systemInfo, keep existing value
return actionProvidersAvailabilityState;
})
.finally(() => {
actionProvidersAvailabilityPromise = null;
});
return actionProvidersAvailabilityPromise;
}
function isZapretInstalledForUi() {
return actionProvidersAvailabilityState.zapretInstalled;
}
function isZapret2InstalledForUi() {
return actionProvidersAvailabilityState.zapret2Installed;
}
function isByedpiInstalledForUi() {
return actionProvidersAvailabilityState.byedpiInstalled;
}
function isSingBoxExtendedForUi() {
return actionProvidersAvailabilityState.singBoxExtended;
}
function getRuleConfiguredAction(section_id) {
const action = uci.get(UCI_PACKAGE, section_id, "action");
return action ? `${action}` : null;
}
function getRuleResolvedAction(section_id) {
return getRuleConfiguredAction(section_id) || "connection";
}
function getActionOptionLabel(action) {
switch (`${action}`) {
case "block":
return "Block";
case "bypass":
return "Bypass";
case "connection":
return "Connection";
case "dns":
return "DNS";
case "vpn":
return "VPN";
case "zapret":
return "Zapret";
case "zapret2":
return "Zapret2";
case "byedpi":
return "ByeDPI";
case "awg":
return "AmneziaWG";
case "warp":
return "WARP";
case "anytls":
return "AnyTLS";
case "snell":
return "Snell";
case "mieru":
return "Mieru";
case "sudoku":
return "Sudoku";
case "masque":
return "MASQUE";
case "openvpn":
return "OpenVPN";
case "hosts":
return _("Hosts");
case "outbound":
return _("JSON outbound");
case "proxy":
default:
return "Proxy";
}
}
function getRuleActionDisplayValue(section_id) {
const action = getRuleResolvedAction(section_id);
if (action === "zapret") {
return "Zapret";
}
if (action === "zapret2") {
return "Zapret2";
}
if (action === "byedpi") {
return "ByeDPI";
}
if (action === "awg") {
return "AmneziaWG";
}
if (action === "warp") {
return "WARP";
}
if (action === "anytls") {
return "AnyTLS";
}
if (action === "snell") {
return "Snell";
}
if (action === "mieru") {
return "Mieru";
}
if (action === "sudoku") {
return "Sudoku";
}
if (action === "masque") {
return "MASQUE";
}
if (action === "openvpn") {
return "OpenVPN";
}
return getActionOptionLabel(action);
}
function getRuleActionDisplayMarkup(section_id) {
return getRuleActionDisplayValue(section_id);
}
function populateActionOptionValues(option) {
delete option.keylist;
delete option.vallist;
option.value("connection", getActionOptionLabel("connection"));
if (isSingBoxExtendedForUi()) {
option.value("awg", getActionOptionLabel("awg"));
option.value("warp", getActionOptionLabel("warp"));
option.value("masque", getActionOptionLabel("masque"));
option.value("openvpn", getActionOptionLabel("openvpn"));
option.value("anytls", getActionOptionLabel("anytls"));
option.value("snell", getActionOptionLabel("snell"));
option.value("mieru", getActionOptionLabel("mieru"));
option.value("sudoku", getActionOptionLabel("sudoku"));
}
option.value("bypass", "Bypass");
option.value("block", "Block");
option.value("dns", "DNS");
if (isZapretInstalledForUi()) {
option.value("zapret", getActionOptionLabel("zapret"));
}
if (isZapret2InstalledForUi()) {
option.value("zapret2", getActionOptionLabel("zapret2"));
}
if (isByedpiInstalledForUi()) {
option.value("byedpi", getActionOptionLabel("byedpi"));
}
option.value("hosts", getActionOptionLabel("hosts"));
}
function getConfigListValues(section_id, key) {
return normalizeOptionValues(uci.get(UCI_PACKAGE, section_id, key));
}
function stringArraysEqual(left, right) {
left = normalizeDynamicListItems(left);
right = normalizeDynamicListItems(right);
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
);
}
function writeListOption(section_id, key, values) {
const normalized = normalizeOptionValues(values);
if (stringArraysEqual(getConfigListValues(section_id, key), normalized)) {
return;
}
if (normalized.length) {
uci.set(UCI_PACKAGE, section_id, key, normalized);
} else {
uci.unset(UCI_PACKAGE, section_id, key);
}
}
function makeDeviceOptionsExclusive(firstOption, secondOption) {
let changing = false;
const firstWidgets = {};
const secondWidgets = {};
function removeMatches(section_id, value, otherWidgets) {
if (changing) {
return;
}
const selected = new Set(normalizeOptionValues(value));
if (!selected.size) {
return;
}
const widget = otherWidgets[section_id];
if (!widget) {
return;
}
const current = normalizeOptionValues(widget.getValue());
const filtered = current.filter((item) => !selected.has(item));
if (stringArraysEqual(current, filtered)) {
return;
}
changing = true;
try {
widget.setValue(filtered);
} finally {
changing = false;
}
}
firstOption.onDeviceWidgetReady = function (section_id, widget) {
firstWidgets[section_id] = widget;
};
secondOption.onDeviceWidgetReady = function (section_id, widget) {
secondWidgets[section_id] = widget;
};
firstOption.onDeviceListChange = function (section_id, value) {
removeMatches(section_id, value, secondWidgets);
};
secondOption.onDeviceListChange = function (section_id, value) {
removeMatches(section_id, value, firstWidgets);
};
}
function childOwnerOption(ownerOption) {
return ownerOption || "section";
}
function childItemOrder(item) {
const value = item ? item.order : null;
const parsed = Number.parseInt(value == null ? "0" : `${value}`, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function getChildItemIds(section_id, typeName, ownerOption) {
const ownerKey = childOwnerOption(ownerOption);
const items = uci
.sections(UCI_PACKAGE, typeName)
.filter((item) => item[ownerKey] === section_id);
if (typeName === "priority_level") {
items.sort((a, b) => {
const orderDiff = childItemOrder(a) - childItemOrder(b);
if (orderDiff !== 0) {
return orderDiff;
}
return `${a[".name"] || ""}`.localeCompare(`${b[".name"] || ""}`);
});
}
return items.map((item) => item[".name"]).filter(Boolean);
}
function childItemValue(itemId, valueOption, fallback) {
if (!valueOption) {
return itemId;
}
const value = uci.get(UCI_PACKAGE, itemId, valueOption);
return value == null || value === "" ? fallback || itemId : `${value}`;
}
function childItemInputValue(
section_id,
value,
typeName,
valueOption,
ownerOption,
) {
const itemId = `${value || ""}`;
if (isExistingChildItem(section_id, itemId, typeName, ownerOption)) {
return childItemValue(itemId, valueOption, itemId);
}
return itemId;
}
function isExistingChildItem(section_id, itemId, typeName, ownerOption) {
const ownerKey = childOwnerOption(ownerOption);
return Boolean(
itemId &&
uci.get(UCI_PACKAGE, itemId, ".type") === typeName &&
uci.get(UCI_PACKAGE, itemId, ownerKey) === section_id,
);
}
function findChildItemForInput(section_id, options, inputValue) {
const rawValue = `${inputValue || ""}`.trim();
if (
isExistingChildItem(
section_id,
rawValue,
options.typeName,
options.ownerOption,
)
) {
return rawValue;
}
if (options.valueOption) {
return getChildItemIds(
section_id,
options.typeName,
options.ownerOption,
).find(
(itemId) =>
childItemValue(itemId, options.valueOption, itemId) === rawValue,
);
}
return null;
}
function createChildItem(section_id, options, inputValue) {
const rawValue = `${inputValue || ""}`.trim();
const existing = findChildItemForInput(section_id, options, rawValue);
if (existing) {
return {
value: existing,
text: options.valueOption
? childItemValue(existing, options.valueOption, existing)
: existing,
created: false,
};
}
const requestedId =
typeof options.createId === "function"
? `${options.createId(rawValue, section_id) || ""}`.trim()
: "";
const itemId =
(requestedId && uci.add(UCI_PACKAGE, options.typeName, requestedId)) ||
requestedId ||
uci.add(UCI_PACKAGE, options.typeName);
uci.set(
UCI_PACKAGE,
itemId,
childOwnerOption(options.ownerOption),
section_id,
);
if (options.valueOption) {
uci.set(UCI_PACKAGE, itemId, options.valueOption, rawValue);
}
if (options.defaults && typeof options.defaults === "object") {
Object.entries(options.defaults).forEach(([key, value]) => {
value =
typeof value === "function"
? value(rawValue, section_id, itemId)
: value;
if (value !== undefined && value !== null && value !== "") {
uci.set(UCI_PACKAGE, itemId, key, `${value}`);
}
});
}
return {
value: itemId,
text: options.valueOption ? rawValue : itemId,
created: true,
};
}
function applyChildItemSettings(itemId, settings) {
Object.entries(settings || {}).forEach(([key, value]) => {
if (!key || key.charAt(0) === ".") {
return;
}
if (value === undefined || value === null || value === "") {
uci.unset(UCI_PACKAGE, itemId, key);
} else if (Array.isArray(value)) {
uci.set(
UCI_PACKAGE,
itemId,
key,
value
.map((item) => `${item || ""}`.trim())
.filter((item) => item.length),
);
} else {
uci.set(UCI_PACKAGE, itemId, key, `${value}`);
}
});
}
function materializeChildItems(section_id, options, inputValue) {
const result = [];
const seen = new Set();
normalizeDynamicListItems(inputValue).forEach((value) => {
const createdItem = createChildItem(section_id, options, value);
const itemId = createdItem.value;
const stagedSettings =
createdItem.created && typeof options.stagedSettings === "function"
? options.stagedSettings(value, itemId, createdItem.created)
: null;
if (stagedSettings) {
applyChildItemSettings(itemId, stagedSettings);
}
if (itemId && !seen.has(itemId)) {
seen.add(itemId);
result.push(itemId);
}
});
return result;
}
function cleanupPriorityLevelsForGroup(groupId) {
getChildItemIds(groupId, "priority_level", "group").forEach((levelId) => {
uci.remove(UCI_PACKAGE, levelId);
});
}
function cleanupRemovedChildItems(
section_id,
typeName,
keepValues,
ownerOption,
) {
const keep = new Set(normalizeDynamicListItems(keepValues));
getChildItemIds(section_id, typeName, ownerOption).forEach((itemId) => {
if (!keep.has(itemId)) {
if (typeName === "priority_group") {
cleanupPriorityLevelsForGroup(itemId);
}
uci.remove(UCI_PACKAGE, itemId);
}
});
}
function parentSectionIdForItem(itemId) {
return uci.get(UCI_PACKAGE, itemId, "section") || "";
}
function refreshOptionChoices(option, choices) {
delete option.keylist;
delete option.vallist;
(choices || []).forEach((choice) => {
if (typeof choice === "object") {
option.value(choice.value, choice.label);
} else {
option.value(choice);
}
});
}
function validateRegex(_section_id, value) {
if (!value || !value.length) {
return true;
}
try {
new RegExp(value);
return true;
} catch (_error) {
return _("Invalid regular expression");
}
}
function validateKeyword(_section_id, value) {
if (!value || !value.length) {
return true;
}
if (/[,\s]/.test(value)) {
return _("Keyword must not contain spaces or commas");
}
return true;
}
function isSingBoxDuration(value) {
return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value);
}
function writeOptionalDurationOption(section_id, key, value) {
const normalized = value ? `${value}`.trim() : "";
const disabledKey = `${key}_disabled`;
if (normalized.length) {
uci.set(UCI_PACKAGE, section_id, key, normalized);
uci.unset(UCI_PACKAGE, section_id, disabledKey);
} else {
uci.unset(UCI_PACKAGE, section_id, key);
uci.set(UCI_PACKAGE, section_id, disabledKey, "1");
}
}
function validateOptionalSingBoxDuration(value) {
const normalized = value ? `${value}`.trim() : "";
if (!normalized.length) {
return true;
}
if (isSingBoxDuration(normalized)) {
return true;
}
return _("Use sing-box duration format like 1d, 12h or 30m");
}
function validateRequiredSingBoxDuration(value) {
const normalized = value ? `${value}`.trim() : "";
if (!normalized.length) {
return _("Use sing-box duration format like 1d, 12h or 30m");
}
if (isSingBoxDuration(normalized)) {
return true;
}
return _("Use sing-box duration format like 1d, 12h or 30m");
}
function parseSubscriptionUrlEntry(value) {
const normalized = value ? `${value}`.trim() : "";
if (!normalized.length) {
return { valid: true, url: "" };
}
if (normalized.includes("|")) {
return {
valid: false,
message: _("Configure User-Agent in the item settings"),
};
}
return { valid: true, url: normalized };
}
function validateSubscriptionUrlEntry(_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const parsed = parseSubscriptionUrlEntry(value);
if (!parsed.valid) {
return parsed.message;
}
const validation = main.validateUrl(parsed.url);
return validation.valid ? true : validation.message;
}
function getDuplicateTextListErrors(values, normalizeValue, duplicateMessage) {
const seen = new Set();
const duplicates = [];
values.forEach((item) => {
const normalized = normalizeValue ? normalizeValue(item) : item;
if (seen.has(normalized)) {
if (!duplicates.includes(item)) {
duplicates.push(item);
}
return;
}
seen.add(normalized);
});
return duplicates.map((item) => `${item}: ${duplicateMessage}`);
}
function getValidationHeaderText() {
return _("Validation errors:");
}
function getDuplicateValueText() {
return _("Duplicate value");
}
function escapeHtml(value) {
return `${value}`
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function ensureAnnotatedTextareaStyles() {
if (
typeof document === "undefined" ||
!document.head ||
document.getElementById(ANNOTATED_TEXTAREA_STYLE_ID)
) {
return;
}
document.head.insertAdjacentHTML(
"beforeend",
`<style id="${ANNOTATED_TEXTAREA_STYLE_ID}">
.fkp-annotated-textarea {
position: relative;
}
.fkp-annotated-textarea > textarea {
position: relative;
z-index: 1;
background: transparent !important;
}
.fkp-annotated-textarea__overlay {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
box-sizing: border-box;
color: transparent;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
}
.fkp-annotated-textarea__invalid {
color: transparent;
text-decoration-line: underline;
text-decoration-style: wavy;
text-decoration-color: var(--error-color-medium, #d44);
text-decoration-thickness: 1.5px;
text-underline-offset: 2px;
text-decoration-skip-ink: none;
}
</style>`,
);
}
function applyTextareaInputAttributes(textarea) {
textarea.setAttribute("spellcheck", "false");
textarea.setAttribute("autocomplete", "off");
textarea.setAttribute("autocorrect", "off");
textarea.setAttribute("autocapitalize", "off");
textarea.setAttribute("data-gramm", "false");
textarea.setAttribute("data-gramm_editor", "false");
textarea.setAttribute("data-enable-grammarly", "false");
textarea.style.resize = "vertical";
textarea.style.maxWidth = "100%";
const getRowsMinHeight = () => {
const rows = Number.parseInt(textarea.getAttribute("rows") || "0", 10);
if (!rows || typeof window === "undefined") {
return 0;
}
const style = window.getComputedStyle(textarea);
const fontSize = Number.parseFloat(style.fontSize) || 16;
const lineHeight =
Number.parseFloat(style.lineHeight) || Math.ceil(fontSize * 1.2);
const verticalPadding =
(Number.parseFloat(style.paddingTop) || 0) +
(Number.parseFloat(style.paddingBottom) || 0);
const verticalBorder =
(Number.parseFloat(style.borderTopWidth) || 0) +
(Number.parseFloat(style.borderBottomWidth) || 0);
return Math.ceil(rows * lineHeight + verticalPadding + verticalBorder);
};
const applyMinHeight = () => {
const storedMinHeight = Number.parseFloat(
textarea.getAttribute("data-fkp-default-min-height") || "0",
);
const nextMinHeight = Math.max(
storedMinHeight,
textarea.offsetHeight,
getRowsMinHeight(),
);
if (nextMinHeight > 0) {
if (storedMinHeight <= 0) {
textarea.setAttribute(
"data-fkp-default-min-height",
`${nextMinHeight}`,
);
}
textarea.style.minHeight = `${nextMinHeight}px`;
return true;
}
return false;
};
if (
!applyMinHeight() &&
typeof window !== "undefined" &&
typeof window.requestAnimationFrame === "function"
) {
window.requestAnimationFrame(() => {
if (!applyMinHeight()) {
window.setTimeout(applyMinHeight, 0);
}
});
}
textarea.addEventListener("focus", applyMinHeight);
textarea.addEventListener("pointerdown", applyMinHeight);
}
function syncAnnotatedTextareaOverlay(textarea, wrapper, overlay) {
if (
typeof window === "undefined" ||
!textarea ||
!wrapper ||
!overlay ||
typeof window.getComputedStyle !== "function"
) {
return;
}
const style = window.getComputedStyle(textarea);
wrapper.style.backgroundColor = style.backgroundColor;
wrapper.style.borderRadius = style.borderRadius;
overlay.style.font = style.font;
overlay.style.lineHeight = style.lineHeight;
overlay.style.letterSpacing = style.letterSpacing;
overlay.style.paddingTop = style.paddingTop;
overlay.style.paddingRight = style.paddingRight;
overlay.style.paddingBottom = style.paddingBottom;
overlay.style.paddingLeft = style.paddingLeft;
overlay.style.borderTopWidth = style.borderTopWidth;
overlay.style.borderRightWidth = style.borderRightWidth;
overlay.style.borderBottomWidth = style.borderBottomWidth;
overlay.style.borderLeftWidth = style.borderLeftWidth;
overlay.style.borderStyle = "solid";
overlay.style.borderColor = "transparent";
overlay.style.textAlign = style.textAlign;
overlay.style.direction = style.direction;
overlay.style.tabSize = style.tabSize;
overlay.style.textIndent = style.textIndent;
overlay.style.textTransform = style.textTransform;
overlay.style.boxSizing = style.boxSizing;
overlay.style.scrollPaddingTop = style.scrollPaddingTop;
overlay.scrollTop = textarea.scrollTop;
overlay.scrollLeft = textarea.scrollLeft;
}
function createAnnotationKey(annotation) {
return `${annotation.start}:${annotation.end}`;
}
function addAnnotationIssue(annotationMap, annotation, message) {
const key = createAnnotationKey(annotation);
const existing = annotationMap.get(key);
if (existing) {
if (!existing.messages.includes(message)) {
existing.messages.push(message);
}
return;
}
annotationMap.set(key, {
start: annotation.start,
end: annotation.end,
messages: [message],
});
}
function finalizeAnnotations(annotationMap) {
return Array.from(annotationMap.values())
.map((annotation) => ({
start: annotation.start,
end: annotation.end,
message: annotation.messages.join("; "),
}))
.sort((left, right) => left.start - right.start || left.end - right.end);
}
function renderAnnotatedTextareaOverlay(value, annotations) {
const text = value ? `${value}` : "";
const normalizedAnnotations = Array.isArray(annotations) ? annotations : [];
if (!text.length) {
return "&#8203;";
}
if (!normalizedAnnotations.length) {
return `${escapeHtml(text)}${text.endsWith("\n") ? "\n " : ""}`;
}
let cursor = 0;
let html = "";
normalizedAnnotations.forEach((annotation) => {
if (
annotation.start < cursor ||
annotation.start >= annotation.end ||
annotation.start < 0
) {
return;
}
html += escapeHtml(text.slice(cursor, annotation.start));
html += `<span class="fkp-annotated-textarea__invalid">${escapeHtml(
text.slice(annotation.start, annotation.end),
)}</span>`;
cursor = annotation.end;
});
html += escapeHtml(text.slice(cursor));
if (text.endsWith("\n")) {
html += "\n ";
}
return html;
}
function attachAnnotatedTextarea(textarea, analyzer) {
if (!textarea || typeof analyzer !== "function") {
return;
}
ensureAnnotatedTextareaStyles();
if (textarea.__tachyonAnnotatedTextareaController) {
textarea.__tachyonAnnotatedTextareaController.analyzer = analyzer;
textarea.__tachyonAnnotatedTextareaController.update();
return;
}
const wrapper = textarea.parentNode;
if (!wrapper) {
return;
}
wrapper.classList.add("fkp-annotated-textarea");
const overlay = document.createElement("div");
overlay.className = "fkp-annotated-textarea__overlay";
overlay.setAttribute("aria-hidden", "true");
wrapper.insertBefore(overlay, textarea.nextSibling);
const controller = {
analyzer,
textarea,
wrapper,
overlay,
update() {
const analysis = this.analyzer(this.textarea.value);
this.overlay.innerHTML = renderAnnotatedTextareaOverlay(
this.textarea.value,
analysis.annotations,
);
syncAnnotatedTextareaOverlay(this.textarea, this.wrapper, this.overlay);
},
};
textarea.__tachyonAnnotatedTextareaController = controller;
const updateAnnotatedTextarea = () => controller.update();
textarea.addEventListener("input", updateAnnotatedTextarea);
textarea.addEventListener("change", updateAnnotatedTextarea);
textarea.addEventListener("scroll", updateAnnotatedTextarea, {
passive: true,
});
textarea.addEventListener("keyup", updateAnnotatedTextarea);
if (typeof ResizeObserver === "function") {
const resizeObserver = new ResizeObserver(() => controller.update());
resizeObserver.observe(textarea);
controller.resizeObserver = resizeObserver;
}
controller.update();
}
function refreshAnnotatedTextareaValidation(option, section_id, textarea) {
if (option && typeof option.triggerValidation === "function") {
option.triggerValidation(section_id);
}
if (
textarea &&
textarea.__tachyonAnnotatedTextareaController &&
typeof textarea.__tachyonAnnotatedTextareaController.update === "function"
) {
textarea.__tachyonAnnotatedTextareaController.update();
}
}
function attachNfqwsRemoteValidation(option, section_id, textarea) {
if (!textarea || textarea.__tachyonNfqwsRemoteValidationAttached) {
return;
}
textarea.__tachyonNfqwsRemoteValidationAttached = true;
textarea.__tachyonNfqwsRemoteValidationRequestId = 0;
textarea.__tachyonNfqwsRemoteValidationTimer = null;
const runValidation = () => {
const value = textarea.value;
const localAnalysis = buildNfqwsLocalAnalysis(value);
if (!localAnalysis.valid) {
refreshAnnotatedTextareaValidation(option, section_id, textarea);
return;
}
const requestId =
(textarea.__tachyonNfqwsRemoteValidationRequestId || 0) + 1;
textarea.__tachyonNfqwsRemoteValidationRequestId = requestId;
validateNfqwsStrategyRemotely(value).then(() => {
if (textarea.__tachyonNfqwsRemoteValidationRequestId !== requestId) {
return;
}
refreshAnnotatedTextareaValidation(option, section_id, textarea);
});
};
const scheduleValidation = (delay = NFQWS_REMOTE_VALIDATION_DEBOUNCE_MS) => {
if (textarea.__tachyonNfqwsRemoteValidationTimer) {
window.clearTimeout(textarea.__tachyonNfqwsRemoteValidationTimer);
}
textarea.__tachyonNfqwsRemoteValidationTimer = window.setTimeout(() => {
textarea.__tachyonNfqwsRemoteValidationTimer = null;
runValidation();
}, delay);
};
textarea.addEventListener("input", () => scheduleValidation());
textarea.addEventListener("change", () => scheduleValidation(0));
textarea.addEventListener("blur", () => scheduleValidation(0));
scheduleValidation(0);
}
function attachNfqws2RemoteValidation(option, section_id, textarea) {
if (!textarea || textarea.__tachyonNfqws2RemoteValidationAttached) {
return;
}
textarea.__tachyonNfqws2RemoteValidationAttached = true;
textarea.__tachyonNfqws2RemoteValidationRequestId = 0;
textarea.__tachyonNfqws2RemoteValidationTimer = null;
const runValidation = () => {
const value = textarea.value;
const localAnalysis = buildNfqws2LocalAnalysis(value);
if (!localAnalysis.valid) {
refreshAnnotatedTextareaValidation(option, section_id, textarea);
return;
}
const requestId =
(textarea.__tachyonNfqws2RemoteValidationRequestId || 0) + 1;
textarea.__tachyonNfqws2RemoteValidationRequestId = requestId;
validateNfqws2StrategyRemotely(value).then(() => {
if (textarea.__tachyonNfqws2RemoteValidationRequestId !== requestId) {
return;
}
refreshAnnotatedTextareaValidation(option, section_id, textarea);
});
};
const scheduleValidation = (delay = NFQWS_REMOTE_VALIDATION_DEBOUNCE_MS) => {
if (textarea.__tachyonNfqws2RemoteValidationTimer) {
window.clearTimeout(textarea.__tachyonNfqws2RemoteValidationTimer);
}
textarea.__tachyonNfqws2RemoteValidationTimer = window.setTimeout(() => {
textarea.__tachyonNfqws2RemoteValidationTimer = null;
runValidation();
}, delay);
};
textarea.addEventListener("input", () => scheduleValidation());
textarea.addEventListener("change", () => scheduleValidation(0));
textarea.addEventListener("blur", () => scheduleValidation(0));
scheduleValidation(0);
}
function parseCommentAwareListTokens(value) {
const text = value ? `${value}` : "";
const tokens = [];
const lines = text.split(/\r\n|\r|\n/);
const newlines = text.match(/\r\n|\r|\n/g) || [];
let offset = 0;
lines.forEach((line, index) => {
const hashIndex = line.indexOf("#");
const slashIndex = line.indexOf("//");
let commentIndex = -1;
if (hashIndex >= 0 && slashIndex >= 0) {
commentIndex = Math.min(hashIndex, slashIndex);
} else if (hashIndex >= 0) {
commentIndex = hashIndex;
} else if (slashIndex >= 0) {
commentIndex = slashIndex;
}
const source = commentIndex >= 0 ? line.slice(0, commentIndex) : line;
const matcher = /[^,\s]+/g;
let match;
while ((match = matcher.exec(source)) !== null) {
tokens.push({
value: match[0],
start: offset + match.index,
end: offset + match.index + match[0].length,
});
}
offset += line.length + (newlines[index] ? newlines[index].length : 0);
});
return tokens;
}
function analyzeTextListValue(value, validateItem, emptyMessage, options = {}) {
const text = value ? `${value}` : "";
if (!text.length) {
return { valid: true, message: "", annotations: [] };
}
const tokens = parseCommentAwareListTokens(text);
if (!tokens.length) {
return { valid: false, message: emptyMessage, annotations: [] };
}
const duplicateMessage = options.duplicateMessage || getDuplicateValueText();
const annotationMap = new Map();
const errors = [];
const seen = new Set();
tokens.forEach((token) => {
if (typeof validateItem === "function") {
const validation = validateItem(token.value);
if (!validation.valid) {
errors.push(`${token.value}: ${validation.message}`);
addAnnotationIssue(annotationMap, token, validation.message);
}
}
const normalized = options.normalizeDuplicateValue
? options.normalizeDuplicateValue(token.value)
: token.value;
if (!normalized) {
return;
}
if (seen.has(normalized)) {
errors.push(`${token.value}: ${duplicateMessage}`);
addAnnotationIssue(annotationMap, token, duplicateMessage);
return;
}
seen.add(normalized);
});
if (!errors.length) {
return { valid: true, message: "", annotations: [] };
}
return {
valid: false,
message: [getValidationHeaderText(), ...errors].join("\n"),
annotations: finalizeAnnotations(annotationMap),
};
}
function analyzeDomainSuffixText(value) {
const validateDomainCondition = (domain) => {
const normalized = `${domain || ""}`.trim();
if (normalized.includes("/")) {
return { valid: false, message: _("Invalid domain address") };
}
return main.validateDomain(normalized, true);
};
return analyzeTextListValue(
value,
(item) => {
const colonIndex = item.indexOf(":");
const prefix = colonIndex > 0 ? item.slice(0, colonIndex) : "";
const body = colonIndex > 0 ? item.slice(colonIndex + 1) : item;
if (!prefix) {
return validateDomainCondition(body);
}
if (!["full", "keyword", "regex"].includes(prefix)) {
return {
valid: false,
message: _("Allowed domain prefixes are full:, keyword:, and regex:"),
};
}
if (!body.length) {
return { valid: false, message: _("Value cannot be empty") };
}
if (prefix === "full") {
return validateDomainCondition(body);
}
if (prefix === "keyword") {
const validation = validateKeyword(null, body);
return validation === true
? { valid: true, message: _("Valid") }
: { valid: false, message: validation };
}
if (/[,\s]/.test(body)) {
return {
valid: false,
message: _("Regular expression must not contain spaces or commas"),
};
}
const validation = validateRegex(null, body);
return validation === true
? { valid: true, message: _("Valid") }
: { valid: false, message: validation };
},
_("At least one valid domain must be specified."),
{
normalizeDuplicateValue: (item) => `${item}`.toLowerCase(),
},
);
}
function domainValuesWithPrefix(section_id, key, prefix) {
return getConfigListValues(section_id, key).map((value) =>
prefix ? `${prefix}:${value}` : value,
);
}
function domainTextValuesWithPrefix(section_id, key, prefix) {
const legacyText = uci.get(UCI_PACKAGE, section_id, `${key}_text`);
if (!legacyText) {
return [];
}
return main
.parseValueList(legacyText)
.map((value) => (prefix ? `${prefix}:${value}` : value));
}
function uniqueDomainTextValues(values) {
const seen = new Set();
return values.filter((value) => {
const key = `${value}`.toLowerCase();
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function appendUniqueDomainTextValues(textValue, values) {
const originalText = typeof textValue === "string" ? textValue : "";
const seen = new Set(
main.parseValueList(originalText).map((value) => `${value}`.toLowerCase()),
);
const additions = uniqueDomainTextValues(values).filter((value) => {
const key = `${value}`.toLowerCase();
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
if (!additions.length) {
return originalText;
}
const base = originalText.replace(/\s+$/, "");
return [base, ...additions].filter(Boolean).join("\n");
}
function loadCombinedDomainText(section_id) {
const textValue =
uci.get(UCI_PACKAGE, section_id, "domain") ||
uci.get(UCI_PACKAGE, section_id, "domain_suffix_text");
const values = [
...domainValuesWithPrefix(section_id, "domain_suffix", ""),
...domainValuesWithPrefix(section_id, "domain_keyword", "keyword"),
...domainValuesWithPrefix(section_id, "domain_regex", "regex"),
...domainTextValuesWithPrefix(section_id, "domain_suffix", ""),
...domainTextValuesWithPrefix(section_id, "domain", "full"),
...domainTextValuesWithPrefix(section_id, "domain_keyword", "keyword"),
...domainTextValuesWithPrefix(section_id, "domain_regex", "regex"),
];
return appendUniqueDomainTextValues(textValue, values);
}
function analyzeIpCidrText(value) {
return analyzeTextListValue(
value,
(item) => main.validateSubnet(item),
_("At least one valid IP or subnet must be specified."),
{
normalizeDuplicateValue: (item) => `${item}`.trim(),
},
);
}
function validatePortCondition(_section_id, value) {
const normalized = value ? `${value}`.trim() : "";
if (!normalized.length) {
return true;
}
const match = normalized.match(/^(\d+)(?:-(\d+))?$/);
if (!match) {
return _("Invalid port or range. Use 80 or 1000-2000");
}
const start = Number.parseInt(match[1], 10);
const end = match[2] ? Number.parseInt(match[2], 10) : start;
if (start < 1 || start > 65535 || end < 1 || end > 65535) {
return _("Port must be between 1 and 65535");
}
if (start > end) {
return _("Port range start must be less than or equal to end");
}
return true;
}
function getNfqwsOptionArgumentMode(option) {
if (NFQWS_REQUIRED_ARG_OPTIONS.has(option)) {
return "required";
}
if (NFQWS_OPTIONAL_ARG_OPTIONS.has(option)) {
return "optional";
}
if (NFQWS_NO_ARG_OPTIONS.has(option)) {
return "none";
}
return "unknown";
}
function getNfqws2OptionArgumentMode(option) {
if (NFQWS2_REQUIRED_ARG_OPTIONS.has(option)) {
return "required";
}
if (NFQWS2_OPTIONAL_ARG_OPTIONS.has(option)) {
return "optional";
}
if (NFQWS2_NO_ARG_OPTIONS.has(option)) {
return "none";
}
return "unknown";
}
function normalizeNfqwsStrategyWhitespace(value) {
return value ? `${value}`.replace(/\s+/g, " ").trim() : "";
}
function parseNfqwsRuntimeTokens(value) {
const text = value ? `${value}` : "";
const tokens = [];
const matcher = /\S+/g;
let match;
while ((match = matcher.exec(text)) !== null) {
tokens.push({
value: match[0],
start: match.index,
end: match.index + match[0].length,
});
}
return tokens;
}
function normalizeNfqwsStrategyValue(value) {
const normalized = normalizeNfqwsStrategyWhitespace(value);
if (!normalized.length) {
return "";
}
return normalized === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT
? ZAPRET_DEFAULT_NFQWS_OPT
: normalized;
}
function getCachedNfqwsRemoteValidation(value) {
const normalized = normalizeNfqwsStrategyValue(value);
return normalized.length
? nfqwsRemoteValidationCache.get(normalized) || null
: null;
}
function cacheNfqwsRemoteValidation(value, result) {
const normalized = normalizeNfqwsStrategyValue(value);
if (!normalized.length) {
return result;
}
const cached = {
valid: result && result.valid === true,
message: result && result.message ? `${result.message}` : "",
needle: result && result.needle ? `${result.needle}` : "",
needles:
result && Array.isArray(result.needles)
? result.needles.filter(Boolean).map((item) => `${item}`)
: result && result.needle
? [`${result.needle}`]
: [],
};
nfqwsRemoteValidationCache.set(normalized, cached);
return cached;
}
function buildNfqwsRemoteValidationFallback(error) {
const message =
error && error.message
? `${error.message}`
: _("Unable to validate the NFQWS strategy through the backend parser.");
return {
valid: false,
message: _("Backend validation failed: %s").format(message),
needle: "",
needles: [],
};
}
function validateNfqwsStrategyRemotely(value) {
const normalized = normalizeNfqwsStrategyValue(value);
if (!normalized.length) {
return Promise.resolve({
valid: true,
message: "",
needle: "",
needles: [],
});
}
if (nfqwsRemoteValidationCache.has(normalized)) {
return Promise.resolve(nfqwsRemoteValidationCache.get(normalized));
}
if (nfqwsRemoteValidationInflight.has(normalized)) {
return nfqwsRemoteValidationInflight.get(normalized);
}
const validationTask = fs
.exec(NFQWS_VALIDATION_COMMAND, [
"validate_nfqws_strategy_json",
normalized,
])
.then((result) => {
const payload = JSON.parse(
(result && result.stdout ? result.stdout : "{}").trim() || "{}",
);
return cacheNfqwsRemoteValidation(normalized, {
valid: payload.valid === true,
message: payload.message || "",
needle: payload.needle || "",
needles: Array.isArray(payload.needles)
? payload.needles.filter(Boolean)
: payload.needle
? [payload.needle]
: [],
});
})
.catch((error) =>
cacheNfqwsRemoteValidation(
normalized,
buildNfqwsRemoteValidationFallback(error),
),
)
.finally(() => {
nfqwsRemoteValidationInflight.delete(normalized);
});
nfqwsRemoteValidationInflight.set(normalized, validationTask);
return validationTask;
}
function getNfqwsForbiddenTokenInfo(token, index) {
const configFileMessage = _(
"External nfqws config files bypass Tachyon queue management and explicit validation.",
);
const hostSelectionMessage = _(
"Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE.",
);
const ipSelectionMessage = _(
"Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE.",
);
const placeholderMessage = _(
"Zapret hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS strategies.",
);
const queueMessage = _(
"The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here.",
);
const fwmarkMessage = _(
"The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here.",
);
const daemonMessage = _(
"Tachyon manages the nfqws process lifecycle itself, so daemon mode is not allowed here.",
);
const dryRunMessage = _(
"This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here.",
);
const versionMessage = _(
"This field must start a working nfqws strategy; --version exits immediately and is not allowed here.",
);
if (index === 0 && (token.startsWith("@") || token.startsWith("$"))) {
return {
reason: configFileMessage,
captureNextValue: false,
};
}
if (token === "<HOSTLIST>" || token === "<HOSTLIST_NOAUTO>") {
return {
reason: placeholderMessage,
captureNextValue: false,
};
}
if (
token === "--hostlist" ||
token.startsWith("--hostlist=") ||
token === "--hostlist-domains" ||
token.startsWith("--hostlist-domains=") ||
token === "--hostlist-exclude" ||
token.startsWith("--hostlist-exclude=") ||
token === "--hostlist-exclude-domains" ||
token.startsWith("--hostlist-exclude-domains=") ||
token === "--hostlist-auto" ||
token.startsWith("--hostlist-auto=") ||
token === "--hostlist-auto-fail-threshold" ||
token.startsWith("--hostlist-auto-fail-threshold=") ||
token === "--hostlist-auto-fail-time" ||
token.startsWith("--hostlist-auto-fail-time=") ||
token === "--hostlist-auto-retrans-threshold" ||
token.startsWith("--hostlist-auto-retrans-threshold=") ||
token === "--hostlist-auto-debug" ||
token.startsWith("--hostlist-auto-debug=")
) {
return {
reason: hostSelectionMessage,
captureNextValue: !token.includes("="),
};
}
if (
token === "--ipset" ||
token.startsWith("--ipset=") ||
token === "--ipset-ip" ||
token.startsWith("--ipset-ip=") ||
token === "--ipset-exclude" ||
token.startsWith("--ipset-exclude=") ||
token === "--ipset-exclude-ip" ||
token.startsWith("--ipset-exclude-ip=")
) {
return {
reason: ipSelectionMessage,
captureNextValue: !token.includes("="),
};
}
if (token === "--qnum" || token.startsWith("--qnum=")) {
return {
reason: queueMessage,
captureNextValue: !token.includes("="),
};
}
if (
token === "--dpi-desync-fwmark" ||
token.startsWith("--dpi-desync-fwmark=")
) {
return {
reason: fwmarkMessage,
captureNextValue: !token.includes("="),
};
}
if (token === "--daemon") {
return {
reason: daemonMessage,
captureNextValue: false,
};
}
if (token === "--dry-run") {
return {
reason: dryRunMessage,
captureNextValue: false,
};
}
if (token === "--version") {
return {
reason: versionMessage,
captureNextValue: false,
};
}
return null;
}
function buildNfqwsLocalAnalysis(value) {
const text = value ? `${value}` : "";
if (!text.trim().length) {
return {
valid: false,
message: _("NFQWS strategy cannot be empty"),
annotations: [],
};
}
if (text.trim() === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT) {
return { valid: true, message: "", annotations: [] };
}
const tokens = parseNfqwsRuntimeTokens(text);
const annotationMap = new Map();
const errors = [];
for (let index = 0; index < tokens.length; ) {
const token = tokens[index];
const bareToken = token.value.includes("=")
? token.value.slice(0, token.value.indexOf("="))
: token.value;
const nextToken = tokens[index + 1] || null;
const forbidden = getNfqwsForbiddenTokenInfo(token.value, index);
if (forbidden) {
addAnnotationIssue(annotationMap, token, forbidden.reason);
let displayToken = token.value;
if (
forbidden.captureNextValue &&
nextToken &&
!nextToken.value.startsWith("--")
) {
addAnnotationIssue(annotationMap, nextToken, forbidden.reason);
displayToken = `${displayToken} ${nextToken.value}`;
index += 2;
} else {
index += 1;
}
errors.push(`${displayToken}: ${forbidden.reason}`);
continue;
}
if (!token.value.startsWith("--")) {
const reason = _(
"Unexpected standalone token. Use explicit flags such as --name or --name=value.",
);
addAnnotationIssue(annotationMap, token, reason);
errors.push(`${token.value}: ${reason}`);
index += 1;
continue;
}
const mode = getNfqwsOptionArgumentMode(bareToken);
if (mode === "unknown") {
const reason = _("Unknown NFQWS flag.");
addAnnotationIssue(annotationMap, token, reason);
errors.push(`${token.value}: ${reason}`);
index += 1;
continue;
}
if (mode === "none") {
if (token.value.includes("=")) {
const reason = _("This flag does not accept a value.");
addAnnotationIssue(annotationMap, token, reason);
errors.push(`${token.value}: ${reason}`);
}
index += 1;
continue;
}
if (mode === "optional") {
if (
nextToken &&
!token.value.includes("=") &&
!nextToken.value.startsWith("--")
) {
const reason = _(
"Optional values must be attached with '=' here; a separate token would be ignored by nfqws.",
);
addAnnotationIssue(annotationMap, token, reason);
addAnnotationIssue(annotationMap, nextToken, reason);
errors.push(`${token.value} ${nextToken.value}: ${reason}`);
index += 2;
} else {
index += 1;
}
continue;
}
if (!token.value.includes("=")) {
if (!nextToken || nextToken.value.startsWith("--")) {
const reason = _("This option requires a value.");
addAnnotationIssue(annotationMap, token, reason);
errors.push(`${token.value}: ${reason}`);
index += 1;
continue;
}
index += 2;
continue;
}
index += 1;
}
if (!errors.length) {
return { valid: true, message: "", annotations: [] };
}
return {
valid: false,
message: [getValidationHeaderText(), ...errors].join("\n"),
annotations: finalizeAnnotations(annotationMap),
};
}
function addNfqwsRemoteValidationNeedleAnnotations(
annotationMap,
tokens,
remoteValidation,
needle,
) {
if (!needle.length) {
return;
}
let matched = false;
tokens.forEach((token) => {
const tokenValue = token.value || "";
const optionMatch =
needle.startsWith("--") &&
(tokenValue === needle || tokenValue.startsWith(`${needle}=`));
const valueMatch =
tokenValue === needle ||
tokenValue.endsWith(`=${needle}`) ||
(!needle.startsWith("--") && tokenValue.includes(`=${needle},`)) ||
(!needle.startsWith("--") && tokenValue.endsWith(`=${needle}`));
if (optionMatch || valueMatch) {
addAnnotationIssue(annotationMap, token, remoteValidation.message);
matched = true;
}
});
if (matched) {
return;
}
if (needle.startsWith("--")) {
tokens
.filter((token) => token.value && token.value.startsWith(needle))
.forEach((token) =>
addAnnotationIssue(annotationMap, token, remoteValidation.message),
);
}
}
function addNfqwsRemoteValidationAnnotations(
annotationMap,
tokens,
remoteValidation,
) {
const needles =
remoteValidation &&
Array.isArray(remoteValidation.needles) &&
remoteValidation.needles.length
? remoteValidation.needles.map((needle) => `${needle}`)
: remoteValidation && remoteValidation.needle
? [`${remoteValidation.needle}`]
: [];
needles.forEach((needle) =>
addNfqwsRemoteValidationNeedleAnnotations(
annotationMap,
tokens,
remoteValidation,
needle,
),
);
}
function analyzeNfqwsStrategy(value) {
const localAnalysis = buildNfqwsLocalAnalysis(value);
if (!localAnalysis.valid) {
return localAnalysis;
}
const remoteValidation = getCachedNfqwsRemoteValidation(value);
if (!remoteValidation || remoteValidation.valid) {
return localAnalysis;
}
const text = value ? `${value}` : "";
const tokens = parseNfqwsRuntimeTokens(text);
const annotationMap = new Map();
localAnalysis.annotations.forEach((annotation) =>
addAnnotationIssue(annotationMap, annotation, annotation.message),
);
addNfqwsRemoteValidationAnnotations(annotationMap, tokens, remoteValidation);
return {
valid: false,
message: [getValidationHeaderText(), remoteValidation.message].join("\n"),
annotations: finalizeAnnotations(annotationMap),
};
}
function normalizeNfqws2StrategyValue(value) {
const normalized = normalizeNfqwsStrategyWhitespace(value);
return normalized.length ? normalized : ZAPRET2_DEFAULT_NFQWS2_OPT;
}
function getCachedNfqws2RemoteValidation(value) {
const normalized = normalizeNfqws2StrategyValue(value);
return normalized.length
? nfqws2RemoteValidationCache.get(normalized) || null
: null;
}
function cacheNfqws2RemoteValidation(value, result) {
const normalized = normalizeNfqws2StrategyValue(value);
if (!normalized.length) {
return result;
}
const cached = {
valid: result && result.valid === true,
message: result && result.message ? `${result.message}` : "",
needle: result && result.needle ? `${result.needle}` : "",
needles:
result && Array.isArray(result.needles)
? result.needles.filter(Boolean).map((item) => `${item}`)
: result && result.needle
? [`${result.needle}`]
: [],
};
nfqws2RemoteValidationCache.set(normalized, cached);
return cached;
}
function buildNfqws2RemoteValidationFallback(error) {
const message =
error && error.message
? `${error.message}`
: _("Unable to validate the NFQWS2 strategy through the backend parser.");
return {
valid: false,
message: _("Backend validation failed: %s").format(message),
needle: "",
needles: [],
};
}
function validateNfqws2StrategyRemotely(value) {
const normalized = normalizeNfqws2StrategyValue(value);
if (!normalized.length) {
return Promise.resolve({
valid: true,
message: "",
needle: "",
needles: [],
});
}
if (nfqws2RemoteValidationCache.has(normalized)) {
return Promise.resolve(nfqws2RemoteValidationCache.get(normalized));
}
if (nfqws2RemoteValidationInflight.has(normalized)) {
return nfqws2RemoteValidationInflight.get(normalized);
}
const validationTask = fs
.exec(NFQWS_VALIDATION_COMMAND, [
"validate_nfqws2_strategy_json",
normalized,
])
.then((result) => {
const payload = JSON.parse(
(result && result.stdout ? result.stdout : "{}").trim() || "{}",
);
return cacheNfqws2RemoteValidation(normalized, {
valid: payload.valid === true,
message: payload.message || "",
needle: payload.needle || "",
needles: Array.isArray(payload.needles)
? payload.needles.filter(Boolean)
: payload.needle
? [payload.needle]
: [],
});
})
.catch((error) =>
cacheNfqws2RemoteValidation(
normalized,
buildNfqws2RemoteValidationFallback(error),
),
)
.finally(() => {
nfqws2RemoteValidationInflight.delete(normalized);
});
nfqws2RemoteValidationInflight.set(normalized, validationTask);
return validationTask;
}
function getNfqws2ForbiddenTokenInfo(token, index, nextToken) {
const configFileMessage = _(
"External nfqws2 config files bypass Tachyon queue management and explicit validation.",
);
const hostSelectionMessage = _(
"Resource selection by hostname inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE.",
);
const ipSelectionMessage = _(
"Resource selection by IP or CIDR inside nfqws2 is not supported here; sing-box selects resources before NFQUEUE.",
);
const placeholderMessage = _(
"Zapret2 hostlist templates are not supported here because Tachyon does not expand them for per-rule NFQWS2 strategies.",
);
const queueMessage = _(
"The NFQUEUE number is assigned by Tachyon for each rule and must not be overridden here.",
);
const fwmarkMessage = _(
"The desync fwmark is managed by Tachyon for loop prevention and must not be overridden here.",
);
const fuzzMessage = _(
"Fuzzing is not supported here because Tachyon needs deterministic runtime validation.",
);
const interceptMessage = _(
"Disabling interception is incompatible with action=zapret2 because Tachyon sends matched traffic through NFQUEUE.",
);
const daemonMessage = _(
"Tachyon manages the nfqws2 process lifecycle itself, so daemon mode is not allowed here.",
);
const dryRunMessage = _(
"This field must start a working nfqws2 strategy; --dry-run exits immediately and is not allowed here.",
);
const versionMessage = _(
"This field must start a working nfqws2 strategy; --version exits immediately and is not allowed here.",
);
if (index === 0 && (token.startsWith("@") || token.startsWith("$"))) {
return {
reason: configFileMessage,
captureNextValue: false,
};
}
if (token === "<HOSTLIST>" || token === "<HOSTLIST_NOAUTO>") {
return {
reason: placeholderMessage,
captureNextValue: false,
};
}
if (
token === "--hostlist" ||
token.startsWith("--hostlist=") ||
token === "--hostlist-domains" ||
token.startsWith("--hostlist-domains=") ||
token === "--hostlist-exclude" ||
token.startsWith("--hostlist-exclude=") ||
token === "--hostlist-exclude-domains" ||
token.startsWith("--hostlist-exclude-domains=") ||
token === "--hostlist-auto" ||
token.startsWith("--hostlist-auto=") ||
token === "--hostlist-auto-fail-threshold" ||
token.startsWith("--hostlist-auto-fail-threshold=") ||
token === "--hostlist-auto-fail-time" ||
token.startsWith("--hostlist-auto-fail-time=") ||
token === "--hostlist-auto-retrans-threshold" ||
token.startsWith("--hostlist-auto-retrans-threshold=") ||
token === "--hostlist-auto-debug" ||
token.startsWith("--hostlist-auto-debug=") ||
token === "--hostlist-auto-retrans-reset" ||
token.startsWith("--hostlist-auto-retrans-reset=")
) {
return {
reason: hostSelectionMessage,
captureNextValue: !token.includes("="),
};
}
if (
token === "--ipset" ||
token.startsWith("--ipset=") ||
token === "--ipset-ip" ||
token.startsWith("--ipset-ip=") ||
token === "--ipset-exclude" ||
token.startsWith("--ipset-exclude=") ||
token === "--ipset-exclude-ip" ||
token.startsWith("--ipset-exclude-ip=")
) {
return {
reason: ipSelectionMessage,
captureNextValue: !token.includes("="),
};
}
if (token === "--qnum" || token.startsWith("--qnum=")) {
return {
reason: queueMessage,
captureNextValue: !token.includes("="),
};
}
if (
token === "--fwmark" ||
token.startsWith("--fwmark=") ||
token === "--dpi-desync-fwmark" ||
token.startsWith("--dpi-desync-fwmark=")
) {
return {
reason: fwmarkMessage,
captureNextValue: !token.includes("="),
};
}
if (token === "--fuzz" || token.startsWith("--fuzz=")) {
return {
reason: fuzzMessage,
captureNextValue: !token.includes("="),
};
}
if (
token === "--intercept=0" ||
token === "--intercept=false" ||
token === "--intercept=no" ||
(token === "--intercept" && nextToken && nextToken.value === "0")
) {
return {
reason: interceptMessage,
captureNextValue: token === "--intercept",
};
}
if (token === "--daemon") {
return {
reason: daemonMessage,
captureNextValue: false,
};
}
if (token === "--dry-run") {
return {
reason: dryRunMessage,
captureNextValue: false,
};
}
if (token === "--version") {
return {
reason: versionMessage,
captureNextValue: false,
};
}
return null;
}
function buildNfqws2LocalAnalysis(value) {
const text = value ? `${value}` : "";
if (!text.trim().length) {
return {
valid: false,
message: _("NFQWS2 strategy cannot be empty"),
annotations: [],
};
}
const tokens = parseNfqwsRuntimeTokens(text);
const annotationMap = new Map();
const errors = [];
for (let index = 0; index < tokens.length; ) {
const token = tokens[index];
const bareToken = token.value.includes("=")
? token.value.slice(0, token.value.indexOf("="))
: token.value;
const nextToken = tokens[index + 1] || null;
const forbidden = getNfqws2ForbiddenTokenInfo(
token.value,
index,
nextToken,
);
if (forbidden) {
addAnnotationIssue(annotationMap, token, forbidden.reason);
let displayToken = token.value;
if (
forbidden.captureNextValue &&
nextToken &&
!nextToken.value.startsWith("--")
) {
addAnnotationIssue(annotationMap, nextToken, forbidden.reason);
displayToken = `${displayToken} ${nextToken.value}`;
index += 2;
} else {
index += 1;
}
errors.push(`${displayToken}: ${forbidden.reason}`);
continue;
}
if (!token.value.startsWith("--")) {
const reason = _(
"Unexpected standalone token. Use explicit flags such as --name or --name=value.",
);
addAnnotationIssue(annotationMap, token, reason);
errors.push(`${token.value}: ${reason}`);
index += 1;
continue;
}
const mode = getNfqws2OptionArgumentMode(bareToken);
if (mode === "unknown") {
const reason = _("Unknown NFQWS2 flag.");
addAnnotationIssue(annotationMap, token, reason);
errors.push(`${token.value}: ${reason}`);
index += 1;
continue;
}
if (mode === "none") {
if (token.value.includes("=")) {
const reason = _("This flag does not accept a value.");
addAnnotationIssue(annotationMap, token, reason);
errors.push(`${token.value}: ${reason}`);
}
index += 1;
continue;
}
if (mode === "optional") {
if (
nextToken &&
!token.value.includes("=") &&
!nextToken.value.startsWith("--")
) {
const reason = _(
"Optional values must be attached with '=' here; a separate token would be ignored by nfqws2.",
);
addAnnotationIssue(annotationMap, token, reason);
addAnnotationIssue(annotationMap, nextToken, reason);
errors.push(`${token.value} ${nextToken.value}: ${reason}`);
index += 2;
} else {
index += 1;
}
continue;
}
if (!token.value.includes("=")) {
if (!nextToken || nextToken.value.startsWith("--")) {
const reason = _("This option requires a value.");
addAnnotationIssue(annotationMap, token, reason);
errors.push(`${token.value}: ${reason}`);
index += 1;
continue;
}
index += 2;
continue;
}
index += 1;
}
if (!errors.length) {
return { valid: true, message: "", annotations: [] };
}
return {
valid: false,
message: [getValidationHeaderText(), ...errors].join("\n"),
annotations: finalizeAnnotations(annotationMap),
};
}
function analyzeNfqws2Strategy(value) {
const localAnalysis = buildNfqws2LocalAnalysis(value);
if (!localAnalysis.valid) {
return localAnalysis;
}
const remoteValidation = getCachedNfqws2RemoteValidation(value);
if (!remoteValidation || remoteValidation.valid) {
return localAnalysis;
}
const text = value ? `${value}` : "";
const tokens = parseNfqwsRuntimeTokens(text);
const annotationMap = new Map();
localAnalysis.annotations.forEach((annotation) =>
addAnnotationIssue(annotationMap, annotation, annotation.message),
);
addNfqwsRemoteValidationAnnotations(annotationMap, tokens, remoteValidation);
return {
valid: false,
message: [getValidationHeaderText(), remoteValidation.message].join("\n"),
annotations: finalizeAnnotations(annotationMap),
};
}
function normalizeByedpiStrategyWhitespace(value) {
return value ? `${value}`.replace(/\s+/g, " ").trim() : "";
}
function normalizeByedpiStrategyValue(value) {
const normalized = normalizeByedpiStrategyWhitespace(value);
return normalized.length ? normalized : BYEDPI_DEFAULT_CMD_OPTS;
}
function getCachedByedpiRemoteValidation(value) {
const normalized = normalizeByedpiStrategyValue(value);
return normalized.length
? byedpiRemoteValidationCache.get(normalized) || null
: null;
}
function cacheByedpiRemoteValidation(value, result) {
const normalized = normalizeByedpiStrategyValue(value);
if (!normalized.length) {
return result;
}
const cached = {
valid: result && result.valid === true,
message: result && result.message ? `${result.message}` : "",
needle: result && result.needle ? `${result.needle}` : "",
needles:
result && Array.isArray(result.needles)
? result.needles.filter(Boolean).map((item) => `${item}`)
: result && result.needle
? [`${result.needle}`]
: [],
};
byedpiRemoteValidationCache.set(normalized, cached);
return cached;
}
function buildByedpiRemoteValidationFallback(error) {
const message =
error && error.message
? `${error.message}`
: _("Unable to validate the ByeDPI strategy through the backend parser.");
return {
valid: false,
message: _("Backend validation failed: %s").format(message),
needle: "",
needles: [],
};
}
function validateByedpiStrategyRemotely(value) {
const normalized = normalizeByedpiStrategyValue(value);
if (!normalized.length) {
return Promise.resolve({
valid: true,
message: "",
needle: "",
needles: [],
});
}
if (byedpiRemoteValidationCache.has(normalized)) {
return Promise.resolve(byedpiRemoteValidationCache.get(normalized));
}
if (byedpiRemoteValidationInflight.has(normalized)) {
return byedpiRemoteValidationInflight.get(normalized);
}
const validationTask = fs
.exec(NFQWS_VALIDATION_COMMAND, [
"validate_byedpi_strategy_json",
normalized,
])
.then((result) => {
const payload = JSON.parse(
(result && result.stdout ? result.stdout : "{}").trim() || "{}",
);
return cacheByedpiRemoteValidation(normalized, {
valid: payload.valid === true,
message: payload.message || "",
needle: payload.needle || "",
needles: Array.isArray(payload.needles)
? payload.needles.filter(Boolean)
: payload.needle
? [payload.needle]
: [],
});
})
.catch((error) =>
cacheByedpiRemoteValidation(
normalized,
buildByedpiRemoteValidationFallback(error),
),
)
.finally(() => {
byedpiRemoteValidationInflight.delete(normalized);
});
byedpiRemoteValidationInflight.set(normalized, validationTask);
return validationTask;
}
function getByedpiShortOptionName(token) {
return token.length > 2 ? token.slice(0, 2) : token;
}
function byedpiTokenLooksLikeOption(token) {
return /^--.+/.test(token) || /^-[A-Za-z].*/.test(token);
}
function getByedpiControlledTokenInfo(token) {
const listenMessage = _(
"ByeDPI listen address and port are assigned by Tachyon and must not be set in the strategy.",
);
const transparentMessage = _(
"Transparent proxy mode is incompatible with action=byedpi because Tachyon connects to ciadpi through SOCKS.",
);
const daemonMessage = _(
"Tachyon manages the ciadpi process lifecycle itself, so daemon mode is not allowed here.",
);
const pidfileMessage = _(
"Tachyon manages ciadpi pid files itself, so pidfile options are not allowed here.",
);
const exitMessage = _(
"This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here.",
);
if (
token === "--ip" ||
token.startsWith("--ip=") ||
token === "-i" ||
/^-i.+/.test(token) ||
token === "--port" ||
token.startsWith("--port=") ||
token === "-p" ||
/^-p.+/.test(token)
) {
return {
reason: listenMessage,
captureNextValue:
token === "--ip" ||
token === "-i" ||
token === "--port" ||
token === "-p",
};
}
if (token === "--transparent" || token === "-E" || /^-E.+/.test(token)) {
return {
reason: transparentMessage,
captureNextValue: false,
};
}
if (token === "--daemon" || token === "-D" || /^-D.+/.test(token)) {
return {
reason: daemonMessage,
captureNextValue: false,
};
}
if (
token === "--pidfile" ||
token.startsWith("--pidfile=") ||
token === "-w" ||
/^-w.+/.test(token)
) {
return {
reason: pidfileMessage,
captureNextValue: token === "--pidfile" || token === "-w",
};
}
if (
token === "--help" ||
token === "-h" ||
/^-h.+/.test(token) ||
token === "--version" ||
token === "-v" ||
/^-v.+/.test(token)
) {
return {
reason: exitMessage,
captureNextValue: false,
};
}
return null;
}
function validateByedpiStrategyToken(token, nextToken) {
const controlled = getByedpiControlledTokenInfo(token);
if (controlled) {
return {
valid: false,
reason: controlled.reason,
captureNextValue: controlled.captureNextValue,
};
}
if (/^--[^=]+=/.test(token)) {
const base = token.split("=", 1)[0];
const value = token.slice(base.length + 1);
if (BYEDPI_LONG_VALUE_OPTIONS.has(base)) {
return value.length
? { valid: true, consumeNext: false }
: {
valid: false,
reason: _("ByeDPI option requires a value: %s").format(base),
captureNextValue: false,
};
}
if (BYEDPI_LONG_FLAG_OPTIONS.has(base)) {
return {
valid: false,
reason: _("ByeDPI option does not accept a value: %s").format(base),
captureNextValue: false,
};
}
return {
valid: false,
reason: _("Unknown ByeDPI option: %s").format(base),
captureNextValue: false,
};
}
if (/^--.+/.test(token)) {
if (BYEDPI_LONG_VALUE_OPTIONS.has(token)) {
return nextToken && !byedpiTokenLooksLikeOption(nextToken)
? { valid: true, consumeNext: true }
: {
valid: false,
reason: _("ByeDPI option requires a value: %s").format(token),
captureNextValue: false,
};
}
if (BYEDPI_LONG_FLAG_OPTIONS.has(token)) {
return { valid: true, consumeNext: false };
}
return {
valid: false,
reason: _("Unknown ByeDPI option: %s").format(token),
captureNextValue: false,
};
}
if (/^-./.test(token)) {
if (token === "-") {
return {
valid: false,
reason: _("Unexpected ByeDPI strategy argument: %s").format(token),
captureNextValue: false,
};
}
const short = getByedpiShortOptionName(token);
const compactValue = token.slice(short.length);
if (BYEDPI_SHORT_VALUE_OPTIONS.has(short)) {
if (token === short) {
return nextToken && !byedpiTokenLooksLikeOption(nextToken)
? { valid: true, consumeNext: true }
: {
valid: false,
reason: _("ByeDPI option requires a value: %s").format(short),
captureNextValue: false,
};
}
return compactValue.length
? { valid: true, consumeNext: false }
: {
valid: false,
reason: _("ByeDPI option requires a value: %s").format(short),
captureNextValue: false,
};
}
if (BYEDPI_SHORT_FLAG_OPTIONS.has(short)) {
return token === short
? { valid: true, consumeNext: false }
: {
valid: false,
reason: _(
"ByeDPI option does not accept a compact value: %s",
).format(short),
captureNextValue: false,
};
}
return {
valid: false,
reason: _("Unknown ByeDPI option: %s").format(short),
captureNextValue: false,
};
}
return {
valid: false,
reason: _("Unexpected ByeDPI strategy argument: %s").format(token),
captureNextValue: false,
};
}
function buildByedpiLocalAnalysis(value) {
const text = value ? `${value}` : "";
if (!text.trim().length) {
return {
valid: false,
message: _("ByeDPI strategy cannot be empty"),
annotations: [],
};
}
const tokens = parseNfqwsRuntimeTokens(text);
const annotationMap = new Map();
const errors = [];
for (let index = 0; index < tokens.length; ) {
const token = tokens[index];
const nextToken = tokens[index + 1] || null;
const tokenValidation = validateByedpiStrategyToken(
token.value,
nextToken ? nextToken.value : null,
);
if (tokenValidation.valid) {
index += tokenValidation.consumeNext ? 2 : 1;
continue;
}
addAnnotationIssue(annotationMap, token, tokenValidation.reason);
let displayToken = token.value;
if (
tokenValidation.captureNextValue &&
nextToken &&
!nextToken.value.startsWith("-")
) {
addAnnotationIssue(annotationMap, nextToken, tokenValidation.reason);
displayToken = `${displayToken} ${nextToken.value}`;
index += 2;
} else {
index += 1;
}
errors.push(`${displayToken}: ${tokenValidation.reason}`);
}
if (!errors.length) {
return { valid: true, message: "", annotations: [] };
}
return {
valid: false,
message: [getValidationHeaderText(), ...errors].join("\n"),
annotations: finalizeAnnotations(annotationMap),
};
}
function analyzeByedpiStrategy(value) {
const localAnalysis = buildByedpiLocalAnalysis(value);
if (!localAnalysis.valid) {
return localAnalysis;
}
const remoteValidation = getCachedByedpiRemoteValidation(value);
if (!remoteValidation || remoteValidation.valid) {
return localAnalysis;
}
const text = value ? `${value}` : "";
const tokens = parseNfqwsRuntimeTokens(text);
const annotationMap = new Map();
localAnalysis.annotations.forEach((annotation) =>
addAnnotationIssue(annotationMap, annotation, annotation.message),
);
addNfqwsRemoteValidationAnnotations(annotationMap, tokens, remoteValidation);
return {
valid: false,
message: [getValidationHeaderText(), remoteValidation.message].join("\n"),
annotations: finalizeAnnotations(annotationMap),
};
}
function configureTextareaOption(option, analyzer, remoteValidationAttacher) {
const originalRenderWidget = option.renderWidget;
option.renderWidget = function (section_id, option_index, cfgvalue) {
const node = originalRenderWidget.call(
this,
section_id,
option_index,
cfgvalue,
);
const textarea =
node && typeof node.querySelector === "function"
? node.querySelector("textarea")
: node;
if (textarea) {
applyTextareaInputAttributes(textarea);
if (typeof analyzer === "function") {
attachAnnotatedTextarea(textarea, analyzer);
}
if (typeof remoteValidationAttacher === "function") {
remoteValidationAttacher(this, section_id, textarea);
}
}
return node;
};
}
function getOptionTextarea(option, section_id) {
const field =
typeof option.map.findElement === "function"
? option.map.findElement("data-field", option.cbid(section_id))
: null;
if (field && typeof field.querySelector === "function") {
return field.querySelector("textarea");
}
const elem =
typeof option.getUIElement === "function"
? option.getUIElement(section_id)
: null;
const node = elem && elem.node ? elem.node : null;
if (node && node.nodeName === "TEXTAREA") {
return node;
}
return node && typeof node.querySelector === "function"
? node.querySelector("textarea")
: null;
}
function rejectStrategyValidation(option, section_id, message) {
const title = option.stripTags(option.title).trim();
const error = message || option.getValidationError(section_id) || "";
return Promise.reject(
new TypeError(
`${_('Option "%s" contains an invalid input value.').format(title || option.option)} ${error}`,
),
);
}
function parseStrategyWithRemoteValidation(section_id, config) {
const active = this.isActive(section_id);
if (active) {
if (typeof this.triggerValidation === "function") {
this.triggerValidation(section_id);
}
if (!this.isValid(section_id)) {
return rejectStrategyValidation(
this,
section_id,
this.getValidationError(section_id),
);
}
const cval = this.cfgvalue(section_id);
const fval = this.formvalue(section_id);
const cvalString = cval == null ? "" : `${cval}`;
const fvalString = fval == null ? "" : `${fval}`;
const shouldWrite = this.forcewrite || cvalString !== fvalString;
if (!shouldWrite) {
return Promise.resolve();
}
return config.remoteValidate(fvalString).then((result) => {
const textarea = getOptionTextarea(this, section_id);
if (textarea) {
refreshAnnotatedTextareaValidation(this, section_id, textarea);
}
if (typeof this.triggerValidation === "function") {
this.triggerValidation(section_id);
}
if (!result || result.valid !== true) {
return rejectStrategyValidation(
this,
section_id,
result && result.message ? result.message : config.invalidMessage,
);
}
return Promise.resolve(this.write(section_id, fvalString));
});
}
if (!this.retain) {
return Promise.resolve(this.remove(section_id));
}
return Promise.resolve();
}
function parseNfqwsStrategyOnSave(section_id) {
return parseStrategyWithRemoteValidation.call(this, section_id, {
remoteValidate: validateNfqwsStrategyRemotely,
invalidMessage: _(
"Unable to validate the NFQWS strategy through the backend parser.",
),
});
}
function parseNfqws2StrategyOnSave(section_id) {
return parseStrategyWithRemoteValidation.call(this, section_id, {
remoteValidate: validateNfqws2StrategyRemotely,
invalidMessage: _(
"Unable to validate the NFQWS2 strategy through the backend parser.",
),
});
}
function addDynamicConditionField(section, config) {
const o = section.taboption(
"conditions",
form.DynamicList,
config.key,
config.label,
config.description,
);
o.modalonly = true;
if (config.placeholder) {
o.placeholder = config.placeholder;
}
if (config.dynamicValidate) {
o.validate = config.dynamicValidate;
}
o.load = function (section_id) {
const values = getConfigListValues(section_id, config.key);
if (values.length) {
return values;
}
const legacyText = uci.get(UCI_PACKAGE, section_id, `${config.key}_text`);
return legacyText ? main.parseValueList(legacyText) : [];
};
o.write = function (section_id, value) {
writeListOption(section_id, config.key, value);
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text`);
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text_mode`);
};
return o;
}
function addLocalDeviceSubnetDynamicField(section, config) {
const o = section.taboption(
"conditions",
form.DynamicList,
config.key,
config.label,
config.description,
);
o.modalonly = true;
o.placeholder = _("Device or IP");
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateSubnet(value);
return validation.valid ? true : validation.message;
};
o.load = function (section_id) {
const values = getConfigListValues(section_id, config.key);
if (values.length) {
return values;
}
const legacyText = uci.get(UCI_PACKAGE, section_id, `${config.key}_text`);
return legacyText ? main.parseValueList(legacyText) : [];
};
o.write = function (section_id, value) {
writeListOption(section_id, config.key, value);
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text`);
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text_mode`);
};
o.renderWidget = function (section_id, _option_index, cfgvalue) {
return localDevices.createLocalDeviceDynamicListWidget(
this,
section_id,
cfgvalue,
);
};
return o;
}
function addTextConditionField(section, config) {
const optionName = config.optionName || `${config.key}_text`;
const legacyTextOptionName =
config.legacyTextOptionName || `${config.key}_text`;
const o = section.taboption(
"conditions",
form.TextValue,
optionName,
config.label,
config.description,
);
o.rows = 8;
o.wrap = "soft";
o.textarea = true;
o.modalonly = true;
if (config.textAnalyze) {
o.validate = function (_section_id, value) {
const analysis = config.textAnalyze(value);
return analysis.valid ? true : analysis.message;
};
} else if (config.textValidate) {
o.validate = config.textValidate;
}
configureTextareaOption(o, config.textAnalyze);
o.load = function (section_id) {
if (typeof config.loadText === "function") {
return config.loadText(section_id);
}
const textValue =
uci.get(UCI_PACKAGE, section_id, optionName) ||
uci.get(UCI_PACKAGE, section_id, legacyTextOptionName);
if (textValue) {
return valuesToText(textValue);
}
return valuesToText(uci.get(UCI_PACKAGE, section_id, config.key));
};
o.write = function (section_id, value) {
const normalized = value ? `${value}`.trim() : "";
if (normalized.length) {
uci.set(UCI_PACKAGE, section_id, optionName, normalized);
} else {
uci.unset(UCI_PACKAGE, section_id, optionName);
}
if (config.key !== optionName) {
uci.unset(UCI_PACKAGE, section_id, config.key);
}
if (legacyTextOptionName !== optionName) {
uci.unset(UCI_PACKAGE, section_id, legacyTextOptionName);
}
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text_mode`);
if (typeof config.afterWrite === "function") {
config.afterWrite(section_id);
}
};
return o;
}
var DOMAIN_LIST_HINTS = {
russia_inside: "youtube.com, instagram.com, twitter.com, ...",
russia_outside: "gosuslugi.ru, ozon.ru, rzd.ru, ...",
ukraine_inside: "ukr.net, pravda.com.ua, suspilne.media, ...",
geoblock: "Домены, заблокированные снаружи России (геоблокировка зарубежными сервисами)",
block: "Домены, заблокированные внутри России (полный реестр блокировок РКН)",
porn: "Pornhub, xvideos, xhamster, ...",
news: "meduza.io, bbc.com, pravda.com.ua, ...",
anime: "bato.to, mangadex.org, myanimelist.net, ...",
youtube: "youtube.com, youtu.be, googlevideo.com, ...",
discord: "discord.com, discord.gg, discordapp.com, ...",
meta: "facebook.com, instagram.com, whatsapp.com, ...",
twitter: "twitter.com, x.com, t.co, ...",
hdrezka: "hdrezka.ag, hdrezka.me, ...",
tiktok: "tiktok.com, tiktokcdn.com, ...",
telegram: "telegram.org, t.me, ...",
cloudflare: "все IP/домены Cloudflare (1.1.1.1, etc.)",
google_ai: "gemini.google.com, bard.google.com, ...",
google_play: "play.google.com, googleplay.com, ...",
hodca: "Домены на CDN: Hetzner, OVH, DigitalOcean, Cloudflare, AWS",
roblox: "roblox.com, rbxcdn.com, ...",
ads_hagezi_pro: "Блокировка рекламы (Hagezi Pro список)",
supercell: "supercell.com, clashofclans.com, ...",
github: "github.com, raw.githubusercontent.com, ...",
hetzner: "IP-адреса Hetzner (ASN)",
ovh: "IP-адреса OVH (ASN)",
digitalocean: "IP-адреса DigitalOcean (ASN)",
cloudfront: "IP-адреса CloudFront/Amazon (ASN)",
};
function loadRulesetValues(option) {
delete option.keylist;
delete option.vallist;
Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => {
let displayLabel = _(label);
if (key === "block") {
displayLabel = "Block (РКН / внутри РФ)";
} else if (key === "geoblock") {
displayLabel = "Geo Block (снаружи РФ)";
}
const hint = DOMAIN_LIST_HINTS[key];
option.value(key, hint ? `${displayLabel} — ${hint}` : displayLabel);
});
}
function isBuiltinRulesetValue(value) {
return Object.prototype.hasOwnProperty.call(main.DOMAIN_LIST_OPTIONS, value);
}
function normalizeReferenceForExtensionCheck(value) {
return `${value || ""}`.split(/[?#]/, 1)[0].toLowerCase();
}
function hasAllowedReferenceExtension(value, extensions) {
const normalized = normalizeReferenceForExtensionCheck(value);
return extensions.some((extension) => normalized.endsWith(extension));
}
function validateFileReference(value, extensions, errorMessage, options = {}) {
if (!value || value.length === 0) {
return true;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
const validation = main.validateUrl(value);
if (
validation.valid &&
(options.allowRemoteWithoutExtension ||
hasAllowedReferenceExtension(value, extensions))
) {
return true;
}
return errorMessage;
}
if (value.startsWith("/")) {
const validation = main.validatePath(value);
if (validation.valid && hasAllowedReferenceExtension(value, extensions)) {
return true;
}
return errorMessage;
}
return errorMessage;
}
function validateCustomRulesetReference(value) {
return validateFileReference(
value,
[".srs", ".json"],
_("Rule set must be an HTTP(S) URL or a local .srs / .json path"),
{ allowRemoteWithoutExtension: true },
);
}
function validatePlainListReference(value) {
return validateFileReference(
value,
[".lst"],
_("List must be an HTTP(S) URL or a local .lst path"),
{ allowRemoteWithoutExtension: true },
);
}
function getRulesetReferences(section_id) {
return getConfigListValues(section_id, "rule_set");
}
function getBuiltInRulesetReferences(section_id) {
const values = getConfigListValues(section_id, "community_lists").filter(
(value) => isBuiltinRulesetValue(value),
);
return values.filter(
(value, index, values) =>
isBuiltinRulesetValue(value) && values.indexOf(value) === index,
);
}
function getCustomRulesetReferences(section_id) {
return uniqueDynamicListItems([
...getRulesetReferences(section_id).filter(
(value) => !isBuiltinRulesetValue(value),
),
...getConfigListValues(section_id, "rule_set_with_subnets"),
]);
}
function writeBuiltInRulesetReferences(section_id, values) {
const refs = normalizeDynamicListItems(values).filter((value) =>
isBuiltinRulesetValue(value),
);
writeListOption(section_id, "community_lists", refs);
}
function writeCustomRulesetReferences(section_id, values) {
const refs = uniqueDynamicListItems(values);
if (getRuleResolvedAction(section_id) === "dns") {
writeDnsRulesetReferences(section_id, refs);
return;
}
const subnetRefs = getConfigListValues(
section_id,
"rule_set_with_subnets",
).filter((value) => refs.includes(value));
const subnetRefSet = new Set(subnetRefs);
writeListOption(
section_id,
"rule_set",
refs.filter((value) => !subnetRefSet.has(value)),
);
writeListOption(section_id, "rule_set_with_subnets", subnetRefs);
uci.unset(UCI_PACKAGE, section_id, RULE_SET_ITEM_SETTINGS_KEY);
}
function writeDnsRulesetReferences(section_id, values) {
writeListOption(section_id, "rule_set", uniqueDynamicListItems(values));
uci.unset(UCI_PACKAGE, section_id, "rule_set_with_subnets");
uci.unset(UCI_PACKAGE, section_id, RULE_SET_ITEM_SETTINGS_KEY);
}
function createSectionContent(section) {
let o;
section.tab("settings", _("Settings"));
section.tab("conditions", _("Conditions"));
o = section.taboption("settings", form.Flag, "enabled", _("Enable"));
o.default = "1";
o.rmempty = false;
o.editable = true;
o.width = "6rem";
o = section.taboption(
"settings",
form.DummyValue,
"_action_display",
_("Action"),
);
o.modalonly = false;
o.rawhtml = true;
o.cfgvalue = function (section_id) {
return getRuleActionDisplayMarkup(section_id);
};
o.textvalue = function (section_id) {
return getRuleActionDisplayValue(section_id);
};
o.width = "7rem";
o = section.taboption(
"settings",
form.Value,
"label",
_("Section name"),
_("Visible name of this section"),
);
o.rmempty = false;
o.modalonly = true;
o.load = function (section_id) {
return uci.get(UCI_PACKAGE, section_id, "label") || section_id;
};
o = section.taboption(
"settings",
form.ListValue,
"action",
_("Action"),
_("What Tachyon should do when this section matches"),
);
populateActionOptionValues(o);
const super_validate = o.validate;
o.validate = function (section_id, value) {
return super_validate
? super_validate.apply(this, [section_id, value])
: true;
};
o.default = "connection";
o.rmempty = false;
o.modalonly = true;
o.cfgvalue = function (section_id) {
return getRuleConfiguredAction(section_id);
};
o.load = function (section_id) {
return ensureActionProvidersAvailabilityLoaded().then(() => {
populateActionOptionValues(this);
return this.cfgvalue(section_id);
});
};
o = section.taboption(
"settings",
form.ListValue,
"dns_type",
_("DNS protocol"),
_("DNS protocol used by the resolver"),
);
o.depends("action", "dns");
dnsTypeChoices().forEach((choice) => o.value(choice.value, choice.label));
o.default = "udp";
o.rmempty = false;
o.modalonly = true;
const perRuleDnsTypeOption = o;
o = section.taboption(
"settings",
form.DynamicList,
"dns_server",
_("DNS server"),
_("DNS server used by the resolver"),
);
o.depends("action", "dns");
o.modalonly = true;
configureLiveDynamicListChoices(o, (section_id) => {
const dnsType = perRuleDnsTypeState.get(section_id) || "udp";
const servers =
main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp;
return Object.entries(servers).map(([value, label]) => ({
value,
label: _(label),
}));
});
perRuleDnsTypeOption.onchange = function (_ev, section_id, value) {
const newType = value || "udp";
const oldType = perRuleDnsTypeState.get(section_id) || "udp";
perRuleDnsTypeState.set(section_id, newType);
if (newType !== oldType) {
const widget = perRuleDnsWidgets.get(section_id);
if (widget) {
const servers =
main.DNS_SERVERS_BY_PROTOCOL[newType] ||
main.DNS_SERVERS_BY_PROTOCOL.udp;
const defaultLabels = {};
Object.entries(servers).forEach(([v, l]) => {
defaultLabels[v] = _(l);
});
const defaultServers = Object.keys(servers).slice(0, 1);
widget.choices = defaultLabels;
widget.setValue(defaultServers);
}
}
const refreshers = dashboardFilterChoiceRefreshers.get(section_id);
if (refreshers) {
refreshers.forEach((fn) => fn());
}
};
o = section.taboption(
"settings",
form.Flag,
"dns_detour_enabled",
_("DNS through section"),
_("Route requests to this DNS server through another section."),
);
o.depends("action", "dns");
o.default = "0";
o.rmempty = false;
o.modalonly = true;
o = section.taboption(
"settings",
form.ListValue,
"dns_detour_section",
_("DNS requests through section"),
);
o.depends({ action: "dns", dns_detour_enabled: "1" });
o.rmempty = false;
o.modalonly = true;
o.load = function (section_id) {
refreshDnsDetourSectionOptionValues(this, section_id);
return uci.get(UCI_PACKAGE, section_id, "dns_detour_section") || "";
};
o.validate = function (_section_id, value) {
return value ? true : _("Select a section");
};
o = section.taboption(
"settings",
form.TextValue,
"nfqws_opt",
_("NFQWS Strategy"),
);
o.depends("action", "zapret");
o.rows = 6;
o.wrap = "soft";
o.textarea = true;
o.modalonly = true;
o.load = function (section_id) {
const value = uci.get(UCI_PACKAGE, section_id, "nfqws_opt");
if (!value || value === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT) {
return ZAPRET_DEFAULT_NFQWS_OPT;
}
return value;
};
o.write = function (section_id, value) {
const normalized = normalizeNfqwsStrategyValue(value);
const nextValue =
!normalized.length || normalized === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT
? ZAPRET_DEFAULT_NFQWS_OPT
: normalized;
uci.set(UCI_PACKAGE, section_id, "nfqws_opt", nextValue);
};
o.validate = function (_section_id, value) {
const analysis = analyzeNfqwsStrategy(value);
return analysis.valid ? true : analysis.message;
};
o = section.taboption(
"settings",
form.TextValue,
"nfqws2_opt",
_("NFQWS2 Strategy"),
);
o.depends("action", "zapret2");
o.rows = 6;
o.wrap = "soft";
o.textarea = true;
o.modalonly = true;
o.load = function (section_id) {
return (
uci.get(UCI_PACKAGE, section_id, "nfqws2_opt") ||
ZAPRET2_DEFAULT_NFQWS2_OPT
);
};
o.write = function (section_id, value) {
const normalized = normalizeNfqws2StrategyValue(value);
uci.set(UCI_PACKAGE, section_id, "nfqws2_opt", normalized);
};
o.validate = function (_section_id, value) {
const analysis = analyzeNfqws2Strategy(value);
return analysis.valid ? true : analysis.message;
};
o = section.taboption(
"settings",
form.TextValue,
"byedpi_cmd_opts",
_("ByeDPI Strategy"),
);
o.depends("action", "byedpi");
o.rows = 6;
o.wrap = "soft";
o.textarea = true;
o.modalonly = true;
o.load = function (section_id) {
return (
uci.get(UCI_PACKAGE, section_id, "byedpi_cmd_opts") ||
BYEDPI_DEFAULT_CMD_OPTS
);
};
o.write = function (section_id, value) {
const normalized = normalizeByedpiStrategyValue(value);
return validateByedpiStrategyRemotely(normalized).then((result) => {
if (!result || result.valid !== true) {
throw new TypeError(
result && result.message
? result.message
: _("Invalid ByeDPI strategy"),
);
}
uci.set(UCI_PACKAGE, section_id, "byedpi_cmd_opts", normalized);
});
};
o.validate = function (_section_id, value) {
const analysis = analyzeByedpiStrategy(value);
return analysis.valid ? true : analysis.message;
};
configureTextareaOption(o, analyzeByedpiStrategy);
// ─── Hosts Import Helpers ──────────────────────────────────────────────
function parseHostsFile(text, options) {
options = options || {};
const skipLoopback = options.skipLoopback !== false;
const lines = (text || "").split(/\r?\n/);
const entries = [];
let skippedComments = 0;
let skippedLoopback = 0;
let skippedInvalid = 0;
const loopbackIPs = new Set([
"127.0.0.1",
"0.0.0.0",
"::1",
"0:0:0:0:0:0:0:1",
"0000:0000:0000:0000:0000:0000:0000:0001",
]);
const loopbackHosts = new Set([
"localhost",
"localhost.localdomain",
"local",
"broadcasthost",
]);
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) continue;
const hashIdx = line.indexOf("#");
if (hashIdx !== -1) {
if (hashIdx === 0) {
skippedComments++;
continue;
}
line = line.substring(0, hashIdx).trim();
if (!line) {
skippedComments++;
continue;
}
}
const parts = line.split(/\s+/);
if (parts.length < 2) {
skippedInvalid++;
continue;
}
const ip = parts[0].trim();
const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(ip);
const isIPv6 = ip.indexOf(":") !== -1 && /^[0-9a-fA-F:]+$/.test(ip);
if (!isIPv4 && !isIPv6) {
skippedInvalid++;
continue;
}
const isIpLoopback = loopbackIPs.has(ip);
for (let j = 1; j < parts.length; j++) {
const domain = parts[j].toLowerCase().trim();
if (!domain) continue;
if (skipLoopback && (isIpLoopback || loopbackHosts.has(domain))) {
skippedLoopback++;
continue;
}
if (domain.length > 253 || /^[^a-z0-9_.-]/i.test(domain)) {
skippedInvalid++;
continue;
}
entries.push({ domain: domain, ip: ip });
}
}
return {
entries,
skippedComments,
skippedLoopback,
skippedInvalid,
totalLines: lines.length,
};
}
function showImportHostsModal(section_id, dnsHostsOptRef) {
const fileInput = E("input", {
type: "file",
accept: ".txt,.hosts,hosts,*",
style: "display:none",
});
const fileSelectBtn = E(
"button",
{
class: "cbi-button cbi-button-neutral",
type: "button",
click: function () {
fileInput.click();
},
},
_("Choose Hosts File..."),
);
const loadCurrentBtn = E(
"button",
{
class: "cbi-button cbi-button-action",
type: "button",
click: function () {
let existingEntries = [];
const uiEl = dnsHostsOptRef
? dnsHostsOptRef.getUIElement(section_id)
: null;
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (typeof v === "string" && v)
existingEntries = v
.split("\n")
.map((l) => l.trim())
.filter((l) => l);
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal)
? uciVal
: uciVal
? [uciVal]
: [];
}
textArea.value = existingEntries.join("\n");
fileStatus.textContent = _("Loaded %d current record(s)").format(
existingEntries.length,
);
updatePreview();
},
},
_("Load Current Records"),
);
const fileStatus = E(
"span",
{
style:
"margin-left:10px;font-size:0.85rem;color:var(--text-color-medium,#888);",
},
_("No file selected"),
);
const textArea = E("textarea", {
class: "cbi-input-textarea",
rows: 8,
style:
"width:100%;font-family:monospace;font-size:0.85rem;margin-top:8px;resize:vertical;",
placeholder:
"127.0.0.1 localhost\n192.168.1.100 server.local\n10.0.0.5 example.com alias.com",
});
const skipLoopbackCb = E("input", {
type: "checkbox",
id: "hosts-skip-loopback",
checked: true,
style: "margin-right:6px;vertical-align:middle;",
});
const overwriteCb = E("input", {
type: "checkbox",
id: "hosts-overwrite",
checked: false,
style: "margin-right:6px;vertical-align:middle;",
});
const previewBox = E(
"div",
{
style:
"margin-top:12px;padding:10px 14px;background:var(--background-color-secondary,#f8f9fa);border:1px solid var(--border-color-medium,#e0e0e0);border-radius:4px;font-size:0.85rem;line-height:1.5;",
},
[
E(
"em",
{},
_("Paste content or upload a file to preview imported DNS records."),
),
],
);
const importBtn = E(
"button",
{
class: "cbi-button cbi-button-action",
type: "button",
disabled: true,
},
_("Import"),
);
let currentParsedResult = null;
function updatePreview() {
const text = textArea.value;
const skipLoopback = skipLoopbackCb.checked;
const overwrite = overwriteCb.checked;
if (!text.trim()) {
previewBox.innerHTML = "";
previewBox.appendChild(
E(
"em",
{},
_(
"Paste content or upload a file to preview imported DNS records.",
),
),
);
importBtn.disabled = true;
importBtn.textContent = _("Import");
currentParsedResult = null;
return;
}
const result = parseHostsFile(text, { skipLoopback: skipLoopback });
currentParsedResult = result;
let existingEntries = [];
const uiEl = dnsHostsOptRef
? dnsHostsOptRef.getUIElement(section_id)
: null;
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (typeof v === "string" && v)
existingEntries = v
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal)
? uciVal
: uciVal
? [uciVal]
: [];
}
const domainMap = new Map();
if (!overwrite) {
for (const entry of existingEntries) {
const parts = `${entry}`.trim().split(/\s+/);
if (parts.length >= 2)
domainMap.set(parts[0].toLowerCase(), parts[1]);
}
}
let newCount = 0,
updatedCount = 0;
for (const item of result.entries) {
if (domainMap.has(item.domain.toLowerCase())) updatedCount++;
else newCount++;
domainMap.set(item.domain.toLowerCase(), item.ip);
}
previewBox.innerHTML = "";
previewBox.appendChild(
E(
"div",
{
style:
"display:grid;grid-template-columns:1fr 1fr;gap:6px;row-gap:4px;",
},
[
E("div", {}, [
E("strong", {}, _("Total lines: ")),
`${result.totalLines}`,
]),
E("div", {}, [
E("strong", {}, _("Parsed records: ")),
`${result.entries.length}`,
]),
E("div", {}, [
E("strong", {}, _("Skipped loopback: ")),
`${result.skippedLoopback}`,
]),
E("div", {}, [
E("strong", {}, _("Skipped comments/invalid: ")),
`${result.skippedComments + result.skippedInvalid}`,
]),
E("div", {}, [E("strong", {}, _("New domains: ")), `${newCount}`]),
E("div", {}, [
E("strong", {}, _("Updated/duplicates: ")),
`${updatedCount}`,
]),
],
),
);
if (result.entries.length > 0) {
importBtn.disabled = false;
importBtn.textContent = _("Import (%d records)").format(
result.entries.length,
);
} else {
importBtn.disabled = true;
importBtn.textContent = _("Import");
}
}
fileInput.addEventListener("change", function (ev) {
const file = ev.target.files[0];
if (!file) return;
fileStatus.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
const reader = new FileReader();
reader.onload = function (e) {
textArea.value = e.target.result || "";
updatePreview();
};
reader.readAsText(file);
});
textArea.addEventListener("input", updatePreview);
skipLoopbackCb.addEventListener("change", updatePreview);
overwriteCb.addEventListener("change", updatePreview);
importBtn.addEventListener("click", function () {
if (!currentParsedResult || !currentParsedResult.entries.length) return;
const overwrite = overwriteCb.checked;
const uiEl = dnsHostsOptRef
? dnsHostsOptRef.getUIElement(section_id)
: null;
let existingEntries = [];
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (typeof v === "string" && v)
existingEntries = v
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal)
? uciVal
: uciVal
? [uciVal]
: [];
}
const domainMap = new Map();
if (!overwrite) {
for (const entry of existingEntries) {
const parts = `${entry}`.trim().split(/\s+/);
if (parts.length >= 2)
domainMap.set(parts[0].toLowerCase(), parts[1]);
}
}
for (const item of currentParsedResult.entries)
domainMap.set(item.domain.toLowerCase(), item.ip);
const finalEntries = [];
for (const [domain, ip] of domainMap.entries())
finalEntries.push(`${domain} ${ip}`);
uci.set(UCI_PACKAGE, section_id, "dns_hosts", finalEntries.join("\n"));
if (uiEl && typeof uiEl.setValue === "function")
uiEl.setValue(finalEntries.join("\n"));
ui.hideModal();
ui.addNotification(
null,
_("Successfully imported %d DNS record(s)").format(finalEntries.length),
"info",
);
});
ui.showModal(_("Import Hosts File"), [
E("div", { class: "cbi-map" }, [
E(
"div",
{
style:
"margin-bottom:12px;display:flex;align-items:center;flex-wrap:wrap;gap:8px;",
},
[fileInput, fileSelectBtn, loadCurrentBtn, fileStatus],
),
E(
"label",
{ style: "display:block;margin-bottom:4px;font-weight:bold;" },
_("Or paste hosts file content:"),
),
textArea,
E("div", { style: "margin-top:10px;" }, [
E(
"label",
{
style:
"display:flex;align-items:center;cursor:pointer;margin-bottom:6px;",
},
[
skipLoopbackCb,
E(
"span",
{},
_(
"Skip loopback & local entries (127.0.0.1, ::1, 0.0.0.0, localhost)",
),
),
],
),
E(
"label",
{ style: "display:flex;align-items:center;cursor:pointer;" },
[
overwriteCb,
E(
"span",
{},
_(
"Overwrite existing records (if unchecked, merge with existing records)",
),
),
],
),
]),
previewBox,
]),
E("div", { class: "button-row", style: "margin-top:16px;" }, [
importBtn,
E(
"button",
{
class: "btn cbi-button cbi-button-neutral",
click: ui.createHandlerFn(this, function () {
ui.hideModal();
}),
},
_("Cancel"),
),
]),
]);
}
// ─── Hosts Section Options ──────────────────────────────────────────────
o = section.taboption(
"settings",
form.TextValue,
"dns_hosts",
_("Custom DNS Records (Hosts)"),
_(
"One record per line: <code>example.com 192.168.1.100</code> or <code>192.168.1.100 example.com</code>. Lines starting with <code>#</code> are ignored.",
),
);
o.depends("action", "hosts");
o.rows = 8;
o.wrap = "soft";
o.textarea = true;
o.modalonly = true;
o.cfgvalue = function (section_id) {
const val = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
if (!val) return "";
if (Array.isArray(val)) return val.join("\n");
return String(val || "");
};
o.write = function (section_id, value) {
const lines = String(value || "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
if (lines.length === 0) {
uci.unset(UCI_PACKAGE, section_id, "dns_hosts");
} else {
uci.set(UCI_PACKAGE, section_id, "dns_hosts", lines.join("\n"));
}
};
o.remove = function (section_id) {
uci.unset(UCI_PACKAGE, section_id, "dns_hosts");
};
o.validate = function (_section_id, value) {
if (!value) return true;
const lines = value.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const parts = trimmed.split(/\s+/);
if (parts.length < 2) {
return _('Invalid line: "%s". Use: domain ip or ip domain').format(
trimmed.length > 40 ? trimmed.substring(0, 40) + "\u2026" : trimmed,
);
}
}
return true;
};
const _origDnsHostsRenderWidget = o.renderWidget;
o.renderWidget = function (section_id, option_index, cfgvalue) {
const node = _origDnsHostsRenderWidget.call(
this,
section_id,
option_index,
cfgvalue,
);
const ta =
node.tagName === "TEXTAREA" ? node : node.querySelector("textarea");
if (ta) {
ta.style.fontFamily = "monospace";
ta.style.fontSize = "0.85rem";
ta.style.lineHeight = "1.4";
ta.style.resize = "vertical";
}
const countBadge = E("span", {
style:
"font-weight:600;font-size:0.85rem;margin-left:auto;color:var(--text-color-medium,#666);",
});
function updateCount() {
const text = ta ? ta.value : "";
const validLines = text
.split("\n")
.filter((l) => l.trim() && !l.trim().startsWith("#"));
countBadge.textContent = _("Records: %d").format(validLines.length);
}
if (ta) {
ta.addEventListener("input", updateCount);
setTimeout(updateCount, 50);
}
const sortBtn = E(
"button",
{
class: "cbi-button cbi-button-neutral",
type: "button",
style: "margin-left:6px;",
click: function () {
if (!ta) return;
const lines = ta.value
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const comments = lines.filter((l) => l.startsWith("#"));
const records = lines.filter((l) => !l.startsWith("#")).sort();
ta.value = [...comments, ...records].join("\n");
updateCount();
},
},
_("Sort Alphabetically"),
);
const cleanBtn = E(
"button",
{
class: "cbi-button cbi-button-neutral",
type: "button",
style: "margin-left:6px;",
click: function () {
if (!ta) return;
const lines = ta.value
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
ta.value = lines.join("\n");
updateCount();
},
},
_("Clean Comments"),
);
const importHostsBtn = E(
"button",
{
class: "cbi-button cbi-button-action",
type: "button",
style: "margin-left:6px;",
click: function () {
showImportHostsModal(section_id, o);
},
},
_("Import Hosts File"),
);
return E("div", {}, [
node,
E(
"div",
{
style:
"display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-top:8px;",
},
[sortBtn, cleanBtn, importHostsBtn, countBadge],
),
]);
};
o = section.taboption(
"settings",
form.DynamicList,
"hosts_list_urls",
_("Remote Hosts Lists"),
_(
"URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>.",
),
);
o.depends("action", "hosts");
o.placeholder = "https://raw.githubusercontent.com/.../hosts";
o.modalonly = true;
o.renderWidget = function (section_id, _option_index, _cfgvalue) {
const pkg = UCI_PACKAGE;
function getUrls() {
let v = uci.get(pkg, section_id, "hosts_list_urls") || [];
if (!Array.isArray(v)) v = v ? [v] : [];
return v;
}
function getDisabled() {
let v = uci.get(pkg, section_id, "hosts_list_disabled") || [];
if (!Array.isArray(v)) v = v ? [v] : [];
return v;
}
function setUrls(urls) {
uci.set(pkg, section_id, "hosts_list_urls", urls.length ? urls : null);
}
function setDisabled(disabled) {
uci.set(
pkg,
section_id,
"hosts_list_disabled",
disabled.length ? disabled : null,
);
}
const container = E("div", { class: "cbi-section-node" });
function render() {
container.innerHTML = "";
const urls = getUrls();
const disabled = getDisabled();
const table = E("table", {
class: "cbi-section-table",
style: "width:100%;",
});
const tbody = E("tbody");
urls.forEach(function (url, idx) {
const isOff = disabled.includes(url);
const tr = E("tr", { class: "cbi-section-table-row" });
const tdCheck = E("td", {
class: "cbi-section-table-cell",
style: "width:30px;text-align:center;",
});
const cb = E("input", {
type: "checkbox",
class: "cbi-input-checkbox",
checked: !isOff,
change: function () {
const nowEnabled = this.checked;
let d = getDisabled();
if (nowEnabled) {
d = d.filter(function (x) {
return x !== url;
});
} else {
if (!d.includes(url)) d.push(url);
}
setDisabled(d);
uci.save();
render();
},
});
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
const tdUrl = E("td", {
class: "cbi-section-table-cell",
style: "word-break:break-all;overflow-wrap:anywhere;font-size:12px;line-height:1.3;max-width:320px;",
});
let displayUrl = url;
try {
displayUrl = decodeURIComponent(url);
} catch(e) {}
const urlSpan = E(
"span",
{
style: isOff ? "text-decoration:line-through;opacity:0.6;" : "",
title: url,
},
displayUrl,
);
tdUrl.appendChild(urlSpan);
tr.appendChild(tdUrl);
const tdStatus = E("td", {
class: "cbi-section-table-cell",
style: "width:60px;text-align:center;white-space:nowrap;",
});
tdStatus.appendChild(
E(
"span",
{
style: isOff ? "color:#e53935;" : "color:#2e7d32;",
},
isOff ? "OFF" : "ON",
),
);
tr.appendChild(tdStatus);
const tdDel = E("td", {
class: "cbi-section-table-cell",
style: "width:40px;text-align:center;",
});
const delBtn = E(
"button",
{
class: "btn cbi-button cbi-button-remove",
type: "button",
title: _("Remove"),
click: function () {
let u = getUrls();
let d = getDisabled();
u.splice(idx, 1);
d = d.filter(function (x) {
return x !== url;
});
setUrls(u);
setDisabled(d);
uci.save();
render();
},
},
"\u2715",
);
tdDel.appendChild(delBtn);
tr.appendChild(tdDel);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
const addRow = E("div", {
class: "cbi-section-create",
style: "margin-top:8px;display:flex;gap:4px;align-items:center;",
});
const input = E("input", {
type: "text",
class: "cbi-input-text",
placeholder: "https://raw.githubusercontent.com/.../hosts",
style: "flex:1;",
});
const addBtn = E(
"button",
{
class: "btn cbi-button cbi-button-action",
type: "button",
click: function () {
let val = (input.value || "").trim();
if (!val) return;
val = val.replace(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/, "https://raw.githubusercontent.com/$1/$2/$3");
val = val.replace(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/raw\/(.+)$/, "https://raw.githubusercontent.com/$1/$2/$3");
let u = getUrls();
if (u.includes(val)) {
input.value = "";
return;
}
u.push(val);
setUrls(u);
uci.save();
input.value = "";
render();
},
},
"+",
);
input.addEventListener("keydown", function (ev) {
if (ev.key === "Enter") addBtn.click();
});
addRow.appendChild(input);
addRow.appendChild(addBtn);
container.appendChild(addRow);
if (urls.length > 0) {
const updateRow = E("div", {
style: "margin-top:8px;display:flex;justify-content:flex-end;",
});
const updateBtn = E(
"button",
{
class: "cbi-button cbi-button-action",
type: "button",
click: function () {
updateBtn.disabled = true;
updateBtn.textContent = _("Updating…");
fs.exec("/usr/bin/tachyon", ["hosts_list_update"])
.then(function (res) {
updateBtn.disabled = false;
updateBtn.textContent = _("Update Lists Now");
let ok = false;
let msg = "";
try {
const data = JSON.parse(res.stdout || "{}");
if (data.success) {
ok = true;
msg = _("Successfully updated %d hosts entries").format(data.entries);
} else {
msg = _("Update failed: ") + (data.error || res.stderr || "");
}
} catch (e) {
msg = res.stdout || res.stderr || _("Finished with code ") + res.code;
}
ui.addNotification(
null,
E("p", {}, msg),
ok ? "info" : "danger"
);
})
.catch(function (err) {
updateBtn.disabled = false;
updateBtn.textContent = _("Update Lists Now");
ui.addNotification(null, E("p", {}, err.message || err), "danger");
});
},
},
_("Update Lists Now"),
);
updateRow.appendChild(updateBtn);
container.appendChild(updateRow);
}
}
render();
return container;
};
o = section.taboption(
"settings",
form.Flag,
"hosts_list_auto_update",
_("Auto-update Hosts Lists"),
_("Automatically update remote hosts lists on schedule"),
);
o.depends("action", "hosts");
o.default = "0";
o.rmempty = false;
o.modalonly = true;
o = section.taboption(
"settings",
form.Value,
"hosts_list_update_interval",
_("Hosts Lists Update Interval"),
_("Use sing-box duration format like 1d, 12h or 6h"),
);
o.depends("action", "hosts");
o.depends({ action: "hosts", hosts_list_auto_update: "1" });
o.placeholder = "24h";
o.default = "24h";
o.rmempty = false;
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value) return true;
return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value)
? true
: _("Use sing-box duration format like 1d, 12h or 6h");
};
// Keep tag chains (<b 0x..>, <t>, <r..>, etc.) and plain hex intact
// Normalizes plain hex to <b 0xHEX> and auto-heals previously truncated tags
const cleanAwgPayload = (v) => {
if (typeof v !== "string") return "";
let s = v.trim();
if (!s || s === "0") return s;
// Heal previously truncated opening binary tag if missing (e.g. 729cd...><t>...)
if (/^[0-9a-fA-F]+><[^<>]+>/.test(s)) s = "<b 0x" + s;
// Heal previously truncated closing bracket if missing (e.g. ...<rd 5)
if (/<[^<>]+$/.test(s)) s += ">";
// If plain hex (classic AWG payload or stripped <b 0x..>), normalize to <b 0xHEX>
const plainHex = s.replace(/^0x/i, "");
if (/^[0-9a-fA-F]+$/.test(plainHex) && plainHex.length >= 2) {
const padded = plainHex.length % 2 !== 0 ? plainHex + "0" : plainHex;
return "<b 0x" + padded.toLowerCase() + ">";
}
return s;
};
o = section.taboption(
"settings",
form.Button,
"_generate_warp",
_("AmneziaWG Generator"),
_(
"Automatically generate a Cloudflare WARP account and configure all AmneziaWG settings.",
),
);
o.modalonly = true;
o.inputtitle = _("Generate WARP Config");
o.depends("action", "awg");
// Custom render: beautiful gradient button with loading / success / error states
o.renderWidget = function (section_id) {
if (!document.getElementById("tachyon-warp-btn-css")) {
const s = document.createElement("style");
s.id = "tachyon-warp-btn-css";
s.textContent = `
.twg-spinner {
width: 13px; height: 13px;
border: 1.5px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
display: none;
flex-shrink: 0;
animation: twg-spin 0.7s linear infinite;
opacity: 0.6;
}
.twg-loading .twg-spinner { display: inline-block; }
.twg-loading .twg-icon { display: none; }
.twg-icon { flex-shrink: 0; }
.twg-btn.twg-loading { opacity: 0.65; }
.twg-btn.twg-success { color: #4caf50 !important; border-color: #4caf50 !important; }
.twg-btn.twg-error { color: #ef5350 !important; border-color: #ef5350 !important; animation: twg-shake 0.4s ease; }
@keyframes twg-spin { to { transform: rotate(360deg); } }
@keyframes twg-shake {
0%,100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
60% { transform: translateX(5px); }
80% { transform: translateX(-3px); }
}
`;
document.head.appendChild(s);
}
const spinner = E("span", { class: "twg-spinner" });
const icon = E("span", { class: "twg-icon" }, ["↺"]);
const label = E("span", { class: "twg-label" }, [
_("Generate WARP Config"),
]);
const btn = E(
"button",
{
class: "btn cbi-button cbi-button-neutral twg-btn",
type: "button",
style: "display:inline-flex;align-items:center;gap:8px;",
},
[spinner, icon, label],
);
btn.addEventListener(
"click",
(ev) => this.onclick && this.onclick.call(this, ev, section_id),
);
return btn;
};
o.onclick = function (ev, section_id) {
const btn = ev.target.closest(".twg-btn");
const label = btn ? btn.querySelector(".twg-label") : null;
const origText = _("Generate WARP Config");
const setState = (state, text) => {
if (!btn) return;
btn.className =
"btn cbi-button cbi-button-neutral twg-btn" +
(state ? " twg-" + state : "");
btn.disabled = state === "loading";
if (label && text) label.textContent = text;
};
const showStatusModal = (title, msg, isError) =>
ui.addNotification(
title,
E("p", {}, msg),
isError ? "danger" : "success",
);
const resetAfter = (ms) => setTimeout(() => setState("", origText), ms);
const getControlWidget = (opt) => {
const widget = document.getElementById(
`widget.cbid.${UCI_PACKAGE}.${section_id}.${opt}`,
);
if (!widget) return null;
if (/^(INPUT|SELECT|TEXTAREA)$/.test(widget.tagName)) return widget;
return widget.querySelector(
"input:not([type='hidden']), select, textarea",
);
};
const verWidget = getControlWidget("awg_version");
const currentVersion =
(verWidget ? verWidget.value : null) ||
uci.get(UCI_PACKAGE, section_id, "awg_version") ||
"2.0";
setState("loading", _("Generating…"));
fs.exec("/usr/bin/tachyon", ["generate_warp", "", "2.0"])
.then(function (response) {
if (!response || (response.code ?? 0) !== 0 || !response.stdout) {
setState("error", _("Error"));
resetAfter(2500);
showStatusModal(
_("Error"),
_("Failed to generate WARP config: ") +
(response?.stderr || "Unknown error"),
true,
);
return;
}
try {
const data = JSON.parse(response.stdout);
if (!data.success) {
setState("error", _("Error"));
resetAfter(2500);
showStatusModal(
_("Error"),
_("Failed to generate WARP config: ") +
(data.message || "Unknown error"),
true,
);
return;
}
const setWidgetValue = (opt, val) => {
const widget = getControlWidget(opt);
if (widget) {
widget.value = val;
widget.dispatchEvent(new Event("input", { bubbles: true }));
widget.dispatchEvent(new Event("change", { bubbles: true }));
}
uci.set(UCI_PACKAGE, section_id, opt, val);
};
const genVer = data.awg_version || "2.0";
setWidgetValue("awg_local_address", data.local_address);
setWidgetValue("awg_private_key", data.private_key);
setWidgetValue("awg_peer_public_key", data.peer_public_key);
setWidgetValue("awg_server_address", data.server_address);
setWidgetValue("awg_server_port", data.server_port);
setWidgetValue("awg_jc", `${data.awg_jc}`);
setWidgetValue("awg_jmin", `${data.awg_jmin}`);
setWidgetValue("awg_jmax", `${data.awg_jmax}`);
setWidgetValue("awg_s1", `${data.awg_s1}`);
setWidgetValue("awg_s2", `${data.awg_s2}`);
setWidgetValue("awg_h1", `${data.awg_h1}`);
setWidgetValue("awg_h2", `${data.awg_h2}`);
setWidgetValue("awg_h3", `${data.awg_h3}`);
setWidgetValue("awg_h4", `${data.awg_h4}`);
setWidgetValue("awg_s3", `${data.awg_s3}`);
setWidgetValue("awg_s4", `${data.awg_s4}`);
setWidgetValue("awg_i1", cleanAwgPayload(data.awg_i1));
setWidgetValue("awg_i2", cleanAwgPayload(data.awg_i2));
setWidgetValue("awg_i3", cleanAwgPayload(data.awg_i3));
setWidgetValue("awg_i4", cleanAwgPayload(data.awg_i4));
setWidgetValue("awg_i5", cleanAwgPayload(data.awg_i5));
setWidgetValue("awg_version", genVer);
// Clear 3.x specific options when generating WARP (Cloudflare only supports AWG 2.0)
setWidgetValue("awg_header_protection_key", "");
setWidgetValue("awg_content_padding_addition", "");
setWidgetValue("awg_rekey_after_time", "");
setWidgetValue("awg_rekey_timeout", "");
setWidgetValue("awg_reject_after_time", "");
setWidgetValue("awg_keepalive_timeout", "");
setWidgetValue("awg_max_handshake_attempts", "");
uci.unset(UCI_PACKAGE, section_id, "awg_header_protection_key");
uci.unset(UCI_PACKAGE, section_id, "awg_content_padding_addition");
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_reject_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_keepalive_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_max_handshake_attempts");
uci.unset(UCI_PACKAGE, section_id, "awg_random_trailers");
uci.unset(UCI_PACKAGE, section_id, "awg_disable_cookies");
setState("success", _("Generated!"));
resetAfter(2500);
let successMsg = _("AmneziaWG (Cloudflare WARP) configuration generated and loaded successfully!");
if (currentVersion !== "2.0") {
successMsg += "\n\n" + _("Notice: Cloudflare WARP edge servers only support AmneziaWG 2.0 (WireGuard). The profile has been automatically set to 2.0 so the connection works properly. Versions 3.0 and 3.1 require an AmneziaWG server via 'Import .conf'.");
}
showStatusModal(
_("Success"),
successMsg,
false,
);
} catch (err) {
setState("error", _("Error"));
resetAfter(2500);
showStatusModal(
_("Error"),
_("Failed to parse WARP config: ") + err.message,
true,
);
}
})
.catch(function (error) {
setState("error", _("Error"));
resetAfter(2500);
showStatusModal(
_("Error"),
_("Error running generator: ") + error.message,
true,
);
});
};
// ── Load .conf button ──────────────────────────────────────────────────────
o = section.taboption(
"settings",
form.Button,
"_load_awg_conf",
_("Load .conf config"),
_("Import AmneziaWG settings from .conf file"),
);
o.modalonly = true;
o.depends("action", "awg");
o.renderWidget = function (section_id) {
// Parse INI-style .conf into { sectionName: { key: value } }
const parseConf = (text) => {
const out = {};
let cur = null;
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
const sm = line.match(/^\[([^\]]+)\]$/);
if (sm) {
cur = sm[1].trim().toLowerCase();
out[cur] = out[cur] || {};
continue;
}
const kv = line.match(/^([^=]+)\s*=\s*(.+)$/);
if (kv && cur) {
const key = kv[1].trim();
let val = kv[2].trim();
// Strip inline comments if not inside payload tags
if (!val.startsWith("<")) {
const commentIdx = val.search(/[#;]/);
if (commentIdx >= 0) val = val.slice(0, commentIdx).trim();
}
out[cur][key] = val;
}
}
return out;
};
// Add /32 or /128 prefix if missing; join with space
const normalizeAddr = (str) =>
str
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((a) =>
a.includes("/") ? a : a.includes(":") ? a + "/128" : a + "/32",
)
.join(" ");
// Use shared payload cleaner for tag chains and hex normalization
const cleanPayload = cleanAwgPayload;
// Write value into a form widget and the UCI cache
const setVal = (opt, val) => {
if (val === undefined || val === null) return;
const str = String(val);
const el =
document.getElementById(`widget.cbid.${UCI_PACKAGE}.${section_id}.${opt}`) ||
document.getElementById(`cbid.${UCI_PACKAGE}.${section_id}.${opt}`);
const w =
el &&
(/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)
? el
: el.querySelector("input:not([type='hidden']),select,textarea"));
if (w) {
w.value = str;
w.dispatchEvent(new Event("input", { bubbles: true }));
w.dispatchEvent(new Event("change", { bubbles: true }));
}
uci.set(UCI_PACKAGE, section_id, opt, str);
};
// Hidden file picker
const fileInput = E("input", {
type: "file",
accept: ".conf",
style: "display:none",
});
const icon = E("span", {}, ["📂"]);
const label = E("span", { class: "twg-label" }, [_("Load .conf")]);
const btn = E(
"button",
{
class: "btn cbi-button cbi-button-neutral twg-btn",
type: "button",
style: "display:inline-flex;align-items:center;gap:8px;",
},
[icon, label],
);
const setBtn = (state, text) => {
btn.className =
"btn cbi-button cbi-button-neutral twg-btn" +
(state ? " twg-" + state : "");
label.textContent = text || _("Load .conf");
};
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
fileInput.value = "";
if (!file) return;
if (!file.name.endsWith(".conf")) {
ui.addNotification(
_("Error"),
E("p", {}, _("Select a file with .conf extension")),
"danger",
);
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
let cfg;
try {
cfg = parseConf(ev.target.result);
} catch (err) {
setBtn("error", _("Error"));
setTimeout(() => setBtn("", null), 2500);
ui.addNotification(
_("Error"),
E("p", {}, _("Failed to parse file: ") + err.message),
"danger",
);
return;
}
const iface = cfg.interface || {};
const peer = cfg.peer || {};
// Case-insensitive lookup across [Interface] and [Peer]
const getConfVal = (k) => {
const lk = k.toLowerCase();
for (const s of [iface, peer]) {
if (!s) continue;
for (const key in s) {
if (key.toLowerCase() === lk) return s[key];
}
}
return undefined;
};
// --- [Interface] / [Peer] ---
const addr = getConfVal("Address") || getConfVal("awg_local_address");
if (addr) setVal("awg_local_address", normalizeAddr(addr));
const priv = getConfVal("PrivateKey") || getConfVal("awg_private_key");
if (priv) setVal("awg_private_key", priv);
const mtu = getConfVal("MTU");
if (mtu !== undefined) setVal("awg_mtu", mtu);
const keepalive = getConfVal("PersistentKeepalive") || getConfVal("Keepalive");
if (keepalive !== undefined) setVal("awg_keepalive", keepalive);
const jc = getConfVal("Jc");
if (jc !== undefined) setVal("awg_jc", jc);
const jmin = getConfVal("Jmin");
if (jmin !== undefined) setVal("awg_jmin", jmin);
const jmax = getConfVal("Jmax");
if (jmax !== undefined) setVal("awg_jmax", jmax);
const s1 = getConfVal("S1");
if (s1 !== undefined) setVal("awg_s1", s1);
const s2 = getConfVal("S2");
if (s2 !== undefined) setVal("awg_s2", s2);
const s3 = getConfVal("S3");
if (s3 !== undefined) setVal("awg_s3", s3);
const s4 = getConfVal("S4");
if (s4 !== undefined) setVal("awg_s4", s4);
const h1 = getConfVal("H1");
if (h1 !== undefined) setVal("awg_h1", h1);
const h2 = getConfVal("H2");
if (h2 !== undefined) setVal("awg_h2", h2);
const h3 = getConfVal("H3");
if (h3 !== undefined) setVal("awg_h3", h3);
const h4 = getConfVal("H4");
if (h4 !== undefined) setVal("awg_h4", h4);
const i1 = getConfVal("I1");
if (i1) setVal("awg_i1", cleanPayload(i1));
const i2 = getConfVal("I2");
if (i2) setVal("awg_i2", cleanPayload(i2));
const i3 = getConfVal("I3");
if (i3) setVal("awg_i3", cleanPayload(i3));
const i4 = getConfVal("I4");
if (i4) setVal("awg_i4", cleanPayload(i4));
const i5 = getConfVal("I5");
if (i5) setVal("awg_i5", cleanPayload(i5));
const j1 = getConfVal("J1");
if (j1) setVal("awg_j1", cleanPayload(j1));
const j2 = getConfVal("J2");
if (j2) setVal("awg_j2", cleanPayload(j2));
const j3 = getConfVal("J3");
if (j3) setVal("awg_j3", cleanPayload(j3));
const itime = getConfVal("Itime");
if (itime !== undefined) setVal("awg_itime", itime);
// --- AmneziaWG version detection and parameters ---
const hpk = getConfVal("HeaderProtectionKey") || "";
const cpa = getConfVal("ContentPaddingAddition") || "";
const rka = getConfVal("RekeyAfterTime") || "";
const rkt = getConfVal("RekeyTimeout") || "";
const rja = getConfVal("RejectAfterTime") || "";
const kpt = getConfVal("KeepaliveTimeout") || "";
const mha = getConfVal("MaxHandshakeAttempts") || "";
const randomTrailers = getConfVal("RandomTrailers") || "";
const disableCookies = getConfVal("DisableCookies") || "";
const isV31 = !!(rka || rkt || rja || kpt || mha || randomTrailers || disableCookies);
const isV30 = !isV31 && !!(hpk || cpa);
const detectedVersion = isV31 ? "3.1" : (isV30 ? "3.0" : "2.0");
setVal("awg_version", detectedVersion);
if (isV31 || isV30) {
setVal("awg_header_protection_key", hpk);
setVal("awg_content_padding_addition", cpa);
} else {
setVal("awg_header_protection_key", "");
setVal("awg_content_padding_addition", "");
uci.unset(UCI_PACKAGE, section_id, "awg_header_protection_key");
uci.unset(UCI_PACKAGE, section_id, "awg_content_padding_addition");
}
if (isV31) {
setVal("awg_rekey_after_time", rka);
setVal("awg_rekey_timeout", rkt);
setVal("awg_reject_after_time", rja);
setVal("awg_keepalive_timeout", kpt);
setVal("awg_max_handshake_attempts", mha);
uci.set(UCI_PACKAGE, section_id, "awg_random_trailers", /^(1|true|yes|on)$/i.test(String(randomTrailers).trim()) ? "1" : "0");
uci.set(UCI_PACKAGE, section_id, "awg_disable_cookies", /^(1|true|yes|on)$/i.test(String(disableCookies).trim()) ? "1" : "0");
} else {
setVal("awg_rekey_after_time", "");
setVal("awg_rekey_timeout", "");
setVal("awg_reject_after_time", "");
setVal("awg_keepalive_timeout", "");
setVal("awg_max_handshake_attempts", "");
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_reject_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_keepalive_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_max_handshake_attempts");
uci.unset(UCI_PACKAGE, section_id, "awg_random_trailers");
uci.unset(UCI_PACKAGE, section_id, "awg_disable_cookies");
}
// --- [Peer] ---
const pub = getConfVal("PublicKey") || getConfVal("awg_peer_public_key");
if (pub) setVal("awg_peer_public_key", pub);
const psk = getConfVal("PresharedKey") || getConfVal("awg_preshared_key");
if (psk) setVal("awg_preshared_key", psk);
const ep = getConfVal("Endpoint");
if (ep) {
const i = ep.lastIndexOf(":");
if (i > 0) {
setVal("awg_server_address", ep.slice(0, i));
setVal("awg_server_port", ep.slice(i + 1));
}
}
setBtn("success", _("Loaded!"));
setTimeout(() => setBtn("", null), 2000);
ui.addNotification(
_("Done"),
E("p", {}, _("AmneziaWG config loaded successfully!")),
"success",
);
};
reader.onerror = () => {
setBtn("error", _("Read error"));
setTimeout(() => setBtn("", null), 2500);
};
reader.readAsText(file);
});
btn.addEventListener("click", () => fileInput.click());
return E("div", { style: "display:contents" }, [fileInput, btn]);
};
o = section.taboption(
"settings",
form.Value,
"awg_local_address",
_("Local Address"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "awg");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"awg_private_key",
_("Private Key"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "awg");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"awg_peer_public_key",
_("Peer Public Key"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "awg");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"awg_server_address",
_("Server Address"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "awg");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"awg_server_port",
_("Server Port"),
);
o.modalonly = true;
o.rmempty = false;
o.datatype = "port";
o.depends("action", "awg");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"awg_preshared_key",
_("Preshared Key"),
);
o.modalonly = true;
o.rmempty = true;
o.depends("action", "awg");
o = section.taboption("settings", form.Value, "awg_mtu", _("MTU"));
o.datatype = "uinteger";
o.placeholder = "1280";
o.modalonly = true;
o.rmempty = true;
o.depends("action", "awg");
o = section.taboption(
"settings",
form.Value,
"awg_keepalive",
_("Persistent Keepalive"),
);
o.placeholder = "25-35";
o.validate = function (_section_id, value) {
if (!value) return true;
if (/^\d+$/.test(value)) return true;
const m = value.match(/^(\d+)-(\d+)$/);
if (m && Number(m[1]) <= Number(m[2])) return true;
return _("Enter a number or range, e.g. 25 or 25-35");
};
o.modalonly = true;
o.rmempty = true;
o.depends("action", "awg");
o = section.taboption(
"settings",
form.ListValue,
"awg_version",
_("AmneziaWG Version"),
_("Protocol version: 2.0 (standard headers, WARP compatible), 3.0 (header protection key, padding ranges), or 3.1 (header protection key, padding ranges, advanced timers)"),
);
o.modalonly = true;
o.rmempty = false;
o.default = "2.0";
o.value("2.0", "AmneziaWG 2.0");
o.value("3.0", "AmneziaWG 3.0");
o.value("3.1", "AmneziaWG 3.1");
o.depends("action", "awg");
o.write = function (section_id, formvalue) {
if (formvalue === "2.0") {
uci.unset(UCI_PACKAGE, section_id, "awg_header_protection_key");
uci.unset(UCI_PACKAGE, section_id, "awg_content_padding_addition");
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_reject_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_keepalive_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_max_handshake_attempts");
uci.unset(UCI_PACKAGE, section_id, "awg_random_trailers");
uci.unset(UCI_PACKAGE, section_id, "awg_disable_cookies");
} else if (formvalue === "3.0") {
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_reject_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_keepalive_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_max_handshake_attempts");
uci.unset(UCI_PACKAGE, section_id, "awg_random_trailers");
uci.unset(UCI_PACKAGE, section_id, "awg_disable_cookies");
}
return uci.set(UCI_PACKAGE, section_id, "awg_version", formvalue);
};
// ── AmneziaWG obfuscation parameters (NATIVE LUCI CBI STYLE) ──────────────
const addAwgParam = (name, title, desc, def) => {
let opt = section.taboption("settings", form.Value, name, title, desc);
opt.modalonly = true;
opt.rmempty = false;
opt.default = def;
opt.depends("action", "awg");
return opt;
};
addAwgParam("awg_jc", _("Junk Packet Count (Jc)"), "", "120");
addAwgParam("awg_jmin", _("Junk Min Size (Jmin)"), "", "23");
addAwgParam("awg_jmax", _("Junk Max Size (Jmax)"), "", "911");
addAwgParam("awg_s1", _("Init Packet Magic Header (S1)"), "", "0");
addAwgParam("awg_s2", _("Response Packet Magic Header (S2)"), "", "0");
addAwgParam("awg_s3", _("Init Packet Magic Header (S3)"), "", "0");
addAwgParam("awg_s4", _("Response Packet Magic Header (S4)"), "", "0");
addAwgParam("awg_h1", _("Underload Packet Magic Header (H1)"), "", "1");
addAwgParam("awg_h2", _("Underload Packet Magic Header (H2)"), "", "2");
addAwgParam("awg_h3", _("Underload Packet Magic Header (H3)"), "", "3");
addAwgParam("awg_h4", _("Underload Packet Magic Header (H4)"), "", "4");
const addAwgPayloadParam = (name, title, desc, def) => {
let opt = section.taboption("settings", form.Value, name, title, desc);
opt.modalonly = true;
opt.rmempty = false;
opt.default = def;
opt.depends("action", "awg");
opt.cfgvalue = function (section_id) {
let val = uci.get(UCI_PACKAGE, section_id, name);
if (val === undefined || val === null || val === "") return def;
return cleanAwgPayload(String(val));
};
opt.write = function (section_id, formvalue) {
return uci.set(UCI_PACKAGE, section_id, name, cleanAwgPayload(formvalue));
};
return opt;
};
addAwgPayloadParam("awg_i1", _("Init Packet Payload (I1)"), "", "0");
addAwgPayloadParam("awg_i2", _("Init Packet Payload (I2)"), "", "0");
addAwgPayloadParam("awg_i3", _("Init Packet Payload (I3)"), "", "0");
addAwgPayloadParam("awg_i4", _("Init Packet Payload (I4)"), "", "0");
addAwgPayloadParam("awg_i5", _("Init Packet Payload (I5)"), "", "0");
// ── AmneziaWG 3.0 & 3.1 Advanced Parameters ───────────────────────────────
const addAwgV3Param = (name, title, desc) => {
let opt = section.taboption("settings", form.Value, name, title, desc);
opt.modalonly = true;
opt.rmempty = true;
opt.depends({ action: "awg", awg_version: "3.0" });
opt.depends({ action: "awg", awg_version: "3.1" });
return opt;
};
const addAwgV31Param = (name, title, desc) => {
let opt = section.taboption("settings", form.Value, name, title, desc);
opt.modalonly = true;
opt.rmempty = true;
opt.depends({ action: "awg", awg_version: "3.1" });
return opt;
};
addAwgV3Param("awg_header_protection_key", _("Header Protection Key"), _("AmneziaWG 3.0 / 3.1 header protection key"));
addAwgV3Param("awg_content_padding_addition", _("Content Padding Addition"), _("AmneziaWG 3.0 / 3.1 content padding range, e.g. 38-104"));
addAwgV31Param("awg_rekey_after_time", _("Rekey After Time"), _("AmneziaWG 3.1 rekey after time"));
addAwgV31Param("awg_rekey_timeout", _("Rekey Timeout"), _("AmneziaWG 3.1 rekey timeout"));
addAwgV31Param("awg_reject_after_time", _("Reject After Time"), _("AmneziaWG 3.1 reject after time"));
addAwgV31Param("awg_keepalive_timeout", _("Keepalive Timeout"), _("AmneziaWG 3.1 keepalive timeout"));
addAwgV31Param("awg_max_handshake_attempts", _("Max Handshake Attempts"), _("AmneziaWG 3.1 max handshake attempts"));
o = section.taboption(
"settings", form.Flag, "awg_random_trailers", _("Random Trailers"),
_("AmneziaWG 3.1 random packet trailers"),
);
o.modalonly = true;
o.rmempty = false;
o.default = "0";
o.depends({ action: "awg", awg_version: "3.1" });
o = section.taboption(
"settings", form.Flag, "awg_disable_cookies", _("Disable Cookies"),
_("AmneziaWG 3.1 disable cookie replies"),
);
o.modalonly = true;
o.rmempty = false;
o.default = "0";
o.depends({ action: "awg", awg_version: "3.1" });
// ── WARP (Cloudflare WARP via sing-box-extended) ──────────────────────────
o = section.taboption(
"settings",
form.Value,
"warp_private_key",
_("WARP Private Key"),
_("WireGuard private key from your Cloudflare WARP account"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "warp");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"warp_account_id",
_("WARP Account ID"),
_("Cloudflare WARP account / device ID"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "warp");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"warp_access_token",
_("WARP Access Token"),
_("Cloudflare WARP API access token"),
);
o.modalonly = true;
o.rmempty = true;
o.depends("action", "warp");
o = section.taboption(
"settings",
form.ListValue,
"warp_detour",
_("Registration Detour"),
_("Proxy to use for WARP API registration (api.cloudflareclient.com)"),
);
o.modalonly = true;
o.rmempty = true;
o.depends("action", "warp");
o.load = function (itemId) {
const choices = subscriptionDownloadTargetChoices(itemId);
choices.unshift({ value: "", label: _("Direct (Default)") });
refreshOptionChoices(this, choices);
return optionMapValue(this, itemId, "warp_detour") || "";
};
// ── AnyTLS (sing-box-extended) ────────────────────────────────────────────
o = section.taboption(
"settings",
form.Value,
"anytls_server",
_("Server Address"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "anytls");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"anytls_server_port",
_("Server Port"),
);
o.modalonly = true;
o.rmempty = false;
o.datatype = "port";
o.depends("action", "anytls");
o = section.taboption(
"settings",
form.Value,
"anytls_password",
_("Password"),
);
o.modalonly = true;
o.rmempty = false;
o.password = true;
o.depends("action", "anytls");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"anytls_sni",
_("TLS SNI"),
_("Server Name Indication — leave empty to use server address"),
);
o.modalonly = true;
o.rmempty = true;
o.depends("action", "anytls");
o = section.taboption(
"settings",
form.Flag,
"anytls_insecure",
_("Allow Insecure TLS"),
_("Skip TLS certificate verification (not recommended)"),
);
o.modalonly = true;
o.rmempty = true;
o.depends("action", "anytls");
// ── Snell (sing-box-extended) ─────────────────────────────────────────────
o = section.taboption(
"settings",
form.Value,
"snell_server",
_("Server Address"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "snell");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"snell_server_port",
_("Server Port"),
);
o.modalonly = true;
o.rmempty = false;
o.datatype = "port";
o.depends("action", "snell");
o = section.taboption(
"settings",
form.Value,
"snell_psk",
_("PSK"),
_("Pre-shared key"),
);
o.modalonly = true;
o.rmempty = false;
o.password = true;
o.depends("action", "snell");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.ListValue,
"snell_version",
_("Snell Version"),
);
o.modalonly = true;
o.rmempty = true;
o.value("4", "v4");
o.value("3", "v3");
o.value("2", "v2");
o.value("1", "v1");
o.default = "4";
o.depends("action", "snell");
// ── Mieru (sing-box-extended) ─────────────────────────────────────────────
o = section.taboption(
"settings",
form.Value,
"mieru_server",
_("Server Address"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "mieru");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"mieru_server_port",
_("Server Port"),
);
o.modalonly = true;
o.rmempty = false;
o.datatype = "port";
o.depends("action", "mieru");
o = section.taboption(
"settings",
form.ListValue,
"mieru_transport",
_("Transport"),
);
o.modalonly = true;
o.rmempty = true;
o.value("TCP", "TCP");
o.value("UDP", "UDP");
o.default = "TCP";
o.depends("action", "mieru");
o = section.taboption(
"settings",
form.Value,
"mieru_username",
_("Username"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "mieru");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"mieru_password",
_("Password"),
);
o.modalonly = true;
o.rmempty = false;
o.password = true;
o.depends("action", "mieru");
o.validate = validateRequiredText;
// ── Sudoku (sing-box-extended) ────────────────────────────────────────────
o = section.taboption(
"settings",
form.Value,
"sudoku_server",
_("Server Address"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "sudoku");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"sudoku_server_port",
_("Server Port"),
);
o.modalonly = true;
o.rmempty = false;
o.datatype = "port";
o.depends("action", "sudoku");
o = section.taboption("settings", form.Value, "sudoku_key", _("Key"));
o.modalonly = true;
o.rmempty = false;
o.password = true;
o.depends("action", "sudoku");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"sudoku_aead_method",
_("AEAD Method"),
_("Optional — leave empty for default"),
);
o.modalonly = true;
o.rmempty = true;
o.depends("action", "sudoku");
// ── MASQUE (Cloudflare MASQUE via sing-box-extended) ──────────────────────
o = section.taboption(
"settings",
form.Value,
"masque_private_key",
_("MASQUE Private Key"),
_("WireGuard private key from your Cloudflare account"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "masque");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"masque_account_id",
_("MASQUE Account ID"),
_("Cloudflare account / device ID"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "masque");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"masque_access_token",
_("MASQUE Access Token"),
_("Cloudflare API access token"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "masque");
o.validate = validateRequiredText;
// ── OpenVPN (sing-box-extended) ───────────────────────────────────────────
o = section.taboption(
"settings",
form.Value,
"openvpn_server",
_("Server Address"),
);
o.modalonly = true;
o.rmempty = false;
o.depends("action", "openvpn");
o.validate = validateRequiredText;
o = section.taboption(
"settings",
form.Value,
"openvpn_server_port",
_("Server Port"),
);
o.modalonly = true;
o.rmempty = false;
o.datatype = "port";
o.placeholder = "1194";
o.depends("action", "openvpn");
o = section.taboption(
"settings",
form.ListValue,
"openvpn_proto",
_("Protocol"),
);
o.modalonly = true;
o.rmempty = true;
o.value("udp", "UDP");
o.value("tcp", "TCP");
o.default = "udp";
o.depends("action", "openvpn");
o = section.taboption(
"settings",
form.Value,
"openvpn_cipher",
_("Cipher"),
_("e.g. AES-256-GCM — leave empty for default"),
);
o.modalonly = true;
o.rmempty = true;
o.depends("action", "openvpn");
o = section.taboption(
"settings",
form.Value,
"openvpn_auth",
_("Auth Digest"),
_("e.g. SHA256 — leave empty for default"),
);
o.modalonly = true;
o.rmempty = true;
o.depends("action", "openvpn");
o = section.taboption(
"settings",
form.TextValue,
"openvpn_ca",
_("CA Certificate"),
_("PEM-encoded CA certificate"),
);
o.modalonly = true;
o.rmempty = true;
o.rows = 6;
o.depends("action", "openvpn");
o = section.taboption(
"settings",
form.TextValue,
"openvpn_cert",
_("Client Certificate"),
_("PEM-encoded client certificate"),
);
o.modalonly = true;
o.rmempty = true;
o.rows = 6;
o.depends("action", "openvpn");
o = section.taboption(
"settings",
form.TextValue,
"openvpn_key",
_("Client Key"),
_("PEM-encoded client private key"),
);
o.modalonly = true;
o.rmempty = true;
o.rows = 6;
o.depends("action", "openvpn");
o = section.taboption(
"settings",
form.TextValue,
"openvpn_tls_auth",
_("TLS Auth/Crypt Key"),
_("Optional tls-auth or tls-crypt key"),
);
o.modalonly = true;
o.rmempty = true;
o.rows = 6;
o.depends("action", "openvpn");
o = section.taboption(
"settings",
form.DynamicList,
"selector_proxy_links",
_("Connection URL"),
_(
"vless://, vmess://, ss://, trojan://, socks4/5://, http/s://, hy2/hysteria2:// links",
),
);
o.depends("action", "connection");
o.rmempty = true;
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrl(value);
return validation.valid ? true : validation.message;
};
o.onchange = function (_event, section_id) {
refreshDashboardFilterChoiceWidgets(section_id);
};
outboundNameSourceOptions.set("selector_proxy_links", o);
o = section.taboption(
"settings",
SettingsDynamicList,
"subscription_url",
_("Subscription URL"),
_("Enter the subscription URL"),
);
o.depends("action", "connection");
o.rmempty = true;
o.modalonly = true;
o.childType = "subscription_url";
o.childValueOption = "url";
o.childDefaults = defaultSubscriptionUrlSettings();
o.renderItemSettingsModal = showSubscriptionUrlSettingsModal;
o.hasItemSettings = function (section_id, value) {
const normalized = `${value || ""}`.trim();
if (isExistingChildItem(section_id, normalized, "subscription_url")) {
return true;
}
return this.validate(section_id, normalized) === true;
};
o.stagedChildSettings = function (section_id, value) {
const inputValue = childItemInputValue(
section_id,
value,
"subscription_url",
"url",
);
const store = childPendingSettingsStore(this, section_id);
return store[inputValue] ? Object.assign({}, store[inputValue]) : null;
};
o.clearStagedChildSettings = function (section_id) {
if (this.pendingChildSettings) {
delete this.pendingChildSettings[section_id];
}
};
o.renderListItemLabel = function (section_id, itemId) {
return childItemInputValue(section_id, itemId, "subscription_url", "url");
};
o.validate = validateSubscriptionUrlEntry;
o.validate = function (section_id, value) {
return validateSubscriptionUrlEntry(
section_id,
childItemInputValue(section_id, value, "subscription_url", "url"),
);
};
o = section.taboption(
"settings",
InterfaceSettingsDynamicList,
"interfaces",
_("Network Interface"),
_("Select network interface for VPN connection"),
);
o.depends("action", "connection");
o.rmempty = true;
o.modalonly = true;
o.placeholder = _("Select a network interface");
o.childType = "section_interface";
o.childValueOption = "name";
o.childDefaults = defaultInterfaceSettings();
o.renderItemSettingsModal = showInterfaceSettingsModal;
o.hasItemSettings = function (section_id, value) {
const normalized = `${value || ""}`.trim();
if (isExistingChildItem(section_id, normalized, "section_interface")) {
return true;
}
return this.validate(section_id, normalized) === true;
};
o.stagedChildSettings = function (section_id, value) {
const inputValue = childItemInputValue(
section_id,
value,
"section_interface",
"name",
);
const store = childPendingSettingsStore(this, section_id);
return store[inputValue] ? Object.assign({}, store[inputValue]) : null;
};
o.clearStagedChildSettings = function (section_id) {
if (this.pendingChildSettings) {
delete this.pendingChildSettings[section_id];
}
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
outboundNameSourceOptions.set("interfaces", o);
o = section.taboption(
"settings",
ButtonAddSettingsDynamicList,
"outbound_jsons",
_("JSON outbound"),
_("Custom outbound configurations in JSON format"),
);
o.depends("action", "connection");
o.rmempty = true;
o.modalonly = true;
o.addButtonLabel = _("+ Add JSON outbound");
o.renderItemSettingsModal = showOutboundJsonSettingsModal;
o.renderListItemLabel = function (_section_id, value) {
return outboundJsonListItemLabel(value);
};
o.validateItemsOnSave = validateOutboundJsonItemsBeforeSave;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return true;
}
const validation = main.validateOutboundJson(value);
return validation.valid ? true : validation.message;
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
outboundNameSourceOptions.set("outbound_jsons", o);
o = section.taboption(
"settings",
ButtonAddSettingsDynamicList,
"urltest",
_("URLTest"),
_("Server group for automatic lowest-latency selection"),
);
o.depends("action", "connection");
o.rmempty = true;
o.modalonly = true;
o.addButtonLabel = _("+ Add URLTest");
o.childType = "urltest";
o.childValueOption = "name";
o.childDefaults = urlTestChildDefaults();
o.renderItemSettingsModal = showUrlTestSettingsModal;
o.validateItemsOnSave = function (section_id, values) {
return validateUrlTestItemsBeforeSave(section_id, values, this);
};
o.hasItemSettings = function (section_id, value) {
const normalized = `${value || ""}`.trim();
if (isExistingChildItem(section_id, normalized, "urltest")) {
return true;
}
return normalized.length > 0;
};
o.inputValueForItem = function (section_id, value) {
const inputValue = childItemInputValue(
section_id,
value,
"urltest",
"name",
);
const store = childPendingSettingsStore(this, section_id);
return store[inputValue] && store[inputValue].name
? store[inputValue].name
: inputValue;
};
o.stagedChildSettings = function (section_id, value) {
const id = childItemInputValue(section_id, value, "urltest", "name");
const store = childPendingSettingsStore(this, section_id);
return store[id] ? Object.assign({}, store[id]) : null;
};
o.clearStagedChildSettings = function (section_id) {
if (this.pendingChildSettings) {
delete this.pendingChildSettings[section_id];
}
};
o.renderListItemLabel = function (section_id, itemId) {
return E(
"span",
{ class: "fkp-dynlist-label" },
this.inputValueForItem(section_id, itemId),
);
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
sectionGroupSourceOptions.set("urltest", o);
o = section.taboption(
"settings",
ButtonAddSettingsDynamicList,
"priority_group",
_("Priority"),
_("Server group for priority failover"),
);
o.depends("action", "connection");
o.rmempty = true;
o.modalonly = true;
o.addButtonLabel = _("+ Add priority");
o.childType = "priority_group";
o.childValueOption = "name";
o.childDefaults = priorityGroupChildDefaults();
o.createId = () => randomPriorityGroupId();
o.renderItemSettingsModal = showPriorityGroupSettingsModal;
o.validateItemsOnSave = validatePriorityGroupItemsBeforeSave;
o.hasItemSettings = function (section_id, value) {
const normalized = `${value || ""}`.trim();
if (isExistingChildItem(section_id, normalized, "priority_group")) {
return true;
}
return normalized.length > 0;
};
o.inputValueForItem = function (section_id, value) {
return childItemInputValue(section_id, value, "priority_group", "name");
};
o.renderListItemLabel = function (section_id, itemId) {
return E(
"span",
{ class: "fkp-dynlist-label" },
this.inputValueForItem(section_id, itemId),
);
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
sectionGroupSourceOptions.set("priority_group", o);
o = section.taboption(
"settings",
form.Flag,
"outbound_detour_enabled",
_("Cascade connection"),
_(
"Use another section as an intermediate hop to connect to servers in this section. Does not apply to network interfaces or JSON outbounds.",
),
);
o.default = "0";
o.rmempty = false;
o.depends("action", "connection");
o.modalonly = true;
o.write = function (section_id, value) {
if (value === "1") {
const currentValue =
uci.get(UCI_PACKAGE, section_id, "outbound_detour_section") || "";
const targetSections = getOutboundDetourTargetSections(section_id);
const currentIsValid = targetSections.some(
(targetSection) => getUciSectionName(targetSection) === currentValue,
);
const selectedValue = currentIsValid
? currentValue
: getDefaultOutboundDetourSection(section_id);
if (selectedValue) {
uci.set(
UCI_PACKAGE,
section_id,
"outbound_detour_section",
selectedValue,
);
}
}
return form.Flag.prototype.write.apply(this, arguments);
};
o = section.taboption(
"settings",
form.ListValue,
"outbound_detour_section",
_("Connect through"),
_("Select a transit section"),
);
o.rmempty = false;
o.depends({ action: "connection", outbound_detour_enabled: "1" });
o.modalonly = true;
o.load = function (section_id) {
refreshOutboundDetourSectionOptionValues(this, section_id);
return Promise.resolve(
uci.get(UCI_PACKAGE, section_id, "outbound_detour_section") || "",
);
};
o.validate = function (section_id, value) {
if (!value) {
return _("Select an intermediate section");
}
if (value === section_id) {
return _("Current section cannot be used as its own transit section");
}
return true;
};
o = section.taboption(
"settings",
form.Flag,
"sort_by_latency",
_("Sort by latency"),
_("Sorts servers in this section by lowest latency in the dashboard."),
);
o.default = "0";
o.rmempty = false;
o.depends("action", "connection");
o.modalonly = true;
o = section.taboption(
"settings",
form.Flag,
"mixed_proxy_enabled",
_("Enable Mixed Proxy"),
_("Expose this section as a local HTTP+SOCKS proxy"),
);
o.default = "0";
o.rmempty = false;
o.depends("action", "connection");
o.depends("action", "byedpi");
o.depends("action", "zapret");
o.depends("action", "zapret2");
o.modalonly = true;
o = section.taboption(
"settings",
form.Value,
"mixed_proxy_port",
_("Mixed Proxy Port"),
_("Port for the local mixed proxy of this section"),
);
o.rmempty = false;
o.depends({ action: "connection", mixed_proxy_enabled: "1" });
o.depends({ action: "byedpi", mixed_proxy_enabled: "1" });
o.depends({ action: "zapret", mixed_proxy_enabled: "1" });
o.depends({ action: "zapret2", mixed_proxy_enabled: "1" });
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return _("Port cannot be empty");
}
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed >= 1 && parsed <= 65535) {
return true;
}
return _("Invalid port number. Must be between 1 and 65535");
};
o = section.taboption(
"settings",
form.Flag,
"mixed_proxy_auth_enabled",
_("Enable Mixed Proxy Authentication"),
_("Require a username and password for the local mixed proxy"),
);
o.default = "0";
o.rmempty = false;
o.depends({ action: "connection", mixed_proxy_enabled: "1" });
o.depends({ action: "byedpi", mixed_proxy_enabled: "1" });
o.depends({ action: "zapret", mixed_proxy_enabled: "1" });
o.depends({ action: "zapret2", mixed_proxy_enabled: "1" });
o.modalonly = true;
o = section.taboption(
"settings",
form.Value,
"mixed_proxy_username",
_("Mixed Proxy Username"),
);
o.rmempty = false;
o.depends({
action: "connection",
mixed_proxy_enabled: "1",
mixed_proxy_auth_enabled: "1",
});
o.depends({
action: "byedpi",
mixed_proxy_enabled: "1",
mixed_proxy_auth_enabled: "1",
});
o.depends({
action: "zapret",
mixed_proxy_enabled: "1",
mixed_proxy_auth_enabled: "1",
});
o.depends({
action: "zapret2",
mixed_proxy_enabled: "1",
mixed_proxy_auth_enabled: "1",
});
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return _("Username cannot be empty");
}
return true;
};
o = section.taboption(
"settings",
form.Value,
"mixed_proxy_password",
_("Mixed Proxy Password"),
);
o.rmempty = false;
o.depends({
action: "connection",
mixed_proxy_enabled: "1",
mixed_proxy_auth_enabled: "1",
});
o.depends({
action: "byedpi",
mixed_proxy_enabled: "1",
mixed_proxy_auth_enabled: "1",
});
o.depends({
action: "zapret",
mixed_proxy_enabled: "1",
mixed_proxy_auth_enabled: "1",
});
o.depends({
action: "zapret2",
mixed_proxy_enabled: "1",
mixed_proxy_auth_enabled: "1",
});
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value || value.length === 0) {
return _("Password cannot be empty");
}
return true;
};
o = section.taboption(
"settings",
form.Flag,
"resolve_real_ip_for_routing",
_("Resolve real IP for routing"),
_(
"Resolve domain names before routing so sing-box can use real destination IPs.",
),
);
o.default = "0";
o.rmempty = false;
o.depends("action", "connection");
o.modalonly = true;
o.cfgvalue = function (section_id) {
const value = uci.get(
UCI_PACKAGE,
section_id,
"resolve_real_ip_for_routing",
);
if (value !== null && value !== undefined && value !== "") {
return value;
}
return getRuleResolvedAction(section_id) === "byedpi" ? "1" : "0";
};
let geoipModeOption = section.taboption(
"conditions",
form.ListValue,
"geoip_mode",
_("GeoIP Mode"),
_(
"Choose whether to route traffic to all destinations except selected countries (Exclude) or only to selected countries (Include).",
),
);
geoipModeOption.value("exclude", _("Exclude selected countries"));
geoipModeOption.value("include", _("Include selected countries"));
geoipModeOption.default = "exclude";
geoipModeOption.modalonly = true;
[
"connection",
"awg",
"warp",
"anytls",
"snell",
"mieru",
"sudoku",
"masque",
"openvpn",
"zapret",
"zapret2",
"byedpi",
].forEach((act) => geoipModeOption.depends("action", act));
o = section.taboption(
"conditions",
form.DynamicList,
"geoip_country",
_("GeoIP Countries"),
_(
"Select one or more country ISO codes (e.g. ru, us, de, nl, fr, fi, se, tr, cn, jp, sg, by, kz, ua).",
),
);
o.value("ru", _("Russia (ru)"));
o.value("us", _("United States (us)"));
o.value("de", _("Germany (de)"));
o.value("nl", _("Netherlands (nl)"));
o.value("gb", _("United Kingdom (gb)"));
o.value("fr", _("France (fr)"));
o.value("fi", _("Finland (fi)"));
o.value("se", _("Sweden (se)"));
o.value("tr", _("Turkey (tr)"));
o.value("cn", _("China (cn)"));
o.value("jp", _("Japan (jp)"));
o.value("sg", _("Singapore (sg)"));
o.value("by", _("Belarus (by)"));
o.value("kz", _("Kazakhstan (kz)"));
o.value("ua", _("Ukraine (ua)"));
o.placeholder = _("Country code (e.g. ru, us, de)");
o.modalonly = true;
o.load = function (section_id) {
let val = uci.get(UCI_PACKAGE, section_id, "geoip_country");
if (!val || val === "all") return [];
if (val === "non-ru") return ["ru"];
if (Array.isArray(val))
return val.filter((v) => v && v !== "all" && v !== "non-ru");
let parts = String(val)
.trim()
.split(/\s+/)
.filter((v) => v && v !== "all" && v !== "non-ru");
return parts;
};
[
"connection",
"awg",
"warp",
"anytls",
"snell",
"mieru",
"sudoku",
"masque",
"openvpn",
"zapret",
"zapret2",
"byedpi",
].forEach((act) => o.depends("action", act));
const domainConditionOption = addTextConditionField(section, {
key: "domain_suffix",
optionName: "domain",
legacyTextOptionName: "domain_suffix_text",
label: _("Domains"),
description: _(
"The rule applies to the domain and all its subdomains. Use full:, keyword:, or regex: prefixes for exact match, keyword match, or regular expression.",
),
textAnalyze: analyzeDomainSuffixText,
loadText: loadCombinedDomainText,
afterWrite: function (section_id) {
[
"domain_suffix",
"domain_suffix_text",
"domain_suffix_text_mode",
"domain_keyword",
"domain_regex",
"domain_text",
"domain_keyword_text",
"domain_regex_text",
"domain_text_mode",
"domain_keyword_text_mode",
"domain_regex_text_mode",
].forEach((key) => {
uci.unset(UCI_PACKAGE, section_id, key);
});
},
});
const userDomainsConditionOption = addTextConditionField(section, {
key: "user_domains",
optionName: "user_domains",
legacyTextOptionName: "user_domains_text",
label: _("Auto-detected Domains"),
description: _(
"Domains automatically detected by Smart Detect or added via Telegram bot. You can manually edit or remove them here.",
),
textAnalyze: analyzeDomainSuffixText,
});
const ipConditionOption = addTextConditionField(section, {
key: "ip_cidr",
optionName: "ip_cidr",
legacyTextOptionName: "ip_cidr_text",
label: _("IPs"),
description: _("Match destination IPs or subnets"),
textAnalyze: analyzeIpCidrText,
});
dependsOnRoutingAction(ipConditionOption);
const builtInRulesetOption = section.taboption(
"conditions",
form.DynamicList,
"community_lists",
_("Built-in rule sets"),
_("Select a predefined domain list") +
' (source: <a href="https://github.com/itdoginfo/allow-domains" target="_blank" rel="noopener noreferrer">itdoginfo/allow-domains</a>)',
);
builtInRulesetOption.modalonly = true;
builtInRulesetOption.placeholder = _("Service list");
dependsOnRoutingAction(builtInRulesetOption);
builtInRulesetOption.depends("action", "dns");
builtInRulesetOption.load = function (section_id) {
loadRulesetValues(this);
return getBuiltInRulesetReferences(section_id);
};
builtInRulesetOption.write = function (section_id, values) {
writeBuiltInRulesetReferences(section_id, values);
};
builtInRulesetOption.remove = function (section_id) {
uci.unset(UCI_PACKAGE, section_id, "community_lists");
};
let isProcessingBuiltIns = false;
builtInRulesetOption.onchange = function (_ev, section_id, value) {
if (isProcessingBuiltIns) {
return;
}
isProcessingBuiltIns = true;
try {
const values = Array.isArray(value)
? value.filter(Boolean)
: value
? [value]
: [];
let newValues = [...values];
const notifications = [];
if (
JSON.stringify(newValues.slice().sort()) !==
JSON.stringify(values.slice().sort())
) {
this.getUIElement(section_id).setValue(newValues);
}
notifications.forEach((notification) =>
ui.addNotification(null, notification),
);
} finally {
isProcessingBuiltIns = false;
}
};
const ruleSetOption = section.taboption(
"conditions",
SettingsDynamicList,
"rule_set",
_("Rule sets"),
_(
"Add URLs or local paths to .srs / .json lists. Subnets are ignored by default.",
),
);
ruleSetOption.modalonly = true;
// Both widgets map to rule_set, so neither inactive view may erase shared storage.
ruleSetOption.retain = true;
dependsOnRoutingAction(ruleSetOption);
ruleSetOption.renderItemSettingsModal = showRuleSetSettingsModal;
ruleSetOption.load = function (section_id) {
return getCustomRulesetReferences(section_id);
};
ruleSetOption.write = function (section_id, value) {
writeCustomRulesetReferences(section_id, value);
};
ruleSetOption.remove = function (section_id) {
uci.unset(UCI_PACKAGE, section_id, "rule_set");
uci.unset(UCI_PACKAGE, section_id, "rule_set_with_subnets");
uci.unset(UCI_PACKAGE, section_id, RULE_SET_ITEM_SETTINGS_KEY);
};
ruleSetOption.validate = function (section_id, value) {
return validateCustomRulesetReference(value);
};
const dnsRuleSetOption = section.taboption(
"conditions",
form.DynamicList,
"_dns_rule_set",
_("Rule sets"),
_(
"Add URLs or local paths to .srs / .json lists. Only domain rules are supported.",
),
);
dnsRuleSetOption.depends("action", "dns");
dnsRuleSetOption.modalonly = true;
dnsRuleSetOption.retain = true;
dnsRuleSetOption.load = function (section_id) {
return getCustomRulesetReferences(section_id);
};
dnsRuleSetOption.write = function (section_id, value) {
writeDnsRulesetReferences(section_id, value);
};
dnsRuleSetOption.remove = function (section_id) {
writeDnsRulesetReferences(section_id, []);
};
dnsRuleSetOption.validate = function (_section_id, value) {
return validateCustomRulesetReference(value);
};
const domainIpListsOption = section.taboption(
"conditions",
form.DynamicList,
"domain_ip_lists",
_("Domain and IP lists"),
_("Add URLs or local paths to .lst lists containing domains and subnets."),
);
domainIpListsOption.modalonly = true;
// Both widgets map to domain_ip_lists, so neither inactive view may erase shared storage.
domainIpListsOption.retain = true;
dependsOnRoutingAction(domainIpListsOption);
domainIpListsOption.load = function (section_id) {
return getConfigListValues(section_id, "domain_ip_lists");
};
domainIpListsOption.write = function (section_id, value) {
writeListOption(section_id, "domain_ip_lists", value);
};
domainIpListsOption.remove = function (section_id) {
uci.unset(UCI_PACKAGE, section_id, "domain_ip_lists");
};
domainIpListsOption.validate = function (_section_id, value) {
return validatePlainListReference(value);
};
const dnsDomainListsOption = section.taboption(
"conditions",
form.DynamicList,
"_dns_domain_ip_lists",
_("Domain lists"),
_(
"Add URLs or local paths to .lst lists containing domains. IP entries are ignored.",
),
);
dnsDomainListsOption.depends("action", "dns");
dnsDomainListsOption.modalonly = true;
dnsDomainListsOption.retain = true;
dnsDomainListsOption.load = function (section_id) {
return getConfigListValues(section_id, "domain_ip_lists");
};
dnsDomainListsOption.write = function (section_id, value) {
writeListOption(section_id, "domain_ip_lists", value);
};
dnsDomainListsOption.remove = function (section_id) {
uci.unset(UCI_PACKAGE, section_id, "domain_ip_lists");
};
dnsDomainListsOption.validate = function (_section_id, value) {
return validatePlainListReference(value);
};
const sourceIpOption = addLocalDeviceSubnetDynamicField(section, {
key: "source_ip_cidr",
label: _("Device filter"),
description: _(
"Apply section rules only to the specified local IP addresses",
),
});
dependsOnRuleConditions(sourceIpOption);
const fullyRoutedOption = addLocalDeviceSubnetDynamicField(section, {
key: "fully_routed_ips",
label: _("Forced device routing"),
description: _(
"All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions.",
),
});
dependsOnRoutingAction(fullyRoutedOption);
fullyRoutedOption.depends("action", "dns");
makeDeviceOptionsExclusive(sourceIpOption, fullyRoutedOption);
const excludedIpsOption = addLocalDeviceSubnetDynamicField(section, {
key: "excluded_ips",
label: _("Excluded devices"),
description: _(
"Traffic from these IP addresses will never be routed through this section, even if other conditions match.",
),
});
dependsOnRoutingAction(excludedIpsOption);
const portsOption = addDynamicConditionField(section, {
key: "ports",
label: _("Ports"),
description: _("Match destination ports. Use a single port or a range"),
dynamicValidate: validatePortCondition,
});
dependsOnRoutingAction(portsOption);
const dscpOption = addDynamicConditionField(section, {
key: "dscp",
label: _("DSCP marks (QoS)"),
description: _(
"Match traffic by packet DSCP mark (assigned in Windows via Policy-Based QoS). Examples: 46 (EF), 0x2e, 34 (AF41)",
),
});
dependsOnRoutingAction(dscpOption);
const excludedProtocolOption = section.taboption(
"conditions",
form.MultiValue,
"excluded_protocol",
_("Exclude traffic types"),
_(
"Traffic of specified protocols (e.g. BitTorrent, QUIC, STUN) will bypass this section.",
),
);
excludedProtocolOption.value("bittorrent", _("BitTorrent / P2P"));
excludedProtocolOption.value("quic", _("QUIC (HTTP/3)"));
excludedProtocolOption.value("stun", _("STUN (P2P Calls)"));
excludedProtocolOption.value("dns", _("DNS"));
excludedProtocolOption.value("http", _("HTTP"));
excludedProtocolOption.value("tls", _("TLS / HTTPS"));
excludedProtocolOption.value("ssh", _("SSH"));
excludedProtocolOption.value("rdp", _("RDP"));
excludedProtocolOption.modalonly = true;
excludedProtocolOption.rmempty = true;
dependsOnRoutingAction(excludedProtocolOption);
const protocolOption = section.taboption(
"conditions",
form.MultiValue,
"protocol",
_("Traffic type filter"),
_("Apply rules of this section only to selected protocols."),
);
protocolOption.value("bittorrent", _("BitTorrent / P2P"));
protocolOption.value("quic", _("QUIC (HTTP/3)"));
protocolOption.value("stun", _("STUN (P2P Calls)"));
protocolOption.value("dns", _("DNS"));
protocolOption.value("http", _("HTTP"));
protocolOption.value("tls", _("TLS / HTTPS"));
protocolOption.value("ssh", _("SSH"));
protocolOption.value("rdp", _("RDP"));
protocolOption.modalonly = true;
protocolOption.rmempty = true;
dependsOnRoutingAction(protocolOption);
addDashboardServerFilterOptions(section);
}
function loadSectionTableOptions(sectionRef) {
const sectionIds = sectionRef.cfgsections();
const tasks = [];
for (let i = 0; i < sectionIds.length; i += 1) {
const sectionId = sectionIds[i];
for (let j = 0; j < sectionRef.children.length; j += 1) {
const option = sectionRef.children[j];
if (option.disable || option.modalonly) {
continue;
}
tasks.push(
Promise.resolve(option.load.call(option, sectionId)).then((value) => {
option.cfgvalue(sectionId, value);
}),
);
}
}
return Promise.all(tasks);
}
const ACTION_COLORS = {
connection: "#2980b9",
proxy: "#2980b9",
outbound: "#3498db",
vpn: "#1abc9c",
bypass: "#27ae60",
block: "#c0392b",
zapret: "#8e44ad",
zapret2: "#9b59b6",
byedpi: "#d35400",
awg: "#008080",
warp: "#e67e22",
anytls: "#16a085",
snell: "#2c3e50",
mieru: "#34495e",
sudoku: "#7f8c8d",
masque: "#8e44ad",
openvpn: "#e74c3c",
hosts: "#2ecc71",
};
function ip4ToInt(ip) {
return (
ip
.split(".")
.reduce((int, octet) => (int << 8) + parseInt(octet, 10), 0) >>> 0
);
}
function ipMatchesCidr(ip, cidr) {
const parts = cidr.split("/");
const subnet = parts[0];
const bits = parts[1] ? parseInt(parts[1], 10) : 32;
if (bits === 0) return true;
const ipInt = ip4ToInt(ip);
const subnetInt = ip4ToInt(subnet);
const mask = (~0 << (32 - bits)) >>> 0;
return (ipInt & mask) === (subnetInt & mask);
}
function matchIpInCidrs(ip, cidrs) {
for (let i = 0; i < cidrs.length; i++) {
const cidr = cidrs[i];
if (!cidr) continue;
try {
if (ipMatchesCidr(ip, cidr)) {
return cidr;
}
} catch (e) {}
}
return null;
}
function matchIpInRuleset(ip, ruleset) {
if (!ruleset || !Array.isArray(ruleset.rules)) return null;
for (const rule of ruleset.rules) {
if (Array.isArray(rule.ip_cidr)) {
const matched = matchIpInCidrs(ip, rule.ip_cidr);
if (matched) {
return matched;
}
}
}
return null;
}
function matchDomainInList(domain, list) {
domain = domain.toLowerCase();
for (let i = 0; i < list.length; i++) {
const item = list[i].trim().toLowerCase();
if (!item) continue;
if (item.startsWith("full:")) {
const target = item.substring(5);
if (domain === target) return item;
} else if (item.startsWith("keyword:")) {
const target = item.substring(8);
if (domain.indexOf(target) !== -1) return item;
} else if (item.startsWith("regex:")) {
const target = item.substring(6);
try {
const re = new RegExp(target);
if (re.test(domain)) return item;
} catch (e) {}
} else {
if (domain === item || domain.endsWith("." + item)) {
return item;
}
}
}
return null;
}
function matchDomainInRuleset(domain, ruleset) {
if (!ruleset || !Array.isArray(ruleset.rules)) return null;
domain = domain.toLowerCase();
for (const rule of ruleset.rules) {
if (Array.isArray(rule.domain)) {
for (let i = 0; i < rule.domain.length; i++) {
const d = rule.domain[i];
if (d.toLowerCase() === domain) return `domain:${d}`;
}
}
if (Array.isArray(rule.domain_suffix)) {
for (let i = 0; i < rule.domain_suffix.length; i++) {
const ds = rule.domain_suffix[i];
const dsLower = ds.toLowerCase();
if (domain === dsLower || domain.endsWith("." + dsLower)) {
return `domain_suffix:${ds}`;
}
}
}
if (Array.isArray(rule.domain_keyword)) {
for (let i = 0; i < rule.domain_keyword.length; i++) {
const dk = rule.domain_keyword[i];
if (domain.indexOf(dk.toLowerCase()) !== -1) {
return `domain_keyword:${dk}`;
}
}
}
if (Array.isArray(rule.domain_regex)) {
for (let i = 0; i < rule.domain_regex.length; i++) {
const dr = rule.domain_regex[i];
try {
const re = new RegExp(dr);
if (re.test(domain)) return `domain_regex:${dr}`;
} catch (e) {}
}
}
}
return null;
}
function matchPort(port, portsList) {
const queryPort = parseInt(port, 10);
if (isNaN(queryPort)) return null;
for (let i = 0; i < portsList.length; i++) {
const item = portsList[i];
if (!item) continue;
if (item.indexOf("-") !== -1) {
const parts = item.split("-");
const start = parseInt(parts[0], 10);
const end = parseInt(parts[1], 10);
if (
!isNaN(start) &&
!isNaN(end) &&
queryPort >= start &&
queryPort <= end
) {
return item;
}
} else {
const p = parseInt(item, 10);
if (p === queryPort) return item;
}
}
return null;
}
function detectQueryType(query) {
query = query.trim();
if (/^\d+$/.test(query)) {
return "port";
}
if (/^[\d./]+$/.test(query) && query.indexOf(".") !== -1) {
return "ip";
}
if (query.indexOf(":") !== -1) {
return "ip";
}
return "domain";
}
async function readRulesetFile(path) {
try {
// fs.read is the LuCI RPC file-read — requires ACL entry for /tmp/sing-box/rulesets/*
let content = await fs.read(path).catch(() => null);
if (!content) {
// Fallback: read via exec cat (runs as root, always has access)
const res = await fs.exec("/bin/cat", [path]).catch(() => null);
if (res && res.code === 0 && res.stdout) {
content = res.stdout;
}
}
if (!content) return null;
return JSON.parse(content);
} catch (e) {
console.warn("Failed to parse ruleset file JSON:", path, e);
return null;
}
}
async function findCachedSrsFile(secName, community) {
const tmpSrs = `/tmp/sing-box/rulesets/community-${community}.srs`;
const etcSrs = `/etc/tachyon/rulesets/community-${community}.srs`;
let tmpStat = await fs.stat(tmpSrs).catch(() => null);
if (tmpStat && tmpStat.type === "file") {
return tmpSrs;
}
let etcStat = await fs.stat(etcSrs).catch(() => null);
if (etcStat && etcStat.type === "file") {
return etcSrs;
}
return null;
}
async function matchCommunityList(secName, community, query, type) {
const srsPath = await findCachedSrsFile(secName, community);
if (!srsPath) return false;
const checkMatch = (res) => {
if (!res || res.code !== 0) return false;
const output = (res.stdout || "") + (res.stderr || "");
return output.trim() !== "";
};
// Try /usr/bin/sing-box first
let res = await fs
.exec("/usr/bin/sing-box", [
"rule-set",
"match",
srsPath,
query,
"-f",
"binary",
])
.catch(() => null);
if (checkMatch(res)) return true;
// Fallback to /usr/sbin/sing-box
res = await fs
.exec("/usr/sbin/sing-box", [
"rule-set",
"match",
srsPath,
query,
"-f",
"binary",
])
.catch(() => null);
if (checkMatch(res)) return true;
return false;
}
async function performTrace(query) {
query = (query || "").trim();
if (!query) return { matched: false };
// Strip protocol and port if user pasted a URL or domain:port
if (query.match(/^https?:\/\//i)) {
query = query
.replace(/^https?:\/\//i, "")
.split("/")[0]
.split(":")[0];
}
const type = detectQueryType(query);
const sections = uci.sections(UCI_PACKAGE, "section") || [];
const totalSections = sections.length;
let queryForMatching = query;
if (type === "ip" && query.indexOf("/") !== -1) {
queryForMatching = query.split("/")[0];
}
// Check DNS Detour from settings if enabled
const dnsDetourEnabled = uci.get(UCI_PACKAGE, "settings", "dns_detour_enabled");
if (dnsDetourEnabled === "1") {
const detourSection = uci.get(UCI_PACKAGE, "settings", "dns_detour_section");
if (detourSection) {
const dnsServers = normalizeOptionValues(uci.get(UCI_PACKAGE, "settings", "dns_server"));
const fallbackServers = normalizeOptionValues(uci.get(UCI_PACKAGE, "settings", "dns_fallback_server"));
const allDns = [...dnsServers, ...fallbackServers];
const detourIps = [];
const detourDomains = [];
for (let s of allDns) {
if (!s) continue;
s = s.split("#")[0].split("/")[0].trim();
if (s.match(/^[0-9.]+$|^[0-9a-fA-F:]+$/)) {
detourIps.push(s);
} else if (s) {
detourDomains.push(s);
}
}
const isDnsMatch = (type === "ip" && detourIps.includes(queryForMatching)) ||
(type === "domain" && detourDomains.includes(queryForMatching));
if (isDnsMatch) {
const label = uci.get(UCI_PACKAGE, detourSection, "label") || detourSection;
const action = uci.get(UCI_PACKAGE, detourSection, "action") || "connection";
return {
matched: true,
sectionName: detourSection,
label: label,
action: action,
ruleType: "DNS Detour (Settings)",
pattern: queryForMatching,
priority: 0,
totalSections: totalSections,
};
}
}
}
for (let i = 0; i < sections.length; i++) {
const sec = sections[i];
const secName = sec[".name"];
if (uci.get(UCI_PACKAGE, secName, "enabled") === "0") {
continue;
}
const label = uci.get(UCI_PACKAGE, secName, "label") || secName;
const action = uci.get(UCI_PACKAGE, secName, "action") || "connection";
if (type === "port") {
const ports = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "ports"),
);
const matchedPort = matchPort(queryForMatching, ports);
if (matchedPort) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Ports",
pattern: matchedPort,
priority: i + 1,
totalSections: totalSections,
};
}
}
if (type === "ip") {
const ipCidr = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "ip_cidr"),
);
const matchedCidr = matchIpInCidrs(queryForMatching, ipCidr);
if (matchedCidr) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "IPs (UCI)",
pattern: matchedCidr,
priority: i + 1,
totalSections: totalSections,
};
}
const listsRuleset = await readRulesetFile(
`/tmp/sing-box/rulesets/${secName}-lists-ruleset.json`,
);
const matchedListIp = matchIpInRuleset(queryForMatching, listsRuleset);
if (matchedListIp) {
const domainIpLists = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_ip_lists"),
);
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "IP/Subnet List (community/plain)",
pattern: matchedListIp,
sourceUrls: domainIpLists,
priority: i + 1,
totalSections: totalSections,
};
}
const remoteSubnetRuleset = await readRulesetFile(
`/tmp/sing-box/rulesets/${secName}-remote-subnet-ruleset.json`,
);
const matchedRemoteIp = matchIpInRuleset(
queryForMatching,
remoteSubnetRuleset,
);
if (matchedRemoteIp) {
const domainIpLists = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_ip_lists"),
);
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Remote Subnet List",
pattern: matchedRemoteIp,
sourceUrls: domainIpLists,
priority: i + 1,
totalSections: totalSections,
};
}
// Check community list rulesets for matching IPs
const communityLists = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "community_lists"),
);
for (const community of communityLists) {
if (
await matchCommunityList(secName, community, queryForMatching, type)
) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Community List (" + community + ")",
pattern: queryForMatching,
priority: i + 1,
totalSections: totalSections,
};
}
}
}
if (type === "domain") {
const domainSuffixes = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_suffix"),
);
const exactDomains = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain"),
).map((d) => `full:${d}`);
const domainKeywords = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_keyword"),
).map((k) => `keyword:${k}`);
const domainRegexes = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_regex"),
).map((r) => `regex:${r}`);
const userDomains = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "user_domains"),
);
const allUciDomains = [
...domainSuffixes,
...exactDomains,
...domainKeywords,
...domainRegexes,
...userDomains,
];
const matchedUciDomain = matchDomainInList(
queryForMatching,
allUciDomains,
);
if (matchedUciDomain) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Domains (UCI)",
pattern: matchedUciDomain,
priority: i + 1,
totalSections: totalSections,
};
}
const listsRuleset = await readRulesetFile(
`/tmp/sing-box/rulesets/${secName}-lists-ruleset.json`,
);
if (listsRuleset) {
const matchedListDomain = matchDomainInRuleset(
queryForMatching,
listsRuleset,
);
if (matchedListDomain) {
const domainIpLists = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_ip_lists"),
);
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Domain List (community/plain)",
pattern: matchedListDomain,
sourceUrls: domainIpLists,
priority: i + 1,
totalSections: totalSections,
};
}
}
const remoteDomainRuleset = await readRulesetFile(
`/tmp/sing-box/rulesets/${secName}-remote-domain-ruleset.json`,
);
if (remoteDomainRuleset) {
const matchedRemoteDomain = matchDomainInRuleset(
queryForMatching,
remoteDomainRuleset,
);
if (matchedRemoteDomain) {
const domainIpLists = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "domain_ip_lists"),
);
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Remote Domain List",
pattern: matchedRemoteDomain,
sourceUrls: domainIpLists,
priority: i + 1,
totalSections: totalSections,
};
}
}
// Check community list rulesets for matching domains
const communityLists = normalizeOptionValues(
uci.get(UCI_PACKAGE, secName, "community_lists"),
);
for (const community of communityLists) {
if (
await matchCommunityList(secName, community, queryForMatching, type)
) {
return {
matched: true,
sectionName: secName,
label: label,
action: action,
ruleType: "Community List (" + community + ")",
pattern: queryForMatching,
priority: i + 1,
totalSections: totalSections,
};
}
}
}
}
return { matched: false };
}
function createTracerSearchWidget(sectionRef) {
const container = document.createElement("div");
container.className = "cbi-section";
container.innerHTML = `
<div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title">${_("Routing Rule Tracer")}</label>
<div class="cbi-value-field">
<div style="display:flex; gap: 5px;">
<input type="text" class="cbi-input-text pdk-tracer-input" placeholder="${_("e.g. google.com, 1.1.1.1, 443")}" style="flex: 1;" />
<button class="btn cbi-button cbi-button-action pdk-tracer-btn">${_("Trace")}</button>
<button class="btn cbi-button cbi-button-neutral pdk-tracer-clear-btn" style="display: none;">${_("Clear")}</button>
</div>
<div class="pdk-tracer-results" style="margin-top: 10px; display: none;"></div>
</div>
</div>
</div>
`;
const inputEl = container.querySelector(".pdk-tracer-input");
const btnEl = container.querySelector(".pdk-tracer-btn");
const clearEl = container.querySelector(".pdk-tracer-clear-btn");
const resultsEl = container.querySelector(".pdk-tracer-results");
function clearResults() {
inputEl.value = "";
resultsEl.style.display = "none";
resultsEl.innerHTML = "";
clearEl.style.display = "none";
}
async function handleTrace() {
const query = inputEl.value.trim();
if (!query) {
clearResults();
return;
}
clearEl.style.display = "inline-block";
resultsEl.style.display = "block";
resultsEl.innerHTML = "";
resultsEl.appendChild(
E(
"div",
{
style:
"color: var(--text-color-medium, #666); display: flex; align-items: center; gap: 0.5rem; font-size: 90%;",
},
[
E("span", {
class: "pdk-spinner",
style:
"border: 2px solid var(--border-color, #ccc); border-top: 2px solid var(--primary-color, #4a90d9); border-radius: 50%; width: 12px; height: 12px; display: inline-block; animation: spin 1s linear infinite;",
}),
E("span", {}, _("Tracing routing rules...")),
],
),
);
try {
const result = await performTrace(query);
resultsEl.innerHTML = "";
if (result && result.matched) {
let actionBadgeColor = ACTION_COLORS[result.action] || "#7f8c8d";
let actionLabel = String(result.action || "").toUpperCase();
const matchDiv = E(
"div",
{
style: `padding: 0.4rem 0.6rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid ${actionBadgeColor}; font-size: 90%; line-height: 1.4;`,
},
[
E(
"div",
{
style:
"display: flex; flex-wrap: wrap; gap: 0.8rem; align-items: center;",
},
[
E("strong", {}, _("Match Found") + ":"),
E("span", {}, [
result.label || "",
" ",
E(
"span",
{
style: `background: ${actionBadgeColor}; color: #fff; padding: 1px 4px; border-radius: 3px; font-weight: bold; font-size: 80%; margin-left: 0.1rem;`,
},
actionLabel,
),
]),
E(
"span",
{ style: "color: var(--text-color-medium, #666);" },
"|",
),
E("span", {}, [
E("strong", {}, _("Rule") + ": "),
E("code", {}, result.ruleType || ""),
` (${result.pattern || ""})`,
]),
...(result.sourceUrls && result.sourceUrls.length > 0
? [
E(
"span",
{ style: "color: var(--text-color-medium, #666);" },
"|",
),
E("span", {}, [
E("strong", {}, _("Source") + ": "),
...result.sourceUrls.map((url, idx) => {
let short = url;
try {
const u = new URL(url);
const parts = u.pathname.split("/").filter(Boolean);
short =
parts.length > 0
? parts[parts.length - 1]
: u.hostname;
} catch (e) {}
return E("span", {}, [
idx > 0 ? ", " : "",
E(
"a",
{
href: url,
target: "_blank",
rel: "noopener",
style:
"font-family: monospace; font-size: 85%; word-break: break-all;",
},
short,
),
]);
}),
]),
]
: []),
E(
"span",
{ style: "color: var(--text-color-medium, #666);" },
"|",
),
E("span", {}, [
E("strong", {}, _("Priority") + ": "),
`${result.priority}/${result.totalSections}`,
]),
],
),
],
);
resultsEl.appendChild(matchDiv);
} else {
const noMatchDiv = E(
"div",
{
style:
"padding: 0.4rem 0.6rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid var(--warning-color, #f1c40f); font-size: 90%; color: var(--text-color-high, #333);",
},
[
E("strong", {}, _("No Custom Rules Matched") + ": "),
_("Traffic will go through the default route (Direct/Default)."),
],
);
resultsEl.appendChild(noMatchDiv);
}
} catch (e) {
console.error(e);
resultsEl.innerHTML = "";
const errorDiv = E(
"div",
{
style:
"padding: 0.6rem 0.8rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid var(--error-color, #e74c3c); color: var(--error-color, #e74c3c); font-size: 90%;",
},
[
E("strong", {}, _("Error running trace:")),
" " + (e?.message || String(e)),
],
);
resultsEl.appendChild(errorDiv);
}
}
btnEl.addEventListener("click", handleTrace);
clearEl.addEventListener("click", clearResults);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleTrace();
}
});
if (!document.getElementById("pdk-tracer-styles")) {
const style = document.createElement("style");
style.id = "pdk-tracer-styles";
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
return container;
}
function showSectionRulesModal(section_id) {
const sectionLabel = uci.get(UCI_PACKAGE, section_id, "label") || section_id;
const getCleanList = (key) => {
let val = uci.get(UCI_PACKAGE, section_id, key);
if (!val) return [];
if (Array.isArray(val)) val = val.join("\n");
return parseCommentAwareListTokens(val).map((t) => t.value);
};
const domains = getCleanList("domain");
const ips = getCleanList("ip_cidr");
const community = getCleanList("community_lists");
const ruleSets = getCleanList("rule_set");
const domainIpLists = getCleanList("domain_ip_lists");
const geosite = getCleanList("geosite");
const geoip = getCleanList("geoip");
const sourceIp = getCleanList("source_ip_cidr");
const ports = getCleanList("ports");
const dscp = getCleanList("dscp");
const createCard = (title, items) => {
let contentNode;
if (!items || items.length === 0) {
contentNode = E("em", {}, _("Not set"));
} else {
contentNode = E(
"div",
{
style:
"display: flex; flex-wrap: wrap; gap: 4px; max-height: 180px; overflow-y: auto;",
},
items.map((item) =>
E(
"span",
{
style:
"padding: 2px 6px; border: 1px solid var(--border-color, #ccc); background: var(--background-color-low, #f9f9f9); border-radius: 3px; word-break: break-all; font-size: 90%;",
},
item,
),
),
);
}
return E("div", { class: "cbi-value" }, [
E(
"label",
{ class: "cbi-value-title", style: "padding-top: 0;" },
`${title} (${items ? items.length : 0})`,
),
E("div", { class: "cbi-value-field" }, [contentNode]),
]);
};
const modalBody = E(
"div",
{ class: "cbi-section-node", style: "min-width: 320px; max-width: 650px;" },
[
E("div", { class: "cbi-value" }, [
E(
"label",
{
class: "cbi-value-title",
style: "padding-top: 0; font-weight: bold;",
},
_("Section action"),
),
E(
"div",
{
class: "cbi-value-field",
style: "font-weight: bold; color: var(--text-color-high, #2980b9);",
},
getRuleActionDisplayValue(section_id),
),
]),
createCard(_("Community Lists"), community),
createCard(_("Domains"), domains),
createCard(_("IP Addresses (IP CIDR)"), ips),
createCard(_("Rule Sets (.srs / .json)"), ruleSets),
createCard(_(".lst Lists"), domainIpLists),
geosite.length || geoip.length
? createCard(_("GeoSite / GeoIP"), [...geosite, ...geoip])
: null,
sourceIp.length ? createCard(_("Source Devices"), sourceIp) : null,
ports.length ? createCard(_("Ports"), ports) : null,
dscp.length ? createCard(_("DSCP marks (QoS)"), dscp) : null,
].filter(Boolean),
);
ui.showModal(
_("Section Rules Content") + `: ${sectionLabel}`,
[
modalBody,
E(
"div",
{
class: "button-row",
style:
"display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px;",
},
[
E(
"button",
{
class: "btn cbi-button cbi-button-action",
type: "button",
click: () => ui.hideModal(),
},
_("Close"),
),
],
),
],
"cbi-modal",
);
}
function configureSectionSection(sectionRef, options = {}) {
setActionProvidersAvailabilityLoader(options.loadActionProvidersAvailability);
sectionRef.sectiontitle = function (section_id) {
return uci.get(UCI_PACKAGE, section_id, "label") || section_id;
};
sectionRef.load = function () {
// The table renders only non-modal fields; the cloned Add/Edit modal loads
// action/provider details when the user opens it.
return loadSectionTableOptions(this);
};
const originalRender = sectionRef.render;
sectionRef.render = function () {
return originalRender.apply(this, arguments).then((node) => {
if (node) {
const tracer = createTracerSearchWidget(sectionRef);
node.appendChild(tracer);
}
return node;
});
};
}
const EntryPoint = {
configureSectionSection,
createSectionContent,
setActionProvidersAvailabilityLoader,
showSectionRulesModal,
};
return baseclass.extend(EntryPoint);
function validateRequiredText(_sectionId, value) {
const normalized = value ? String(value).trim() : "";
if (!normalized) {
return _("This field is required");
}
return /[\u0000-\u001F\u007F]/.test(normalized)
? _("Value must not contain control characters")
: true;
}