Restore per-interface domain resolvers

This commit is contained in:
ushan0v
2026-07-12 21:38:58 +03:00
parent cea4ab4505
commit afc05c9100
22 changed files with 3063 additions and 2433 deletions
+11 -1
View File
@@ -340,6 +340,12 @@ EOF
cat > "$control_dir/conffiles" <<'EOF'
/etc/config/forkop
EOF
cat > "$control_dir/postinst" <<'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT}" ] && exit 0
FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate
EOF
cat > "$control_dir/prerm" <<'EOF'
@@ -351,7 +357,7 @@ if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
exit(0);
EOF
chmod 0755 "$control_dir/prerm"
chmod 0755 "$control_dir/postinst" "$control_dir/prerm"
}
write_app_ipk_control() {
@@ -487,6 +493,8 @@ EOF
cat > "$scripts_dir/backend-post-install.sh" <<'EOF'
#!/usr/bin/ucode
if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
exit(system("FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate"));
exit(0);
EOF
@@ -506,6 +514,8 @@ EOF
cat > "$scripts_dir/backend-post-upgrade.sh" <<'EOF'
#!/usr/bin/ucode
if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
exit(system("FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate"));
exit(0);
EOF
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -2
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: FORKOP\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-11 19:00+0300\n"
"PO-Revision-Date: 2026-07-11 19:00+0300\n"
"POT-Creation-Date: 2026-07-12 20:23+0300\n"
"PO-Revision-Date: 2026-07-12 20:23+0300\n"
"Last-Translator: ushan0v\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -431,18 +431,30 @@ msgstr "DNS через HTTPS (DoH)"
msgid "DNS over TLS (DoT)"
msgstr "DNS через TLS (DoT)"
msgid "DNS protocol"
msgstr "Протокол DNS"
msgid "DNS Protocol Type"
msgstr "Тип протокола DNS"
msgid "DNS protocol used by the resolver"
msgstr "DNS-протокол, который будет использовать резолвер"
msgid "DNS requests through section"
msgstr "DNS-запросы через секцию"
msgid "DNS Rewrite TTL"
msgstr "Перезапись TTL для DNS"
msgid "DNS server"
msgstr "DNS-сервер"
msgid "DNS server address cannot be empty"
msgstr "Адрес DNS-сервера не может быть пустым"
msgid "DNS server used by the resolver"
msgstr "DNS-сервер, который будет использовать резолвер"
msgid "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup."
msgstr "DNS-сервер для получения IP вышестоящих DNS и прокси. Если выбрать несколько, то при таймауте произойдёт переключение на резервный."
@@ -476,6 +488,9 @@ msgstr "Не резолвится"
msgid "Domain and IP Lists"
msgstr "Списки доменов и IPs"
msgid "Domain Resolver"
msgstr "Резолвер доменов"
msgid "Domains"
msgstr "Домены"
@@ -539,6 +554,9 @@ msgstr "Включить автоматическое обновление уд
msgid "Enable autostart"
msgstr "Включить автостарт"
msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых этой секцией"
msgid "Enable list updates"
msgstr "Включить обновление списков"
@@ -1184,6 +1202,9 @@ msgstr "Имя, которое отображается в списке уров
msgid "Network Interface"
msgstr "Сетевой интерфейс"
msgid "Network interface settings"
msgstr "Настройки сетевого интерфейса"
msgid "New version %s is available for %s"
msgstr "Доступна новая версия %s для %s"
+7
View File
@@ -48,6 +48,13 @@ if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "")
exit(0);
endef
define Package/forkop/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] && exit 0
FORKOP_LIB=/usr/lib/forkop ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate
endef
define Package/forkop/conffiles
/etc/config/forkop
endef
+10 -1
View File
@@ -1,4 +1,7 @@
config settings 'settings'
option config_version '1.0.2'
list applied_migrations 'interface_sections'
list applied_migrations 'enable_component_checks'
option dns_type 'udp'
list dns_server '77.88.8.8'
list bootstrap_dns_server '77.88.8.8'
@@ -38,7 +41,6 @@ config settings 'settings'
# option enabled '1'
# option action 'connection'
# list selector_proxy_links 'vless://example'
# list interfaces 'awg0'
# list community_lists 'russia_inside'
# list rule_set 'https://example.com/rules.srs'
# list rule_set_with_subnets 'https://example.com/rules-with-subnets.json'
@@ -47,6 +49,13 @@ config settings 'settings'
# option sort_by_latency '0'
# #list fully_routed_ips '192.168.1.2'
#config section_interface
# option section 'subscription_example'
# option name 'awg0'
# option domain_resolver_enabled '0'
# option domain_resolver_dns_type 'udp'
# option domain_resolver_dns_server '8.8.8.8'
#config subscription_url
# option section 'subscription_example'
# option url 'https://example.com/api/sub'
@@ -9,6 +9,7 @@ let object_or_empty = common.object_or_empty;
const CONFIG_NAME = getenv("FORKOP_CONFIG_NAME") || "forkop";
const ITEM_TYPES = [
"subscription_url",
"section_interface",
"urltest",
"priority_group",
"priority_level"
@@ -316,6 +317,10 @@ function subscription_urls(section) {
}
function interfaces(section) {
let items = child_values(section, "section_interface", "name", "");
if (length(items) > 0)
return items;
let result = list_value(section, "interfaces");
if (length(result) == 0) {
let value = option(section, "interface", "");
@@ -441,6 +446,35 @@ function subscription_url_settings(section, value) {
return child != null ? child : item_settings(section, "subscription_url_settings", value);
}
function interface_settings(section, value) {
let child = child_item_by_value(section, "section_interface", "name", value);
return child != null ? child : item_settings(section, "interface_settings", value);
}
function interface_domain_resolver_enabled(section, value) {
let child = child_item_by_value(section, "section_interface", "name", value);
if (child != null)
return child_bool(child, "domain_resolver_enabled", false);
return item_bool(section, "interface_settings", value, "domain_resolver_enabled",
bool_option(section, "domain_resolver_enabled", false));
}
function interface_domain_resolver_dns_type(section, value) {
let child = child_item_by_value(section, "section_interface", "name", value);
if (child != null)
return child_option(child, "domain_resolver_dns_type", "udp");
return item_option(section, "interface_settings", value, "domain_resolver_dns_type",
option(section, "domain_resolver_dns_type", "udp") || "udp");
}
function interface_domain_resolver_dns_server(section, value) {
let child = child_item_by_value(section, "section_interface", "name", value);
if (child != null)
return child_option(child, "domain_resolver_dns_server", "8.8.8.8");
return item_option(section, "interface_settings", value, "domain_resolver_dns_server",
option(section, "domain_resolver_dns_server", "8.8.8.8") || "8.8.8.8");
}
function urltest_child(section, value) {
let child = owned_child_section(section, "urltest", value);
if (child != null)
@@ -893,6 +927,7 @@ return {
rule_sets_with_subnets_value,
has_connection_sources,
subscription_url_settings,
interface_settings,
urltest_settings,
subscription_update_enabled,
subscription_update_interval,
@@ -907,6 +942,9 @@ return {
subscription_user_agent,
subscription_hwid,
subscription_download_section,
interface_domain_resolver_enabled,
interface_domain_resolver_dns_type,
interface_domain_resolver_dns_server,
urltest_check_interval,
urltest_tolerance,
urltest_testing_url,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -766,7 +766,7 @@ function fixture_get_section(section_name) {
if (section_name == "settings" && type(fixture.settings) == "object")
return fixture.settings;
for (let type_name in [ "settings", "server", "section", "subscription_url", "urltest", "priority_group", "priority_level" ]) {
for (let type_name in [ "settings", "server", "section", "subscription_url", "section_interface", "urltest", "priority_group", "priority_level" ]) {
for (let section in fixture_section_list(type_name)) {
if (as_string(section[".name"]) == section_name)
return section;
+10 -1
View File
@@ -936,7 +936,16 @@ function subscription_urls_signature(section) {
}
function interfaces_signature(section) {
return sprintf("%J", connections.interfaces(section));
let result = [];
for (let entry in connections.interfaces(section)) {
push(result, {
name: entry,
domain_resolver_enabled: connections.interface_domain_resolver_enabled(section, entry) ? "1" : "0",
domain_resolver_dns_type: connections.interface_domain_resolver_dns_type(section, entry),
domain_resolver_dns_server: connections.interface_domain_resolver_dns_server(section, entry)
});
}
return sprintf("%J", result);
}
function urltests_signature(section) {
@@ -68,6 +68,10 @@ function outbound_tag(section_name) {
return tag(section_name, "out");
}
function domain_resolver_tag(section_name) {
return tag(section_name, "domain-resolver");
}
function server_inbound_tag(section_name) {
return tag("server-" + as_string(section_name), "in");
}
@@ -113,6 +117,7 @@ return {
tag,
inbound_tag,
outbound_tag,
domain_resolver_tag,
server_inbound_tag,
tailscale_dns_server_tag
};
+18 -1
View File
@@ -104,7 +104,7 @@ function fixture_get_section(section_name) {
if (section_name == "settings" && type(fixture.settings) == "object")
return fixture.settings;
for (let type_name in [ "settings", "server", "section", "subscription_url", "urltest", "priority_group", "priority_level" ]) {
for (let type_name in [ "settings", "server", "section", "subscription_url", "section_interface", "urltest", "priority_group", "priority_level" ]) {
for (let section in fixture_section_list(type_name)) {
if (as_string(section[".name"]) == section_name)
return section;
@@ -1967,12 +1967,29 @@ function add_interface_connection_outbound(config, state, section, interface_ind
tag_name = unique_tag(tag_name, taken);
taken[tag_name] = true;
let domain_resolver = "";
if (connections.interface_domain_resolver_enabled(section, interface_name)) {
domain_resolver = runtime_constants.domain_resolver_tag(section_name + "-interface-" + interface_index);
let dns_server = runtime_dns.server_from_options(
domain_resolver,
connections.interface_domain_resolver_dns_type(section, interface_name),
connections.interface_domain_resolver_dns_server(section, interface_name),
tag_name
);
if (dns_server.unsupported)
runtime_generate_unsupported(dns_server.unsupported);
push(config.dns.servers, dns_server);
}
let outbound = {
type: "direct",
tag: tag_name,
bind_interface: interface_name,
domain_resolver,
routing_mark: runtime_constants.OUTBOUND_MARK
};
if (domain_resolver == "")
delete outbound.domain_resolver;
push(config.outbounds, outbound);
push(selector_tags, tag_name);
+1 -1420
View File
File diff suppressed because it is too large Load Diff
@@ -1441,8 +1441,13 @@ function currentDraftOutboundNames(section_id) {
names.push(name || `${section_id}-${index + 1}-out`);
},
);
currentSourceOptionValues(section_id, "interfaces").forEach((name) => {
const normalized = `${name || ""}`.trim();
currentSourceOptionValues(section_id, "interfaces").forEach((itemId) => {
const normalized = childItemInputValue(
section_id,
itemId,
"section_interface",
"name",
).trim();
if (normalized) {
names.push(normalized);
}
@@ -1497,6 +1502,14 @@ function subscriptionDownloadTargetChoices(section_id) {
}));
}
function dnsTypeChoices() {
return [
{ value: "doh", label: _("DNS over HTTPS (DoH)") },
{ value: "dot", label: _("DNS over TLS (DoT)") },
{ value: "udp", label: "UDP" },
];
}
function isConnectionNetworkInterfaceAllowed(deviceName, device) {
if (CONNECTIONS_BLOCKED_INTERFACES.includes(deviceName)) {
return false;
@@ -1567,29 +1580,7 @@ function refreshNetworkInterfaceOptionValues(option) {
});
}
const InterfaceUIDynamicList = ui.DynamicList.extend({
render() {
ensureConnectionsDynamicListStyles();
return ui.DynamicList.prototype.render.apply(this, arguments);
},
addItem(dl, value, text, flash) {
const itemText =
typeof this.options.itemLabel === "function"
? this.options.itemLabel(value, text)
: text;
return ui.DynamicList.prototype.addItem.call(
this,
dl,
value,
itemText,
flash,
);
},
});
const InterfaceDynamicList = form.DynamicList.extend({
const InterfaceSettingsDynamicList = SettingsDynamicList.extend({
load(section_id) {
return network.getDevices().then(
L.bind(function (devices) {
@@ -1601,30 +1592,9 @@ const InterfaceDynamicList = form.DynamicList.extend({
);
},
renderWidget(section_id, _option_index, cfgvalue) {
const value = cfgvalue != null ? cfgvalue : this.default;
const choices = this.transformChoices();
const items = L.toArray(value);
const widget = new InterfaceUIDynamicList(items, 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,
itemLabel: (itemValue, itemText) =>
renderNetworkInterfaceListItem(
this.interfaceDeviceMap ? this.interfaceDeviceMap[itemValue] : null,
itemValue || itemText,
),
});
validate(section_id, value) {
value = childItemInputValue(section_id, value, "section_interface", "name");
return widget.render();
},
validate(_section_id, value) {
if (!value || value.length === 0) {
return true;
}
@@ -1635,6 +1605,15 @@ const InterfaceDynamicList = form.DynamicList.extend({
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() {
@@ -1740,6 +1719,22 @@ function defaultSubscriptionUrlSettings() {
};
}
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",
@@ -2071,6 +2066,43 @@ function addSubscriptionUrlItemOptions(itemSection, options = {}) {
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"
@@ -2901,6 +2933,17 @@ function showSubscriptionUrlSettingsModal(_section_id, itemValue, option) {
});
}
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,
@@ -6533,7 +6576,7 @@ function createSectionContent(section) {
o = section.taboption(
"settings",
InterfaceDynamicList,
InterfaceSettingsDynamicList,
"interfaces",
_("Network Interface"),
_("Select network interface for VPN connection"),
@@ -6542,6 +6585,33 @@ function createSectionContent(section) {
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];
}
};
outboundNameSourceOptions.set("interfaces", o);
o = section.taboption(
+23 -2
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: FORKOP\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-11 19:00+0300\n"
"PO-Revision-Date: 2026-07-11 19:00+0300\n"
"POT-Creation-Date: 2026-07-12 20:23+0300\n"
"PO-Revision-Date: 2026-07-12 20:23+0300\n"
"Last-Translator: ushan0v\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -431,18 +431,30 @@ msgstr "DNS через HTTPS (DoH)"
msgid "DNS over TLS (DoT)"
msgstr "DNS через TLS (DoT)"
msgid "DNS protocol"
msgstr "Протокол DNS"
msgid "DNS Protocol Type"
msgstr "Тип протокола DNS"
msgid "DNS protocol used by the resolver"
msgstr "DNS-протокол, который будет использовать резолвер"
msgid "DNS requests through section"
msgstr "DNS-запросы через секцию"
msgid "DNS Rewrite TTL"
msgstr "Перезапись TTL для DNS"
msgid "DNS server"
msgstr "DNS-сервер"
msgid "DNS server address cannot be empty"
msgstr "Адрес DNS-сервера не может быть пустым"
msgid "DNS server used by the resolver"
msgstr "DNS-сервер, который будет использовать резолвер"
msgid "DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup."
msgstr "DNS-сервер для получения IP вышестоящих DNS и прокси. Если выбрать несколько, то при таймауте произойдёт переключение на резервный."
@@ -476,6 +488,9 @@ msgstr "Не резолвится"
msgid "Domain and IP Lists"
msgstr "Списки доменов и IPs"
msgid "Domain Resolver"
msgstr "Резолвер доменов"
msgid "Domains"
msgstr "Домены"
@@ -539,6 +554,9 @@ msgstr "Включить автоматическое обновление уд
msgid "Enable autostart"
msgstr "Включить автостарт"
msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых этой секцией"
msgid "Enable list updates"
msgstr "Включить обновление списков"
@@ -1184,6 +1202,9 @@ msgstr "Имя, которое отображается в списке уров
msgid "Network Interface"
msgstr "Сетевой интерфейс"
msgid "Network interface settings"
msgstr "Настройки сетевого интерфейса"
msgid "New version %s is available for %s"
msgstr "Доступна новая версия %s для %s"
File diff suppressed because it is too large Load Diff
+152 -15
View File
@@ -6,7 +6,8 @@ FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib"
RUNTIME_MIGRATION="$FORKOP_LIB/config/migration.uc"
INSTALLER="$ROOT_DIR/install.sh"
WORK_DIR="$(mktemp -d)"
MIGRATION="$WORK_DIR/config-migration.uc"
MIGRATION="$RUNTIME_MIGRATION"
MIGRATIONS_DIR="$FORKOP_LIB/config/migrations"
cleanup() {
rm -rf "$WORK_DIR"
@@ -18,24 +19,23 @@ fail() {
exit 1
}
awk '
/cat > "\$helper_path" <<'\''FORKOP_CONFIG_MIGRATION_EOF'\''/ { capture = 1; next }
capture && /^FORKOP_CONFIG_MIGRATION_EOF$/ { exit }
capture { print }
' "$INSTALLER" > "$MIGRATION"
[ -s "$MIGRATION" ] || fail "failed to extract config migration from install.sh"
grep -Fq 'Reserved for future Forkop configuration migrations.' "$RUNTIME_MIGRATION" ||
fail "runtime config/migration.uc must remain a placeholder"
if grep -n -E 'require\("core\.uci"\)|function migrate_|migrate_runtime|migrate-fixture' "$RUNTIME_MIGRATION" >/dev/null 2>&1; then
fail "runtime config/migration.uc must not contain migration logic"
if grep -n -E 'FORKOP_CONFIG_MIGRATION_EOF|installer_config_migration_path' "$INSTALLER" >/dev/null 2>&1; then
fail "install.sh must not embed configuration migration logic"
fi
[ -s "$MIGRATION" ] || fail "runtime configuration migration module is missing"
[ ! -e "$MIGRATIONS_DIR" ] || fail "configuration migrations must stay in one migration.uc file"
if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$MIGRATION" >/dev/null 2>&1; then
fail "installer config migration must use core.uci instead of direct UCI cursor or CLI access"
fi
grep -Fq 'require("core.uci")' "$MIGRATION" ||
fail "installer config migration must import core.uci"
grep -Fq '{ id: "interface_sections", run: migrate_interface_sections }' "$MIGRATION" ||
fail "interface section migration must have a stable named marker"
grep -Fq '{ id: "enable_component_checks", run: migrate_enable_component_checks }' "$MIGRATION" ||
fail "component check migration must have a stable named marker"
grep -Fq 'release_at_most(ctx, "1.0.1")' "$MIGRATION" ||
fail "release-specific migrations must use the source release only as a condition"
eval "$(ucode -L "$FORKOP_LIB" "$FORKOP_LIB/core/constants.uc" shell-env)"
export ZAPRET_LEGACY_DEFAULT_NFQWS_OPT ZAPRET_DEFAULT_NFQWS_OPT
@@ -155,7 +155,7 @@ const fixture = {
process.stdout.write(`${JSON.stringify(fixture, null, 2)}\n`);
NODE
FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/fixture.json" >"$WORK_DIR/output.json"
FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/fixture.json" podkop >"$WORK_DIR/output.json"
node - "$WORK_DIR/output.json" <<'NODE'
const fs = require('fs');
@@ -164,10 +164,14 @@ const config = out.config;
const sections = Object.fromEntries(config.section.map(section => [section['.name'], section]));
const childByType = type => config[type] || [];
const subscriptionUrls = childByType('subscription_url');
const interfaces = childByType('section_interface');
const urltests = childByType('urltest');
assert(JSON.stringify(config.settings.dns_server) === JSON.stringify(['9.9.9.9']), 'legacy main DNS scalar migrated to ordered list');
assert(JSON.stringify(config.settings.bootstrap_dns_server) === JSON.stringify(['1.1.1.1']), 'legacy Bootstrap DNS scalar migrated to ordered list');
assert(config.settings.config_version === '1.0.2', 'legacy config should be marked at the current schema version');
assert(config.settings.component_update_check_enabled === '1', 'component update checks should be enabled during migration');
assert(JSON.stringify(config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks']), 'legacy config should record named migrations');
function assert(condition, message) {
if (!condition) {
@@ -319,9 +323,14 @@ absent(legacyZap, 'cmd_opts', 'legacy-zap');
const legacyVpn = sections['legacy-vpn'];
assert(legacyVpn.action === 'connection', 'vpn action inferred');
assert(JSON.stringify(legacyVpn.interfaces) === JSON.stringify(['awg0']), 'vpn interface migrated to section list');
assert(JSON.stringify(childValues(legacyVpn, interfaces, 'name')) === JSON.stringify(['awg0']), 'vpn interface migrated to interface item');
const legacyVpnInterface = childObjects(legacyVpn, interfaces)[0];
assert(legacyVpnInterface.domain_resolver_enabled === '1', 'vpn domain resolver enabled migrated');
assert(legacyVpnInterface.domain_resolver_dns_type === 'doh', 'vpn domain resolver type migrated');
assert(legacyVpnInterface.domain_resolver_dns_server === 'https://dns.example/dns-query', 'vpn domain resolver server migrated');
absent(legacyVpn, 'proxy_config_type', 'legacy-vpn');
absent(legacyVpn, 'interface', 'legacy-vpn');
absent(legacyVpn, 'interfaces', 'legacy-vpn');
absent(legacyVpn, 'interface_items', 'legacy-vpn');
absent(legacyVpn, 'domain_resolver_enabled', 'legacy-vpn');
absent(legacyVpn, 'domain_resolver_dns_type', 'legacy-vpn');
@@ -344,6 +353,83 @@ assert(out.removed_caches.includes('/tmp/sing-box/subscriptions/legacy-sub.json'
assert(out.removed_caches.includes('/var/run/forkop/section-cache/legacy-sub.json'), 'section cache removal');
NODE
cat >"$WORK_DIR/forkop-1.0.1.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"config_version": "1.0.1",
"component_update_check_enabled": "0"
},
"section": [
{
".name": "main",
".type": "section",
"action": "connection",
"interfaces": [ "awg0", "tun0" ]
}
]
}
JSON
FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/forkop-1.0.1.json" >"$WORK_DIR/forkop-1.0.2.json"
node - "$WORK_DIR/forkop-1.0.2.json" <<'NODE'
const fs = require('fs');
const out = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const section = out.config.section[0];
const interfaces = out.config.section_interface || [];
function assert(condition, message) {
if (!condition) {
console.error(message);
process.exit(1);
}
}
assert(out.changed === true, '1.0.1 config should require migration');
assert(out.config.settings.config_version === '1.0.2', 'config schema version should advance to 1.0.2');
assert(out.config.settings.component_update_check_enabled === '1', 'updates from 1.0.1 and below should enable component update checks');
assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks']), 'named migrations should be recorded');
assert(!Object.prototype.hasOwnProperty.call(section, 'interfaces'), 'parent interface list should be removed');
assert(JSON.stringify(interfaces.map(item => item.name)) === JSON.stringify(['awg0', 'tun0']), 'interfaces should keep their order');
for (const item of interfaces) {
assert(item.section === 'main', 'interface child should reference its parent');
assert(item.domain_resolver_enabled === '0', 'resolver should default to disabled');
assert(item.domain_resolver_dns_type === 'udp', 'resolver protocol default');
assert(item.domain_resolver_dns_server === '8.8.8.8', 'resolver server default');
}
NODE
cat >"$WORK_DIR/forkop-1.0.2-disabled.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"config_version": "1.0.2",
"component_update_check_enabled": "0"
},
"section": []
}
JSON
FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/forkop-1.0.2-disabled.json" >"$WORK_DIR/forkop-1.0.2-disabled-output.json"
node - "$WORK_DIR/forkop-1.0.2-disabled-output.json" <<'NODE'
const fs = require('fs');
const out = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
function assert(condition, message) {
if (!condition) {
console.error(message);
process.exit(1);
}
}
assert(out.config.settings.component_update_check_enabled === '0', '1.0.2 config must preserve an explicitly disabled component check');
assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks']), 'newer configs should mark skipped migrations');
NODE
cat >"$WORK_DIR/runtime-migrate.state" <<'EOF_UCI'
forkop.settings=settings
forkop.settings.routing_excluded_ips=192.0.2.0/24
@@ -355,6 +441,13 @@ forkop.legacy.proxy_string=vless://one
forkop.old_direct=section
forkop.old_direct.enabled=1
forkop.old_direct.action=direct
forkop.vpn=section
forkop.vpn.enabled=1
forkop.vpn.connection_type=vpn
forkop.vpn.interface=awg0
forkop.vpn.domain_resolver_enabled=1
forkop.vpn.domain_resolver_dns_type=doh
forkop.vpn.domain_resolver_dns_server=https://dns.example/dns-query
EOF_UCI
mkdir -p "$WORK_DIR/runtime" "$WORK_DIR/persistent-cache"
: >"$WORK_DIR/runtime-migrate.log"
@@ -365,7 +458,7 @@ TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/tmp-subscriptions" \
FORKOP_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
FORKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent-cache" \
FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \
ucode -L "$FORKOP_LIB" "$MIGRATION" migrate
ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-podkop
grep -Fxq 'forkop.legacy=section' "$WORK_DIR/runtime-migrate.state" ||
fail "runtime migration must convert legacy rule type through core.uci"
@@ -379,6 +472,12 @@ grep -Fxq 'forkop.legacy.selector_proxy_links=vless://one' "$WORK_DIR/runtime-mi
if grep -Fq '=connection_url' "$WORK_DIR/runtime-migrate.state"; then
fail "runtime migration must not create connection_url child sections"
fi
grep -Eq '^forkop\.cfg[0-9a-f]+=section_interface$' "$WORK_DIR/runtime-migrate.state" ||
fail "runtime migration must create an anonymous interface settings section"
grep -Eq '^forkop\.cfg[0-9a-f]+\.section=vpn$' "$WORK_DIR/runtime-migrate.state" ||
fail "runtime interface settings section must reference its parent"
grep -Eq '^forkop\.cfg[0-9a-f]+\.name=' "$WORK_DIR/runtime-migrate.state" ||
fail "runtime interface settings section must store the interface name"
if grep -Fq 'forkop.legacy.urltest_enabled=' "$WORK_DIR/runtime-migrate.state"; then
fail "runtime migration must delete migrated urltest_enabled through core.uci"
fi
@@ -391,6 +490,44 @@ fi
grep -Fxq 'commit forkop' "$WORK_DIR/runtime-migrate.log" ||
fail "runtime migration must commit through core.uci"
cat >"$WORK_DIR/runtime-version.state" <<'EOF_UCI'
forkop.settings=settings
forkop.settings.config_version=1.0.1
forkop.settings.component_update_check_enabled=0
forkop.main=section
forkop.main.enabled=1
forkop.main.action=connection
forkop.main.interfaces=awg0
EOF_UCI
: >"$WORK_DIR/runtime-version.log"
FORKOP_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \
FORKOP_UCI_LOG_FILE="$WORK_DIR/runtime-version.log" \
FORKOP_CONFIG_NAME="forkop" \
FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \
ucode -L "$FORKOP_LIB" "$MIGRATION" migrate
grep -Fxq 'forkop.settings.config_version=1.0.2' "$WORK_DIR/runtime-version.state" ||
fail "runtime version migration must advance config_version"
grep -Fxq 'forkop.settings.component_update_check_enabled=1' "$WORK_DIR/runtime-version.state" ||
fail "runtime version migration must enable component update checks"
grep -Fq 'forkop.settings.applied_migrations=interface_sections enable_component_checks' "$WORK_DIR/runtime-version.state" ||
fail "runtime migration must record stable migration names"
grep -Eq '^forkop\.cfg[0-9a-f]+=section_interface$' "$WORK_DIR/runtime-version.state" ||
fail "runtime version migration must create interface child settings"
if grep -Fq 'forkop.main.interfaces=' "$WORK_DIR/runtime-version.state"; then
fail "runtime version migration must remove the parent interface list"
fi
: >"$WORK_DIR/runtime-version.log"
FORKOP_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \
FORKOP_UCI_LOG_FILE="$WORK_DIR/runtime-version.log" \
FORKOP_CONFIG_NAME="forkop" \
FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \
ucode -L "$FORKOP_LIB" "$MIGRATION" migrate
if grep -Fq 'commit forkop' "$WORK_DIR/runtime-version.log"; then
fail "completed named migrations must be idempotent"
fi
: >"$WORK_DIR/uci-commit.log"
: >"$WORK_DIR/uci-commit.state"
FORKOP_UCI_STATE_FILE="$WORK_DIR/uci-commit.state" \
+5 -4
View File
@@ -86,10 +86,11 @@ grep -Fq 'install_json_ucode installer-cleanup-legacy' "$INSTALLER" ||
fail "install.sh cleanup must delegate to embedded ucode"
grep -Fq 'install_json_ucode installer-post-install' "$INSTALLER" ||
fail "install.sh post-install must delegate to embedded ucode"
grep -Fq 'installer_config_migration_path()' "$INSTALLER" ||
fail "install.sh must embed the one-time configuration migration"
grep -Fq 'ucode -L /usr/lib/forkop "$(installer_config_migration_path)" migrate' "$INSTALLER" ||
fail "install.sh must execute the embedded migration only during the legacy transition"
if grep -Fq 'installer_config_migration_path()' "$INSTALLER"; then
fail "install.sh must not embed configuration migration logic"
fi
grep -Fq 'ucode -L /usr/lib/forkop /usr/lib/forkop/config/migration.uc migrate-podkop' "$INSTALLER" ||
fail "install.sh must delegate the legacy transition to the installed migration module"
if grep -n -E 'restore_forkop_dnsmasq_failsafe|remember_service_state|stop_conflicting_services|deactivate_original_forkop_if_present|remove_conflicting_dns_proxy|pkg_remove_if_installed|pkg_remove_matching_prefix|pkg_list_installed_names' "$INSTALLER" >/dev/null 2>&1; then
fail "install.sh must not keep shell cleanup/remove service owners"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
grep -Fq 'function dnsTypeChoices() {' "$SECTION_JS" ||
fail "network interface settings must define DNS protocol choices"
grep -Fq 'dnsTypeChoices().forEach((choice) => o.value(choice.value, choice.label));' "$SECTION_JS" ||
fail "network interface settings must populate the DNS protocol field"
grep -Fq 'o.renderItemSettingsModal = showInterfaceSettingsModal;' "$SECTION_JS" ||
fail "network interfaces must keep their settings modal handler"
printf 'LuCI network interface settings checks passed\n'
+10
View File
@@ -52,6 +52,16 @@ grep -Fq 'APK_INTERNAL_VERSION="$RELEASE_VERSION"' "$BUILD_SCRIPT" ||
fail "build.sh must use the exact three-part release version for APK metadata"
grep -Fq "option component_update_check_enabled '1'" "$FORKOP_CONFIG" ||
fail "new installations must enable component update checks by default"
grep -Fq "option config_version '1.0.2'" "$FORKOP_CONFIG" ||
fail "new installations must start at the current configuration schema version"
grep -Fq "list applied_migrations 'interface_sections'" "$FORKOP_CONFIG" ||
fail "new installations must mark the interface section migration as applied"
grep -Fq "list applied_migrations 'enable_component_checks'" "$FORKOP_CONFIG" ||
fail "new installations must mark the component check migration as applied"
grep -Fq '/usr/lib/forkop/config/migration.uc migrate' "$FORKOP_MAKEFILE" ||
fail "OpenWrt package postinst must run configuration migrations"
[ "$(grep -Fc '/usr/lib/forkop/config/migration.uc migrate' "$BUILD_SCRIPT")" -ge 3 ] ||
fail "manual IPK/APK package scripts must run configuration migrations after install and upgrade"
if grep -Rqs 'require("uci")' "$FORKOP_LIB"; then
require_package_dependency "ucode-mod-uci"
+12 -1
View File
@@ -401,6 +401,17 @@ cat >"$WORK_DIR/sing-box-signature.json" <<'JSON'
"resolve_real_ip_for_routing": "yes"
}
],
"section_interface": [
{
".name": "vpn-interface",
".type": "section_interface",
"section": "vpn1",
"name": "wg0",
"domain_resolver_enabled": "1",
"domain_resolver_dns_type": "doh",
"domain_resolver_dns_server": "https://dns.example/dns-query"
}
],
"server": [
{
".name": "srv_disabled",
@@ -665,7 +676,7 @@ vpn
[rule.vpn1.subscription_urls]
[ ]
[rule.vpn1.interfaces]
[ "wg0" ]
[ { "name": "wg0", "domain_resolver_enabled": "1", "domain_resolver_dns_type": "doh", "domain_resolver_dns_server": "https://dns.example/dns-query" } ]
[rule.vpn1.outbound_jsons]
[rule.vpn1.legacy_interface]
+13 -1
View File
@@ -409,9 +409,19 @@ cat >"$WORK_DIR/vpn-interface-fixture.json" <<'JSON'
".type": "section",
"enabled": "1",
"action": "connection",
"interfaces": [ "tun0" ],
"domain_suffix": [ "vpn.example" ]
}
],
"section_interface": [
{
".name": "vpn-interface",
".type": "section_interface",
"section": "renamed_awg",
"name": "tun0",
"domain_resolver_enabled": "1",
"domain_resolver_dns_type": "doh",
"domain_resolver_dns_server": "https://dns.example/dns-query"
}
]
}
JSON
@@ -1105,6 +1115,8 @@ assert(outbound(manual, "proxy-3-out").type == "shadowsocks", "manual Shadowsock
let vpn = cfg("vpn");
assert(outbound(vpn, "renamed_awg-interface-1-out").bind_interface == "tun0", "renamed VPN interface outbound");
assert(outbound(vpn, "renamed_awg-interface-1-out").domain_resolver == "renamed_awg-interface-1-domain-resolver", "renamed VPN domain resolver tag");
assert(dns_server(vpn, r => r.tag == "renamed_awg-interface-1-domain-resolver") != null, "renamed VPN domain resolver DNS server");
assert(outbound(vpn, "renamed_awg-interface-1-out").detour == null, "interface outbound must not receive a detour");
assert(route_rule(vpn, r => r.outbound == "renamed_awg-out" && contains(r.domain_suffix, "vpn.example")) != null, "renamed VPN custom domain route");
assert(dns_rule(vpn, r => contains(r.domain_suffix, "vpn.example")) != null, "renamed VPN custom domain DNS rule");