17 Commits
Author SHA1 Message Date
hacker07 997d72f85c fix(install): strip UTF-8 BOM so the shebang is not misread by BusyBox ash 2026-09-18 16:59:57 +03:00
hacker07 b083789e74 Merge upstream 383b426e (GeoIP localization + dnsmasq DNS-cache monitoring)
Keep both: upstream RAM-usage calculator for dns_cache_size and the fork
AdGuard Home DNS mode options (dns_main_mode / dns_adguard_failover_enabled).
2026-09-17 21:52:45 +03:00
hacker07 6f0055d636 Merge feature/adguard (upstream 3.0.0 + fork patches) into main 2026-09-17 20:48:52 +03:00
hacker07 15c1b1eb81 Merge upstream main (3.0.0) into feature/adguard
Upstream rewrote install.sh to 3.0.0. Re-apply the fork installer changes on
top: point at the NeoTachyon Gitea repo (REPO_HOST/OWNER/NAME), use the Gitea
API base for release resolution and support optional GITEA_USER/TOKEN auth for
the API and downloads.
2026-09-17 20:48:47 +03:00
Dushnilin 383b426e46 feat: локализация исключений GeoIP по секциям и мониторинг памяти DNS-кэша dnsmasq 2026-09-17 20:48:14 +03:00
hacker07 da508f51e6 ci: drop upstream GitHub CI workflows (backend-ci/frontend-ci/shellcheck)
These target GitHub Actions runners and do not fit the bare Gitea Actions
host runner, producing failed runs on every branch push. The build.yml
release workflow (auto-build on tags) is kept.
2026-09-17 20:34:29 +03:00
Dushnilin 623ea59797 feat: гармонизация секции AWG в дашборде и изоляция маршрутизации FPTN 2026-09-17 19:42:54 +03:00
Dushnilin 44e93b1f16 fix(test): restore CACHE and RUNTIME module paths in subscription_happ_crypt4 test 2026-09-17 18:55:51 +03:00
Dushnilin 5f3b3eb77b fix(test): make crypt4 test resilient without openssl CLI and add openssl-util to docker e2e 2026-09-17 18:44:22 +03:00
Dushnilin bf4255438f release: 1.3.29 — Happ Crypt4 decryption, dual HWID headers, P2P isolation fix & diagnostic symptom collapsing 2026-09-17 18:26:35 +03:00
Dushnilin e2c90ed6ea fix(singbox): fix p2p isolation rule matching for transport network and port directions 2026-09-17 18:17:44 +03:00
Dushnilin b556f2539b feat(subscription): add crypt4 decryption, dual HWID headers, and diagnosis symptom collapsing 2026-09-17 18:04:23 +03:00
hacker07 f010dc2cf2 feat(install): support private Gitea credentials (TACHYON_GITEA_USER/TOKEN) for API and downloads
Differential ShellCheck / Differential ShellCheck (push) Failing after 16s
2026-09-17 17:10:12 +03:00
Dushnilin e610ea9ca3 fix(installer): use core.common in runtime healthcheck and fix CLI guard in ucode modules 2026-09-17 15:45:50 +03:00
Dushnilin 3bcce51ec3 Refactor Tachyon installer into resilient transactional flow (#61)
Replace the monolithic installer with a staged transactional flow, add atomic locking, APK lock waiting/diagnostics, tmpfs and overlay preflight, verified release downloads, recovery snapshots, package transaction healthchecks, and active installer regression tests.
2026-09-17 15:26:07 +03:00
Dushnilin a27173a04a fix(singbox): prevent empty logical subrules when excluding IPs on evaluate and match-all rules (#60) 2026-09-17 14:53:12 +03:00
Dushnilin 2ab4e0db74 fix(subscription): auto fallback on unsupported app provider stubs 2026-09-17 14:39:47 +03:00
53 changed files with 6807 additions and 7775 deletions
-88
View File
@@ -1,88 +0,0 @@
name: Backend CI
permissions:
contents: read
on:
pull_request:
paths:
- 'tachyon/files/**'
- 'tachyon/Makefile'
- 'build.sh'
- 'tests/**'
- 'luci-app-tachyon/**'
- 'fe-app-tachyon/locales/**'
- '.github/workflows/build.yml'
- '.github/workflows/backend-ci.yml'
push:
branches:
- main
paths:
- 'tachyon/files/**'
- 'tachyon/Makefile'
- 'build.sh'
- 'tests/**'
- 'luci-app-tachyon/**'
- 'fe-app-tachyon/locales/**'
- '.github/workflows/build.yml'
- '.github/workflows/backend-ci.yml'
jobs:
backend-checks:
name: Backend Runtime Checks
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Build ucode
shell: bash
run: |
sudo rm -f /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update
sudo apt-get install -y build-essential cmake git libjson-c-dev pkg-config nodejs curl
git clone --depth 1 --branch v0.0.20250529 https://github.com/jow-/ucode.git /tmp/ucode
cmake -S /tmp/ucode -B /tmp/ucode/build \
-DUBUS_SUPPORT=OFF \
-DUCI_SUPPORT=OFF \
-DULOOP_SUPPORT=OFF
cmake --build /tmp/ucode/build --parallel
sudo cmake --install /tmp/ucode/build
sudo ldconfig
command -v ucode
ucode -e 'print("ucode ready\n")'
- name: Check ucode syntax
shell: bash
run: |
find tachyon/files/usr/lib -name '*.uc' -print0 | xargs -0 -n1 ucode -c -o /dev/null
find tachyon/files/usr/lib -name '*.uc' -print0 | xargs -0 -n1 ucode -S -c -o /dev/null
- name: Run backend tests
shell: bash
run: |
find tests -maxdepth 1 -name '*.sh' \
! -name 'docker_e2e_test.sh' \
! -name 'container_entrypoint.sh' \
! -name 'run_all.sh' \
! -name 'ucode_syntax_lint.sh' \
-print0 | xargs -0 -n1 -P4 bash
openwrt-e2e:
name: OpenWrt Docker E2E
needs: backend-checks
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Run OpenWrt E2E suite
shell: bash
run: bash tests/docker_e2e_test.sh
-93
View File
@@ -1,93 +0,0 @@
name: Frontend CI
permissions:
contents: read
on:
pull_request:
paths:
- 'fe-app-tachyon/**'
- 'luci-app-tachyon/po/**'
- '.github/workflows/frontend-ci.yml'
push:
branches:
- main
paths:
- 'fe-app-tachyon/**'
- 'luci-app-tachyon/po/**'
- '.github/workflows/frontend-ci.yml'
jobs:
frontend-checks:
name: Frontend Quality Checks
runs-on: ubuntu-24.04
defaults:
run:
working-directory: fe-app-tachyon
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'
cache: npm
cache-dependency-path: fe-app-tachyon/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check formatting
id: format
run: |
npm run format
if ! git diff --exit-code; then
echo "::error::Code is not formatted. Run 'npm run format' locally."
exit 1
fi
- name: Run linter
run: npm run lint -- --max-warnings=0
- name: Run tests
run: npm test -- --run
- name: Check translation extraction
id: locale_extract
run: |
npm run locales:extract-calls
if ! git diff --exit-code -- locales/calls.json; then
echo "::error::Translation call index is stale. Run 'npm run locales:extract-calls' locally."
exit 1
fi
- name: Check distributed locales
id: locale_distribute
run: |
npm run locales:distribute
if ! git diff --exit-code -- ../luci-app-tachyon/po; then
echo "::error::LuCI locale copies are stale. Run 'npm run locales:distribute' locally."
exit 1
fi
- name: Build project
id: build
run: |
npm run build
if ! git diff --exit-code -- ../luci-app-tachyon/htdocs; then
echo "::error::Build generated changes. Check build output."
exit 1
fi
- name: Summary
if: always()
run: |
echo "## Frontend CI Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Format check: ${{ steps.format.outcome }}" >> $GITHUB_STEP_SUMMARY
echo "- Translation extraction: ${{ steps.locale_extract.outcome }}" >> $GITHUB_STEP_SUMMARY
echo "- Locale distribution: ${{ steps.locale_distribute.outcome }}" >> $GITHUB_STEP_SUMMARY
echo "- Overall lint/tests status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
echo "- Build: ${{ steps.build.outcome }}" >> $GITHUB_STEP_SUMMARY
-62
View File
@@ -1,62 +0,0 @@
name: Differential ShellCheck
on:
workflow_dispatch:
push:
branches:
- main
- 'rc/**'
paths:
- 'build.sh'
- 'install.sh'
- 'tachyon/files/etc/init.d/**'
- 'tachyon/files/usr/lib/cgi-bin/**'
- 'tachyon/files/etc/hotplug.d/iface/**'
- 'luci-app-tachyon/root/etc/uci-defaults/**'
- 'tests/**/*.sh'
- '.github/workflows/shellcheck.yml'
pull_request:
branches:
- main
- 'rc/**'
paths:
- 'build.sh'
- 'install.sh'
- 'tachyon/files/etc/init.d/**'
- 'tachyon/files/usr/lib/cgi-bin/**'
- 'tachyon/files/etc/hotplug.d/iface/**'
- 'luci-app-tachyon/root/etc/uci-defaults/**'
- 'tests/**/*.sh'
- '.github/workflows/shellcheck.yml'
permissions:
contents: read
jobs:
shellcheck:
name: Differential ShellCheck
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install ShellCheck
run: |
sudo rm -f /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update
sudo apt-get install -y shellcheck
- name: ShellCheck
shell: bash
run: |
mapfile -d '' scripts < <(
find . -type f \
\( -name '*.sh' -o -path './build.sh' -o -path './install.sh' \
-o -path './tachyon/files/etc/init.d/*' \
-o -path './tachyon/files/usr/lib/cgi-bin/*' \
-o -path './tachyon/files/etc/hotplug.d/iface/*' \
-o -path './luci-app-tachyon/root/etc/uci-defaults/*' \) \
-print0
)
shellcheck --severity=error "${scripts[@]}"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+32 -14
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-17 13:43+0300\n"
"PO-Revision-Date: 2026-09-17 13:43+0300\n"
"POT-Creation-Date: 2026-09-17 20:48+0300\n"
"PO-Revision-Date: 2026-09-17 20:48+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -273,10 +273,10 @@ msgid "Add URLs or local paths to .lst lists containing domains. IP entries are
msgstr "Добавьте URL или локальные пути к спискам .lst, содержащим домены. IP-записи игнорируются."
msgid "Add URLs or local paths to .srs / .json / .lst / .txt lists. Only domain rules are supported."
msgstr ""
msgstr "Укажите URL или локальные пути к спискам .srs / .json / .lst / .txt. Поддерживаются только правила для доменов."
msgid "Add URLs or local paths to .srs / .json / .lst / .txt lists. Subnets are ignored by default."
msgstr ""
msgstr "Укажите URL или локальные пути к спискам .srs / .json / .lst / .txt. Подсети по умолчанию игнорируются."
msgid "Add URLTest / Priority group"
msgstr "Добавить группу URLTest / Приоритет"
@@ -425,6 +425,9 @@ msgstr "Все сервисы"
msgid "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions."
msgstr "Весь трафик от этих IP-адресов будет маршрутизирован через секцию безусловно, игнорируя все остальные условия."
msgid "allocated dynamically on demand, not upfront"
msgstr "выделяется динамически по мере запросов, а не сразу"
msgid "Allow / Enable ONLY in scheduled hours (White period)"
msgstr "Разрешать / Включать ТОЛЬКО в указанные часы (Белый список)"
@@ -863,6 +866,9 @@ msgstr "Обходит UDP-трафик для выбранных игровых
msgid "CA Certificate"
msgstr "CA-сертификат"
msgid "Cache"
msgstr ""
msgid "Cache all built-in lists and databases even if they are not currently selected in any section. Recommended if you have plenty of storage space or USB drive."
msgstr "Кэшировать все встроенные списки и базы данных, даже если они не выбраны ни в одной секции. Рекомендуется при большом объёме хранилища или USB-накопителе."
@@ -1395,7 +1401,7 @@ msgid "Direct connection via ISP"
msgstr "Прямое подключение через провайдера"
msgid "Direct Proxy"
msgstr "Прямой прокси"
msgstr "Direct Proxy"
msgid "Direct WAN"
msgstr "Прямой WAN"
@@ -1439,8 +1445,8 @@ msgstr "Автоподбор и бенчмарк DNS"
msgid "DNS Blocking"
msgstr "Блокировка DNS"
msgid "DNS Cache Size"
msgstr ""
msgid "DNS Cache Size (entries)"
msgstr "Размер DNS-кэша (записей)"
msgid "DNS Check Interval"
msgstr "Интервал проверки DNS"
@@ -1797,7 +1803,7 @@ msgid "Enabled (always prioritize)"
msgstr "Включено (всегда повышать приоритет)"
msgid "Enables high-capacity local DNS caching in dnsmasq to accelerate DNS queries and absorb transient latency during service restarts."
msgstr ""
msgstr "Включает емкий локальный кэш DNS в dnsmasq для ускорения запросов и устранения задержек при перезапуске служб."
msgid "Enables the background daemon that polls Telegram for commands and sends notifications."
msgstr "Включает фоновый демон, который опрашивает Telegram на наличие команд и отправляет уведомления."
@@ -1874,6 +1880,9 @@ msgstr "Ошибка выполнения трассировки:"
msgid "Error updating lists:"
msgstr "Ошибка обновления списков:"
msgid "Estimated RAM usage (max):"
msgstr "Ориентировочный объём RAM (макс.):"
msgid "Every day"
msgstr "Каждый день"
@@ -3102,7 +3111,10 @@ msgid "Local Address"
msgstr "Локальный адрес"
msgid "Local DNS Cache"
msgstr ""
msgstr "Локальный кэш DNS"
msgid "Local DNS caching disabled (0 entries)"
msgstr "Локальное DNS-кэширование отключено (0 записей)"
msgid "Local SOCKS proxy desynchronizer (ciadpi)"
msgstr "Локальный десинхронизатор SOCKS-прокси (ciadpi)"
@@ -3176,6 +3188,9 @@ msgstr "Максимально допустимый суммарный траф
msgid "Maximum allowed online time per guest device per day. When reached, access is blocked until midnight. Enter 0 for unlimited."
msgstr "Максимально допустимое время доступа в интернет на одно гостевое устройство в день. При исчерпании доступ блокируется до полуночи. Введите 0 для отсутствия ограничений."
msgid "Maximum number of cached domain entries in dnsmasq (default: 10000 ≈ 1.2 MB RAM). Memory is allocated dynamically per request (~120 B per domain), not pre-allocated upfront. Recommended: 10000 for routers with ≥128 MB RAM, 2500–5000 for 64 MB devices."
msgstr "Максимальный лимит кэшируемых доменов в dnsmasq (по умолчанию: 10000 ≈ 1.2 МБ RAM). Память выделяется динамически по мере запросов (~120 Б на домен), а не резервируется сразу. Рекомендуется: 10000 для роутеров от 128 МБ RAM, 2500–5000 для 64 МБ устройств."
msgid "Maximum size of junk packets"
msgstr "Максимальный размер мусорных пакетов"
@@ -3479,9 +3494,6 @@ msgstr "Уведомлять о перезапусках сервиса"
msgid "Notify on Subscription Updates"
msgstr "Уведомлять об обновлениях подписок"
msgid "Number of cached DNS domain entries in dnsmasq (default: 10000)."
msgstr ""
msgid "Number of consecutive failed checks required before switching DNS servers."
msgstr "Количество последовательных неудачных проверок до переключения DNS-серверов."
@@ -4287,7 +4299,7 @@ msgid "Rule Name"
msgstr "Название правила"
msgid "Rule set must be an HTTP(S) URL or a local .srs / .json / .lst / .txt path"
msgstr ""
msgstr "Набор правил должен быть URL HTTP(S) или локальным путем к .srs / .json / .lst / .txt"
msgid "Rule set settings"
msgstr "Настройки набора правил"
@@ -5990,6 +6002,12 @@ msgstr "Правила маршрутизации sing-box для Zapret2 не
msgid "Zero config mesh VPN daemon"
msgstr "Mesh VPN-демон с нулевой конфигурацией"
msgid "DNS Cache Size"
msgstr "Размер кэша DNS"
msgid "Number of cached DNS domain entries in dnsmasq (default: 10000)."
msgstr "Количество кэшируемых доменных записей в dnsmasq (по умолчанию: 10000)."
msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported."
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только правила доменов."
@@ -5997,7 +6015,7 @@ msgid "Add URLs or local paths to .srs / .json lists. Subnets are ignored by def
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Подсети игнорируются по умолчанию."
msgid "Plain .lst / .txt lists belong in \"Domain and IP lists\", not \"Rule sets\""
msgstr ""
msgstr "Текстовые списки .lst / .txt относятся к «Спискам доменов и IP», а не к «Наборам правил»"
msgid "Rule set must be an HTTP(S) URL or a local .srs / .json path"
msgstr "Набор правил должен быть HTTP(S) URL или локальным путём .srs / .json"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "fe-app-tachyon",
"version": "1.3.28",
"version": "1.3.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fe-app-tachyon",
"version": "1.3.28",
"version": "1.3.29",
"license": "MIT",
"devDependencies": {
"@babel/parser": "7.28.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fe-app-tachyon",
"version": "1.3.28",
"version": "1.3.29",
"license": "MIT",
"type": "module",
"scripts": {
@@ -76,6 +76,10 @@ export async function fetchServicesInfo() {
: previousData.watchdogRunning,
zapret2Running: previousData.zapret2Running,
zapret2MemoryMb: previousData.zapret2MemoryMb,
dnsmasqRunning: previousData.dnsmasqRunning,
dnsmasqMemoryMb: previousData.dnsmasqMemoryMb,
dnsmasqLocalCacheEnabled: previousData.dnsmasqLocalCacheEnabled,
dnsmasqCacheSize: previousData.dnsmasqCacheSize,
},
},
});
@@ -170,6 +170,10 @@ export interface StoreType {
singboxMemoryMb?: number;
zapret2Running?: number;
zapret2MemoryMb?: number;
dnsmasqRunning?: number;
dnsmasqMemoryMb?: number;
dnsmasqLocalCacheEnabled?: number;
dnsmasqCacheSize?: number;
};
};
tailscaleWidget: {
@@ -135,6 +135,18 @@ function applyServiceState(uiState: Tachyon.UiState) {
zapret2MemoryMb: uiState.service.zapret2
? uiState.service.zapret2.memory_rss_mb
: undefined,
dnsmasqRunning: uiState.service.dnsmasq
? uiState.service.dnsmasq.running
: undefined,
dnsmasqMemoryMb: uiState.service.dnsmasq
? uiState.service.dnsmasq.memory_rss_mb
: undefined,
dnsmasqLocalCacheEnabled: uiState.service.dnsmasq
? uiState.service.dnsmasq.local_cache_enabled
: undefined,
dnsmasqCacheSize: uiState.service.dnsmasq
? uiState.service.dnsmasq.cache_size
: undefined,
},
},
diagnosticsSystemInfo: normalizeSingBoxVariantFields(nextSystemInfo),
@@ -2251,6 +2251,30 @@ async function renderServicesInfoWidget() {
});
}
if (data.dnsmasqRunning != null) {
let cacheDetail = '';
if (data.dnsmasqLocalCacheEnabled && data.dnsmasqCacheSize) {
const approxMb = (
(data.dnsmasqCacheSize * 120) /
(1024 * 1024)
).toFixed(1);
cacheDetail = ` [${_('Cache')}: ${data.dnsmasqCacheSize} ≈ ${approxMb} MB]`;
}
items.push({
key: 'Dnsmasq (DNS)',
value: data.dnsmasqRunning
? data.dnsmasqMemoryMb
? `${_('✔ Running')} (${data.dnsmasqMemoryMb} MB)${cacheDetail}`
: `${_('✔ Running')}${cacheDetail}`
: _('✘ Stopped'),
attributes: {
class: data.dnsmasqRunning
? 'tachyon_dashboard-page__widgets-section__item__row--success'
: 'tachyon_dashboard-page__widgets-section__item__row--error',
},
});
}
return items;
},
'renderServicesInfoWidget',
@@ -47,6 +47,19 @@ interface IRenderSectionsProps {
testingOutboundCodes?: Record<string, boolean>;
}
const CONNECTION_ACTION_LABELS: Record<string, string> = {
awg: 'AmneziaWG',
warp: 'WARP',
anytls: 'AnyTLS',
snell: 'Snell',
mieru: 'Mieru',
sudoku: 'Sudoku',
masque: 'MASQUE',
openvpn: 'OpenVPN',
fptn: 'FPTN',
vpn: 'VPN',
};
function renderFailedState() {
return E(
'div',
@@ -529,89 +542,88 @@ function renderDefaultState({
.join(' ');
if (isConnectionNode) {
const statusText = latencyFetching
? _('Checking...')
: outbound.latency === -1 || outbound.latency < 0
? _('Not responding')
: outbound.runtimeAvailable
? _('Connected')
: _('Not connected');
const statusColor = latencyFetching
? 'var(--warn-color-medium, orange)'
: outbound.latency === -1 || outbound.latency < 0
? 'var(--error-color-medium, red)'
: outbound.runtimeAvailable
? 'var(--success-color-medium, green)'
: 'var(--error-color-medium, red)';
const latencyDisplay =
outbound.latency && outbound.latency > 0
? `${outbound.latency} ms`
: '';
return E(
'div',
{
class: className,
style:
'display: flex; align-items: center; justify-content: space-between; padding: 12px; min-width: 0; gap: 16px;',
'display: flex; flex-direction: column; justify-content: space-between; padding: 12px; min-width: 0;',
},
[
E(
'div',
{
style:
'display: flex; align-items: center; gap: 12px; min-width: 0;',
class: 'tachyon_dashboard-page__outbound-grid__item__header',
style: 'margin-bottom: 8px;',
},
[
E(
'b',
{
style:
'overflow-wrap: anywhere; word-break: break-all; min-width: 0;',
},
renderFlagEmojis(outbound.displayName),
),
outbound.prefix
? E(
'span',
{
class:
'tachyon_dashboard-page__outbound-grid__item__prefix-badge',
},
renderFlagEmojis(outbound.prefix),
)
: '',
E(
'span',
{
style:
'opacity: 0.7; font-size: 13px; white-space: nowrap; flex-shrink: 0;',
},
[formatOutboundType(outbound.type, outbound.transport)].filter(
Boolean,
),
),
E(
'div',
{
class: getLatencyClass(),
style: 'white-space: nowrap; flex-shrink: 0;',
class:
'tachyon_dashboard-page__outbound-grid__item__title-wrapper',
},
connectionStatusText,
[
E(
'span',
{
style: `color: ${statusColor}; font-weight: 500; font-size: 13px;`,
},
statusText,
),
],
),
],
),
E(
'button',
'div',
{
type: 'button',
class: 'btn dashboard-sections-grid-item-test-latency',
style: 'padding: 4px 12px; height: 30px; flex-shrink: 0;',
disabled: latencyFetching ? true : undefined,
click: (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (latencyFetching) return;
testLatency();
},
class: 'tachyon_dashboard-page__outbound-grid__item__footer',
style:
'display: flex; align-items: center; justify-content: space-between; gap: 8px;',
},
latencyFetching
? [
renderLoaderCircleIcon24(),
E(
'span',
[
E(
'div',
{
class: 'tachyon_dashboard-page__outbound-grid__item__type',
style:
'overflow: hidden; text-overflow: ellipsis; white-space: nowrap;',
},
renderFlagEmojis(outbound.displayName),
),
latencyDisplay
? E(
'div',
{
class: 'dashboard-sections-grid-item-test-latency__label',
class: getLatencyClass(),
style: 'white-space: nowrap; flex-shrink: 0;',
},
_('Checking...'),
),
]
: E(
'span',
{ class: 'dashboard-sections-grid-item-test-latency__label' },
_('Check Connection'),
),
latencyDisplay,
)
: '',
],
),
],
);
@@ -817,26 +829,6 @@ function renderDefaultState({
);
}
if (isConnectionNode) {
return E(
'div',
{
class: 'tachyon_dashboard-page__outbound-section',
style: 'border: none; padding: 0;',
},
[
E(
'div',
{
style:
'display: flex; flex-direction: column; gap: 8px; padding: 0;',
},
[...section.outbounds.map((outbound) => renderOutbound(outbound))],
),
],
);
}
const metadataNodes = (section.subscriptionMetadata || [])
.map((metadata) => renderSubscriptionMetadata(metadata))
.filter(Boolean) as HTMLElement[];
@@ -907,6 +899,11 @@ function renderDefaultState({
'openvpn',
].includes(section.action || '');
const connectionTitle =
(section.action &&
CONNECTION_ACTION_LABELS[section.action]) ||
selectedOutbound.displayName;
function getLatencyColor() {
if (isConnectionNode) {
if (latencyFetching)
@@ -974,7 +971,10 @@ function renderDefaultState({
E(
'span',
{ style: 'opacity: 0.7;' },
selectedOutbound.displayName,
isConnectionNode
? connectionTitle
: selectedOutbound.prefix ||
selectedOutbound.displayName,
),
latencyText
? E(
@@ -986,7 +986,32 @@ function renderDefaultState({
],
);
})()
: '',
: isConnectionNode
? (() => {
const selectedOutbound = section.outbounds.find(
(o) => o.selected,
);
if (!selectedOutbound) return '';
const connectionTitle =
(section.action &&
CONNECTION_ACTION_LABELS[section.action]) ||
selectedOutbound.displayName;
if (
section.displayName.toLowerCase().trim() ===
connectionTitle.toLowerCase().trim()
) {
return '';
}
return E(
'span',
{
style:
'font-size: 13px; font-weight: normal; margin-left: 8px; opacity: 0.7;',
},
connectionTitle,
);
})()
: '',
],
),
E(
@@ -527,4 +527,81 @@ describe('renderSections', () => {
expect(prefixBadge).not.toBeNull();
expect(prefixBadge?.textContent).toBe('Geodema Network');
});
it('renders connection node (AWG) with collapsible header and check connection action', () => {
const section: any = {
sectionName: 'AWG',
displayName: 'AMNEZIA',
action: 'awg',
outbounds: [
{
code: 'awg-out',
displayName: 'AmneziaWG 3.0',
latency: 155,
runtimeAvailable: true,
selected: true,
},
],
};
// 1. Collapsed state
const collapsedEl = renderSections({
loading: false,
failed: false,
section,
isCollapsed: true,
onTestLatency: vi.fn(),
onChooseOutbound: vi.fn(),
onCopyOutbound: vi.fn(),
onShowUrlTestInfo: vi.fn(),
onShowPriorityInfo: vi.fn(),
onUpdateSubscription: vi.fn(),
latencyFetching: false,
subscriptionUpdating: false,
});
const titleSec = collapsedEl.querySelector(
'.tachyon_dashboard-page__outbound-section__title-section',
);
expect(titleSec).not.toBeNull();
expect(titleSec?.textContent).toContain('AMNEZIA');
expect(titleSec?.textContent).toContain('AmneziaWG');
expect(titleSec?.textContent).toContain('155ms');
expect(titleSec?.textContent).toContain('Check Connection');
expect(
collapsedEl.querySelector('.tachyon_dashboard-page__outbound-grid'),
).toBeNull();
// 2. Expanded state
const expandedEl = renderSections({
loading: false,
failed: false,
section,
isCollapsed: false,
onTestLatency: vi.fn(),
onChooseOutbound: vi.fn(),
onCopyOutbound: vi.fn(),
onShowUrlTestInfo: vi.fn(),
onShowPriorityInfo: vi.fn(),
onUpdateSubscription: vi.fn(),
latencyFetching: false,
subscriptionUpdating: false,
});
const expandedTitleSec = expandedEl.querySelector(
'.tachyon_dashboard-page__outbound-section__title-section',
);
expect(expandedTitleSec).not.toBeNull();
expect(expandedTitleSec?.textContent).toContain('AMNEZIA');
expect(expandedTitleSec?.textContent).toContain('AmneziaWG');
expect(expandedTitleSec?.textContent).toContain('Check Connection');
const grid = expandedEl.querySelector(
'.tachyon_dashboard-page__outbound-grid',
);
expect(grid).not.toBeNull();
expect(grid?.textContent).toContain('Connected');
expect(grid?.textContent).toContain('AmneziaWG 3.0');
expect(grid?.textContent).toContain('155 ms');
});
});
+6
View File
@@ -756,6 +756,12 @@ export namespace Tachyon {
running: number;
memory_rss_mb?: number;
};
dnsmasq?: {
running: number;
memory_rss_mb?: number;
local_cache_enabled?: number;
cache_size?: number;
};
};
capabilities: GetUiCapabilities;
actions: {
+728 -3106
View File
File diff suppressed because it is too large Load Diff
@@ -1862,6 +1862,18 @@ function formatServiceStatusMessage(msg) {
}
// src/tachyon/tabs/dashboard/partials/renderSections.ts
var CONNECTION_ACTION_LABELS = {
awg: "AmneziaWG",
warp: "WARP",
anytls: "AnyTLS",
snell: "Snell",
mieru: "Mieru",
sudoku: "Sudoku",
masque: "MASQUE",
openvpn: "OpenVPN",
fptn: "FPTN",
vpn: "VPN"
};
function renderFailedState() {
return E(
"div",
@@ -2209,80 +2221,64 @@ function renderDefaultState({
outboundSwitching ? "tachyon_dashboard-page__outbound-grid__item--switching" : ""
].filter(Boolean).join(" ");
if (isConnectionNode) {
const statusText = latencyFetching ? _("Checking...") : outbound.latency === -1 || outbound.latency < 0 ? _("Not responding") : outbound.runtimeAvailable ? _("Connected") : _("Not connected");
const statusColor = latencyFetching ? "var(--warn-color-medium, orange)" : outbound.latency === -1 || outbound.latency < 0 ? "var(--error-color-medium, red)" : outbound.runtimeAvailable ? "var(--success-color-medium, green)" : "var(--error-color-medium, red)";
const latencyDisplay = outbound.latency && outbound.latency > 0 ? `${outbound.latency} ms` : "";
return E(
"div",
{
class: className,
style: "display: flex; align-items: center; justify-content: space-between; padding: 12px; min-width: 0; gap: 16px;"
style: "display: flex; flex-direction: column; justify-content: space-between; padding: 12px; min-width: 0;"
},
[
E(
"div",
{
style: "display: flex; align-items: center; gap: 12px; min-width: 0;"
class: "tachyon_dashboard-page__outbound-grid__item__header",
style: "margin-bottom: 8px;"
},
[
E(
"b",
"div",
{
style: "overflow-wrap: anywhere; word-break: break-all; min-width: 0;"
class: "tachyon_dashboard-page__outbound-grid__item__title-wrapper"
},
[
E(
"span",
{
style: `color: ${statusColor}; font-weight: 500; font-size: 13px;`
},
statusText
)
]
)
]
),
E(
"div",
{
class: "tachyon_dashboard-page__outbound-grid__item__footer",
style: "display: flex; align-items: center; justify-content: space-between; gap: 8px;"
},
[
E(
"div",
{
class: "tachyon_dashboard-page__outbound-grid__item__type",
style: "overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
},
renderFlagEmojis(outbound.displayName)
),
outbound.prefix ? E(
"span",
{
class: "tachyon_dashboard-page__outbound-grid__item__prefix-badge"
},
renderFlagEmojis(outbound.prefix)
) : "",
E(
"span",
{
style: "opacity: 0.7; font-size: 13px; white-space: nowrap; flex-shrink: 0;"
},
[formatOutboundType(outbound.type, outbound.transport)].filter(
Boolean
)
),
E(
latencyDisplay ? E(
"div",
{
class: getLatencyClass(),
style: "white-space: nowrap; flex-shrink: 0;"
},
connectionStatusText
)
latencyDisplay
) : ""
]
),
E(
"button",
{
type: "button",
class: "btn dashboard-sections-grid-item-test-latency",
style: "padding: 4px 12px; height: 30px; flex-shrink: 0;",
disabled: latencyFetching ? true : void 0,
click: (event) => {
event.preventDefault();
event.stopPropagation();
if (latencyFetching) return;
testLatency();
}
},
latencyFetching ? [
renderLoaderCircleIcon24(),
E(
"span",
{
class: "dashboard-sections-grid-item-test-latency__label"
},
_("Checking...")
)
] : E(
"span",
{ class: "dashboard-sections-grid-item-test-latency__label" },
_("Check Connection")
)
)
]
);
@@ -2435,24 +2431,6 @@ function renderDefaultState({
]
);
}
if (isConnectionNode) {
return E(
"div",
{
class: "tachyon_dashboard-page__outbound-section",
style: "border: none; padding: 0;"
},
[
E(
"div",
{
style: "display: flex; flex-direction: column; gap: 8px; padding: 0;"
},
[...section.outbounds.map((outbound) => renderOutbound(outbound))]
)
]
);
}
const metadataNodes = (section.subscriptionMetadata || []).map((metadata) => renderSubscriptionMetadata(metadata)).filter(Boolean);
const subscriptionUpdateAction = renderSubscriptionUpdateAction(
section,
@@ -2512,6 +2490,7 @@ function renderDefaultState({
"masque",
"openvpn"
].includes(section.action || "");
const connectionTitle = section.action && CONNECTION_ACTION_LABELS[section.action] || selectedOutbound.displayName;
function getLatencyColor() {
if (isConnectionNode2) {
if (latencyFetching)
@@ -2552,7 +2531,7 @@ function renderDefaultState({
E(
"span",
{ style: "opacity: 0.7;" },
selectedOutbound.displayName
isConnectionNode2 ? connectionTitle : selectedOutbound.prefix || selectedOutbound.displayName
),
latencyText ? E(
"span",
@@ -2561,6 +2540,22 @@ function renderDefaultState({
) : ""
]
);
})() : isConnectionNode ? (() => {
const selectedOutbound = section.outbounds.find(
(o) => o.selected
);
if (!selectedOutbound) return "";
const connectionTitle = section.action && CONNECTION_ACTION_LABELS[section.action] || selectedOutbound.displayName;
if (section.displayName.toLowerCase().trim() === connectionTitle.toLowerCase().trim()) {
return "";
}
return E(
"span",
{
style: "font-size: 13px; font-weight: normal; margin-left: 8px; opacity: 0.7;"
},
connectionTitle
);
})() : ""
]
),
@@ -6797,7 +6792,11 @@ function applyServiceState(uiState) {
tachyonStatus: uiState.service.tachyon.status,
watchdogRunning: store.get().servicesInfoWidget.data.watchdogRunning,
zapret2Running: uiState.service.zapret2 ? uiState.service.zapret2.running : void 0,
zapret2MemoryMb: uiState.service.zapret2 ? uiState.service.zapret2.memory_rss_mb : void 0
zapret2MemoryMb: uiState.service.zapret2 ? uiState.service.zapret2.memory_rss_mb : void 0,
dnsmasqRunning: uiState.service.dnsmasq ? uiState.service.dnsmasq.running : void 0,
dnsmasqMemoryMb: uiState.service.dnsmasq ? uiState.service.dnsmasq.memory_rss_mb : void 0,
dnsmasqLocalCacheEnabled: uiState.service.dnsmasq ? uiState.service.dnsmasq.local_cache_enabled : void 0,
dnsmasqCacheSize: uiState.service.dnsmasq ? uiState.service.dnsmasq.cache_size : void 0
}
},
diagnosticsSystemInfo: normalizeSingBoxVariantFields(nextSystemInfo)
@@ -7445,7 +7444,11 @@ async function fetchServicesInfo() {
tachyonStatus: tachyon.success ? tachyon.data.status : previousData.tachyonStatus,
watchdogRunning: watchdog.success ? Number(watchdog.data.running) : previousData.watchdogRunning,
zapret2Running: previousData.zapret2Running,
zapret2MemoryMb: previousData.zapret2MemoryMb
zapret2MemoryMb: previousData.zapret2MemoryMb,
dnsmasqRunning: previousData.dnsmasqRunning,
dnsmasqMemoryMb: previousData.dnsmasqMemoryMb,
dnsmasqLocalCacheEnabled: previousData.dnsmasqLocalCacheEnabled,
dnsmasqCacheSize: previousData.dnsmasqCacheSize
}
}
});
@@ -9264,6 +9267,20 @@ async function renderServicesInfoWidget() {
}
});
}
if (data.dnsmasqRunning != null) {
let cacheDetail = "";
if (data.dnsmasqLocalCacheEnabled && data.dnsmasqCacheSize) {
const approxMb = (data.dnsmasqCacheSize * 120 / (1024 * 1024)).toFixed(1);
cacheDetail = ` [${_("Cache")}: ${data.dnsmasqCacheSize} ≈ ${approxMb} MB]`;
}
items.push({
key: "Dnsmasq (DNS)",
value: data.dnsmasqRunning ? data.dnsmasqMemoryMb ? `${_("✔ Running")} (${data.dnsmasqMemoryMb} MB)${cacheDetail}` : `${_("✔ Running")}${cacheDetail}` : _("✘ Stopped"),
attributes: {
class: data.dnsmasqRunning ? "tachyon_dashboard-page__widgets-section__item__row--success" : "tachyon_dashboard-page__widgets-section__item__row--error"
}
});
}
return items;
},
"renderServicesInfoWidget"
@@ -1876,14 +1876,75 @@ function createSettingsContent(section, capabilities) {
"dns",
form.Value,
"dns_cache_size",
_("DNS Cache Size"),
_("Number of cached DNS domain entries in dnsmasq (default: 10000)."),
_("DNS Cache Size (entries)"),
_(
"Maximum number of cached domain entries in dnsmasq (default: 10000 ≈ 1.2 MB RAM). Memory is allocated dynamically per request (~120 B per domain), not pre-allocated upfront. Recommended: 10000 for routers with ≥128 MB RAM, 2500–5000 for 64 MB devices.",
),
);
o.depends("dns_local_cache", "1");
o.default = "10000";
o.datatype = "uinteger";
o.placeholder = "10000";
o.rmempty = true;
const originalDnsCacheRenderWidget = o.renderWidget;
o.renderWidget = function (section_id, option_index, cfgvalue) {
const node = originalDnsCacheRenderWidget.call(
this,
section_id,
option_index,
cfgvalue,
);
const input =
node && typeof node.querySelector === "function"
? node.querySelector("input")
: node;
const calcNode = document.createElement("div");
calcNode.className = "cbi-value-description";
calcNode.style.marginTop = "4px";
calcNode.style.fontSize = "12px";
const updateCalc = () => {
const rawVal =
input && input.value !== undefined && input.value !== ""
? input.value
: (cfgvalue || 10000);
const val = parseInt(rawVal, 10);
if (isNaN(val) || val <= 0) {
calcNode.textContent =
"⚡ " + _("Local DNS caching disabled (0 entries)");
return;
}
const bytes = val * 120;
const sizeText =
bytes < 1024 * 1024
? (bytes / 1024).toFixed(0) + " KB"
: (bytes / (1024 * 1024)).toFixed(1) + " MB";
calcNode.innerHTML =
"📊 " +
_("Estimated RAM usage (max):") +
" <strong>~" +
sizeText +
"</strong> " +
"<span style='opacity: 0.8;'>(" +
_("allocated dynamically on demand, not upfront") +
")</span>";
};
if (input && typeof input.addEventListener === "function") {
input.addEventListener("input", updateCalc);
input.addEventListener("change", updateCalc);
}
updateCalc();
if (node && typeof node.appendChild === "function") {
node.appendChild(calcNode);
}
return node;
};
// ─── AdGuard Home main DNS mode ──────────────────────────────────────────
o = section.taboption(
@@ -2210,13 +2271,16 @@ function createSettingsContent(section, capabilities) {
const entries = value.split(",");
for (const entry of entries) {
if (!/^(tcp|udp)(:[0-9]{1,5}(-[0-9]{1,5})?)?$/.test(entry.trim())) {
const trimmed = entry.trim();
if (!/^((tcp|udp):)?[0-9]{1,5}(-[0-9]{1,5})?$|^(tcp|udp)$/.test(trimmed)) {
return _(
'Each entry must be "proto", "proto:port" or "proto:port-port" (tcp/udp), e.g. tcp:6881,udp:6881-6889',
);
}
const portSpec = entry.split(":")[1];
const portSpec = trimmed.includes(":")
? trimmed.split(":")[1]
: (/^[0-9]/.test(trimmed) ? trimmed : null);
if (!portSpec) {
continue;
}
+32 -14
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-17 13:43+0300\n"
"PO-Revision-Date: 2026-09-17 13:43+0300\n"
"POT-Creation-Date: 2026-09-17 20:48+0300\n"
"PO-Revision-Date: 2026-09-17 20:48+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -273,10 +273,10 @@ msgid "Add URLs or local paths to .lst lists containing domains. IP entries are
msgstr "Добавьте URL или локальные пути к спискам .lst, содержащим домены. IP-записи игнорируются."
msgid "Add URLs or local paths to .srs / .json / .lst / .txt lists. Only domain rules are supported."
msgstr ""
msgstr "Укажите URL или локальные пути к спискам .srs / .json / .lst / .txt. Поддерживаются только правила для доменов."
msgid "Add URLs or local paths to .srs / .json / .lst / .txt lists. Subnets are ignored by default."
msgstr ""
msgstr "Укажите URL или локальные пути к спискам .srs / .json / .lst / .txt. Подсети по умолчанию игнорируются."
msgid "Add URLTest / Priority group"
msgstr "Добавить группу URLTest / Приоритет"
@@ -425,6 +425,9 @@ msgstr "Все сервисы"
msgid "All traffic from these IP addresses will be routed through the section unconditionally, ignoring all other conditions."
msgstr "Весь трафик от этих IP-адресов будет маршрутизирован через секцию безусловно, игнорируя все остальные условия."
msgid "allocated dynamically on demand, not upfront"
msgstr "выделяется динамически по мере запросов, а не сразу"
msgid "Allow / Enable ONLY in scheduled hours (White period)"
msgstr "Разрешать / Включать ТОЛЬКО в указанные часы (Белый список)"
@@ -863,6 +866,9 @@ msgstr "Обходит UDP-трафик для выбранных игровых
msgid "CA Certificate"
msgstr "CA-сертификат"
msgid "Cache"
msgstr ""
msgid "Cache all built-in lists and databases even if they are not currently selected in any section. Recommended if you have plenty of storage space or USB drive."
msgstr "Кэшировать все встроенные списки и базы данных, даже если они не выбраны ни в одной секции. Рекомендуется при большом объёме хранилища или USB-накопителе."
@@ -1395,7 +1401,7 @@ msgid "Direct connection via ISP"
msgstr "Прямое подключение через провайдера"
msgid "Direct Proxy"
msgstr "Прямой прокси"
msgstr "Direct Proxy"
msgid "Direct WAN"
msgstr "Прямой WAN"
@@ -1439,8 +1445,8 @@ msgstr "Автоподбор и бенчмарк DNS"
msgid "DNS Blocking"
msgstr "Блокировка DNS"
msgid "DNS Cache Size"
msgstr ""
msgid "DNS Cache Size (entries)"
msgstr "Размер DNS-кэша (записей)"
msgid "DNS Check Interval"
msgstr "Интервал проверки DNS"
@@ -1797,7 +1803,7 @@ msgid "Enabled (always prioritize)"
msgstr "Включено (всегда повышать приоритет)"
msgid "Enables high-capacity local DNS caching in dnsmasq to accelerate DNS queries and absorb transient latency during service restarts."
msgstr ""
msgstr "Включает емкий локальный кэш DNS в dnsmasq для ускорения запросов и устранения задержек при перезапуске служб."
msgid "Enables the background daemon that polls Telegram for commands and sends notifications."
msgstr "Включает фоновый демон, который опрашивает Telegram на наличие команд и отправляет уведомления."
@@ -1874,6 +1880,9 @@ msgstr "Ошибка выполнения трассировки:"
msgid "Error updating lists:"
msgstr "Ошибка обновления списков:"
msgid "Estimated RAM usage (max):"
msgstr "Ориентировочный объём RAM (макс.):"
msgid "Every day"
msgstr "Каждый день"
@@ -3102,7 +3111,10 @@ msgid "Local Address"
msgstr "Локальный адрес"
msgid "Local DNS Cache"
msgstr ""
msgstr "Локальный кэш DNS"
msgid "Local DNS caching disabled (0 entries)"
msgstr "Локальное DNS-кэширование отключено (0 записей)"
msgid "Local SOCKS proxy desynchronizer (ciadpi)"
msgstr "Локальный десинхронизатор SOCKS-прокси (ciadpi)"
@@ -3176,6 +3188,9 @@ msgstr "Максимально допустимый суммарный траф
msgid "Maximum allowed online time per guest device per day. When reached, access is blocked until midnight. Enter 0 for unlimited."
msgstr "Максимально допустимое время доступа в интернет на одно гостевое устройство в день. При исчерпании доступ блокируется до полуночи. Введите 0 для отсутствия ограничений."
msgid "Maximum number of cached domain entries in dnsmasq (default: 10000 ≈ 1.2 MB RAM). Memory is allocated dynamically per request (~120 B per domain), not pre-allocated upfront. Recommended: 10000 for routers with ≥128 MB RAM, 2500–5000 for 64 MB devices."
msgstr "Максимальный лимит кэшируемых доменов в dnsmasq (по умолчанию: 10000 ≈ 1.2 МБ RAM). Память выделяется динамически по мере запросов (~120 Б на домен), а не резервируется сразу. Рекомендуется: 10000 для роутеров от 128 МБ RAM, 2500–5000 для 64 МБ устройств."
msgid "Maximum size of junk packets"
msgstr "Максимальный размер мусорных пакетов"
@@ -3479,9 +3494,6 @@ msgstr "Уведомлять о перезапусках сервиса"
msgid "Notify on Subscription Updates"
msgstr "Уведомлять об обновлениях подписок"
msgid "Number of cached DNS domain entries in dnsmasq (default: 10000)."
msgstr ""
msgid "Number of consecutive failed checks required before switching DNS servers."
msgstr "Количество последовательных неудачных проверок до переключения DNS-серверов."
@@ -4287,7 +4299,7 @@ msgid "Rule Name"
msgstr "Название правила"
msgid "Rule set must be an HTTP(S) URL or a local .srs / .json / .lst / .txt path"
msgstr ""
msgstr "Набор правил должен быть URL HTTP(S) или локальным путем к .srs / .json / .lst / .txt"
msgid "Rule set settings"
msgstr "Настройки набора правил"
@@ -5990,6 +6002,12 @@ msgstr "Правила маршрутизации sing-box для Zapret2 не
msgid "Zero config mesh VPN daemon"
msgstr "Mesh VPN-демон с нулевой конфигурацией"
msgid "DNS Cache Size"
msgstr "Размер кэша DNS"
msgid "Number of cached DNS domain entries in dnsmasq (default: 10000)."
msgstr "Количество кэшируемых доменных записей в dnsmasq (по умолчанию: 10000)."
msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported."
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только правила доменов."
@@ -5997,7 +6015,7 @@ msgid "Add URLs or local paths to .srs / .json lists. Subnets are ignored by def
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Подсети игнорируются по умолчанию."
msgid "Plain .lst / .txt lists belong in \"Domain and IP lists\", not \"Rule sets\""
msgstr ""
msgstr "Текстовые списки .lst / .txt относятся к «Спискам доменов и IP», а не к «Наборам правил»"
msgid "Rule set must be an HTTP(S) URL or a local .srs / .json path"
msgstr "Набор правил должен быть HTTP(S) URL или локальным путём .srs / .json"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2236,7 +2236,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
exit(main(ARGV));
+1 -1
View File
@@ -258,7 +258,7 @@ function module_exports() {
return constants_map();
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
let mode = ARGV[0] || "";
+1 -1
View File
@@ -213,7 +213,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
// CLI surface exists for the shell test suite, which has no way to import a
+1 -1
View File
@@ -590,7 +590,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
let mode = ARGV[0] || "";
+1 -1
View File
@@ -235,7 +235,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
let mode = ARGV[0] || "";
+80 -33
View File
@@ -4704,17 +4704,52 @@ function local_rule_doctor(pre_res, pre_verify) {
}
}
// ── 1. End-to-end live verification ──
let wan_cause_added = false;
// ── 1. End-to-end live verification & root-cause correlation ──
let wan_failed = false;
let singbox_failed = false;
for (let c in verify.checks) {
if (c.status == "fail") {
if (c.name == "WAN interface" || c.name == "Default gateway")
wan_failed = true;
if (c.name == "sing-box process")
singbox_failed = true;
}
}
let wan_cause = null;
let singbox_cause = null;
if (wan_failed) {
wan_cause = {
probability: 95,
cause: lang == "en" ? "WAN default gateway or Internet uplink is unreachable" : "Шлюз по умолчанию или внешний интернет недоступен",
fix: "fix_wan_interface",
symptoms: []
};
push(causes, wan_cause);
add_fix("fix_wan_interface");
} else if (singbox_failed) {
singbox_cause = {
probability: 95,
cause: lang == "en" ? "sing-box process is stopped or non-functional" : "Процесс sing-box остановлен или не функционирует",
fix: "start_singbox",
symptoms: []
};
push(causes, singbox_cause);
add_fix("start_singbox");
}
for (let c in verify.checks) {
if (c.status != "fail") continue;
if (c.name == "sing-box process") {
push(causes, {
probability: 95,
cause: lang == "en" ? "sing-box process is stopped or non-functional" : "Процесс sing-box остановлен или не функционирует",
fix: "start_singbox"
});
add_fix("start_singbox");
if (!singbox_cause && !wan_cause) {
push(causes, {
probability: 95,
cause: lang == "en" ? "sing-box process is stopped or non-functional" : "Процесс sing-box остановлен или не функционирует",
fix: "start_singbox"
});
add_fix("start_singbox");
}
} else if (c.name == "LAN DNS via dnsmasq") {
push(causes, {
probability: 85,
@@ -4723,31 +4758,37 @@ function local_rule_doctor(pre_res, pre_verify) {
});
add_fix("fix_dnsmasq");
} else if (c.name == "Proxy DNS via sing-box") {
push(causes, {
probability: 85,
cause: lang == "en" ? "Proxy DNS via sing-box failed to respond" : "Прокси-DNS через sing-box (127.0.0.42) не отвечает",
fix: "clear_dns_cache"
});
add_fix("clear_dns_cache");
if (cfg.dns_type != "doh") {
add_fix("switch_to_doh");
if (wan_cause) {
push(wan_cause.symptoms, lang == "en" ? "Proxy DNS unavailable (WAN down)" : "Прокси-DNS недоступен (нет связи с WAN)");
} else if (singbox_cause) {
push(singbox_cause.symptoms, lang == "en" ? "Proxy DNS not answering (sing-box stopped)" : "Прокси-DNS не отвечает (sing-box остановлен)");
} else {
push(causes, {
probability: 85,
cause: lang == "en" ? "Proxy DNS via sing-box failed to respond" : "Прокси-DNS через sing-box (127.0.0.42) не отвечает",
fix: "clear_dns_cache"
});
add_fix("clear_dns_cache");
if (cfg.dns_type != "doh") {
add_fix("switch_to_doh");
}
}
} else if ((c.name == "WAN interface" || c.name == "Default gateway") && !wan_cause_added) {
wan_cause_added = true;
push(causes, {
probability: 90,
cause: lang == "en" ? "WAN default gateway or Internet uplink is unreachable" : "Шлюз по умолчанию или внешний интернет недоступен",
fix: "fix_wan_interface"
});
add_fix("fix_wan_interface");
} else if (c.name == "WAN interface" || c.name == "Default gateway") {
// Already added as primary root cause if wan_failed
} else if (c.name == "HTTP via proxy") {
push(causes, {
probability: 88,
cause: lang == "en" ? "HTTP through the proxy fails end-to-end (node offline or blocked)" : "HTTP через прокси не проходит (узел офлайн или заблокирован)",
fix: "update_subscriptions"
});
add_fix("update_subscriptions");
add_fix("restart_zapret");
if (wan_cause) {
push(wan_cause.symptoms, lang == "en" ? "HTTP through proxy unreachable (WAN down)" : "HTTP через прокси недоступен (нет связи с WAN)");
} else if (singbox_cause) {
push(singbox_cause.symptoms, lang == "en" ? "HTTP through proxy fails (sing-box stopped)" : "HTTP через прокси не проходит (sing-box остановлен)");
} else {
push(causes, {
probability: 88,
cause: lang == "en" ? "HTTP through the proxy fails end-to-end (node offline or blocked)" : "HTTP через прокси не проходит (узел офлайн или заблокирован)",
fix: "update_subscriptions"
});
add_fix("update_subscriptions");
add_fix("restart_zapret");
}
}
}
@@ -4868,7 +4909,10 @@ function local_rule_doctor(pre_res, pre_verify) {
} else {
push(report_lines, "#### Root Cause Analysis:");
for (let c in causes) {
push(report_lines, sprintf("- [%d%% Probability] %s", c.probability, c.cause));
let line = sprintf("- [%d%% Probability] %s", c.probability, c.cause);
if (c.symptoms && length(c.symptoms) > 0)
line += " (Symptoms: " + join("; ", c.symptoms) + ")";
push(report_lines, line);
}
}
} else {
@@ -4885,7 +4929,10 @@ function local_rule_doctor(pre_res, pre_verify) {
} else {
push(report_lines, "#### Анализ возможных причин сбоя:");
for (let c in causes) {
push(report_lines, sprintf("- [%d%% вероятность] %s", c.probability, c.cause));
let line = sprintf("- [%d%% вероятность] %s", c.probability, c.cause);
if (c.symptoms && length(c.symptoms) > 0)
line += " (Симптомы: " + join("; ", c.symptoms) + ")";
push(report_lines, line);
}
}
}
@@ -232,7 +232,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
let mode = ARGV[0] || "";
@@ -22,6 +22,7 @@ function config(ctx) {
mark_mask: env_get("TACHYON_FPTN_MARK_MASK", "0x00ff0000"),
rule_priority: env_get("TACHYON_FPTN_RULE_PRIORITY", "102"),
state_dir: state_dir,
shims_dir: state_dir + "/bin",
pid_file: state_dir + "/fptn.pid",
log_file: state_dir + "/fptn.log",
package_name: "fptn",
+103 -10
View File
@@ -86,6 +86,87 @@ function package_version() {
return "";
}
function install_shims() {
let shims_dir = cfg.shims_dir || (cfg.state_dir + "/bin");
command_status("mkdir -p " + shell_quote(shims_dir) + " 2>/dev/null");
let ip_shim = "#!/bin/sh\n" +
"REAL_IP=''\n" +
"for p in /sbin/ip /usr/sbin/ip /usr/bin/ip /bin/ip; do\n" +
" if [ -x \"$p\" ] && [ \"$p\" != \"$0\" ]; then\n" +
" REAL_IP=\"$p\"\n" +
" break\n" +
" fi\n" +
"done\n" +
"[ -z \"$REAL_IP\" ] && REAL_IP='ip'\n" +
"CMD_LINE=\"$*\"\n" +
"case \"$CMD_LINE\" in\n" +
" *route*default*|*default*route*)\n" +
" case \"$CMD_LINE\" in\n" +
" *replace*|*add*|*del*)\n" +
" exit 0\n" +
" ;;\n" +
" esac\n" +
" ;;\n" +
"esac\n" +
"exec \"$REAL_IP\" \"$@\"\n";
fs.writefile(shims_dir + "/ip", ip_shim);
command_status("chmod +x " + shell_quote(shims_dir + "/ip") + " 2>/dev/null");
let sed_shim = "#!/bin/sh\n" +
"REAL_SED=''\n" +
"for p in /bin/sed /usr/bin/sed /sbin/sed /usr/sbin/sed; do\n" +
" if [ -x \"$p\" ] && [ \"$p\" != \"$0\" ]; then\n" +
" REAL_SED=\"$p\"\n" +
" break\n" +
" fi\n" +
"done\n" +
"[ -z \"$REAL_SED\" ] && REAL_SED='sed'\n" +
"case \"$*\" in\n" +
" *resolv.conf*)\n" +
" exit 0\n" +
" ;;\n" +
"esac\n" +
"exec \"$REAL_SED\" \"$@\"\n";
fs.writefile(shims_dir + "/sed", sed_shim);
command_status("chmod +x " + shell_quote(shims_dir + "/sed") + " 2>/dev/null");
let dummy_names = ["iptables", "ip6tables", "resolvectl", "chattr", "systemctl"];
let dummy_shim = "#!/bin/sh\nexit 0\n";
for (let idx, name in dummy_names) {
fs.writefile(shims_dir + "/" + name, dummy_shim);
command_status("chmod +x " + shell_quote(shims_dir + "/" + name) + " 2>/dev/null");
}
}
function sanitize_system() {
command_status("chattr -i /etc/resolv.conf 2>/dev/null; true");
if (fs.stat("/etc/resolv.conf") == null) {
if (fs.stat("/tmp/resolv.conf") != null)
command_status("ln -sf /tmp/resolv.conf /etc/resolv.conf 2>/dev/null; true");
else if (fs.stat("/tmp/resolv.conf.d/resolv.conf.auto") != null)
command_status("ln -sf /tmp/resolv.conf.d/resolv.conf.auto /etc/resolv.conf 2>/dev/null; true");
}
command_status("sed -i '/nameserver 172\\.20\\./d' /etc/resolv.conf 2>/dev/null; true");
for (let i = 0; i < 4; i++) {
command_status("iptables -D OUTPUT -p udp --dport 53 -m comment --comment fptn -j DROP 2>/dev/null; true");
command_status("iptables -D OUTPUT -p tcp --dport 53 -m comment --comment fptn -j DROP 2>/dev/null; true");
command_status("iptables -D OUTPUT -p udp --dport 853 -m comment --comment fptn -j DROP 2>/dev/null; true");
command_status("iptables -D OUTPUT -p tcp --dport 853 -m comment --comment fptn -j DROP 2>/dev/null; true");
}
command_status("uci del_list dhcp.@dnsmasq[0].server='172.20.0.1' 2>/dev/null; uci commit dhcp 2>/dev/null; true");
let main_default = trim(command_output("ip -4 route show table main default 2>/dev/null | grep dev | grep " + shell_quote(cfg.tun_interface) + " || true"));
if (main_default != "") {
log_message("Sanitizing rogue default route dev " + cfg.tun_interface + " in table main", "warn");
command_status("ip -4 route del default dev " + shell_quote(cfg.tun_interface) + " table main 2>/dev/null; true");
}
}
function remove_kernel_routing() {
let p = cfg.rule_priority || "102";
let mark_spec = cfg.fwmark + "/" + cfg.mark_mask;
@@ -99,6 +180,8 @@ function remove_kernel_routing() {
command_status("ip -4 route del default dev " + shell_quote(cfg.tun_interface) + " table main 2>/dev/null; true");
if (command_status("ip link show " + shell_quote(cfg.tun_interface) + " >/dev/null 2>&1") == 0)
command_status("ip link set dev " + shell_quote(cfg.tun_interface) + " down 2>/dev/null; true");
sanitize_system();
}
function install_kernel_routing() {
@@ -119,12 +202,7 @@ function install_kernel_routing() {
return false;
}
// Ensure table main default route was not hijacked by fptn-client-cli
let main_default_dev = trim(command_output("ip -4 route show table main default 2>/dev/null | awk '{print $5; exit}'"));
if (main_default_dev == cfg.tun_interface) {
log_message("FPTN client hijacked table main default route; restoring WAN route", "warn");
command_status("ip -4 route del default dev " + shell_quote(cfg.tun_interface) + " table main 2>/dev/null; true");
}
sanitize_system();
return true;
}
@@ -161,6 +239,7 @@ function launch_fptn_process(section) {
return false;
command_status("mkdir -p " + shell_quote(cfg.state_dir) + " 2>/dev/null");
install_shims();
let cmd_args = [
cfg.binary,
@@ -187,7 +266,9 @@ function launch_fptn_process(section) {
push(cmd_args, preferred_server);
}
let cmd_str = command_from_args(cmd_args) + " >> " + shell_quote(cfg.log_file) + " 2>&1 & echo $! > " + shell_quote(cfg.pid_file);
let shims_dir = cfg.shims_dir || (cfg.state_dir + "/bin");
let cmd_str = "PATH=" + shell_quote(shims_dir) + ":$PATH " +
command_from_args(cmd_args) + " >> " + shell_quote(cfg.log_file) + " 2>&1 & echo $! > " + shell_quote(cfg.pid_file);
log_message("Starting FPTN client on interface " + cfg.tun_interface, "info");
system(cmd_str);
return true;
@@ -246,8 +327,10 @@ function ensure_routing() {
if (p != null && tun_up) {
let route_installed = command_status("ip route show table " + cfg.route_table + " default dev " + shell_quote(cfg.tun_interface) + " 2>/dev/null | grep -q default") == 0;
let rule_installed = command_status("ip rule show 2>/dev/null | grep -q " + shell_quote(cfg.route_table)) == 0;
if (route_installed && rule_installed)
if (route_installed && rule_installed) {
sanitize_system();
return true;
}
return install_kernel_routing();
}
return start_runtime();
@@ -379,11 +462,13 @@ function module_exports() {
ensure_routing: ensure_routing,
supervise_runtime: supervise_runtime,
status_json: status_json,
check_json: check_json
check_json: check_json,
install_shims: install_shims,
sanitize_system: sanitize_system
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
let mode = as_string(ARGV[0]);
@@ -399,6 +484,14 @@ else if (mode == "ensure-routing")
exit(ensure_routing() ? 0 : 1);
else if (mode == "supervise-runtime")
exit(supervise_runtime() ? 0 : 1);
else if (mode == "install-shims") {
install_shims();
exit(0);
}
else if (mode == "sanitize-system") {
sanitize_system();
exit(0);
}
else if (mode == "status")
status_json();
else if (mode == "check")
@@ -342,7 +342,7 @@ function run(argv, usage_path) {
}
}
if (sourcepath(1) != null && sourcepath(1) != "") {
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null) {
return {
run,
nft_queue_overlap
@@ -364,7 +364,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
let mode = ARGV[0] || "";
@@ -17,7 +17,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
validator.run_expected(KIND, USAGE, ARGV);
@@ -17,7 +17,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
validator.run_expected(KIND, USAGE, ARGV);
@@ -1346,7 +1346,7 @@ function module_exports() {
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
// CLI surface for the shell test suite, which cannot import a ucode module.
+15
View File
@@ -1109,6 +1109,10 @@ function process_memory_rss_mb(process_name) {
return total_kb > 0 ? int(total_kb / 1024) : 0;
}
function truthy(value) {
return value == "1" || value == "true" || value == "on" || value == "yes";
}
function current_ui_state_json() {
maybe_refresh_action_dirs(false);
@@ -1132,6 +1136,11 @@ function current_ui_state_json() {
let sing_box_rss = sing_box_is_running ? process_memory_rss_mb("sing-box") : 0;
let zapret2_rss = file_executable(ZAPRET2_PROVIDER_NFQWS2_BIN) ? process_memory_rss_mb("nfqws2") : 0;
let dnsmasq_pids = get_process_pids("dnsmasq");
let dnsmasq_is_running = length(dnsmasq_pids) > 0 ? 1 : 0;
let dnsmasq_rss = dnsmasq_is_running ? process_memory_rss_mb("dnsmasq") : 0;
let dns_local_cache = truthy(uci_core.get(CONFIG_NAME + ".settings.dns_local_cache"));
let dns_cache_size = dns_local_cache ? int(uci_core.get(CONFIG_NAME + ".settings.dns_cache_size") || 10000) : 0;
write_json({
service: {
@@ -1150,6 +1159,12 @@ function current_ui_state_json() {
zapret2: {
running: zapret2_rss > 0 ? 1 : 0,
memory_rss_mb: zapret2_rss
},
dnsmasq: {
running: dnsmasq_is_running,
memory_rss_mb: dnsmasq_rss,
local_cache_enabled: dns_local_cache ? 1 : 0,
cache_size: dns_cache_size
}
},
capabilities,
+191 -56
View File
@@ -1352,8 +1352,42 @@ function add_domain_array(rule, key, values) {
rule[key] = values;
}
function sanitize_rule(rule) {
if (!rule)
return null;
if (rule.type == "logical" && type(rule.rules) == "array") {
let clean_subrules = [];
for (let sub in rule.rules) {
if (sub && length(keys(sub)) > 0)
push(clean_subrules, sub);
}
if (length(clean_subrules) == 0)
return null;
if (length(clean_subrules) == 1) {
let single = {};
for (let k, v in rule) {
if (k != "type" && k != "mode" && k != "rules")
single[k] = v;
}
for (let k, v in clean_subrules[0])
single[k] = v;
return single;
}
rule.rules = clean_subrules;
}
return rule;
}
function push_dns_matcher_rule(config, rule) {
push(config.dns.rules, rule);
rule = sanitize_rule(rule);
if (rule)
push(config.dns.rules, rule);
}
function push_route_matcher_rule(config, rule) {
rule = sanitize_rule(rule);
if (rule)
push(config.route.rules, rule);
}
function section_dns_server(section) {
@@ -1734,17 +1768,117 @@ function outbound_supports_udp(config, tag_name, visited) {
return false;
}
const ACTION_KEYS = {
action: true,
outbound: true,
server: true,
rewrite_ttl: true,
client_subnet: true,
disable_cache: true,
disable_optimistic_cache: true,
timeout: true,
method: true,
no_drop: true,
strategy: true
};
function apply_section_geoip_filter(rule, geo_tags, country_mode) {
if (!rule || !geo_tags || length(geo_tags) == 0)
return rule;
let geo_rule = {
rule_set: single_or_array(geo_tags)
};
if (country_mode == "exclude")
geo_rule.invert = true;
if (rule.type == "logical" && rule.mode == "and" && type(rule.rules) == "array") {
let cloned = {};
for (let k, v in rule) {
if (k == "rules") {
cloned.rules = [];
for (let r in v) {
if (r && length(keys(r)) > 0)
push(cloned.rules, r);
}
push(cloned.rules, geo_rule);
} else {
cloned[k] = v;
}
}
return cloned;
}
let match_part = {};
for (let k, v in rule) {
if (!ACTION_KEYS[k] && substr(k, 0, 2) != "__")
match_part[k] = v;
}
if (length(keys(match_part)) == 0) {
let single_rule = {};
for (let k, v in rule)
single_rule[k] = v;
single_rule.rule_set = single_or_array(geo_tags);
if (country_mode == "exclude")
single_rule.invert = true;
return single_rule;
}
let logical_rule = {
type: "logical",
mode: "and",
rules: [
match_part,
geo_rule
]
};
for (let k, v in rule) {
if (ACTION_KEYS[k] || substr(k, 0, 2) == "__")
logical_rule[k] = v;
}
return logical_rule;
}
function apply_excluded_source_ips(rule, excluded_cidrs) {
if (!rule || !excluded_cidrs || length(excluded_cidrs) == 0)
return rule;
let action_keys = {
action: true,
outbound: true,
server: true,
rewrite_ttl: true
};
if (rule.type == "logical" && rule.mode == "and" && type(rule.rules) == "array") {
let cloned = {};
for (let k, v in rule) {
if (k == "rules") {
cloned.rules = [];
for (let r in v) {
if (r && length(keys(r)) > 0)
push(cloned.rules, r);
}
push(cloned.rules, {
invert: true,
source_ip_cidr: single_or_array(excluded_cidrs)
});
} else {
cloned[k] = v;
}
}
return cloned;
}
let match_part = {};
for (let k, v in rule) {
if (!ACTION_KEYS[k] && substr(k, 0, 2) != "__")
match_part[k] = v;
}
if (length(keys(match_part)) == 0) {
let single_rule = {};
for (let k, v in rule)
single_rule[k] = v;
single_rule.invert = true;
single_rule.source_ip_cidr = single_or_array(excluded_cidrs);
return single_rule;
}
let logical_rule = {
type: "logical",
mode: "and",
@@ -1757,24 +1891,27 @@ function apply_excluded_source_ips(rule, excluded_cidrs) {
]
};
for (let k, v in rule) {
if (action_keys[k])
if (ACTION_KEYS[k] || substr(k, 0, 2) == "__")
logical_rule[k] = v;
else
match_part[k] = v;
}
return logical_rule;
}
function push_section_route_rule(config, rule, target_outbound, excluded_cidrs) {
function push_section_route_rule(config, rule, target_outbound, excluded_cidrs, geo_tags, country_mode) {
let apply_filters = function(r) {
let filtered = apply_section_geoip_filter(r, geo_tags, country_mode);
return apply_excluded_source_ips(filtered, excluded_cidrs);
};
if (target_outbound && !outbound_supports_udp(config, target_outbound)) {
if (rule.network == "tcp") {
push(config.route.rules, apply_excluded_source_ips(rule, excluded_cidrs));
push_route_matcher_rule(config, apply_filters(rule));
return;
}
if (rule.network == "udp") {
delete rule.outbound;
rule.action = "reject";
push(config.route.rules, apply_excluded_source_ips(rule, excluded_cidrs));
push_route_matcher_rule(config, apply_filters(rule));
return;
}
let udp_rule = {};
@@ -1783,11 +1920,11 @@ function push_section_route_rule(config, rule, target_outbound, excluded_cidrs)
delete udp_rule.outbound;
udp_rule.action = "reject";
udp_rule.network = "udp";
push(config.route.rules, apply_excluded_source_ips(udp_rule, excluded_cidrs));
push_route_matcher_rule(config, apply_filters(udp_rule));
rule.network = "tcp";
}
push(config.route.rules, apply_excluded_source_ips(rule, excluded_cidrs));
push_route_matcher_rule(config, apply_filters(rule));
}
function add_fully_routed_ips_rule(config, section) {
@@ -1961,6 +2098,23 @@ function add_combined_route_for_section(config, section) {
if (target.unsupported)
ctx.runtime_generate_unsupported(target.unsupported);
let country_list = connections.geoip_country_list(section);
let country_mode = connections.geoip_country_mode(section);
let geo_tags = [];
if ((target.outbound || target.action) && length(country_list) > 0) {
for (let cc in country_list) {
let ip_ruleset = ensure_community_ruleset(config, section_name, "geoip_" + cc);
if (ip_ruleset && ip_ruleset.tag)
push(geo_tags, ip_ruleset.tag);
if (cc == "ru") {
let site_ruleset = ensure_community_ruleset(config, section_name, "geosite_ru");
if (site_ruleset && site_ruleset.tag)
push(geo_tags, site_ruleset.tag);
}
}
}
let has_domain = length(domain) > 0 || length(domain_suffix) > 0 ||
length(domain_keyword) > 0 || length(domain_regex) > 0;
let has_ruleset = length(rule_set_tags) > 0;
@@ -1992,9 +2146,9 @@ function add_combined_route_for_section(config, section) {
if (type(resolve) == "object" && resolve.warning)
warn(resolve.warning, "\n");
else if (type(resolve) == "object" && resolve.rule)
push(config.route.rules, apply_excluded_source_ips(resolve.rule, excluded_cidrs));
push_route_matcher_rule(config, apply_excluded_source_ips(resolve.rule, excluded_cidrs));
push_section_route_rule(config, domain_rule, target.outbound, excluded_cidrs);
push_section_route_rule(config, domain_rule, target.outbound, excluded_cidrs, geo_tags, country_mode);
}
if (has_ruleset) {
@@ -2005,15 +2159,15 @@ function add_combined_route_for_section(config, section) {
if (type(resolve) == "object" && resolve.warning)
warn(resolve.warning, "\n");
else if (type(resolve) == "object" && resolve.rule)
push(config.route.rules, apply_excluded_source_ips(resolve.rule, excluded_cidrs));
push_route_matcher_rule(config, apply_excluded_source_ips(resolve.rule, excluded_cidrs));
push_section_route_rule(config, rs_rule, target.outbound, excluded_cidrs);
push_section_route_rule(config, rs_rule, target.outbound, excluded_cidrs, geo_tags, country_mode);
}
if (has_ip_cidr) {
let ip_rule = create_section_route_rule();
ip_rule.ip_cidr = ip_cidr;
push_section_route_rule(config, ip_rule, target.outbound, excluded_cidrs);
push_section_route_rule(config, ip_rule, target.outbound, excluded_cidrs, geo_tags, country_mode);
}
if (length(discord_cf_subnets) > 0) {
@@ -2021,47 +2175,26 @@ function add_combined_route_for_section(config, section) {
voice_rule.network = "udp";
voice_rule.ip_cidr = discord_cf_subnets;
voice_rule.port_range = core_ip.DISCORD_VOICE_PORT_RANGES || [ "5000:5020", "3478:3478", "19294:19344", "50000:65535" ];
push_section_route_rule(config, voice_rule, target.outbound, excluded_cidrs);
}
let country_list = connections.geoip_country_list(section);
let country_mode = connections.geoip_country_mode(section);
if (target.outbound && length(country_list) > 0) {
let geo_tags = [];
for (let cc in country_list) {
let ip_ruleset = ensure_community_ruleset(config, section_name, "geoip_" + cc);
if (ip_ruleset && ip_ruleset.tag)
push(geo_tags, ip_ruleset.tag);
if (cc == "ru") {
let site_ruleset = ensure_community_ruleset(config, section_name, "geosite_ru");
if (site_ruleset && site_ruleset.tag)
push(geo_tags, site_ruleset.tag);
}
}
if (length(geo_tags) > 0) {
let geoip_route_rule = create_section_route_rule();
geoip_route_rule.rule_set = geo_tags;
if (country_mode == "exclude")
geoip_route_rule.invert = true;
push_section_route_rule(config, geoip_route_rule, target.outbound, excluded_cidrs);
}
push_section_route_rule(config, voice_rule, target.outbound, excluded_cidrs, geo_tags, country_mode);
}
if (bool_option(section, "match_all", false)) {
let all_rule = create_section_route_rule();
push_section_route_rule(config, all_rule, target.outbound, excluded_cidrs);
push_section_route_rule(config, all_rule, target.outbound, excluded_cidrs, geo_tags, country_mode);
}
else if (!has_domain && !has_ruleset && !has_ip_cidr && length(discord_cf_subnets) == 0 && length(country_list) == 0) {
let fallback_rule = create_section_route_rule();
let has_any_matcher = fallback_rule.source_ip_cidr != null ||
fallback_rule.port != null || fallback_rule.port_range != null ||
fallback_rule.protocol != null || fallback_rule.dscp != null;
if (has_any_matcher)
push_section_route_rule(config, fallback_rule, target.outbound, excluded_cidrs);
else if (!has_domain && !has_ruleset && !has_ip_cidr && length(discord_cf_subnets) == 0) {
if (length(geo_tags) > 0) {
let geoip_route_rule = create_section_route_rule();
push_section_route_rule(config, geoip_route_rule, target.outbound, excluded_cidrs, geo_tags, country_mode);
}
else {
let fallback_rule = create_section_route_rule();
let has_any_matcher = fallback_rule.source_ip_cidr != null ||
fallback_rule.port != null || fallback_rule.port_range != null ||
fallback_rule.protocol != null || fallback_rule.dscp != null;
if (has_any_matcher)
push_section_route_rule(config, fallback_rule, target.outbound, excluded_cidrs);
}
}
let rewrite_ttl = int_option(ctx.runtime_settings(), "dns_rewrite_ttl", "60");
@@ -2546,6 +2679,8 @@ return {
add_server_routes,
outbound_supports_udp,
push_section_route_rule,
apply_section_geoip_filter,
apply_excluded_source_ips,
is_valid_detour,
load_community_subnet_cidrs
};
+60 -20
View File
@@ -18,45 +18,83 @@ function normalize_port_entry(value) {
}
// P2P leak protection can be pinned down to the torrent client's own ports so
// nothing sneaks past the protocol sniffer. Each entry is "proto" or
// "proto:port" or "proto:port-port" (tcp/udp, comma separated). Without a
// port the whole protocol is routed direct.
// nothing sneaks past the protocol sniffer. Each entry is:
// "port", "port-port", "proto", "proto:port" or "proto:port-port" (tcp/udp, comma separated).
// Without a port the whole protocol is routed direct.
function p2p_direct_rules(settings) {
let rules = [];
for (let entry in split(option(settings, "p2p_ports", ""), ",")) {
entry = trim(entry);
if (entry == "")
continue;
let proto = "tcp";
let proto = "both";
let port_spec = "";
let colon = index(entry, ":");
if (colon >= 0) {
proto = trim(substr(entry, 0, colon));
port_spec = trim(substr(entry, colon + 1));
let p = lc(trim(substr(entry, 0, colon)));
if (p == "tcp" || p == "udp") {
proto = p;
port_spec = trim(substr(entry, colon + 1));
} else {
continue;
}
} else {
proto = entry;
let p = lc(entry);
if (p == "tcp" || p == "udp") {
proto = p;
port_spec = "";
} else {
proto = "both";
port_spec = entry;
}
}
if (proto != "tcp" && proto != "udp")
continue;
let rule = { protocol: [ proto ], action: "route", outbound: runtime_constants.DIRECT_OUTBOUND_TAG };
let networks = proto == "both" ? [ "tcp", "udp" ] : [ proto ];
if (port_spec != "") {
let dash = index(port_spec, "-");
if (dash >= 0) {
let start = normalize_port_entry(substr(port_spec, 0, dash));
let end = normalize_port_entry(substr(port_spec, dash + 1));
if (start != null && end != null && start <= end)
rule.source_port_range = [ sprintf("%d:%d", start, end) ];
else
continue;
if (start != null && end != null && start <= end) {
let range_str = sprintf("%d:%d", start, end);
push(rules, {
network: networks,
source_port_range: [ range_str ],
action: "route",
outbound: runtime_constants.DIRECT_OUTBOUND_TAG
});
push(rules, {
network: networks,
port_range: [ range_str ],
action: "route",
outbound: runtime_constants.DIRECT_OUTBOUND_TAG
});
}
} else {
let port = normalize_port_entry(port_spec);
if (port != null)
rule.source_port = port;
else
continue;
if (port != null) {
push(rules, {
network: networks,
source_port: port,
action: "route",
outbound: runtime_constants.DIRECT_OUTBOUND_TAG
});
push(rules, {
network: networks,
port: [ port ],
action: "route",
outbound: runtime_constants.DIRECT_OUTBOUND_TAG
});
}
}
} else {
push(rules, {
network: networks,
action: "route",
outbound: runtime_constants.DIRECT_OUTBOUND_TAG
});
}
push(rules, rule);
}
return rules;
}
@@ -175,5 +213,7 @@ return {
config,
target,
has_resolve_matchers,
resolve_rule_for_section
resolve_rule_for_section,
p2p_direct_rules,
normalize_port_entry
};
+26 -1
View File
@@ -14,6 +14,7 @@ let command_output_from_args = common.command_output_from_args;
let command_success_from_args = common.command_success_from_args;
let connections = require("config.connections");
let subscription_share_link = require("subscription.share_link");
let crypt4 = require("subscription.crypt4");
function core_url_module_or_null() {
try {
@@ -1535,6 +1536,22 @@ function get_subscription_hwid(custom_hwid) {
}
function download_subscription(url, filepath, http_proxy_address, headers_filepath, effective_user_agent, effective_hwid, device_headers, allow_insecure) {
if (crypt4.is_crypt4(url)) {
let dec = crypt4.decrypt(url, effective_hwid, generate_hwid());
if (dec != null && dec != "") {
let stamp = clock();
let suffix = sprintf(".part.%d.%d", stamp[0], stamp[1]);
let tmpfile = filepath + suffix;
if (fs.writefile(tmpfile, dec)) {
move_file(tmpfile, filepath);
if (headers_filepath != "")
unlink_path(headers_filepath);
return 0;
}
}
return 1;
}
let retries = 3;
let wait_seconds = 2;
let timeout = 15;
@@ -1579,9 +1596,11 @@ function download_subscription(url, filepath, http_proxy_address, headers_filepa
push(args, "-o");
push(args, tmpfile);
let hwid_val = get_subscription_hwid(effective_hwid);
let request_headers = [
"User-Agent: " + get_subscription_user_agent(effective_user_agent),
"X-HWID: " + get_subscription_hwid(effective_hwid)
"X-HWID: " + hwid_val,
"HWID: " + hwid_val
];
for (let header in device_request_headers(device_headers))
push(request_headers, header);
@@ -1593,6 +1612,12 @@ function download_subscription(url, filepath, http_proxy_address, headers_filepa
let status = command_status_from_args(args);
if (status == 0 && file_nonempty(tmpfile)) {
let downloaded_body = fs.readfile(tmpfile);
if (downloaded_body != null && crypt4.is_crypt4(downloaded_body)) {
let dec = crypt4.decrypt(downloaded_body, effective_hwid, generate_hwid());
if (dec != null && dec != "")
fs.writefile(tmpfile, dec);
}
move_file(tmpfile, filepath);
if (headers_filepath != "") {
if (file_nonempty(headers_tmpfile))
@@ -0,0 +1,355 @@
#!/usr/bin/env ucode
let fs = require("fs");
let common = require("core.common");
let as_string = common.as_string;
let shell_quote = common.shell_quote;
function trim(str) {
return replace(as_string(str), /^[\s\r\n]+|[\s\r\n]+$/g, "");
}
function is_crypt4(value) {
value = lc(trim(value));
return match(value, /^(happ:\/\/)?crypt4\//) != null;
}
function strip_prefix(value) {
value = trim(value);
if (match(value, /^(happ:\/\/)?crypt4\//i))
return replace(value, /^(happ:\/\/)?crypt4\//i, "");
return value;
}
function hex_to_bytes(h) {
let res = "";
for (let i = 0; i < length(h); i += 2)
res += chr(hex(substr(h, i, 2)));
return res;
}
function sha256_hex(str) {
let p = fs.popen("printf %s " + shell_quote(str) + " | sha256sum 2>/dev/null", "r");
if (!p)
return "";
let out = trim(p.read("all") || "");
p.close();
return split(out, /[ \t]+/)[0];
}
// Inverted S-Box for AES
let InvSbox = [
0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,
0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,
0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,
0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,
0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,
0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,
0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,
0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,
0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,
0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,
0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,
0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,
0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,
0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,
0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,
0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d
];
// Forward S-box for key expansion
let Sbox = [
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
];
let Rcon = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36];
let Mul9 = [], Mul11 = [], Mul13 = [], Mul14 = [];
function gmul(a, b) {
let p = 0;
for (let c = 0; c < 8; c++) {
if ((b & 1) != 0) p ^= a;
let hi = (a & 0x80);
a = (a << 1) & 0xff;
if (hi != 0) a ^= 0x1b;
b >>= 1;
}
return p;
}
for (let i = 0; i < 256; i++) {
push(Mul9, gmul(i, 9));
push(Mul11, gmul(i, 11));
push(Mul13, gmul(i, 13));
push(Mul14, gmul(i, 14));
}
function key_expansion(key_bytes) {
let Nk = length(key_bytes) / 4;
let Nr = Nk + 6;
let w = [];
for (let i = 0; i < length(key_bytes); i += 4)
push(w, [ord(key_bytes, i), ord(key_bytes, i + 1), ord(key_bytes, i + 2), ord(key_bytes, i + 3)]);
for (let i = Nk; i < 4 * (Nr + 1); i++) {
let temp = [w[i - 1][0], w[i - 1][1], w[i - 1][2], w[i - 1][3]];
if (i % Nk == 0) {
let t0 = temp[0];
temp[0] = Sbox[temp[1]] ^ Rcon[i / Nk];
temp[1] = Sbox[temp[2]];
temp[2] = Sbox[temp[3]];
temp[3] = Sbox[t0];
} else if (Nk > 6 && (i % Nk == 4)) {
temp[0] = Sbox[temp[0]];
temp[1] = Sbox[temp[1]];
temp[2] = Sbox[temp[2]];
temp[3] = Sbox[temp[3]];
}
push(w, [w[i - Nk][0] ^ temp[0], w[i - Nk][1] ^ temp[1], w[i - Nk][2] ^ temp[2], w[i - Nk][3] ^ temp[3]]);
}
return { w, Nr };
}
function inv_cipher_block(block, exp) {
let w = exp.w;
let Nr = exp.Nr;
let s = [];
for (let r = 0; r < 4; r++) {
s[r] = [];
for (let c = 0; c < 4; c++)
s[r][c] = ord(block, r + 4 * c);
}
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++)
s[r][c] ^= w[Nr * 4 + c][r];
for (let round = Nr - 1; round >= 1; round--) {
let t1 = s[1][3]; s[1][3] = s[1][2]; s[1][2] = s[1][1]; s[1][1] = s[1][0]; s[1][0] = t1;
let t20 = s[2][0], t21 = s[2][1]; s[2][0] = s[2][2]; s[2][1] = s[2][3]; s[2][2] = t20; s[2][3] = t21;
let t3 = s[3][0]; s[3][0] = s[3][1]; s[3][1] = s[3][2]; s[3][2] = s[3][3]; s[3][3] = t3;
for (let r = 0; r < 4; r++)
for (let c = 0; c < 4; c++)
s[r][c] = InvSbox[s[r][c]];
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++)
s[r][c] ^= w[round * 4 + c][r];
for (let c = 0; c < 4; c++) {
let s0 = s[0][c], s1 = s[1][c], s2 = s[2][c], s3 = s[3][c];
s[0][c] = Mul14[s0] ^ Mul11[s1] ^ Mul13[s2] ^ Mul9[s3];
s[1][c] = Mul9[s0] ^ Mul14[s1] ^ Mul11[s2] ^ Mul13[s3];
s[2][c] = Mul13[s0] ^ Mul9[s1] ^ Mul14[s2] ^ Mul11[s3];
s[3][c] = Mul11[s0] ^ Mul13[s1] ^ Mul9[s2] ^ Mul14[s3];
}
}
let t1 = s[1][3]; s[1][3] = s[1][2]; s[1][2] = s[1][1]; s[1][1] = s[1][0]; s[1][0] = t1;
let t20 = s[2][0], t21 = s[2][1]; s[2][0] = s[2][2]; s[2][1] = s[2][3]; s[2][2] = t20; s[2][3] = t21;
let t3 = s[3][0]; s[3][0] = s[3][1]; s[3][1] = s[3][2]; s[3][2] = s[3][3]; s[3][3] = t3;
for (let r = 0; r < 4; r++)
for (let c = 0; c < 4; c++)
s[r][c] = InvSbox[s[r][c]];
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++)
s[r][c] ^= w[c][r];
let out = "";
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++)
out += chr(s[r][c]);
return out;
}
function aes_cbc_decrypt(ciphertext, key_bytes, iv) {
if (length(ciphertext) % 16 != 0 || length(key_bytes) < 16 || length(iv) != 16)
return null;
let exp = key_expansion(key_bytes);
let plaintext = "";
let prev = iv;
for (let i = 0; i < length(ciphertext); i += 16) {
let block = substr(ciphertext, i, 16);
let dec = inv_cipher_block(block, exp);
let xored = "";
for (let j = 0; j < 16; j++)
xored += chr(ord(dec, j) ^ ord(prev, j));
plaintext += xored;
prev = block;
}
// PKCS#7 unpadding validation
let pad = ord(plaintext, length(plaintext) - 1);
if (pad < 1 || pad > 16 || length(plaintext) < pad)
return null;
for (let i = length(plaintext) - pad; i < length(plaintext); i++) {
if (ord(plaintext, i) != pad)
return null;
}
return substr(plaintext, 0, length(plaintext) - pad);
}
function is_valid_plaintext(str) {
if (str == null || length(str) == 0)
return false;
let len = length(str);
for (let i = 0; i < len; i++) {
let ch = ord(substr(str, i, 1));
if (ch < 32 && ch != 9 && ch != 10 && ch != 13)
return false;
}
return true;
}
function try_openssl_decrypt(cipher, key_hex, iv_hex, cipher_type) {
let tmp_in = "/tmp/tachyon-c4-in." + clock()[0] + "." + clock()[1];
if (fs.writefile(tmp_in, cipher) == null)
return null;
let cmd = "openssl enc -d -" + cipher_type + " -in " + shell_quote(tmp_in) + " -K " + shell_quote(key_hex) + " -iv " + shell_quote(iv_hex) + " 2>/dev/null";
let p = fs.popen(cmd, "r");
let out = p ? p.read("all") : null;
let code = p ? p.close() : -1;
fs.unlink(tmp_in);
if (code != 0 || out == null || length(out) == 0 || !is_valid_plaintext(out))
return null;
return out;
}
function get_system_device_hwid() {
let mac = trim(as_string(fs.readfile("/sys/class/net/eth0/address")));
if (mac == "")
mac = trim(as_string(fs.readfile("/sys/class/net/br-lan/address")));
if (mac == "") {
let mid = trim(as_string(fs.readfile("/etc/machine-id")));
if (mid != "") return mid;
}
if (mac == "") return "";
let p = fs.popen("printf %s " + shell_quote(mac) + " | md5sum 2>/dev/null", "r");
if (!p) return "";
let out = trim(p.read("all") || "");
p.close();
let hash = split(out, /[ \t]+/)[0];
if (length(hash) >= 16)
return substr(hash, 0, 4) + "-" + substr(hash, 4, 4) + "-" + substr(hash, 8, 4) + "-" + substr(hash, 12, 4);
return "";
}
function decrypt_crypt4(payload, ...secret_keys) {
payload = trim(as_string(payload));
if (match(payload, /^(happ:\/\/)?crypt4\//i))
payload = replace(payload, /^(happ:\/\/)?crypt4\//i, "");
payload = replace(payload, /[\r\n\t ]/g, "");
payload = replace(payload, /-/g, "+");
payload = replace(payload, /_/g, "/");
let rem = length(payload) % 4;
if (rem == 1)
return null;
if (rem > 1) {
for (let i = 0; i < 4 - rem; i++)
payload += "=";
}
let binary = b64dec(payload);
if (binary == null || length(binary) < 32)
return null;
let iv = substr(binary, 0, 16);
let cipher = substr(binary, 16);
if (length(cipher) % 16 != 0)
return null;
let keys = [];
let seen_keys = {};
function add_key(k) {
if (type(k) == "array") {
for (let item in k)
add_key(item);
} else {
k = trim(as_string(k));
if (k != "" && !seen_keys[k]) {
seen_keys[k] = true;
push(keys, k);
}
}
}
for (let k in secret_keys)
add_key(k);
let dev_hwid = get_system_device_hwid();
if (dev_hwid != "")
add_key(dev_hwid);
add_key("HappDefaultSalt");
let iv_hex = common.bytes_to_hex(iv);
for (let k in keys) {
let h = sha256_hex(k);
if (length(h) != 64)
continue;
// 1. Try OpenSSL fast path if available
let open_res = try_openssl_decrypt(cipher, h, iv_hex, "aes-256-cbc");
if (open_res != null)
return open_res;
open_res = try_openssl_decrypt(cipher, substr(h, 0, 32), iv_hex, "aes-128-cbc");
if (open_res != null)
return open_res;
// 2. Pure ucode fallback
let full_key = hex_to_bytes(h);
let res = aes_cbc_decrypt(cipher, full_key, iv);
if (res != null && is_valid_plaintext(res))
return res;
res = aes_cbc_decrypt(cipher, substr(full_key, 0, 16), iv);
if (res != null && is_valid_plaintext(res))
return res;
}
return null;
}
function module_exports() {
return {
is_crypt4,
strip_prefix,
decrypt: decrypt_crypt4
};
}
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
let mode = ARGV[0] || "";
if (mode == "is-crypt4")
exit(is_crypt4(ARGV[1]) ? 0 : 1);
else if (mode == "decrypt") {
let dec = decrypt_crypt4(ARGV[1], ARGV[2], ARGV[3]);
if (dec == null)
exit(1);
print(dec);
}
+140 -7
View File
@@ -2,6 +2,7 @@
let fs = require("fs");
let common = require("core.common");
let crypt4 = require("subscription.crypt4");
let as_string = common.as_string;
let object_or_empty = common.object_or_empty;
let array_or_empty = common.array_or_empty;
@@ -31,7 +32,8 @@ function is_supported_share_link(line) {
starts_with(line, "socks4a://") ||
starts_with(line, "socks5://") ||
starts_with(line, "http://") ||
starts_with(line, "https://");
starts_with(line, "https://") ||
crypt4.is_crypt4(line);
}
function split_csv(value) {
@@ -346,19 +348,78 @@ function reality_public_key_valid(value) {
return match(as_string(value), /^[A-Za-z0-9_-]{43}$/) != null;
}
function outbound_is_unsupported_stub(outbound) {
if (type(outbound) != "object")
return false;
let tag = lc(as_string(outbound.tag || outbound.name || outbound.remark || ""));
let server = lc(trim(as_string(outbound.server || "")));
if (index(tag, "не поддерживается") >= 0 ||
index(tag, "неподдерживаем") >= 0 ||
index(tag, "клиент не поддерживается") >= 0 ||
index(tag, "приложение не поддерживается") >= 0 ||
index(tag, "unsupported") >= 0 ||
index(tag, "not supported") >= 0 ||
index(tag, "update app") >= 0 ||
index(tag, "update client") >= 0 ||
index(tag, "обновите приложение") >= 0 ||
index(tag, "обновите клиент") >= 0 ||
index(tag, "скачайте happ") >= 0 ||
index(tag, "скачать happ") >= 0 ||
index(tag, "используйте happ") >= 0 ||
index(tag, "use happ") >= 0 ||
index(tag, "download happ") >= 0 ||
index(tag, "скачайте v2box") >= 0 ||
index(tag, "используйте v2box") >= 0 ||
index(tag, "скачайте v2rayn") >= 0 ||
index(tag, "используйте v2rayn") >= 0)
return true;
let is_dummy_server = (
server == "" ||
server == "127.0.0.1" ||
server == "0.0.0.0" ||
server == "localhost" ||
server == "::1" ||
server == "0.0.0.1"
);
if (is_dummy_server && (
index(tag, "⚠️") >= 0 ||
index(tag, "❗") >= 0 ||
index(tag, "❌") >= 0 ||
index(tag, "warning") >= 0 ||
index(tag, "error") >= 0 ||
index(tag, "notice") >= 0 ||
index(tag, "attention") >= 0 ||
index(tag, "внимание") >= 0 ||
index(tag, "ошибка") >= 0
))
return true;
return false;
}
function validate_subscription(path) {
let value = read_json_file(path);
if (type(value) != "object" || type(value.outbounds) != "array" || length(value.outbounds) == 0)
return false;
let usable_count = 0;
for (let outbound in value.outbounds) {
if (outbound_is_unsupported_stub(outbound))
continue;
let tls = type(outbound) == "object" && type(outbound.tls) == "object" ? outbound.tls : {};
let reality = tls.reality;
if (type(reality) == "object" && reality.enabled !== false && !reality_public_key_valid(reality.public_key))
return false;
usable_count++;
}
return true;
return usable_count > 0;
}
function write_json_file(path, value) {
@@ -1685,8 +1746,13 @@ function normalize_clash_yaml(input_file, output_file) {
for (let record in clash_yaml_records(input_file)) {
let outbound = parse_clash_record(record);
if (outbound)
if (outbound) {
if (outbound_is_unsupported_stub(outbound)) {
skipped++;
continue;
}
push(outbounds, outbound);
}
else
skipped++;
}
@@ -1722,8 +1788,36 @@ function normalize_uri_list_data(data, output_file) {
line = replace(line, /\r/g, "");
line = trim(line);
if (line != "" && !starts_with(line, "#")) {
if (crypt4.is_crypt4(line)) {
let decrypted = crypt4.decrypt(line);
if (decrypted != null && decrypted != "") {
for (let dec_line in split(decrypted, "\n")) {
dec_line = trim(dec_line);
if (dec_line != "" && !starts_with(dec_line, "#")) {
let outbound = parse_share_link(dec_line);
if (outbound) {
if (outbound_is_unsupported_stub(outbound)) {
skipped++;
continue;
}
if (added > 0)
output.write(",");
output.write(sprintf("%J", outbound));
added++;
} else {
skipped++;
}
}
}
continue;
}
}
let outbound = parse_share_link(line);
if (outbound) {
if (outbound_is_unsupported_stub(outbound)) {
skipped++;
continue;
}
if (added > 0)
output.write(",");
output.write(sprintf("%J", outbound));
@@ -2134,8 +2228,36 @@ function normalize_uri_list_stream(input, output_file, strip_metadata) {
if (strip_metadata && line_no <= 20 && is_metadata_preamble_line(line))
continue;
if (line != "" && !starts_with(line, "#")) {
if (crypt4.is_crypt4(line)) {
let decrypted = crypt4.decrypt(line);
if (decrypted != null && decrypted != "") {
for (let dec_line in split(decrypted, "\n")) {
dec_line = trim(dec_line);
if (dec_line != "" && !starts_with(dec_line, "#")) {
let outbound = parse_share_link(dec_line);
if (outbound) {
if (outbound_is_unsupported_stub(outbound)) {
skipped++;
continue;
}
if (added > 0)
output.write(",");
output.write(sprintf("%J", outbound));
added++;
} else {
skipped++;
}
}
}
continue;
}
}
let outbound = parse_share_link(line);
if (outbound) {
if (outbound_is_unsupported_stub(outbound)) {
skipped++;
continue;
}
if (added > 0)
output.write(",");
output.write(sprintf("%J", outbound));
@@ -2171,6 +2293,10 @@ function file_looks_like_uri_list(input_file) {
line = trim(line);
if (line == "" || starts_with(line, "#") || is_metadata_preamble_line(line))
continue;
if (crypt4.is_crypt4(line)) {
input.close();
return false;
}
if (is_supported_share_link(line)) {
input.close();
return true;
@@ -2906,7 +3032,7 @@ function normalize_sing_box_json_outbounds(candidates) {
let detour_refs = {};
for (let outbound in candidates) {
if (type(outbound) != "object")
if (type(outbound) != "object" || outbound_is_unsupported_stub(outbound))
continue;
outbound = normalize_sing_box_hysteria2_outbound(outbound);
outbound = normalize_sing_box_tuic_outbound(outbound);
@@ -3065,6 +3191,12 @@ function normalize_content_data(data, output_file, depth) {
data = replace(data, /\r/g, "");
data = strip_metadata_preamble_data(data);
if (depth < 2 && crypt4.is_crypt4(data)) {
let decrypted = crypt4.decrypt(data);
if (decrypted != null && decrypted != "")
return normalize_content_data(decrypted, output_file, depth + 1);
}
let first = first_non_ws_char(data);
if (first == "{" || first == "[") {
let decoded_json = json_decode_text(data);
@@ -3159,7 +3291,7 @@ function parse_subscription_source_entry(entry) {
if (index(entry, "|") >= 0)
return subscription_source_entry_result(false, "", "", "Configure User-Agent in the subscription item settings");
if (!(substr(entry, 0, 7) == "http://" || substr(entry, 0, 8) == "https://"))
if (!(substr(entry, 0, 7) == "http://" || substr(entry, 0, 8) == "https://" || crypt4.is_crypt4(entry)))
return subscription_source_entry_result(false, "", "", "Subscription URL must start with http:// or https://");
return subscription_source_entry_result(true, entry, "", "");
@@ -3177,11 +3309,12 @@ function module_exports() {
normalized_skipped_message,
try_decode_gzip_content_file,
extract_ui_metadata_file,
runtime_outbounds_equal
runtime_outbounds_equal,
outbound_is_unsupported_stub
};
}
if (sourcepath(1) != null && sourcepath(1) != "")
if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null)
return module_exports();
let mode = ARGV[0];
+3
View File
@@ -64,4 +64,7 @@ grep -Fq "export const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi';" "$FRONTEND_CO
grep -Fq "export const IP_CHECK_DOMAIN = 'ip.podkop.fyi';" "$FRONTEND_CONSTANTS" ||
fail "LuCI diagnostics must use the deployed public IP endpoint"
ucode -L "$TACHYON_LIB" -e 'let c = require("core.constants"); exit(c.TACHYON_CONFIG_NAME == "tachyon" ? 0 : 1);' || \
fail "requiring core.constants from inline eval failed"
printf 'constants ownership checks passed\n'
+1 -1
View File
@@ -96,7 +96,7 @@ if ! "$DOCKER_BIN" image inspect "$TEST_IMAGE:latest" >/dev/null 2>&1; then
"$DOCKER_BIN" exec "$TEMP_CONTAINER" opkg update && break || sleep 5
done
for i in 1 2 3 4 5; do
"$DOCKER_BIN" exec "$TEMP_CONTAINER" opkg install ucode ucode-mod-fs ucode-mod-uci curl ca-bundle bind-dig nftables ip-full coreutils-base64 sing-box bash node git git-http && break || sleep 5
"$DOCKER_BIN" exec "$TEMP_CONTAINER" opkg install ucode ucode-mod-fs ucode-mod-uci curl ca-bundle bind-dig nftables ip-full coreutils-base64 sing-box bash node git git-http openssl-util && break || sleep 5
done
"$DOCKER_BIN" exec "$TEMP_CONTAINER" git config --global --add safe.directory '*' || true
+46 -164
View File
@@ -3,173 +3,55 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALLER="$ROOT_DIR/install.sh"
WORK_DIR="$(mktemp -d)"
MAKEFILE="$ROOT_DIR/tachyon/Makefile"
MIGRATION="$ROOT_DIR/tachyon/files/usr/lib/config/migration.uc"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
[ -r "$INSTALLER" ] || fail "install.sh is missing"
[ -r "$MAKEFILE" ] || fail "tachyon/Makefile is missing"
[ -r "$MIGRATION" ] || fail "runtime migration module is missing"
# 1. Extract embedded ucode helper and verify its syntax
helper="$WORK_DIR/install-json.uc"
# Installer v3 deliberately does not duplicate legacy conversion logic. Package
# postinst owns the move into /etc/config/tachyon and the runtime migration module
# owns schema conversion.
if grep -Fq 'installer_cleanup_legacy' "$INSTALLER"; then
fail "installer must not embed legacy cleanup/migration ownership"
fi
if grep -Fq 'migrate-podkop' "$INSTALLER"; then
fail "installer must delegate legacy schema migration to package/runtime code"
fi
grep -Fq 'if [ -f /etc/config/netshift ]; then' "$MAKEFILE" ||
fail "package postinst must detect NetShift config"
grep -Fq 'elif [ -f /etc/config/forkop ]; then' "$MAKEFILE" ||
fail "package postinst must detect Forkop config"
grep -Fq 'elif [ -f /etc/config/podkop ]; then' "$MAKEFILE" ||
fail "package postinst must detect Podkop config"
grep -Fq '/usr/lib/tachyon/config/migration.uc migrate-podkop' "$MAKEFILE" ||
fail "legacy package postinst must call migrate-podkop"
grep -Fq 'migrate-podkop' "$MIGRATION" ||
fail "runtime migration module must expose migrate-podkop"
# Before package hooks can move/remove legacy files, the installer takes a safety
# snapshot of every supported legacy config.
for path in \
/etc/config/netshift \
/etc/config/forkop \
/etc/config/forkop_plus \
/etc/config/podkop \
/etc/config/podkop_plus; do
grep -Fq "$path" "$INSTALLER" ||
fail "installer recovery snapshot must include $path"
done
grep -Fq 'snapshot_state || fail "Could not create recovery snapshot"' "$INSTALLER" ||
fail "recovery snapshot must be created before package installation"
awk '
/cat > "\$helper_path" <<'\''EOF'\''/ { capture = 1; next }
capture && /^EOF$/ { exit }
capture { print }
' "$INSTALLER" > "$helper"
[ -s "$helper" ] || fail "failed to extract embedded installer ucode helper"
/^main\(\)/ { in_main=1 }
in_main && /snapshot_state/ && !snapshot { snapshot=NR }
in_main && /install_core_transaction/ && !install { install=NR }
END { exit !(snapshot > 0 && install > snapshot) }
' "$INSTALLER" || fail "legacy configs must be snapshotted before package hooks run"
if command -v ucode >/dev/null 2>&1; then
ucode -c -o /dev/null "$helper" || fail "embedded ucode helper failed syntax check"
fi
# 2. Test installer_cleanup_legacy with mock Forkop environment
MOCK_ROOT="$WORK_DIR/rootfs"
mkdir -p "$MOCK_ROOT/etc/config"
mkdir -p "$MOCK_ROOT/etc/init.d"
mkdir -p "$MOCK_ROOT/usr/bin"
mkdir -p "$MOCK_ROOT/usr/lib/forkop"
mkdir -p "$MOCK_ROOT/tmp"
mkdir -p "$MOCK_ROOT/var/run/forkop"
cat >"$MOCK_ROOT/etc/config/forkop" <<'EOF'
config settings 'settings'
option enabled '1'
EOF
cat >"$MOCK_ROOT/etc/init.d/forkop" <<'EOF'
#!/usr/bin/env sh
case "$1" in
enabled) exit 0 ;;
running) exit 0 ;;
stop|disable) exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod 0755 "$MOCK_ROOT/etc/init.d/forkop"
cat >"$MOCK_ROOT/usr/bin/forkop" <<'EOF'
#!/usr/bin/env sh
case "$1" in
get_status) printf '{"running":1}\n' ;;
restore_dnsmasq) exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod 0755 "$MOCK_ROOT/usr/bin/forkop"
# Mock opkg
MOCK_BIN="$WORK_DIR/bin"
mkdir -p "$MOCK_BIN"
cat >"$MOCK_BIN/opkg" <<'EOF'
#!/usr/bin/env sh
case "$1" in
list-installed)
printf '%s\n' \
'forkop - 1.0.4' \
'luci-app-forkop - 1.0.4' \
'luci-i18n-forkop-ru - 1.0.4'
;;
remove)
shift
[ "$1" = "--force-depends" ] && shift
printf 'removed: %s\n' "$1" >> "$TMP_DIR/opkg-removed.log"
;;
esac
exit 0
EOF
chmod 0755 "$MOCK_BIN/opkg"
# Run installer-cleanup-legacy in mock environment
if command -v ucode >/dev/null 2>&1; then
export PATH="$MOCK_BIN:$PATH"
export TMP_DIR="$WORK_DIR"
state_output="$(
TACHYON_LEGACY_DETECTED=1 \
TACHYON_INSTALLER_OPKG_BIN="$MOCK_BIN/opkg" \
TACHYON_INSTALLER_TACHYON_INIT="$WORK_DIR/no-init" \
TACHYON_INSTALLER_TACHYON_BIN="$WORK_DIR/no-bin" \
TACHYON_INSTALLER_TACHYON_LIB="$WORK_DIR/no-lib" \
TACHYON_INSTALLER_TACHYON_UCI_DEFAULTS="$WORK_DIR/no-defaults" \
TACHYON_INSTALLER_TACHYON_LUCI_VIEW="$WORK_DIR/no-view" \
TACHYON_INSTALLER_MENU_JSON="$WORK_DIR/no-menu" \
TACHYON_INSTALLER_ACL_JSON="$WORK_DIR/no-acl" \
TACHYON_INSTALLER_RU_LMO="$WORK_DIR/no-ru-lmo" \
TACHYON_INSTALLER_EN_LMO="$WORK_DIR/no-en-lmo" \
TACHYON_INSTALLER_RU_LUA="$WORK_DIR/no-ru-lua" \
TACHYON_INSTALLER_EN_LUA="$WORK_DIR/no-en-lua" \
TACHYON_INSTALLER_LEGACY_INIT="$WORK_DIR/no-leg-init" \
TACHYON_INSTALLER_LEGACY_BIN="$WORK_DIR/no-leg-bin" \
TACHYON_INSTALLER_LEGACY_LIB="$WORK_DIR/no-leg-lib" \
TACHYON_INSTALLER_LEGACY_UCI_DEFAULTS="$WORK_DIR/no-leg-defaults" \
TACHYON_INSTALLER_LEGACY_LUCI_VIEW="$WORK_DIR/no-leg-view" \
TACHYON_INSTALLER_LEGACY_MENU_JSON="$WORK_DIR/no-leg-menu" \
TACHYON_INSTALLER_LEGACY_ACL_JSON="$WORK_DIR/no-leg-acl" \
TACHYON_INSTALLER_LEGACY_BASE_INIT="$WORK_DIR/no-base-init" \
TACHYON_INSTALLER_LEGACY_BASE_BIN="$WORK_DIR/no-base-bin" \
TACHYON_INSTALLER_LEGACY_BASE_LIB="$WORK_DIR/no-base-lib" \
TACHYON_INSTALLER_LEGACY_BASE_UCI_DEFAULTS="$WORK_DIR/no-base-defaults" \
TACHYON_INSTALLER_LEGACY_BASE_LUCI_VIEW="$WORK_DIR/no-base-view" \
TACHYON_INSTALLER_LEGACY_BASE_MENU_JSON="$WORK_DIR/no-base-menu" \
TACHYON_INSTALLER_LEGACY_BASE_ACL_JSON="$WORK_DIR/no-base-acl" \
TACHYON_INSTALLER_LEGACY_BASE_I18N="$WORK_DIR/no-base-i18n" \
TACHYON_INSTALLER_LEGACY_BRAND="podkop" \
TACHYON_INSTALLER_LEGACY_BACKEND="podkop-plus" \
ucode "$helper" installer-cleanup-legacy
)"
printf '%s\n' "$state_output" | grep -Fxq 'TACHYON_LEGACY_DETECTED=1' ||
fail "installer_cleanup_legacy must report TACHYON_LEGACY_DETECTED=1 when Forkop is installed"
# 3. Test post-install when TACHYON_LEGACY_DETECTED=1
# reset_settings must NOT be executed
cat >"$WORK_DIR/mock-tachyon-bin" <<'EOF'
#!/usr/bin/env sh
if [ "$1" = "reset_settings" ]; then
echo "CALLED_RESET_SETTINGS" >> "$TMP_DIR/reset_settings.called"
fi
exit 0
EOF
chmod 0755 "$WORK_DIR/mock-tachyon-bin"
TACHYON_WAS_INSTALLED=0 \
TACHYON_LEGACY_DETECTED=1 \
TACHYON_WAS_ENABLED=1 \
TACHYON_WAS_RUNNING=1 \
TACHYON_INSTALLER_TACHYON_BIN="$WORK_DIR/mock-tachyon-bin" \
TACHYON_INSTALLER_TACHYON_INIT="$WORK_DIR/mock-tachyon-bin" \
ucode "$helper" installer-post-install
if [ -f "$WORK_DIR/reset_settings.called" ]; then
fail "installer_post_install must not invoke reset_settings when legacy migration is active"
fi
fi
# 4. Static checks on install.sh to ensure Forkop / Netshift migration contracts
grep -Fq 'TACHYON_FORKOP_MIGRATION=1' "$INSTALLER" ||
fail "install.sh must set TACHYON_FORKOP_MIGRATION flag"
grep -Fq 'TACHYON_NETSHIFT_MIGRATION=1' "$INSTALLER" ||
fail "install.sh must set TACHYON_NETSHIFT_MIGRATION flag"
grep -Fq 'legacy_detected_before="$TACHYON_LEGACY_DETECTED"' "$INSTALLER" ||
fail "cleanup_legacy_installation must guard TACHYON_LEGACY_DETECTED against being overwritten to 0"
grep -Fq 'installer_package_installed("forkop")' "$INSTALLER" ||
fail "installer_cleanup_legacy must detect forkop packages"
grep -Fq 'installer_package_installed("netshift")' "$INSTALLER" ||
fail "installer_cleanup_legacy must detect netshift packages"
grep -Fq '"/etc/config/forkop"' "$INSTALLER" ||
fail "installer must reference /etc/config/forkop for migration and cleanup"
printf 'Forkop migration installer tests passed successfully.\n'
printf 'PASS: legacy migration ownership and installer recovery contract\n'
+31
View File
@@ -154,5 +154,36 @@ TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$ROOT_DIR/tachyon/files/usr/
TG_MASK_OUT="$(ucode -L "$TACHYON_LIB" "$ROOT_DIR/tachyon/files/usr/lib/service/telegram.uc" mask-token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9Q7Fx")"
assert_eq "••••••••Q7Fx" "$TG_MASK_OUT" "telegram token masking"
# 9. Test shims creation and isolation behavior
export TACHYON_FPTN_STATE_DIR="$WORK_DIR/fptn_state"
mkdir -p "$TACHYON_FPTN_STATE_DIR"
TACHYON_LIB_DIR="$TACHYON_LIB" ucode_run "$FPTN_RUNTIME_UC" install-shims
SHIMS_DIR="$TACHYON_FPTN_STATE_DIR/bin"
[ -x "$SHIMS_DIR/ip" ] || fail "ip shim not created or not executable"
[ -x "$SHIMS_DIR/sed" ] || fail "sed shim not created or not executable"
[ -x "$SHIMS_DIR/iptables" ] || fail "iptables shim not created or not executable"
[ -x "$SHIMS_DIR/chattr" ] || fail "chattr shim not created or not executable"
# Test ip shim blocks default route replace/del/add
IP_REPLACE_STATUS=0
"$SHIMS_DIR/ip" route replace default dev tun-fptn scope link || IP_REPLACE_STATUS=$?
assert_eq "0" "$IP_REPLACE_STATUS" "ip shim should return 0 for route replace default"
IP_DEL_STATUS=0
"$SHIMS_DIR/ip" route del default dev tun-fptn scope link || IP_DEL_STATUS=$?
assert_eq "0" "$IP_DEL_STATUS" "ip shim should return 0 for route del default"
# Test sed shim blocks modifying /etc/resolv.conf
SED_STATUS=0
"$SHIMS_DIR/sed" -i '1i nameserver 172.20.0.1' /etc/resolv.conf || SED_STATUS=$?
assert_eq "0" "$SED_STATUS" "sed shim should return 0 for resolv.conf"
# Test dummy shims exit 0
IPT_STATUS=0
"$SHIMS_DIR/iptables" -A OUTPUT -p udp --dport 53 -j DROP || IPT_STATUS=$?
assert_eq "0" "$IPT_STATUS" "iptables shim should return 0"
echo "fptn component tests passed"
+54 -122
View File
@@ -1,138 +1,70 @@
#!/usr/bin/env bash
# A rebuilt release keeps its version string, so "the package is installed and the
# version matches" stays true even when the package manager rolled the upgrade
# back — which is how a failed install used to end with a green success banner.
# The only thing that moves is the commit SHA baked into the installed constants
# module, so these checks pin how the installer reads it.
set -eo pipefail
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALLER="$ROOT_DIR/install.sh"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_eq() {
local expected="$1" actual="$2" label="$3"
[ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'"
}
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
[ -r "$INSTALLER" ] || fail "install.sh must exist"
# The function is lifted out of the installer rather than sourced: install.sh runs
# main() at the bottom, so sourcing it would attempt a real installation. Only the
# hardcoded path is redirected, so the extraction logic under test is the shipped
# one, character for character.
sed -n '/^installed_backend_sha() {/,/^}/p' "$INSTALLER" >"$WORK_DIR/sha.sh"
[ -s "$WORK_DIR/sha.sh" ] ||
fail "install.sh must define installed_backend_sha for rollback detection"
grep -Fq '/usr/lib/tachyon/core/constants.uc' "$WORK_DIR/sha.sh" ||
fail "installed_backend_sha must read the installed constants module"
sed 's#/usr/lib/tachyon/core/constants.uc#'"$WORK_DIR"'/constants.uc#' \
"$WORK_DIR/sha.sh" >"$WORK_DIR/sha-under-test.sh"
# Package manager errors are fatal. The old installer tried to infer success after
# a non-zero apk result and could accidentally accept a rolled-back upgrade.
grep -Fq 'install_core_transaction || fail "Tachyon package transaction failed"' "$INSTALLER" ||
fail "package transaction failures must abort the install"
if grep -Fq 'Package manager reported non-critical errors during installation' "$INSTALLER"; then
fail "installer must not convert package-manager failures into success"
fi
read_sha() {
local constants="$1"
rm -f "$WORK_DIR/constants.uc"
[ "$constants" = "" ] || cp "$constants" "$WORK_DIR/constants.uc"
(
# shellcheck disable=SC1090
. "$WORK_DIR/sha-under-test.sh"
installed_backend_sha
)
}
# Backend, LuCI and optional i18n are installed in one package-manager invocation.
grep -Fq 'pkg_install_local_bundle "$@"' "$INSTALLER" ||
fail "release packages must be submitted as one transaction"
grep -Fq 'apk_run apk-transaction "$PACKAGE_TIMEOUT_SECONDS" add --allow-untrusted "$@"' "$INSTALLER" ||
fail "APK install must be one bounded transaction"
grep -Fq 'run_logged_timeout opkg-transaction "$PACKAGE_TIMEOUT_SECONDS" opkg install' "$INSTALLER" ||
fail "opkg install must be one bounded transaction"
printf ' c.TACHYON_COMMIT_SHA = env("TACHYON_COMMIT_SHA", "ba69e3d2");\n' \
>"$WORK_DIR/stamped.uc"
assert_eq "ba69e3d2" "$(read_sha "$WORK_DIR/stamped.uc")" "a stamped build must report its SHA"
# The historical 120s package timeout caused valid postinst hooks to be killed and
# APK to roll the upgrade back. Keep a larger package transaction budget.
grep -Fq 'PACKAGE_TIMEOUT_SECONDS=420' "$INSTALLER" ||
fail "package transaction timeout must allow slow OpenWrt package hooks"
# An unprocessed tree keeps the literal placeholder; treating it as a SHA would
# make every install look like a rollback.
printf ' c.TACHYON_COMMIT_SHA = env("TACHYON_COMMIT_SHA", "__COMPILED_COMMIT_SHA__");\n' \
>"$WORK_DIR/placeholder.uc"
assert_eq "" "$(read_sha "$WORK_DIR/placeholder.uc")" "the build placeholder must not read as a SHA"
# Before the package manager runs, old rc.common/procd lock holders must be
# cleared. Old Tachyon releases could otherwise block prerm until APK rolled back.
grep -Fq 'release_tachyon_init_lock' "$INSTALLER" ||
fail "installer must release stale Tachyon init locks"
grep -Fq '/proc/[0-9]*/fd/1000' "$INSTALLER" ||
fail "installer must find leaked procd lock holders by fd 1000"
grep -Fq '*procd_tachyon*|*tachyon*lock*' "$INSTALLER" ||
fail "fd sweep must be limited to Tachyon-owned locks"
grep -Fq 'is_protected_pid' "$INSTALLER" ||
fail "installer must protect its own process ancestry"
grep -Fq '99-tachyon-wan|flock 1000|/etc/init.d/tachyon' "$INSTALLER" ||
fail "installer must clear old rc.common waiters"
# The Makefile substitutes "unknown" when git is unavailable at build time. It
# never changes between builds, so it must not be compared as a SHA either.
printf ' c.TACHYON_COMMIT_SHA = env("TACHYON_COMMIT_SHA", "unknown");\n' \
>"$WORK_DIR/unknown.uc"
assert_eq "" "$(read_sha "$WORK_DIR/unknown.uc")" "an unstamped build must not read as a SHA"
# The lock release must happen before packages are handed to apk/opkg.
awk '
/^main\(\)/ { in_main=1 }
in_main && /prepare_transaction/ && !prepare { prepare=NR }
in_main && /install_core_transaction/ && !install { install=NR }
END { exit !(prepare > 0 && install > prepare) }
' "$INSTALLER" || fail "stale init locks must be released before package install"
assert_eq "" "$(read_sha "")" "a missing constants module must yield no SHA"
# A failed later stage restores the safety snapshot rather than leaving user
# configuration rewritten by package hooks.
grep -Fq 'TX_ACTIVE=1' "$INSTALLER" || fail "transaction recovery guard missing"
grep -Fq 'TX_COMMITTED=1' "$INSTALLER" || fail "transaction commit guard missing"
grep -Fq 'restore_snapshot' "$INSTALLER" || fail "failed transaction must restore snapshot"
grep -Fq 'chmod 0600 /etc/config/tachyon' "$INSTALLER" ||
fail "restored Tachyon config must remain private"
# A rolled-back rebuild is only detectable by comparing SHAs, so the backend
# install must refuse to accept a mere presence check when the SHA did not move.
grep -Fq 'rolled the upgrade back' "$INSTALLER" ||
fail "install.sh must fail loudly when the backend SHA did not move after install"
# An empty "before" SHA is not evidence of a fresh install: installed_backend_sha
# rejects both the placeholder and "unknown", which is exactly what a stale build
# on disk reports. Presence before the install is what separates an upgrade from a
# first install, so the rollback check must consult it — otherwise a rolled-back
# upgrade of an unstamped build reaches the "installed — continuing" branch and the
# run marches on over a failure.
sed -n '/^install_backend_package() {/,/^}/p' "$INSTALLER" >"$WORK_DIR/backend.sh"
[ -s "$WORK_DIR/backend.sh" ] || fail "install.sh must define install_backend_package"
grep -Fq 'backend_present_before' "$WORK_DIR/backend.sh" ||
fail "install_backend_package must record whether tachyon was installed before the upgrade"
grep -Fq 'still reports no commit SHA' "$WORK_DIR/backend.sh" ||
fail "an upgrade that leaves the installed build without a SHA must fail, not warn and continue"
# 120s was below the time the package hooks legitimately need to stop and restart
# the service; being killed there makes apk roll the transaction back.
for pm in apk opkg; do
if grep -nE "(apk_with_lock_retry|run_logged_timeout) \"$pm\" 120 $pm (add|install)" "$INSTALLER" >/dev/null; then
fail "$pm package install must not keep the 120s ceiling that rolled upgrades back"
fi
done
# Bounding the hooks only helps the build being installed; during THIS upgrade the
# package manager runs the prerm of whatever is already on disk, and every release
# up to 1.2.66 shipped one with no timeout. It blocks on rc.common's `flock -w
# 1000`, so the installer has to free that lock itself before invoking the package
# manager — otherwise the pre-upgrade hook outlives apk's patience and the whole
# transaction is rolled back.
sed -n '/^function installer_release_init_lock() {/,/^}/p' "$INSTALLER" >"$WORK_DIR/lock.uc"
[ -s "$WORK_DIR/lock.uc" ] ||
fail "install.sh must define installer_release_init_lock to unwedge the old prerm hook"
for pattern in '99-tachyon-wan|flock 1000' '/etc/init.d/tachyon'; do
grep -Fq "$pattern" "$WORK_DIR/lock.uc" ||
fail "installer_release_init_lock must clear '$pattern' waiters"
done
# Killing by name misses the actual holders. procd_lock() does `exec 1000>` on the
# lock file and `flock 1000` with no -w, so every background spawn inheriting fd 1000
# keeps the lock. On a live router the holders were watchdog workers, logread, the
# zapret2 supervisors, nfqws2 and a curl — none of which match a tachyon name.
grep -Fq '/fd/1000' "$WORK_DIR/lock.uc" ||
fail "installer_release_init_lock must clear lock holders by descriptor, not only by name"
grep -Fq 'procd_tachyon' "$WORK_DIR/lock.uc" ||
fail "installer_release_init_lock must only kill holders of Tachyon's own procd lock"
# The installer runs as a child of /usr/bin/tachyon during an upgrade, so a sweep that
# does not exclude its own ancestors kills the process performing the install.
grep -Fq '/stat' "$WORK_DIR/lock.uc" ||
fail "installer_release_init_lock must walk /proc/<pid>/stat to spare its own ancestors"
# ucode resolves names lexically and does not hoist, so a call placed above the
# declaration silently becomes null and the lock is never released.
lock_decl="$(grep -m 1 -n '^function installer_release_init_lock() {' "$INSTALLER" | cut -d: -f1)"
lock_call="$(grep -m 1 -n 'installer_release_init_lock();' "$INSTALLER" | cut -d: -f1)"
[ -n "$lock_decl" ] && [ -n "$lock_call" ] ||
fail "installer_release_init_lock must be both declared and called"
[ "$lock_decl" -lt "$lock_call" ] ||
fail "installer_release_init_lock is called before its declaration (ucode does not hoist)"
# The lock has to be dropped as part of stopping the service, i.e. before any
# package is handed to the package manager.
sed -n '/^function installer_cleanup_legacy() {/,/^}/p' "$INSTALLER" >"$WORK_DIR/cleanup.uc"
grep -Fq 'installer_release_init_lock();' "$WORK_DIR/cleanup.uc" ||
fail "installer_cleanup_legacy must release the init lock before the packages are installed"
# Successful completion is only printed after the runtime healthcheck and commit.
awk '
/^main\(\)/ { in_main=1 }
in_main && /healthcheck/ && !health { health=NR }
in_main && /commit_transaction/ && !commit { commit=NR }
in_main && /installed successfully/ && !success { success=NR }
END { exit !(health > 0 && commit > health && success > commit) }
' "$INSTALLER" || fail "success must be gated by healthcheck and transaction commit"
printf 'PASS: installer rollback detection\n'
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALLER="$ROOT_DIR/install.sh"
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
[ -r "$INSTALLER" ] || fail "install.sh is missing"
sh -n "$INSTALLER" || fail "install.sh must be valid POSIX shell"
grep -Fq 'mkdir "$LOCK_DIR"' "$INSTALLER" || fail "installer lock must use atomic mkdir"
grep -Fq 'Removing stale installer lock' "$INSTALLER" || fail "stale installer lock must be recoverable"
grep -Fq 'trap cleanup EXIT' "$INSTALLER" || fail "cleanup must run on EXIT"
grep -Fq 'trap on_int INT' "$INSTALLER" || fail "INT handler missing"
grep -Fq 'on_int() { exit 130; }' "$INSTALLER" || fail "INT must return 130"
grep -Fq 'on_term() { exit 143; }' "$INSTALLER" || fail "TERM must return 143"
if grep -Fq 'trap cleanup EXIT HUP INT TERM' "$INSTALLER"; then
fail "cleanup-only signal trap can resume installation after a signal"
fi
awk '
/^main\(\)/ { in_main=1 }
in_main && /sync_time/ && !sync { sync=NR }
in_main && /ensure_bootstrap_ucode_runtime/ && !bootstrap { bootstrap=NR }
END { exit !(sync > 0 && bootstrap > sync) }
' "$INSTALLER" || fail "sync_time must happen before bootstrap package/network operations"
grep -Fq 'apk --wait "$APK_LOCK_WAIT_SECONDS"' "$INSTALLER" || fail "apk --wait support is required"
grep -Fq 'diagnose_apk_lock' "$INSTALLER" || fail "APK lock diagnosis is required"
if grep -Fq 'sanitize_apk_world' "$INSTALLER"; then
fail "installer must not mutate /etc/apk/world as generic APK cleanup"
fi
grep -Fq 'TACHYON_DOWNLOAD_BYTES=' "$INSTALLER" || fail "release download size accounting missing"
grep -Fq 'free_kb /tmp' "$INSTALLER" || fail "/tmp free-space check missing"
grep -Fq 'MIN_TMP_HEADROOM_KB' "$INSTALLER" || fail "/tmp safety headroom missing"
grep -Fq 'pkg_install_local_bundle "$@"' "$INSTALLER" || fail "bundle transaction helper is not used"
grep -Fq 'apk-transaction' "$INSTALLER" || fail "APK transaction tag missing"
grep -Fq 'opkg-transaction' "$INSTALLER" || fail "opkg transaction tag missing"
awk '
/^main\(\)/ { in_main=1 }
in_main && /download_release/ && !download { download=NR }
in_main && /snapshot_state/ && !snapshot { snapshot=NR }
in_main && /prepare_transaction/ && !prepare { prepare=NR }
in_main && /install_core_transaction/ && !install { install=NR }
in_main && /healthcheck/ && !health { health=NR }
in_main && /commit_transaction/ && !commit { commit=NR }
END { exit !(download > 0 && snapshot > download && prepare > snapshot && install > prepare && health > install && commit > health) }
' "$INSTALLER" || fail "transaction phase ordering is unsafe"
grep -Fq 'restore_snapshot' "$INSTALLER" || fail "failed transaction must restore recovery snapshot"
grep -Fq 'TX_COMMITTED' "$INSTALLER" || fail "transaction commit guard missing"
grep -Fq '/fd/1000' "$INSTALLER" || fail "procd fd1000 lock-holder cleanup missing"
grep -Fq 'is_protected_pid' "$INSTALLER" || fail "installer must protect its own process ancestry"
grep -Fq '99-tachyon-wan|flock 1000|/etc/init.d/tachyon' "$INSTALLER" || fail "legacy init-lock waiter cleanup missing"
for pattern in 'tachyon_' 'luci-app-tachyon_' 'luci-i18n-tachyon-ru_' 'sha256sums.txt'; do
grep -Fq "$pattern" "$INSTALLER" || fail "release asset contract missing: $pattern"
done
grep -Fq 'Checksum mismatch' "$INSTALLER" || fail "checksum failure path missing"
if grep -qi 'jsdelivr' "$INSTALLER"; then
fail "GitHub release assets must not use the jsDelivr Git-tree endpoint"
fi
grep -Fq '/usr/bin/tachyon component_action sing_box "$_action"' "$INSTALLER" || fail "sing-box must be delegated to component_action"
grep -Fq 'require("core.common")' "$INSTALLER" || fail "runtime healthcheck must require core.common"
export TACHYON_INSTALLER_TEST=1
# shellcheck source=/dev/null
. "$INSTALLER"
TEST_TMP="$(mktemp -d)"
trap 'rm -rf "$TEST_TMP"' EXIT
LOCK_DIR="$TEST_TMP/lock"
acquire_lock || fail "first atomic lock acquisition failed"
[ -f "$LOCK_DIR/pid" ] || fail "lock PID was not recorded"
release_lock
[ ! -e "$LOCK_DIR" ] || fail "lock was not released"
start="$(date +%s)"
if run_with_deadline 1 sh -c 'sleep 5'; then
fail "deadline watchdog accepted an over-deadline command"
fi
elapsed="$(( $(date +%s) - start ))"
[ "$elapsed" -lt 4 ] || fail "deadline watchdog did not stop the command promptly"
run_with_deadline 3 sh -c 'exit 0' || fail "deadline helper changed a successful exit status"
printf 'PASS: ultimate installer transaction contract\n'
+44 -101
View File
@@ -8,24 +8,17 @@ TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon"
REAL_LIB="$ROOT_DIR/tachyon/files/usr/lib"
INSTALLER="$ROOT_DIR/install.sh"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
[ -r "$RESET_UC" ] || fail "service/reset.uc must own reset-to-defaults logic"
[ -r "$TACHYON_BIN" ] || fail "tachyon entrypoint is missing"
grep -Fq 'reset_settings: [ "service/reset.uc", "reset-settings", 1 ]' "$TACHYON_BIN" ||
fail "tachyon entrypoint must dispatch reset_settings to service/reset.uc"
grep -Fq 'reset_settings' "$TACHYON_BIN" ||
fail "tachyon entrypoint must list reset_settings in the command table"
grep -Fq 'reset_settings [no-start]' "$TACHYON_BIN" ||
fail "tachyon help must document reset_settings"
mkdir -p "$WORK_DIR/lib/defaults"
cat >"$WORK_DIR/default-config" <<'EOF'
@@ -39,15 +32,14 @@ cp "$WORK_DIR/default-config" "$WORK_DIR/lib/defaults/config"
mkdir -p \
"$WORK_DIR/persistent/subscription-cache" \
"$WORK_DIR/persistent/rulesets" \
"$WORK_DIR/persistent/tailscale/server-main"
printf '%s\n' 'stale-cache' >"$WORK_DIR/persistent/subscription-cache/cache.json"
printf '%s\n' 'stale-ruleset' >"$WORK_DIR/persistent/rulesets/community.srs"
printf '%s\n' 'tailscale-identity' >"$WORK_DIR/persistent/tailscale/server-main/node.key"
mkdir -p "$WORK_DIR/runtime" "$WORK_DIR/sing-box-tmp"
printf '%s\n' '{"log":{}}' >"$WORK_DIR/sing-box-tmp/config.json"
mkdir -p "$(dirname "$WORK_DIR/sing-box-config")"
printf '%s\n' '{"log":{}}' >"$WORK_DIR/sing-box-config"
printf '%s\n' 'custom-user-config' >"$WORK_DIR/config"
"$WORK_DIR/persistent/tailscale/server-main" \
"$WORK_DIR/runtime" \
"$WORK_DIR/sing-box-tmp"
printf '%s\n' stale >"$WORK_DIR/persistent/subscription-cache/cache.json"
printf '%s\n' stale >"$WORK_DIR/persistent/rulesets/community.srs"
printf '%s\n' identity >"$WORK_DIR/persistent/tailscale/server-main/node.key"
printf '%s\n' '{}' >"$WORK_DIR/sing-box-config"
printf '%s\n' custom >"$WORK_DIR/config"
: >"$WORK_DIR/bin.log"
: >"$WORK_DIR/init.log"
@@ -56,97 +48,48 @@ cat >"$WORK_DIR/fake-bin" <<'SH'
printf '%s\n' "$*" >> "$TACHYON_RESET_BIN_LOG"
exit 0
SH
chmod 0755 "$WORK_DIR/fake-bin"
cat >"$WORK_DIR/fake-init" <<'SH'
#!/usr/bin/env sh
printf '%s\n' "$*" >> "$TACHYON_RESET_INIT_LOG"
exit 0
SH
chmod 0755 "$WORK_DIR/fake-init"
chmod 0755 "$WORK_DIR/fake-bin" "$WORK_DIR/fake-init"
TACHYON_LIB="$WORK_DIR/lib" \
TACHYON_BIN="$WORK_DIR/fake-bin" \
TACHYON_SERVICE_INIT="$WORK_DIR/fake-init" \
TACHYON_CONFIG_PATH="$WORK_DIR/config" \
TACHYON_PERSISTENT_DIR="$WORK_DIR/persistent" \
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
TACHYON_SING_BOX_TMP_DIR="$WORK_DIR/sing-box-tmp" \
TACHYON_SING_BOX_CONFIG_PATH="$WORK_DIR/sing-box-config" \
TACHYON_RESET_BIN_LOG="$WORK_DIR/bin.log" \
TACHYON_RESET_INIT_LOG="$WORK_DIR/init.log" \
ucode -L "$WORK_DIR/lib" -L "$REAL_LIB" "$RESET_UC" reset-settings no-start >"$WORK_DIR/result.json"
grep -Fq '"success":true' "$WORK_DIR/result.json" ||
fail "reset-settings must report success"
run_reset() {
TACHYON_LIB="$WORK_DIR/lib" \
TACHYON_BIN="$WORK_DIR/fake-bin" \
TACHYON_SERVICE_INIT="$WORK_DIR/fake-init" \
TACHYON_CONFIG_PATH="$WORK_DIR/config" \
TACHYON_PERSISTENT_DIR="$WORK_DIR/persistent" \
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
TACHYON_SING_BOX_TMP_DIR="$WORK_DIR/sing-box-tmp" \
TACHYON_SING_BOX_CONFIG_PATH="$WORK_DIR/sing-box-config" \
TACHYON_RESET_BIN_LOG="$WORK_DIR/bin.log" \
TACHYON_RESET_INIT_LOG="$WORK_DIR/init.log" \
ucode -L "$WORK_DIR/lib" -L "$REAL_LIB" "$RESET_UC" reset-settings "$@"
}
grep -Fxq 'stop' "$WORK_DIR/bin.log" ||
fail "reset must stop the service through the backend entrypoint"
grep -Fxq 'stop' "$WORK_DIR/init.log" ||
fail "reset must stop the service through init.d"
if grep -Fxq 'start' "$WORK_DIR/init.log"; then
fail "reset no-start must not start the service"
fi
cmp -s "$WORK_DIR/config" "$WORK_DIR/default-config" ||
fail "reset must restore the config file to factory defaults"
# busybox-based images may lack stat(1); ucode's fs module is always present.
run_reset no-start >"$WORK_DIR/result.json"
grep -Fq '"success":true' "$WORK_DIR/result.json" || fail "reset-settings must report success"
grep -Fxq 'stop' "$WORK_DIR/bin.log" || fail "reset must stop through backend entrypoint"
grep -Fxq 'stop' "$WORK_DIR/init.log" || fail "reset must stop through init.d"
if grep -Fxq 'start' "$WORK_DIR/init.log"; then fail "no-start reset must not start service"; fi
cmp -s "$WORK_DIR/config" "$WORK_DIR/default-config" || fail "reset must restore factory config"
[ "$(ucode -e "let fs = require('fs'); printf('%o', fs.stat('$WORK_DIR/config').mode & 511);")" = "600" ] ||
fail "reset config must be chmod 600"
[ ! -e "$WORK_DIR/persistent/subscription-cache" ] || fail "reset must clear subscription cache"
[ ! -e "$WORK_DIR/persistent/rulesets" ] || fail "reset must clear ruleset cache"
[ "$(cat "$WORK_DIR/persistent/tailscale/server-main/node.key")" = identity ] || fail "reset must preserve Tailscale identity"
[ ! -e "$WORK_DIR/runtime" ] || fail "reset must clear runtime state"
[ ! -e "$WORK_DIR/sing-box-tmp" ] || fail "reset must clear sing-box temporary state"
[ ! -e "$WORK_DIR/sing-box-config" ] || fail "reset must remove generated sing-box config"
[ ! -e "$WORK_DIR/persistent/subscription-cache" ] ||
fail "reset must remove stale subscription caches"
[ ! -e "$WORK_DIR/persistent/rulesets" ] ||
fail "reset must remove stale ruleset caches"
[ "$(cat "$WORK_DIR/persistent/tailscale/server-main/node.key")" = 'tailscale-identity' ] ||
fail "reset must preserve the Tailscale identity"
[ ! -e "$WORK_DIR/runtime" ] ||
fail "reset must remove the runtime state directory"
[ ! -e "$WORK_DIR/sing-box-tmp" ] ||
fail "reset must remove the sing-box temporary directory"
[ ! -e "$WORK_DIR/sing-box-config" ] ||
fail "reset must remove the sing-box config file"
TACHYON_LIB="$WORK_DIR/lib" \
TACHYON_BIN="$WORK_DIR/fake-bin" \
TACHYON_SERVICE_INIT="$WORK_DIR/fake-init" \
TACHYON_CONFIG_PATH="$WORK_DIR/config" \
TACHYON_PERSISTENT_DIR="$WORK_DIR/persistent" \
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
TACHYON_SING_BOX_TMP_DIR="$WORK_DIR/sing-box-tmp" \
TACHYON_SING_BOX_CONFIG_PATH="$WORK_DIR/sing-box-config" \
TACHYON_RESET_BIN_LOG="$WORK_DIR/bin.log" \
TACHYON_RESET_INIT_LOG="$WORK_DIR/init.log" \
ucode -L "$WORK_DIR/lib" -L "$REAL_LIB" "$RESET_UC" reset-settings >"$WORK_DIR/result.json"
grep -Fxq 'start' "$WORK_DIR/init.log" ||
fail "reset without no-start must start the service"
TACHYON_LIB="$WORK_DIR/lib" \
TACHYON_BIN="$WORK_DIR/fake-bin" \
TACHYON_SERVICE_INIT="$WORK_DIR/fake-init" \
TACHYON_CONFIG_PATH="$WORK_DIR/config" \
TACHYON_PERSISTENT_DIR="$WORK_DIR/persistent" \
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
TACHYON_SING_BOX_TMP_DIR="$WORK_DIR/sing-box-tmp" \
TACHYON_SING_BOX_CONFIG_PATH="$WORK_DIR/sing-box-config" \
TACHYON_RESET_BIN_LOG="$WORK_DIR/bin.log" \
TACHYON_RESET_INIT_LOG="$WORK_DIR/init.log" \
ucode -L "$WORK_DIR/lib" -L "$REAL_LIB" "$RESET_UC" bogus-mode 2>"$WORK_DIR/usage.err" &&
fail "reset module must reject unknown modes"
grep -Fq 'Usage' "$WORK_DIR/usage.err" ||
fail "reset module must print usage for unknown modes"
grep -Fq 'reset_settings [no-start]' "$TACHYON_BIN" ||
fail "tachyon help must document reset_settings"
grep -Fq 'reset_settings' "$TACHYON_BIN" ||
fail "tachyon failsafe dnsmasq restore must cover reset_settings"
# The installer must wipe leftover state on a fresh install but keep user
# configuration on upgrades and legacy migrations.
grep -Fq 'TACHYON_WAS_INSTALLED' "$INSTALLER" ||
fail "installer must remember whether Tachyon was installed before"
grep -Fq 'reset_settings", "no-start"' "$INSTALLER" ||
fail "installer post-install must delegate fresh-install defaults to reset_settings"
if grep -n -E 'reset_settings|TACHYON_WAS_INSTALLED' "$INSTALLER" | grep -qE 'rm -f |uci |sed -i '; then
fail "installer must not own reset/state shell logic"
# Installer v3 preserves configuration and delegates package initialization to
# package hooks/runtime modules. It must never implement reset semantics itself.
grep -Fq 'snapshot_state()' "$INSTALLER" || fail "installer must snapshot config before package changes"
grep -Fq 'TACHYON_WAS_INSTALLED' "$INSTALLER" || fail "installer must remember previous install state"
if grep -n -E '(^|[;&|[:space:]])uci[[:space:]]+-q|sed -i .*/etc/config/tachyon' "$INSTALLER" >/dev/null; then
fail "installer shell must not own UCI reset/mutation logic"
fi
printf 'reset settings contract checks passed\n'
+20 -24
View File
@@ -2,52 +2,48 @@
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
# 1. Structural checks in CLI and backend ucode
grep -Fq 'delete_section' "$ROOT_DIR/tachyon/files/usr/bin/tachyon" ||
fail "tachyon CLI must declare delete_section command"
grep -Fq 'cascade_delete_section' "$ROOT_DIR/tachyon/files/usr/lib/config/connections.uc" ||
fail "connections.uc must implement cascade_delete_section"
grep -Fq 'cli_delete_section' "$ROOT_DIR/tachyon/files/usr/lib/config/connections.uc" ||
fail "connections.uc must export cli_delete_section"
grep -Fq 'priority_group' "$ROOT_DIR/tachyon/files/usr/lib/config/migration.uc" ||
fail "migration.uc CHILD_ITEM_TYPES must include priority_group"
grep -Fq 'priority_level' "$ROOT_DIR/tachyon/files/usr/lib/config/migration.uc" ||
fail "migration.uc CHILD_ITEM_TYPES must include priority_level"
# 2. Structural checks in LuCI JS view
grep -Fq 'cascadeDeleteSection' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" ||
fail "section.js must implement cascadeDeleteSection"
grep -Fq 'cascadeDeleteSection(section_id)' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" ||
fail "section.js remove handler must invoke cascadeDeleteSection"
grep -Fq 'cascadeDeleteSection' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js" ||
fail "tachyon.js must invoke cascadeDeleteSection on section remove/add"
# 3. Installer safety checks
grep -Fq 'backup_existing_config' "$ROOT_DIR/install.sh" ||
fail "install.sh must invoke backup_existing_config"
# 3. Installer safety contract: never mutate user configuration before it is
# snapshotted, and verify the resulting runtime before declaring success.
INSTALLER="$ROOT_DIR/install.sh"
grep -Fq 'snapshot_state()' "$INSTALLER" ||
fail "install.sh must provide a recovery snapshot stage"
grep -Fq '/etc/config/tachyon' "$INSTALLER" ||
fail "installer snapshot/healthcheck must cover /etc/config/tachyon"
grep -Fq 'chmod 0600 /etc/config/tachyon' "$INSTALLER" ||
fail "installer must keep Tachyon config private"
grep -Fq 'healthcheck()' "$INSTALLER" ||
fail "installer must verify the installed runtime"
grep -Fq '/usr/share/luci/menu.d/luci-app-tachyon.json' "$INSTALLER" ||
fail "installer healthcheck must verify the LuCI menu"
grep -Fq 'path_exists("/etc/config/tachyon")' "$ROOT_DIR/install.sh" ||
fail "install.sh must check /etc/config/tachyon before considering installation fresh"
grep -Fq 'path_executable(INSTALLER_TACHYON_BIN)' "$ROOT_DIR/install.sh" ||
fail "install.sh must check INSTALLER_TACHYON_BIN before considering installation fresh"
awk '
/^main\(\)/ { in_main=1 }
in_main && /snapshot_state/ && !snapshot { snapshot=NR }
in_main && /install_core_transaction/ && !install { install=NR }
END { exit !(snapshot > 0 && install > snapshot) }
' "$INSTALLER" || fail "installer must snapshot configuration before package hooks run"
printf 'section cascade deletion and installer checks passed\n'
+10 -5
View File
@@ -653,7 +653,7 @@ cat >"$WORK_DIR/p2p-isolate-fixture.json" <<'JSON'
"dns_server": "1.1.1.1",
"service_listen_address": "127.0.0.1",
"isolate_p2p": "1",
"p2p_ports": "tcp:6881,udp:6881-6889,udp"
"p2p_ports": "tcp:6881,udp:6881-6889,udp,51413"
},
"section": [
{
@@ -662,7 +662,8 @@ cat >"$WORK_DIR/p2p-isolate-fixture.json" <<'JSON'
"enabled": "1",
"action": "outbound",
"outbound_json": "{\"type\":\"direct\"}",
"domain_suffix": [ "example.org" ]
"domain_suffix": [ "example.org" ],
"excluded_protocol": [ "bittorrent" ]
}
]
}
@@ -1413,9 +1414,13 @@ assert(mwan3_pinned.route.default_interface == "wan2", "mwan3 pinned interface i
let p2p_isolate = cfg("p2p-isolate");
assert(route_rule(p2p_isolate, r => contains(r.protocol, "bittorrent") && r.outbound == "direct-out") != null, "p2p isolation sniffer rule");
assert(route_rule(p2p_isolate, r => contains(r.protocol, "tcp") && r.source_port == 6881 && r.outbound == "direct-out") != null, "p2p client tcp port rule");
assert(route_rule(p2p_isolate, r => contains(r.protocol, "udp") && contains(r.source_port_range, "6881:6889") && r.outbound == "direct-out") != null, "p2p client udp port range rule");
assert(route_rule(p2p_isolate, r => contains(r.protocol, "udp") && r.source_port == null && r.source_port_range == null && r.outbound == "direct-out") != null, "p2p protocol-wide direct rule");
assert(route_rule(p2p_isolate, r => contains(r.network, "tcp") && r.source_port == 6881 && r.outbound == "direct-out") != null, "p2p client tcp source port rule");
assert(route_rule(p2p_isolate, r => contains(r.network, "tcp") && contains(r.port, 6881) && r.outbound == "direct-out") != null, "p2p client tcp dest port rule");
assert(route_rule(p2p_isolate, r => contains(r.network, "udp") && contains(r.source_port_range, "6881:6889") && r.outbound == "direct-out") != null, "p2p client udp source port range rule");
assert(route_rule(p2p_isolate, r => contains(r.network, "udp") && contains(r.port_range, "6881:6889") && r.outbound == "direct-out") != null, "p2p client udp dest port range rule");
assert(route_rule(p2p_isolate, r => contains(r.network, "udp") && r.source_port == null && r.port == null && r.source_port_range == null && r.port_range == null && r.outbound == "direct-out") != null, "p2p protocol-wide direct rule");
assert(route_rule(p2p_isolate, r => contains(r.network, "tcp") && contains(r.network, "udp") && r.source_port == 51413 && r.outbound == "direct-out") != null, "p2p plain port source rule");
assert(route_rule(p2p_isolate, r => contains(r.network, "tcp") && contains(r.network, "udp") && contains(r.port, 51413) && r.outbound == "direct-out") != null, "p2p plain port dest rule");
let lists = cfg("domain-ip-rulesets");
assert(no_internal_fields(lists), "internal runtime fields stripped from generated config");
+101
View File
@@ -486,4 +486,105 @@ if [ -n "$SB_BIN" ]; then
fi
fi
# 10. Test Issue #60: Section with mixed list and excluded_ips WITHOUT source_ip_cidr
# Must generate evaluate rule with invert: true, NOT a logical rule with empty subrules
cat > "$WORK_DIR/fixture_issue60.json" << 'EOF'
{
"settings": {
".name": "settings",
".type": "settings",
"enabled": "1",
"dns_type": "udp",
"dns_server": "1.1.1.1",
"service_listen_address": "127.0.0.1"
},
"section": [
{
".name": "vpn_issue60",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"vpn_issue60-out\"}" ],
"community_lists": [ "telegram" ],
"excluded_ips": [ "192.168.1.200" ]
}
]
}
EOF
output_issue60="$WORK_DIR/out_issue60.json"
mkdir -p "$output_issue60.section-cache" "$output_issue60.rulesets"
SB_VERSION_STATE_FILE="$WORK_DIR/sb_v14" \
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/fixture_issue60.json" "$output_issue60" "127.0.0.1" "0" "1"
ucode -e '
let fs = require("fs");
let cfg = json(fs.readfile(ARGV[0]));
let eval_rule = null;
let resp_rule = null;
for (let r in cfg.dns.rules || []) {
if (r.action == "evaluate")
eval_rule = r;
if (r.action == "route" && (r.match_response === true || (r.rules && r.rules[0] && r.rules[0].match_response === true)))
resp_rule = r;
}
if (!eval_rule) {
warn("eval_rule missing in issue60 reproduction config\n");
exit(1);
}
if (!resp_rule) {
warn("resp_rule missing in issue60 reproduction config\n");
exit(2);
}
// eval_rule MUST NOT be a logical rule with an empty condition sub-rule
if (eval_rule.type == "logical") {
for (let child in eval_rule.rules || []) {
if (!child || length(keys(child)) == 0) {
warn("eval_rule contains empty condition sub-rule!\n");
exit(3);
}
}
}
// eval_rule must have invert: true and source_ip_cidr
if (!eval_rule.invert || !eval_rule.source_ip_cidr) {
warn("eval_rule missing invert or source_ip_cidr\n");
exit(4);
}
// Check NO rule in dns.rules or route.rules has empty condition sub-rules
for (let r in cfg.dns.rules || []) {
if (r.type == "logical") {
for (let child in r.rules || []) {
if (!child || length(keys(child)) == 0) {
warn("dns rule contains empty sub-rule!\n");
exit(5);
}
}
}
}
for (let r in cfg.route.rules || []) {
if (r.type == "logical") {
for (let child in r.rules || []) {
if (!child || length(keys(child)) == 0) {
warn("route rule contains empty sub-rule!\n");
exit(6);
}
}
}
}
' "$output_issue60" || fail "issue60 reproduction config verification failed"
if [ -n "$SB_BIN" ]; then
INSTALLED_SB_VER="$("$SB_BIN" version 2>/dev/null | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -n1 || echo '0.0')"
if [ "$(printf '%s\n1.14\n' "$INSTALLED_SB_VER" | sort -V | head -n1)" = "1.14" ]; then
"$SB_BIN" check -c "$output_issue60" || fail "sing-box 1.14 check failed on issue60 reproduction config!"
fi
fi
echo "sing-box 1.14 DNS rules tests passed"
+205
View File
@@ -0,0 +1,205 @@
#!/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"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
cat >"$WORK_DIR/fixture.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"enabled": "1",
"dns_type": "udp",
"dns_server": "1.1.1.1",
"service_listen_address": "127.0.0.1"
},
"section": [
{
".name": "sec_yt",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_yt-out\"}" ],
"domain_suffix": [ "youtube.com" ],
"geoip_country": [ "ru" ],
"geoip_mode": "exclude"
},
{
".name": "sec_all_noru",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_noru-out\"}" ],
"match_all": "1",
"geoip_country": [ "ru" ],
"geoip_mode": "exclude"
},
{
".name": "sec_pure_ru",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_ru-out\"}" ],
"geoip_country": [ "ru" ],
"geoip_mode": "include"
},
{
".name": "sec_sub",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_sub-out\"}" ],
"domain_suffix": [ "other.com" ]
},
{
".name": "sec_both",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\",\"tag\":\"sec_both-out\"}" ],
"domain_suffix": [ "twitch.tv" ],
"geoip_country": [ "ru" ],
"geoip_mode": "exclude",
"excluded_ips": [ "192.168.1.99" ]
}
]
}
JSON
output="$WORK_DIR/out.json"
mkdir -p "$output.section-cache" "$output.rulesets"
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/fixture.json" "$output" "127.0.0.1" "0" "1"
# Verify route rules in out.json
ucode -e '
let fs = require("fs");
let raw = fs.readfile("'"$output"'");
let cfg = json(raw);
let rules = cfg.route.rules || [];
let sec_yt_logical_found = false;
let sec_yt_unscoped_found = false;
let sec_noru_rule_count = 0;
let sec_noru_inverted_found = false;
let sec_noru_unconstrained_all = false;
let sec_pure_ru_found = false;
let sec_sub_found = false;
let sec_both_found = false;
for (let r in rules) {
if (r.domain == "ip.podkop.fyi" || (type(r.domain) == "array" && index(r.domain, "ip.podkop.fyi") >= 0))
continue;
// 1. Check sec_yt: Should be a logical rule combining domain_suffix and inverted geoip/geosite rule_set
if (r.outbound == "sec_yt-out") {
if (r.type == "logical" && r.mode == "and" && type(r.rules) == "array") {
let has_domain = false;
let has_inverted_geo = false;
for (let sub in r.rules) {
if (sub.domain_suffix && index(sub.domain_suffix, "youtube.com") >= 0)
has_domain = true;
if (sub.invert == true && sub.rule_set) {
let rs = type(sub.rule_set) == "array" ? sub.rule_set : [ sub.rule_set ];
for (let tag in rs) {
if (index(tag, "sec_yt-geoip_ru") >= 0 || index(tag, "sec_yt-geosite_ru") >= 0)
has_inverted_geo = true;
}
}
}
if (has_domain && has_inverted_geo)
sec_yt_logical_found = true;
} else {
// A non-logical rule for sec_yt-out that is unscoped
sec_yt_unscoped_found = true;
}
}
// 2. Check sec_all_noru: Should have an inverted rule_set and NOT have an unconstrained all-traffic rule
if (r.outbound == "sec_all_noru-out") {
sec_noru_rule_count++;
if (r.type == "logical" && r.mode == "and") {
for (let sub in r.rules) {
if (sub.invert == true && sub.rule_set)
sec_noru_inverted_found = true;
}
} else if (r.invert == true && r.rule_set) {
sec_noru_inverted_found = true;
} else if (!r.domain && !r.domain_suffix && !r.rule_set && !r.ip_cidr && !r.source_ip_cidr) {
sec_noru_unconstrained_all = true;
}
}
// 3. Check sec_pure_ru: Pure GeoIP section without domain
if (r.outbound == "sec_pure_ru-out") {
if (r.type == "logical" && r.mode == "and") {
for (let sub in r.rules) {
if (!sub.invert && sub.rule_set)
sec_pure_ru_found = true;
}
} else if (r.rule_set && !r.invert) {
sec_pure_ru_found = true;
}
}
// 4. Check sec_sub
if (r.outbound == "sec_sub-out") {
sec_sub_found = true;
}
// 5. Check sec_both: Single logical rule combining domain, inverted GeoIP, AND inverted client IP
if (r.outbound == "sec_both-out") {
if (r.type == "logical" && r.mode == "and" && type(r.rules) == "array") {
let has_domain = false;
let has_inverted_geo = false;
let has_inverted_src = false;
for (let sub in r.rules) {
if (sub.domain_suffix && index(sub.domain_suffix, "twitch.tv") >= 0)
has_domain = true;
if (sub.invert == true && sub.rule_set)
has_inverted_geo = true;
if (sub.invert == true && (sub.source_ip_cidr == "192.168.1.99/32" || (type(sub.source_ip_cidr) == "array" && index(sub.source_ip_cidr, "192.168.1.99/32") >= 0)))
has_inverted_src = true;
}
if (has_domain && has_inverted_geo && has_inverted_src)
sec_both_found = true;
}
}
}
if (!sec_yt_logical_found)
die("Expected scoped logical rule for sec_yt-out (youtube.com AND NOT ru) not found!\n");
if (sec_yt_unscoped_found)
die("Found unwanted unscoped rule for sec_yt-out!\n");
if (!sec_noru_inverted_found)
die("Expected inverted geoip rule for sec_noru-out not found!\n");
if (sec_noru_unconstrained_all)
die("Found unconstrained all-traffic rule for sec_noru-out that negates GeoIP exclusion!\n");
if (!sec_pure_ru_found)
die("Expected GeoIP include rule for sec_ru-out not found!\n");
if (!sec_sub_found)
die("sec_sub-out rule not found!\n");
if (!sec_both_found)
die("Expected combined logical rule for sec_both-out (domain + inverted geoip + inverted client ip) not found!\n");
' || fail "singbox_section_geoip verification failed"
printf 'PASS: singbox_section_geoip\n'
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
CRYPT4="$ROOT_DIR/tachyon/files/usr/lib/subscription/crypt4.uc"
CACHE="$ROOT_DIR/tachyon/files/usr/lib/subscription/cache.uc"
RUNTIME="$ROOT_DIR/tachyon/files/usr/lib/diagnostics/runtime.uc"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
ucode_run() {
command ucode -L "$TACHYON_LIB" "$@"
}
# 1. CLI is-crypt4 tests
ucode_run "$CRYPT4" is-crypt4 "happ://crypt4/dGVzdA==" || fail "happ://crypt4 should be recognized"
ucode_run "$CRYPT4" is-crypt4 "crypt4/dGVzdA==" || fail "crypt4/ should be recognized"
if ucode_run "$CRYPT4" is-crypt4 "https://example.com/sub.txt" 2>/dev/null; then
fail "https:// URL should not be recognized as crypt4"
fi
# 2. End-to-end Decryption tests (AES-256-CBC)
PLAINTEXT="vless://00000000-0000-4000-8000-000000000001@example.com:443?encryption=none&security=tls&sni=example.com#HappNode"
SECRET="my-custom-device-hwid"
# Deterministic test vector (or dynamically generated if openssl CLI is present)
B64_PAYLOAD="AAECAwQFBgcICQoLDA0OD0OMKcI1aFmugKQB4r4K4OSN6xxDU5FmUUFRentyDOXfswM3t0kyFovZ5yaTDJ1pva68axdwhD4QWAebC6Jnbv7X5xJKCGOAMMAQ3uRuJe+nXl0Az2QN+xgljgGeQaM8UHM1t3+tX4FlEWyuaaNZ4dZFwJUh0Nitkz26Mfa9//Hn"
if command -v openssl >/dev/null 2>&1; then
KEY_HEX="$(printf '%s' "$SECRET" | sha256sum | awk '{print $1}')"
IV_HEX="000102030405060708090a0b0c0d0e0f"
printf '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f' >"$WORK_DIR/iv.bin"
printf '%s' "$PLAINTEXT" | openssl enc -aes-256-cbc -K "$KEY_HEX" -iv "$IV_HEX" >"$WORK_DIR/cipher.bin" 2>/dev/null || true
if [ -s "$WORK_DIR/cipher.bin" ]; then
cat "$WORK_DIR/iv.bin" "$WORK_DIR/cipher.bin" >"$WORK_DIR/payload.bin"
B64_PAYLOAD="$(base64 <"$WORK_DIR/payload.bin" | tr -d '\r\n ')"
fi
fi
CRYPT4_URL="happ://crypt4/${B64_PAYLOAD}"
# Decrypt using crypt4 CLI
DECRYPTED="$(ucode_run "$CRYPT4" decrypt "$CRYPT4_URL" "$SECRET")"
[ "$DECRYPTED" = "$PLAINTEXT" ] || fail "Decrypted plaintext mismatch: got '$DECRYPTED', expected '$PLAINTEXT'"
# 3. Default salt decryption ("HappDefaultSalt")
SALT_B64="AAECAwQFBgcICQoLDA0OD6K0gDGOn/K3and0NuhjH6rDKR/ov8Xn8ESMdCk4+0rUkXQVLyUBpJTUyCagj1wCUWkMyOczDnAbhwthZ6HsuitMrPx4PnOGQypFH9+MdCQhZ+5mIkkO4g8+S5p4LTTqzA3H6U2wT6s21QmtkYxCmI8+0flp6JHnnm3Lp6krqaWq"
if command -v openssl >/dev/null 2>&1; then
SALT_HEX="$(printf '%s' "HappDefaultSalt" | sha256sum | awk '{print $1}')"
IV_HEX="000102030405060708090a0b0c0d0e0f"
printf '\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f' >"$WORK_DIR/iv.bin"
printf '%s' "$PLAINTEXT" | openssl enc -aes-256-cbc -K "$SALT_HEX" -iv "$IV_HEX" >"$WORK_DIR/salt_cipher.bin" 2>/dev/null || true
if [ -s "$WORK_DIR/salt_cipher.bin" ]; then
cat "$WORK_DIR/iv.bin" "$WORK_DIR/salt_cipher.bin" >"$WORK_DIR/salt_payload.bin"
SALT_B64="$(base64 <"$WORK_DIR/salt_payload.bin" | tr -d '\r\n ')"
fi
fi
SALT_CRYPT4_URL="crypt4/${SALT_B64}"
SALT_DECRYPTED="$(ucode_run "$CRYPT4" decrypt "$SALT_CRYPT4_URL")"
[ "$SALT_DECRYPTED" = "$PLAINTEXT" ] || fail "Default salt decrypt mismatch: got '$SALT_DECRYPTED'"
# 4. Corrupted / invalid input tests
if ucode_run "$CRYPT4" decrypt "crypt4/invalid_base64_!@#$" 2>/dev/null; then
fail "corrupted base64 should return failure"
fi
# 5. Parser integration
cat >"$WORK_DIR/test_parser.uc" <<'UCODE'
let parser = require("subscription.parser");
// URL source entry validation
let entry = parser.parse_subscription_source_entry("happ://crypt4/dGVzdA==");
if (!entry.valid)
exit(1);
let entry2 = parser.parse_subscription_source_entry("crypt4/dGVzdA==");
if (!entry2.valid)
exit(2);
exit(0);
UCODE
ucode_run "$WORK_DIR/test_parser.uc" || fail "parser source entry validation for crypt4 failed"
# 6. Normalize content validation with encrypted file
ENCRYPTED_FILE="$WORK_DIR/sub_encrypted.txt"
NORMALIZED_OUT="$WORK_DIR/normalized.json"
printf '%s' "$SALT_CRYPT4_URL" >"$ENCRYPTED_FILE"
cat >"$WORK_DIR/test_normalize.uc" <<'UCODE'
let parser = require("subscription.parser");
let enc_file = ARGV[0];
let out_file = ARGV[1];
let ok = parser.normalize_content_validated(enc_file, out_file);
if (!ok) exit(1);
UCODE
ucode_run "$WORK_DIR/test_normalize.uc" "$ENCRYPTED_FILE" "$NORMALIZED_OUT" || fail "normalize_content_validated failed on crypt4"
grep -Fq "example.com" "$NORMALIZED_OUT" || fail "normalized output missing expected server domain"
grep -Fq "HappNode" "$NORMALIZED_OUT" || fail "normalized output missing server tag"
# 7. Cache download_subscription with crypt4 URL
CACHE_OUT="$WORK_DIR/cache_sub.txt"
ucode_run "$CACHE" download-subscription "$CRYPT4_URL" "$CACHE_OUT" "" "" "" "$SECRET" || fail "download_subscription failed for crypt4 URL"
[ -s "$CACHE_OUT" ] || fail "download_subscription produced empty cache output"
grep -Fq "HappNode" "$CACHE_OUT" || fail "download_subscription did not properly decrypt content"
# 8. Dual HWID request headers check in cache.uc
grep -Fq '"X-HWID: " + hwid_val' "$CACHE" || fail "missing X-HWID header in cache.uc"
grep -Fq '"HWID: " + hwid_val' "$CACHE" || fail "missing HWID header in cache.uc"
# 9. Diagnostics symptom collapsing test
DOCTOR_OUT="$(ucode_run "$RUNTIME" ai-doctor 2>/dev/null)" || fail "ai-doctor failed to run"
echo "$DOCTOR_OUT" | grep -Fq '"success": true' || fail "ai-doctor report missing success flag"
echo "$DOCTOR_OUT" | grep -Fq '"model": "rule_engine_v2"' || fail "ai-doctor report missing rule_engine_v2 model"
# Verify symptom collapsing: when WAN or sing-box fails, downstream proxy updates are collapsed/suppressed
if echo "$DOCTOR_OUT" | grep -Fq '"update_subscriptions"'; then
fail "update_subscriptions should be suppressed when WAN/core service is unreachable"
fi
printf 'All Happ Crypt4, HWID, and symptom collapsing checks passed!\n'
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -eo pipefail
TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$TEST_DIR/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
PARSER_UC="$TACHYON_LIB/subscription/parser.uc"
CACHE_UC="$TACHYON_LIB/subscription/cache.uc"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
# 1. Test that a subscription with only "Данное приложение не поддерживается" is rejected
cat >"$WORK_DIR/stub_uri.txt" <<'EOF'
vless://00000000-0000-0000-0000-000000000000@127.0.0.1:443?security=none#%D0%94%D0%B0%D0%BD%D0%BD%D0%BE%D0%B5%20%D0%BF%D1%80%D0%B8%D0%BB%D0%BE%D0%B6%D0%B5%D0%BD%D0%B8%D0%B5%20%D0%BD%D0%B5%20%D0%BF%D0%BE%D0%B4%D0%B4%D0%B5%D1%80%D0%B6%D0%B8%D0%B2%D0%B0%D0%B5%D1%82%D1%81%D1%8F
EOF
if ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/stub_uri.txt" "$WORK_DIR/out_stub.json" >/dev/null 2>&1; then
fail "normalize-content-validated should reject subscription with only 'Данное приложение не поддерживается'"
fi
# 2. Test English variant: "This app is not supported. Use Happ"
cat >"$WORK_DIR/stub_en.txt" <<'EOF'
vless://00000000-0000-0000-0000-000000000000@127.0.0.1:443?security=none#Unsupported%20client%20-%20Use%20Happ
EOF
if ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/stub_en.txt" "$WORK_DIR/out_stub_en.json" >/dev/null 2>&1; then
fail "normalize-content-validated should reject English unsupported client notice"
fi
# 3. Test sing-box JSON with only stub outbound
cat >"$WORK_DIR/stub_singbox.json" <<'EOF'
{
"outbounds": [
{
"type": "vless",
"tag": "⚠️ Данное приложение не поддерживается",
"server": "127.0.0.1",
"server_port": 443,
"uuid": "00000000-0000-0000-0000-000000000000"
}
]
}
EOF
if ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/stub_singbox.json" "$WORK_DIR/out_stub_sb.json" >/dev/null 2>&1; then
fail "normalize-content-validated should reject singbox JSON with stub outbound"
fi
# 4. Test Clash YAML with only stub outbound
cat >"$WORK_DIR/stub_clash.yaml" <<'EOF'
proxies:
- name: "Данное приложение не поддерживается"
type: vless
server: 127.0.0.1
port: 443
uuid: 00000000-0000-0000-0000-000000000000
EOF
if ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/stub_clash.yaml" "$WORK_DIR/out_stub_clash.json" >/dev/null 2>&1; then
fail "normalize-content-validated should reject Clash YAML with stub outbound"
fi
# 5. Test mixed subscription: 1 real node + 1 stub node
cat >"$WORK_DIR/mixed_uri.txt" <<'EOF'
vless://11111111-1111-1111-1111-111111111111@vpn.example.com:443?security=none#Valid-Server-DE
vless://00000000-0000-0000-0000-000000000000@127.0.0.1:443?security=none#Данное%20приложение%20не%20поддерживается
EOF
ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/mixed_uri.txt" "$WORK_DIR/out_mixed.json" ||
fail "normalize-content-validated should accept mixed subscription with valid nodes"
ucode -e '
let fs = require("fs");
let doc = json(fs.readfile("'"$WORK_DIR/out_mixed.json"'"));
let list = doc.outbounds || [];
if (length(list) != 1) {
print("Expected 1 valid outbound after filtering stub, got " + length(list) + "\n");
exit(1);
}
if (list[0].tag != "Valid-Server-DE" || list[0].server != "vpn.example.com") {
print("Unexpected outbound content: " + sprintf("%J", list[0]) + "\n");
exit(2);
}
' || fail "Failed to filter out stub node from mixed subscription"
# 6. Test validate-subscription CLI mode directly
if ucode -L "$TACHYON_LIB" "$PARSER_UC" validate-subscription "$WORK_DIR/stub_singbox.json" >/dev/null 2>&1; then
fail "validate-subscription CLI should return error for stub-only JSON"
fi
ucode -L "$TACHYON_LIB" "$PARSER_UC" validate-subscription "$WORK_DIR/out_mixed.json" >/dev/null ||
fail "validate-subscription CLI should return success for valid JSON"
# 7. Test programmatic validate_subscription in parser module
cat >"$WORK_DIR/test_validate.uc" <<'EOF'
let parser = require("subscription.parser");
let stub_file = ARGV[0];
let valid_file = ARGV[1];
if (parser.validate_subscription(stub_file)) {
print("validate_subscription must return false for stub JSON\n");
exit(1);
}
if (!parser.validate_subscription(valid_file)) {
print("validate_subscription must return true for valid JSON\n");
exit(2);
}
exit(0);
EOF
ucode -L "$TACHYON_LIB" "$WORK_DIR/test_validate.uc" "$WORK_DIR/stub_singbox.json" "$WORK_DIR/out_mixed.json" ||
fail "subscription validation cache checks failed"
echo "subscription unsupported stub checks passed"