Preserve domain comments across UI and runtime

This commit is contained in:
ushan0v
2026-07-12 15:06:00 +03:00
parent 00b7a400ec
commit 3c0cc5fe7d
7 changed files with 57 additions and 30 deletions
+1
View File
@@ -320,6 +320,7 @@ function has_community_subnet_list(value) {
}
return {
text_list_values,
normalize_port_number_value,
normalize_port_condition_value,
normalize_port_range_value,
+1 -21
View File
@@ -350,26 +350,6 @@ function regex_valid(pattern) {
}
}
function strip_list_comment(line) {
line = replace(as_string(line), /[[:space:]]*\/\/.*$/, "");
return replace(line, /[[:space:]]*#.*$/, "");
}
function text_list_values(value) {
let result = [];
for (let line in split(as_string(value), "\n")) {
line = replace(strip_list_comment(line), /[ ,]/g, "\n");
for (let item in split(line, "\n")) {
item = trim(replace(item, /\r/g, ""));
if (item != "")
push(result, item);
}
}
return result;
}
function combined_domain_valid(value) {
value = trim(as_string(value));
if (value == "")
@@ -383,7 +363,7 @@ function combined_domain_valid(value) {
}
function combined_domain_text_valid(value) {
for (let item in text_list_values(value))
for (let item in rule_config.text_list_values(value, "comma-space"))
if (!combined_domain_valid(item))
return false;
return true;
+12 -5
View File
@@ -2303,13 +2303,20 @@ function split_condition_text(value) {
function legacy_condition_values(section, key) {
let raw_values = object_or_empty(section)[key];
let domain_key = key == "domain" || key == "domain_suffix" ||
key == "domain_keyword" || key == "domain_regex";
let list_values = type(raw_values) == "array"
? raw_values
: [];
let option_text_values = type(raw_values) == "array"
let option_text_values = type(raw_values) == "array" || key == "domain"
? []
: split_condition_text(raw_values);
let text_values = split_condition_text(option(section, key + "_text", ""));
: (domain_key
? rule_config.text_list_values(raw_values, "comma-space")
: split_condition_text(raw_values));
let text_value = option(section, key + "_text", "");
let text_values = domain_key
? rule_config.text_list_values(text_value, "comma-space")
: split_condition_text(text_value);
if (bool_option(section, key + "_text_mode", false) || bool_option(section, "conditions_text_mode", false))
return text_values;
@@ -2323,11 +2330,11 @@ function legacy_condition_values(section, key) {
function combined_domain_source_values(section) {
let values = [];
if (type(object_or_empty(section)["domain"]) != "array") {
for (let value in split_condition_text(option(section, "domain", "")))
for (let value in rule_config.text_list_values(option(section, "domain", ""), "comma-space"))
if (as_string(value) != "")
push(values, as_string(value));
}
for (let value in split_condition_text(option(section, "domain_suffix_text", "")))
for (let value in rule_config.text_list_values(option(section, "domain_suffix_text", ""), "comma-space"))
if (as_string(value) != "")
push(values, as_string(value));
for (let value in list_option(section, "domain_suffix"))
@@ -4489,12 +4489,34 @@ function uniqueDomainTextValues(values) {
});
}
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 = uniqueDomainTextValues([
...(textValue ? main.parseValueList(textValue) : []),
const values = [
...domainValuesWithPrefix(section_id, "domain_suffix", ""),
...domainValuesWithPrefix(section_id, "domain_keyword", "keyword"),
...domainValuesWithPrefix(section_id, "domain_regex", "regex"),
@@ -4502,9 +4524,9 @@ function loadCombinedDomainText(section_id) {
...domainTextValuesWithPrefix(section_id, "domain", "full"),
...domainTextValuesWithPrefix(section_id, "domain_keyword", "keyword"),
...domainTextValuesWithPrefix(section_id, "domain_regex", "regex"),
]);
];
return valuesToText(values);
return appendUniqueDomainTextValues(textValue, values);
}
function analyzeIpCidrText(value) {
+11
View File
@@ -7,6 +7,9 @@ FORKOP_BIN="$FORKOP_FILES/usr/bin/forkop"
FORKOP_LIB="$FORKOP_FILES/usr/lib"
CLI_UC="$FORKOP_BIN"
VALIDATOR="$FORKOP_LIB/config/validator.uc"
RULE_CONFIG="$FORKOP_LIB/config/rule.uc"
GENERATOR="$FORKOP_LIB/singbox/generator.uc"
SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js"
LIFECYCLE="$FORKOP_LIB/service/lifecycle.uc"
fail() {
@@ -32,6 +35,14 @@ grep -Fq 'mode == "mwan3-is-active"' "$VALIDATOR" ||
fail "config validator must own mwan3 runtime predicate"
grep -Fq 'require("core.uci")' "$VALIDATOR" ||
fail "config validator must use core.uci for runtime UCI access"
grep -Fq 'text_list_values,' "$RULE_CONFIG" ||
fail "config.rule must export the shared comment-aware text parser"
grep -Fq 'rule_config.text_list_values(value, "comma-space")' "$VALIDATOR" ||
fail "domain validation must use the shared comment-aware text parser"
grep -Fq 'rule_config.text_list_values(option(section, "domain", ""), "comma-space")' "$GENERATOR" ||
fail "sing-box domain generation must use the shared comment-aware text parser"
grep -Fq 'return appendUniqueDomainTextValues(textValue, values);' "$SECTION_JS" ||
fail "Domains field loading must preserve the original combined text"
if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"|command_output\("uci' "$VALIDATOR" >/dev/null 2>&1; then
fail "config validator must not own direct UCI cursor or CLI access"
fi
+1
View File
@@ -97,6 +97,7 @@ cat >"$WORK_DIR/valid.json" <<'JSON'
"urltest_include_countries": [ "US" ],
"urltest_exclude_regex": [ "bad.*" ],
"detect_server_country": "flag_emoji",
"domain": "commented.example # keep this note\nfull:exact-comment.example // and this note\nkeyword:clip",
"domain_suffix": [ "example.org", "сайт.рф", "full:exact.example", "full:full:legacy.example", "full:пример.испытание", "keyword:video", "keyword:пример", "regex:^api[.]example$", "regex:^сайт[.]рф$" ],
"domain_suffix_text": "text.example\nmünich.example\nkeyword:stream",
"community_lists": [ "discord" ],
+5
View File
@@ -308,6 +308,7 @@ cat >"$WORK_DIR/runtime-matchers-fixture.json" <<'JSON'
"enabled": "1",
"action": "connection",
"selector_proxy_links": [ "socks5://127.0.0.1:1080" ],
"domain": "commented.example # ignored.example\nfull:exact-comment.example // ignored-full.example\nkeyword:clip",
"domain_suffix": [ "proxy.example.org", "сайт.рф", "full:пример.испытание", "keyword:пример", "regex:^сайт[.]рф$" ],
"source_ip_cidr": [ "10.0.0.2/32", "2001:db8::2/128" ],
"outbound_detour_enabled": "1",
@@ -1063,6 +1064,10 @@ assert(dns_rule(matchers, r => contains(r.domain_suffix, "xn--80aswg.xn--p1ai"))
assert(dns_rule(matchers, r => contains(r.domain, "xn--e1afmkfd.xn--80akhbyknj4f")).server == "fakeip-server", "IDN full domain converted for DNS rule");
assert(dns_rule(matchers, r => contains(r.domain_keyword, "xn--e1afmkfd")).server == "fakeip-server", "IDN keyword converted for DNS rule");
assert(dns_rule(matchers, r => contains(r.domain_regex, "^xn--80aswg[.]xn--p1ai$")).server == "fakeip-server", "IDN regex converted for DNS rule");
assert(dns_rule(matchers, r => contains(r.domain_suffix, "commented.example")).server == "fakeip-server", "commented domain value generated");
assert(dns_rule(matchers, r => contains(r.domain, "exact-comment.example")).server == "fakeip-server", "commented full domain generated");
assert(dns_rule(matchers, r => contains(r.domain_keyword, "clip")).server == "fakeip-server", "commented keyword generated");
assert(index(sprintf("%J", matchers), "ignored.example") < 0 && index(sprintf("%J", matchers), "ignored-full.example") < 0, "domain comments excluded from generated config");
assert(route_rule(matchers, r => r.outbound == "bypass-out" && contains(r.domain_suffix, "example.org") && contains(r.source_ip_cidr, "10.0.0.3/32")) != null, "bypass fallback route");
assert(route_rule(matchers, r => r.outbound == "proxy-out" && contains(r.domain_suffix, "xn--80aswg.xn--p1ai")) != null, "IDN suffix converted for route rule");
assert(route_rule(matchers, r => r.outbound == "proxy-out" && contains(r.domain, "xn--e1afmkfd.xn--80akhbyknj4f")) != null, "IDN full domain converted for route rule");