fix(config): ложное предупреждение про AWG, сироты section_interface, дубль cron

- 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
This commit is contained in:
Dushnilin
2026-08-22 10:08:05 +03:00
parent c788c86184
commit 44016ec95b
4 changed files with 81 additions and 7 deletions
+41 -2
View File
@@ -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" } ]
};
+29 -1
View File
@@ -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")
@@ -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 ||
+4 -4
View File
@@ -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'