feat: стабильность подписок, порог DNS failover, прямой порт прокси и TorrServer Direct

- subscription: детерминированная сортировка аутбаундов при расчете сигнатуры кэша (защита от перезапусков при shuffle)
- dns: порог последовательных ошибок dns_failover_failure_threshold (защита от флаппинга)
- singbox: выделенный direct_bypass inbound (mixed port 2080) для прямого выхода устройств в обход TPROXY
- torrserver: автоматический bypass cgroups v2 services/torrserver прямо в WAN
- zapret: нормализация устаревших параметров conntrack --ctrack-timeouts
- ui: интеграция управления службами TorrServer Direct и Direct Proxy в LuCI
- tests: новые тесты subscription_order_independence и direct_bypass_torrserver
This commit is contained in:
Dushnilin
2026-09-06 21:19:42 +03:00
parent 510fb48dd8
commit e8c1d9d382
30 changed files with 1173 additions and 30 deletions
@@ -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);
}
@@ -38,6 +38,10 @@ const componentActionKeyMap: Record<string, UpdatesActionKey> = {
'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(
@@ -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');
});
});
@@ -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,
@@ -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 },
};
}
@@ -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),
)
: '',
]);
@@ -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: '' },
},
};
@@ -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<Tachyon.ComponentName, string> = {
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;
+13 -2
View File
@@ -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;
@@ -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;
+2
View File
@@ -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'
@@ -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
}
+89 -1
View File
@@ -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");
}
+25
View File
@@ -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) {
+1 -1
View File
@@ -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.");
+2 -2
View File
@@ -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 <HOSTLIST> --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 <HOSTLIST_NOAUTO> --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 <HOSTLIST> --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");
@@ -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])
@@ -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
};
+2
View File
@@ -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);
@@ -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,
@@ -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"));
@@ -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);
+5 -9
View File
@@ -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);
+9 -1
View File
@@ -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) {
+146
View File
@@ -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);
+120
View File
@@ -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'
+4
View File
@@ -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]
+1
View File
@@ -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
+178
View File
@@ -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"
+13
View File
@@ -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"