Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
997d72f85c | ||
|
|
b083789e74 | ||
|
|
6f0055d636 | ||
|
|
15c1b1eb81 | ||
|
|
383b426e46 | ||
|
|
da508f51e6 | ||
|
|
623ea59797 | ||
|
|
44e93b1f16 | ||
|
|
5f3b3eb77b | ||
|
|
bf4255438f | ||
|
|
e2c90ed6ea | ||
|
|
b556f2539b | ||
|
|
f010dc2cf2 | ||
|
|
e610ea9ca3 | ||
|
|
3bcce51ec3 | ||
|
|
a27173a04a | ||
|
|
2ab4e0db74 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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[@]}"
|
||||
+1268
-1230
File diff suppressed because it is too large
Load Diff
+1252
-1226
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1252
-1226
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
@@ -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] || "";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] || "";
|
||||
|
||||
@@ -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] || "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user