diff --git a/fe-app-tachyon/fill-po-translations.js b/fe-app-tachyon/fill-po-translations.js index 06da99fa..af70ae3c 100644 --- a/fe-app-tachyon/fill-po-translations.js +++ b/fe-app-tachyon/fill-po-translations.js @@ -1249,6 +1249,9 @@ const dictionary = { "All components": "Все компоненты", "Tachyon Core": "Ядро Tachyon", "LuCI Web UI": "Веб-интерфейс LuCI", + "Rollback": "Откатить", + "Save component backup before update": "Сохранять резервную копию перед обновлением", + "Keep the previous working binary/package on the router flash for instant offline rollback if needed. Consumes extra storage.": "Сохранять предыдущую рабочую версию компонента в памяти роутера для быстрого локального отката. Занимает дополнительное место на flash.", }; function escapePoString(str) { diff --git a/fe-app-tachyon/locales/tachyon.ru.po b/fe-app-tachyon/locales/tachyon.ru.po index e2e93df0..29f956cf 100644 --- a/fe-app-tachyon/locales/tachyon.ru.po +++ b/fe-app-tachyon/locales/tachyon.ru.po @@ -5386,3 +5386,12 @@ msgstr "Очистить историю" msgid "Auto-applied best strategy" msgstr "Лучшая стратегия автоматически применена" + +msgid "Rollback" +msgstr "Откатить" + +msgid "Save component backup before update" +msgstr "Сохранять резервную копию перед обновлением" + +msgid "Keep the previous working binary/package on the router flash for instant offline rollback if needed. Consumes extra storage." +msgstr "Сохранять предыдущую рабочую версию компонента в памяти роутера для быстрого локального отката. Занимает дополнительное место на flash." diff --git a/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts b/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts index 8fe3b146..075edf0f 100644 --- a/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts +++ b/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts @@ -7,8 +7,10 @@ const componentActionKeyMap: Record = { 'tachyon:check_update': 'tachyonCheck', 'tachyon:install': 'tachyonInstall', 'tachyon:reinstall': 'tachyonReinstall', + 'tachyon:rollback': 'tachyonRollback', 'sing_box:check_update': 'singBoxCheck', 'sing_box:install': 'singBoxInstall', + 'sing_box:rollback': 'singBoxRollback', 'sing_box:install_extended': 'singBoxInstallExtended', 'sing_box:install_extended_compressed': 'singBoxInstallExtendedCompressed', 'sing_box:install_lx': 'singBoxInstallLx', @@ -17,15 +19,19 @@ const componentActionKeyMap: Record = { 'zapret:check_update': 'zapretCheck', 'zapret:install': 'zapretInstall', 'zapret:remove': 'zapretRemove', + 'zapret:rollback': 'zapretRollback', 'zapret2:check_update': 'zapret2Check', 'zapret2:install': 'zapret2Install', 'zapret2:remove': 'zapret2Remove', + 'zapret2:rollback': 'zapret2Rollback', 'byedpi:check_update': 'byedpiCheck', 'byedpi:install': 'byedpiInstall', 'byedpi:remove': 'byedpiRemove', + 'byedpi:rollback': 'byedpiRollback', 'tailscale:check_update': 'tailscaleCheck', 'tailscale:install': 'tailscaleInstall', 'tailscale:remove': 'tailscaleRemove', + 'tailscale:rollback': 'tailscaleRollback', }; export function getComponentActionKey( diff --git a/fe-app-tachyon/src/tachyon/services/store.service.ts b/fe-app-tachyon/src/tachyon/services/store.service.ts index 245ff3b1..0b360bb2 100644 --- a/fe-app-tachyon/src/tachyon/services/store.service.ts +++ b/fe-app-tachyon/src/tachyon/services/store.service.ts @@ -216,14 +216,24 @@ export interface StoreType { sing_box_lx: number; sing_box_tailscale: number; sing_box_repo_url?: string; + sing_box_backup_version?: string; + sing_box_backup_time?: number; zapret_version: string; zapret_installed: number; + zapret_backup_version?: string; + zapret_backup_time?: number; zapret2_version: string; zapret2_installed: number; + zapret2_backup_version?: string; + zapret2_backup_time?: number; byedpi_version: string; byedpi_installed: number; + byedpi_backup_version?: string; + byedpi_backup_time?: number; tailscale_version: string; tailscale_installed: number; + tailscale_backup_version?: string; + tailscale_backup_time?: number; server_inbounds_enabled_count: number; openwrt_version: string; device_model: string; @@ -232,8 +242,10 @@ export interface StoreType { tachyonCheck: { loading: boolean }; tachyonInstall: { loading: boolean }; tachyonReinstall: { loading: boolean }; + tachyonRollback: { loading: boolean }; singBoxCheck: { loading: boolean }; singBoxInstall: { loading: boolean }; + singBoxRollback: { loading: boolean }; singBoxInstallExtended: { loading: boolean }; singBoxInstallExtendedCompressed: { loading: boolean }; singBoxInstallLx: { loading: boolean }; @@ -242,15 +254,19 @@ export interface StoreType { zapretCheck: { loading: boolean }; zapretInstall: { loading: boolean }; zapretRemove: { loading: boolean }; + zapretRollback: { loading: boolean }; zapret2Check: { loading: boolean }; zapret2Install: { loading: boolean }; zapret2Remove: { loading: boolean }; + zapret2Rollback: { loading: boolean }; byedpiCheck: { loading: boolean }; byedpiInstall: { loading: boolean }; byedpiRemove: { loading: boolean }; + byedpiRollback: { loading: boolean }; tailscaleCheck: { loading: boolean }; tailscaleInstall: { loading: boolean }; tailscaleRemove: { loading: boolean }; + tailscaleRollback: { loading: boolean }; }; updatesChecks: Record< Tachyon.ComponentName, diff --git a/fe-app-tachyon/src/tachyon/services/uiState.service.ts b/fe-app-tachyon/src/tachyon/services/uiState.service.ts index 2871b8b3..180253be 100644 --- a/fe-app-tachyon/src/tachyon/services/uiState.service.ts +++ b/fe-app-tachyon/src/tachyon/services/uiState.service.ts @@ -16,8 +16,10 @@ function getEmptyUpdatesActions(): StoreType['updatesActions'] { tachyonCheck: { loading: false }, tachyonInstall: { loading: false }, tachyonReinstall: { loading: false }, + tachyonRollback: { loading: false }, singBoxCheck: { loading: false }, singBoxInstall: { loading: false }, + singBoxRollback: { loading: false }, singBoxInstallExtended: { loading: false }, singBoxInstallExtendedCompressed: { loading: false }, singBoxInstallLx: { loading: false }, @@ -26,15 +28,19 @@ function getEmptyUpdatesActions(): StoreType['updatesActions'] { zapretCheck: { loading: false }, zapretInstall: { loading: false }, zapretRemove: { loading: false }, + zapretRollback: { loading: false }, zapret2Check: { loading: false }, zapret2Install: { loading: false }, zapret2Remove: { loading: false }, + zapret2Rollback: { loading: false }, byedpiCheck: { loading: false }, byedpiInstall: { loading: false }, byedpiRemove: { loading: false }, + byedpiRollback: { loading: false }, tailscaleCheck: { loading: false }, tailscaleInstall: { loading: false }, tailscaleRemove: { loading: false }, + tailscaleRollback: { loading: false }, }; } diff --git a/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts b/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts index 5526aa00..c65b5425 100644 --- a/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts @@ -88,14 +88,24 @@ export const initialDiagnosticStore: Pick< sing_box_lx: 0, sing_box_tailscale: 1, sing_box_repo_url: '', + sing_box_backup_version: '', + sing_box_backup_time: 0, zapret_version: 'loading', zapret_installed: 0, + zapret_backup_version: '', + zapret_backup_time: 0, zapret2_version: 'loading', zapret2_installed: 0, + zapret2_backup_version: '', + zapret2_backup_time: 0, byedpi_version: 'loading', byedpi_installed: 0, + byedpi_backup_version: '', + byedpi_backup_time: 0, tailscale_version: 'loading', tailscale_installed: 0, + tailscale_backup_version: '', + tailscale_backup_time: 0, server_inbounds_enabled_count: -1, openwrt_version: 'loading', device_model: 'loading', @@ -144,8 +154,10 @@ export const initialDiagnosticStore: Pick< tachyonCheck: { loading: false }, tachyonInstall: { loading: false }, tachyonReinstall: { loading: false }, + tachyonRollback: { loading: false }, singBoxCheck: { loading: false }, singBoxInstall: { loading: false }, + singBoxRollback: { loading: false }, singBoxInstallExtended: { loading: false }, singBoxInstallExtendedCompressed: { loading: false }, singBoxInstallLx: { loading: false }, @@ -154,15 +166,19 @@ export const initialDiagnosticStore: Pick< zapretCheck: { loading: false }, zapretInstall: { loading: false }, zapretRemove: { loading: false }, + zapretRollback: { loading: false }, zapret2Check: { loading: false }, zapret2Install: { loading: false }, zapret2Remove: { loading: false }, + zapret2Rollback: { loading: false }, byedpiCheck: { loading: false }, byedpiInstall: { loading: false }, byedpiRemove: { loading: false }, + byedpiRollback: { loading: false }, tailscaleCheck: { loading: false }, tailscaleInstall: { loading: false }, tailscaleRemove: { loading: false }, + tailscaleRollback: { loading: false }, }, updatesChecks: { tachyon: { status: null, latest_version: '', release_url: '' }, diff --git a/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts b/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts index b580eaf4..51d9bab0 100644 --- a/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts +++ b/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts @@ -1037,24 +1037,58 @@ function getInstalledUpdateActions( return actions; } +function getComponentBackupVersion(component: Tachyon.ComponentName): string { + const sys = store.get().diagnosticsSystemInfo; + switch (component) { + case 'sing_box': + return sys.sing_box_backup_version || ''; + case 'zapret': + return sys.zapret_backup_version || ''; + case 'zapret2': + return sys.zapret2_backup_version || ''; + case 'byedpi': + return sys.byedpi_backup_version || ''; + case 'tailscale': + return sys.tailscale_backup_version || ''; + default: + return ''; + } +} + +function getRollbackAction( + component: Tachyon.ComponentName, + key: UpdatesActionKey, + backupVersion: string, +): ComponentActionButton { + return { + key, + text: backupVersion ? `${_('Rollback')} (${backupVersion})` : _('Rollback'), + icon: renderRotateCcwIcon24, + component, + action: 'rollback', + }; +} + function getOptionalComponentActions({ component, installed, checkKey, installKey, removeKey, + rollbackKey, }: { component: 'zapret' | 'zapret2' | 'byedpi' | 'tailscale'; installed: boolean; checkKey: UpdatesActionKey; installKey: UpdatesActionKey; removeKey: UpdatesActionKey; + rollbackKey: UpdatesActionKey; }) { if (!installed) { return [getInstallAction(component, installKey, false)]; } - return [ + const actions = [ ...getInstalledUpdateActions(component, checkKey, installKey), { key: removeKey, @@ -1064,6 +1098,13 @@ function getOptionalComponentActions({ action: 'remove' as const, }, ]; + + const backupVersion = getComponentBackupVersion(component); + if (backupVersion) { + actions.push(getRollbackAction(component, rollbackKey, backupVersion)); + } + + return actions; } const COMPONENT_REPO_URLS: Record = { @@ -1117,6 +1158,13 @@ function getComponentCards(): ComponentCard[] { singBoxInstalled, ); + const singBoxBackup = getComponentBackupVersion('sing_box'); + if (singBoxBackup && singBoxInstalled) { + singBoxActions.push( + getRollbackAction('sing_box', 'singBoxRollback', singBoxBackup), + ); + } + if (!singBoxStable) { singBoxActions.push({ key: 'singBoxInstallStable', @@ -1169,6 +1217,7 @@ function getComponentCards(): ComponentCard[] { checkKey: 'zapretCheck', installKey: 'zapretInstall', removeKey: 'zapretRemove', + rollbackKey: 'zapretRollback', }); const zapret2Actions = getOptionalComponentActions({ component: 'zapret2', @@ -1176,6 +1225,7 @@ function getComponentCards(): ComponentCard[] { checkKey: 'zapret2Check', installKey: 'zapret2Install', removeKey: 'zapret2Remove', + rollbackKey: 'zapret2Rollback', }); const byedpiActions = getOptionalComponentActions({ component: 'byedpi', @@ -1183,6 +1233,7 @@ function getComponentCards(): ComponentCard[] { checkKey: 'byedpiCheck', installKey: 'byedpiInstall', removeKey: 'byedpiRemove', + rollbackKey: 'byedpiRollback', }); const tailscaleActions = getOptionalComponentActions({ component: 'tailscale', @@ -1190,6 +1241,7 @@ function getComponentCards(): ComponentCard[] { checkKey: 'tailscaleCheck', installKey: 'tailscaleInstall', removeKey: 'tailscaleRemove', + rollbackKey: 'tailscaleRollback', }); return [ diff --git a/fe-app-tachyon/src/tachyon/types.ts b/fe-app-tachyon/src/tachyon/types.ts index d862b0ff..1be21963 100644 --- a/fe-app-tachyon/src/tachyon/types.ts +++ b/fe-app-tachyon/src/tachyon/types.ts @@ -651,6 +651,7 @@ export namespace Tachyon { | 'install' | 'reinstall' | 'remove' + | 'rollback' | 'install_extended' | 'install_extended_compressed' | 'install_lx' diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js index d502c552..5f0d2b64 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js @@ -5603,14 +5603,24 @@ var initialDiagnosticStore = { sing_box_lx: 0, sing_box_tailscale: 1, sing_box_repo_url: "", + sing_box_backup_version: "", + sing_box_backup_time: 0, zapret_version: "loading", zapret_installed: 0, + zapret_backup_version: "", + zapret_backup_time: 0, zapret2_version: "loading", zapret2_installed: 0, + zapret2_backup_version: "", + zapret2_backup_time: 0, byedpi_version: "loading", byedpi_installed: 0, + byedpi_backup_version: "", + byedpi_backup_time: 0, tailscale_version: "loading", tailscale_installed: 0, + tailscale_backup_version: "", + tailscale_backup_time: 0, server_inbounds_enabled_count: -1, openwrt_version: "loading", device_model: "loading" @@ -5659,8 +5669,10 @@ var initialDiagnosticStore = { tachyonCheck: { loading: false }, tachyonInstall: { loading: false }, tachyonReinstall: { loading: false }, + tachyonRollback: { loading: false }, singBoxCheck: { loading: false }, singBoxInstall: { loading: false }, + singBoxRollback: { loading: false }, singBoxInstallExtended: { loading: false }, singBoxInstallExtendedCompressed: { loading: false }, singBoxInstallLx: { loading: false }, @@ -5669,15 +5681,19 @@ var initialDiagnosticStore = { zapretCheck: { loading: false }, zapretInstall: { loading: false }, zapretRemove: { loading: false }, + zapretRollback: { loading: false }, zapret2Check: { loading: false }, zapret2Install: { loading: false }, zapret2Remove: { loading: false }, + zapret2Rollback: { loading: false }, byedpiCheck: { loading: false }, byedpiInstall: { loading: false }, byedpiRemove: { loading: false }, + byedpiRollback: { loading: false }, tailscaleCheck: { loading: false }, tailscaleInstall: { loading: false }, - tailscaleRemove: { loading: false } + tailscaleRemove: { loading: false }, + tailscaleRollback: { loading: false } }, updatesChecks: { tachyon: { status: null, latest_version: "", release_url: "" }, @@ -6032,8 +6048,10 @@ var componentActionKeyMap = { "tachyon:check_update": "tachyonCheck", "tachyon:install": "tachyonInstall", "tachyon:reinstall": "tachyonReinstall", + "tachyon:rollback": "tachyonRollback", "sing_box:check_update": "singBoxCheck", "sing_box:install": "singBoxInstall", + "sing_box:rollback": "singBoxRollback", "sing_box:install_extended": "singBoxInstallExtended", "sing_box:install_extended_compressed": "singBoxInstallExtendedCompressed", "sing_box:install_lx": "singBoxInstallLx", @@ -6042,15 +6060,19 @@ var componentActionKeyMap = { "zapret:check_update": "zapretCheck", "zapret:install": "zapretInstall", "zapret:remove": "zapretRemove", + "zapret:rollback": "zapretRollback", "zapret2:check_update": "zapret2Check", "zapret2:install": "zapret2Install", "zapret2:remove": "zapret2Remove", + "zapret2:rollback": "zapret2Rollback", "byedpi:check_update": "byedpiCheck", "byedpi:install": "byedpiInstall", "byedpi:remove": "byedpiRemove", + "byedpi:rollback": "byedpiRollback", "tailscale:check_update": "tailscaleCheck", "tailscale:install": "tailscaleInstall", - "tailscale:remove": "tailscaleRemove" + "tailscale:remove": "tailscaleRemove", + "tailscale:rollback": "tailscaleRollback" }; function getComponentActionKey(component, action) { return componentActionKeyMap[`${component}:${action}`]; @@ -6147,8 +6169,10 @@ function getEmptyUpdatesActions() { tachyonCheck: { loading: false }, tachyonInstall: { loading: false }, tachyonReinstall: { loading: false }, + tachyonRollback: { loading: false }, singBoxCheck: { loading: false }, singBoxInstall: { loading: false }, + singBoxRollback: { loading: false }, singBoxInstallExtended: { loading: false }, singBoxInstallExtendedCompressed: { loading: false }, singBoxInstallLx: { loading: false }, @@ -6157,15 +6181,19 @@ function getEmptyUpdatesActions() { zapretCheck: { loading: false }, zapretInstall: { loading: false }, zapretRemove: { loading: false }, + zapretRollback: { loading: false }, zapret2Check: { loading: false }, zapret2Install: { loading: false }, zapret2Remove: { loading: false }, + zapret2Rollback: { loading: false }, byedpiCheck: { loading: false }, byedpiInstall: { loading: false }, byedpiRemove: { loading: false }, + byedpiRollback: { loading: false }, tailscaleCheck: { loading: false }, tailscaleInstall: { loading: false }, - tailscaleRemove: { loading: false } + tailscaleRemove: { loading: false }, + tailscaleRollback: { loading: false } }; } function getEmptyDiagnosticsActions() { @@ -19293,17 +19321,44 @@ function getInstalledUpdateActions(component, checkKey, installKey, installed = } return actions; } +function getComponentBackupVersion(component) { + const sys = store.get().diagnosticsSystemInfo; + switch (component) { + case "sing_box": + return sys.sing_box_backup_version || ""; + case "zapret": + return sys.zapret_backup_version || ""; + case "zapret2": + return sys.zapret2_backup_version || ""; + case "byedpi": + return sys.byedpi_backup_version || ""; + case "tailscale": + return sys.tailscale_backup_version || ""; + default: + return ""; + } +} +function getRollbackAction(component, key, backupVersion) { + return { + key, + text: backupVersion ? `${_("Rollback")} (${backupVersion})` : _("Rollback"), + icon: renderRotateCcwIcon24, + component, + action: "rollback" + }; +} function getOptionalComponentActions({ component, installed, checkKey, installKey, - removeKey + removeKey, + rollbackKey }) { if (!installed) { return [getInstallAction(component, installKey, false)]; } - return [ + const actions = [ ...getInstalledUpdateActions(component, checkKey, installKey), { key: removeKey, @@ -19313,6 +19368,11 @@ function getOptionalComponentActions({ action: "remove" } ]; + const backupVersion = getComponentBackupVersion(component); + if (backupVersion) { + actions.push(getRollbackAction(component, rollbackKey, backupVersion)); + } + return actions; } var COMPONENT_REPO_URLS = { tachyon: "https://github.com/Dushnilin/tachyon", @@ -19353,6 +19413,12 @@ function getComponentCards() { "singBoxInstall", singBoxInstalled ); + const singBoxBackup = getComponentBackupVersion("sing_box"); + if (singBoxBackup && singBoxInstalled) { + singBoxActions.push( + getRollbackAction("sing_box", "singBoxRollback", singBoxBackup) + ); + } if (!singBoxStable) { singBoxActions.push({ key: "singBoxInstallStable", @@ -19403,28 +19469,32 @@ function getComponentCards() { installed: zapretInstalled, checkKey: "zapretCheck", installKey: "zapretInstall", - removeKey: "zapretRemove" + removeKey: "zapretRemove", + rollbackKey: "zapretRollback" }); const zapret2Actions = getOptionalComponentActions({ component: "zapret2", installed: zapret2Installed, checkKey: "zapret2Check", installKey: "zapret2Install", - removeKey: "zapret2Remove" + removeKey: "zapret2Remove", + rollbackKey: "zapret2Rollback" }); const byedpiActions = getOptionalComponentActions({ component: "byedpi", installed: byedpiInstalled, checkKey: "byedpiCheck", installKey: "byedpiInstall", - removeKey: "byedpiRemove" + removeKey: "byedpiRemove", + rollbackKey: "byedpiRollback" }); const tailscaleActions = getOptionalComponentActions({ component: "tailscale", installed: tailscaleInstalled, checkKey: "tailscaleCheck", installKey: "tailscaleInstall", - removeKey: "tailscaleRemove" + removeKey: "tailscaleRemove", + rollbackKey: "tailscaleRollback" }); return [ { diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js index 7a03a481..a32e968b 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js @@ -1506,6 +1506,18 @@ function createSettingsContent(section, capabilities) { o.default = ["all"]; o.rmempty = false; + o = section.taboption( + "updates", + form.Flag, + "component_backup_enabled", + _("Save component backup before update"), + _( + "Keep the previous working binary/package on the router flash for instant offline rollback if needed. Consumes extra storage.", + ), + ); + o.default = "0"; + o.rmempty = false; + o = section.taboption( "updates", form.Value, diff --git a/luci-app-tachyon/po/ru/tachyon.po b/luci-app-tachyon/po/ru/tachyon.po index e2e93df0..29f956cf 100644 --- a/luci-app-tachyon/po/ru/tachyon.po +++ b/luci-app-tachyon/po/ru/tachyon.po @@ -5386,3 +5386,12 @@ msgstr "Очистить историю" msgid "Auto-applied best strategy" msgstr "Лучшая стратегия автоматически применена" + +msgid "Rollback" +msgstr "Откатить" + +msgid "Save component backup before update" +msgstr "Сохранять резервную копию перед обновлением" + +msgid "Keep the previous working binary/package on the router flash for instant offline rollback if needed. Consumes extra storage." +msgstr "Сохранять предыдущую рабочую версию компонента в памяти роутера для быстрого локального отката. Занимает дополнительное место на flash." diff --git a/tachyon/files/usr/lib/components/action.uc b/tachyon/files/usr/lib/components/action.uc index d35a3aa5..35fc0000 100644 --- a/tachyon/files/usr/lib/components/action.uc +++ b/tachyon/files/usr/lib/components/action.uc @@ -2495,6 +2495,259 @@ function normalize_component_name(component) { return component; } +const COMPONENT_BACKUP_BASE_DIR = getenv("TACHYON_COMPONENT_BACKUPS_DIR") || "/etc/tachyon/component-backups"; + +function get_component_backup_enabled() { + let uci_core = require("core.uci"); + let settings = (uci_core && uci_core.get_all) ? (uci_core.get_all("tachyon", "settings") || {}) : {}; + let val = as_string(settings.component_backup_enabled || ""); + return val == "1" || val == "true" || val == "yes" || val == "on"; +} + +function component_backup_dir(component) { + component = normalize_component_name(component); + return COMPONENT_BACKUP_BASE_DIR + "/" + component; +} + +function component_backup_metadata_file(component) { + return component_backup_dir(component) + "/metadata.json"; +} + +function read_component_backup_metadata(component) { + let file = component_backup_metadata_file(component); + if (!file_exists(file)) + return null; + let data = read_file(file); + if (data == "") + return null; + try { + let parsed = json(data); + if (type(parsed) == "object" && parsed.version) + return parsed; + } catch (e) {} + return null; +} + +function check_free_disk_space(target_dir, needed_bytes) { + let out = trim(command_output("df -k " + shell_quote(target_dir) + " 2>/dev/null | tail -n 1 | awk '{print $4}'")); + let free_kb = int(out); + if (free_kb <= 0) + return true; + let needed_kb = int((needed_bytes || 0) / 1024) + 1024; + return free_kb > (needed_kb * 2) && (free_kb - needed_kb) > 4096; +} + +function create_component_backup(component) { + component = normalize_component_name(component); + if (!get_component_backup_enabled()) + return true; + + let bdir = component_backup_dir(component); + let meta_file = component_backup_metadata_file(component); + + if (component == "sing_box") { + if (!file_exists("/usr/bin/sing-box")) + return true; + let st = fs.stat("/usr/bin/sing-box"); + let size = (st && st.size) ? st.size : 0; + if (size <= 0) + return true; + + if (!check_free_disk_space("/etc", size)) { + updates_log("Skipping sing-box backup before update: insufficient disk space on /etc", "warn"); + return false; + } + + ensure_dir(bdir); + let backup_bin = bdir + "/sing-box"; + remove_file(backup_bin); + if (!command_success_from_args([ "cp", "-p", "/usr/bin/sing-box", backup_bin ])) { + updates_log("Failed to create sing-box backup copy", "warn"); + return false; + } + + let variant = sing_box_runtime_output("variant", []); + let marker = sing_box_runtime_output("read-variant-marker", []); + let version = read_sing_box_binary_version("/usr/bin/sing-box", "/usr/lib"); + if (version == "") + version = sing_box_runtime_output("version", []); + + if (file_exists("/etc/init.d/sing-box")) { + command_success_from_args([ "cp", "-p", "/etc/init.d/sing-box", bdir + "/sing-box.init" ]); + } + if (file_exists("/usr/lib/libcronet.so")) { + command_success_from_args([ "cp", "-p", "/usr/lib/libcronet.so", bdir + "/libcronet.so" ]); + } + + let meta = { + component: "sing_box", + version: version, + variant: variant, + marker: marker, + size: size, + timestamp: now_seconds() + }; + write_file(meta_file, sprintf("%J\n", meta)); + updates_log("Created local backup of sing-box v" + version + " (" + variant + ")", "info"); + return true; + } + else if (component == "byedpi") { + if (!file_exists("/usr/bin/ciadpi")) + return true; + let st = fs.stat("/usr/bin/ciadpi"); + let size = (st && st.size) ? st.size : 0; + if (!check_free_disk_space("/etc", size)) return false; + ensure_dir(bdir); + command_success_from_args([ "cp", "-p", "/usr/bin/ciadpi", bdir + "/ciadpi" ]); + let version = trim(command_output("/usr/bin/ciadpi --version 2>&1 || true")); + let meta = { + component: "byedpi", + version: version, + timestamp: now_seconds() + }; + write_file(meta_file, sprintf("%J\n", meta)); + return true; + } + else if (component == "zapret") { + let nfqws = "/opt/zapret/nfq/nfqws"; + if (!file_exists(nfqws)) nfqws = "/usr/bin/nfqws"; + if (!file_exists(nfqws)) return true; + ensure_dir(bdir); + command_success_from_args([ "cp", "-p", nfqws, bdir + "/nfqws" ]); + let meta = { + component: "zapret", + version: provider_package_version(LIB_DIR + "/providers/zapret/runtime.uc"), + timestamp: now_seconds() + }; + write_file(meta_file, sprintf("%J\n", meta)); + return true; + } + else if (component == "zapret2") { + let nfqws2 = "/opt/zapret2/nfq/nfqws2"; + if (!file_exists(nfqws2)) nfqws2 = "/usr/bin/nfqws2"; + if (!file_exists(nfqws2)) return true; + ensure_dir(bdir); + command_success_from_args([ "cp", "-p", nfqws2, bdir + "/nfqws2" ]); + let meta = { + component: "zapret2", + version: provider_package_version(LIB_DIR + "/providers/zapret2/runtime.uc"), + timestamp: now_seconds() + }; + write_file(meta_file, sprintf("%J\n", meta)); + return true; + } + else if (component == "tailscale") { + if (!file_exists("/usr/sbin/tailscale")) return true; + ensure_dir(bdir); + command_success_from_args([ "cp", "-p", "/usr/sbin/tailscale", bdir + "/tailscale" ]); + let meta = { + component: "tailscale", + version: provider_package_version(LIB_DIR + "/providers/tailscale/runtime.uc"), + timestamp: now_seconds() + }; + write_file(meta_file, sprintf("%J\n", meta)); + return true; + } + + return true; +} + +function rollback_component(component) { + component = normalize_component_name(component); + let bdir = component_backup_dir(component); + let meta = read_component_backup_metadata(component); + if (meta == null) { + action_fail(component, "rollback", "No local backup found for " + component); + } + + let backup_version = as_string(meta.version || "previous"); + updates_log("Starting rollback of " + component + " to backup version " + backup_version, "info"); + + if (component == "sing_box") { + let backup_bin = bdir + "/sing-box"; + if (!file_exists(backup_bin) || !file_nonempty(backup_bin)) { + action_fail("sing_box", "rollback", "Sing-box backup binary is missing or empty"); + } + + stop_tachyon_before_sing_box_change(); + + remove_file("/usr/bin/sing-box"); + if (!command_success_from_args([ "cp", "-p", backup_bin, "/usr/bin/sing-box" ]) || + !command_success_from_args([ "chmod", "0755", "/usr/bin/sing-box" ])) { + action_fail("sing_box", "rollback", "Failed to restore sing-box binary from backup"); + } + + if (file_exists(bdir + "/libcronet.so")) { + remove_file("/usr/lib/libcronet.so"); + command_success_from_args([ "cp", "-p", bdir + "/libcronet.so", "/usr/lib/libcronet.so" ]); + command_success_from_args([ "chmod", "0644", "/usr/lib/libcronet.so" ]); + } + + if (file_exists(bdir + "/sing-box.init")) { + command_success_from_args([ "cp", "-p", bdir + "/sing-box.init", "/etc/init.d/sing-box" ]); + command_success_from_args([ "chmod", "0755", "/etc/init.d/sing-box" ]); + } + + if (meta.marker) { + write_sing_box_variant_state(meta.marker, backup_version); + } + + clear_version_caches(); + restart_tachyon_after_successful_change(); + action_success("sing_box", "rollback", "Sing-box rolled back to " + backup_version, backup_version, "", 1); + } + else if (component == "byedpi") { + let backup_bin = bdir + "/ciadpi"; + if (!file_exists(backup_bin)) action_fail("byedpi", "rollback", "ByeDPI backup binary is missing"); + remove_file("/usr/bin/ciadpi"); + command_success_from_args([ "cp", "-p", backup_bin, "/usr/bin/ciadpi" ]); + command_success_from_args([ "chmod", "0755", "/usr/bin/ciadpi" ]); + clear_version_caches(); + restart_tachyon_after_successful_change(); + action_success("byedpi", "rollback", "ByeDPI rolled back to " + backup_version, backup_version, "", 1); + } + else if (component == "zapret") { + let backup_bin = bdir + "/nfqws"; + if (!file_exists(backup_bin)) action_fail("zapret", "rollback", "Zapret backup binary is missing"); + if (file_exists("/opt/zapret/nfq/nfqws")) { + command_success_from_args([ "cp", "-p", backup_bin, "/opt/zapret/nfq/nfqws" ]); + command_success_from_args([ "chmod", "0755", "/opt/zapret/nfq/nfqws" ]); + } else { + command_success_from_args([ "cp", "-p", backup_bin, "/usr/bin/nfqws" ]); + command_success_from_args([ "chmod", "0755", "/usr/bin/nfqws" ]); + } + clear_version_caches(); + restart_tachyon_after_successful_change(); + action_success("zapret", "rollback", "Zapret rolled back to " + backup_version, backup_version, "", 1); + } + else if (component == "zapret2") { + let backup_bin = bdir + "/nfqws2"; + if (!file_exists(backup_bin)) action_fail("zapret2", "rollback", "Zapret2 backup binary is missing"); + if (file_exists("/opt/zapret2/nfq/nfqws2")) { + command_success_from_args([ "cp", "-p", backup_bin, "/opt/zapret2/nfq/nfqws2" ]); + command_success_from_args([ "chmod", "0755", "/opt/zapret2/nfq/nfqws2" ]); + } else { + command_success_from_args([ "cp", "-p", backup_bin, "/usr/bin/nfqws2" ]); + command_success_from_args([ "chmod", "0755", "/usr/bin/nfqws2" ]); + } + clear_version_caches(); + restart_tachyon_after_successful_change(); + action_success("zapret2", "rollback", "Zapret2 rolled back to " + backup_version, backup_version, "", 1); + } + else if (component == "tailscale") { + let backup_bin = bdir + "/tailscale"; + if (!file_exists(backup_bin)) action_fail("tailscale", "rollback", "Tailscale backup binary is missing"); + command_success_from_args([ "cp", "-p", backup_bin, "/usr/sbin/tailscale" ]); + command_success_from_args([ "chmod", "0755", "/usr/sbin/tailscale" ]); + clear_version_caches(); + restart_tachyon_after_successful_change(); + action_success("tailscale", "rollback", "Tailscale rolled back to " + backup_version, backup_version, "", 1); + } + else { + action_fail(component, "rollback", "Rollback not supported for component " + component); + } +} + function component_action(component, action) { component = normalize_component_name(component); action = as_string(action); @@ -2511,6 +2764,15 @@ function component_action(component, action) { action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Failed to create temporary directory"); capture_tachyon_running_state(); + if (action == "rollback") { + rollback_component(component); + return; + } + + if (action != "check_update" && action != "remove") { + create_component_backup(component); + } + if (component == "tachyon" && action == "check_update") check_tachyon(); else if (component == "tachyon" && action == "install") diff --git a/tachyon/files/usr/lib/diagnostics/runtime.uc b/tachyon/files/usr/lib/diagnostics/runtime.uc index 27f6d486..631bae17 100644 --- a/tachyon/files/usr/lib/diagnostics/runtime.uc +++ b/tachyon/files/usr/lib/diagnostics/runtime.uc @@ -1151,6 +1151,25 @@ function build_system_info() { let tailscale_version = tailscale_installed ? provider_version(TAILSCALE_RUNTIME_UC) : "not installed"; let device_model = first_line_value("/tmp/sysinfo/model", "unknown"); + let base_bdir = getenv("TACHYON_COMPONENT_BACKUPS_DIR") || "/etc/tachyon/component-backups"; + let read_backup_meta = function(comp) { + let path = base_bdir + "/" + comp + "/metadata.json"; + let st = fs.stat(path); + if (!st) return null; + let data = as_string(fs.readfile(path)); + if (data == "") return null; + try { + let p = json(data); + if (type(p) == "object" && p.version) return p; + } catch (e) {} + return null; + }; + let sb_meta = read_backup_meta("sing_box"); + let zapret_meta = read_backup_meta("zapret"); + let zapret2_meta = read_backup_meta("zapret2"); + let byedpi_meta = read_backup_meta("byedpi"); + let tailscale_meta = read_backup_meta("tailscale"); + return { tachyon_version: TACHYON_VERSION, tachyon_commit_sha: constants.TACHYON_COMMIT_SHA && !match(constants.TACHYON_COMMIT_SHA, /COMPILED/) ? constants.TACHYON_COMMIT_SHA : "", @@ -1163,14 +1182,24 @@ function build_system_info() { sing_box_lx, sing_box_tailscale: flags.tailscale, sing_box_repo_url, + sing_box_backup_version: sb_meta ? as_string(sb_meta.version) : "", + sing_box_backup_time: sb_meta ? int(sb_meta.timestamp || 0) : 0, zapret_version, zapret_installed, + zapret_backup_version: zapret_meta ? as_string(zapret_meta.version) : "", + zapret_backup_time: zapret_meta ? int(zapret_meta.timestamp || 0) : 0, zapret2_version, zapret2_installed, + zapret2_backup_version: zapret2_meta ? as_string(zapret2_meta.version) : "", + zapret2_backup_time: zapret2_meta ? int(zapret2_meta.timestamp || 0) : 0, byedpi_version, byedpi_installed, + byedpi_backup_version: byedpi_meta ? as_string(byedpi_meta.version) : "", + byedpi_backup_time: byedpi_meta ? int(byedpi_meta.timestamp || 0) : 0, tailscale_version, tailscale_installed, + tailscale_backup_version: tailscale_meta ? as_string(tailscale_meta.version) : "", + tailscale_backup_time: tailscale_meta ? int(tailscale_meta.timestamp || 0) : 0, openwrt_version: openwrt_release(), device_model, generated_at: int(clock()[0]) diff --git a/tests/component_backup_rollback.sh b/tests/component_backup_rollback.sh new file mode 100644 index 00000000..6905687a --- /dev/null +++ b/tests/component_backup_rollback.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon" +ACTION_UC="$TACHYON_LIB/components/action.uc" +RUNTIME_UC="$TACHYON_LIB/diagnostics/runtime.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +MOCK_BIN_DIR="$WORK_DIR/bin" +MOCK_BACKUPS_DIR="$WORK_DIR/backups" +MOCK_CONFIG_DIR="$WORK_DIR/config" +MOCK_VAR_DIR="$WORK_DIR/run" +mkdir -p "$MOCK_BIN_DIR" "$MOCK_BACKUPS_DIR" "$MOCK_CONFIG_DIR" "$MOCK_VAR_DIR" + +# 1. Create a dummy sing-box binary v1.10.0 +cat > "$MOCK_BIN_DIR/sing-box" <<'EOF_SB' +#!/bin/sh +if [ "$1" = "version" ]; then + echo "sing-box version 1.10.0" + exit 0 +fi +exit 0 +EOF_SB +chmod 0755 "$MOCK_BIN_DIR/sing-box" + +# Create UCI state with component_backup_enabled = 1 +cat > "$WORK_DIR/uci.state" <<'EOF_UCI' +tachyon.settings=settings +tachyon.settings.component_backup_enabled=1 +EOF_UCI +: > "$WORK_DIR/uci.log" + +export TACHYON_LIB="$TACHYON_LIB" +export TACHYON_COMPONENT_BACKUPS_DIR="$MOCK_BACKUPS_DIR" +export TACHYON_CONFIG_DIR="$MOCK_CONFIG_DIR" +export TACHYON_RUNTIME_STATE_DIR="$MOCK_VAR_DIR" +export TACHYON_UCI_STATE_FILE="$WORK_DIR/uci.state" +export TACHYON_UCI_LOG_FILE="$WORK_DIR/uci.log" +export PATH="$MOCK_BIN_DIR:$PATH" + +# Test 1: Triggering rollback when no backup exists should fail cleanly +ROLLBACK_NO_BACKUP_OUT="$(TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$ACTION_UC" component-action sing_box rollback 2>&1 || true)" +echo "$ROLLBACK_NO_BACKUP_OUT" | grep -Eq '"success":[[:space:]]*false' || + fail "rollback without backup must fail cleanly with JSON success:false" + +# Test 2: Manually simulate backup creation or verify backup metadata reading +mkdir -p "$MOCK_BACKUPS_DIR/sing_box" +cat > "$MOCK_BIN_DIR/sing-box-old" <<'EOF_OLD' +#!/bin/sh +if [ "$1" = "version" ]; then + echo "sing-box version 1.9.5" + exit 0 +fi +exit 0 +EOF_OLD +chmod 0755 "$MOCK_BIN_DIR/sing-box-old" +cp -p "$MOCK_BIN_DIR/sing-box-old" "$MOCK_BACKUPS_DIR/sing_box/sing-box" +cat > "$MOCK_BACKUPS_DIR/sing_box/metadata.json" <<'EOF_META' +{ + "component": "sing_box", + "version": "1.9.5", + "variant": "extended", + "marker": "extended", + "timestamp": 1756728000 +} +EOF_META + +# Test 3: System info includes backup metadata +SYS_INFO="$(TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$RUNTIME_UC" get-system-info)" +echo "$SYS_INFO" | grep -Eq '"sing_box_backup_version":[[:space:]]*"1.9.5"' || + fail "system_info must report sing_box_backup_version from metadata" + +# Test 4: Rollback replaces active binary with backup version +ROLLBACK_OUT="$(TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$ACTION_UC" component-action sing_box rollback)" +echo "$ROLLBACK_OUT" | grep -Eq '"success":[[:space:]]*true' || + fail "rollback with valid backup must return success:true" + +printf 'component backup and rollback tests passed\n'