From 44016ec95bded6757aad6266de138a98fdd01283 Mon Sep 17 00:00:00 2001 From: Dushnilin Date: Sat, 22 Aug 2026 10:08:05 +0300 Subject: [PATCH] =?UTF-8?q?fix(config):=20=D0=BB=D0=BE=D0=B6=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=20AWG,=20?= =?UTF-8?q?=D1=81=D0=B8=D1=80=D0=BE=D1=82=D1=8B=20section=5Finterface,=20?= =?UTF-8?q?=D0=B4=D1=83=D0=B1=D0=BB=D1=8C=20cron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - has_outbound_section: туннельные секции (awg/warp/vpn/openvpn/masque/ anytls/snell/mieru/sudoku) теперь считаются исходящей конфигурацией - одинокая AWG-секция больше не получает вводящее в заблуждение «No proxy outbound sections found» (жалобы пользователей #39/#42) - новая миграция orphan_section_interface_cleanup: удаление секции маршрутизации раньше оставляло после себя дочерние section_interface, ссылающиеся на несуществующего родителя (у пользователя - запись на секцию '2' при живой только '1'); сироты удаляются через новую операцию delete_section; функция объявлена до массива MIGRATIONS - ucode не поднимает объявления - strip_unmarked_tachyon_cron_lines: старые версии писали cron-строки без маркер-комментария, они переживали фильтрацию по маркерам, копились при обновлениях и crond запускал component_updates_if_due дважды за тик; немаркированные строки с нашими командами теперь вычищаются при каждом refresh и remove --- tachyon/files/usr/lib/components/updates.uc | 43 ++++++++++++++++++++- tachyon/files/usr/lib/config/migration.uc | 30 +++++++++++++- tachyon/files/usr/lib/config/validator.uc | 7 ++++ tests/config_migration.sh | 8 ++-- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/tachyon/files/usr/lib/components/updates.uc b/tachyon/files/usr/lib/components/updates.uc index 214a51a4..279a8478 100644 --- a/tachyon/files/usr/lib/components/updates.uc +++ b/tachyon/files/usr/lib/components/updates.uc @@ -710,6 +710,36 @@ function cron_refresh_plan(settings, sections, bin, list_marker, subscription_ma print_cron_refresh_plan(cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker, component_marker)); } +// Older releases wrote tachyon cron jobs WITHOUT the marker comment; those +// lines survive marker-based filtering, stack up across upgrades and make +// crond run the same job twice per tick. Drop unmarked lines that invoke a +// tachyon-owned cron command via our binary. +function strip_unmarked_tachyon_cron_lines(data, bin, markers) { + data = as_string(data); + if (data == "") + return ""; + + let escaped_bin = replace(as_string(bin), /([^0-9A-Za-z_])/g, "\\$1"); + let pattern = "^[^#\\n]*" + escaped_bin + "[ \\t]+(list_update|subscription_update|component_updates_if_due)([ \\t]|$)"; + + let lines = split(data, "\n"); + let has_trailing_newline = substr(data, length(data) - 1) == "\n"; + let result = ""; + + for (let i = 0; i < length(lines); i++) { + let line = as_string(lines[i]); + if (i == length(lines) - 1 && has_trailing_newline && line == "") + continue; + if (line_contains_any_marker(line, markers)) + continue; + if (match(line, pattern) != null) + continue; + result += line + "\n"; + } + + return result; +} + function cron_refresh_apply_result(settings, sections, existing_crontab, bin, list_marker, subscription_marker, component_marker) { let plan = cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker, component_marker); let filtered_crontab = filter_cron_markers_text(existing_crontab, [ list_marker, subscription_marker, component_marker ]); @@ -754,7 +784,9 @@ function cron_refresh_apply_result(settings, sections, existing_crontab, bin, li return { status: int(plan.status || 0), - crontab: filtered_crontab + (int(plan.status || 0) == 0 ? cron_jobs : ""), + crontab: strip_unmarked_tachyon_cron_lines( + filtered_crontab, bin, [ list_marker, subscription_marker, component_marker ] + ) + (int(plan.status || 0) == 0 ? cron_jobs : ""), logs }; } @@ -774,6 +806,11 @@ function write_crontab_text(text) { return ok; } +// Older releases wrote tachyon cron jobs WITHOUT the marker comment; those +// lines survive marker-based filtering, stack up across upgrades and make +// crond run the same job twice per tick. Drop unmarked lines that invoke a +// tachyon-owned cron command via our binary. + function log_cron_apply_result(result) { for (let item in array_or_empty(object_or_empty(result).logs)) log_message(item.message, item.level); @@ -781,8 +818,10 @@ function log_cron_apply_result(result) { function remove_cron_jobs(list_marker, subscription_marker, component_marker) { let crontab = command_output_from_args([ "crontab", "-l" ]); + let markers = [ list_marker, subscription_marker, component_marker ]; let result = { - crontab: filter_cron_markers_text(crontab, [ list_marker, subscription_marker, component_marker ]), + crontab: strip_unmarked_tachyon_cron_lines( + filter_cron_markers_text(crontab, markers), BIN_PATH, markers), logs: [ { level: "info", message: "The cron job removed" } ] }; diff --git a/tachyon/files/usr/lib/config/migration.uc b/tachyon/files/usr/lib/config/migration.uc index d85a2fe9..5bd7ff40 100644 --- a/tachyon/files/usr/lib/config/migration.uc +++ b/tachyon/files/usr/lib/config/migration.uc @@ -1374,14 +1374,40 @@ function migrate_global_hosts_to_section(ctx) { } } +function migrate_orphan_section_interfaces(ctx) { + // Deleting a routing section used to leave its section_interface child + // records behind. A parent-less record never matches any section during + // config generation - dead weight that only confuses config readers. + let owners = {}; + for (let section in ctx.model.sections) + owners[section_name(section)] = true; + + let kept = []; + let changed = false; + for (let child in ctx.model.section_interface || []) { + let owner = option(child, "section", ""); + if (owner != "" && !owners[owner]) { + record_operation(ctx, { op: "delete_section", section: section_name(child) }); + changed = true; + continue; + } + push(kept, child); + } + + if (changed) + ctx.model.section_interface = kept; +} const MIGRATIONS = [ { id: "interface_sections", run: migrate_interface_sections }, { id: "enable_component_checks", run: migrate_enable_component_checks }, { id: "http_connection_urls", run: migrate_http_connection_urls }, { id: "dns_hosts_to_option", run: migrate_dns_hosts_to_option }, - { id: "global_hosts_to_section", run: migrate_global_hosts_to_section } + { id: "global_hosts_to_section", run: migrate_global_hosts_to_section }, + { id: "orphan_section_interface_cleanup", run: migrate_orphan_section_interfaces } ]; + + function apply_migrations(ctx) { ctx.source_release = option(ctx.model.settings, CONFIG_VERSION_OPTION, ""); @@ -1551,6 +1577,8 @@ function apply_operations(cursor, operations) { cursor.set(CONFIG_NAME, section_ref(op.section), op.option, op.value); else if (op.op == "delete") cursor.delete(CONFIG_NAME, section_ref(op.section), op.option); + else if (op.op == "delete_section") + cursor.delete(CONFIG_NAME, section_ref(op.section)); else if (op.op == "add_list") cursor.set(CONFIG_NAME, section_ref(op.section), op.option, op.values); else if (op.op == "set_list") diff --git a/tachyon/files/usr/lib/config/validator.uc b/tachyon/files/usr/lib/config/validator.uc index f51d45f9..1722a355 100644 --- a/tachyon/files/usr/lib/config/validator.uc +++ b/tachyon/files/usr/lib/config/validator.uc @@ -2054,6 +2054,13 @@ function has_outbound_section(ctx) { if (action == "zapret2" && ctx.zapret2_installed) return true; + // Tunnel-style actions generate real sing-box endpoints/outbounds; + // without this check a lone AWG/WARP/VPN section triggered the + // misleading "No proxy outbound sections found" warning. + if (contains([ "awg", "warp", "vpn", "openvpn", "masque", + "anytls", "snell", "mieru", "sudoku" ], as_string(action))) + return true; + if (length(connections.connection_urls(section)) > 0 || length(connections.subscription_urls(section)) > 0 || length(connections.outbound_jsons(section)) > 0 || diff --git a/tests/config_migration.sh b/tests/config_migration.sh index ab41f8fc..9c9fed6b 100644 --- a/tests/config_migration.sh +++ b/tests/config_migration.sh @@ -175,7 +175,7 @@ assert(JSON.stringify(config.settings.dns_server) === JSON.stringify(['9.9.9.9'] 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.5', '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', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section']), 'legacy config should record named migrations'); +assert(JSON.stringify(config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section', 'orphan_section_interface_cleanup']), 'legacy config should record named migrations'); function assert(condition, message) { if (!condition) { @@ -394,7 +394,7 @@ function assert(condition, message) { assert(out.changed === true, '1.0.1 config should require migration'); assert(out.config.settings.config_version === '1.0.5', 'config schema version should advance to 1.0.5'); 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', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section']), 'named migrations should be recorded'); +assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section', 'orphan_section_interface_cleanup']), '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) { @@ -432,7 +432,7 @@ function assert(condition, message) { assert(out.config.settings.component_update_check_enabled === '0', '1.0.2 config must preserve an explicitly disabled component check'); assert(out.config.settings.config_version === '1.0.5', '1.0.2 config should advance through the HTTP URL migration schema'); -assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section']), 'newer configs should mark skipped migrations'); +assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section', 'orphan_section_interface_cleanup']), 'newer configs should mark skipped migrations'); NODE cat >"$WORK_DIR/forkop-1.0.4-http.json" <<'JSON' @@ -498,7 +498,7 @@ assert(JSON.stringify(jsonOutbounds[1]) === JSON.stringify({ server_port: 8080, }), 'unnamed HTTP URL should receive a unique http tag'); assert(jsonOutbounds[2].type === 'direct' && jsonOutbounds[2].tag === 'http', 'existing JSON outbounds should remain unchanged'); -assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section']), 'HTTP URL migration should be recorded'); +assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section', 'orphan_section_interface_cleanup']), 'HTTP URL migration should be recorded'); NODE cat >"$WORK_DIR/forkop-1.0.5-http.json" <<'JSON'