fix: cascade delete child UCI sections and harden installer against config reset

This commit is contained in:
Dushnilin
2026-09-09 13:30:24 +03:00
parent 142fff6b4a
commit 695a2bfe36
8 changed files with 909 additions and 62 deletions
@@ -0,0 +1,360 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-empty */
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('cascadeDeleteSection logic', () => {
let uciState: Record<string, any[]>;
let mockUci: any;
let mockFs: any;
let removedFiles: string[];
beforeEach(() => {
removedFiles = [];
uciState = {
section: [
{ '.name': 'sec1', '.type': 'section', label: 'Section 1' },
{
'.name': 'sec2',
'.type': 'section',
label: 'Section 2',
outbound_detour_section: 'sec1',
outbound_detour_enabled: '1',
dns_detour_section: 'sec1',
dns_detour_enabled: '1',
},
],
priority_group: [
{ '.name': 'pg1', '.type': 'priority_group', section: 'sec1' },
{ '.name': 'pg2', '.type': 'priority_group', section: 'sec2' },
],
priority_level: [
{ '.name': 'pl1', '.type': 'priority_level', group: 'pg1' },
{ '.name': 'pl2', '.type': 'priority_level', section: 'sec1' },
{ '.name': 'pl3', '.type': 'priority_level', group: 'pg2' },
],
subscription_url: [
{
'.name': 'sub1',
'.type': 'subscription_url',
section: 'sec1',
url: 'https://example.com/1',
},
{
'.name': 'sub2',
'.type': 'subscription_url',
section: 'sec2',
url: 'https://example.com/2',
},
],
section_interface: [
{
'.name': 'if1',
'.type': 'section_interface',
section: 'sec1',
interface: 'wg0',
},
],
urltest: [
{ '.name': 'ut1', '.type': 'urltest', section: 'sec1', interval: '3m' },
],
settings: [
{
'.name': 'settings',
'.type': 'settings',
smart_detect_sections: ['sec1', 'sec2'],
dns_detour_section: 'sec1',
download_lists_via_proxy_section: 'sec1',
download_components_via_proxy_section: 'sec1',
warp_proxy_section: 'sec1',
},
],
server: [
{
'.name': 'srv1',
'.type': 'server',
routing_section: 'sec1',
routing_mode: 'section',
},
{
'.name': 'srv2',
'.type': 'server',
routing_section: 'sec2',
routing_mode: 'section',
},
],
};
mockUci = {
sections: vi.fn((pkg: string, typeName: string) => {
return uciState[typeName] || [];
}),
get: vi.fn((pkg: string, sid: string, opt?: string) => {
for (const typeName in uciState) {
const found = uciState[typeName].find((s) => s['.name'] === sid);
if (found) {
return opt ? found[opt] : found;
}
}
return null;
}),
set: vi.fn((pkg: string, sid: string, opt: string, val: any) => {
for (const typeName in uciState) {
const found = uciState[typeName].find((s) => s['.name'] === sid);
if (found) {
found[opt] = val;
return;
}
}
}),
remove: vi.fn((pkg: string, sid: string) => {
for (const typeName in uciState) {
uciState[typeName] = uciState[typeName].filter(
(s) => s['.name'] !== sid,
);
}
}),
};
mockFs = {
remove: vi.fn(async (filePath: string) => {
removedFiles.push(filePath);
}),
exec: vi.fn(async (_cmd: string, args: string[]) => {
if (args && args.length > 1) {
removedFiles.push(...args.slice(1));
}
}),
};
});
function cascadeDelete(section_id: string, UCI_PACKAGE = 'tachyon') {
if (!section_id) return;
// 1. Remove child sections: priority_group, priority_level, subscription_url, section_interface, urltest
try {
const priorityGroups = (
mockUci.sections(UCI_PACKAGE, 'priority_group') || []
).filter((item: any) => item.section === section_id);
priorityGroups.forEach((group: any) => {
const groupId = group['.name'];
(mockUci.sections(UCI_PACKAGE, 'priority_level') || [])
.filter(
(level: any) =>
level.group === groupId || level.section === section_id,
)
.forEach((level: any) => {
mockUci.remove(UCI_PACKAGE, level['.name']);
});
mockUci.remove(UCI_PACKAGE, groupId);
});
(mockUci.sections(UCI_PACKAGE, 'priority_level') || [])
.filter((level: any) => level.section === section_id)
.forEach((level: any) => {
mockUci.remove(UCI_PACKAGE, level['.name']);
});
['subscription_url', 'section_interface', 'urltest'].forEach(
(typeName) => {
(mockUci.sections(UCI_PACKAGE, typeName) || [])
.filter((item: any) => item.section === section_id)
.forEach((item: any) => {
mockUci.remove(UCI_PACKAGE, item['.name']);
});
},
);
} catch (_e) {}
// 2. Clean references in settings
try {
const smartDetect = mockUci.get(
UCI_PACKAGE,
'settings',
'smart_detect_sections',
);
if (Array.isArray(smartDetect)) {
const filtered = smartDetect.filter((sec) => sec !== section_id);
if (filtered.length !== smartDetect.length) {
mockUci.set(
UCI_PACKAGE,
'settings',
'smart_detect_sections',
filtered,
);
}
} else if (
typeof smartDetect === 'string' &&
smartDetect.trim() === section_id
) {
mockUci.set(UCI_PACKAGE, 'settings', 'smart_detect_sections', []);
}
[
'dns_detour_section',
'download_lists_via_proxy_section',
'download_components_via_proxy_section',
'warp_proxy_section',
].forEach((opt) => {
if (mockUci.get(UCI_PACKAGE, 'settings', opt) === section_id) {
mockUci.set(UCI_PACKAGE, 'settings', opt, '');
}
});
} catch (_e) {}
// 3. Clean references in other sections
try {
(mockUci.sections(UCI_PACKAGE, 'section') || []).forEach((sec: any) => {
const sName = sec['.name'];
if (sName === section_id) return;
if (sec.outbound_detour_section === section_id) {
mockUci.set(UCI_PACKAGE, sName, 'outbound_detour_section', '');
mockUci.set(UCI_PACKAGE, sName, 'outbound_detour_enabled', '0');
}
if (sec.dns_detour_section === section_id) {
mockUci.set(UCI_PACKAGE, sName, 'dns_detour_section', '');
mockUci.set(UCI_PACKAGE, sName, 'dns_detour_enabled', '0');
}
});
} catch (_e) {}
// 4. Clean references in servers
try {
(mockUci.sections(UCI_PACKAGE, 'server') || []).forEach((srv: any) => {
const srvName = srv['.name'];
if (srv.routing_section === section_id) {
mockUci.set(UCI_PACKAGE, srvName, 'routing_section', '');
if (srv.routing_mode === 'section') {
mockUci.set(UCI_PACKAGE, srvName, 'routing_mode', 'rules');
}
}
});
} catch (_e) {}
// 5. Clean disk caches
try {
if (typeof mockFs !== 'undefined') {
if (typeof mockFs.remove === 'function') {
mockFs.remove(`/var/run/tachyon/section-cache/${section_id}.json`);
mockFs.remove(`/etc/tachyon/subscription-cache/${section_id}.json`);
mockFs.remove(`/etc/tachyon/subscription-cache/${section_id}.yaml`);
mockFs.remove(`/etc/tachyon/subscription-cache/${section_id}.txt`);
}
}
} catch (_e) {}
}
it('cascades deletion of all child sections for a given section', () => {
cascadeDelete('sec1');
// pg1 should be removed, pg2 kept
expect(
uciState.priority_group.find((g) => g['.name'] === 'pg1'),
).toBeUndefined();
expect(
uciState.priority_group.find((g) => g['.name'] === 'pg2'),
).toBeDefined();
// pl1 and pl2 should be removed, pl3 kept
expect(
uciState.priority_level.find((l) => l['.name'] === 'pl1'),
).toBeUndefined();
expect(
uciState.priority_level.find((l) => l['.name'] === 'pl2'),
).toBeUndefined();
expect(
uciState.priority_level.find((l) => l['.name'] === 'pl3'),
).toBeDefined();
// sub1, if1, ut1 removed; sub2 kept
expect(
uciState.subscription_url.find((s) => s['.name'] === 'sub1'),
).toBeUndefined();
expect(
uciState.subscription_url.find((s) => s['.name'] === 'sub2'),
).toBeDefined();
expect(
uciState.section_interface.find((i) => i['.name'] === 'if1'),
).toBeUndefined();
expect(uciState.urltest.find((u) => u['.name'] === 'ut1')).toBeUndefined();
});
it('cleans references in settings', () => {
cascadeDelete('sec1');
const settings = uciState.settings[0];
expect(settings.smart_detect_sections).toEqual(['sec2']);
expect(settings.dns_detour_section).toBe('');
expect(settings.download_lists_via_proxy_section).toBe('');
expect(settings.download_components_via_proxy_section).toBe('');
expect(settings.warp_proxy_section).toBe('');
});
it('cleans detour references in other sections', () => {
cascadeDelete('sec1');
const sec2 = uciState.section.find((s) => s['.name'] === 'sec2');
expect(sec2.outbound_detour_section).toBe('');
expect(sec2.outbound_detour_enabled).toBe('0');
expect(sec2.dns_detour_section).toBe('');
expect(sec2.dns_detour_enabled).toBe('0');
});
it('cleans server routing references and resets routing_mode', () => {
cascadeDelete('sec1');
const srv1 = uciState.server.find((s) => s['.name'] === 'srv1');
expect(srv1.routing_section).toBe('');
expect(srv1.routing_mode).toBe('rules');
const srv2 = uciState.server.find((s) => s['.name'] === 'srv2');
expect(srv2.routing_section).toBe('sec2');
expect(srv2.routing_mode).toBe('section');
});
it('removes disk cache files', () => {
cascadeDelete('sec1');
expect(removedFiles).toContain('/var/run/tachyon/section-cache/sec1.json');
expect(removedFiles).toContain('/etc/tachyon/subscription-cache/sec1.json');
expect(removedFiles).toContain('/etc/tachyon/subscription-cache/sec1.yaml');
expect(removedFiles).toContain('/etc/tachyon/subscription-cache/sec1.txt');
});
it('re-creating a section with the same name starts clean', () => {
// 1. Delete sec1
cascadeDelete('sec1');
mockUci.remove('tachyon', 'sec1');
// 2. Add sec1 again with purge
cascadeDelete('sec1');
uciState.section.push({
'.name': 'sec1',
'.type': 'section',
label: 'Fresh sec1',
});
// 3. Verify no leftover child sections exist for sec1
const childSubs = (
mockUci.sections('tachyon', 'subscription_url') || []
).filter((item: any) => item.section === 'sec1');
const childGroups = (
mockUci.sections('tachyon', 'priority_group') || []
).filter((item: any) => item.section === 'sec1');
const childLevels = (
mockUci.sections('tachyon', 'priority_level') || []
).filter((item: any) => item.section === 'sec1');
const childIfs = (
mockUci.sections('tachyon', 'section_interface') || []
).filter((item: any) => item.section === 'sec1');
const childUt = (mockUci.sections('tachyon', 'urltest') || []).filter(
(item: any) => item.section === 'sec1',
);
expect(childSubs).toHaveLength(0);
expect(childGroups).toHaveLength(0);
expect(childLevels).toHaveLength(0);
expect(childIfs).toHaveLength(0);
expect(childUt).toHaveLength(0);
});
});
+20 -11
View File
@@ -1285,7 +1285,9 @@ function installer_release_init_lock() {
}
function installer_cleanup_legacy() {
let tachyon_installed = installer_package_installed("tachyon");
let tachyon_installed = installer_package_installed("tachyon") ||
path_exists("/etc/config/tachyon") ||
path_executable(INSTALLER_TACHYON_BIN);
let legacy_installed = LEGACY_BRAND != "" && installer_package_installed(LEGACY_BACKEND_PACKAGE);
let active_init = legacy_installed ? INSTALLER_LEGACY_INIT : INSTALLER_TACHYON_INIT;
let active_bin = legacy_installed ? INSTALLER_LEGACY_BIN : INSTALLER_TACHYON_BIN;
@@ -1505,16 +1507,6 @@ function installer_post_install() {
warn("Failed to start Tachyon after upgrade.\n");
}
// Fresh installs must end up in a pristine default state: wipe leftover
// caches and runtime state from previous installations and rewrite the
// config from the bundled default. Upgrades and legacy migrations keep
// the user configuration untouched.
if (env("TACHYON_WAS_INSTALLED", "0") != "1" &&
env("TACHYON_LEGACY_DETECTED", "0") != "1") {
if (!run_args([ "timeout", "60", INSTALLER_TACHYON_BIN, "reset_settings", "no-start" ]))
warn("Failed to reset Tachyon settings to defaults after a fresh install.\n");
}
return true;
}
@@ -2531,6 +2523,22 @@ install_zram_if_requested() {
fi
}
backup_existing_config() {
[ "$DRY_RUN" -eq 1 ] && return 0
if [ -s /etc/config/tachyon ]; then
_ts="$(date +%Y%m%d_%H%M%S 2>/dev/null || date +%s 2>/dev/null || echo "0")"
_backup_file="/etc/config/tachyon.backup-${_ts}"
if cp -af /etc/config/tachyon "$_backup_file" 2>/dev/null; then
chmod 0600 "$_backup_file" 2>/dev/null || true
cp -af /etc/config/tachyon /etc/config/tachyon.bak 2>/dev/null || true
chmod 0600 /etc/config/tachyon.bak 2>/dev/null || true
log_line "INFO Safety backup of /etc/config/tachyon created at $_backup_file"
else
warn "Failed to create safety backup of /etc/config/tachyon"
fi
fi
}
cleanup_legacy_installation() {
if [ "$DRY_RUN" -eq 1 ]; then
msg "[dry-run] would stop/disable and remove legacy or conflicting packages"
@@ -2834,6 +2842,7 @@ main() {
detect_fetcher
detect_installer_language
check_system
backup_existing_config
# Print TUI banner
if [ "$QUIET" -eq 0 ] && command -v tui_banner >/dev/null 2>&1; then
@@ -7370,7 +7370,8 @@ 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: "Домены, заблокированные снаружи России (геоблокировка зарубежными сервисами)",
geoblock:
"Домены, заблокированные снаружи России (геоблокировка зарубежными сервисами)",
block: "Домены, заблокированные внутри России (полный реестр блокировок РКН)",
porn: "Pornhub, xvideos, xhamster, ...",
news: "meduza.io, bbc.com, pravda.com.ua, ...",
@@ -8460,12 +8461,13 @@ function createSectionContent(section) {
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;",
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) {}
} catch (e) {}
const urlSpan = E(
"span",
{
@@ -8546,8 +8548,14 @@ function createSectionContent(section) {
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");
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 = "";
@@ -8593,23 +8601,33 @@ function createSectionContent(section) {
const data = JSON.parse(res.stdout || "{}");
if (data.success) {
ok = true;
msg = _("Successfully updated %d hosts entries").format(data.entries);
msg = _("Successfully updated %d hosts entries").format(
data.entries,
);
} else {
msg = _("Update failed: ") + (data.error || res.stderr || "");
msg =
_("Update failed: ") + (data.error || res.stderr || "");
}
} catch (e) {
msg = res.stdout || res.stderr || _("Finished with code ") + res.code;
msg =
res.stdout ||
res.stderr ||
_("Finished with code ") + res.code;
}
ui.addNotification(
null,
E("p", {}, msg),
ok ? "info" : "danger"
ok ? "info" : "danger",
);
})
.catch(function (err) {
updateBtn.disabled = false;
updateBtn.textContent = _("Update Lists Now");
ui.addNotification(null, E("p", {}, err.message || err), "danger");
ui.addNotification(
null,
E("p", {}, err.message || err),
"danger",
);
});
},
},
@@ -8869,15 +8887,17 @@ function createSectionContent(section) {
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,
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);
@@ -8958,7 +8978,9 @@ function createSectionContent(section) {
if (val === undefined || val === null) return;
const str = String(val);
const el =
document.getElementById(`widget.cbid.${UCI_PACKAGE}.${section_id}.${opt}`) ||
document.getElementById(
`widget.cbid.${UCI_PACKAGE}.${section_id}.${opt}`,
) ||
document.getElementById(`cbid.${UCI_PACKAGE}.${section_id}.${opt}`);
const w =
el &&
@@ -9055,7 +9077,8 @@ function createSectionContent(section) {
const mtu = getConfVal("MTU");
if (mtu !== undefined) setVal("awg_mtu", mtu);
const keepalive = getConfVal("PersistentKeepalive") || getConfVal("Keepalive");
const keepalive =
getConfVal("PersistentKeepalive") || getConfVal("Keepalive");
if (keepalive !== undefined) setVal("awg_keepalive", keepalive);
const jc = getConfVal("Jc");
@@ -9129,9 +9152,17 @@ function createSectionContent(section) {
const randomTrailers = getConfVal("RandomTrailers") || "";
const disableCookies = getConfVal("DisableCookies") || "";
const isV31 = !!(rka || rkt || rja || kpt || mha || randomTrailers || 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");
const detectedVersion = isV31 ? "3.1" : isV30 ? "3.0" : "2.0";
setVal("awg_version", detectedVersion);
@@ -9151,8 +9182,22 @@ function createSectionContent(section) {
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");
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", "");
@@ -9169,10 +9214,12 @@ function createSectionContent(section) {
}
// --- [Peer] ---
const pub = getConfVal("PublicKey") || getConfVal("awg_peer_public_key");
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");
const psk =
getConfVal("PresharedKey") || getConfVal("awg_preshared_key");
if (psk) setVal("awg_preshared_key", psk);
const ep = getConfVal("Endpoint");
@@ -9303,7 +9350,9 @@ function createSectionContent(section) {
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)"),
_(
"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;
@@ -9397,16 +9446,47 @@ function createSectionContent(section) {
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"));
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"),
"settings",
form.Flag,
"awg_random_trailers",
_("Random Trailers"),
_("AmneziaWG 3.1 random packet trailers"),
);
o.modalonly = true;
@@ -9415,7 +9495,10 @@ function createSectionContent(section) {
o.depends({ action: "awg", awg_version: "3.1" });
o = section.taboption(
"settings", form.Flag, "awg_disable_cookies", _("Disable Cookies"),
"settings",
form.Flag,
"awg_disable_cookies",
_("Disable Cookies"),
_("AmneziaWG 3.1 disable cookie replies"),
);
o.modalonly = true;
@@ -9423,7 +9506,6 @@ function createSectionContent(section) {
o.default = "0";
o.depends({ action: "awg", awg_version: "3.1" });
// ── WARP (Cloudflare WARP via sing-box-extended) ──────────────────────────
o = section.taboption(
@@ -11009,12 +11091,24 @@ async function performTrace(query) {
}
// Check DNS Detour from settings if enabled
const dnsDetourEnabled = uci.get(UCI_PACKAGE, "settings", "dns_detour_enabled");
const dnsDetourEnabled = uci.get(
UCI_PACKAGE,
"settings",
"dns_detour_enabled",
);
if (dnsDetourEnabled === "1") {
const detourSection = uci.get(UCI_PACKAGE, "settings", "dns_detour_section");
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 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 = [];
@@ -11027,11 +11121,14 @@ async function performTrace(query) {
detourDomains.push(s);
}
}
const isDnsMatch = (type === "ip" && detourIps.includes(queryForMatching)) ||
(type === "domain" && detourDomains.includes(queryForMatching));
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";
const label =
uci.get(UCI_PACKAGE, detourSection, "label") || detourSection;
const action =
uci.get(UCI_PACKAGE, detourSection, "action") || "connection";
return {
matched: true,
sectionName: detourSection,
@@ -11612,6 +11709,132 @@ function showSectionRulesModal(section_id) {
);
}
function cascadeDeleteSection(section_id) {
if (!section_id) return;
// 1. Remove child sections: priority_group, priority_level, subscription_url, section_interface, urltest
try {
const priorityGroups = (
uci.sections(UCI_PACKAGE, "priority_group") || []
).filter((item) => item.section === section_id);
priorityGroups.forEach((group) => {
const groupId = group[".name"];
(uci.sections(UCI_PACKAGE, "priority_level") || [])
.filter(
(level) => level.group === groupId || level.section === section_id,
)
.forEach((level) => {
uci.remove(UCI_PACKAGE, level[".name"]);
});
uci.remove(UCI_PACKAGE, groupId);
});
(uci.sections(UCI_PACKAGE, "priority_level") || [])
.filter((level) => level.section === section_id)
.forEach((level) => {
uci.remove(UCI_PACKAGE, level[".name"]);
});
["subscription_url", "section_interface", "urltest"].forEach((typeName) => {
(uci.sections(UCI_PACKAGE, typeName) || [])
.filter((item) => item.section === section_id)
.forEach((item) => {
uci.remove(UCI_PACKAGE, item[".name"]);
});
});
} catch (_e) {}
// 2. Clean references in settings
try {
const smartDetect = uci.get(
UCI_PACKAGE,
"settings",
"smart_detect_sections",
);
if (Array.isArray(smartDetect)) {
const filtered = smartDetect.filter((sec) => sec !== section_id);
if (filtered.length !== smartDetect.length) {
uci.set(UCI_PACKAGE, "settings", "smart_detect_sections", filtered);
}
} else if (
typeof smartDetect === "string" &&
smartDetect.trim() === section_id
) {
uci.set(UCI_PACKAGE, "settings", "smart_detect_sections", []);
}
[
"dns_detour_section",
"download_lists_via_proxy_section",
"download_components_via_proxy_section",
"warp_proxy_section",
].forEach((opt) => {
if (uci.get(UCI_PACKAGE, "settings", opt) === section_id) {
uci.set(UCI_PACKAGE, "settings", opt, "");
}
});
} catch (_e) {}
// 3. Clean references in other sections
try {
(uci.sections(UCI_PACKAGE, "section") || []).forEach((sec) => {
const sName = sec[".name"];
if (sName === section_id) return;
if (sec.outbound_detour_section === section_id) {
uci.set(UCI_PACKAGE, sName, "outbound_detour_section", "");
uci.set(UCI_PACKAGE, sName, "outbound_detour_enabled", "0");
}
if (sec.dns_detour_section === section_id) {
uci.set(UCI_PACKAGE, sName, "dns_detour_section", "");
uci.set(UCI_PACKAGE, sName, "dns_detour_enabled", "0");
}
});
} catch (_e) {}
// 4. Clean references in servers
try {
(uci.sections(UCI_PACKAGE, "server") || []).forEach((srv) => {
const srvName = srv[".name"];
if (srv.routing_section === section_id) {
uci.set(UCI_PACKAGE, srvName, "routing_section", "");
if (srv.routing_mode === "section") {
uci.set(UCI_PACKAGE, srvName, "routing_mode", "rules");
}
}
});
} catch (_e) {}
// 5. Clean disk caches
try {
if (typeof fs !== "undefined") {
if (typeof fs.remove === "function") {
fs.remove(`/var/run/tachyon/section-cache/${section_id}.json`).catch(
() => null,
);
fs.remove(`/etc/tachyon/subscription-cache/${section_id}.json`).catch(
() => null,
);
fs.remove(`/etc/tachyon/subscription-cache/${section_id}.yaml`).catch(
() => null,
);
fs.remove(`/etc/tachyon/subscription-cache/${section_id}.txt`).catch(
() => null,
);
}
if (typeof fs.exec === "function") {
fs.exec("/bin/rm", [
"-f",
`/var/run/tachyon/section-cache/${section_id}.json`,
`/etc/tachyon/subscription-cache/${section_id}.json`,
`/etc/tachyon/subscription-cache/${section_id}.yaml`,
`/etc/tachyon/subscription-cache/${section_id}.txt`,
]).catch(() => null);
}
}
} catch (_e) {}
}
function configureSectionSection(sectionRef, options = {}) {
setActionProvidersAvailabilityLoader(options.loadActionProvidersAvailability);
@@ -11625,6 +11848,39 @@ function configureSectionSection(sectionRef, options = {}) {
return loadSectionTableOptions(this);
};
const originalRemove = sectionRef.remove;
sectionRef.remove = function (section_id) {
cascadeDeleteSection(section_id);
return originalRemove
? originalRemove.apply(this, arguments)
: form.GridSection.prototype.remove.apply(this, arguments);
};
const originalAdd = sectionRef.handleAdd;
sectionRef.handleAdd = function (ev, name) {
let sectionName =
typeof name === "string" && name.trim() ? name.trim() : null;
if (!sectionName && ev && ev.target) {
const container = ev.target.closest
? ev.target.closest(".cbi-section-create")
: null;
const input = container
? container.querySelector(".cbi-section-create-name")
: this.map
? document.querySelector(".cbi-section-create-name")
: null;
if (input && input.value) {
sectionName = input.value.trim();
}
}
if (sectionName) {
cascadeDeleteSection(sectionName);
}
return originalAdd
? originalAdd.apply(this, arguments)
: form.GridSection.prototype.handleAdd.apply(this, arguments);
};
const originalRender = sectionRef.render;
sectionRef.render = function () {
return originalRender.apply(this, arguments).then((node) => {
@@ -11638,6 +11894,7 @@ function configureSectionSection(sectionRef, options = {}) {
}
const EntryPoint = {
cascadeDeleteSection,
configureSectionSection,
createSectionContent,
setActionProvidersAvailabilityLoader,
@@ -88,6 +88,47 @@ function configureGridSection(sectionRef, type, title, addTitle) {
return renderSectionAdd(sectionRef, extra_class);
};
if (type === "section") {
const origRemove = sectionRef.remove;
sectionRef.remove = function (section_id) {
if (section && typeof section.cascadeDeleteSection === "function") {
section.cascadeDeleteSection(section_id);
}
return origRemove
? origRemove.apply(this, arguments)
: form.GridSection.prototype.remove.apply(this, arguments);
};
const origAdd = sectionRef.handleAdd;
sectionRef.handleAdd = function (ev, name) {
let sectionName =
typeof name === "string" && name.trim() ? name.trim() : null;
if (!sectionName && ev && ev.target) {
const container = ev.target.closest
? ev.target.closest(".cbi-section-create")
: null;
const input = container
? container.querySelector(".cbi-section-create-name")
: this.map
? document.querySelector(".cbi-section-create-name")
: null;
if (input && input.value) {
sectionName = input.value.trim();
}
}
if (
sectionName &&
section &&
typeof section.cascadeDeleteSection === "function"
) {
section.cascadeDeleteSection(sectionName);
}
return origAdd
? origAdd.apply(this, arguments)
: form.GridSection.prototype.handleAdd.apply(this, arguments);
};
}
sectionRef.renderRowActions = function (section_id) {
const els = form.TableSection.prototype.renderRowActions.call(
this,
+2
View File
@@ -67,6 +67,7 @@ function show_help() {
print(" disable Disable Tachyon autostart\n");
print(" reset_settings [no-start]\n");
print(" Reset Tachyon settings to factory defaults\n");
print(" delete_section <name> Cascade-delete a section and all its child configs\n");
print(" uninstall Remove Tachyon files installed outside opkg/apk\n");
print(" main Run main Tachyon process\n");
print(" list_update Update remote lists\n");
@@ -181,6 +182,7 @@ function command_spec(command) {
disable: [ "service/lifecycle.uc", "disable", 0 ],
uninstall: [ "service/uninstall.uc", "uninstall", 1 ],
reset_settings: [ "service/reset.uc", "reset-settings", 1 ],
delete_section: [ "config/connections.uc", "delete-section", 1 ],
dnsmasq_restore: [ "service/lifecycle.uc", "dnsmasq-restore", 0 ],
restore_dnsmasq: [ "service/lifecycle.uc", "dnsmasq-restore", 0 ],
main: [ "service/lifecycle.uc", "main", 0 ],
+118 -4
View File
@@ -1242,10 +1242,39 @@ function subscription_download_target_port(sections, target, base_port) {
}
// Cascade-delete all child sections (subscription_url, urltest, etc.) that
// belong to the given parent section. Returns the number of children removed.
// belong to the given parent section, along with cross-references and disk caches.
// Returns the number of children removed.
function cascade_delete_section(cursor, config_name, parent_name) {
let removed = 0;
for (let type_name in ITEM_TYPES) {
config_name = as_string(config_name || CONFIG_NAME);
parent_name = as_string(parent_name || "");
if (parent_name == "")
return 0;
// 1. Delete priority_level belonging to priority_group in this section
try {
let group_names = [];
cursor.foreach(config_name, "priority_group", function(section) {
if (option(section, "section", "") == parent_name)
push(group_names, section_name(section));
});
for (let gname in group_names) {
let level_names = [];
cursor.foreach(config_name, "priority_level", function(section) {
if (option(section, "group", "") == gname || option(section, "section", "") == parent_name)
push(level_names, section_name(section));
});
for (let lname in level_names) {
cursor.delete(config_name, lname);
removed++;
}
cursor.delete(config_name, gname);
removed++;
}
} catch (e) {}
// 2. Delete all other child sections (subscription_url, section_interface, urltest, priority_level)
for (let type_name in [ "subscription_url", "section_interface", "urltest", "priority_level" ]) {
try {
let to_delete = [];
cursor.foreach(config_name, type_name, function(section) {
@@ -1258,9 +1287,93 @@ function cascade_delete_section(cursor, config_name, parent_name) {
}
} catch (e) {}
}
// 3. Clean references in settings
try {
let smart_detect = cursor.get(config_name, "settings", "smart_detect_sections");
if (type(smart_detect) == "array") {
let filtered = [];
for (let sec in smart_detect) {
if (as_string(sec) != parent_name)
push(filtered, sec);
}
if (length(filtered) != length(smart_detect))
cursor.set(config_name, "settings", "smart_detect_sections", filtered);
} else if (as_string(smart_detect) == parent_name) {
cursor.delete(config_name, "settings", "smart_detect_sections");
}
for (let opt in [ "dns_detour_section", "download_lists_via_proxy_section", "download_components_via_proxy_section", "warp_proxy_section" ]) {
if (as_string(cursor.get(config_name, "settings", opt)) == parent_name)
cursor.delete(config_name, "settings", opt);
}
} catch (e) {}
// 4. Clean references in other sections
try {
cursor.foreach(config_name, "section", function(sec) {
let sname = section_name(sec);
if (sname == parent_name) return;
if (option(sec, "outbound_detour_section", "") == parent_name) {
cursor.delete(config_name, sname, "outbound_detour_section");
cursor.set(config_name, sname, "outbound_detour_enabled", "0");
}
if (option(sec, "dns_detour_section", "") == parent_name) {
cursor.delete(config_name, sname, "dns_detour_section");
cursor.set(config_name, sname, "dns_detour_enabled", "0");
}
});
} catch (e) {}
// 5. Clean references in servers
try {
cursor.foreach(config_name, "server", function(srv) {
let sname = section_name(srv);
if (option(srv, "routing_section", "") == parent_name) {
cursor.delete(config_name, sname, "routing_section");
if (option(srv, "routing_mode", "") == "section")
cursor.set(config_name, sname, "routing_mode", "rules");
}
});
} catch (e) {}
// 6. Delete section cache files
try {
command_success_from_args([ "rm", "-f",
"/var/run/tachyon/section-cache/" + parent_name + ".json",
"/etc/tachyon/subscription-cache/" + parent_name + ".json",
"/etc/tachyon/subscription-cache/" + parent_name + ".yaml",
"/etc/tachyon/subscription-cache/" + parent_name + ".txt"
]);
} catch (e) {}
return removed;
}
function cli_delete_section(sec_name) {
sec_name = trim(as_string(sec_name));
if (sec_name == "") {
warn("Usage: tachyon delete_section <section_name>\n");
return 1;
}
let c = uci_core.cursor();
c.load(CONFIG_NAME);
let s = c.get_all(CONFIG_NAME, sec_name);
if (!s) {
warn("Section '" + sec_name + "' not found\n");
return 1;
}
cascade_delete_section(c, CONFIG_NAME, sec_name);
c.delete(CONFIG_NAME, sec_name);
c.commit(CONFIG_NAME);
print(sprintf("{\"success\":true,\"deleted\":%J}\n", sec_name));
return 0;
}
let cli_mode = as_string(ARGV[0]);
if (cli_mode == "delete-section")
exit(cli_delete_section(ARGV[1]));
return {
option,
bool_option,
@@ -1405,5 +1518,6 @@ return {
subscription_download_targets,
subscription_download_target_port,
has_dns_matchers,
item_index_from_cursor
};
item_index_from_cursor,
cli_delete_section
};
+15 -2
View File
@@ -42,7 +42,9 @@ const SERVER_COUNTRY_METHOD_COUNTRY_IS = "country_is";
const CHILD_ITEM_TYPES = [
"subscription_url",
"section_interface",
"urltest"
"urltest",
"priority_group",
"priority_level"
];
function shell_quote(value) {
@@ -1439,13 +1441,24 @@ function migrate_orphan_section_interfaces(ctx) {
for (let section in ctx.model.sections)
owners[section_name(section)] = true;
let group_owners = {};
for (let group in ctx.model.priority_group || [])
group_owners[section_name(group)] = true;
let child_types = [ "subscription_url", "section_interface", "urltest", "priority_group", "priority_level" ];
for (let type_name in child_types) {
let kept = [];
let changed = false;
for (let child in ctx.model[type_name] || []) {
let owner = option(child, "section", "");
if (owner != "" && !owners[owner]) {
let group = option(child, "group", "");
let is_orphan = false;
if (owner != "" && !owners[owner])
is_orphan = true;
if (type_name == "priority_level" && group != "" && !group_owners[group])
is_orphan = true;
if (is_orphan) {
record_operation(ctx, { op: "delete_section", section: section_name(child) });
changed = true;
continue;
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
# 1. Structural checks in CLI and backend ucode
grep -Fq 'delete_section' "$ROOT_DIR/tachyon/files/usr/bin/tachyon" ||
fail "tachyon CLI must declare delete_section command"
grep -Fq 'cascade_delete_section' "$ROOT_DIR/tachyon/files/usr/lib/config/connections.uc" ||
fail "connections.uc must implement cascade_delete_section"
grep -Fq 'cli_delete_section' "$ROOT_DIR/tachyon/files/usr/lib/config/connections.uc" ||
fail "connections.uc must export cli_delete_section"
grep -Fq 'priority_group' "$ROOT_DIR/tachyon/files/usr/lib/config/migration.uc" ||
fail "migration.uc CHILD_ITEM_TYPES must include priority_group"
grep -Fq 'priority_level' "$ROOT_DIR/tachyon/files/usr/lib/config/migration.uc" ||
fail "migration.uc CHILD_ITEM_TYPES must include priority_level"
# 2. Structural checks in LuCI JS view
grep -Fq 'cascadeDeleteSection' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" ||
fail "section.js must implement cascadeDeleteSection"
grep -Fq 'cascadeDeleteSection(section_id)' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" ||
fail "section.js remove handler must invoke cascadeDeleteSection"
grep -Fq 'cascadeDeleteSection' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js" ||
fail "tachyon.js must invoke cascadeDeleteSection on section remove/add"
# 3. Installer safety checks
grep -Fq 'backup_existing_config' "$ROOT_DIR/install.sh" ||
fail "install.sh must invoke backup_existing_config"
if grep -n 'reset_settings' "$ROOT_DIR/install.sh" | grep -v 'msg ' | grep -v '#' >/dev/null 2>&1; then
fail "install.sh must never automatically invoke reset_settings"
fi
printf 'section cascade deletion and installer checks passed\n'