diff --git a/fe-app-tachyon/src/tachyon/helpers/formatServiceStatusMessage.ts b/fe-app-tachyon/src/tachyon/helpers/formatServiceStatusMessage.ts new file mode 100644 index 00000000..8cef090d --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/formatServiceStatusMessage.ts @@ -0,0 +1,93 @@ +/** + * Formats and localizes backend service status messages (zapret, zapret2, byedpi, tailscale) + * into localized, user-friendly messages for the LuCI dashboard. + */ +export function formatServiceStatusMessage(msg: string): string { + if (!msg) return ''; + + const normalMatch = msg.match(/^([a-zA-Z0-9_-]+) provider status is normal$/); + if (normalMatch) { + const provider = normalMatch[1]; + return `${_('Provider status is normal')}: ${provider}`; + } + + const notReadyWithDetailMatch = msg.match( + /^action=([^ ]+) is configured, but the Tachyon-managed ([^ ]+) runtime is not ready \((.+)\)$/, + ); + if (notReadyWithDetailMatch) { + const [, action, bin, detail] = notReadyWithDetailMatch; + return `${_('Action is configured, but runtime is not ready')} (${action} -> ${bin}): ${detail}`; + } + + const notReadyMatch = msg.match( + /^action=([^ ]+) is configured, but the Tachyon-managed ([^ ]+) runtime is not ready$/, + ); + if (notReadyMatch) { + const [, action, bin] = notReadyMatch; + return `${_('Action is configured, but runtime is not ready')} (${action} -> ${bin})`; + } + + const notAvailablePathMatch = msg.match( + /^action=([^ ]+) is configured, but (.+) (?:provider|ciadpi) is not available at (.+)$/, + ); + if (notAvailablePathMatch) { + const [, action, provider, path] = notAvailablePathMatch; + return `${_('Action is configured, but binary is not available')} (${action} -> ${provider}): ${path}`; + } + + const overlapMatch = msg.match( + /^external NFQUEUE rules overlap with the Tachyon (.+) range (.+)$/, + ); + if (overlapMatch) { + const [, provider, range] = overlapMatch; + return `${_('External NFQUEUE rules overlap with Tachyon')} ${provider} (${range})`; + } + + if (msg === 'legacy zapret runtime paths are still present and should be migrated') { + return _('Legacy zapret runtime paths are still present and should be migrated'); + } + + const unexpectedProcessesMatch = msg.match( + /^unexpected Tachyon-managed ([^ ]+) processes are running without matching action=([^ ]+) rules$/, + ); + if (unexpectedProcessesMatch) { + const [, bin, action] = unexpectedProcessesMatch; + return `${_('Unexpected background processes')} (${bin}, action=${action})`; + } + + if (msg.startsWith('standalone ')) { + return _('Standalone service is active alongside Tachyon; policy or port conflicts are possible'); + } + + const packageInstalledNoBinMatch = msg.match( + /^(.+) package is installed, but (?:the provider binary|ciadpi) is not available at (.+)$/, + ); + if (packageInstalledNoBinMatch) { + const [, provider, path] = packageInstalledNoBinMatch; + return `${_('Package is installed, but binary missing')} (${provider}): ${path}`; + } + + const notInstalledMatch = msg.match( + /^(.+) (?:provider|package) is not installed; (?:action=([^ ]+) is unavailable|native Tailscale is unavailable)$/, + ); + if (notInstalledMatch) { + const [, provider, action] = notInstalledMatch; + return `${_('Package is not installed')} (${provider})${action ? `: action=${action} ${_('is unavailable')}` : ''}`; + } + + if (msg.includes('ciadpi has restarted after exiting')) { + return _('ByeDPI restarted after exiting; strategy or traffic load may be unstable'); + } + + if (msg.includes('native Tailscale is configured, but the tailscale package is missing')) { + return _('Native Tailscale is configured, but tailscale package is missing'); + } + if (msg.includes('native Tailscale is configured, but tailscaled is not running')) { + return _('Native Tailscale is configured, but tailscaled is not running for every section'); + } + if (msg.includes('no server section uses native mode')) { + return _('Tailscale package is installed, but no server section uses native mode'); + } + + return _(msg); +} diff --git a/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts b/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts index 31ea07d9..aa264a6e 100644 --- a/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts +++ b/fe-app-tachyon/src/tachyon/helpers/getComponentActionKey.ts @@ -38,6 +38,10 @@ const componentActionKeyMap: Record = { 'tailscale:install_version': 'tailscaleInstall', 'tailscale:remove': 'tailscaleRemove', 'tailscale:rollback': 'tailscaleRollback', + 'direct_bypass:enable': 'directBypassEnable', + 'direct_bypass:disable': 'directBypassDisable', + 'torrserver_direct:enable': 'torrserverDirectEnable', + 'torrserver_direct:disable': 'torrserverDirectDisable', }; export function getComponentActionKey( diff --git a/fe-app-tachyon/src/tachyon/helpers/tests/formatServiceStatusMessage.test.ts b/fe-app-tachyon/src/tachyon/helpers/tests/formatServiceStatusMessage.test.ts new file mode 100644 index 00000000..b20a2b5a --- /dev/null +++ b/fe-app-tachyon/src/tachyon/helpers/tests/formatServiceStatusMessage.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { formatServiceStatusMessage } from '../formatServiceStatusMessage'; + +describe('formatServiceStatusMessage', () => { + it('formats normal provider status', () => { + expect(formatServiceStatusMessage('zapret2 provider status is normal')).toBe( + 'Provider status is normal: zapret2', + ); + expect(formatServiceStatusMessage('byedpi provider status is normal')).toBe( + 'Provider status is normal: byedpi', + ); + }); + + it('formats action not ready with details', () => { + const msg = + 'action=zapret2 is configured, but the Tachyon-managed nfqws2 runtime is not ready (Zapret2: invalid ctrack-timeouts value)'; + expect(formatServiceStatusMessage(msg)).toBe( + 'Action is configured, but runtime is not ready (zapret2 -> nfqws2): Zapret2: invalid ctrack-timeouts value', + ); + }); + + it('formats missing binary path', () => { + const msg = 'action=zapret2 is configured, but zapret2 provider is not available at /opt/zapret2/nfq2/nfqws2'; + expect(formatServiceStatusMessage(msg)).toBe( + 'Action is configured, but binary is not available (zapret2 -> zapret2): /opt/zapret2/nfq2/nfqws2', + ); + }); + + it('formats empty or unknown string', () => { + expect(formatServiceStatusMessage('')).toBe(''); + expect(formatServiceStatusMessage('Random error')).toBe('Random error'); + }); +}); diff --git a/fe-app-tachyon/src/tachyon/services/store.service.ts b/fe-app-tachyon/src/tachyon/services/store.service.ts index 125be6c3..228b2a13 100644 --- a/fe-app-tachyon/src/tachyon/services/store.service.ts +++ b/fe-app-tachyon/src/tachyon/services/store.service.ts @@ -238,6 +238,13 @@ export interface StoreType { tailscale_backup_version?: string; tailscale_backup_time?: number; server_inbounds_enabled_count: number; + direct_bypass_enabled?: number; + direct_bypass_address?: string; + direct_bypass_port?: string; + torrserver_running?: number; + torrserver_direct_available?: number; + torrserver_direct_enabled?: number; + torrserver_direct_active?: number; openwrt_version: string; device_model: string; }; @@ -270,6 +277,10 @@ export interface StoreType { tailscaleInstall: { loading: boolean }; tailscaleRemove: { loading: boolean }; tailscaleRollback: { loading: boolean }; + directBypassEnable: { loading: boolean }; + directBypassDisable: { loading: boolean }; + torrserverDirectEnable: { loading: boolean }; + torrserverDirectDisable: { 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 42d3d5d1..e0ee5ae6 100644 --- a/fe-app-tachyon/src/tachyon/services/uiState.service.ts +++ b/fe-app-tachyon/src/tachyon/services/uiState.service.ts @@ -41,6 +41,10 @@ function getEmptyUpdatesActions(): StoreType['updatesActions'] { tailscaleInstall: { loading: false }, tailscaleRemove: { loading: false }, tailscaleRollback: { loading: false }, + directBypassEnable: { loading: false }, + directBypassDisable: { loading: false }, + torrserverDirectEnable: { loading: false }, + torrserverDirectDisable: { loading: false }, }; } diff --git a/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderSections.ts b/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderSections.ts index 64f335b8..4b23c2e0 100644 --- a/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderSections.ts +++ b/fe-app-tachyon/src/tachyon/tabs/dashboard/partials/renderSections.ts @@ -8,6 +8,7 @@ import { isCopyableProxyLink, svgEl } from '../../../../helpers'; import { prettyBytes } from '../../../../helpers/prettyBytes'; import { Tachyon } from '../../../types'; import { renderFlagEmojis } from './renderFlagEmojis'; +import { formatServiceStatusMessage } from '../../../helpers/formatServiceStatusMessage'; interface IRenderSectionsProps { loading: boolean; @@ -723,7 +724,7 @@ function renderDefaultState({ style: 'padding: 0 16px 8px; font-size: 12px; opacity: 0.6; word-break: break-word;', }, - ss.statusMessage, + formatServiceStatusMessage(ss.statusMessage), ) : '', ]); 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 c65b5425..ce91941c 100644 --- a/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts +++ b/fe-app-tachyon/src/tachyon/tabs/diagnostic/diagnostic.store.ts @@ -107,6 +107,13 @@ export const initialDiagnosticStore: Pick< tailscale_backup_version: '', tailscale_backup_time: 0, server_inbounds_enabled_count: -1, + direct_bypass_enabled: 0, + direct_bypass_address: '', + direct_bypass_port: '', + torrserver_running: 0, + torrserver_direct_available: 0, + torrserver_direct_enabled: 0, + torrserver_direct_active: 0, openwrt_version: 'loading', device_model: 'loading', }, @@ -179,6 +186,10 @@ export const initialDiagnosticStore: Pick< tailscaleInstall: { loading: false }, tailscaleRemove: { loading: false }, tailscaleRollback: { loading: false }, + directBypassEnable: { loading: false }, + directBypassDisable: { loading: false }, + torrserverDirectEnable: { loading: false }, + torrserverDirectDisable: { loading: false }, }, updatesChecks: { tachyon: { status: null, latest_version: '', release_url: '' }, @@ -187,5 +198,7 @@ export const initialDiagnosticStore: Pick< zapret2: { status: null, latest_version: '', release_url: '' }, byedpi: { status: null, latest_version: '', release_url: '' }, tailscale: { status: null, latest_version: '', release_url: '' }, + direct_bypass: { status: null, latest_version: '', release_url: '' }, + torrserver_direct: { 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 5f9511f2..b6c389cd 100644 --- a/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts +++ b/fe-app-tachyon/src/tachyon/tabs/updates/initController.ts @@ -1,9 +1,11 @@ import { onMount, preserveScrollForPage } from '../../../helpers'; +import { copyToClipboard } from '../../../helpers/copyToClipboard'; import { TACHYON_ACTION_PROVIDERS_AVAILABILITY_EVENT } from '../../../constants'; import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion'; import { capSetSize } from '../../helpers/capCollectionSize'; import { showToast } from '../../../helpers/showToast'; import { + renderCopyIcon24, renderDownloadIcon24, renderGlobeIcon24, renderRotateCcwIcon24, @@ -106,6 +108,7 @@ interface ComponentActionButton { component: Tachyon.ComponentName; action: Tachyon.ComponentAction; targetVersion?: string; + disabled?: boolean; } interface ComponentCard { @@ -119,6 +122,7 @@ interface ComponentCard { actions: ComponentActionButton[]; badgeNode?: Node | null; supportsVersions?: boolean; + copyValue?: string; } let updatesLifecycleRegistered = false; @@ -523,6 +527,16 @@ function patchSystemInfoAfterMutation(result: Tachyon.ComponentActionResult) { } } + if (result.component === 'direct_bypass') { + nextSystemInfo.direct_bypass_enabled = result.action === 'enable' ? 1 : 0; + } + if (result.component === 'torrserver_direct') { + nextSystemInfo.torrserver_direct_enabled = + result.action === 'enable' ? 1 : 0; + nextSystemInfo.torrserver_direct_active = + result.action === 'enable' ? 1 : 0; + } + const normalizedSystemInfo = normalizeSingBoxVariantFields(nextSystemInfo); store.set({ @@ -1031,6 +1045,8 @@ function getComponentInstallKey( return 'byedpiInstall'; case 'tailscale': return 'tailscaleInstall'; + default: + return 'tachyonInstall'; } } @@ -1136,6 +1152,8 @@ const COMPONENT_REPO_URLS: Record = { zapret2: 'https://github.com/Dushnilin/zapret2-openwrt', byedpi: 'https://github.com/DPITrickster/ByeDPI-OpenWrt', tailscale: 'https://openwrt.org/packages/pkgdata/tailscale', + direct_bypass: '', + torrserver_direct: '', }; function getComponentCards(): ComponentCard[] { @@ -1266,6 +1284,54 @@ function getComponentCards(): ComponentCard[] { rollbackKey: 'tailscaleRollback', }); + const directBypassEnabled = Boolean(systemInfo.direct_bypass_enabled); + const directBypassEndpoint = systemInfo.direct_bypass_address + ? `${systemInfo.direct_bypass_address}:${systemInfo.direct_bypass_port || '2080'}` + : ''; + const torrserverRunning = Boolean(systemInfo.torrserver_running); + const torrserverDirectAvailable = Boolean( + systemInfo.torrserver_direct_available, + ); + const torrserverDirectEnabled = Boolean(systemInfo.torrserver_direct_enabled); + const torrserverDirectActive = Boolean(systemInfo.torrserver_direct_active); + + const directBypassActions: ComponentActionButton[] = [ + directBypassEnabled + ? { + key: 'directBypassDisable' as const, + text: _('Disable'), + icon: renderXIcon24, + component: 'direct_bypass' as const, + action: 'disable' as const, + } + : { + key: 'directBypassEnable' as const, + text: _('Enable'), + icon: renderRotateCcwIcon24, + component: 'direct_bypass' as const, + action: 'enable' as const, + }, + ]; + + const torrserverDirectActions: ComponentActionButton[] = [ + torrserverDirectEnabled + ? { + key: 'torrserverDirectDisable' as const, + text: _('Disable'), + icon: renderXIcon24, + component: 'torrserver_direct' as const, + action: 'disable' as const, + } + : { + key: 'torrserverDirectEnable' as const, + text: _('Enable'), + icon: renderRotateCcwIcon24, + component: 'torrserver_direct' as const, + action: 'enable' as const, + disabled: !torrserverDirectAvailable, + }, + ]; + return [ { component: 'tachyon', @@ -1305,6 +1371,34 @@ function getComponentCards(): ComponentCard[] { actions: singBoxActions, supportsVersions: true, }, + { + component: 'direct_bypass', + column: 0, + title: _('Direct Proxy'), + version: directBypassEnabled + ? `HTTP/SOCKS5 ยท ${directBypassEndpoint || _('Enabled')}` + : _('Disabled'), + copyValue: + directBypassEnabled && directBypassEndpoint + ? directBypassEndpoint + : undefined, + actions: directBypassActions, + }, + { + component: 'torrserver_direct', + column: 0, + title: _('TorrServer Direct'), + version: !torrserverRunning + ? _('TorrServer not found') + : !torrserverDirectAvailable + ? _('Dedicated cgroup unavailable') + : torrserverDirectEnabled && torrserverDirectActive + ? _('Enabled') + : torrserverDirectEnabled + ? _('Waiting for TorrServer') + : _('Disabled'), + actions: torrserverDirectActions, + }, { component: 'zapret', column: 1, @@ -1677,7 +1771,10 @@ function renderComponentCard(card: ComponentCard) { text: action.text, icon: action.icon, loading, - disabled: systemInfoLoading || (anyActionLoading && !loading), + disabled: + action.disabled || + systemInfoLoading || + (anyActionLoading && !loading), onClick: () => void handleComponentAction(action), }); }); @@ -1690,7 +1787,10 @@ function renderComponentCard(card: ComponentCard) { text: action.text, icon: action.icon, loading, - disabled: systemInfoLoading || (anyActionLoading && !loading), + disabled: + action.disabled || + systemInfoLoading || + (anyActionLoading && !loading), onClick: () => void handleComponentAction(action), }); }); @@ -1704,6 +1804,19 @@ function renderComponentCard(card: ComponentCard) { ); } + if (card.copyValue) { + actionElements.push( + E('div', { class: 'tachyon_updates-page__component__actions-main' }, [ + renderButton({ + text: _('Copy address'), + icon: renderCopyIcon24, + disabled: anyActionLoading, + onClick: () => copyToClipboard(card.copyValue || ''), + }), + ]), + ); + } + if (variantActions.length > 0) { const variantButtons = variantActions.map((action) => { const loading = updatesActions[action.key].loading; diff --git a/fe-app-tachyon/src/tachyon/types.ts b/fe-app-tachyon/src/tachyon/types.ts index 0482bddb..24e5e407 100644 --- a/fe-app-tachyon/src/tachyon/types.ts +++ b/fe-app-tachyon/src/tachyon/types.ts @@ -596,6 +596,13 @@ export namespace Tachyon { byedpi_installed: 0 | 1; tailscale_version: string; tailscale_installed: 0 | 1; + direct_bypass_enabled?: 0 | 1; + direct_bypass_address?: string; + direct_bypass_port?: string; + torrserver_running?: 0 | 1; + torrserver_direct_available?: 0 | 1; + torrserver_direct_enabled?: 0 | 1; + torrserver_direct_active?: 0 | 1; openwrt_version: string; device_model: string; generated_at?: number; @@ -677,7 +684,9 @@ export namespace Tachyon { | 'zapret' | 'zapret2' | 'byedpi' - | 'tailscale'; + | 'tailscale' + | 'direct_bypass' + | 'torrserver_direct'; export type ComponentAction = | 'check_update' @@ -690,7 +699,9 @@ export namespace Tachyon { | 'install_extended_compressed' | 'install_lx' | 'install_tiny' - | 'install_stable'; + | 'install_stable' + | 'enable' + | 'disable'; export interface ComponentActionResult { success: boolean; 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 be13fe6f..9733afe7 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 @@ -2123,6 +2123,84 @@ function prettyBytes(n) { return n + " " + unit; } +// src/tachyon/helpers/formatServiceStatusMessage.ts +function formatServiceStatusMessage(msg) { + if (!msg) return ""; + const normalMatch = msg.match(/^([a-zA-Z0-9_-]+) provider status is normal$/); + if (normalMatch) { + const provider = normalMatch[1]; + return `${_("Provider status is normal")}: ${provider}`; + } + const notReadyWithDetailMatch = msg.match( + /^action=([^ ]+) is configured, but the Tachyon-managed ([^ ]+) runtime is not ready \((.+)\)$/ + ); + if (notReadyWithDetailMatch) { + const [, action, bin, detail] = notReadyWithDetailMatch; + return `${_("Action is configured, but runtime is not ready")} (${action} -> ${bin}): ${detail}`; + } + const notReadyMatch = msg.match( + /^action=([^ ]+) is configured, but the Tachyon-managed ([^ ]+) runtime is not ready$/ + ); + if (notReadyMatch) { + const [, action, bin] = notReadyMatch; + return `${_("Action is configured, but runtime is not ready")} (${action} -> ${bin})`; + } + const notAvailablePathMatch = msg.match( + /^action=([^ ]+) is configured, but (.+) (?:provider|ciadpi) is not available at (.+)$/ + ); + if (notAvailablePathMatch) { + const [, action, provider, path] = notAvailablePathMatch; + return `${_("Action is configured, but binary is not available")} (${action} -> ${provider}): ${path}`; + } + const overlapMatch = msg.match( + /^external NFQUEUE rules overlap with the Tachyon (.+) range (.+)$/ + ); + if (overlapMatch) { + const [, provider, range] = overlapMatch; + return `${_("External NFQUEUE rules overlap with Tachyon")} ${provider} (${range})`; + } + if (msg === "legacy zapret runtime paths are still present and should be migrated") { + return _("Legacy zapret runtime paths are still present and should be migrated"); + } + const unexpectedProcessesMatch = msg.match( + /^unexpected Tachyon-managed ([^ ]+) processes are running without matching action=([^ ]+) rules$/ + ); + if (unexpectedProcessesMatch) { + const [, bin, action] = unexpectedProcessesMatch; + return `${_("Unexpected background processes")} (${bin}, action=${action})`; + } + if (msg.startsWith("standalone ")) { + return _("Standalone service is active alongside Tachyon; policy or port conflicts are possible"); + } + const packageInstalledNoBinMatch = msg.match( + /^(.+) package is installed, but (?:the provider binary|ciadpi) is not available at (.+)$/ + ); + if (packageInstalledNoBinMatch) { + const [, provider, path] = packageInstalledNoBinMatch; + return `${_("Package is installed, but binary missing")} (${provider}): ${path}`; + } + const notInstalledMatch = msg.match( + /^(.+) (?:provider|package) is not installed; (?:action=([^ ]+) is unavailable|native Tailscale is unavailable)$/ + ); + if (notInstalledMatch) { + const [, provider, action] = notInstalledMatch; + return `${_("Package is not installed")} (${provider})${action ? `: action=${action} ${_("is unavailable")}` : ""}`; + } + if (msg.includes("ciadpi has restarted after exiting")) { + return _("ByeDPI restarted after exiting; strategy or traffic load may be unstable"); + } + if (msg.includes("native Tailscale is configured, but the tailscale package is missing")) { + return _("Native Tailscale is configured, but tailscale package is missing"); + } + if (msg.includes("native Tailscale is configured, but tailscaled is not running")) { + return _("Native Tailscale is configured, but tailscaled is not running for every section"); + } + if (msg.includes("no server section uses native mode")) { + return _("Tailscale package is installed, but no server section uses native mode"); + } + return _(msg); +} + // src/tachyon/tabs/dashboard/partials/renderSections.ts function renderFailedState() { return E( @@ -2649,7 +2727,7 @@ function renderDefaultState({ { style: "padding: 0 16px 8px; font-size: 12px; opacity: 0.6; word-break: break-word;" }, - ss.statusMessage + formatServiceStatusMessage(ss.statusMessage) ) : "" ]); } @@ -5947,6 +6025,13 @@ var initialDiagnosticStore = { tailscale_backup_version: "", tailscale_backup_time: 0, server_inbounds_enabled_count: -1, + direct_bypass_enabled: 0, + direct_bypass_address: "", + direct_bypass_port: "", + torrserver_running: 0, + torrserver_direct_available: 0, + torrserver_direct_enabled: 0, + torrserver_direct_active: 0, openwrt_version: "loading", device_model: "loading" }, @@ -6018,7 +6103,11 @@ var initialDiagnosticStore = { tailscaleCheck: { loading: false }, tailscaleInstall: { loading: false }, tailscaleRemove: { loading: false }, - tailscaleRollback: { loading: false } + tailscaleRollback: { loading: false }, + directBypassEnable: { loading: false }, + directBypassDisable: { loading: false }, + torrserverDirectEnable: { loading: false }, + torrserverDirectDisable: { loading: false } }, updatesChecks: { tachyon: { status: null, latest_version: "", release_url: "" }, @@ -6026,7 +6115,9 @@ var initialDiagnosticStore = { zapret: { status: null, latest_version: "", release_url: "" }, zapret2: { status: null, latest_version: "", release_url: "" }, byedpi: { status: null, latest_version: "", release_url: "" }, - tailscale: { status: null, latest_version: "", release_url: "" } + tailscale: { status: null, latest_version: "", release_url: "" }, + direct_bypass: { status: null, latest_version: "", release_url: "" }, + torrserver_direct: { status: null, latest_version: "", release_url: "" } } }; @@ -6403,7 +6494,11 @@ var componentActionKeyMap = { "tailscale:install": "tailscaleInstall", "tailscale:install_version": "tailscaleInstall", "tailscale:remove": "tailscaleRemove", - "tailscale:rollback": "tailscaleRollback" + "tailscale:rollback": "tailscaleRollback", + "direct_bypass:enable": "directBypassEnable", + "direct_bypass:disable": "directBypassDisable", + "torrserver_direct:enable": "torrserverDirectEnable", + "torrserver_direct:disable": "torrserverDirectDisable" }; function getComponentActionKey(component, action) { return componentActionKeyMap[`${component}:${action}`]; @@ -6573,7 +6668,11 @@ function getEmptyUpdatesActions() { tailscaleCheck: { loading: false }, tailscaleInstall: { loading: false }, tailscaleRemove: { loading: false }, - tailscaleRollback: { loading: false } + tailscaleRollback: { loading: false }, + directBypassEnable: { loading: false }, + directBypassDisable: { loading: false }, + torrserverDirectEnable: { loading: false }, + torrserverDirectDisable: { loading: false } }; } function getEmptyDiagnosticsActions() { @@ -19967,6 +20066,13 @@ function patchSystemInfoAfterMutation(result) { nextSystemInfo.byedpi_version = version; } } + if (result.component === "direct_bypass") { + nextSystemInfo.direct_bypass_enabled = result.action === "enable" ? 1 : 0; + } + if (result.component === "torrserver_direct") { + nextSystemInfo.torrserver_direct_enabled = result.action === "enable" ? 1 : 0; + nextSystemInfo.torrserver_direct_active = result.action === "enable" ? 1 : 0; + } const normalizedSystemInfo = normalizeSingBoxVariantFields(nextSystemInfo); store.set({ diagnosticsSystemInfo: normalizedSystemInfo @@ -20351,6 +20457,8 @@ function getComponentInstallKey(component) { return "byedpiInstall"; case "tailscale": return "tailscaleInstall"; + default: + return "tachyonInstall"; } } function getComponentInstallAction(component) { @@ -20427,7 +20535,9 @@ var COMPONENT_REPO_URLS = { zapret: "https://github.com/remittor/zapret-openwrt", zapret2: "https://github.com/Dushnilin/zapret2-openwrt", byedpi: "https://github.com/DPITrickster/ByeDPI-OpenWrt", - tailscale: "https://openwrt.org/packages/pkgdata/tailscale" + tailscale: "https://openwrt.org/packages/pkgdata/tailscale", + direct_bypass: "", + torrserver_direct: "" }; function getComponentCards() { const systemInfo = normalizeSingBoxVariantFields( @@ -20543,6 +20653,45 @@ function getComponentCards() { removeKey: "tailscaleRemove", rollbackKey: "tailscaleRollback" }); + const directBypassEnabled = Boolean(systemInfo.direct_bypass_enabled); + const directBypassEndpoint = systemInfo.direct_bypass_address ? `${systemInfo.direct_bypass_address}:${systemInfo.direct_bypass_port || "2080"}` : ""; + const torrserverRunning = Boolean(systemInfo.torrserver_running); + const torrserverDirectAvailable = Boolean( + systemInfo.torrserver_direct_available + ); + const torrserverDirectEnabled = Boolean(systemInfo.torrserver_direct_enabled); + const torrserverDirectActive = Boolean(systemInfo.torrserver_direct_active); + const directBypassActions = [ + directBypassEnabled ? { + key: "directBypassDisable", + text: _("Disable"), + icon: renderXIcon24, + component: "direct_bypass", + action: "disable" + } : { + key: "directBypassEnable", + text: _("Enable"), + icon: renderRotateCcwIcon24, + component: "direct_bypass", + action: "enable" + } + ]; + const torrserverDirectActions = [ + torrserverDirectEnabled ? { + key: "torrserverDirectDisable", + text: _("Disable"), + icon: renderXIcon24, + component: "torrserver_direct", + action: "disable" + } : { + key: "torrserverDirectEnable", + text: _("Enable"), + icon: renderRotateCcwIcon24, + component: "torrserver_direct", + action: "enable", + disabled: !torrserverDirectAvailable + } + ]; return [ { component: "tachyon", @@ -20570,6 +20719,21 @@ function getComponentCards() { actions: singBoxActions, supportsVersions: true }, + { + component: "direct_bypass", + column: 0, + title: _("Direct Proxy"), + version: directBypassEnabled ? `HTTP/SOCKS5 \xB7 ${directBypassEndpoint || _("Enabled")}` : _("Disabled"), + copyValue: directBypassEnabled && directBypassEndpoint ? directBypassEndpoint : void 0, + actions: directBypassActions + }, + { + component: "torrserver_direct", + column: 0, + title: _("TorrServer Direct"), + version: !torrserverRunning ? _("TorrServer not found") : !torrserverDirectAvailable ? _("Dedicated cgroup unavailable") : torrserverDirectEnabled && torrserverDirectActive ? _("Enabled") : torrserverDirectEnabled ? _("Waiting for TorrServer") : _("Disabled"), + actions: torrserverDirectActions + }, { component: "zapret", column: 1, @@ -20884,7 +21048,7 @@ function renderComponentCard(card) { text: action.text, icon: action.icon, loading: loading2, - disabled: systemInfoLoading || anyActionLoading && !loading2, + disabled: action.disabled || systemInfoLoading || anyActionLoading && !loading2, onClick: () => void handleComponentAction(action) }); }); @@ -20895,7 +21059,7 @@ function renderComponentCard(card) { text: action.text, icon: action.icon, loading: loading2, - disabled: systemInfoLoading || anyActionLoading && !loading2, + disabled: action.disabled || systemInfoLoading || anyActionLoading && !loading2, onClick: () => void handleComponentAction(action) }); }); @@ -20907,6 +21071,18 @@ function renderComponentCard(card) { ]) ); } + if (card.copyValue) { + actionElements.push( + E("div", { class: "tachyon_updates-page__component__actions-main" }, [ + renderButton({ + text: _("Copy address"), + icon: renderCopyIcon24, + disabled: anyActionLoading, + onClick: () => copyToClipboard(card.copyValue || "") + }) + ]) + ); + } if (variantActions.length > 0) { const variantButtons = variantActions.map((action) => { const loading2 = updatesActions[action.key].loading; diff --git a/tachyon/files/etc/config/tachyon b/tachyon/files/etc/config/tachyon index fc42948f..4a917075 100644 --- a/tachyon/files/etc/config/tachyon +++ b/tachyon/files/etc/config/tachyon @@ -25,6 +25,8 @@ config settings 'settings' #option badwan_reload_delay '2000' option enable_yacd '0' option disable_quic '0' + option direct_bypass_enabled '0' + option direct_bypass_port '2080' option list_update_enabled '1' option update_interval '1d' option download_all_presets '0' diff --git a/tachyon/files/etc/init.d/tachyon-torrserver-direct b/tachyon/files/etc/init.d/tachyon-torrserver-direct new file mode 100644 index 00000000..dee1744d --- /dev/null +++ b/tachyon/files/etc/init.d/tachyon-torrserver-direct @@ -0,0 +1,19 @@ +#!/bin/sh /etc/rc.common + +START=100 +STOP=9 +USE_PROCD=1 + +start_service() { + [ "$(uci -q get tachyon.settings.torrserver_direct_enabled)" = "1" ] || return 0 + procd_open_instance + procd_set_param command /usr/bin/ucode -L /usr/lib/tachyon /usr/lib/tachyon/torrserver/direct.uc worker + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +stop_service() { + /usr/bin/ucode -L /usr/lib/tachyon /usr/lib/tachyon/torrserver/direct.uc remove >/dev/null 2>&1 || true +} diff --git a/tachyon/files/usr/lib/components/action.uc b/tachyon/files/usr/lib/components/action.uc index 1f37c53c..53725793 100644 --- a/tachyon/files/usr/lib/components/action.uc +++ b/tachyon/files/usr/lib/components/action.uc @@ -2785,6 +2785,10 @@ function normalize_component_name(component) { return "sing_box"; if (component == "tachyon") return "tachyon"; + if (component == "direct-bypass" || component == "directbypass" || component == "direct_proxy") + return "direct_bypass"; + if (component == "torrserver_direct" || component == "torrserver-direct") + return "torrserver_direct"; return component; } @@ -3134,6 +3138,86 @@ function install_component_version(component, tag) { } } +function set_direct_bypass(action) { + let enable = action == "enable"; + let cursor = uci_core.cursor(); + cursor.load("tachyon"); + + if (enable) { + cursor.set("tachyon", "settings", "direct_bypass_enabled", "1"); + let current_port = trim(as_string(cursor.get("tachyon", "settings", "direct_bypass_port") || "")); + if (current_port == "" || match(current_port, /^[0-9]+$/) == null) + cursor.set("tachyon", "settings", "direct_bypass_port", "2080"); + } else { + cursor.set("tachyon", "settings", "direct_bypass_enabled", "0"); + } + cursor.commit("tachyon"); + + remove_file(SYSTEM_INFO_CACHE_FILE); + restart_tachyon_after_successful_change(); + + action_success( + "direct_bypass", + action, + enable ? "Direct bypass enabled" : "Direct bypass disabled", + enable ? "enabled" : "disabled", + enable ? "enabled" : "disabled", + 1 + ); +} + +const TORRSERVER_DIRECT_INIT = getenv("TACHYON_TORRSERVER_DIRECT_INIT") || "/etc/init.d/tachyon-torrserver-direct"; +const TORRSERVER_DIRECT_UC = LIB_DIR + "/torrserver/direct.uc"; + +function set_torrserver_direct(action) { + let cursor = uci_core.cursor(); + cursor.load("tachyon"); + let current_enabled = trim(as_string(cursor.get("tachyon", "settings", "torrserver_direct_enabled") || "0")) == "1" ? "1" : "0"; + let target_enabled = action == "enable" ? "1" : "0"; + + if (!file_exists(TORRSERVER_DIRECT_INIT) || !file_exists(TORRSERVER_DIRECT_UC)) + action_fail("torrserver_direct", action, "TorrServer Direct service is not available", current_enabled, target_enabled); + + if (target_enabled == "1") { + if (!command_success_from_args([ "modprobe", "nft_socket" ]) && + (!run_logged("Installing TorrServer Direct kernel support", pkg_install_name_command("kmod-nft-socket")) || + !command_success_from_args([ "modprobe", "nft_socket" ]))) + action_fail("torrserver_direct", action, "This firmware does not provide kmod-nft-socket required for TorrServer Direct", current_enabled, target_enabled); + let status = null; + try { + status = json(command_output_from_args([ "ucode", "-L", LIB_DIR, TORRSERVER_DIRECT_UC, "status" ])); + } catch (e) {} + if (type(status) != "object" || int(status.running || 0) != 1) + action_fail("torrserver_direct", action, "TorrServer is not running", current_enabled, target_enabled); + if (int(status.available || 0) != 1) + action_fail("torrserver_direct", action, "TorrServer does not have a dedicated cgroup", current_enabled, target_enabled); + } + + cursor.set("tachyon", "settings", "torrserver_direct_enabled", target_enabled); + cursor.commit("tachyon"); + + let applied = target_enabled == "1" + ? command_success_from_args([ TORRSERVER_DIRECT_INIT, "enable" ]) && + command_success_from_args([ TORRSERVER_DIRECT_INIT, "restart" ]) && + command_success_from_args([ "ucode", "-L", LIB_DIR, TORRSERVER_DIRECT_UC, "reconcile" ]) + : command_success_from_args([ TORRSERVER_DIRECT_INIT, "stop" ]) && + command_success_from_args([ TORRSERVER_DIRECT_INIT, "disable" ]); + if (!applied) { + cursor.set("tachyon", "settings", "torrserver_direct_enabled", current_enabled); + cursor.commit("tachyon"); + action_fail("torrserver_direct", action, "Failed to apply TorrServer Direct settings", current_enabled, target_enabled); + } + remove_file(SYSTEM_INFO_CACHE_FILE); + action_success( + "torrserver_direct", + action, + target_enabled == "1" ? "TorrServer Direct has been enabled" : "TorrServer Direct has been disabled", + target_enabled == "1" ? "enabled" : "disabled", + target_enabled == "1" ? "enabled" : "disabled", + current_enabled == target_enabled ? 0 : 1 + ); +} + function component_action(component, action, extra) { component = normalize_component_name(component); action = as_string(action); @@ -3160,7 +3244,7 @@ function component_action(component, action, extra) { return; } - if (action != "check_update" && action != "remove") { + if (action != "check_update" && action != "remove" && component != "direct_bypass" && component != "torrserver_direct") { create_component_backup(component); } @@ -3191,6 +3275,10 @@ function component_action(component, action, extra) { install_tailscale(action); else if (component == "tailscale" && action == "remove") remove_optional_component("tailscale", "tailscale", "Tailscale", LIB_DIR + "/providers/tailscale/runtime.uc"); + else if (component == "direct_bypass" && (action == "enable" || action == "disable")) + set_direct_bypass(action); + else if (component == "torrserver_direct" && (action == "enable" || action == "disable")) + set_torrserver_direct(action); else action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Unknown component action"); } diff --git a/tachyon/files/usr/lib/config/migration.uc b/tachyon/files/usr/lib/config/migration.uc index e9cc89d2..7ac0225f 100644 --- a/tachyon/files/usr/lib/config/migration.uc +++ b/tachyon/files/usr/lib/config/migration.uc @@ -638,6 +638,30 @@ function migrate_byedpi_cmd_opts(ctx, section) { delete_option(ctx, section, "cmd_opts"); } +function migrate_ctrack_timeouts(ctx, section) { + let action = migrated_rule_action(section); + let opt_name = action == "zapret2" ? "nfqws2_opt" : (action == "zapret" ? "nfqws_opt" : ""); + if (opt_name == "") + return; + let opt = option(section, opt_name, ""); + if (opt == "" || index(opt, "--ctrack-timeouts=") < 0) + return; + let normalized = replace(opt, /--ctrack-timeouts=([0-9,]+)/g, function(full, numbers) { + if (index(numbers, ",") < 0) + return full; + let parts = split(numbers, ","); + if (length(parts) >= 8 && parts[0] == "30" && parts[2] == "600") + return "--ctrack-timeouts=30:600:30:30"; + if (length(parts) >= 4) + return "--ctrack-timeouts=" + parts[0] + ":" + parts[1] + ":" + parts[2] + ":" + parts[3]; + if (length(parts) >= 3) + return "--ctrack-timeouts=" + parts[0] + ":" + parts[1] + ":" + parts[2]; + return "--ctrack-timeouts=30:600:30:30"; + }); + if (normalized != opt) + set_option(ctx, section, opt_name, normalized); +} + function migrate_zapret_nfqws_default(ctx, section, constants) { if (migrated_rule_action(section) != "zapret") return; @@ -1045,6 +1069,7 @@ function migrate_rule(ctx, section, converted_from_rule, constants) { migrate_byedpi_cmd_opts(ctx, section); migrate_zapret_nfqws_default(ctx, section, constants); + migrate_ctrack_timeouts(ctx, section); } function migrate_rule_section(ctx, section, constants) { diff --git a/tachyon/files/usr/lib/config/validator.uc b/tachyon/files/usr/lib/config/validator.uc index 78f9bdc4..52e5176d 100644 --- a/tachyon/files/usr/lib/config/validator.uc +++ b/tachyon/files/usr/lib/config/validator.uc @@ -885,7 +885,7 @@ function validate_dns_settings(settings, sections, context) { validate_required_duration_option(option(settings, "dns_check_interval", "10s"), "settings.dns_check_interval"); validate_required_duration_option(option(settings, "dns_recovery_check_interval", "60s"), "settings.dns_recovery_check_interval"); validate_required_duration_option(option(settings, "dns_check_timeout", "2s"), "settings.dns_check_timeout"); - for (let name in [ "dns_failure_threshold", "dns_recovery_threshold" ]) { + for (let name in [ "dns_failure_threshold", "dns_recovery_threshold", "dns_failover_failure_threshold" ]) { let value = trim(option(settings, name, "3")); if (match(value, /^[0-9]+$/) == null || int(value) < 1 || int(value) > 10) fail_validation("Invalid " + name + " value '" + value + "'. Use a number from 1 to 10. Aborted."); diff --git a/tachyon/files/usr/lib/core/constants.uc b/tachyon/files/usr/lib/core/constants.uc index fbcd7fc2..e0b8b48c 100644 --- a/tachyon/files/usr/lib/core/constants.uc +++ b/tachyon/files/usr/lib/core/constants.uc @@ -181,7 +181,7 @@ function constants_map() { c.ZAPRET_DESYNC_MARK = env("ZAPRET_DESYNC_MARK", "0x40000000"); c.ZAPRET_DESYNC_MARK_POSTNAT = env("ZAPRET_DESYNC_MARK_POSTNAT", "0x20000000"); c.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT = env("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1"); - c.ZAPRET_DEFAULT_NFQWS_OPT = env("ZAPRET_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --ctrack-timeouts=30,30,600,30,30,30,30,30"); + c.ZAPRET_DEFAULT_NFQWS_OPT = env("ZAPRET_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --ctrack-timeouts=30:600:30:30"); c.ZAPRET2_PROVIDER_BASE_DIR = env("ZAPRET2_PROVIDER_BASE_DIR", "/opt/zapret2"); c.ZAPRET2_PROVIDER_NFQWS2_BIN = env("ZAPRET2_PROVIDER_NFQWS2_BIN", c.ZAPRET2_PROVIDER_BASE_DIR + "/nfq2/nfqws2"); @@ -199,7 +199,7 @@ function constants_map() { c.ZAPRET2_NFQWS2_RESPAWN_DELAY = env("ZAPRET2_NFQWS2_RESPAWN_DELAY", "5"); c.ZAPRET2_DESYNC_MARK = env("ZAPRET2_DESYNC_MARK", "0x40000000"); c.ZAPRET2_DESYNC_MARK_POSTNAT = env("ZAPRET2_DESYNC_MARK_POSTNAT", "0x20000000"); - c.ZAPRET2_DEFAULT_NFQWS2_OPT = env("ZAPRET2_DEFAULT_NFQWS2_OPT", "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6 --ctrack-timeouts=30,30,600,30,30,30,30,30"); + c.ZAPRET2_DEFAULT_NFQWS2_OPT = env("ZAPRET2_DEFAULT_NFQWS2_OPT", "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6 --ctrack-timeouts=30:600:30:30"); c.BYEDPI_BIN = env("BYEDPI_BIN", "/usr/bin/ciadpi"); c.BYEDPI_SERVICE_INIT = env("BYEDPI_SERVICE_INIT", "/etc/init.d/byedpi"); diff --git a/tachyon/files/usr/lib/diagnostics/runtime.uc b/tachyon/files/usr/lib/diagnostics/runtime.uc index 1df37106..2190e9fa 100644 --- a/tachyon/files/usr/lib/diagnostics/runtime.uc +++ b/tachyon/files/usr/lib/diagnostics/runtime.uc @@ -1148,6 +1148,15 @@ 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 direct_bypass_enabled = bool_option(settings(), "direct_bypass_enabled", false) ? 1 : 0; + let direct_bypass_port = option(settings(), "direct_bypass_port", "2080"); + let direct_bypass_address = direct_bypass_enabled + ? trim(command_output_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/singbox/runtime.uc", "service-listen-address" ])) + : ""; + let torrserver_direct_status = parse_json_or_null(command_output_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/torrserver/direct.uc", "status" ])); + if (type(torrserver_direct_status) != "object") + torrserver_direct_status = {}; + 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"; @@ -1197,6 +1206,13 @@ function build_system_info() { tailscale_installed, tailscale_backup_version: tailscale_meta ? as_string(tailscale_meta.version) : "", tailscale_backup_time: tailscale_meta ? int(tailscale_meta.timestamp || 0) : 0, + direct_bypass_enabled, + direct_bypass_address, + direct_bypass_port, + torrserver_running: int(torrserver_direct_status.running || 0), + torrserver_direct_available: int(torrserver_direct_status.available || 0), + torrserver_direct_enabled: int(torrserver_direct_status.enabled || 0), + torrserver_direct_active: int(torrserver_direct_status.active || 0), openwrt_version: openwrt_release(), device_model, generated_at: int(clock()[0]) diff --git a/tachyon/files/usr/lib/providers/nfqueue/runtime.uc b/tachyon/files/usr/lib/providers/nfqueue/runtime.uc index c9b8b67f..db6dff91 100644 --- a/tachyon/files/usr/lib/providers/nfqueue/runtime.uc +++ b/tachyon/files/usr/lib/providers/nfqueue/runtime.uc @@ -293,8 +293,24 @@ function cleanup_legacy_runtime(cfg) { command_success_from_args([ "rm", "-rf", cfg.legacy_runtime_base ]); } -function expand_strategy(cfg, value) { +function normalize_ctrack_timeouts(value) { value = as_string(value); + return replace(value, /--ctrack-timeouts=([0-9,]+)/g, function(full, numbers) { + if (index(numbers, ",") < 0) + return full; + let parts = split(numbers, ","); + if (length(parts) >= 8 && parts[0] == "30" && parts[2] == "600") + return "--ctrack-timeouts=30:600:30:30"; + if (length(parts) >= 4) + return "--ctrack-timeouts=" + parts[0] + ":" + parts[1] + ":" + parts[2] + ":" + parts[3]; + if (length(parts) >= 3) + return "--ctrack-timeouts=" + parts[0] + ":" + parts[1] + ":" + parts[2]; + return "--ctrack-timeouts=30:600:30:30"; + }); +} + +function expand_strategy(cfg, value) { + value = normalize_ctrack_timeouts(as_string(value)); if (cfg.legacy_runtime_base == "" || cfg.provider_base_dir == "") return value; return replace(value, cfg.legacy_runtime_base, cfg.provider_base_dir); @@ -701,5 +717,7 @@ function run(provider, argv) { } return { - run + run, + expand_strategy, + normalize_ctrack_timeouts }; \ No newline at end of file diff --git a/tachyon/files/usr/lib/service/state.uc b/tachyon/files/usr/lib/service/state.uc index 816947e4..6dd6a781 100644 --- a/tachyon/files/usr/lib/service/state.uc +++ b/tachyon/files/usr/lib/service/state.uc @@ -1516,6 +1516,8 @@ function sing_box_signature_body(settings, sections, servers, mwan3_active) { body = signature_add_value(body, "settings.log_level", option(settings, "log_level", "warn")); body = signature_add_value(body, "settings.service_listen_address", option(settings, "service_listen_address", "")); body = signature_add_value(body, "runtime.mwan3_active", bool_value(mwan3_active)); + body = signature_add_value(body, "settings.direct_bypass_enabled", bool_option_value(settings, "direct_bypass_enabled", false)); + body = signature_add_value(body, "settings.direct_bypass_port", option(settings, "direct_bypass_port", "2080")); let enable_yacd = bool_option_value(settings, "enable_yacd", false); body = signature_add_value(body, "settings.enable_yacd", enable_yacd); diff --git a/tachyon/files/usr/lib/singbox/constants.uc b/tachyon/files/usr/lib/singbox/constants.uc index a176c247..3707b3db 100644 --- a/tachyon/files/usr/lib/singbox/constants.uc +++ b/tachyon/files/usr/lib/singbox/constants.uc @@ -22,6 +22,9 @@ const DNS_BLOCK_INBOUND_PORT = 1053; const SERVICE_MIXED_INBOUND_TAG = "service-mixed-in"; const SERVICE_MIXED_INBOUND_ADDRESS = "127.0.0.1"; const SERVICE_MIXED_INBOUND_PORT = 4534; +const DIRECT_BYPASS_INBOUND_TAG = "direct-bypass-in"; +const DIRECT_BYPASS_OUTBOUND_TAG = "direct-bypass-out"; +const DIRECT_BYPASS_DEFAULT_PORT = 2080; const DIRECT_OUTBOUND_TAG = "direct-out"; const BYPASS_OUTBOUND_TAG = "bypass-out"; const OUTBOUND_MARK = 134217728; @@ -54,6 +57,8 @@ const RESERVED_TAGS = { [DNS_INBOUND_TAG]: true, [DNS_BLOCK_INBOUND_TAG]: true, [SERVICE_MIXED_INBOUND_TAG]: true, + [DIRECT_BYPASS_INBOUND_TAG]: true, + [DIRECT_BYPASS_OUTBOUND_TAG]: true, [DIRECT_OUTBOUND_TAG]: true, [BYPASS_OUTBOUND_TAG]: true }; @@ -104,6 +109,9 @@ return { SERVICE_MIXED_INBOUND_TAG, SERVICE_MIXED_INBOUND_ADDRESS, SERVICE_MIXED_INBOUND_PORT, + DIRECT_BYPASS_INBOUND_TAG, + DIRECT_BYPASS_OUTBOUND_TAG, + DIRECT_BYPASS_DEFAULT_PORT, DIRECT_OUTBOUND_TAG, BYPASS_OUTBOUND_TAG, OUTBOUND_MARK, diff --git a/tachyon/files/usr/lib/singbox/dns_failover.uc b/tachyon/files/usr/lib/singbox/dns_failover.uc index e039d98f..78f6a71e 100644 --- a/tachyon/files/usr/lib/singbox/dns_failover.uc +++ b/tachyon/files/usr/lib/singbox/dns_failover.uc @@ -294,7 +294,7 @@ function worker() { let failure_threshold = is_parallel ? 2 - : int(common.option(current_cfg, "dns_failure_threshold", "3")); + : int(common.option(current_cfg, "dns_failure_threshold", common.option(current_cfg, "dns_failover_failure_threshold", "3"))); let recovery_threshold = is_parallel ? 2 : int(common.option(current_cfg, "dns_recovery_threshold", "3")); diff --git a/tachyon/files/usr/lib/singbox/generator.uc b/tachyon/files/usr/lib/singbox/generator.uc index c3a0e61f..430483a0 100644 --- a/tachyon/files/usr/lib/singbox/generator.uc +++ b/tachyon/files/usr/lib/singbox/generator.uc @@ -618,6 +618,45 @@ function add_mixed_proxy_for_section(config, section, service_address) { }); } +/* + * Direct Bypass provides an explicit, clean mixed (HTTP+SOCKS5) proxy port on LAN + * mapped directly to the direct outbound marked with OUTBOUND_MARK (0x08000000). + * Traffic received on this port bypasses all section routing rules, VPN tunnels, and + * DPI evasion engines, giving clients a guaranteed straight WAN route. + */ +function add_direct_bypass_proxy(config, settings, service_address) { + if (!bool_option(settings, "direct_bypass_enabled", false)) + return; + + let listen = as_string(service_address || ""); + if (listen == "") + runtime_generate_unsupported("direct bypass listen address is not set"); + + let port_value = option(settings, "direct_bypass_port", as_string(runtime_constants.DIRECT_BYPASS_DEFAULT_PORT)); + if (match(port_value, /^[0-9]+$/) == null) + runtime_generate_unsupported("direct bypass port is invalid"); + let listen_port = int(port_value, 10); + if (listen_port < 1 || listen_port > 65535) + runtime_generate_unsupported("direct bypass port is invalid"); + + push(config.inbounds, { + type: "mixed", + tag: runtime_constants.DIRECT_BYPASS_INBOUND_TAG, + listen, + listen_port + }); + push(config.outbounds, { + type: "direct", + tag: runtime_constants.DIRECT_BYPASS_OUTBOUND_TAG, + routing_mark: runtime_constants.OUTBOUND_MARK + }); + push(config.route.rules, { + action: "route", + inbound: runtime_constants.DIRECT_BYPASS_INBOUND_TAG, + outbound: runtime_constants.DIRECT_BYPASS_OUTBOUND_TAG + }); +} + function add_service_mixed_proxy_inbound(config, tag_name, listen_port, outbound) { push(config.inbounds, { type: "mixed", @@ -990,6 +1029,7 @@ function generate_config(output_path, service_address, mwan3_active, supports_xh push(config.dns.rules, rule); } + add_direct_bypass_proxy(config, settings, service_address); add_service_mixed_proxy(config, settings, sections); for (let section in sections) add_mixed_proxy_for_section(config, section, service_address); diff --git a/tachyon/files/usr/lib/subscription/cache.uc b/tachyon/files/usr/lib/subscription/cache.uc index c0f12a4b..8a7af302 100644 --- a/tachyon/files/usr/lib/subscription/cache.uc +++ b/tachyon/files/usr/lib/subscription/cache.uc @@ -1783,14 +1783,10 @@ function download_subscription_into_cache(section_name_value, subscription_url, } if (file_nonempty(subscription_json_path) && parser.runtime_outbounds_equal(normalized_tmpfile, subscription_json_path)) { - if (!move_file(normalized_tmpfile, subscription_json_path)) { - if (metadata_output_path != "") - unlink_path(metadata_output_path); - unlink_path(raw_tmpfile); - unlink_path(headers_tmpfile); - unlink_path(metadata_tmpfile); - return 1; - } + // Keep the current file and running outbound ordering. The newly downloaded file + // differs only in provider node ordering or metadata ignored by runtime comparison, + // neither of which should churn the on-disk file or trigger a sing-box reload. + unlink_path(normalized_tmpfile); write_text_if_changed(subscription_url_cache_path, subscription_url); write_text_if_changed(subscription_user_agent_cache_path, effective_user_agent); @@ -1816,7 +1812,7 @@ function download_subscription_into_cache(section_name_value, subscription_url, return 1; } -write_text_if_changed(subscription_url_cache_path, subscription_url); + write_text_if_changed(subscription_url_cache_path, subscription_url); write_text_if_changed(subscription_user_agent_cache_path, effective_user_agent); write_text_if_changed(subscription_hwid_cache_path, effective_hwid); write_text_if_changed(subscription_device_headers_cache_path, subscription_device_headers_signature); diff --git a/tachyon/files/usr/lib/subscription/parser.uc b/tachyon/files/usr/lib/subscription/parser.uc index 81dbd766..ca5be9ea 100644 --- a/tachyon/files/usr/lib/subscription/parser.uc +++ b/tachyon/files/usr/lib/subscription/parser.uc @@ -397,7 +397,15 @@ function runtime_outbounds_signature(path) { if (type(subscription) != "object" || type(subscription.outbounds) != "array") return null; - return canonical_runtime_value(subscription.outbounds); + let result = []; + for (let outbound in subscription.outbounds) + push(result, sprintf("%J", canonical_runtime_value(outbound))); + + sort(result, function(a, b) { + return a < b ? -1 : (a > b ? 1 : 0); + }); + + return result; } function runtime_outbounds_equal(left_path, right_path) { diff --git a/tachyon/files/usr/lib/torrserver/direct.uc b/tachyon/files/usr/lib/torrserver/direct.uc new file mode 100644 index 00000000..14471525 --- /dev/null +++ b/tachyon/files/usr/lib/torrserver/direct.uc @@ -0,0 +1,146 @@ +#!/usr/bin/ucode + +let fs = require("fs"); +let uci = require("core.uci"); + +const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon"; +const TABLE = "TachyonTorrServerDirect"; +const OUTBOUND_MARK = getenv("NFT_OUTBOUND_MARK") || "0x08000000"; + +function text(value) { return value == null ? "" : "" + value; } +function quote(value) { return "'" + replace(text(value), /'/g, "'\\''") + "'"; } +function command(args) { + let values = []; + for (let arg in args) push(values, quote(arg)); + return join(" ", values); +} +function success(args) { return system(command(args) + " >/dev/null 2>&1") == 0; } +function command_output(args) { + let pipe = fs.popen(command(args) + " 2>/dev/null", "r"); + if (!pipe) return ""; + let data = pipe.read("all"); + let status = pipe.close(); + return status == 0 && data != null ? text(data) : ""; +} +function read(path) { let value = fs.readfile(path); return value == null ? "" : text(value); } +function is_torrserver_cmdline(value) { + value = lc(replace(text(value), /\x00/g, " ")); + return match(value, /(^|[/ ])torrserver([^/ ]*)?( |$)/) != null; +} +function numeric_pid(path) { + let parts = split(path, "/"); + let pid = length(parts) > 2 ? parts[2] : ""; + return match(pid, /^[0-9]+$/) != null ? pid : ""; +} +function process_cgroup(pid) { + for (let line in split(read("/proc/" + pid + "/cgroup"), "\n")) { + let match_value = match(line, /^[0-9]+::(\/.+)$/); + if (match_value != null) return match_value[1]; + } + return ""; +} +function valid_cgroup(path) { + if (match(path, /^\/[A-Za-z0-9_.@:-]+(\/[A-Za-z0-9_.@:-]+)+$/) == null) + return false; + return path != "/services" && path != "/system.slice" && path != "/user.slice"; +} +function dedicated_cgroup(path) { + let pids = split(replace(read("/sys/fs/cgroup" + path + "/cgroup.procs"), /[\r\n]+$/g, ""), /[\r\n]+/); + let found = 0; + for (let pid in pids) { + if (pid == "") continue; + found++; + if (!is_torrserver_cmdline(read("/proc/" + pid + "/cmdline"))) return false; + } + return found > 0; +} +function discover() { + for (let cmdline_path in fs.glob("/proc/[0-9]*/cmdline")) { + let pid = numeric_pid(cmdline_path); + if (pid == "" || !is_torrserver_cmdline(read(cmdline_path))) continue; + let path = process_cgroup(pid); + if (valid_cgroup(path) && dedicated_cgroup(path)) + return { running: 1, available: 1, pid, cgroup: path }; + return { running: 1, available: 0, pid, cgroup: path }; + } + return { running: 0, available: 0, pid: "", cgroup: "" }; +} +function enabled() { return trim(uci.get(CONFIG_NAME + ".settings.torrserver_direct_enabled")) == "1"; } +function rule_output_active(output, info) { + if (!info.available) return false; + let path = substr(info.cgroup, 1); + let parts = split(path, "/"); + let level = length(parts) > 2 ? 2 : length(parts); + if (level == 2) path = parts[0] + "/" + parts[1]; + return (index(output, "type route hook output priority mangle - 1") >= 0 || + index(output, "type route hook output priority -151") >= 0) && + index(output, "socket cgroupv2 level " + level + " \"" + path + "\"") >= 0 && + (index(output, "meta mark set 0x08000000") >= 0 || + index(output, "meta mark set 0x8000000") >= 0) && + index(output, "Tachyon TorrServer Direct") >= 0; +} +function active(info) { + return rule_output_active(command_output([ "nft", "list", "chain", "inet", TABLE, "output" ]), info); +} +function remove_rule() { success([ "nft", "delete", "table", "inet", TABLE ]); } +function apply_rule(info) { + if (!info.available) return false; + let path = substr(info.cgroup, 1); + let parts = split(path, "/"); + let level = length(parts) > 2 ? 2 : length(parts); + if (level == 2) + path = parts[0] + "/" + parts[1]; + remove_rule(); + let ruleset_path = "/tmp/tachyon-torrserver-direct.nft"; + let ruleset = "add table inet " + TABLE + "\n" + + "add chain inet " + TABLE + " output { type route hook output priority -151; policy accept; }\n" + + "add rule inet " + TABLE + " output socket cgroupv2 level " + level + " \"" + path + + "\" meta mark set " + OUTBOUND_MARK + " counter comment \"Tachyon TorrServer Direct\"\n"; + if (fs.writefile(ruleset_path, ruleset) == null) + return false; + let applied = success([ "nft", "-f", ruleset_path ]); + try { fs.unlink(ruleset_path); } catch (e) { } + if (!applied || !active(info)) { + remove_rule(); + return false; + } + return true; +} +function status() { + let info = discover(); + info.enabled = enabled() ? 1 : 0; + info.active = active(info) ? 1 : 0; + return info; +} +function reconcile() { + if (!enabled()) { remove_rule(); return 0; } + let info = discover(); + if (!info.available) { remove_rule(); return 1; } + return apply_rule(info) ? 0 : 1; +} +function worker() { + let last_cgroup = ""; + while (enabled()) { + let info = discover(); + if (info.available && (info.cgroup != last_cgroup || !active(info))) { + if (apply_rule(info)) last_cgroup = info.cgroup; + } + else if (!info.available) { + remove_rule(); + last_cgroup = ""; + } + system("sleep 60"); + } + remove_rule(); +} + +let mode = ARGV[0] || "status"; +if (mode == "status") print(sprintf("%J\n", status())); +else if (mode == "reconcile") exit(reconcile()); +else if (mode == "remove") { remove_rule(); exit(0); } +else if (mode == "worker") worker(); +else if (mode == "rule-output-active") { + let info = { available: 1, cgroup: ARGV[1] || "" }; + exit(rule_output_active(read("/dev/stdin"), info) ? 0 : 1); +} +else exit(1); diff --git a/tests/direct_bypass_torrserver.sh b/tests/direct_bypass_torrserver.sh new file mode 100644 index 00000000..0d220ec3 --- /dev/null +++ b/tests/direct_bypass_torrserver.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc" +VALIDATOR_UC="$TACHYON_LIB/config/validator.uc" +TORRSERVER_UC="$TACHYON_LIB/torrserver/direct.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +generate_config() { + local fixture="$1" + local output="$2" + mkdir -p "${output}.section-cache" + ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \ + "$fixture" "$output" "127.0.0.1" +} + +# 1. Verify direct_bypass disabled -> no direct inbound +cat >"$WORK_DIR/no_bypass.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "direct_bypass_enabled": "0" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ + "{\"type\":\"vless\",\"tag\":\"Alpha\",\"server\":\"alpha.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000001\",\"tls\":{\"enabled\":true}}" + ] + } + ] +} +JSON + +generate_config "$WORK_DIR/no_bypass.json" "$WORK_DIR/out_no_bypass.json" +if grep -Fq '"tag": "direct-bypass-in"' "$WORK_DIR/out_no_bypass.json"; then + fail "direct-bypass-in should not be present when direct_bypass_enabled is 0" +fi + +# 2. Verify direct_bypass enabled -> mixed inbound present with correct port and detour +cat >"$WORK_DIR/bypass_on.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "direct_bypass_enabled": "1", + "direct_bypass_port": "3128" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "connection", + "outbound_jsons": [ + "{\"type\":\"vless\",\"tag\":\"Alpha\",\"server\":\"alpha.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000001\",\"tls\":{\"enabled\":true}}" + ] + } + ] +} +JSON + +generate_config "$WORK_DIR/bypass_on.json" "$WORK_DIR/out_bypass_on.json" +grep -Fq '"tag": "direct-bypass-in"' "$WORK_DIR/out_bypass_on.json" || + fail "direct-bypass-in must be present when direct_bypass_enabled is 1" +grep -Fq '"listen_port": 3128' "$WORK_DIR/out_bypass_on.json" || + fail "direct-bypass-in must listen on configured port 3128" +grep -Fq '"outbound": "direct-bypass-out"' "$WORK_DIR/out_bypass_on.json" || + fail "direct-bypass-out must be configured" +grep -Fq 'direct-bypass-in' "$WORK_DIR/out_bypass_on.json" || + fail "direct-bypass-in must be routed" + +# 3. Verify validator validates direct_bypass_port +cat >"$WORK_DIR/invalid_port.json" <<'JSON' +{ + "settings": { + "direct_bypass_enabled": "1", + "direct_bypass_port": "99999" + } +} +JSON + +if ucode -L "$TACHYON_LIB" "$VALIDATOR_UC" validate-fixture "$WORK_DIR/invalid_port.json" >/dev/null 2>&1; then + fail "validator should reject direct_bypass_port 99999" +fi + +# 4. Verify TorrServer direct rule recognition +sample_nft='table inet TachyonTorrServerDirect { + chain output { + type route hook output priority -151; policy accept; + socket cgroupv2 level 2 "services/torrserver" meta mark set 0x08000000 counter comment "Tachyon TorrServer Direct" + } +}' +echo "$sample_nft" | ucode -L "$TACHYON_LIB" "$TORRSERVER_UC" rule-output-active "/services/torrserver" || + fail "TorrServer rule-output-active must match valid rule" + +# 5. Verify TorrServer direct status outputs JSON +status_output="$(ucode -L "$TACHYON_LIB" "$TORRSERVER_UC" status)" +echo "$status_output" | grep -Fq '"available":' || + fail "TorrServer status must include available field" +echo "$status_output" | grep -Fq '"running":' || + fail "TorrServer status must include running field" + +printf 'direct bypass and torrserver tests passed\n' diff --git a/tests/runtime_state_predicates.sh b/tests/runtime_state_predicates.sh index e8d47839..90f3d832 100644 --- a/tests/runtime_state_predicates.sh +++ b/tests/runtime_state_predicates.sh @@ -575,6 +575,10 @@ debug 127.0.0.1 [runtime.mwan3_active] 1 +[settings.direct_bypass_enabled] +0 +[settings.direct_bypass_port] +2080 [settings.enable_yacd] 1 [settings.enable_yacd_wan_access] diff --git a/tests/shell_inventory.sh b/tests/shell_inventory.sh index 4907d25e..5d6aa03a 100644 --- a/tests/shell_inventory.sh +++ b/tests/shell_inventory.sh @@ -46,6 +46,7 @@ expected_shell_scripts="$( 'luci-app-tachyon/root/etc/uci-defaults/50_luci-tachyon' \ 'tachyon/files/etc/hotplug.d/iface/99-tachyon-wan-monitor' \ 'tachyon/files/etc/init.d/tachyon' \ + 'tachyon/files/etc/init.d/tachyon-torrserver-direct' \ 'tachyon/files/usr/lib/cgi-bin/tachyon-agent' \ 'tachyon/files/usr/lib/firewall.include' | LC_ALL=C sort diff --git a/tests/subscription_order_independence.sh b/tests/subscription_order_independence.sh new file mode 100644 index 00000000..44d3e155 --- /dev/null +++ b/tests/subscription_order_independence.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PARSER_UC="$ROOT_DIR/tachyon/files/usr/lib/subscription/parser.uc" +CACHE_UC="$ROOT_DIR/tachyon/files/usr/lib/subscription/cache.uc" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +# 1. Verify that identical subscriptions with reordered outbounds match +cat << 'EOF' > "$WORK_DIR/order_a.json" +{ + "outbounds": [ + { + "tag": "nl-ams-01", + "type": "vless", + "server": "ams.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000001", + "tls": { "enabled": true, "server_name": "ams.example.com" } + }, + { + "tag": "de-fra-02", + "type": "vless", + "server": "fra.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000002", + "tls": { "enabled": true, "server_name": "fra.example.com" } + }, + { + "tag": "us-nyc-03", + "type": "vless", + "server": "nyc.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000003", + "tls": { "enabled": true, "server_name": "nyc.example.com" } + } + ] +} +EOF + +cat << 'EOF' > "$WORK_DIR/order_b.json" +{ + "outbounds": [ + { + "tag": "us-nyc-03", + "type": "vless", + "server": "nyc.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000003", + "tls": { "enabled": true, "server_name": "nyc.example.com" } + }, + { + "tag": "nl-ams-01", + "type": "vless", + "server": "ams.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000001", + "tls": { "enabled": true, "server_name": "ams.example.com" } + }, + { + "tag": "de-fra-02", + "type": "vless", + "server": "fra.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000002", + "tls": { "enabled": true, "server_name": "fra.example.com" } + } + ] +} +EOF + +ucode -L "$TACHYON_LIB" "$PARSER_UC" runtime-outbounds-equal "$WORK_DIR/order_a.json" "$WORK_DIR/order_b.json" || + fail "Reordered subscriptions must be considered runtime equal" + +# 2. Verify that non-runtime metadata difference (share_link) does not affect equality +cat << 'EOF' > "$WORK_DIR/order_metadata.json" +{ + "outbounds": [ + { + "tag": "de-fra-02", + "type": "vless", + "server": "fra.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000002", + "tls": { "enabled": true, "server_name": "fra.example.com" }, + "share_link": "vless://00000000-0000-0000-0000-000000000002@fra.example.com:443#fra" + }, + { + "tag": "nl-ams-01", + "type": "vless", + "server": "ams.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000001", + "tls": { "enabled": true, "server_name": "ams.example.com" } + }, + { + "tag": "us-nyc-03", + "type": "vless", + "server": "nyc.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000003", + "tls": { "enabled": true, "server_name": "nyc.example.com" } + } + ] +} +EOF + +ucode -L "$TACHYON_LIB" "$PARSER_UC" runtime-outbounds-equal "$WORK_DIR/order_a.json" "$WORK_DIR/order_metadata.json" || + fail "Subscriptions differing only by share_link metadata and order must be considered equal" + +# 3. Verify that real configuration change is NOT equal +cat << 'EOF' > "$WORK_DIR/order_different.json" +{ + "outbounds": [ + { + "tag": "nl-ams-01", + "type": "vless", + "server": "ams.example.com", + "server_port": 8443, + "uuid": "00000000-0000-0000-0000-000000000001", + "tls": { "enabled": true, "server_name": "ams.example.com" } + }, + { + "tag": "de-fra-02", + "type": "vless", + "server": "fra.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000002", + "tls": { "enabled": true, "server_name": "fra.example.com" } + }, + { + "tag": "us-nyc-03", + "type": "vless", + "server": "nyc.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000003", + "tls": { "enabled": true, "server_name": "nyc.example.com" } + } + ] +} +EOF + +if ucode -L "$TACHYON_LIB" "$PARSER_UC" runtime-outbounds-equal "$WORK_DIR/order_a.json" "$WORK_DIR/order_different.json"; then + fail "Subscriptions with port change must not be considered runtime equal" +fi + +# 4. Verify that missing an outbound is NOT equal +cat << 'EOF' > "$WORK_DIR/order_missing.json" +{ + "outbounds": [ + { + "tag": "nl-ams-01", + "type": "vless", + "server": "ams.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000001", + "tls": { "enabled": true, "server_name": "ams.example.com" } + } + ] +} +EOF + +if ucode -L "$TACHYON_LIB" "$PARSER_UC" runtime-outbounds-equal "$WORK_DIR/order_a.json" "$WORK_DIR/order_missing.json"; then + fail "Subscriptions with missing outbounds must not be considered runtime equal" +fi + +echo "subscription_order_independence: ALL ASSERTIONS PASSED" diff --git a/tests/zapret_validator.sh b/tests/zapret_validator.sh index 066984d9..5361368e 100644 --- a/tests/zapret_validator.sh +++ b/tests/zapret_validator.sh @@ -106,6 +106,19 @@ if (zapret2_validator.validate_strategy("nfqws", "--dpi-desync=fake", "").valid) if (validator.strategy_or_default("", "--default\t1") != "--default 1") exit(1); + +let runtime = require("providers.nfqueue.runtime"); +let cfg = { legacy_runtime_base: "", provider_base_dir: "" }; +let normalized = runtime.expand_strategy(cfg, "--filter-tcp=80 --ctrack-timeouts=30,30,600,30,30,30,30,30"); +if (normalized != "--filter-tcp=80 --ctrack-timeouts=30:600:30:30") { + print("Unexpected normalized timeouts: " + normalized + "\n"); + exit(1); +} +let untouched = runtime.expand_strategy(cfg, "--filter-tcp=80 --ctrack-timeouts=30:600:30:30"); +if (untouched != "--filter-tcp=80 --ctrack-timeouts=30:600:30:30") { + print("Untouched timeouts changed: " + untouched + "\n"); + exit(1); +} UCODE ucode -L "$TACHYON_LIB" "$WORK_DIR/require-zapret-validator.uc"