diff --git a/fe-app-tachyon/src/tachyon/tests/sectionCascadeDelete.test.ts b/fe-app-tachyon/src/tachyon/tests/sectionCascadeDelete.test.ts new file mode 100644 index 00000000..51449e78 --- /dev/null +++ b/fe-app-tachyon/src/tachyon/tests/sectionCascadeDelete.test.ts @@ -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; + 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); + }); +}); diff --git a/install.sh b/install.sh index 972549cf..1d321909 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js index 1d1e856d..c04555e6 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js @@ -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, diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js index fa70755c..4e8bd25f 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js @@ -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, diff --git a/tachyon/files/usr/bin/tachyon b/tachyon/files/usr/bin/tachyon index 0fae5c93..6b0b5cd7 100755 --- a/tachyon/files/usr/bin/tachyon +++ b/tachyon/files/usr/bin/tachyon @@ -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 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 ], diff --git a/tachyon/files/usr/lib/config/connections.uc b/tachyon/files/usr/lib/config/connections.uc index 771cafed..0e49054c 100644 --- a/tachyon/files/usr/lib/config/connections.uc +++ b/tachyon/files/usr/lib/config/connections.uc @@ -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 \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 -}; \ No newline at end of file + item_index_from_cursor, + cli_delete_section +}; diff --git a/tachyon/files/usr/lib/config/migration.uc b/tachyon/files/usr/lib/config/migration.uc index 7ac0225f..80e9dbd0 100644 --- a/tachyon/files/usr/lib/config/migration.uc +++ b/tachyon/files/usr/lib/config/migration.uc @@ -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; diff --git a/tests/section_cascade_delete.sh b/tests/section_cascade_delete.sh new file mode 100644 index 00000000..1b79d10e --- /dev/null +++ b/tests/section_cascade_delete.sh @@ -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'