13 Commits
0.8.7 ... main

Author SHA1 Message Date
8b467baba8 mirror: replace github.com with uralgit.ru for RF access
Some checks failed
OpenWrt Smoke Tests / OpenWrt rootfs smoke tests (push) Has been cancelled
Differential ShellCheck / Differential ShellCheck (push) Has been cancelled
- install.sh: REPO, RELEASES_LATEST_REDIRECT, RELEASES_DOWNLOAD_BASE → uralgit.ru
- install.sh: raw.githubusercontent.com → uralgit.ru raw endpoint
- constants.sh: NETSHIFT_RELEASE_API_URL, REPO URLs → uralgit.ru
- constants.sh: GITHUB_RAW_URL, SRS_MAIN_URL → uralgit.ru/allow-domains
- updater.sh: UPDATES_SING_BOX_EXTENDED_REPO → ural/sing-box-extended
- updater.sh: api.github.com → uralgit.ru API
- README.md: all github.com links → uralgit.ru
- Mirror repos: itdoginfo/allow-domains, shtorm-7/sing-box-extended
2026-08-10 20:21:29 +05:00
40d2be72db пакет: дефолт PKG_VERSION=0.9.6 для релиза 0.9.6
Some checks failed
OpenWrt Smoke Tests / OpenWrt rootfs smoke tests (push) Has been cancelled
Build packages / OpenWrt rootfs smoke tests (push) Has been cancelled
Build packages / Setup build version (push) Has been cancelled
Build packages / Builder for apk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Builder for ipk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Create Release (push) Has been cancelled
2026-07-07 21:53:22 +03:00
ce3c917f8a Урлтест и селектор со вставкой ссылок в текстовое поле
Some checks failed
Build packages / OpenWrt rootfs smoke tests (push) Has been cancelled
Build packages / Setup build version (push) Has been cancelled
Build packages / Builder for apk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Builder for ipk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Create Release (push) Has been cancelled
2026-06-13 14:41:37 +03:00
a70339bf32 обновил README 2026-06-13 11:52:39 +03:00
da72c64b50 скриншот 2026-06-13 11:44:53 +03:00
883811bd55 добавлен выбор самой быстрой группы из самых быстрых urltest
Some checks failed
Build packages / OpenWrt rootfs smoke tests (push) Has been cancelled
Build packages / Setup build version (push) Has been cancelled
Build packages / Builder for apk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Builder for ipk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Create Release (push) Has been cancelled
2026-06-13 11:21:22 +03:00
ba75930510 меньше обращений к гитхаб апи
Some checks failed
Build packages / OpenWrt rootfs smoke tests (push) Has been cancelled
Build packages / Setup build version (push) Has been cancelled
Build packages / Builder for apk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Builder for ipk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Create Release (push) Has been cancelled
2026-06-12 23:42:45 +03:00
f63e0b9fd9 фоллбек и миграция листов со старых кгф 2026-06-12 23:16:47 +03:00
0ac0a36598 фикс парсинга проверки версии netshift
Some checks failed
Build packages / OpenWrt rootfs smoke tests (push) Has been cancelled
Build packages / Setup build version (push) Has been cancelled
Build packages / Builder for apk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Builder for ipk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Create Release (push) Has been cancelled
2026-06-12 10:00:52 +03:00
996eb7ab29 поддержка gzip в подписках 2026-06-12 09:37:35 +03:00
8e40c49fa4 универсальная группировка прокси в подписках 2026-06-12 09:00:28 +03:00
a7a9f720e1 фикс юзер агента Happ
Some checks failed
Build packages / OpenWrt rootfs smoke tests (push) Has been cancelled
Build packages / Setup build version (push) Has been cancelled
Build packages / Builder for apk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Builder for ipk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Create Release (push) Has been cancelled
2026-06-11 22:28:15 +03:00
a062f41a2e фикс апдейта пакета на ipk 2026-06-11 21:57:57 +03:00
27 changed files with 3830 additions and 570 deletions

151
README.md
View File

@ -6,7 +6,7 @@
<img src="./docs/icon.png" alt="Clash" width="128" />
<br>
<br>
<a href="https://github.com/yandexru45/netshift/releases">
<a href="https://uralgit.ru/ural/netshift/releases">
<img src="https://img.shields.io/github/release/yandexru45/netshift/all.svg">
</a>
</p>
@ -32,10 +32,14 @@
## Функции
- [x] **Маршрутизация по доменам и подсетям** - нужное в туннель, остальное напрямую<br><sub>VLESS · Shadowsocks · Trojan · Hysteria2 · готовые community-списки</sub>
- [x] **Subscription URL** - ссылки подписки от провайдера с автообновлением и автовыбором лучшего сервера<br><sub>любая подписка remnawave · 3x-ui · marzban · github</sub>
- [x] **Переключаемое ядро sing-box** - стабильное ↔ sing-box-extended прямо из веб-интерфейса<br><sub>клиентский транспорт xhttp · установка и откат в один клик</sub>
- [x] **Веб-интерфейс LuCI** - дашборд, диагностика и настройки без ручной правки конфигов<br><sub>статус серверов · проверка соединения · логи</sub>
- [x] **Маршрутизация по доменам и подсетям** - нужное в туннель, остальное напрямую<br><sub>VLESS · Shadowsocks · Trojan · Hysteria2 · VMess · SOCKS · готовые community-списки</sub>
- [x] **Subscription URL** - ссылки подписки от провайдера с автообновлением и автовыбором лучшего сервера<br><sub>любая подписка remnawave · 3x-ui · marzban · github · форматы base64 / URI / Clash / Xray JSON</sub>
- [x] **Несколько подписок и фильтры** - несколько фидов в одной секции, фильтр серверов по ключевым словам (include / exclude)<br><sub>объединение без дублей · регистронезависимо · работает и по эмодзи</sub>
- [x] **Группировка серверов** - по флагу страны или по префиксу имени, с авто-выбором «⚡ Самый быстрый» среди всех групп<br><sub>URLTest внутри группы · URLTest над группами · ручной выбор сохранён</sub>
- [x] **Переключаемое ядро sing-box** - стабильное ↔ sing-box-extended прямо из веб-интерфейса<br><sub>клиентский транспорт xhttp · самовосстановление и автооткат · установка в один клик</sub>
- [x] **Самообновление из веб-интерфейса** - проверка и установка обновлений NetShift прямо из LuCI<br><sub>асинхронно · бэкап конфига · без риска «окирпичивания»</sub>
- [x] **Веб-интерфейс LuCI** - дашборд, менеджер компонентов, диагностика и настройки без ручной правки конфигов<br><sub>статус серверов · проверка соединения · логи · вкладки-карточки</sub>
- [x] **IPv6, блокировка DoH, глобальный прокси** - полная маршрутизация v6 через туннель, защита DNS роутера, режим «весь трафик в туннель»<br><sub>v6 tproxy / DNS / FakeIP · DNS через прокси · фоновый watchdog sing-box</sub>
- [x] **Автоматическая миграция** - обновление со старого podkop переносит конфиг без перенастройки
@ -54,8 +58,9 @@
<details open>
<summary><b>Системные требования</b></summary>
- OpenWrt **24.10** или выше.
- OpenWrt **24.10** или выше (поддерживаются и сборки на `opkg`/`.ipk`, и новые на `apk`/`.apk` - OpenWrt 25.12+).
- Минимум **25 МБ** свободного места. Устройства с флеш-памятью 16 МБ не поддерживаются.
- На устройстве: `sing-box >= 1.12.0`, `jq >= 1.7.1`, `coreutils-base64 >= 9.7` (ставятся как зависимости пакета).
</details>
@ -95,7 +100,7 @@
```sh
mv /etc/config/netshift /etc/config/netshift-070
wget -O /etc/config/netshift https://raw.githubusercontent.com/yandexru45/netshift/refs/heads/main/netshift/files/etc/config/netshift
wget -O /etc/config/netshift https://uralgit.ru/ural/netshift/raw/branch/main/netshift/files/etc/config/netshift
# затем настроить заново через LuCI или UCI
```
@ -108,19 +113,34 @@ wget -O /etc/config/netshift https://raw.githubusercontent.com/yandexru45/netshi
Для установки и обновления достаточно одного скрипта:
```sh
sh <(wget -O - https://raw.githubusercontent.com/yandexru45/netshift/refs/heads/main/install.sh)
sh <(wget -O - https://uralgit.ru/ural/netshift/raw/branch/main/install.sh)
```
Интерфейс появится в LuCI: **Services → NetShift**.
<details>
<summary><b>Готовые community-списки</b></summary>
Готовые наборы доменов/подсетей, которые можно добавить в секцию через `community_lists` (в UI - чекбоксами). Списки обновляются автоматически:
`russia_inside` · `russia_outside` · `ukraine_inside` · `geoblock` · `block` · `porn` · `news` · `anime` · `youtube` · `hdrezka` · `tiktok` · `google_ai` · `google_play` · `hodca` · `discord` · `meta` · `twitter` · `cloudflare` · `cloudfront` · `digitalocean` · `hetzner` · `ovh` · `telegram` · `roblox`
```sh
uci add_list netshift.my_sub.community_lists='youtube'
uci add_list netshift.my_sub.community_lists='telegram'
uci commit netshift
```
</details>
<details>
<summary><b>Настройка подписки (Subscription URL) через UCI</b></summary>
При скачивании подписки отправляются заголовки:
Поддерживаются любые подписки (remnawave · 3x-ui · marzban · github) в форматах **base64 · список URI · Clash · Xray JSON**, в т.ч. **gzip-сжатые** ответы. При скачивании подписки отправляются заголовки:
| Заголовок | Значение |
|---|---|
| `User-Agent` | `singbox/<версия>` |
| `User-Agent` | подбирается автоматически (`singbox/<версия>` или клиентский, см. формат) |
| `X-HWID` | уникальный идентификатор роутера |
| `X-Device-OS` | `OpenWrt Linux` |
| `X-Device-Model` | модель роутера |
@ -136,26 +156,113 @@ uci add_list netshift.my_sub.community_lists='russia_inside'
uci commit netshift
```
Ручное обновление подписки:
**Несколько подписок** в одной секции - добавьте `subscription_url` списком (в UI - поле с «+»); все фиды скачиваются и объединяются в один набор узлов без дублей:
```sh
/usr/bin/netshift subscription_update
uci add_list netshift.my_sub.subscription_url='https://provider-a.com/sub'
uci add_list netshift.my_sub.subscription_url='https://provider-b.com/sub'
```
**Фильтр серверов** по ключевым словам - белый/чёрный список (регистр не важен, работает и по эмодзи):
```sh
uci add_list netshift.my_sub.subscription_filter_include='🇩🇪'
uci add_list netshift.my_sub.subscription_filter_exclude='trial'
```
**Группировка серверов** - собирает узлы в URLTest-группы и добавляет авто-выбор «⚡ Самый быстрый» среди всех групп (при ≥2 группах он же выбор по умолчанию; ручной выбор группы сохраняется):
```sh
# off | country (по флагу страны) | prefix (по первым N символам имени)
uci set netshift.my_sub.subscription_group_mode='country'
# для prefix: сколько первых символов имени брать (по умолчанию 2)
uci set netshift.my_sub.subscription_group_prefix_len='2'
```
**Предпочтительный формат** - для панелей, которые отдают нужные узлы (например xhttp / Hysteria2) только под определённым клиентом:
```sh
# auto | xray (Xray JSON, UA как у Happ) | singbox
uci set netshift.my_sub.subscription_format_preference='auto'
```
**Подписки по IP-хосту и «кривой» HTTPS** - можно указать подписку с IP вместо домена (например `https://22.23.43.52:2096/sub/xxxx`); для панелей с самоподписанным / несовпадающим сертификатом включите небезопасный TLS:
```sh
uci set netshift.my_sub.subscription_allow_insecure='1'
```
Ручное обновление подписки и очистка кеша:
```sh
/usr/bin/netshift subscription_update # перечитать и применить
# Очистка кеша всех подписок и повторное скачивание - кнопка во вкладке «Диагностика»
```
</details>
<details>
<summary><b>Ядро sing-box-extended (xhttp)</b></summary>
<summary><b>Менеджер компонентов: ядро sing-box-extended (xhttp) и самообновление</b></summary>
Переключение ядра между стабильным sing-box и сборкой **sing-box-extended** прямо из вкладки **Diagnostics** в LuCI:
Вкладка **Менеджер компонентов** в LuCI управляет NetShift и ядром sing-box в одном месте - три карточки: **NetShift** / **sing-box (stock)** / **sing-box (extended)**. Установленная версия видна сразу, статус (актуально / устарело / не установлено) и кнопка «Проверить обновление» - по нажатию.
- **Install extended** - установить расширенное ядро sing-box-extended.
**Переключение ядра** между стабильным sing-box и сборкой **sing-box-extended**:
- **Install extended** - расширенное ядро (даёт клиентский транспорт **xhttp**, только клиентский режим). Также поддерживается **VMess**.
- **Install stable** - вернуться на стабильное ядро.
После установки расширенного ядра становится доступен клиентский транспорт **xhttp** (только клиентский режим, не серверный). По умолчанию ставится стабильное ядро - extended включается по желанию.
Смена ядра безопасна: перед переключением проверяется и при необходимости чинится связь, делается бэкап; при сбое - **автооткат**, роутер никогда не остаётся без рабочего ядра. По умолчанию стоит стабильное - extended включается по желанию.
**Самообновление NetShift** - кнопка обновления прямо из веб-интерфейса: асинхронно, с бэкапом конфига, проверкой фактической версии после установки и без риска «окирпичивания». Русская локализация обновляется только если уже установлена.
</details>
<details>
<summary><b>Дополнительные настройки (IPv6, блокировка DoH, глобальный прокси, DNS через прокси)</b></summary>
Все опции - в секции `settings` (`0` - выкл, `1` - вкл):
```sh
# Полная маршрутизация IPv6 через туннель (v6 tproxy / DNS / FakeIP). По умолчанию выкл.
uci set netshift.settings.enable_ipv6='1'
# Блокировка DoH: клиенты в сети не обойдут DNS роутера через DNS-over-HTTPS
# (режет известные DoH-эндпоинты IPv4 + IPv6 на уровне маршрутов sing-box).
uci set netshift.settings.block_doh='1'
# Глобальный прокси: ВЕСЬ трафик через выбранный outbound (а не только избранное).
# Только при явном включении - иначе действует выборочная маршрутизация.
uci set netshift.settings.global_proxy='1'
# DNS через прокси (detour): DNS-запросы идут через туннель.
uci set netshift.settings.dns_via_outbound='1'
# Блокировать QUIC (заставляет приложения откатываться на TCP/TLS).
uci set netshift.settings.disable_quic='1'
uci commit netshift
```
> По умолчанию NetShift гонит в sing-box **только** проксируемые подсети/домены, остальное - напрямую (выборочная маркировка). Режим «весь трафик в туннель» включается **только** опцией `global_proxy`.
</details>
## История изменений
Полный список изменений по версиям - на странице [Releases](https://github.com/yandexru45/netshift/releases). Анонсы обновлений публикуются в [Telegram-канале](https://t.me/netshift_news).
Коротко о крупных вехах:
| Версия | Главное |
|---|---|
| **0.9.1** | Авто-выбор «⚡ Самый быстрый» среди групп (URLTest над URLTest'ами) |
| **0.9.0** | Меньше ошибок «лимит GitHub API» (обход через redirect-путь github.com); фикс старого `option subscription_url` |
| **0.8.9** | Универсальная группировка подписки (страна / префикс имени); поддержка gzip-подписок; фикс ложного «версия устарела» |
| **0.8.7-0.8.8** | Критфикс маршрутизации 2-й секции; выборочная маркировка (меньше нагрузки CPU); Hysteria2 + xhttp везде; несколько подписок; надёжное самообновление |
| **0.8.6** | IPv6 · блокировка DoH · вкладка «Менеджер компонентов» · самообновление · подписки по IP / небезопасный TLS · глобальный прокси · DNS через прокси · watchdog |
| **0.8.5** | VMess (extended) · надёжная смена ядра с автооткатом · фильтр серверов по ключевым словам · Xray JSON + автоподбор User-Agent |
| **0.8.0** | Переименование podkop → NetShift с авто-миграцией конфигов; sing-box-extended (xhttp) из веб-интерфейса |
## Project Structure
```
@ -188,7 +295,7 @@ uci commit netshift
## Build Artifacts
Пакеты собираются в Docker-образе OpenWrt SDK (24.10) и публикуются как релиз при push git-тега ([`.github/workflows/build.yml`](.github/workflows/build.yml)).
Пакеты собираются в Docker-образах OpenWrt SDK (`.ipk` - 24.10, `.apk` - 25.12) и публикуются как релиз при push git-тега ([`.github/workflows/build.yml`](.github/workflows/build.yml)).
| Пакет | Формат | Назначение |
|---|---|---|
@ -199,14 +306,14 @@ uci commit netshift
Локальная сборка:
```sh
# ipk (большинство устройств OpenWrt 24.10)
docker build -f Dockerfile-ipk --build-arg NETSHIFT_VERSION=0.8.0 -t netshift:ipk .
# ipk (OpenWrt 24.10, opkg)
docker build -f Dockerfile-ipk --build-arg NETSHIFT_VERSION=0.9.1 -t netshift:ipk .
# apk (новые сборки OpenWrt на apk)
docker build -f Dockerfile-apk --build-arg NETSHIFT_VERSION=0.8.0 -t netshift:apk .
# apk (новые сборки OpenWrt 25.12+, apk)
docker build -f Dockerfile-apk --build-arg NETSHIFT_VERSION=0.9.1 -t netshift:apk .
```
> Требуется sing-box >= 1.12.0 и jq >= 1.7.1 на целевом устройстве.
> Требуется sing-box >= 1.12.0, jq >= 1.7.1 и coreutils-base64 >= 9.7 на целевом устройстве.
## Star History

View File

@ -1052,3 +1052,400 @@ save+`sing-box check` -> cron jobs -> start sing-box -> dnsmasq_configure ->
["component_action_async","subscription","clear_cache"] in built main.js match;
smoke all 166/0 (11 new cc-case all green); 472 vitest; no yarn pollution. All
UNCOMMITTED, stacked with tasks 027..038 — operator commits manually.
## DIAGNOSED: self-update silently no-ops on opkg/ipk routers with a v-prefixed installed build (2026-06-11)
- USER (main router ssh root@192.168.1.1, OWRT 24.10.5 mediatek/filogic aarch64,
OPKG/ipk, Xiaomi AX3000T): NetShift self-update from the web UI reports
"updated to 0.8.7" but the installed version stays v0.8.6. Logs show the whole
self_update worker succeeding ("installing netshift-0.8.7-r1-all.ipk" ...
"NetShift updated to 0.8.7").
- ROOT CAUSE (empirically proven on the router): the installed build is the OLD
ipk that carried a leading `v` (constants `NETSHIFT_VERSION="v0.8.6"`, opkg
pkg version `v0.8.6-r1`). The new release is `0.8.7-r1` (no v, post task-028).
`opkg install <file.ipk>` REFUSES to "downgrade": it prints
`Not downgrading package ... from v0.8.6-r1 to 0.8.7-r1.` and RETURNS rc=0
(NOT an error for opkg). So updates_pkg_install_file (updater.sh:1410,
`opkg install "$f" >/dev/null 2>&1`) sees rc=0 -> the worker logs success and
emits {"success":true,...} while NOTHING was installed.
- WHY opkg thinks it's a downgrade: `opkg compare-versions "v0.8.6-r1" ">>"
"0.8.7-r1"` => rc=0 (TRUE). The leading `v` sorts ABOVE the digit, so
v0.8.6 > 0.8.7 in opkg's dpkg-style compare. Without the v,
`0.8.6-r1 << 0.8.7-r1` => true (correct). This is the packaging.md §3 / task-028
fragility realized: routers still running a pre-028 v-build can never self-update
to a no-v release.
- TWO distinct bugs to fix in updater.sh:
1. PRIMARY (silent success): `updates_pkg_install_file` swallows opkg's
"Not downgrading" no-op as rc=0. The install path never VERIFIES the
post-install version actually changed. FIX directions:
(a) opkg path add `--force-downgrade` (and/or `--force-reinstall`) so a
v->no-v transition actually installs; AND/OR
(b) post-install VERIFY: after install, re-read the installed pkg version
and compare to the target; if unchanged, treat as failure (honest JSON
{"success":false}) instead of reporting success. Verify-after-install is
the robust belt — opkg "already installed"/"not downgrading"/"up to date"
all return rc=0, so rc alone is NOT a reliable success signal on opkg.
2. CONTRIBUTING: the v-prefix legacy build. The compare in
_updates_self_update_netshift_core ALREADY v-strips both sides
(`${installed#v}` = `${latest#v}`, :1679) so it correctly decides "need
update"; the failure is purely at the opkg install step.
- MANUAL FIX applied on the router (recovery, verified): downloaded all 3 ipks
from the latest release and `opkg install --force-downgrade <file>` each ->
netshift + luci-app-netshift now 0.8.7-r1, constants NETSHIFT_VERSION="0.8.7"
(no v), get_system_info netshift_version 0.8.7. Future 0.8.7->0.8.8 upgrades
will work normally (both sides no-v). Note: install drops
/etc/config/netshift-opkg conffile-diff artifact (harmless; rm'd).
- apk SIDE NOTE (not the user's box but same helper): apk uses `-r` for release,
treats a dashed UPSTREAM version specially (task-034 landmine #A: equal-version
no-overwrite). The same verify-after-install belt would harden apk too. Whatever
fix is chosen must be tested on BOTH opkg and apk paths (packaging gate).
- LANDMINE for the fix: `updates_pkg_install_file` redirects stdout+stderr to
/dev/null, so the "Not downgrading" message is invisible — never rely on opkg
text; rely on rc PLUS an explicit post-install version re-check. opkg
compare-versions is available on-device for a robust numeric compare if needed
(but the installer should not need the leading v at all once verify is added).
## task-043 Xray subscription UA — versioned UAs (2026-06-11)
- USER reported: with subscription_format_preference=xray, the panel returns 502
and NetShift falls back to singbox/<ver> (sing-box JSON WITHOUT xhttp/hysteria2
— the original task-031 complaint). PRIVACY: operator required NO
subscription-identifying data anywhere (no panel host/IP/URL/path/query/server/
keys) in code/tests/comments/memory — only generic client UA strings are safe.
Diagnosis lived in the ephemeral SSH session only; never committed.
- ROOT CAUSE (reproduced on a live panel, abstractly): the Xray-probe constant
SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES was bare/version-less ("v2rayN Happ").
That panel UA-gates its Xray branch on a <client>/<version> shape: a BARE Happ
(and v2rayN in any form) → 502 Bad Gateway; a VERSIONED Happ/<x.y.z> → 200 with
the wanted Xray-JSON ARRAY (multi-profile). The download path/headers
(_wget_subscription_request, helpers.sh:803) are correct & unchanged — only the
UA VALUE decides 502-vs-200.
- DIAGNOSIS METHOD (reusable, privacy-safe): on the router, replay the exact
request per UA with `curl -A "$UA" -H <same X-* headers netshift sends> -w
"HTTP %{http_code}"` to isolate UA-vs-headers; the headers were identical for
all UAs, so the UA alone is the variable. Sniff the 200 body's FORMAT (first
char `[`/`{`, jq top-keys) to confirm it's the Xray-JSON array
(dns,inbounds,log,outbounds,remarks,routing per element) — NOT to read values.
Clean up /tmp on the router after.
- FIX (task-043, APPROVED W/ CONDITIONS, smoke 170/0): constants-only —
SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES -> versioned ("Happ/1.0.0 v2rayN/7.0.0
v2rayNG/1.9.0", versioned Happ FIRST). The xray-mode probe order in
build_subscription_user_agent_candidates (helpers.sh:765-771) front-loads these
before default+cached+whitelist, dedup whole-entry, so the working versioned
Happ wins first; non-working UAs just fall through. Coupled smoke
fb-caseI-xraypref-* updated to DERIVE expected first/second/third from the
constant (won't rot) + a generic guard that the first candidate contains "/"
(bare-UA regression catch). Operator REJECTED making it a UI option (overkill /
system-level) and rejected touching headers / auto-mode list.
- DEDUP NOTE: versioned "Happ/1.0.0" is DISTINCT from the bare "Happ" still in the
main auto-mode SUBSCRIPTION_USER_AGENT_CANDIDATES whitelist, so neither is
dropped by the whole-entry dedup. The bare v2rayN/Happ in the MAIN list still
502 on this panel but are only reached AFTER the versioned UAs already win, so
no wasted probes in practice. Did NOT change the main list (broader/auto-mode).
- The CONDITION (M1) is commit-hygiene only: keep the pre-existing unrelated
.opencode/agent/*.md churn (model rename + bash permission) OUT of the task-043
commit — not a code issue. Those were already in the tree at session start.
## task-044/045 Universal subscription grouper (2026-06-12)
- FEATURE: generalize the country-flag subscription grouper into modes
off/country/prefix. Operator wanted "group by first N chars of the remark, N
configurable, default 2"; chose Variant A (single mode dropdown off/country/
prefix + length, count by Unicode CODEPOINTS), full backend+frontend.
- ARCHITECTURE SEAM (reusable insight): the urltest-per-group + main-selector
tree builder in bin/netshift `subscription)` branch is mode-agnostic — only the
KEY EXTRACTOR + the on/off decision needed generalizing.
`sing_box_build_subscription_country_groups` -> `sing_box_build_subscription_
groups <tags_json> <mode> <prefix_len>` returning {group_order,groups,ungrouped}.
prefix key = `(.|explode)|.[0:$n]|implode` (codepoint slice, Unicode-safe, no
Oniguruma). country path kept byte-identical (regional-indicator gate). N=2
over a flag tag == the flag, so country is a consistent special case.
- LANDMINE CAUGHT IN REVIEW LOOP (architect spotted, not the dev): the inherited
group loop `for k in $(jq -r '.group_order[]')` WORD-SPLITS on IFS. Safe for
country flags (no spaces) but BREAKS prefix mode whose keys can contain spaces
(e.g. tag "A 1" prefix-2 -> key "A "). Fix: mktemp + `while IFS= read -r k ...
done < "$tmp"` in the CURRENT shell (NOT `cmd | while read` — that subshell
would lose the `config=`/`selector_outbounds_json=` mutations; same landmine
class as the smoke `cmd|while read` trap). Mirror the existing
`subscription_urls_tmp` mktemp+read<file pattern already in that branch. mktemp
failure must fatal+exit 1. ALWAYS audit reused loops when the key domain widens.
- prefix_len hardening: jq side `try tonumber catch default | <1 -> default |
floor`; shell side `case '' | *[!0-9]* -> default ;; *) [ x -ge 1 ] 2>/dev/null`.
short tag (<N) keys by whole self (NOT ungrouped); empty tag -> ungrouped.
- LEGACY MIGRATION done BACKEND-side (no JS migration): subscription_group_mode
present OUTRANKS legacy; absent -> read subscription_group_by_countries (then
alias group_by_countries) truthy->country else off. Frontend just writes the
new options. New constant SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN=2.
- FRONTEND: form.Flag -> form.ListValue subscription_group_mode (off/country/
prefix) + form.Value subscription_group_prefix_len (datatype and(uinteger,
min(1)), depends subscription_group_mode:"prefix"); both taboption() in the
`subscription` tab (tabbed-CBI completeness). types.ts old key removed (no TS
reader). 7 i18n msgids + RU, fe<->luci byte-identical. main.js NO diff
(type-only change + hand-written view) — so the rendered tab needs a HUMAN
visual check (carried as APPROVED-WITH-CONDITIONS [M1]).
- GATES: shellcheck error clean; smoke 170->174/0 (country regression kept +
prefix len-2/consistency/bad-len/space-key); whole-chain sing-box check PASS
for mode=prefix; yarn ci green (472 vitest), main.js no-diff. Both APPROVED
(045 with the human-eyeball condition). PRIVACY: synthetic tags only, full
diff privacy-scan clean. Subagents truncated their final messages TWICE this
session (frontend stopped before section.js; reviewer stopped mid-analysis) —
ALWAYS verify on-disk state + run/inspect gates yourself rather than trusting
a truncated "done".
## task-046 gzip subscription bodies — issue #13 (2026-06-12)
- ISSUE #13: a subscription panel sometimes returns a gzip-compressed body;
NetShift parsed the binary as text -> all nodes skipped -> "No subscription
User-Agent candidate produced valid outbounds". Reporter's workaround
gzip-decompressed only inside normalize_subscription_to_singbox — INSUFFICIENT
because validate_subscription_file runs BEFORE normalize and also chokes on a
gzipped sing-box JSON. Correct seam = DOWNLOAD path (decompress once, both
consumers see text).
- DEVICE FACTS (verified, OWRT 24.10 + 25.12): gzip/gunzip/zcat present
(busybox); zstd/unzstd ABSENT (would need a new DEPENDS pkg). wget sends NO
Accept-Encoding and does NOT transparently decompress, so gzip = server
unconditionally compressing. gzip magic 1f 8b; `gzip -dc` on non-gzip rc=1.
NO od/hexdump/xxd on device.
- OPERATOR DECISIONS: gzip ONLY (not zstd/deflate — separate future task if
panels actually send them); detect at download time; + NUL-byte guard (reject
still-binary body to next UA). Full backend+smoke.
- FIX (task-046, APPROVED W/ CONDITIONS, smoke 174/0): two helpers.sh helpers
(modeled on convert_crlf_to_lf, mktemp+mv, all local, best-effort return 0):
* maybe_gunzip_subscription_file <f>: ATTEMPT-DECOMPRESS detector (NO od) —
`gzip -dc f > tmp` accept ONLY if rc=0 AND non-empty AND NUL-free, else leave
original byte-identical (plain text makes gzip -dc rc!=0 -> untouched, can
never corrupt text). This doubles as the gzip detector, avoids byte-fiddling.
* subscription_body_is_binary <f>: NUL detector via `wc -c < f` vs
`tr -d '\000' < f | wc -c` (counts differ -> NUL). Portable, no special grep
flag, no od.
Wired into download_subscription_into_cache per-UA while-read loop AFTER
download_subscription, BEFORE validate (netshift:585-594): gunzip then if
binary -> warn + continue to next UA (same control flow as a validation fail).
file_size/debug log MOVED to after decompress so size reflects decompressed
body. NO wget/Accept-Encoding change, NO Makefile DEPENDS change.
- REUSABLE PATTERNS: file-transform helper = mktemp+transform+mv-on-success/
rm-on-fail (convert_crlf_to_lf is the template). NUL/binary detect without od =
wc-vs-tr-d-NUL byte-count. attempt-decompress is a clean od-free magic detector.
- CONDITION (M1) is commit-hygiene only: keep the pre-existing unrelated
.opencode/agent/*.md churn (model rename + `bash "*": ask`->`allow`) OUT of the
commit; the permission loosening is a separate human decision.
## task-047 false-"Outdated" — minified-JSON tag parse (2026-06-12)
- ISSUE (user, AX3000T, OWRT 25.12.2 apk, NetShift 0.8.8): "check for updates"
always says Outdated; "update" -> error; reinstall via Putty doesn't help. OCR
of the UI showed the "latest version" as a URL
`https://api.github.com/repos/yandexru45/netshift/releases/<id>` instead of a
version number.
- ROOT CAUSE (PROVEN by reproduction): updates_netshift_latest_tag
(updater.sh) parsed the tag with `grep '"tag_name":' | head -n1 |
cut -d'"' -f4`. That ONLY works on PRETTY-PRINTED JSON. GitHub API/CDN/proxies
often return MINIFIED JSON (whole object on one line) -> grep matches the
entire line, `cut -d'"' -f4` returns the FIRST key's value = the release
"url". So latest="<url>", `0.8.8 != <url>` -> false "outdated"; and the
self-update worker (same fn for $latest) logged "downloading NetShift <url>
release packages" then failed. Reinstall is useless: bug is in PARSING.
Repro: `printf '{"url":".../releases/338202209","id":...,"tag_name":"0.8.8",...}'
| grep '"tag_name":'|head -1|cut -d'"' -f4` => the url.
- WHY ONLY THIS FN: sing-box-extended path already uses jq (.tag_name); asset
download uses `grep -o "https://...\.(ipk|apk)"` (newline-agnostic, robust);
install.sh only rate-limit-greps + asset grep -o. All unaffected. jq is a hard
dep used 23x in updater.sh.
- FIX (task-047, APPROVED W/ CONDITIONS): replace the body with
`tag="$(printf '%s' "$response" | jq -r '.tag_name // empty' 2>/dev/null)";
[ -n "$tag" ] || return 1; printf '%s' "$tag"`. Format-independent; `.tag_name
// empty` -> empty on rate-limit/error object (callers already handle
"could not determine latest"). Fixes BOTH check_update and self-update (shared
fn). Callers untouched. No Oniguruma.
- TEST (smoke `latesttag`, registered main all)+case+usage): stub the network
boundary `updates_http_get_once` (NOT the fn under test), run the REAL
updates_netshift_latest_tag. Cases: MINIFIED (exact bug shape, url before
tag_name) -> 0.8.8 not url (regression guard); pretty -> 0.8.8; rate-limit ->
empty+nonzero; e2e minified through updates_check_netshift -> status latest.
set -e rc capture via `&& printf 0 || printf %s "$?"`. Guard self-proven.
- LESSON (reusable): NEVER field-position-parse JSON (`grep KEY|head|cut -d'"'
-fN`) — it silently breaks on minified responses by grabbing the first key's
value. Use jq (already a dep) for any tag/scalar extraction; reserve `grep -o
<url-regex>` for URL-list extraction (that IS newline-agnostic and fine).
- WHOLE-CHAIN verified on the user's class of box earlier: get_system_info keeps
latest="unknown" (no net); check_update is the on-demand fetch; the diagnostic
row + manager card flag outdated when installed != latest, so a bad latest
poisons BOTH UI spots — fixing the parse fixes all consumers.
- CONDITION [S1] is commit-hygiene: keep the pre-existing unrelated
.opencode/agent/*.md churn (model rename + `bash "*": ask`->`allow`) OUT of the
commit; the bash-permission widening is security-relevant and needs a separate
explicit human decision. (Recurring across tasks 043/046/047 — these 5 files
were already dirty at session start.)
## task-048 scalar option subscription_url — sing-box won't start (2026-06-12)
- ISSUE (Nick, Cudy WR3000E, OWRT 25.12.4, 0.8.9): urltest section works; switch
to subscription -> whole chain dead (sing-box not running, no nft table,
FakeIP 127.0.0.42:53 refused). Startup log: "Outbound section not found.
Aborted." despite config having a subscription_url.
- DIAGNOSIS METHOD: user sent 2x3 diagnostic txt (global_check/show_sing_box_config/
view_logs) for working vs broken. The view_logs filenames were SWAPPED (the
tiny 435B file was the broken run, the big one was the working urltest run) —
read by CONTENT not filename. Broken view_logs: single "Outbound section not
found. Aborted." line. global_check(broken): proxy_config_type 'subscription'
with `option subscription_url '...'` (scalar, NOT list).
- ROOT CAUSE (PROVEN on hardware): get_subscription_urls_for_section reads
subscription_url ONLY via config_list_foreach, which iterates ONLY UCI `list`
values and returns EMPTY for a scalar `option`. Proven: config_list_foreach
over option => []; config_get => the value; over list => works. The UI writes
`list` (form.DynamicList) so new configs are fine; legacy/CLI/podkop-migrated
configs use `option` and broke. Every subscription consumer funnels through
this ONE helper -> fixing it fixes the whole chain.
- FIX (task-048, APPROVED round 2): (1) load-bearing read-fallback in
get_subscription_urls_for_section: if the list read is empty, config_get the
scalar and feed it through _collect_subscription_url_handler. (2) one-time
idempotent option->list migration at top of start_main (after config_load,
before check_requirements), only on the broken shape, never exits.
- REVIEW LOOP (2 rounds, important): round 1 REQUIRES CHANGES — BLOCKER [B1]
data loss: the migration used `uci add_list "key=value"` which SPLITS ON THE
FIRST `=` and loses query-string URLs (?token=abc&x=1) — reproduced on hardware
(rc=1, list empty, scalar already deleted => URL gone on disk). Fix: use the
`uci_add_list <cfg> <sec> <opt> "<val>"` SHELL HELPER (separate-arg, preserves
=/&), delete-then-add with scalar RESTORE on add failure, flag gates the
commit. Plus [S1] the new test ran assertions on the RHS of a pipe (subshell
counter loss) so it didn't gate CI -> fixed to `while read < tmpfile`. Round 2
APPROVED.
- LESSONS (reusable): (a) NEVER trust user-supplied filenames for which-is-which —
read by content. (b) `config_list_foreach` does NOT read scalar options; any
list-option reader needs a scalar config_get fallback for back-compat with
legacy/CLI/migrated configs. (c) `uci add_list "k=v"` CLI form is unsafe for
values containing `=` — use the uci_add_list shell helper. (d) a smoke test
that pipes into `while read; pass/fail` does NOT gate CI (subshell) — the count
jump (178->190) when fixed is the tell. (e) RE-check the dev's memory note on a
fix round — it's often pre-fix and re-seeds the anti-pattern.
- PRIVACY: Nick's dump contained a real subscription URL; moved all 6 txt to
/tmp/opencode/nick-diag (out of git); code/tests/specs/memory use ONLY synthetic
https://example.com/sub. Final whole-tree sweep for the real host => clean.
- GATES: shellcheck -S error clean; smoke `all` 178->190/0 (the +12 are the now-
gating suburlopt tokens incl. 4 =-URL guards); whole-chain verified (option
config -> has_outbound_section TRUE -> gen -> sing-box check). Runtime contract
intact (UCI schema = back-compat repr normalization only).
## GitHub API rate-limit — research + task-049 (2026-06-12)
- HOW IT WORKS (official docs, verified): anonymous api.github.com = 60 req/HOUR
per IP (authed=5000). On 403/429 the body is {"message":"API rate limit
exceeded for <IP>..."} and headers x-ratelimit-remaining:0 + x-ratelimit-reset
(UTC epoch). GET /rate_limit shows budget and does NOT cost primary quota.
- WHY users hit it a lot: limit is PER IP. Routers behind CGNAT / shared ISP
IPs / shared-VPN egress share ONE IP's 60/hour with many strangers -> the
budget is often already drained by others. Not the user's fault; we're
anonymous so we can't raise it without a token.
- KEY LEVER (PROVEN on hardware, curl): github.com/<repo>/releases/latest is
served by the github.com FRONTEND, NOT the rate-limited API. It 302-redirects
to /releases/tag/<tag>. `curl -sI -o /dev/null -w '%{redirect_url}'` returns
`.../releases/tag/0.8.9` -> tag extracted WITHOUT touching api.github.com.
And github.com/<repo>/releases/download/<tag>/<asset> 302s to the CDN
(release-assets.githubusercontent.com) -> direct asset download, no API.
(We already use this redirect path for SRS_MAIN_URL = releases/latest/download.)
- BUSYBOX wget on-device is STRIPPED: no -S, no --max-redirect, can't read
Location/headers. So tag extraction MUST use curl (a hard DEPENDS: +curl),
via %{redirect_url} (or -w %{url_effective} with -L). updates_http_get_once
already prefers curl.
- ASSET NAMING is deterministic from the tag: ipk = netshift-<ver>-r1-all.ipk,
luci-app-netshift-<ver>-r1-all.ipk, luci-i18n-netshift-ru-<ver>.ipk; apk =
netshift-<ver>-r1.apk, luci-app-netshift-<ver>-r1.apk,
luci-i18n-netshift-ru-<ver>.apk. (<ver> = tag.) All 302 on the direct path.
- SCOPE DECISION (task-049): migrate the 3 NETSHIFT-repo touchpoints off
api.github.com -> redirect path: version check (updater.sh:1638
updates_netshift_latest_tag), self-update asset download
(updater.sh:1657 _updates_self_update_download_assets), and install.sh:259.
KEEP the sing-box-EXTENDED path (updater.sh:553 releases?per_page=30) on the
API + proxy-fallback: it genuinely needs the releases LIST (draft/prerelease
flags + per-arch asset selection) which a redirect can't give; it's also a
rarer, on-demand action. Proxy-fallback stays as the safety net there.
- ALSO: honor x-ratelimit-reset / show honest "GitHub limit, retry after HH:MM"
instead of generic error; optionally TTL-cache the latest tag. (Secondary.)
- itdoginfo's podkop historically had the SAME complaint class; this redirect
approach is the standard fix.
## task-049 CLOSED — APPROVED (2026-06-12)
- Implemented: redirect-first tag fetch (updates_github_resolve_redirect +
updates_netshift_latest_tag), deterministic asset-URL download
(updates_netshift_asset_filename + _updates_self_update_download_assets), and
install.sh redirect path (+ shared download_release_asset helper). API path
kept as graceful fallback everywhere; sing-box-extended path untouched.
- Gates: shellcheck -S error clean; smoke 190->196/0 (+6 ghredirect, gating);
self-proved. Tag is rejected if empty/`/`-containing (injection/traversal
guard) and only used quoted in URL strings (no eval) — reviewed safe.
- Review: 1 round, APPROVED. Only finding [M1] = a comment typo
(updates_http_get_once -> updates_download_to_file); fixed during review,
shellcheck re-clean, ghredirect re-run 6/0.
- Net result for the user complaint: the normal version-check/self-update/install
path no longer touches the 60/hr-per-IP api.github.com, so CGNAT/shared-IP
rate-limit errors should largely disappear; API remains the fallback.
## task-050 "Fastest" urltest-of-urltests — CLOSED, APPROVED (2026-06-13)
- RESEARCH (confirmed): sing-box urltest.outbounds = "list of outbound tags to
test" with NO type restriction -> a urltest CAN nest other urltest tags. At
runtime it probes each member THROUGH that member's dial path, so a nested
group-urltest member is measured via that group's currently-selected (fastest)
node => "fastest country/group" emerges correctly. Maintainer confirms nested
urltest runs (SagerNet/sing-box #2130; the failover caveat there is unrelated).
`sing-box check` accepts a urltest referencing urltest tags (PROVEN by the
smoke's real in-container check).
- FEATURE: when subscription grouping is ON (country/prefix) AND there are >=2
groups, build a top-level urltest "⚡ Fastest" (SB_SUBSCRIPTION_FASTEST_GROUP_TAG)
over the per-group urltests, PREPEND it to the main selector, make it the
selector DEFAULT. Specific groups + ungrouped stay selectable. ==1 group ->
skip the redundant nested layer (default = lone group). ==0 -> no fastest
urltest. OFF mode unchanged. Operator: this becomes the default-on-grouping.
- KEY IMPL DETAIL: capture group_tags_json from selector_outbounds_json BEFORE
ungrouped is appended, so the nested urltest nests ONLY group urltests (not raw
ungrouped nodes). Reuse the section's urltest knobs (interval/tolerance) — no
hardcoded aggressive cadence; documented that it adds one probe layer.
- NO FRONTEND CHANGE: the dashboard (getDashboardSections.ts) renders selector
members live from the Clash structure and shows each member's name verbatim
(only legacy `<section>-urltest-out` maps to _('Fastest')). The new deduped
"⚡ Fastest" tag (code==tag, not -urltest-out) renders raw like per-group
"🇷🇺 Fastest", urltests sort first so it leads + is selectable. main.js untouched.
- GATES: shellcheck -S error clean; smoke 196->202/0 (+6 fastest tokens, gating;
test calls pass/fail directly in the fn body, not a pipe-subshell). Real
`sing-box -c check` on the nested config passes (skips cleanly when sing-box
absent). Self-proved. Review 1 round APPROVED, no conditions.
- This is a natural extension of task-044/045 (universal grouper). Performance
caveat on record: nested probing = extra load; tunable via the section's
urltest_check_interval/tolerance.
## task-051 Text-list Selector/URLTest — issue #10 — CLOSED, APPROVED (2026-06-13)
- REQUEST (Naxeron #10 + killyourgod TG): paste many proxy links (one per line)
into a textarea -> build a Selector or URLTest. KEY FINDING: the backend ALREADY
built selector/urltest from a LIST of links (proxy_config_type selector/urltest
reading selector_proxy_links/urltest_proxy_links via `for link in $...`). The
only gap was the INPUT shape (DynamicList = add-one-by-one). The `url` type
already used a textarea (scalar proxy_string).
- OPERATOR DECISION: TWO NEW types `selector_text`/`urltest_text` (keep the
existing DynamicList ones), labels "Selector (text list)"/"URLTest (text list)",
textarea storing SCALAR options selector_proxy_links_text/urltest_proxy_links_text.
- BACKEND: refactored the duplicated per-link build loop into ONE shared helper
`_build_proxy_member_outbounds <section> <blob> <udp_over_tcp> <label>` used by
ALL FOUR branches. It MUTATES global $config in place + reports via globals
_member_outbound_tags/_member_default_outbound which it RESETS at the top of
each call (no stale leak across config_foreach sections — the key refactor
risk, verified safe). Line parsing: `for link in $blob` IFS-splits newlines +
spaces; trailing CR stripped (`cr=$(printf '\r'); link="${link%"$cr"}"`); blanks
skipped. Empty->fatal+exit1; all-unsupported->mark_section_outbound_unavailable.
section_has_configured_outbound + the missing-fields message updated.
- FRONTEND: 2 dropdown values + 2 textareas (taboption in the SAME tab as their
list-typed siblings: selector_text->connection, urltest_text->subscription);
urltest tuning fields (interval/tolerance/testing_url) got urltest_text
depends-twins. New validator validateProxyUrlList (split \n, trim/CRLF, skip
blanks, validateProxyUrl per line, 1-based line-context error) barrel-exported
-> main.validateProxyUrlList; 13-case vitest. 7 i18n msgids + RU, fe<->luci
byte-identical. main.js +28 (validator), fresh build = no further diff.
- PARALLEL EXECUTION: backend + frontend launched in parallel (disjoint files,
shared only by the UCI contract). Both subagents truncated mid-work (recurring
this session) -> resumed each + verified on-disk state myself.
- GATES: shellcheck error clean; smoke 202->218/0 (+16 textlist tokens, gating;
real in-container sing-box check for BOTH new types); yarn ci green (485
vitest); main.js freshly-built. Reviews: backend APPROVED; frontend APPROVED
WITH CONDITIONS ([C1] non-code: human visual check of the rendered tabs — no
browser in CI, main.js only carries the validator delta).
- REUSABLE: when a "new input type" request lands, FIRST check if the backend
already supports the underlying data (it often does) — the work may be purely
a FE input-shape change + a thin backend branch reusing the existing build.

View File

@ -64,3 +64,12 @@ append recurring findings; keep under ~200 lines.
- Frontend barrel exposure: anything added to src/helpers/index.ts (or any export* barrel reaching main.ts) AND actually used appears in the generated main.js baseclass.extend block as a main.* symbol; unused re-exports get tree-shaken. So internal-only helper + added to barrel + used = it WILL leak to main.*. To keep a helper truly internal, place it in the consuming module, not the barrel.
- OpenWrt jq ascii_downcase only folds ASCII A-Z; case-insensitive matching on Cyrillic/Unicode names needs an inline codepoint fold (explode/map/implode: ASCII 65-90 +32, Cyrillic 1040-1071 +32, Yo 1025->1105). When reviewing such a fold: (a) already-lowercase ranges excluded (no double-fold), (b) def before first use when the program does NOT import helpers.jq, (c) a pure-emoji-keyword exact-match test proves non-folded codepoints pass through unchanged on both sides. (task-010)
- Package-manager rc is NOT a reliable success signal on opkg: rc=0 for "Not downgrading"/"already installed"/"up to date". A self-update/install that trusts only rc silently no-ops (the v→no-v rename trap: legacy `v0.8.6` sorts ABOVE `0.8.7` in opkg's compare, so `opkg install` refuses the "downgrade" and returns 0). When reviewing a package-install path, require: (a) `--force-downgrade --force-reinstall` on the opkg branch (apk overwrites by default); AND (b) verify-after-install — RE-READ the installed version (opkg `list-installed | grep "^pkg "`, apk `list --installed`; grep/awk only, NO Oniguruma jq) and compare v-stripped semver (`${x#v}`, `${x%%-*}`) with `==` OR `is_min_package_version installed target`; empty-installed must fail-safe to success:false. Keep install.sh `pkg_install` and updater.sh `updates_pkg_install_file` opkg branches ALIGNED. (task-041/042)
- Async self-update worker landmine: the `_*_core` worker MUST `return 1` (NEVER `exit`) on failure so the public wrapper's always-run `updates_restore_after_swap` epilogue + finished-job-state write still execute. Verify the wrapper captures core rc/JSON to a temp file then unconditionally restores. Smoke assertions for these must be in the MAIN shell body (direct `if…pass/fail`), never inside `cmd | while read` (subshell swallows PASS/FAIL — harness-wide landmine). (task-041)
- UCI option→list rewrites: the `uci add_list "key=value"` CLI form splits on the FIRST `=` and SILENTLY LOSES query-string URLs (`?token=abc&x=1`) — reproduced on hardware (rc=1, list empty). Require the `uci_add_list <cfg> <sec> <opt> "<val>"` SHELL HELPER (separate-arg, preserves `=`/`&`). For delete-then-add rewrites, verify a failed add RESTORES the scalar AND that the change-flag gates the `uci commit` (an uncommitted in-memory delete must never persist). (task-048 [B1])
- When RE-reviewing a fix round, also diff the developer's MEMORY note: it is frequently written against the PRE-fix code and re-seeds the very anti-pattern that was just fixed (task-048 [M2]: note still showed the `key=value` form + "non-gating piped-while" after both were fixed). Flag a stale memory note as a (minor) condition.
- Test-gating landmine: a smoke test whose assertions run on the RHS of a pipe (`cmd | while read; pass/fail`) does NOT gate CI (subshell counter loss) — a FAIL token prints red but the suite exits 0. Require current-shell parsing (`while read < tmpfile`). The 178→190 count jump when task-048 fixed this is the tell. (task-048 [S1])
- Rate-limit avoidance via redirect path (task-049): version-check/self-update/install can read the latest tag from `github.com/<repo>/releases/latest` (302 -> /releases/tag/<tag>, served by the github.com FRONTEND, NOT the 60/hr-per-IP api.github.com) instead of the API. Tag extracted with `curl -sI -o /dev/null -w '%{redirect_url}'` then `case`/param-expansion `${r##*/releases/tag/}` — when reviewing such code REQUIRE: (a) the tag is rejected if empty OR `/`-containing (path-traversal/injection guard) via `case "$tag" in ''|*/*) tag="" ;;`; (b) the tag is only used quoted inside a URL string / passed quoted to helpers, never `eval`'d or used as a bare filesystem path; (c) curl-absent / non-match degrades to the API fallback (no hard-fail/exit); (d) the file-download helper uses `curl -fsSL`/`-L` so the CDN 302 on `releases/download/<tag>/<asset>` is followed. busybox wget on-device is STRIPPED (no -S/--max-redirect/header read) so redirect reading MUST use curl (hard +curl dep). Keep the sing-box-EXTENDED releases-LIST path on the API (a redirect can't give draft/prerelease/per-arch).

View File

@ -811,3 +811,114 @@ append findings; keep under ~200 lines.
task-039 backend changes (netshift bin, updater.sh, tests/entrypoint.sh) +
.opencode/agent edits — NOT mine. FLAG (no browser): button render + toast
sequence verified by reasoning + the gate, NOT screenshotted.
## task-045 — universal subscription grouper (mode dropdown + prefix length)
- REPLACED the single `subscription_group_by_countries` form.Flag (section.js
~190-201) with TWO taboptions in the SAME `subscription` tab (mandatory —
tabbed section, a plain option() renders nothing):
(1) `form.ListValue subscription_group_mode` — values off/country/prefix
(`_("Off")`/`_("By country flag")`/`_("By name prefix")`), `o.default="off"`,
`o.rmempty=false`, depends `{connection_type:"proxy",proxy_config_type:
"subscription"}`; title `_("Subscription grouping")` + single-literal help.
(2) `form.Value subscription_group_prefix_len` — title `_("Prefix length")`,
`o.default="2"`, `o.datatype="and(uinteger,min(1))"`, `o.rmempty=false`,
depends ADDS `subscription_group_mode:"prefix"` (3-key object) so it shows
ONLY when mode=prefix. CBI cross-field depends within the same section/tab
works fine; a fully-hidden field is OK.
- CROSS-LAYER CONTRACT (task-044 backend, DONE): UCI options EXACTLY
`subscription_group_mode` ∈ {off,country,prefix} default off, and
`subscription_group_prefix_len` positive-int string default 2 (meaningful
only when mode=prefix). Backend falls back to the LEGACY
`subscription_group_by_countries` boolean ONLY when the new option is ABSENT
→ the UI writes only the NEW options; NO JS migration written.
- types.ts: swapped `subscription_group_by_countries?: '0'|'1'` →
`subscription_group_mode?: 'off'|'country'|'prefix'` +
`subscription_group_prefix_len?: string`. Grepped src first — NOTHING in TS
reads the old key (only the type decl), so removing it is safe (backend reads
the legacy UCI key directly, not via UI). Pure type-only → erased at build.
- main.js: ZERO diff (section.js hand-written + not bundled; types.ts type-only).
md5 unchanged across the build (aa89dfc5…). Confirmed via
`git diff --exit-code main.js`. This is the EXPECTED/correct outcome — a diff
there would mean an unexpected src change.
- i18n: ran `node {extract-calls,generate-pot,generate-po ru,distribute-
locales}.js` (yarn classic 1.22.22, but used node to avoid corepack). msgid
delta = clean SWAP: removed 2 (`Group by countries` + its long description),
added 7 (Off / By country flag / By name prefix / Subscription grouping /
Prefix length / the grouping description / the prefix-length description).
Filled 7 RU msgstr in SOURCE locales/netshift.ru.po then re-ran distribute →
po/ru + po/templates byte-identical to source (diff -q both pairs). 0 empty
non-header msgstr after. RU: Off→Выключено, By country flag→По флагу страны,
By name prefix→По префиксу имени, Subscription grouping→Группировка подписки,
Prefix length→Длина префикса.
- yarn ci GREEN: format no-diff, eslint --max-warnings=0, vitest 472 pass, tsup
build. yarn.lock unchanged, no .yarn/.yarnrc.yml. No new vitest (no new pure
TS logic — datatype validation is LuCI client-side).
- PRIVACY: no subscription-identifying data (hosts/IPs/URLs/keys/node names) in
any code/comment/i18n/test/memory — generic "proxy name"/"country flag"
wording only.
- FLAG (no browser in env): the rendered Subscription tab (dropdown +
conditional prefix-length field appearing only on mode=prefix, taboption
auto-hide) needs a HUMAN VISUAL CHECK before merge — verified structurally
only (taboption completeness, depends preserved).
## task-051 — text-list Selector/URLTest (paste links, one per line)
- CROSS-LAYER CONTRACT (backend done first): proxy_config_type values
`selector_text` / `urltest_text`; scalar UCI options (textarea, one link per
line) `selector_proxy_links_text` / `urltest_proxy_links_text`. Matched VERBATIM.
- section.js (HAND-WRITTEN, NOT bundled → 0 main.js diff): (a) 2 new
`o.value("selector_text",_("Selector (text list)"))` /
`o.value("urltest_text",_("URLTest (text list)"))` after the `urltest` value.
(b) 2 `form.TextValue` textareas modelled on the `url`-type `proxy_string`
one (`o.textarea=true; o.rows=5; o.wrap="soft"; o.rmempty=false`): placed
`selector_proxy_links_text` in the **connection** tab next to the existing
`selector_proxy_links` DynamicList, and `urltest_proxy_links_text` in the
**subscription** tab next to `urltest_proxy_links` (mirror the tab each
list-typed sibling already lives in — they differ!). Each `o.validate` calls
`main.validateProxyUrlList`.
- URLTEST-TWIN GATING: the 3 urltest tuning fields (urltest_check_interval,
urltest_tolerance, urltest_testing_url) each had `depends urltest` + `depends
subscription`; added a 3rd `o.depends({connection_type:"proxy",
proxy_config_type:"urltest_text"})` to each (CBI ORs depends). DID NOT touch
`enable_udp_over_tcp` (gated on `connection_type:"proxy"` only → already shows
for urltest_text) nor the subscription-only grouping/filter fields. The
`urltest_proxy_links` DynamicList itself stays urltest-only (its text variant
is the NEW separate field) — grep `proxy_config_type:"urltest"` leaves exactly
4 hits: the DynamicList + 3 tuning fields.
- NEW VALIDATOR `validateProxyUrlList(value:string):ValidationResult` — splits on
`\n`, `.trim()` each line (so CRLF `\r` is stripped), skips blank lines, runs
the EXISTING `validateProxyUrl` per line, returns first failure as
`{valid:false, message:`${_('Line')} ${i+1}: ${msg}`}` (1-based incl. blank
lines in the count) or `{valid:true,message:''}`. Empty/blank-only →
`_('At least one proxy link must be specified.')`. ValidationResult REQUIRES
`message:string` so valid branch sets `message:''`. BARREL-EXPORTED via
`validators/index.ts` (`export * from './validateProxyUrlList'`) → reaches
`main.validateProxyUrlList`. This is an EXPORTED leaf (NOT dispatcher-only like
validateHysteria2Url/validateVmessUrl) because section.js calls it directly.
- main.js: EXPECTED +28-line diff (the bundled validator fn + 1 export-block
entry). Export-symbol set delta vs HEAD = EXACTLY `+ validateProxyUrlList`
(no leak). Build IDEMPOTENT (md5 e5273ea1… across 2 builds), banner +
`return baseclass.extend({` intact. The regenerated main.js IS the deliverable.
- TEST `validators/tests/validateProxyUrlList.test.js`: table-driven describe.each
(valid blobs incl. CRLF/blank-line/whitespace; invalid incl. empty/unsupported/
garbage) + line-number-context assertions. SS fixture is the KNOWN-VALID
`ss://2022-blake3-aes-256-gcm:dmCly/…=@127.0.0.1:27214?type=tcp` form copied
from validateShadowsocksUrl.test.js (do NOT invent base64 that may fail). VLESS
fixture copied from validateVlessUrl.test.js. 13 tests; total 485 pass.
- i18n: 7 NEW msgids (Selector (text list); URLTest (text list); Selector Proxy
Links (one per line); URLTest Proxy Links (one per line); the shared scheme-doc
desc "…links — one per line"; "Line"; "At least one proxy link must be
specified."). RU filled in SOURCE locales/netshift.ru.po then distribute →
po/ru + po/templates byte-identical (diff -q). msgid count 352→359 purely
additive. Ran `node {extract-calls,generate-pot,generate-po ru,distribute}.js`
(generate-pot needs git user.name set). 1 empty msgstr remains = header only.
- PRIVACY: all link strings synthetic (`127.0.0.1` hosts + scheme-doc literals);
no real proxy/subscription data anywhere.
- GATES GREEN: prettier --write src (all unchanged → no format churn beyond my
files), eslint --max-warnings=0, vitest 485 pass, tsup build. yarn classic
1.22.22 → ran via node_modules/.bin; yarn.lock unchanged, no .yarn/.yarnrc.yml.
- FLAG (no browser in env): the rendered Connection/Subscription tabs (2 new
dropdown choices, the 2 textareas appearing only for their type, the urltest
tuning fields now appearing for urltest_text) need a HUMAN VISUAL CHECK —
verified structurally only (taboption completeness, depends grep).

View File

@ -105,3 +105,12 @@ artifacts out of the container -> **ipk underscore->dash rename**
unsupported; needs >=15 MB on `/overlay`; NO uninstall path (removal lives in
package `prerm`). GitHub API rate-limit is a known fragility (wget path has no
guard).
- `pkg_install` opkg branch uses `opkg install --force-downgrade
--force-reinstall "$pkg_file"` (task-042). Plain `opkg install` silently
no-op'd (rc=0) when re-run on a router with an older build: the legacy
v-prefixed version (`v0.8.6-r1`) sorts ABOVE the no-v release (`0.8.7-r1`) so
opkg "won't downgrade", and equal versions report "up to date". Both force
flags make opkg remove+reinstall (proven on OWRT 24.10.5 aarch64). apk branch
unchanged — `apk add --allow-untrusted` overwrites by default. This is the
install.sh twin of the task-041 `updates_pkg_install_file` updater.sh fix;
keep both upgrade paths (README script + in-app self-update) aligned.

View File

@ -1334,3 +1334,398 @@ findings; keep under ~200 lines.
"$out"` so counts are EXACT). Pre-existing `rh-case1/2/6:FAIL` red marks persist
(task-031 piped-while quirk; suite EXIT=0). No sacred value/port/mark/path/ACL/
frontend/async-machinery/download-guard change.
## task-041: self-update verify-after-install (opkg silent no-op fix)
- **Root cause (proven on router):** opkg returns rc=0 for "Not downgrading"/
"already installed"/"up to date". A legacy v-prefixed build (`v0.8.6-r1`) sorts
ABOVE the no-v target (`0.8.7-r1`) in opkg's dpkg-style compare, so a plain
`opkg install` refuses AND returns rc=0. The self-update worker trusted rc and
emitted `{"success":true}` while NOTHING installed. **opkg install rc is NOT a
reliable success signal** — the only robust check is to RE-READ the installed
version after the call.
- Fix #1 (`updates_pkg_install_file` opkg branch): `opkg install
--force-downgrade --force-reinstall "$pkg_file"`. `--force-downgrade` lands the
v→no-v transition; `--force-reinstall` covers the same-exact-version no-op. apk
branch unchanged (`apk add --allow-untrusted`; apk overwrites by default).
- Fix #2 new helper `updates_pkg_installed_version <pkg>` (mirrors
`updates_pkg_is_installed`/`_candidate_version` but reads INSTALLED not feed):
opkg `list-installed | grep "^<pkg> " | head -n1 | awk -F' - ' '{print $2}'`;
apk `list --installed <pkg> | awk '{print $1}' | head -n1` then strip `<pkg>-`
prefix via `${line#"$pkg"-}`. grep/awk only, NO Oniguruma.
- Fix #3 verify-after-install belt in `_updates_self_update_netshift_core` (after
the install loop, BEFORE the defensive restore + success cleanup): re-read the
CORE installed version, normalize with the SAME rules the version-decision uses
(`${x#v}` then `${x%%-*}` to drop `-rN`), and gate on
`[ "$inst_semver" != "$latest_semver" ] && ! is_min_package_version
"$inst_semver" "$latest_semver"` (i.e. fail unless installed == target OR
installed >= target). On fail: `_updates_self_update_restore_config
"$backup_made"` + `rm -rf "$UPDATES_NETSHIFT_DOWNLOAD_DIR"` + `updates_log ...
"error"` + `echo '{"success":false,"message":"NetShift core package did not
upgrade ...; configuration preserved"}'` + **`return 1` (NEVER exit** — async
worker; exit skips the wrapper's `updates_restore_after_swap` epilogue + the
finished-job-state write). Only a verified change reaches the existing success
JSON. Gates the CORE pkg only (luci/ru stay non-critical).
- `is_min_package_version current required` returns 0 when `current >= required`
(it's a `sort -V | head -1 == required` test) — so call it as
`is_min_package_version "$installed" "$target"`.
- All new vars declared at the TOP of the core fn (`core_installed
core_installed_semver latest_semver`) — shellcheck `-S error` clean.
- **Smoke (test_self_update_netshift, alias `selfupdate`, no new top-level test):**
extended the fake opkg stub: `install` arm now skips leading `--*` flags
(`while ...; case --*) shift;; *) break;; esac`) so `$1` is the file path even
with the new force flags; on a REAL success it rewrites the `netshift -` line in
`$SU_INSTALLED_LIST` to `$SU_TARGET_INSTALLED_VER` (so verify passes) UNLESS
`$SU_NOOP` is set (then list keeps the OLD version → simulates "Not
downgrading"). luci-* files excluded from the rewrite via nested case. Each
scenario now seeds `installed.list` with `netshift - 0.8.0-r1` (the install arm
mutates it, so reset per-scenario). New Scenario 6 (`SU_NOOP=1`, all other
markers ok): asserts `selfupdate-noop-detected-successfalse` (success:false),
`-noop-install-attempted`, `-noop-config-intact`, `-noop-download-dir-cleaned`.
Happy path Scenario 3 still `success:true version 0.8.1` (verify passes because
the stub reports the target post-install). SELF-PROVED the guard: temporarily
prefixed the verify `if` with `false &&` → `selfupdate-noop-detected-successfalse`
FAILED (worker falsely reported success on the no-op), then restored → passes.
- shellcheck -S error clean (bin+libs+install.sh); `smoke-tests all` = 170 passed
/ 0 failed (166 baseline + 4 new no-op assertions; selfupdate category 13→17).
No constants.sh / frontend / async-machinery / version-decision(:1679) /
sing-box-install-path / sacred-value change. apk path: the same verify belt
covers apk's equal-version no-overwrite quirk (reasoned; opkg covered in smoke).
## task-043 — versioned Xray-JSON probe User-Agents (constants-only)
- Finding (abstract, no identifiers): some panels gate their Xray-JSON branch on
a `<client>/<version>` UA SHAPE. A bare/version-less UA can be rejected (server
502); a VERSIONED UA of the same client yields the wanted Xray-JSON array body
(the multi-profile format `xray_json_to_uri_lines` parses, carrying
xhttp/hysteria2). Fix = make the probe send versioned UAs first.
- `SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES` (constants.sh) is the ordered set
probed FIRST in `xray` format-preference mode by
`build_subscription_user_agent_candidates` (helpers.sh:765-771;
order = XRAY_CANDIDATES → default(singbox/<ver>) → cached-winner → main
whitelist, deduped). Changed it from bare `"v2rayN Happ"` to versioned
`"Happ/1.0.0 v2rayN/7.0.0 v2rayNG/1.9.0"` (versioned client first). The
separate auto-mode `SUBSCRIPTION_USER_AGENT_CANDIDATES` (still has bare names)
and the request headers in `_wget_subscription_request` were untouched — only
the UA VALUE decides the 502-vs-200 outcome, no header change needed.
- Coupled smoke test: `fb-caseI-xraypref-*` (tests/entrypoint.sh CASE I, run via
the `subscription` category) hardcoded the old bare literals. Rewrote them to
DERIVE the expected first/second/third candidates from
`$SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES` (sourced in the CASE-I subshell via
`set -- $SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES`) so a future version bump
won't rot the test. Added one generic guard `fb-caseI-xraypref-first-versioned`
(`case "$first" in */*)`) so we never regress to a bare UA. The auto-mode
`fb-caseI-auto-has-v2rayN` assertion is unrelated (tests the auto whitelist).
- shellcheck -S error clean (entrypoint.sh is OUT of the lint scope — only
bin/lib/install.sh). `smoke-tests all` = 170 passed / 0 failed (same total
before+after; the standalone `subscription` category went 86→87 from the +1
guard, but the aggregate `all` "Results:" counter reported 170 either way — a
harness counting quirk, not a regression; all four `fb-caseI-xraypref-*` lines
print :OK in both runs). No ports/marks/paths/schema touched; runtime contract
intact.
## task-046 — gzip subscription body decompress + NUL guard (issue #13)
- Some panels return a gzip-compressed HTTP body unconditionally; busybox wget
does NOT transparently decompress and NetShift sends no Accept-Encoding, so the
raw bytes are binary and validate/normalize choke ("No subscription User-Agent
candidate produced valid outbounds").
- Added two best-effort helpers to `helpers.sh` (next to `convert_crlf_to_lf`):
- `maybe_gunzip_subscription_file <f>`: attempt-based detection (no
od/hexdump/xxd — none on device). `gzip -dc` (busybox built-in) into a
mktemp; accept ONLY if rc=0 AND result non-empty AND NUL-free, then `mv` into
place (else `rm`). `gzip -dc` on plain text returns rc!=0 cleanly, so plain
text is left byte-for-byte untouched — never corrupts text. Always returns 0.
- `subscription_body_is_binary <f>`: returns 0 (true) if file has a NUL byte.
Busybox-safe, no od: compare `wc -c < f` to `tr -d '\000' < f | wc -c` (differ
⇒ had NUL). All vars local.
- Wired in `bin/netshift` `download_subscription_into_cache` right after a
successful `download_subscription` (now ~:590-591), BEFORE
`validate_subscription_file`: call `maybe_gunzip_subscription_file`, then if
`subscription_body_is_binary` log a warn and `continue` (inside the per-UA
`while read` loop → falls to next UA, same flow as a validation failure). The
file_size/debug log stays AFTER so the logged size reflects the decompressed
body. mv/cache-persist below unchanged.
- NO Accept-Encoding/wget change, NO Makefile DEPENDS change (gzip/gunzip/zcat
are busybox built-ins), NO schema/ports/marks/frontend. zstd/unzstd are NOT on
device — gzip-only by design; deflate/zstd are a future task if panels send
them.
- Smoke: extended the `test_subscription` fb harness (sources helpers via the
facade) with synthetic-only fixtures: caseP gzip→text (cmp byte-equal),
text-passthrough (cmp unchanged), gzip→validate (validate_subscription_file
passes); caseQ `printf 'abc\000def'`→binary true, plain text→false. The smoke
container has gzip (tests/Dockerfile apk add gzip). No new test_* fn — folded
into existing test_subscription, so no main()/case/usage registration needed.
- Gates: shellcheck -S error clean on bin + all libs + install.sh; `smoke-tests
all` 174 passed / 0 failed (aggregate counter folds the 5 new tokens into the
test_subscription header group — count unchanged, the 5 fb-caseP/Q :OK lines
print explicitly in the `subscription` run).
## task-047 — latest-tag jq parse (false "Outdated" fix)
- `updates_netshift_latest_tag` (`updater.sh:~1635`) used
`grep '"tag_name":' | head -n1 | cut -d'"' -f4`. That ONLY works on
pretty-printed JSON. On MINIFIED GitHub JSON (whole object on one line, `"url"`
before `"tag_name"`), grep matches the whole line and `cut -f4` returns the
FIRST key's value = the release `"url"` → false "outdated" + self-update
downloads a garbage "version". Fix: `jq -r '.tag_name // empty'`
(format-independent; `// empty` → empty on rate-limit/error objects; no
Oniguruma). jq is a hard dep (used 23× in updater.sh) and the sibling
sing-box-extended path already used `.tag_name`.
- LESSON (whitespace-fragile grep|cut on JSON): never field-position-`cut` JSON
that may be minified. Prefer jq when it's already a dep.
- New smoke test `test_netshift_latest_tag` (token `latesttag`): driver sources
updater.sh, stubs ONLY the network boundary `updates_http_get_once` (NOT the
parse fn), runs the REAL `updates_netshift_latest_tag`. 4 cases: minified→tag
(regression guard), pretty→tag, rate-limit→empty+nonzero, e2e via
`updates_check_netshift`→status "latest". Registered in main() all)+case+usage
+docker-compose.yml comment.
- GOTCHA: under harness `set -e`, a stub-driver `ash "$drv"` that returns
non-zero (the rate-limit case) aborts the whole suite — guard rc capture with
`ash ... && printf 0 >rc || printf $? >rc`.
- Guard self-proven: restoring the old grep|cut line made
`latesttag-minified-returns-tag-not-url` FAIL (returned the .../releases/<id>
url), then restored jq. Gates: shellcheck -S error clean (bin + libs +
install.sh); `smoke-tests all` 174→178 passed / 0 failed (+4 latesttag).
## task-048: scalar `option subscription_url` read-fallback + option->list migration
- Root cause (PROVEN on hardware, Cudy WR3000E / OWRt 25.12.4 / NetShift 0.8.9):
a section storing `option subscription_url '<url>'` (legacy / CLI /
podkop-migrated configs) made `get_subscription_urls_for_section`
(`bin/netshift`) return EMPTY → `has_outbound_section` false → "Outbound
section not found. Aborted." → sing-box never starts → whole chain down (no nft
table, FakeIP 127.0.0.42:53 refused). `config_list_foreach` iterates ONLY UCI
`list` values; over a scalar `option` it iterates NOTHING. `config_get` reads
the scalar. Regression from task-022 (multi-URL feature made subscription_url a
list / form.DynamicList); the task-022 memory note "a lone legacy option reads
as a 1-element list — NO migration code" was the FALSE assumption that shipped
the bug. EVERY subscription-URL reader funnels through this one helper.
- Fix 1 (load-bearing, single source): in `get_subscription_urls_for_section`,
AFTER the `config_list_foreach`, if `SUBSCRIPTION_URLS_COLLECTED` is still
empty, `config_get scalar_url "$section" "subscription_url"` and (if non-empty)
`_collect_subscription_url_handler "$scalar_url"` (reuse the handler so
dedup/format stays identical). All new vars `local`. Must stand alone on
read-only fs / when migration is skipped. Corrected the false comment at
`section_has_configured_outbound` (subscription branch) and the collector
header.
- Fix 2 (hygiene, idempotent): `migrate_legacy_subscription_url_option` +
`_migrate_legacy_subscription_url_option_handler` (config_foreach callback).
Detects the broken shape robustly: LIST read empty AND scalar config_get
non-empty (an already-correct list is never touched). Rewrites via
`uci -q delete netshift.<sec>.subscription_url` then the `uci_add_list netshift
"$sec" subscription_url "$url"` SHELL HELPER (from /lib/functions.sh) — NOT the
`uci add_list "key=value"` CLI form, which splits on the first `=` and SILENTLY
LOSES query-string URLs (`?token=abc&x=1`) [code-review BLOCKER B1, reproduced
on hardware: CLI add_list rc=1, list empty, scalar already deleted => URL lost
on disk]. On add_list FAILURE the else branch `uci_set`s the scalar back so a
failed migration never leaves the section with NO url (and the flag stays 0 =>
no commit => uncommitted in-memory delete never persists; on-disk URL survives).
sets a module-level flag `SUBSCRIPTION_URL_OPTION_MIGRATED`; a SINGLE `uci commit netshift` +
`config_load "$NETSHIFT_CONFIG"` only if anything changed (mirrors the
:956/:1099 commit+reload). NEVER exits — uci failures log `warn` and continue
(the read-fallback covers correctness). Invoked ONCE at the TOP of `start_main`
BEFORE `check_requirements` (which reads URLs via has_outbound_section), AFTER
the file-scope `config_load`.
- Smoke landmine confirmed (again): the multi-url `test_subscription` harness
STUBS `config_list_foreach` (feeds MU_URLS) and does NOT touch real UCI / does
NOT stub `config_get` — so it can NEVER catch this bug (it bypasses the broken
primitive). The regression guard MUST be a REAL-UCI test that `config_load`s a
fixture and runs the SHIPPED (awk-extracted) functions.
- New top-level smoke test `test_sub_url_option` (alias `suburlopt`). 12 tokens:
`suburlopt:scalar-read` (regression guard — empty before fix),
`:scalar-hasoutbound`, `:list-read` (no-regression), `:migrate-flag`,
`:migrate-value`, `:migrate-islist`, `:migrate-idempotent`,
`:migrate-idempotent-value`, PLUS the `=`-URL [B1] guards
`:migrate-equrl-preserved`, `:migrate-equrl-islist`, `:migrate-equrl-single`
(asserts exactly 1 list element), `:migrate-idempotent-equrl` — fixture URL
`https://example.com/sub?token=abc&x=1`. The driver output is parsed in the
CURRENT shell (temp file + `while read < "$out"`, NO pipe) so the tokens
ACTUALLY GATE CI (fixed the harness-wide piped-while counter-quirk for this
test). Migration is tested against a throwaway
`/etc/config/netshift` (the function hardcodes the `netshift` config name) with
NETSHIFT_CONFIG=netshift; the caller backs up + restores any real one. Skips
cleanly if /lib/functions.sh or uci unavailable. Registered in all)+case alias+
"Available:" usage line + docker-compose.yml comment. Synthetic
`https://example.com/sub` ONLY (operator privacy rule: a user dump leaked a
real URL — never write a real subscription URL/host/id anywhere).
- Self-prove DONE: removing the read-fallback block made `suburlopt:scalar-read`
go empty (its `:OK` disappeared) and `suburlopt:scalar-hasoutbound:FAIL`
appeared; restored and all tokens green again. Also self-proved [B1]: the old
`key=value` CLI add_list made `:migrate-equrl-preserved` FAIL (URL lost);
`uci_add_list` helper fixed it.
- Whole-chain verified in-container: `has_outbound_section` returns TRUE for an
option-shaped config → requirements gate passes → config gen + sing-box check
proceed.
- PRE-EXISTING (NOT mine): `test_rejected_hash` rh-case1/2/6 fail on the BASELINE
bin/netshift too (verified via git stash) — an existing container/env issue,
unrelated to task-048.
- Gates: shellcheck -S error clean (bin + libs + install.sh); `smoke-tests all`
178→190 passed / 0 failed (the +12 is the 12 `suburlopt` tokens, which now
count because the test parses driver output in the CURRENT shell, NOT a pipe).
NO sacred constant/port/mark/path changed; UCI schema only normalizes an
existing key's representation (option→list, back-compat).
- code-review round 2: APPROVED WITH CONDITIONS — [B1]/[S1]/[M1] all resolved;
the only condition was fixing THIS stale memory note (done).
## task-049: avoid api.github.com rate-limit via github.com redirect (curl)
- Anonymous api.github.com = 60 req/HOUR/IP; CGNAT/shared-IP/shared-VPN routers
share that budget → frequent "API rate limit exceeded". LEVER (proven on HW):
github.com/<repo>/releases/latest is the github.com FRONTEND (NOT the API) and
302-redirects to /releases/tag/<tag>; releases/download/<tag>/<asset> 302s to
the CDN. Neither hits the rate-limited API.
- New constants (constants.sh, repo slug ONLY here): NETSHIFT_REPO_RELEASES_LATEST_URL
(.../releases/latest), NETSHIFT_REPO_RELEASES_DOWNLOAD_BASE (.../releases/download).
Kept NETSHIFT_RELEASE_API_URL as the fallback.
- New STUBBABLE resolver `updates_github_resolve_redirect <url>` (updater.sh):
`command -v curl || return 1; curl -sI -o /dev/null -w '%{redirect_url}'
--connect-timeout 5 -m 15 -A 'netshift-updater' "$url"`. busybox wget is
STRIPPED (no -S/header read) so tag extraction MUST be curl; curl is a hard dep.
- `updates_netshift_latest_tag` rewrite: PRIMARY resolve redirect, parse with
`case "$redirect" in */releases/tag/*) tag="${redirect##*/releases/tag/}";
case "$tag" in ''|*/*) tag="" ;; esac ;; *) tag="" ;; esac` (NO Oniguruma) —
a trailing-slash redirect leaves a `/` in tag → rejected → empty → fallback.
FALLBACK = the task-047 api.github.com + `jq -r '.tag_name // empty'` path,
kept intact. Bare-tag/non-zero contract preserved (feeds updates_check_netshift
+ self-update worker).
- `updates_netshift_asset_filename <pkg> <tag> <ext>` single-source naming helper:
i18n = `<pkg>-<tag>.<ext>` (no -r1); core/luci ipk = `<pkg>-<tag>-r1-all.ipk`,
apk = `<pkg>-<tag>-r1.apk`. `_updates_self_update_download_assets` now resolves
the tag and builds deterministic `$DOWNLOAD_BASE/<tag>/<filename>` URLs (core+luci
always, i18n only if updates_pkg_is_installed), downloads via updates_http_get_once
(follows the 302 to CDN), got_core=1 when core `-s "$dest"`. OLD api-JSON grep -o
loop KEPT verbatim as the else branch when tag unresolved.
- install.sh: added RELEASES_LATEST_REDIRECT + RELEASES_DOWNLOAD_BASE literals
(install.sh has its own REPO, not constants.sh). PRIMARY: curl -sI redirect →
case/param-expansion tag → deterministic releases/download/<tag>/<asset> URLs
(core+luci, RU i18n if pkg_is_installed). FALLBACK: existing API scrape + the
"API rate limit" message kept intact. Factored the retry-download into a new
`download_release_asset url filename` helper reused by both paths. name-prefix
install loop semantics unchanged.
- GOTCHA: the EXISTING test_netshift_latest_tag driver had to ALSO stub
`updates_github_resolve_redirect() { printf ''; }` — else the new primary would
shell out to real curl in CI (network) and bypass the API path that test targets.
- EXTENDED PATH UNTOUCHED: updates_fetch_sing_box_extended_releases
(releases?per_page=30) + updates_extended_release_* — they need the releases LIST
(draft/prerelease/per-arch) a redirect can't give. Left on API + proxy-fallback.
- New smoke test `test_github_redirect_tag` (alias `ghredirect`, 6 tokens): stubs
the resolver + updates_http_get_once, parses driver output in the CURRENT shell
(gates). tag-from-redirect, tag-trailing-slash-rejected (→fallback empty),
nonmatch-falls-back (login URL→API stub→tag), ratelimit-empty (curl-absent +
rate-limit object→empty+nonzero), asset-ipk, asset-apk. Registered all 5 points.
- SELF-PROVEN: `if false && [ -n "$tag" ]` on the primary return made
ghredirect:tag-from-redirect FAIL (5/1), restored→6/0.
- Gates: shellcheck -S error clean (bin+libs+install.sh); `smoke-tests all`
190→196 passed / 0 failed (+6 ghredirect). NO sacred constant/port/mark/path/
schema/frontend change.
## task-050: "Fastest" cross-group urltest of urltests (grouping-on default)
- New constant `SB_SUBSCRIPTION_FASTEST_GROUP_TAG="⚡ Fastest"` (constants.sh,
sing-box Outbounds group, valid UTF-8) — single source for the top-level
cross-group urltest tag. Per-group tags stay the inline literal
`"$group_key Fastest"` (NOT a constant; the spec only added the cross-group
one). Lightning glyph is deliberately distinct from a per-group `<flag>
Fastest` so the auto choice is tellable apart in the dashboard.
- Grouped branch (bin/netshift `configure_outbound_handler`, subscription
`group_mode != off`): after the per-group urltest loop fills
`selector_outbounds_json` with ONLY group tags (before ungrouped is
appended), capture `group_tags_json="$selector_outbounds_json"` +
`group_tags_count=$(... | jq -r 'length')`. If `>= 2`:
`fastest_tag=$(sing_box_get_unique_outbound_tag "$config" "$SB_..._TAG")`,
add the nested urltest via `sing_box_cm_add_urltest_outbound "$config"
"$fastest_tag" "$group_tags_json" <section's url/interval/tolerance>` (reuse
the SECTION's urltest knobs — user-tunable, no hardcoded aggressive
interval), prepend with `jq -acn --arg t --argjson rest '[$t] + $rest'`, and
set `selector_default="$fastest_tag"`. EDGE: `==1` group → skip nest (lone
group already IS fastest; default = `.[0]`); `==0` → no nest, default =
first ungrouped. Never emits an empty-member urltest. New locals
`group_tags_json group_tags_count fastest_tag`. Existing fatal+exit 1 guards
intact. No Oniguruma.
- WHOLE-CHAIN proven: `sing-box check` ACCEPTS a urltest whose members are
other urltest tags (nesting works) — asserted live in-container in the new
test. Runtime-contract impact NONE (pure outbound-tree shape).
- FRONTEND: ZERO change needed. The subscription dashboard
(`getDashboardSections.ts` `proxy_config_type === 'subscription'`) maps the
LIVE `selector.value.all` and shows each member's `value.name` VERBATIM,
EXCEPT it maps ONLY the legacy `${section}-urltest-out` code to `_('Fastest')`
(`isLegacyFastest`). The new "⚡ Fastest" gets a DEDUPED synthetic tag (code =
the tag, NOT `-urltest-out`), so it renders raw `⚡ Fastest` — same treatment
as the per-group `🇷🇺 Fastest` tags. Urltests sort first → it leads the list
and is selectable automatically. main.js untouched (correct).
- TEST `test_fastest_group` (alias `fastest`, after test_subscription; 6
tokens). The grouped branch is INLINE shell (not a function), so the driver
awk-extracts the WHOLE `if [ "$group_mode" != "off" ]; then ... else ... fi`
region VERBATIM (from the `if`-opener through the off-branch's
`"$urltest_tag" "true")"` line + the following `fi`; awk q-style: set
`seen_else_end` on the off selector line, exit on the next `^\s*fi$`) and
wraps it in a driver `_grouped_branch()` so the leading `if ...; then local`
is valid. Sources real constants.sh + sing_box_config_manager.sh; awk-extracts
`sing_box_get_unique_outbound_tag` + `sing_box_build_subscription_groups`
verbatim; stubs `get_outbound_tag_by_section`/`log`. Synthetic flag tags
built by codepoint (RU=flag(17,20), DE=flag(3,4)) + a `plain-node` ungrouped
+ shadowsocks/aes-256-gcm so `sing-box check` accepts. Asserts: (a) one
top-level urltest tagged the constant whose outbounds == [ru,de] group tags;
(b) selector default == fastest + outbounds == [fastest, ru, de,
plain-node]; (c) live `sing-box check` passes WITH the nest; (d) groups==1 →
no nested urltest, default = lone group; (e) off → flat 1 urltest, no
fastest tag, default == `<section>-urltest-out`. Parsed in the CURRENT shell
via per-run `ash "$work/runN.sh" > out.json` (each run sources the spliced
driver) → tokens GATE. Registered all 5 points (all)/case alias/usage/compose
comment).
- SPLICE PATTERN (reusable for inline-region extraction): write the driver with
a placeholder line `EXTRACT_GROUPED`, then rebuild it as
`{ sed '/MARK/q' drv | sed '$d'; cat region; sed -n '/MARK/,$p' drv | sed
'1d'; } > drv.spliced; mv`. Replaces exactly the one placeholder line with the
arbitrary-content region (no s/// escaping hazard).
- SELF-PROVED twice: (1) comment out the prepend line → only
`fastest-selector-default-membership` FAILs; (2) change the guard to `-ge 99`
(never nest) → BOTH `fastest-nested-urltest-members` AND
`-selector-default-membership` FAIL. Restored → 6/0.
- Gates: shellcheck -S error clean (bin + libs + install.sh). `smoke-tests all`
196→202 passed / 0 failed (+6 fastest, all counted — current-shell parse).
Pre-existing `rh-case1/2/6:FAIL` red marks persist (documented task-031/048
env quirk; suite EXIT=0). PRIVACY: synthetic codepoint-built flag tags only,
no real subscription URL/host/id anywhere. NO sacred constant/port/mark/path/
UCI-schema/frontend/main.js change.
## task-051 — text-list selector/urltest (selector_text / urltest_text)
- TWO new proxy_config_type values + TWO scalar UCI options
(`selector_proxy_links_text` / `urltest_proxy_links_text`): a multi-line
textarea blob, one link per line. Behaviour identical to the LIST-based
`selector`/`urltest`; ONLY the input shape differs.
- Refactored the duplicated per-link member-build loop (was inline in `selector)`
and `urltest)`) into ONE shared helper
`_build_proxy_member_outbounds <section> <links_blob> <udp_over_tcp> <label>`.
It mutates GLOBAL `$config` in place (documented, same discipline the
subscription in-shell loop uses) and reports via TWO globals the caller reads:
`_member_outbound_tags` (comma-joined) + `_member_default_outbound` (first
member = selector default). Used by all FOUR branches. Echo-and-reassign for
the FINAL cm_add_selector/urltest stays in each branch.
- LINE PARSING: `for link in $blob` already word-splits on IFS incl. newlines.
KEY GOTCHA: a BLANK line is collapsed by IFS BEFORE the loop body, so it does
NOT consume the `$i` index — members are numbered by NON-blank tokens only
(blank between link2 and link3 ⇒ ss is `<section>-3`, not `-4`). CRLF: strip a
trailing CR per link with `cr="$(printf '\r')"; link="${link%"$cr"}"` then skip
empties. A CR buried in a query string is harmless (facade tolerates it); put
the CRLF on a bare `ss://host:port` line to make CR-strip a DECISIVE gate.
- `section_has_configured_outbound` (the fn `_check_outbound_section` delegates
to) got `selector_text)`/`urltest_text)` cases returning 0 when the text option
is non-empty; added both option names to the "Outbound section not found …
missing …" error string.
- Empty links = `fatal`+`exit 1` (mirrors existing branches verbatim);
all-unsupported = `mark_section_outbound_unavailable` + clear error.
- TEST `test_text_list_outbound` (alias `textlist`): awk-extracts the SHIPPED
helper+handler+marker+`section_has_configured_outbound` chain, table-driven
config_get stub, real `sing-box check` via `check_full`. GATING FIX vs
`test_unsupported_skip` (which uses `cmd | while read` ⇒ counters lost in
subshell): write driver output to a FILE, then `while read … < file` in the
CURRENT shell so pass/fail mutate real PASS/FAIL and the suite gates.
SELF-PROVED: comment out the CR-strip line ⇒ `tl-seltxt-ss-crlf-present`
(+ downstream members-clean) FAIL, suite EXIT=1. Restored ⇒ 16/0.
- Gates: shellcheck -S error CLEAN (bin+libs+install.sh+tests). `smoke-tests all`
202218 passed / 0 failed (+16). PRIVACY: synthetic vless://uuid@…/ss://b64@…
/tuic:// placeholders only no real link/sub data. NO sacred
constant/port/mark/path change; UCI schema ADDITIVE + back-compat; FRONTEND
untouched (parallel agent owns section.js/TS/i18n/main.js).

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

View File

@ -38,21 +38,21 @@
"call": "Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged.",
"key": "Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:123"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:125"
]
},
{
"call": "Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip",
"key": "Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:588"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:670"
]
},
{
"call": "Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip",
"key": "Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:676"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:758"
]
},
{
@ -74,7 +74,7 @@
"call": "Allow insecure TLS for subscription fetch",
"key": "Allow insecure TLS for subscription fetch",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:163"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:165"
]
},
{
@ -88,28 +88,35 @@
"call": "Applicable for SOCKS and Shadowsocks proxy",
"key": "Applicable for SOCKS and Shadowsocks proxy",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:371"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:453"
]
},
{
"call": "At least one proxy link must be specified.",
"key": "At least one proxy link must be specified.",
"places": [
"src/validators/validateProxyUrlList.ts:39"
]
},
{
"call": "At least one valid domain must be specified. Comments-only content is not allowed.",
"key": "At least one valid domain must be specified. Comments-only content is not allowed.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:648"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:730"
]
},
{
"call": "At least one valid subnet or IP must be specified. Comments-only content is not allowed.",
"key": "At least one valid subnet or IP must be specified. Comments-only content is not allowed.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:737"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:819"
]
},
{
"call": "Auto",
"key": "Auto",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:153"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:155"
]
},
{
@ -161,6 +168,20 @@
"src/netshift/tabs/diagnostic/checks/runFakeIPCheck.ts:57"
]
},
{
"call": "By country flag",
"key": "By country flag",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:202"
]
},
{
"call": "By name prefix",
"key": "By name prefix",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:203"
]
},
{
"call": "Cache File Path",
"key": "Cache File Path",
@ -258,7 +279,7 @@
"call": "Community Lists",
"key": "Community Lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:494"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:576"
]
},
{
@ -350,14 +371,14 @@
"call": "Custom domains",
"key": "Custom domains",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:587"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:669"
]
},
{
"call": "Custom subnets",
"key": "Custom subnets",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:675"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:757"
]
},
{
@ -435,15 +456,15 @@
"call": "Disabled",
"key": "Disabled",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:592",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:680"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:674",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:762"
]
},
{
"call": "Disables TLS certificate verification when downloading the subscription.",
"key": "Disables TLS certificate verification when downloading the subscription.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:164"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:166"
]
},
{
@ -471,7 +492,7 @@
"call": "DNS over HTTPS (DoH)",
"key": "DNS over HTTPS (DoH)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:460",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:542",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:48"
]
},
@ -479,7 +500,7 @@
"call": "DNS over TLS (DoT)",
"key": "DNS over TLS (DoT)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:461",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:543",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:49"
]
},
@ -487,7 +508,7 @@
"call": "DNS Protocol Type",
"key": "DNS Protocol Type",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:457",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:539",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:45"
]
},
@ -502,7 +523,7 @@
"call": "DNS Server",
"key": "DNS Server",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:471",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:553",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:58"
]
},
@ -531,7 +552,7 @@
"call": "Domain Resolver",
"key": "Domain Resolver",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:446"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528"
]
},
{
@ -582,15 +603,15 @@
"call": "Drop subscription servers whose name contains any of these keywords (case-insensitive).",
"key": "Drop subscription servers whose name contains any of these keywords (case-insensitive).",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:220"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:241"
]
},
{
"call": "Dynamic List",
"key": "Dynamic List",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:593",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:681"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:675",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:763"
]
},
{
@ -604,14 +625,14 @@
"call": "Enable built-in DNS resolver for domains handled by this section",
"key": "Enable built-in DNS resolver for domains handled by this section",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:447"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:529"
]
},
{
"call": "Enable DNS resolve to get real IP when routing",
"key": "Enable DNS resolve to get real IP when routing",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:914"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:996"
]
},
{
@ -632,7 +653,7 @@
"call": "Enable Mixed Proxy",
"key": "Enable Mixed Proxy",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:883"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:965"
]
},
{
@ -646,7 +667,7 @@
"call": "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies",
"key": "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:884"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:966"
]
},
{
@ -667,98 +688,98 @@
"call": "Enter complete outbound configuration in JSON format",
"key": "Enter complete outbound configuration in JSON format",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:99"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:101"
]
},
{
"call": "Enter domain names separated by commas, spaces, or newlines. You can add comments using //",
"key": "Enter domain names separated by commas, spaces, or newlines. You can add comments using //",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:630"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:712"
]
},
{
"call": "Enter domain names without protocols, e.g. example.com or sub.example.com",
"key": "Enter domain names without protocols, e.g. example.com or sub.example.com",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:603"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:685"
]
},
{
"call": "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses",
"key": "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:691"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:773"
]
},
{
"call": "Every 1 minute",
"key": "Every 1 minute",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:287"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:366"
]
},
{
"call": "Every 12 hours",
"key": "Every 12 hours",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:185"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:187"
]
},
{
"call": "Every 3 hours",
"key": "Every 3 hours",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:183"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:185"
]
},
{
"call": "Every 3 minutes",
"key": "Every 3 minutes",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:288"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:367"
]
},
{
"call": "Every 30 minutes",
"key": "Every 30 minutes",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:181"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:183"
]
},
{
"call": "Every 30 seconds",
"key": "Every 30 seconds",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:286"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:365"
]
},
{
"call": "Every 5 minutes",
"key": "Every 5 minutes",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:289"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:368"
]
},
{
"call": "Every 6 hours",
"key": "Every 6 hours",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:184"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:186"
]
},
{
"call": "Every day",
"key": "Every day",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:186"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:188"
]
},
{
"call": "Every hour",
"key": "Every hour",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:182"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:184"
]
},
{
@ -779,7 +800,7 @@
"call": "Exclude servers by keyword",
"key": "Exclude servers by keyword",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:219"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:240"
]
},
{
@ -832,7 +853,7 @@
"call": "Fully Routed IPs",
"key": "Fully Routed IPs",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:855"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:937"
]
},
{
@ -853,28 +874,21 @@
"call": "Global Proxy",
"key": "Global Proxy",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:381"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:463"
]
},
{
"call": "Group by countries",
"key": "Group by countries",
"call": "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters.",
"key": "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:194"
]
},
{
"call": "Group subscription proxies into separate URLTest groups by the country flag at the start of each tag",
"key": "Group subscription proxies into separate URLTest groups by the country flag at the start of each tag",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:195"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:197"
]
},
{
"call": "How often to automatically update the subscription",
"key": "How often to automatically update the subscription",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:179"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:181"
]
},
{
@ -888,7 +902,7 @@
"call": "Include servers by keyword",
"key": "Include servers by keyword",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:207"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:228"
]
},
{
@ -1314,7 +1328,7 @@
"call": "Keep only subscription servers whose name contains at least one of these keywords (case-insensitive). Leave empty to keep all.",
"key": "Keep only subscription servers whose name contains at least one of these keywords (case-insensitive). Leave empty to keep all.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:208"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:229"
]
},
{
@ -1332,6 +1346,13 @@
"src/netshift/tabs/manager/initController.ts:103"
]
},
{
"call": "Line",
"key": "Line",
"places": [
"src/validators/validateProxyUrlList.ts:31"
]
},
{
"call": "List Update Frequency",
"key": "List Update Frequency",
@ -1357,14 +1378,14 @@
"call": "Local Domain Lists",
"key": "Local Domain Lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:759"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:841"
]
},
{
"call": "Local Subnet Lists",
"key": "Local Subnet Lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:783"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:865"
]
},
{
@ -1406,7 +1427,7 @@
"call": "Mixed Proxy Port",
"key": "Mixed Proxy Port",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:897"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:979"
]
},
{
@ -1420,7 +1441,7 @@
"call": "Must be a number in the range of 50 - 1000",
"key": "Must be a number in the range of 50 - 1000",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:324"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:405"
]
},
{
@ -1462,7 +1483,7 @@
"call": "Network Interface",
"key": "Network Interface",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:399"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481"
]
},
{
@ -1509,11 +1530,25 @@
"src/netshift/tabs/diagnostic/diagnostic.store.ts:91"
]
},
{
"call": "Number of leading characters of each proxy name to group by.",
"key": "Number of leading characters of each proxy name to group by.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:213"
]
},
{
"call": "Off",
"key": "Off",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:201"
]
},
{
"call": "Only one section can be global at a time.",
"key": "Only one section can be global at a time.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:390"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472"
]
},
{
@ -1527,14 +1562,14 @@
"call": "Outbound Config",
"key": "Outbound Config",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:58"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:60"
]
},
{
"call": "Outbound Configuration",
"key": "Outbound Configuration",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:98"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:100"
]
},
{
@ -1591,6 +1626,13 @@
"src/netshift/tabs/diagnostic/diagnostic.store.ts:139"
]
},
{
"call": "Prefix length",
"key": "Prefix length",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:212"
]
},
{
"call": "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT.",
"key": "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT.",
@ -1602,7 +1644,7 @@
"call": "Proxy Configuration URL",
"key": "Proxy Configuration URL",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:66"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:68"
]
},
{
@ -1623,28 +1665,28 @@
"call": "Regional options cannot be used together",
"key": "Regional options cannot be used together",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:610"
]
},
{
"call": "Remote Domain Lists",
"key": "Remote Domain Lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:807"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:889"
]
},
{
"call": "Remote Subnet Lists",
"key": "Remote Subnet Lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:831"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:913"
]
},
{
"call": "Resolve real IP for routing",
"key": "Resolve real IP for routing",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:913"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:995"
]
},
{
@ -1658,7 +1700,7 @@
"call": "Route all unmatched traffic through this section's outbound.",
"key": "Route all unmatched traffic through this section's outbound.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:382"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:464"
]
},
{
@ -1742,7 +1784,7 @@
"call": "Russia inside restrictions",
"key": "Russia inside restrictions",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:547"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629"
]
},
{
@ -1763,7 +1805,7 @@
"call": "Select a predefined list for routing",
"key": "Select a predefined list for routing",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:495"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:577"
]
},
{
@ -1798,14 +1840,14 @@
"call": "Select network interface for VPN connection",
"key": "Select network interface for VPN connection",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:400"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:482"
]
},
{
"call": "Select or enter DNS server address",
"key": "Select or enter DNS server address",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:554",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:59"
]
},
@ -1827,7 +1869,7 @@
"call": "Select the DNS protocol type for the domain resolver",
"key": "Select the DNS protocol type for the domain resolver",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:458"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:540"
]
},
{
@ -1865,11 +1907,25 @@
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:55"
]
},
{
"call": "Selector (text list)",
"key": "Selector (text list)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:57"
]
},
{
"call": "Selector Proxy Links",
"key": "Selector Proxy Links",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:231"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:252"
]
},
{
"call": "Selector Proxy Links (one per line)",
"key": "Selector Proxy Links (one per line)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:278"
]
},
{
@ -1912,7 +1968,7 @@
"call": "Sing-box",
"key": "Sing-box",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:155",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:157",
"src/netshift/tabs/dashboard/initController.ts:354"
]
},
@ -1990,29 +2046,29 @@
"call": "Specify local IP addresses or subnets whose traffic will always be routed through the configured route",
"key": "Specify local IP addresses or subnets whose traffic will always be routed through the configured route",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:856"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:938"
]
},
{
"call": "Specify remote URLs to download and use domain lists",
"key": "Specify remote URLs to download and use domain lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:808"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:890"
]
},
{
"call": "Specify remote URLs to download and use subnet lists",
"key": "Specify remote URLs to download and use subnet lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:832"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:914"
]
},
{
"call": "Specify the path to the list file located on the router filesystem",
"key": "Specify the path to the list file located on the router filesystem",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:760",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:784"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:842",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:866"
]
},
{
@ -2034,7 +2090,7 @@
"key": "Subscription",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:21",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:57"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:59"
]
},
{
@ -2055,21 +2111,28 @@
"call": "Subscription format",
"key": "Subscription format",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:148"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:150"
]
},
{
"call": "Subscription grouping",
"key": "Subscription grouping",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:196"
]
},
{
"call": "Subscription Update Interval",
"key": "Subscription Update Interval",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:178"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:180"
]
},
{
"call": "Subscription URLs",
"key": "Subscription URLs",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:122"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:124"
]
},
{
@ -2132,8 +2195,8 @@
"call": "Text List",
"key": "Text List",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:594",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:682"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:676",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:764"
]
},
{
@ -2147,28 +2210,28 @@
"call": "The interval between connectivity tests",
"key": "The interval between connectivity tests",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:284"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:363"
]
},
{
"call": "The maximum difference in response times (ms) allowed when comparing servers",
"key": "The maximum difference in response times (ms) allowed when comparing servers",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:299"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:379"
]
},
{
"call": "The URL used to test server connectivity",
"key": "The URL used to test server connectivity",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:332"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:413"
]
},
{
"call": "This is a security trade-off: an attacker could intercept the fetch.",
"key": "This is a security trade-off: an attacker could intercept the fetch.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:168"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:170"
]
},
{
@ -2217,7 +2280,7 @@
"call": "UDP (Unprotected DNS)",
"key": "UDP (Unprotected DNS)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:462",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:544",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:50"
]
},
@ -2225,7 +2288,7 @@
"call": "UDP over TCP",
"key": "UDP over TCP",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:370"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:452"
]
},
{
@ -2319,39 +2382,53 @@
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:56"
]
},
{
"call": "URLTest (text list)",
"key": "URLTest (text list)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:58"
]
},
{
"call": "URLTest Check Interval",
"key": "URLTest Check Interval",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:283"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:362"
]
},
{
"call": "URLTest Proxy Links",
"key": "URLTest Proxy Links",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:257"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:307"
]
},
{
"call": "URLTest Proxy Links (one per line)",
"key": "URLTest Proxy Links (one per line)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:333"
]
},
{
"call": "URLTest Testing URL",
"key": "URLTest Testing URL",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:331"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:412"
]
},
{
"call": "URLTest Tolerance",
"key": "URLTest Tolerance",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:298"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:378"
]
},
{
"call": "Use only for IP-host panels that serve an invalid or self-signed certificate.",
"key": "Use only for IP-host panels that serve an invalid or self-signed certificate.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:166"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:168"
]
},
{
@ -2365,35 +2442,35 @@
"call": "Use with Exclusion sections to route specific domains directly.",
"key": "Use with Exclusion sections to route specific domains directly.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:388"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:470"
]
},
{
"call": "User Domains",
"key": "User Domains",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:602"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:684"
]
},
{
"call": "User Domains List",
"key": "User Domains List",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:711"
]
},
{
"call": "User Subnets",
"key": "User Subnets",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:690"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:772"
]
},
{
"call": "User Subnets List",
"key": "User Subnets List",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:717"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:799"
]
},
{
@ -2425,8 +2502,8 @@
"call": "Validation errors:",
"key": "Validation errors:",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:662",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:749"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:744",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:831"
]
},
{
@ -2455,30 +2532,38 @@
"call": "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links",
"key": "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:67",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:232",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:258"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:69",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:253",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:308"
]
},
{
"call": "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line",
"key": "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:279",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:334"
]
},
{
"call": "Warning: %s cannot be used together with %s. Previous selections have been removed.",
"key": "Warning: %s cannot be used together with %s. Previous selections have been removed.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:530"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:612"
]
},
{
"call": "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.",
"key": "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:549"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:631"
]
},
{
"call": "When enabled, traffic not matching any other section's lists will go through this proxy.",
"key": "When enabled, traffic not matching any other section's lists will go through this proxy.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:384"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:466"
]
},
{
@ -2492,14 +2577,14 @@
"call": "Which subscription format (client) to fetch first. Auto uses the default order. Choose Xray JSON (Happ) when your panel only exposes some nodes (e.g. xhttp) under a Happ-like client, or Sing-box to prefer the sing-box format.",
"key": "Which subscription format (client) to fetch first. Auto uses the default order. Choose Xray JSON (Happ) when your panel only exposes some nodes (e.g. xhttp) under a Happ-like client, or Sing-box to prefer the sing-box format.",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:149"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:151"
]
},
{
"call": "Xray JSON (Happ)",
"key": "Xray JSON (Happ)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:154"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:156"
]
},
{

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: NETSHIFT\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-11 17:31+0300\n"
"PO-Revision-Date: 2026-06-11 17:31+0300\n"
"POT-Creation-Date: 2026-06-13 11:14+0300\n"
"PO-Revision-Date: 2026-06-13 11:14+0300\n"
"Last-Translator: yandexru45 <>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@ -36,15 +36,15 @@ msgstr ""
msgid "Active Connections"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:123
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:125
msgid "Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:588
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:670
msgid "Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:676
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:758
msgid "Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
msgstr ""
@ -57,7 +57,7 @@ msgstr ""
msgid "Advanced"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:163
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:165
msgid "Allow insecure TLS for subscription fetch"
msgstr ""
@ -65,19 +65,23 @@ msgstr ""
msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:371
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:453
msgid "Applicable for SOCKS and Shadowsocks proxy"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:648
#: src/validators/validateProxyUrlList.ts:39
msgid "At least one proxy link must be specified."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:730
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:737
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:819
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:153
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:155
msgid "Auto"
msgstr ""
@ -109,6 +113,14 @@ msgstr ""
msgid "Browser is using FakeIP correctly"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:202
msgid "By country flag"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:203
msgid "By name prefix"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:517
msgid "Cache File Path"
msgstr ""
@ -166,7 +178,7 @@ msgstr ""
msgid "Close"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:494
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:576
msgid "Community Lists"
msgstr ""
@ -219,11 +231,11 @@ msgstr ""
msgid "Currently unavailable"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:587
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:669
msgid "Custom domains"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:675
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:757
msgid "Custom subnets"
msgstr ""
@ -268,12 +280,12 @@ msgstr ""
msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:592
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:680
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:674
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:762
msgid "Disabled"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:164
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:166
msgid "Disables TLS certificate verification when downloading the subscription."
msgstr ""
@ -289,17 +301,17 @@ msgstr ""
msgid "DNS outbound section"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:460
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:542
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:48
msgid "DNS over HTTPS (DoH)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:461
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:543
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:49
msgid "DNS over TLS (DoT)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:457
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:539
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:45
msgid "DNS Protocol Type"
msgstr ""
@ -308,7 +320,7 @@ msgstr ""
msgid "DNS Rewrite TTL"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:471
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:553
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:58
msgid "DNS Server"
msgstr ""
@ -325,7 +337,7 @@ msgstr ""
msgid "Domain and subnet lists that decide which traffic uses this section"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:446
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528
msgid "Domain Resolver"
msgstr ""
@ -355,12 +367,12 @@ msgstr ""
msgid "Downloading all lists via specific Proxy/VPN"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:220
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:241
msgid "Drop subscription servers whose name contains any of these keywords (case-insensitive)."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:593
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:681
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:675
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:763
msgid "Dynamic List"
msgstr ""
@ -368,11 +380,11 @@ msgstr ""
msgid "Enable autostart"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:447
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:529
msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:914
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:996
msgid "Enable DNS resolve to get real IP when routing"
msgstr ""
@ -384,7 +396,7 @@ msgstr ""
msgid "Enable IPv6 TProxy routing, IPv6 DNS inbound, and IPv6 FakeIP support."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:883
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:965
msgid "Enable Mixed Proxy"
msgstr ""
@ -392,7 +404,7 @@ msgstr ""
msgid "Enable Output Network Interface"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:884
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:966
msgid "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies"
msgstr ""
@ -404,59 +416,59 @@ msgstr ""
msgid "Enable YACD WAN Access"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:99
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:101
msgid "Enter complete outbound configuration in JSON format"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:630
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:712
msgid "Enter domain names separated by commas, spaces, or newlines. You can add comments using //"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:603
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:685
msgid "Enter domain names without protocols, e.g. example.com or sub.example.com"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:691
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:773
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:287
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:366
msgid "Every 1 minute"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:185
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:187
msgid "Every 12 hours"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:183
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:185
msgid "Every 3 hours"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:288
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:367
msgid "Every 3 minutes"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:181
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:183
msgid "Every 30 minutes"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:286
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:365
msgid "Every 30 seconds"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:289
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:368
msgid "Every 5 minutes"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:184
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:186
msgid "Every 6 hours"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:186
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:188
msgid "Every day"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:182
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:184
msgid "Every hour"
msgstr ""
@ -468,7 +480,7 @@ msgstr ""
msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:219
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:240
msgid "Exclude servers by keyword"
msgstr ""
@ -506,7 +518,7 @@ msgstr ""
msgid "Fastest"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:855
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:937
msgid "Fully Routed IPs"
msgstr ""
@ -518,19 +530,15 @@ msgstr ""
msgid "Global check"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:381
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:463
msgid "Global Proxy"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:194
msgid "Group by countries"
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:197
msgid "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:195
msgid "Group subscription proxies into separate URLTest groups by the country flag at the start of each tag"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:179
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:181
msgid "How often to automatically update the subscription"
msgstr ""
@ -538,7 +546,7 @@ msgstr ""
msgid "HTTP error"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:207
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:228
msgid "Include servers by keyword"
msgstr ""
@ -784,7 +792,7 @@ msgstr ""
msgid "Issues detected"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:208
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:229
msgid "Keep only subscription servers whose name contains at least one of these keywords (case-insensitive). Leave empty to keep all."
msgstr ""
@ -797,6 +805,10 @@ msgstr ""
msgid "Latest version is installed"
msgstr ""
#: src/validators/validateProxyUrlList.ts:31
msgid "Line"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:327
msgid "List Update Frequency"
msgstr ""
@ -809,11 +821,11 @@ msgstr ""
msgid "Lists & Updates"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:759
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:841
msgid "Local Domain Lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:783
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:865
msgid "Local Subnet Lists"
msgstr ""
@ -837,7 +849,7 @@ msgstr ""
msgid "Mixed proxy and DNS resolution tuning"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:897
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:979
msgid "Mixed Proxy Port"
msgstr ""
@ -845,7 +857,7 @@ msgstr ""
msgid "Monitored Interfaces"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:324
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:405
msgid "Must be a number in the range of 50 - 1000"
msgstr ""
@ -869,7 +881,7 @@ msgstr ""
msgid "Network"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:399
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481
msgid "Network Interface"
msgstr ""
@ -902,7 +914,15 @@ msgstr ""
msgid "Not running"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:390
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:213
msgid "Number of leading characters of each proxy name to group by."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:201
msgid "Off"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472
msgid "Only one section can be global at a time."
msgstr ""
@ -910,11 +930,11 @@ msgstr ""
msgid "Operation timed out"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:58
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:60
msgid "Outbound Config"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:98
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:100
msgid "Outbound Configuration"
msgstr ""
@ -951,11 +971,15 @@ msgstr ""
msgid "Pending"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:212
msgid "Prefix length"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:37
msgid "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:66
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:68
msgid "Proxy Configuration URL"
msgstr ""
@ -967,19 +991,19 @@ msgstr ""
msgid "Proxy traffic is routed via FakeIP"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:610
msgid "Regional options cannot be used together"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:807
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:889
msgid "Remote Domain Lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:831
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:913
msgid "Remote Subnet Lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:913
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:995
msgid "Resolve real IP for routing"
msgstr ""
@ -987,7 +1011,7 @@ msgstr ""
msgid "Restart NetShift"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:382
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:464
msgid "Route all unmatched traffic through this section's outbound."
msgstr ""
@ -1035,7 +1059,7 @@ msgstr ""
msgid "Run Diagnostic"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:547
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629
msgid "Russia inside restrictions"
msgstr ""
@ -1047,7 +1071,7 @@ msgstr ""
msgid "Sections"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:495
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:577
msgid "Select a predefined list for routing"
msgstr ""
@ -1067,11 +1091,11 @@ msgstr ""
msgid "Select how to configure the proxy"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:400
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:482
msgid "Select network interface for VPN connection"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:554
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:59
msgid "Select or enter DNS server address"
msgstr ""
@ -1084,7 +1108,7 @@ msgstr ""
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:458
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:540
msgid "Select the DNS protocol type for the domain resolver"
msgstr ""
@ -1108,10 +1132,18 @@ msgstr ""
msgid "Selector"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:231
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:57
msgid "Selector (text list)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:252
msgid "Selector Proxy Links"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:278
msgid "Selector Proxy Links (one per line)"
msgstr ""
#: src/netshift/methods/shell/index.ts:303
msgid "Self-update failed"
msgstr ""
@ -1133,7 +1165,7 @@ msgstr ""
msgid "Show sing-box config"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:155
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:157
#: src/netshift/tabs/dashboard/initController.ts:354
msgid "Sing-box"
msgstr ""
@ -1178,20 +1210,20 @@ msgstr ""
msgid "Specify a local IP address to be excluded from routing"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:856
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:938
msgid "Specify local IP addresses or subnets whose traffic will always be routed through the configured route"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:808
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:890
msgid "Specify remote URLs to download and use domain lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:832
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:914
msgid "Specify remote URLs to download and use subnet lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:760
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:784
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:842
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:866
msgid "Specify the path to the list file located on the router filesystem"
msgstr ""
@ -1204,7 +1236,7 @@ msgid "Stop NetShift"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:21
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:57
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:59
msgid "Subscription"
msgstr ""
@ -1216,15 +1248,19 @@ msgstr ""
msgid "Subscription feeds, server filters and URLTest tuning"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:148
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:150
msgid "Subscription format"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:178
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:196
msgid "Subscription grouping"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:180
msgid "Subscription Update Interval"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:122
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:124
msgid "Subscription URLs"
msgstr ""
@ -1260,8 +1296,8 @@ msgstr ""
msgid "Test latency"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:594
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:682
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:676
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:764
msgid "Text List"
msgstr ""
@ -1269,19 +1305,19 @@ msgstr ""
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:284
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:363
msgid "The interval between connectivity tests"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:299
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:379
msgid "The maximum difference in response times (ms) allowed when comparing servers"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:332
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:413
msgid "The URL used to test server connectivity"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:168
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:170
msgid "This is a security trade-off: an attacker could intercept the fetch."
msgstr ""
@ -1309,12 +1345,12 @@ msgstr ""
msgid "TTL value cannot be empty"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:462
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:544
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:50
msgid "UDP (Unprotected DNS)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:370
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:452
msgid "UDP over TCP"
msgstr ""
@ -1376,23 +1412,31 @@ msgstr ""
msgid "URLTest"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:283
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:58
msgid "URLTest (text list)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:362
msgid "URLTest Check Interval"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:257
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:307
msgid "URLTest Proxy Links"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:331
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:333
msgid "URLTest Proxy Links (one per line)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:412
msgid "URLTest Testing URL"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:298
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:378
msgid "URLTest Tolerance"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:166
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:168
msgid "Use only for IP-host panels that serve an invalid or self-signed certificate."
msgstr ""
@ -1400,23 +1444,23 @@ msgstr ""
msgid "Use this only when the router has working IPv6 connectivity."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:388
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:470
msgid "Use with Exclusion sections to route specific domains directly."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:602
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:684
msgid "User Domains"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:711
msgid "User Domains List"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:690
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:772
msgid "User Subnets"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:717
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:799
msgid "User Subnets List"
msgstr ""
@ -1442,8 +1486,8 @@ msgstr ""
msgid "Valid"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:662
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:749
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:744
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:831
msgid "Validation errors:"
msgstr ""
@ -1460,21 +1504,26 @@ msgstr ""
msgid "Visit Wiki"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:67
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:232
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:258
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:69
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:253
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:308
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:530
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:279
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:334
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:612
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:549
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:631
msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:384
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:466
msgid "When enabled, traffic not matching any other section's lists will go through this proxy."
msgstr ""
@ -1482,11 +1531,11 @@ msgstr ""
msgid "Which proxy/VPN section carries the DNS. Leave unset to use the first configured outbound."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:149
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:151
msgid "Which subscription format (client) to fetch first. Auto uses the default order. Choose Xray JSON (Happ) when your panel only exposes some nodes (e.g. xhttp) under a Happ-like client, or Sing-box to prefer the sing-box format."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:154
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:156
msgid "Xray JSON (Happ)"
msgstr ""

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: NETSHIFT\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-11 20:31+0300\n"
"PO-Revision-Date: 2026-06-11 20:31+0300\n"
"POT-Creation-Date: 2026-06-13 14:14+0300\n"
"PO-Revision-Date: 2026-06-13 14:14+0300\n"
"Last-Translator: yandexru45\n"
"Language-Team: none\n"
"Language: ru\n"
@ -56,6 +56,9 @@ msgstr "Обеспечивает доступ к YACD из WAN. Убедитес
msgid "Applicable for SOCKS and Shadowsocks proxy"
msgstr "Применимо для SOCKS и Shadowsocks прокси"
msgid "At least one proxy link must be specified."
msgstr "Необходимо указать хотя бы одну прокси-ссылку."
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
@ -86,6 +89,12 @@ msgstr "Браузер не использует FakeIP"
msgid "Browser is using FakeIP correctly"
msgstr "Браузер использует FakeIP"
msgid "By country flag"
msgstr "По флагу страны"
msgid "By name prefix"
msgstr "По префиксу имени"
msgid "Cache File Path"
msgstr "Путь к файлу кэша"
@ -368,11 +377,8 @@ msgstr "Глобальная проверка"
msgid "Global Proxy"
msgstr "Глобальный прокси"
msgid "Group by countries"
msgstr "Группировать по странам"
msgid "Group subscription proxies into separate URLTest groups by the country flag at the start of each tag"
msgstr "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
msgid "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters."
msgstr "Группировать прокси из подписки в группы URLTest. «По флагу страны» использует эмодзи флага в начале каждого имени; «По префиксу имени» группирует по первым N символам."
msgid "How often to automatically update the subscription"
msgstr "Как часто автоматически обновлять подписку"
@ -569,6 +575,9 @@ msgstr "Последняя"
msgid "Latest version is installed"
msgstr "Установлена последняя версия"
msgid "Line"
msgstr "Строка"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
@ -641,6 +650,12 @@ msgstr "Не отвечает"
msgid "Not running"
msgstr "Не запущено"
msgid "Number of leading characters of each proxy name to group by."
msgstr "Количество начальных символов имени каждого прокси для группировки."
msgid "Off"
msgstr "Выключено"
msgid "Only one section can be global at a time."
msgstr "Только одна секция может быть глобальной одновременно."
@ -674,6 +689,9 @@ msgstr "Путь должен заканчиваться на cache.db"
msgid "Pending"
msgstr "Ожидает запуска"
msgid "Prefix length"
msgstr "Длина префикса"
msgid "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."
msgstr "Переключатели протоколов, пути к файлам и журналирование. Включайте блокировку DoH только после переключения вышестоящего DNS на UDP или DoT."
@ -791,9 +809,15 @@ msgstr "Выберите WAN интерфейсы для мониторинга"
msgid "Selector"
msgstr "Selector"
msgid "Selector (text list)"
msgstr "Selector (текстовый список)"
msgid "Selector Proxy Links"
msgstr "Ссылки прокси для Selector"
msgid "Selector Proxy Links (one per line)"
msgstr "Прокси-ссылки Selector (по одной в строке)"
msgid "Self-update failed"
msgstr "Не удалось обновить"
@ -872,6 +896,9 @@ msgstr "Источники подписок, фильтры серверов и
msgid "Subscription format"
msgstr "Формат подписки"
msgid "Subscription grouping"
msgstr "Группировка подписки"
msgid "Subscription Update Interval"
msgstr "Интервал обновления подписки"
@ -977,12 +1004,18 @@ msgstr "URL должен использовать один из следующи
msgid "URLTest"
msgstr "URLTest"
msgid "URLTest (text list)"
msgstr "URLTest (текстовый список)"
msgid "URLTest Check Interval"
msgstr "Интервал проверки URLTest"
msgid "URLTest Proxy Links"
msgstr "Ссылки прокси для URLTest"
msgid "URLTest Proxy Links (one per line)"
msgstr "Прокси-ссылки URLTest (по одной в строке)"
msgid "URLTest Testing URL"
msgstr "URLTest ссылка для проверки"
@ -1028,6 +1061,9 @@ msgstr "Перейти в wiki"
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2://"
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line"
msgstr "Ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// — по одной в строке"
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."

View File

@ -120,7 +120,8 @@ export namespace NetShift {
subscription_url: string[];
subscription_format_preference?: 'auto' | 'xray' | 'singbox';
subscription_update_interval?: string;
subscription_group_by_countries?: '0' | '1';
subscription_group_mode?: 'off' | 'country' | 'prefix';
subscription_group_prefix_len?: string;
subscription_filter_include_keywords?: string[];
subscription_filter_exclude_keywords?: string[];
}

View File

@ -10,4 +10,5 @@ export * from './validateVlessUrl';
export * from './validateOutboundJson';
export * from './validateTrojanUrl';
export * from './validateProxyUrl';
export * from './validateProxyUrlList';
export * from './validateSocksUrl';

View File

@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { validateProxyUrlList } from '../validateProxyUrlList';
// Synthetic placeholder links only — never real proxy/subscription data.
const VLESS =
'vless://94792286-7bbe-4f33-8b36-18d1bbf70723@127.0.0.1:34520?type=tcp&encryption=none&security=none#node-a';
const SS =
'ss://2022-blake3-aes-256-gcm:dmCly/Zh15Ww9+s+GFXiFTIkpw7c/qCISaBrai7WhhY=@127.0.0.1:27214?type=tcp#node-b';
const validBlobs = [
['single vless line', VLESS],
['single ss line', SS],
['two links', `${VLESS}\n${SS}`],
['blank lines ignored', `\n${VLESS}\n\n${SS}\n`],
['leading/trailing whitespace trimmed', ` ${VLESS} \n\t${SS}\t`],
['CRLF tolerated', `${VLESS}\r\n${SS}\r`],
];
const invalidBlobs = [
['empty string', ''],
['whitespace/blank only', ' \n\t\n '],
['unsupported scheme', 'tuic://127.0.0.1:443#node'],
['second line invalid', `${VLESS}\ntuic://127.0.0.1:443`],
['garbage line', 'not-a-link'],
];
describe('validateProxyUrlList', () => {
describe.each(validBlobs)('Valid blob: %s', (_desc, blob) => {
it('returns valid=true', () => {
const res = validateProxyUrlList(blob);
expect(res.valid).toBe(true);
});
});
describe.each(invalidBlobs)('Invalid blob: %s', (_desc, blob) => {
it('returns valid=false', () => {
const res = validateProxyUrlList(blob);
expect(res.valid).toBe(false);
expect(typeof res.message).toBe('string');
expect(res.message.length).toBeGreaterThan(0);
});
});
it('reports the 1-based line number of the first failing line', () => {
const res = validateProxyUrlList(`${VLESS}\n${SS}\ntuic://127.0.0.1:443`);
expect(res.valid).toBe(false);
expect(res.message).toContain('Line 3');
});
it('counts blank lines toward the reported line number', () => {
const res = validateProxyUrlList(`${VLESS}\n\ntuic://127.0.0.1:443`);
expect(res.valid).toBe(false);
expect(res.message).toContain('Line 3');
});
});

View File

@ -0,0 +1,44 @@
import { ValidationResult } from './types';
import { validateProxyUrl } from './validateProxyUrl';
/**
* Validate a textarea blob of proxy links (one per line).
*
* Splits on newlines, trims each line, ignores blank lines, then runs the
* single-link `validateProxyUrl` on every remaining line. Returns the first
* error encountered (annotated with the 1-based line number) or
* `{ valid: true }` when every non-blank line is a valid proxy link.
*/
export function validateProxyUrlList(value: string): ValidationResult {
const lines = value.split('\n');
let hasLink = false;
for (let index = 0; index < lines.length; index++) {
const line = lines[index].trim();
if (line.length === 0) {
continue;
}
hasLink = true;
const validation = validateProxyUrl(line);
if (!validation.valid) {
return {
valid: false,
message: `${_('Line')} ${index + 1}: ${validation.message}`,
};
}
}
if (!hasLink) {
return {
valid: false,
message: _('At least one proxy link must be specified.'),
};
}
return { valid: true, message: '' };
}

View File

@ -1,7 +1,13 @@
#!/bin/sh
# shellcheck shell=dash
REPO="https://api.github.com/repos/yandexru45/netshift/releases/latest"
REPO="https://uralgit.ru/api/v1/repos/ural/netshift/releases/latest"
# uralgit.ru FRONTEND redirect path (NOT the rate-limited api.github.com).
# /releases/latest 303s to /releases/tag/<tag>; /releases/download/<tag>/<asset>
# direct download. Primary install path so CGNAT / shared-IP routers avoid the
# 60/hour/IP API limit; REPO stays as the fallback.
RELEASES_LATEST_REDIRECT="https://uralgit.ru/ural/netshift/releases/latest"
RELEASES_DOWNLOAD_BASE="https://uralgit.ru/ural/netshift/releases/download"
DOWNLOAD_DIR="/tmp/netshift"
COUNT=3
@ -57,7 +63,7 @@ pkg_install() {
# If you're installing a non-standard (self-built) package, use the --allow-untrusted option:
apk add --allow-untrusted "$pkg_file"
else
opkg install "$pkg_file"
opkg install --force-downgrade --force-reinstall "$pkg_file"
fi
}
@ -66,7 +72,7 @@ update_config() {
printf "\033[48;5;196m\033[1m║ ! Обнаружена старая версия NetShift. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Если продолжите обновление, вам потребуется настроить NetShift заново.║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Старая конфигурация будет сохранена в /etc/config/netshift-070 ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Подробности: https://github.com/yandexru45/netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Подробности: https://uralgit.ru/ural/netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Точно хотите продолжить? ║\033[0m\n"
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
@ -76,7 +82,7 @@ update_config() {
printf "\033[48;5;196m\033[1m║ ! Detected old NetShift version. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ If you continue the update, you will need to RECONFIGURE NetShift. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Your old configuration will be saved to /etc/config/netshift-070 ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Details: https://github.com/yandexru45/netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Details: https://uralgit.ru/ural/netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Are you sure you want to continue? ║\033[0m\n"
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
@ -88,7 +94,7 @@ update_config() {
yes|y|Y)
mv /etc/config/netshift /etc/config/netshift-070
wget -O /etc/config/netshift https://raw.githubusercontent.com/yandexru45/netshift/refs/heads/main/netshift/files/etc/config/netshift
wget -O /etc/config/netshift https://uralgit.ru/ural/netshift/raw/branch/main/netshift/files/etc/config/netshift
msg "NetShift config has been reset to default. Your old config saved in /etc/config/netshift-070"
break
;;
@ -129,7 +135,7 @@ migrate_from_podkop() {
printf "\033[48;5;196m\033[1m║ Ваша конфигурация будет перенесена автоматически. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Старая конфигурация сохранится в /etc/config/podkop.bak.pre-netshift║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Старый пакет podkop будет удалён, NetShift будет установлен. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Подробности: https://github.com/yandexru45/netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Подробности: https://uralgit.ru/ural/netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Точно хотите продолжить? ║\033[0m\n"
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
@ -140,7 +146,7 @@ migrate_from_podkop() {
printf "\033[48;5;196m\033[1m║ Your configuration will be carried over automatically. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Old config will be backed up to /etc/config/podkop.bak.pre-netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ The old podkop package will be removed, NetShift installed. ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Details: https://github.com/yandexru45/netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Details: https://uralgit.ru/ural/netshift ║\033[0m\n"
printf "\033[48;5;196m\033[1m║ Are you sure you want to continue? ║\033[0m\n"
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
@ -241,6 +247,30 @@ migrate_from_podkop() {
msg "Your old config is preserved at /etc/config/podkop.bak.pre-netshift"
}
# Download one release asset URL into $DOWNLOAD_DIR with retry. POSIX sh.
download_release_asset() {
url="$1"
filename="$2"
filepath="$DOWNLOAD_DIR/$filename"
attempt=0
while [ $attempt -lt $COUNT ]; do
msg "Download $filename (count $((attempt + 1)))..."
if wget -q -O "$filepath" "$url"; then
if [ -s "$filepath" ]; then
msg "$filename successfully downloaded"
return 0
fi
fi
msg "Download error for $filename. Retrying..."
rm -f "$filepath"
attempt=$((attempt + 1))
done
msg "Failed to download $filename after $COUNT attempts"
return 1
}
main() {
check_system
sing_box
@ -255,44 +285,63 @@ main() {
msg "Installing NetShift..."
fi
if command -v curl >/dev/null 2>&1; then
check_response=$(curl -s "https://api.github.com/repos/yandexru45/netshift/releases/latest")
if echo "$check_response" | grep -q 'API rate limit '; then
msg "You've reached the GitHub rate limit. Repeat in five minutes."
exit 1
fi
fi
local grep_url_pattern
local ext release_tag redirect_url
if [ "$PKG_IS_APK" -eq 1 ]; then
grep_url_pattern='https://[^"[:space:]]*\.apk'
ext="apk"
else
grep_url_pattern='https://[^"[:space:]]*\.ipk'
ext="ipk"
fi
wget -qO- "$REPO" | grep -o "$grep_url_pattern" | while read -r url; do
filename=$(basename "$url")
filepath="$DOWNLOAD_DIR/$filename"
# PRIMARY: resolve the latest tag via the uralgit.ru frontend redirect (no
# api.github.com hit → not subject to the 60/hour/IP rate limit), then build
# the deterministic releases/download/<tag>/<asset> URLs and download them.
release_tag=""
if command -v curl >/dev/null 2>&1; then
redirect_url=$(curl -sI -o /dev/null -w '%{redirect_url}' \
--connect-timeout 5 -m 15 -A 'netshift-installer' \
"$RELEASES_LATEST_REDIRECT" 2>/dev/null)
case "$redirect_url" in
*/releases/tag/*)
release_tag="${redirect_url##*/releases/tag/}"
case "$release_tag" in '' | */*) release_tag="" ;; esac
;;
esac
fi
attempt=0
while [ $attempt -lt $COUNT ]; do
msg "Download $filename (count $((attempt+1)))..."
if wget -q -O "$filepath" "$url"; then
if [ -s "$filepath" ]; then
msg "$filename successfully downloaded"
break
fi
if [ -n "$release_tag" ]; then
msg "Latest NetShift release: $release_tag (direct download, no GitHub API)"
for pkg in netshift luci-app-netshift; do
if [ "$ext" = "ipk" ]; then
filename="${pkg}-${release_tag}-r1-all.${ext}"
else
filename="${pkg}-${release_tag}-r1.${ext}"
fi
msg "Download error for $filename. Retrying..."
rm -f "$filepath"
attempt=$((attempt+1))
download_release_asset "$RELEASES_DOWNLOAD_BASE/$release_tag/$filename" "$filename"
done
if [ $attempt -eq $COUNT ]; then
msg "Failed to download $filename after $COUNT attempts"
# RU i18n only if already installed (mirrors the install flow below).
if pkg_is_installed luci-i18n-netshift-ru; then
filename="luci-i18n-netshift-ru-${release_tag}.${ext}"
download_release_asset "$RELEASES_DOWNLOAD_BASE/$release_tag/$filename" "$filename"
fi
done
else
# FALLBACK: scrape the api.github.com release JSON for .ipk/.apk URLs.
if command -v curl >/dev/null 2>&1; then
check_response=$(curl -s "$REPO")
if echo "$check_response" | grep -q 'API rate limit '; then
msg "You've reached the GitHub rate limit. Repeat in five minutes."
exit 1
fi
fi
local grep_url_pattern
grep_url_pattern="https://[^\"[:space:]]*\.${ext}"
wget -qO- "$REPO" | grep -o "$grep_url_pattern" | while read -r url; do
filename=$(basename "$url")
download_release_asset "$url" "$filename"
done
fi
# Check if any files were downloaded
if ! ls "$DOWNLOAD_DIR"/*netshift* >/dev/null 2>&1; then

View File

@ -753,6 +753,33 @@ function validateProxyUrl(url) {
};
}
// src/validators/validateProxyUrlList.ts
function validateProxyUrlList(value) {
const lines = value.split("\n");
let hasLink = false;
for (let index = 0; index < lines.length; index++) {
const line = lines[index].trim();
if (line.length === 0) {
continue;
}
hasLink = true;
const validation = validateProxyUrl(line);
if (!validation.valid) {
return {
valid: false,
message: `${_("Line")} ${index + 1}: ${validation.message}`
};
}
}
if (!hasLink) {
return {
valid: false,
message: _("At least one proxy link must be specified.")
};
}
return { valid: true, message: "" };
}
// src/helpers/parseValueList.ts
function parseValueList(value) {
return value.split(/\n/).map((line) => line.split("//")[0]).join(" ").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
@ -6164,6 +6191,7 @@ return baseclass.extend({
validateOutboundJson,
validatePath,
validateProxyUrl,
validateProxyUrlList,
validateShadowsocksUrl,
validateSocksUrl,
validateSubnet,

View File

@ -54,6 +54,8 @@ function createSectionContent(section) {
o.value("url", _("Connection URL"));
o.value("selector", _("Selector"));
o.value("urltest", _("URLTest"));
o.value("selector_text", _("Selector (text list)"));
o.value("urltest_text", _("URLTest (text list)"));
o.value("subscription", _("Subscription"));
o.value("outbound", _("Outbound Config"));
o.default = "url";
@ -189,17 +191,36 @@ function createSectionContent(section) {
o = section.taboption(
"subscription",
form.Flag,
"subscription_group_by_countries",
_("Group by countries"),
form.ListValue,
"subscription_group_mode",
_("Subscription grouping"),
_(
"Group subscription proxies into separate URLTest groups by the country flag at the start of each tag",
"Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters.",
),
);
o.default = "0";
o.value("off", _("Off"));
o.value("country", _("By country flag"));
o.value("prefix", _("By name prefix"));
o.default = "off";
o.rmempty = false;
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
o = section.taboption(
"subscription",
form.Value,
"subscription_group_prefix_len",
_("Prefix length"),
_("Number of leading characters of each proxy name to group by."),
);
o.default = "2";
o.datatype = "and(uinteger,min(1))";
o.rmempty = false;
o.depends({
connection_type: "proxy",
proxy_config_type: "subscription",
subscription_group_mode: "prefix",
});
o = section.taboption(
"subscription",
form.DynamicList,
@ -250,6 +271,35 @@ function createSectionContent(section) {
return validation.message;
};
o = section.taboption(
"connection",
form.TextValue,
"selector_proxy_links_text",
_("Selector Proxy Links (one per line)"),
_(
"vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line",
),
);
o.depends({ connection_type: "proxy", proxy_config_type: "selector_text" });
o.rows = 5;
o.wrap = "soft";
o.textarea = true;
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrlList(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.taboption(
"subscription",
form.DynamicList,
@ -276,6 +326,35 @@ function createSectionContent(section) {
return validation.message;
};
o = section.taboption(
"subscription",
form.TextValue,
"urltest_proxy_links_text",
_("URLTest Proxy Links (one per line)"),
_(
"vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line",
),
);
o.depends({ connection_type: "proxy", proxy_config_type: "urltest_text" });
o.rows = 5;
o.wrap = "soft";
o.textarea = true;
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrlList(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = section.taboption(
"subscription",
form.ListValue,
@ -289,6 +368,7 @@ function createSectionContent(section) {
o.value("5m", _("Every 5 minutes"));
o.default = "3m";
o.depends({ connection_type: "proxy", proxy_config_type: "urltest" });
o.depends({ connection_type: "proxy", proxy_config_type: "urltest_text" });
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
o = section.taboption(
@ -303,6 +383,7 @@ function createSectionContent(section) {
o.default = "50";
o.rmempty = false;
o.depends({ connection_type: "proxy", proxy_config_type: "urltest" });
o.depends({ connection_type: "proxy", proxy_config_type: "urltest_text" });
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
o.validate = function (section_id, value) {
if (!value || value.length === 0) {
@ -347,6 +428,7 @@ function createSectionContent(section) {
o.default = "https://www.gstatic.com/generate_204";
o.rmempty = false;
o.depends({ connection_type: "proxy", proxy_config_type: "urltest" });
o.depends({ connection_type: "proxy", proxy_config_type: "urltest_text" });
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
o.validate = function (section_id, value) {

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: NETSHIFT\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-11 20:31+0300\n"
"PO-Revision-Date: 2026-06-11 20:31+0300\n"
"POT-Creation-Date: 2026-06-13 14:14+0300\n"
"PO-Revision-Date: 2026-06-13 14:14+0300\n"
"Last-Translator: yandexru45\n"
"Language-Team: none\n"
"Language: ru\n"
@ -56,6 +56,9 @@ msgstr "Обеспечивает доступ к YACD из WAN. Убедитес
msgid "Applicable for SOCKS and Shadowsocks proxy"
msgstr "Применимо для SOCKS и Shadowsocks прокси"
msgid "At least one proxy link must be specified."
msgstr "Необходимо указать хотя бы одну прокси-ссылку."
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
@ -86,6 +89,12 @@ msgstr "Браузер не использует FakeIP"
msgid "Browser is using FakeIP correctly"
msgstr "Браузер использует FakeIP"
msgid "By country flag"
msgstr "По флагу страны"
msgid "By name prefix"
msgstr "По префиксу имени"
msgid "Cache File Path"
msgstr "Путь к файлу кэша"
@ -368,11 +377,8 @@ msgstr "Глобальная проверка"
msgid "Global Proxy"
msgstr "Глобальный прокси"
msgid "Group by countries"
msgstr "Группировать по странам"
msgid "Group subscription proxies into separate URLTest groups by the country flag at the start of each tag"
msgstr "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
msgid "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters."
msgstr "Группировать прокси из подписки в группы URLTest. «По флагу страны» использует эмодзи флага в начале каждого имени; «По префиксу имени» группирует по первым N символам."
msgid "How often to automatically update the subscription"
msgstr "Как часто автоматически обновлять подписку"
@ -569,6 +575,9 @@ msgstr "Последняя"
msgid "Latest version is installed"
msgstr "Установлена последняя версия"
msgid "Line"
msgstr "Строка"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
@ -641,6 +650,12 @@ msgstr "Не отвечает"
msgid "Not running"
msgstr "Не запущено"
msgid "Number of leading characters of each proxy name to group by."
msgstr "Количество начальных символов имени каждого прокси для группировки."
msgid "Off"
msgstr "Выключено"
msgid "Only one section can be global at a time."
msgstr "Только одна секция может быть глобальной одновременно."
@ -674,6 +689,9 @@ msgstr "Путь должен заканчиваться на cache.db"
msgid "Pending"
msgstr "Ожидает запуска"
msgid "Prefix length"
msgstr "Длина префикса"
msgid "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."
msgstr "Переключатели протоколов, пути к файлам и журналирование. Включайте блокировку DoH только после переключения вышестоящего DNS на UDP или DoT."
@ -791,9 +809,15 @@ msgstr "Выберите WAN интерфейсы для мониторинга"
msgid "Selector"
msgstr "Selector"
msgid "Selector (text list)"
msgstr "Selector (текстовый список)"
msgid "Selector Proxy Links"
msgstr "Ссылки прокси для Selector"
msgid "Selector Proxy Links (one per line)"
msgstr "Прокси-ссылки Selector (по одной в строке)"
msgid "Self-update failed"
msgstr "Не удалось обновить"
@ -872,6 +896,9 @@ msgstr "Источники подписок, фильтры серверов и
msgid "Subscription format"
msgstr "Формат подписки"
msgid "Subscription grouping"
msgstr "Группировка подписки"
msgid "Subscription Update Interval"
msgstr "Интервал обновления подписки"
@ -977,12 +1004,18 @@ msgstr "URL должен использовать один из следующи
msgid "URLTest"
msgstr "URLTest"
msgid "URLTest (text list)"
msgstr "URLTest (текстовый список)"
msgid "URLTest Check Interval"
msgstr "Интервал проверки URLTest"
msgid "URLTest Proxy Links"
msgstr "Ссылки прокси для URLTest"
msgid "URLTest Proxy Links (one per line)"
msgstr "Прокси-ссылки URLTest (по одной в строке)"
msgid "URLTest Testing URL"
msgstr "URLTest ссылка для проверки"
@ -1028,6 +1061,9 @@ msgstr "Перейти в wiki"
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2://"
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line"
msgstr "Ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// — по одной в строке"
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: NETSHIFT\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-11 17:31+0300\n"
"PO-Revision-Date: 2026-06-11 17:31+0300\n"
"POT-Creation-Date: 2026-06-13 11:14+0300\n"
"PO-Revision-Date: 2026-06-13 11:14+0300\n"
"Last-Translator: yandexru45 <>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@ -36,15 +36,15 @@ msgstr ""
msgid "Active Connections"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:123
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:125
msgid "Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:588
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:670
msgid "Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:676
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:758
msgid "Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
msgstr ""
@ -57,7 +57,7 @@ msgstr ""
msgid "Advanced"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:163
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:165
msgid "Allow insecure TLS for subscription fetch"
msgstr ""
@ -65,19 +65,23 @@ msgstr ""
msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:371
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:453
msgid "Applicable for SOCKS and Shadowsocks proxy"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:648
#: src/validators/validateProxyUrlList.ts:39
msgid "At least one proxy link must be specified."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:730
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:737
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:819
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:153
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:155
msgid "Auto"
msgstr ""
@ -109,6 +113,14 @@ msgstr ""
msgid "Browser is using FakeIP correctly"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:202
msgid "By country flag"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:203
msgid "By name prefix"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:517
msgid "Cache File Path"
msgstr ""
@ -166,7 +178,7 @@ msgstr ""
msgid "Close"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:494
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:576
msgid "Community Lists"
msgstr ""
@ -219,11 +231,11 @@ msgstr ""
msgid "Currently unavailable"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:587
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:669
msgid "Custom domains"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:675
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:757
msgid "Custom subnets"
msgstr ""
@ -268,12 +280,12 @@ msgstr ""
msgid "Disable the QUIC protocol to improve compatibility or fix issues with video streaming"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:592
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:680
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:674
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:762
msgid "Disabled"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:164
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:166
msgid "Disables TLS certificate verification when downloading the subscription."
msgstr ""
@ -289,17 +301,17 @@ msgstr ""
msgid "DNS outbound section"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:460
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:542
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:48
msgid "DNS over HTTPS (DoH)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:461
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:543
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:49
msgid "DNS over TLS (DoT)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:457
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:539
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:45
msgid "DNS Protocol Type"
msgstr ""
@ -308,7 +320,7 @@ msgstr ""
msgid "DNS Rewrite TTL"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:471
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:553
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:58
msgid "DNS Server"
msgstr ""
@ -325,7 +337,7 @@ msgstr ""
msgid "Domain and subnet lists that decide which traffic uses this section"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:446
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528
msgid "Domain Resolver"
msgstr ""
@ -355,12 +367,12 @@ msgstr ""
msgid "Downloading all lists via specific Proxy/VPN"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:220
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:241
msgid "Drop subscription servers whose name contains any of these keywords (case-insensitive)."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:593
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:681
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:675
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:763
msgid "Dynamic List"
msgstr ""
@ -368,11 +380,11 @@ msgstr ""
msgid "Enable autostart"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:447
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:529
msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:914
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:996
msgid "Enable DNS resolve to get real IP when routing"
msgstr ""
@ -384,7 +396,7 @@ msgstr ""
msgid "Enable IPv6 TProxy routing, IPv6 DNS inbound, and IPv6 FakeIP support."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:883
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:965
msgid "Enable Mixed Proxy"
msgstr ""
@ -392,7 +404,7 @@ msgstr ""
msgid "Enable Output Network Interface"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:884
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:966
msgid "Enable the mixed proxy, allowing this section to route traffic through both HTTP and SOCKS proxies"
msgstr ""
@ -404,59 +416,59 @@ msgstr ""
msgid "Enable YACD WAN Access"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:99
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:101
msgid "Enter complete outbound configuration in JSON format"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:630
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:712
msgid "Enter domain names separated by commas, spaces, or newlines. You can add comments using //"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:603
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:685
msgid "Enter domain names without protocols, e.g. example.com or sub.example.com"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:691
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:773
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:287
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:366
msgid "Every 1 minute"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:185
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:187
msgid "Every 12 hours"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:183
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:185
msgid "Every 3 hours"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:288
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:367
msgid "Every 3 minutes"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:181
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:183
msgid "Every 30 minutes"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:286
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:365
msgid "Every 30 seconds"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:289
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:368
msgid "Every 5 minutes"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:184
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:186
msgid "Every 6 hours"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:186
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:188
msgid "Every day"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:182
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:184
msgid "Every hour"
msgstr ""
@ -468,7 +480,7 @@ msgstr ""
msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:219
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:240
msgid "Exclude servers by keyword"
msgstr ""
@ -506,7 +518,7 @@ msgstr ""
msgid "Fastest"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:855
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:937
msgid "Fully Routed IPs"
msgstr ""
@ -518,19 +530,15 @@ msgstr ""
msgid "Global check"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:381
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:463
msgid "Global Proxy"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:194
msgid "Group by countries"
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:197
msgid "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:195
msgid "Group subscription proxies into separate URLTest groups by the country flag at the start of each tag"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:179
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:181
msgid "How often to automatically update the subscription"
msgstr ""
@ -538,7 +546,7 @@ msgstr ""
msgid "HTTP error"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:207
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:228
msgid "Include servers by keyword"
msgstr ""
@ -784,7 +792,7 @@ msgstr ""
msgid "Issues detected"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:208
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:229
msgid "Keep only subscription servers whose name contains at least one of these keywords (case-insensitive). Leave empty to keep all."
msgstr ""
@ -797,6 +805,10 @@ msgstr ""
msgid "Latest version is installed"
msgstr ""
#: src/validators/validateProxyUrlList.ts:31
msgid "Line"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:327
msgid "List Update Frequency"
msgstr ""
@ -809,11 +821,11 @@ msgstr ""
msgid "Lists & Updates"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:759
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:841
msgid "Local Domain Lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:783
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:865
msgid "Local Subnet Lists"
msgstr ""
@ -837,7 +849,7 @@ msgstr ""
msgid "Mixed proxy and DNS resolution tuning"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:897
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:979
msgid "Mixed Proxy Port"
msgstr ""
@ -845,7 +857,7 @@ msgstr ""
msgid "Monitored Interfaces"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:324
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:405
msgid "Must be a number in the range of 50 - 1000"
msgstr ""
@ -869,7 +881,7 @@ msgstr ""
msgid "Network"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:399
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481
msgid "Network Interface"
msgstr ""
@ -902,7 +914,15 @@ msgstr ""
msgid "Not running"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:390
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:213
msgid "Number of leading characters of each proxy name to group by."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:201
msgid "Off"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472
msgid "Only one section can be global at a time."
msgstr ""
@ -910,11 +930,11 @@ msgstr ""
msgid "Operation timed out"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:58
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:60
msgid "Outbound Config"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:98
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:100
msgid "Outbound Configuration"
msgstr ""
@ -951,11 +971,15 @@ msgstr ""
msgid "Pending"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:212
msgid "Prefix length"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:37
msgid "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:66
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:68
msgid "Proxy Configuration URL"
msgstr ""
@ -967,19 +991,19 @@ msgstr ""
msgid "Proxy traffic is routed via FakeIP"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:610
msgid "Regional options cannot be used together"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:807
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:889
msgid "Remote Domain Lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:831
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:913
msgid "Remote Subnet Lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:913
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:995
msgid "Resolve real IP for routing"
msgstr ""
@ -987,7 +1011,7 @@ msgstr ""
msgid "Restart NetShift"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:382
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:464
msgid "Route all unmatched traffic through this section's outbound."
msgstr ""
@ -1035,7 +1059,7 @@ msgstr ""
msgid "Run Diagnostic"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:547
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629
msgid "Russia inside restrictions"
msgstr ""
@ -1047,7 +1071,7 @@ msgstr ""
msgid "Sections"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:495
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:577
msgid "Select a predefined list for routing"
msgstr ""
@ -1067,11 +1091,11 @@ msgstr ""
msgid "Select how to configure the proxy"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:400
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:482
msgid "Select network interface for VPN connection"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:554
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:59
msgid "Select or enter DNS server address"
msgstr ""
@ -1084,7 +1108,7 @@ msgstr ""
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:458
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:540
msgid "Select the DNS protocol type for the domain resolver"
msgstr ""
@ -1108,10 +1132,18 @@ msgstr ""
msgid "Selector"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:231
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:57
msgid "Selector (text list)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:252
msgid "Selector Proxy Links"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:278
msgid "Selector Proxy Links (one per line)"
msgstr ""
#: src/netshift/methods/shell/index.ts:303
msgid "Self-update failed"
msgstr ""
@ -1133,7 +1165,7 @@ msgstr ""
msgid "Show sing-box config"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:155
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:157
#: src/netshift/tabs/dashboard/initController.ts:354
msgid "Sing-box"
msgstr ""
@ -1178,20 +1210,20 @@ msgstr ""
msgid "Specify a local IP address to be excluded from routing"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:856
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:938
msgid "Specify local IP addresses or subnets whose traffic will always be routed through the configured route"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:808
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:890
msgid "Specify remote URLs to download and use domain lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:832
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:914
msgid "Specify remote URLs to download and use subnet lists"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:760
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:784
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:842
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:866
msgid "Specify the path to the list file located on the router filesystem"
msgstr ""
@ -1204,7 +1236,7 @@ msgid "Stop NetShift"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:21
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:57
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:59
msgid "Subscription"
msgstr ""
@ -1216,15 +1248,19 @@ msgstr ""
msgid "Subscription feeds, server filters and URLTest tuning"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:148
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:150
msgid "Subscription format"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:178
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:196
msgid "Subscription grouping"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:180
msgid "Subscription Update Interval"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:122
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:124
msgid "Subscription URLs"
msgstr ""
@ -1260,8 +1296,8 @@ msgstr ""
msgid "Test latency"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:594
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:682
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:676
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:764
msgid "Text List"
msgstr ""
@ -1269,19 +1305,19 @@ msgstr ""
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:284
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:363
msgid "The interval between connectivity tests"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:299
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:379
msgid "The maximum difference in response times (ms) allowed when comparing servers"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:332
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:413
msgid "The URL used to test server connectivity"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:168
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:170
msgid "This is a security trade-off: an attacker could intercept the fetch."
msgstr ""
@ -1309,12 +1345,12 @@ msgstr ""
msgid "TTL value cannot be empty"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:462
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:544
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:50
msgid "UDP (Unprotected DNS)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:370
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:452
msgid "UDP over TCP"
msgstr ""
@ -1376,23 +1412,31 @@ msgstr ""
msgid "URLTest"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:283
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:58
msgid "URLTest (text list)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:362
msgid "URLTest Check Interval"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:257
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:307
msgid "URLTest Proxy Links"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:331
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:333
msgid "URLTest Proxy Links (one per line)"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:412
msgid "URLTest Testing URL"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:298
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:378
msgid "URLTest Tolerance"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:166
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:168
msgid "Use only for IP-host panels that serve an invalid or self-signed certificate."
msgstr ""
@ -1400,23 +1444,23 @@ msgstr ""
msgid "Use this only when the router has working IPv6 connectivity."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:388
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:470
msgid "Use with Exclusion sections to route specific domains directly."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:602
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:684
msgid "User Domains"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:711
msgid "User Domains List"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:690
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:772
msgid "User Subnets"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:717
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:799
msgid "User Subnets List"
msgstr ""
@ -1442,8 +1486,8 @@ msgstr ""
msgid "Valid"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:662
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:749
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:744
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:831
msgid "Validation errors:"
msgstr ""
@ -1460,21 +1504,26 @@ msgstr ""
msgid "Visit Wiki"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:67
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:232
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:258
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:69
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:253
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:308
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:530
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:279
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:334
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:612
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:549
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:631
msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:384
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:466
msgid "When enabled, traffic not matching any other section's lists will go through this proxy."
msgstr ""
@ -1482,11 +1531,11 @@ msgstr ""
msgid "Which proxy/VPN section carries the DNS. Leave unset to use the first configured outbound."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:149
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:151
msgid "Which subscription format (client) to fetch first. Auto uses the default order. Choose Xray JSON (Happ) when your panel only exposes some nodes (e.g. xhttp) under a Happ-like client, or Sing-box to prefer the sing-box format."
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:154
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:156
msgid "Xray JSON (Happ)"
msgstr ""

View File

@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=netshift
PKG_VERSION := $(if $(NETSHIFT_VERSION),$(NETSHIFT_VERSION),0.$(shell date +%d%m%Y))
PKG_VERSION := $(if $(NETSHIFT_VERSION),$(NETSHIFT_VERSION),0.9.6)
PKG_RELEASE:=1

View File

@ -70,6 +70,12 @@ config section 'main'
# # is set (an explicit UA always wins).
# #option subscription_format_preference 'auto'
# option subscription_update_interval '1h'
# # Node grouping: off | country | prefix. 'country' clusters by leading
# # flag emoji; 'prefix' clusters by the first N codepoints of each node
# # name (N = subscription_group_prefix_len). subscription_group_mode
# # outranks the legacy subscription_group_by_countries boolean below.
# #option subscription_group_mode 'off'
# #option subscription_group_prefix_len '2'
# #option subscription_group_by_countries '0'
# #option urltest_check_interval '3m'
# #option urltest_tolerance '50'
@ -80,5 +86,28 @@ config section 'main'
# #list subscription_filter_include_keywords 'grpc'
# # Keyword blacklist: drop any node whose display name contains any of
# # these (OR). Empty/absent = no exclusion.
# #list subscription_filter_exclude_keywords 'expired'
# list community_lists 'russia_inside'
# #list subscription_filter_exclude_keywords 'expired'
# list community_lists 'russia_inside'
# Text-list Selector / URLTest: paste proxy links into a single multi-line
# textarea option (one link per line) instead of one DynamicList item each. The
# supported links (vless/vmess/ss/trojan/hysteria2/socks) are built into a
# Selector ('selector_text') or a URLTest + selector ('urltest_text'); blank
# lines and unsupported schemes are skipped. The value is a scalar newline-
# delimited blob — NOT a UCI list.
#config section 'selector_text_example'
# option connection_type 'proxy'
# option proxy_config_type 'selector_text'
# # one link per line in a single multi-line option value
# option selector_proxy_links_text 'vless://example-node-1
#vless://example-node-2'
#config section 'urltest_text_example'
# option connection_type 'proxy'
# option proxy_config_type 'urltest_text'
# # one link per line in a single multi-line option value
# option urltest_proxy_links_text 'vless://example-node-1
#vless://example-node-2'
# #option urltest_check_interval '3m'
# #option urltest_tolerance '50'
# #option urltest_testing_url 'https://www.gstatic.com/generate_204'

View File

@ -80,7 +80,7 @@ check_requirements() {
if has_outbound_section; then
log "Outbound section found" "debug"
else
log "Outbound section not found. Please check your configuration file (missing proxy_string, selector_proxy_links, urltest_proxy_links, subscription_url, outbound_json, or interface according to connection_type/proxy_config_type). Aborted." "error"
log "Outbound section not found. Please check your configuration file (missing proxy_string, selector_proxy_links, urltest_proxy_links, selector_proxy_links_text, urltest_proxy_links_text, subscription_url, outbound_json, or interface according to connection_type/proxy_config_type). Aborted." "error"
exit 1
fi
}
@ -111,15 +111,26 @@ section_has_configured_outbound() {
config_get urltest_proxy_links "$section" "urltest_proxy_links"
[ -n "$urltest_proxy_links" ] && return 0
;;
selector_text)
local selector_proxy_links_text
config_get selector_proxy_links_text "$section" "selector_proxy_links_text"
[ -n "$selector_proxy_links_text" ] && return 0
;;
urltest_text)
local urltest_proxy_links_text
config_get urltest_proxy_links_text "$section" "urltest_proxy_links_text"
[ -n "$urltest_proxy_links_text" ] && return 0
;;
outbound)
local outbound_json
config_get outbound_json "$section" "outbound_json"
[ -n "$outbound_json" ] && return 0
;;
subscription)
# subscription_url is now a UCI list (a lone legacy option reads as a
# 1-element list). The section has a configured outbound if at least
# one URL is present.
# get_subscription_urls_for_section handles both the UCI `list` shape
# (new UI configs) and a scalar `option` shape (legacy / CLI /
# migrated configs), so the section has a configured outbound if it
# returns at least one URL.
[ -n "$(get_subscription_urls_for_section "$section")" ] && return 0
;;
esac
@ -197,9 +208,10 @@ get_subscription_user_agent_cache_path() {
echo "$SUBSCRIPTION_CACHE_FOLDER/${section}${urlhash:+.$urlhash}.user_agent"
}
# Collect a section's subscription_url entries (a UCI list, but a lone legacy
# `option subscription_url` reads as a 1-element list exactly like
# community_lists) into the newline-delimited global SUBSCRIPTION_URLS_COLLECTED.
# Collect a section's subscription_url entries (a UCI list; a legacy / CLI /
# migrated `option subscription_url` is a scalar that config_list_foreach does
# NOT iterate — get_subscription_urls_for_section handles that shape with a
# scalar fallback) into the newline-delimited global SUBSCRIPTION_URLS_COLLECTED.
# URLs are opaque user text and may contain shell-special chars, so they are
# accumulated newline-delimited (URLs cannot contain a newline) and consumers
# read them with `while IFS= read -r`, never via word-splitting.
@ -217,9 +229,23 @@ $url"
get_subscription_urls_for_section() {
local section="$1"
local scalar_url
SUBSCRIPTION_URLS_COLLECTED=""
config_list_foreach "$section" "subscription_url" _collect_subscription_url_handler
# Backward compat: legacy / CLI / podkop-migrated configs store
# subscription_url as a scalar `option` (not a `list`). config_list_foreach
# iterates ONLY list values, so it returns nothing for a scalar option. Fall
# back to a scalar read and treat it as a 1-element list. (PROVEN on hardware:
# config_list_foreach over an option => empty; config_get => the value.) This
# is the load-bearing fix and must stand alone even when the option->list
# migration is skipped (read-only fs / uci failure).
if [ -z "$SUBSCRIPTION_URLS_COLLECTED" ]; then
config_get scalar_url "$section" "subscription_url"
[ -n "$scalar_url" ] && _collect_subscription_url_handler "$scalar_url"
fi
printf '%s' "$SUBSCRIPTION_URLS_COLLECTED"
}
@ -583,6 +609,16 @@ download_subscription_into_cache() {
continue
fi
# Some panels return a gzip-compressed body unconditionally (we send no
# Accept-Encoding and busybox wget does not transparently decompress).
# Decompress in place before validate/normalize so every consumer sees
# text. Best-effort: a plain-text body is left untouched.
maybe_gunzip_subscription_file "$tmpfile"
if subscription_body_is_binary "$tmpfile"; then
log "Subscription body for section '$section' is binary/undecodable after gzip handling (not text, not gzip) with User-Agent '$effective_user_agent'; trying next candidate" "warn"
continue
fi
file_size="$(wc -c < "$tmpfile" 2>/dev/null | tr -d ' ')"
log "Downloaded subscription body for section '$section': bytes=${file_size:-unknown}, User-Agent='$effective_user_agent'" "debug"
@ -829,6 +865,12 @@ start_subscription_startup_retry_worker() {
start_main() {
log "Starting netshift"
# Normalize legacy scalar `option subscription_url` to the canonical `list`
# shape BEFORE check_requirements / config generation read the URLs. The
# read-fallback in get_subscription_urls_for_section already covers
# correctness; this is hygiene that converges stored configs to a list.
migrate_legacy_subscription_url_option
check_requirements
migration
@ -1160,6 +1202,71 @@ migration() {
:
}
# config_foreach callback for migrate_legacy_subscription_url_option. For a
# subscription section whose subscription_url is stored as a scalar `option`
# (legacy / CLI / podkop-migrated configs) rather than a UCI `list`, rewrite it
# in place as a `list` via uci. Detects the broken shape robustly: the LIST read
# (config_list_foreach) yields nothing AND a scalar config_get is non-empty —
# exactly the option-only shape, so an already-correct list is never touched.
# Sets the module-level SUBSCRIPTION_URL_OPTION_MIGRATED flag when it changes
# anything so the caller commits + reloads exactly once. Never exits: any uci
# failure is logged at warn and skipped (the read-fallback in
# get_subscription_urls_for_section covers correctness regardless).
_migrate_legacy_subscription_url_option_handler() {
local section="$1"
local connection_type proxy_config_type scalar_url
config_get connection_type "$section" "connection_type"
[ "$connection_type" = "proxy" ] || return 0
config_get proxy_config_type "$section" "proxy_config_type" "url"
[ "$proxy_config_type" = "subscription" ] || return 0
# Only the broken shape: empty via the list path but present as a scalar.
SUBSCRIPTION_URLS_COLLECTED=""
config_list_foreach "$section" "subscription_url" _collect_subscription_url_handler
[ -z "$SUBSCRIPTION_URLS_COLLECTED" ] || return 0
config_get scalar_url "$section" "subscription_url"
[ -n "$scalar_url" ] || return 0
# Rewrite the scalar option as a list. Use the uci_add_list SHELL HELPER
# (from /lib/functions.sh), NOT the `uci add_list "key=value"` CLI form: the
# CLI form splits on the FIRST `=`, so a URL with a query string (very common,
# e.g. "...?token=abc&x=1") makes the CLI add_list fail and lose the value.
# The helper passes the value as a separate argument, preserving `=`/`&`
# byte-for-byte. Delete the scalar first so the result is a CLEAN single-
# element list (adding while the scalar option still exists would duplicate
# it into a 2-element list); if the add then fails, RESTORE the scalar option
# so a failed migration can never leave the section with NO url. Never exits.
uci -q delete "netshift.${section}.subscription_url" 2>/dev/null
if uci_add_list netshift "$section" subscription_url "$scalar_url" 2>/dev/null; then
SUBSCRIPTION_URL_OPTION_MIGRATED=1
log "Migrated legacy scalar subscription_url to list for section '$section'" "info"
else
uci_set netshift "$section" subscription_url "$scalar_url"
log "Failed to migrate scalar subscription_url to list for section '$section'; restored the original option and continuing (read fallback covers correctness)" "warn"
fi
}
# One-time, idempotent normalization of a legacy scalar `option subscription_url`
# into the canonical `list subscription_url`. Runs once at startup AFTER
# config_load and BEFORE config generation reads the URLs. Idempotent: a config
# already using `list` is left untouched (no commit, no churn). Never exits.
migrate_legacy_subscription_url_option() {
SUBSCRIPTION_URL_OPTION_MIGRATED=0
config_foreach _migrate_legacy_subscription_url_option_handler "section"
if [ "$SUBSCRIPTION_URL_OPTION_MIGRATED" -eq 1 ]; then
if uci commit "netshift" 2>/dev/null; then
config_load "$NETSHIFT_CONFIG"
else
log "Failed to commit subscription_url option->list migration; continuing (read fallback covers correctness)" "warn"
fi
fi
}
validate_service() {
local service="$1"
@ -2141,10 +2248,25 @@ sing_box_get_unique_outbound_tag() {
echo "$candidate"
}
sing_box_build_subscription_country_groups() {
# Mode-aware subscription group-key builder (task-044). Generalizes the former
# country-only grouper into off/country/prefix modes. Returns the shape
# {group_order: [...], groups: {key: [tags]}, ungrouped: [...]}.
# - mode=country: byte-identical to the legacy flag extractor (regional-
# indicator gate; non-flag tags -> ungrouped).
# - mode=prefix: group key = first N codepoints of the tag (N = $3). A tag
# shorter than N codepoints keys by its WHOLE tag (still groups with
# identical short tags); an empty tag -> ungrouped. $3 is coerced via
# tonumber, floored to 1; bad/0/empty -> default 2.
# No Oniguruma jq (explode/implode/slice/index/reduce only).
sing_box_build_subscription_groups() {
local subscription_outbound_tags_json="$1"
local mode="$2"
local prefix_len="$3"
printf '%s' "$subscription_outbound_tags_json" | jq -c '
printf '%s' "$subscription_outbound_tags_json" | jq -c \
--arg mode "$mode" \
--arg prefix_len "$prefix_len" \
--argjson default_len "$SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN" '
def is_regional_indicator: . >= 127462 and . <= 127487;
def extract_country_flag:
(. | explode) as $codepoints
@ -2154,17 +2276,33 @@ sing_box_build_subscription_country_groups() {
then ($codepoints[0:2] | implode)
else ""
end;
# extract_prefix($n): group key = first $n codepoints, or "" (ungrouped)
# for an empty tag. A short tag keys by its whole self.
def extract_prefix($n):
(. | explode) as $codepoints
| if ($codepoints | length) == 0
then ""
else ($codepoints[0:$n] | implode)
end;
(if type == "array" then . else [] end) as $tags
# Effective prefix length: coerce, floor to 1, fall back to default.
(try ($prefix_len | tonumber) catch $default_len) as $raw_len
| (if ($raw_len | type) != "number" or $raw_len < 1
then $default_len
else ($raw_len | floor)
end) as $n
| (if type == "array" then . else [] end) as $tags
| reduce $tags[] as $tag (
{country_order: [], country_groups: {}, ungrouped: []};
($tag | extract_country_flag) as $country_flag
| if $country_flag == "" then
{group_order: [], groups: {}, ungrouped: []};
(if $mode == "prefix" then ($tag | extract_prefix($n))
else ($tag | extract_country_flag) end) as $key
| if $key == "" then
.ungrouped += [$tag]
else
.country_groups[$country_flag] = ((.country_groups[$country_flag] // []) + [$tag])
| if (.country_order | index($country_flag)) == null then
.country_order += [$country_flag]
.groups[$key] = ((.groups[$key] // []) + [$tag])
| if (.group_order | index($key)) == null then
.group_order += [$key]
else
.
end
@ -2186,6 +2324,61 @@ is_truthy_option() {
esac
}
# Shared per-link member-outbound builder for the selector/urltest/selector_text/
# urltest_text proxy types. Iterates a whitespace/newline-separated blob of proxy
# links, creating one outbound per supported link via the facade and collecting
# their tags. Unsupported schemes are skipped with a warn (the facade echoes the
# config UNCHANGED and returns non-zero, so a single bad link never wipes the
# config nor leaves a selector referencing a non-existent outbound). A trailing
# CR (pasted CRLF blobs) is stripped per link and blank lines are skipped, so the
# text-list types tolerate pasted multi-line input. POSIX ash, no Oniguruma.
#
# Mutates the GLOBAL $config in place (same echo-and-reassign discipline as the
# subscription branch uses for its in-shell loop) and reports results via two
# GLOBALS the caller reads after the call:
# _member_outbound_tags comma-joined member outbound tags (empty if none)
# _member_default_outbound first member tag (selector default; empty if none)
# Args: <section> <links_blob> <udp_over_tcp> <label>
_build_proxy_member_outbounds() {
local section="$1"
local links_blob="$2"
local udp_over_tcp="$3"
local label="$4"
local link i outbound_tag _new_config cr
_member_outbound_tags=""
_member_default_outbound=""
cr="$(printf '\r')"
i=1
for link in $links_blob; do
# Tolerate pasted CRLF blobs: strip a trailing CR and skip blank lines.
link="${link%"$cr"}"
[ -n "$link" ] || continue
# The facade returns non-zero (config echoed UNCHANGED) for an
# unsupported scheme. Only add the member tag when the outbound was
# actually created, so the selector/urltest never references a
# non-existent outbound; a single bad link is skipped and the remaining
# links still build. Reassign $config only on a non-empty result so a
# skip never wipes the config.
if _new_config="$(sing_box_cf_add_proxy_outbound "$config" "$section-$i" "$link" "$udp_over_tcp")" \
&& [ -n "$_new_config" ]; then
config="$_new_config"
outbound_tag="$(get_outbound_tag_by_section "$section-$i")"
if [ -z "$_member_outbound_tags" ]; then
_member_outbound_tags="$outbound_tag"
_member_default_outbound="$outbound_tag"
else
_member_outbound_tags="$_member_outbound_tags,$outbound_tag"
fi
else
log "$label section '$section' link #$i uses an unsupported scheme; skipping it" "warn"
fi
i=$((i + 1))
done
}
configure_outbound_handler() {
local section="$1"
@ -2232,7 +2425,7 @@ configure_outbound_handler() {
;;
selector)
log "Detected proxy configuration type: selector" "debug"
local selector_proxy_links udp_over_tcp i outbound_tags outbound_tag default_outbound _new_config
local selector_proxy_links udp_over_tcp selector_tag selector_outbounds
config_get selector_proxy_links "$section" "selector_proxy_links"
config_get udp_over_tcp "$section" "enable_udp_over_tcp"
@ -2241,44 +2434,22 @@ configure_outbound_handler() {
exit 1
fi
i=1
for link in $selector_proxy_links; do
# The facade returns non-zero (config echoed UNCHANGED) for an
# unsupported scheme. Only add the member tag when the outbound
# was actually created, so the selector never references a
# non-existent outbound; a single bad link is skipped and the
# remaining links still build. Reassign $config only on a
# non-empty result so a skip never wipes the config.
if _new_config="$(sing_box_cf_add_proxy_outbound "$config" "$section-$i" "$link" "$udp_over_tcp")" \
&& [ -n "$_new_config" ]; then
config="$_new_config"
outbound_tag="$(get_outbound_tag_by_section "$section-$i")"
if [ -z "$outbound_tags" ]; then
outbound_tags="$outbound_tag"
default_outbound="$outbound_tag"
else
outbound_tags="$outbound_tags,$outbound_tag"
fi
else
log "Selector section '$section' link #$i uses an unsupported scheme; skipping it" "warn"
fi
i=$((i + 1))
done
_build_proxy_member_outbounds "$section" "$selector_proxy_links" "$udp_over_tcp" "Selector"
if [ -z "$outbound_tags" ]; then
if [ -z "$_member_outbound_tags" ]; then
echolog "Selector section '$section' has no usable links (all unsupported); its traffic will be rejected until a supported link is configured" "error"
mark_section_outbound_unavailable "$section"
else
selector_tag="$(get_outbound_tag_by_section "$section")"
selector_outbounds="$(comma_string_to_json_array "$outbound_tags")"
selector_outbounds="$(comma_string_to_json_array "$_member_outbound_tags")"
config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" \
"$default_outbound" "true")"
"$_member_default_outbound" "true")"
fi
;;
urltest)
log "Detected proxy configuration type: urltest" "debug"
local urltest_proxy_links udp_over_tcp i urltest_tag selector_tag outbound_tag outbound_tags \
urltest_outbounds selector_outbounds urltest_check_interval urltest_tolerance urltest_testing_url _new_config
local urltest_proxy_links udp_over_tcp urltest_tag selector_tag \
urltest_outbounds selector_outbounds urltest_check_interval urltest_tolerance urltest_testing_url
config_get urltest_proxy_links "$section" "urltest_proxy_links"
config_get udp_over_tcp "$section" "enable_udp_over_tcp"
config_get urltest_check_interval "$section" "urltest_check_interval" "3m"
@ -2290,37 +2461,69 @@ configure_outbound_handler() {
exit 1
fi
i=1
for link in $urltest_proxy_links; do
# The facade returns non-zero (config echoed UNCHANGED) for an
# unsupported scheme. Only add the member tag when the outbound
# was actually created, so the urltest/selector never references
# a non-existent outbound; a single bad link is skipped and the
# remaining links still build. Reassign $config only on a
# non-empty result so a skip never wipes the config.
if _new_config="$(sing_box_cf_add_proxy_outbound "$config" "$section-$i" "$link" "$udp_over_tcp")" \
&& [ -n "$_new_config" ]; then
config="$_new_config"
outbound_tag="$(get_outbound_tag_by_section "$section-$i")"
if [ -z "$outbound_tags" ]; then
outbound_tags="$outbound_tag"
else
outbound_tags="$outbound_tags,$outbound_tag"
fi
else
log "URLTest section '$section' link #$i uses an unsupported scheme; skipping it" "warn"
fi
i=$((i + 1))
done
_build_proxy_member_outbounds "$section" "$urltest_proxy_links" "$udp_over_tcp" "URLTest"
if [ -z "$outbound_tags" ]; then
if [ -z "$_member_outbound_tags" ]; then
echolog "URLTest section '$section' has no usable links (all unsupported); its traffic will be rejected until a supported link is configured" "error"
mark_section_outbound_unavailable "$section"
else
urltest_tag="$(get_outbound_tag_by_section "$section-urltest")"
selector_tag="$(get_outbound_tag_by_section "$section")"
urltest_outbounds="$(comma_string_to_json_array "$outbound_tags")"
selector_outbounds="$(comma_string_to_json_array "$outbound_tags,$urltest_tag")"
urltest_outbounds="$(comma_string_to_json_array "$_member_outbound_tags")"
selector_outbounds="$(comma_string_to_json_array "$_member_outbound_tags,$urltest_tag")"
config="$(sing_box_cm_add_urltest_outbound "$config" "$urltest_tag" "$urltest_outbounds" \
"$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"
config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" "$urltest_tag" "true")"
fi
;;
selector_text)
log "Detected proxy configuration type: selector_text" "debug"
local selector_text_links udp_over_tcp selector_tag selector_outbounds
config_get selector_text_links "$section" "selector_proxy_links_text"
config_get udp_over_tcp "$section" "enable_udp_over_tcp"
if [ -z "$selector_text_links" ]; then
log "URLTest proxy links is not set. Aborted." "fatal"
exit 1
fi
_build_proxy_member_outbounds "$section" "$selector_text_links" "$udp_over_tcp" "Selector"
if [ -z "$_member_outbound_tags" ]; then
echolog "Selector section '$section' has no usable links (all unsupported); its traffic will be rejected until a supported link is configured" "error"
mark_section_outbound_unavailable "$section"
else
selector_tag="$(get_outbound_tag_by_section "$section")"
selector_outbounds="$(comma_string_to_json_array "$_member_outbound_tags")"
config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" \
"$_member_default_outbound" "true")"
fi
;;
urltest_text)
log "Detected proxy configuration type: urltest_text" "debug"
local urltest_text_links udp_over_tcp urltest_tag selector_tag \
urltest_outbounds selector_outbounds urltest_check_interval urltest_tolerance urltest_testing_url
config_get urltest_text_links "$section" "urltest_proxy_links_text"
config_get udp_over_tcp "$section" "enable_udp_over_tcp"
config_get urltest_check_interval "$section" "urltest_check_interval" "3m"
config_get urltest_tolerance "$section" "urltest_tolerance" 50
config_get urltest_testing_url "$section" "urltest_testing_url" "https://www.gstatic.com/generate_204"
if [ -z "$urltest_text_links" ]; then
log "URLTest proxy links is not set. Aborted." "fatal"
exit 1
fi
_build_proxy_member_outbounds "$section" "$urltest_text_links" "$udp_over_tcp" "URLTest"
if [ -z "$_member_outbound_tags" ]; then
echolog "URLTest section '$section' has no usable links (all unsupported); its traffic will be rejected until a supported link is configured" "error"
mark_section_outbound_unavailable "$section"
else
urltest_tag="$(get_outbound_tag_by_section "$section-urltest")"
selector_tag="$(get_outbound_tag_by_section "$section")"
urltest_outbounds="$(comma_string_to_json_array "$_member_outbound_tags")"
selector_outbounds="$(comma_string_to_json_array "$_member_outbound_tags,$urltest_tag")"
config="$(sing_box_cm_add_urltest_outbound "$config" "$urltest_tag" "$urltest_outbounds" \
"$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"
config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" "$urltest_tag" "true")"
@ -2330,7 +2533,7 @@ configure_outbound_handler() {
log "Detected proxy configuration type: subscription" "debug"
local subscription_urls_tmp subscription_url subscription_url_count urltest_tag selector_tag \
urltest_outbounds selector_outbounds urltest_check_interval urltest_tolerance \
urltest_testing_url subscription_group_by_countries subscription_group_by_countries_raw \
urltest_testing_url group_mode group_mode_raw prefix_len prefix_len_raw legacy_group_raw \
subscription_outbound_tags_json service_proxy_address subscription_ready \
subscription_filter_include_keywords_json subscription_filter_exclude_keywords_json \
subscription_keyword_filter_active urlhash subscription_json_path subscription_url_cache_path \
@ -2340,19 +2543,54 @@ configure_outbound_handler() {
config_get urltest_check_interval "$section" "urltest_check_interval" "3m"
config_get urltest_tolerance "$section" "urltest_tolerance" 50
config_get urltest_testing_url "$section" "urltest_testing_url" "https://www.gstatic.com/generate_204"
config_get subscription_group_by_countries_raw "$section" "subscription_group_by_countries" ""
if [ -z "$subscription_group_by_countries_raw" ]; then
# Backward-compatible alias in case custom builds used another key
config_get subscription_group_by_countries_raw "$section" "group_by_countries" ""
fi
# Grouping mode (task-044): off | country | prefix. The new
# subscription_group_mode option OUTRANKS the legacy boolean. When
# the new option is absent we fall back to the legacy boolean
# subscription_group_by_countries (and its older alias
# group_by_countries): truthy => country, else off.
config_get group_mode_raw "$section" "subscription_group_mode" ""
config_get prefix_len_raw "$section" "subscription_group_prefix_len" "$SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN"
if is_truthy_option "$subscription_group_by_countries_raw"; then
subscription_group_by_countries=1
if [ -z "$group_mode_raw" ]; then
config_get legacy_group_raw "$section" "subscription_group_by_countries" ""
if [ -z "$legacy_group_raw" ]; then
config_get legacy_group_raw "$section" "group_by_countries" ""
fi
if is_truthy_option "$legacy_group_raw"; then
group_mode="country"
else
group_mode="off"
fi
else
subscription_group_by_countries=0
case "$group_mode_raw" in
off | country | prefix)
group_mode="$group_mode_raw"
;;
*)
group_mode="off"
;;
esac
fi
log "Subscription country grouping for section '$section': raw='${subscription_group_by_countries_raw:-<empty>}', enabled=$subscription_group_by_countries" "debug"
# Sanitize the prefix length: positive integer only, else default.
case "$prefix_len_raw" in
'' | *[!0-9]*)
prefix_len="$SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN"
;;
*)
if [ "$prefix_len_raw" -ge 1 ] 2>/dev/null; then
prefix_len="$prefix_len_raw"
else
prefix_len="$SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN"
fi
;;
esac
if [ "$group_mode" = "prefix" ]; then
log "Subscription grouping for section '$section': mode=$group_mode, prefix_len=$prefix_len" "debug"
else
log "Subscription grouping for section '$section': mode=$group_mode" "debug"
fi
# Keyword whitelist/blacklist filtering of subscription nodes by
# display name. Both are optional UCI lists of opaque match strings.
@ -2519,41 +2757,84 @@ configure_outbound_handler() {
subscription_outbound_tags_json="$(comma_string_to_json_array "$SUBSCRIPTION_OUTBOUND_TAGS")"
fi
if [ "$subscription_group_by_countries" -eq 1 ]; then
local grouping_json country_flag country_group_outbounds country_group_tag \
selector_outbounds_json selector_default ungrouped_outbounds_json grouped_count ungrouped_count
if [ "$group_mode" != "off" ]; then
local grouping_json group_key group_outbounds group_tag group_keys_tmp \
selector_outbounds_json selector_default ungrouped_outbounds_json grouped_count ungrouped_count \
group_tags_json group_tags_count fastest_tag
grouping_json="$(sing_box_build_subscription_country_groups "$subscription_outbound_tags_json")"
grouping_json="$(sing_box_build_subscription_groups "$subscription_outbound_tags_json" "$group_mode" "$prefix_len")"
if [ -z "$grouping_json" ]; then
log "Failed to build grouped subscription outbounds for section '$section'. Aborted." "fatal"
exit 1
fi
grouped_count="$(echo "$grouping_json" | jq -r '.country_order | length' 2>/dev/null)"
grouped_count="$(echo "$grouping_json" | jq -r '.group_order | length' 2>/dev/null)"
ungrouped_count="$(echo "$grouping_json" | jq -r '.ungrouped | length' 2>/dev/null)"
log "Country grouping prepared for section '$section': groups=$grouped_count, ungrouped=$ungrouped_count" "debug"
log "Subscription grouping prepared for section '$section' (mode=$group_mode): groups=$grouped_count, ungrouped=$ungrouped_count" "debug"
selector_outbounds_json="[]"
for country_flag in $(echo "$grouping_json" | jq -r '.country_order[]' 2>/dev/null); do
country_group_outbounds="$(echo "$grouping_json" | jq -c --arg country_flag "$country_flag" '.country_groups[$country_flag] // []' 2>/dev/null)"
if [ -z "$country_group_outbounds" ] || [ "$country_group_outbounds" = "[]" ]; then
# Iterate group keys WITHOUT word-splitting: prefix-mode keys
# can legitimately contain spaces (e.g. a "letter+space"
# prefix), which a `for k in $(...)` loop would shatter. Use
# the same mktemp + `while read < file` pattern as the URL
# loop above so the body runs in the CURRENT shell and its
# mutations to $config / $selector_outbounds_json survive
# (a `... | while read` body would run in a subshell and lose
# them). One key per line; group keys never contain newlines.
group_keys_tmp="$(mktemp "${TMPDIR:-/tmp}/netshift-cfg-groupkeys.XXXXXX")" || {
log "Failed to enumerate subscription group keys for section '$section'. Aborted." "fatal"
exit 1
}
echo "$grouping_json" | jq -r '.group_order[]' 2>/dev/null > "$group_keys_tmp"
while IFS= read -r group_key || [ -n "$group_key" ]; do
group_outbounds="$(echo "$grouping_json" | jq -c --arg group_key "$group_key" '.groups[$group_key] // []' 2>/dev/null)"
if [ -z "$group_outbounds" ] || [ "$group_outbounds" = "[]" ]; then
continue
fi
country_group_tag="$(sing_box_get_unique_outbound_tag "$config" "$country_flag Fastest")"
config="$(sing_box_cm_add_urltest_outbound "$config" "$country_group_tag" "$country_group_outbounds" \
group_tag="$(sing_box_get_unique_outbound_tag "$config" "$group_key Fastest")"
config="$(sing_box_cm_add_urltest_outbound "$config" "$group_tag" "$group_outbounds" \
"$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"
selector_outbounds_json=$(
printf '%s' "$selector_outbounds_json" | jq -ac --arg tag "$country_group_tag" '. + [$tag]' 2>/dev/null
printf '%s' "$selector_outbounds_json" | jq -ac --arg tag "$group_tag" '. + [$tag]' 2>/dev/null
)
done
done < "$group_keys_tmp"
rm -f "$group_keys_tmp"
if [ -z "$selector_outbounds_json" ]; then
selector_outbounds_json="[]"
fi
# At this point $selector_outbounds_json holds ONLY the
# per-group urltest tags ("<key> Fastest"), before ungrouped
# nodes are appended below. Capture that group-only list so
# we can build a top-level "Fastest" urltest OVER the groups
# (a urltest of urltests) and default the selector to it.
group_tags_json="$selector_outbounds_json"
group_tags_count="$(printf '%s' "$group_tags_json" | jq -r 'length' 2>/dev/null)"
[ -n "$group_tags_count" ] || group_tags_count=0
fastest_tag=""
# Only nest when there are >= 2 groups. With exactly 1 group
# the lone group urltest IS already the fastest (a urltest
# over a single member is redundant), so we skip the extra
# layer and fall back to the lone group as the default. With
# 0 groups (everything ungrouped) there is nothing to nest;
# never emit an empty-member urltest.
if [ "$group_tags_count" -ge 2 ]; then
fastest_tag="$(sing_box_get_unique_outbound_tag "$config" "$SB_SUBSCRIPTION_FASTEST_GROUP_TAG")"
# Reuse the section's urltest probe knobs (testing URL,
# check interval, tolerance) so the extra probe layer's
# cadence stays user-tunable; do NOT hardcode an
# aggressive interval. This adds one more probe layer
# (Fastest tests each group; each group tests its nodes).
config="$(sing_box_cm_add_urltest_outbound "$config" "$fastest_tag" "$group_tags_json" \
"$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"
selector_outbounds_json="$(jq -acn --arg t "$fastest_tag" --argjson rest "$selector_outbounds_json" '[$t] + $rest')"
fi
ungrouped_outbounds_json="$(echo "$grouping_json" | jq -c '.ungrouped // []' 2>/dev/null)"
if [ -n "$ungrouped_outbounds_json" ] && [ "$ungrouped_outbounds_json" != "[]" ]; then
selector_outbounds_json=$(
@ -2567,7 +2848,16 @@ configure_outbound_handler() {
exit 1
fi
selector_default="$(echo "$selector_outbounds_json" | jq -r '.[0] // ""' 2>/dev/null)"
if [ -n "$fastest_tag" ]; then
# >= 2 groups: the cross-group "Fastest" urltest is the
# default (it was prepended above, so it is also .[0]).
selector_default="$fastest_tag"
else
# 0 or 1 groups: keep the existing "first element"
# default (the lone group urltest, or the first
# ungrouped node).
selector_default="$(echo "$selector_outbounds_json" | jq -r '.[0] // ""' 2>/dev/null)"
fi
if [ -z "$selector_default" ] || [ "$selector_default" = "null" ]; then
log "Unable to determine default selector outbound for section '$section'. Aborted." "fatal"
exit 1

View File

@ -28,12 +28,16 @@ TMP_SUBSCRIPTION_MERGE_FOLDER="$TMP_SING_BOX_FOLDER/subscription-merge"
# "singbox/<version>" candidate is prepended at runtime (it depends on the
# installed sing-box). Order matters: most-likely-to-work first.
SUBSCRIPTION_USER_AGENT_CANDIDATES="v2rayN Happ Hiddify Clash.Meta ClashMetaForAndroid"
# Subset of SUBSCRIPTION_USER_AGENT_CANDIDATES that well-known panels answer with
# an Xray JSON body (which carries xhttp/transport nodes the default sing-box JSON
# may omit). Used by build_subscription_user_agent_candidates when a section's
# Versioned client UAs that well-known panels answer with an Xray JSON body
# (which carries xhttp/transport nodes the default sing-box JSON may omit). Used
# by build_subscription_user_agent_candidates when a section's
# subscription_format_preference is "xray": these UAs are probed FIRST so an
# Xray-JSON feed is recovered before a sing-box JSON under the default UA wins.
SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES="v2rayN Happ"
# Panels commonly gate their Xray branch on a "<client>/<version>" UA shape, so
# these are VERSIONED (a bare/version-less UA can be rejected, e.g. with a 502).
# Order matters: a versioned Happ is first (empirically yields the Xray-JSON
# array body), then versioned v2rayN/v2rayNG forms as panel-agnostic fallbacks.
SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES="Happ/1.0.0 v2rayN/7.0.0 v2rayNG/1.9.0"
CLOUDFLARE_OCTETS="8.47 162.159 188.114" # Endpoints https://github.com/ampetelin/warp-endpoint-checker
JQ_REQUIRED_VERSION="1.7.1"
COREUTILS_BASE64_REQUIRED_VERSION="9.7"
@ -87,7 +91,16 @@ UPDATES_LIBCRONET_LIB="/usr/lib/libcronet.so"
# Component Manager — NetShift self-update (task-017). The GitHub latest-release
# API for NetShift itself (same endpoint install.sh and get_system_info use);
# the self-update worker downloads the release .ipk/.apk assets from it.
NETSHIFT_RELEASE_API_URL="https://api.github.com/repos/yandexru45/netshift/releases/latest"
NETSHIFT_RELEASE_API_URL="https://uralgit.ru/api/v1/repos/ural/netshift/releases/latest"
# uralgit.ru FRONTEND redirect path (NOT the rate-limited api.github.com)
# for the NetShift repo. /releases/latest 303-redirects to /releases/tag/<tag>
# (resolve with curl -w '%{redirect_url}' — no API hit, not subject to the
# 60/hour/IP anonymous API limit); /releases/download/<tag>/<asset> for direct
# asset download. Primary path for version-check + self-update;
# NETSHIFT_RELEASE_API_URL stays as the graceful fallback. Repo slug lives here
# only — do not hardcode it elsewhere.
NETSHIFT_REPO_RELEASES_LATEST_URL="https://uralgit.ru/ural/netshift/releases/latest"
NETSHIFT_REPO_RELEASES_DOWNLOAD_BASE="https://uralgit.ru/ural/netshift/releases/download"
# tmpfs scratch dir for the self-update download (release packages) — RAM, never
# the tiny overlay; reaped on success and on reboot.
UPDATES_NETSHIFT_DOWNLOAD_DIR="/tmp/netshift/selfupdate"
@ -123,6 +136,15 @@ SB_SERVICE_MIXED_INBOUND_ADDRESS="127.0.0.1"
SB_SERVICE_MIXED_INBOUND_PORT=4534
# Outbounds
SB_DIRECT_OUTBOUND_TAG="direct-out"
# Subscription grouping (task-044). Default codepoint count for prefix-mode
# grouping when subscription_group_prefix_len is unset/invalid.
SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN=2
# Subscription grouping (task-050). Tag/label for the top-level "Fastest"
# urltest that probes ACROSS the per-group urltests (a urltest of urltests)
# when grouping is on. Valid UTF-8 emoji + English; deliberately distinct from
# a per-group "<flag> Fastest" tag so the cross-group auto choice is tellable
# apart in the dashboard. Single source for the tag (keep this file UTF-8).
SB_SUBSCRIPTION_FASTEST_GROUP_TAG="⚡ Fastest"
# Route
SB_REJECT_RULE_TAG="reject-rule-tag"
SB_EXCLUSION_RULE_TAG="exclusion-rule-tag"
@ -135,8 +157,8 @@ DOH_BLOCK_IPV4_CIDRS="1.1.1.1/32 1.0.0.1/32 8.8.8.8/32 8.8.4.4/32 9.9.9.9/32 9.9
DOH_BLOCK_IPV6_CIDRS="2606:4700:4700::1111/128 2606:4700:4700::1001/128 2001:4860:4860::8888/128 2001:4860:4860::8844/128 2620:fe::fe/128 2620:fe::9/128 2620:119:35::35/128 2620:119:53::53/128 2a10:50c0::ad1:ff/128 2a10:50c0::ad2:ff/128 2a02:6b8::feed:0ff/128 2a02:6b8:0:1::feed:0ff/128"
## Lists
GITHUB_RAW_URL="https://raw.githubusercontent.com/itdoginfo/allow-domains/main"
SRS_MAIN_URL="https://github.com/itdoginfo/allow-domains/releases/latest/download"
GITHUB_RAW_URL="https://uralgit.ru/ural/allow-domains/raw/branch/main"
SRS_MAIN_URL="https://uralgit.ru/ural/allow-domains/releases/latest/download"
SUBNETS_TWITTER="${GITHUB_RAW_URL}/Subnets/IPv4/twitter.lst"
SUBNETS_META="${GITHUB_RAW_URL}/Subnets/IPv4/meta.lst"
SUBNETS_DISCORD="${GITHUB_RAW_URL}/Subnets/IPv4/discord.lst"

View File

@ -572,6 +572,58 @@ convert_crlf_to_lf() {
fi
}
# Best-effort, in-place gzip decompression of a downloaded subscription body.
#
# Some panels unconditionally return a gzip-compressed HTTP body (busybox wget
# does NOT transparently decompress and we send no Accept-Encoding), so the raw
# bytes are binary and every downstream consumer (validate/normalize) chokes.
# This decompresses once at download time so all consumers see text.
#
# Detection is attempt-based (no od/hexdump/xxd, none of which exist on device):
# we try `gzip -dc` (busybox built-in) into a temp file and accept the result
# ONLY if (a) gzip returned 0, (b) the result is non-empty, and (c) the result
# is NUL-free. gzip -dc on plain-text input returns rc!=0 cleanly, so a
# plain-text body is left byte-for-byte untouched; this can never corrupt text.
# Modeled on convert_crlf_to_lf: mktemp -> transform -> mv on success / rm on
# failure. Best-effort: always returns 0 (never aborts the caller).
maybe_gunzip_subscription_file() {
local filepath="$1"
local tmpfile
[ -s "$filepath" ] || return 0
tmpfile=$(mktemp)
if gzip -dc "$filepath" > "$tmpfile" 2>/dev/null &&
[ -s "$tmpfile" ] &&
! subscription_body_is_binary "$tmpfile"; then
log "Decompressed gzip subscription body for '$filepath'" "debug"
mv "$tmpfile" "$filepath"
else
rm -f "$tmpfile"
fi
return 0
}
# Returns 0 (true) if the file contains at least one NUL byte (i.e. it is
# binary / undecodable, not text). Busybox-safe, no od/hexdump/xxd: `tr -d`
# strips NUL bytes and we compare the resulting byte count to the original; a
# difference means a NUL was present. All vars local.
subscription_body_is_binary() {
local filepath="$1"
local raw_count stripped_count
[ -s "$filepath" ] || return 1
raw_count="$(wc -c < "$filepath" 2>/dev/null | tr -d ' ')"
stripped_count="$(tr -d '\000' < "$filepath" 2>/dev/null | wc -c 2>/dev/null | tr -d ' ')"
[ -n "$raw_count" ] || raw_count=0
[ -n "$stripped_count" ] || stripped_count=0
[ "$raw_count" != "$stripped_count" ]
}
#######################################
# Parses a whitespace-separated string, validates items as either domains
# or IPv4 addresses/subnets, and returns a comma-separated string of valid items.

View File

@ -5,7 +5,7 @@
# This file is sourced from /usr/bin/netshift, so log() is available.
SB_EXT_ARCH_SUFFIX=""
UPDATES_SING_BOX_EXTENDED_REPO="shtorm-7/sing-box-extended"
UPDATES_SING_BOX_EXTENDED_REPO="ural/sing-box-extended"
# Async component-action job state. State lives on tmpfs (/var/run): it survives
# the rpcd call that started the worker but is intentionally transient (cleared
@ -550,7 +550,7 @@ updates_http_get_once() {
# mistaken for a releases list.
updates_fetch_sing_box_extended_releases() {
local url response proxy
url="https://api.github.com/repos/${UPDATES_SING_BOX_EXTENDED_REPO}/releases?per_page=30"
url="https://uralgit.ru/api/v1/repos/${UPDATES_SING_BOX_EXTENDED_REPO}/releases?per_page=30"
response="$(updates_http_get_once "$url" "")"
if updates_response_is_release_array "$response"; then
@ -1407,7 +1407,15 @@ updates_pkg_install_file() {
if updates_pkg_is_apk; then
apk add --allow-untrusted "$pkg_file" </dev/null >/dev/null 2>&1
else
opkg install "$pkg_file" </dev/null >/dev/null 2>&1
# --force-downgrade: a legacy v-prefixed build (e.g. v0.8.6) sorts ABOVE
# the no-v target (0.8.7) in opkg's dpkg-style compare, so a plain
# `opkg install` returns rc=0 and refuses ("Not downgrading ..."). The
# flag forces the v->no-v transition to actually land.
# --force-reinstall: covers the "already installed at this exact version"
# no-op. opkg rc is NOT a reliable success signal either way — the
# verify-after-install belt in _updates_self_update_netshift_core is the
# authoritative check.
opkg install --force-downgrade --force-reinstall "$pkg_file" </dev/null >/dev/null 2>&1
fi
}
@ -1447,6 +1455,32 @@ updates_pkg_candidate_version() {
printf '%s' "$version"
}
# Echoes the INSTALLED version of a package (what is on the system right now),
# or nothing if the package is not installed. Distinct from
# updates_pkg_candidate_version (that reads the FEED candidate). Parsed with
# grep/awk only — NEVER Oniguruma jq. Mirrors updates_pkg_is_installed.
# opkg list-installed -> "<name> - <version>" (field after " - ")
# apk list --installed <pkg> -> "<name>-<version> <arch> {...} ..." (strip "<name>-")
updates_pkg_installed_version() {
local pkg_name="$1"
local line version=""
if updates_pkg_is_apk; then
# First installed-list token is "<name>-<version>"; strip the leading
# "<pkg>-" so only the version (e.g. "0.8.7-r1") remains.
line="$(apk list --installed "$pkg_name" 2>/dev/null | awk '{print $1}' | head -n1)"
case "$line" in
"$pkg_name"-*) version="${line#"$pkg_name"-}" ;;
esac
else
# opkg list-installed prints "<name> - <version>"; take the field after
# " - " for the exact package name.
version="$(opkg list-installed 2>/dev/null | grep "^${pkg_name} " | head -n1 | awk -F' - ' '{print $2}')"
fi
printf '%s' "$version"
}
# Checks whether a newer STOCK (stable) sing-box is available via the system
# package manager. SYNC (quick call → stays on the synchronous component_action
# path). Graceful on an unreachable feed / parse failure: echoes
@ -1592,38 +1626,124 @@ updates_self_update_netshift() {
return "$rc"
}
# Echoes the GitHub latest-release tag for NetShift (e.g. "v0.8.1"), or nothing.
# Reuses the same API endpoint as get_system_info / install.sh; parsed with
# grep/cut (the tag is needed only as a display/compare string, no jq array).
updates_netshift_latest_tag() {
local response
# Resolve a URL's HTTP redirect target via curl WITHOUT hitting the rate-limited
# API or downloading the body. Echoes the redirect URL (empty if curl absent or
# no redirect). Stubbable in tests.
updates_github_resolve_redirect() {
local url="$1"
command -v curl >/dev/null 2>&1 || return 1
curl -sI -o /dev/null -w '%{redirect_url}' --connect-timeout 5 -m 15 -A 'netshift-updater' "$url" 2>/dev/null
}
# Echoes the GitHub latest-release tag for NetShift (e.g. "0.8.8"), or nothing.
# PRIMARY: resolve the uralgit.ru frontend redirect of /releases/latest — it
# 303s to /releases/tag/<tag>. That frontend is NOT the 60/hour-per-IP
# api.github.com, so it sidesteps the anonymous rate limit entirely (the common
# failure on CGNAT / shared-IP / shared-VPN-egress routers). FALLBACK: the
# uralgit.ru API release object parsed with jq (task-047) so a curl-less box or a
# changed-redirect still degrades gracefully instead of hard-failing.
# jq is format-independent (minified or pretty); a field-positional grep|cut
# grabbed the wrong key on minified JSON, causing a false "outdated".
# Bare tag on success / non-zero otherwise (contract consumed by
# updates_check_netshift and the self-update worker).
updates_netshift_latest_tag() {
local response tag redirect
# PRIMARY: uralgit.ru/<repo>/releases/latest 303-redirects to
# /releases/tag/<tag>. Parse with case/param-expansion (no Oniguruma).
redirect="$(updates_github_resolve_redirect "$NETSHIFT_REPO_RELEASES_LATEST_URL")"
case "$redirect" in
*/releases/tag/*)
tag="${redirect##*/releases/tag/}"
case "$tag" in '' | */*) tag="" ;; esac
;;
*) tag="" ;;
esac
if [ -n "$tag" ]; then
printf '%s' "$tag"
return 0
fi
# FALLBACK: api.github.com (rate-limited) parsed with jq.
response="$(updates_http_get_once "$NETSHIFT_RELEASE_API_URL" "")"
if [ -z "$response" ]; then
return 1
fi
printf '%s' "$response" | grep '"tag_name":' | head -n1 | cut -d'"' -f4
tag="$(printf '%s' "$response" | jq -r '.tag_name // empty' 2>/dev/null)"
[ -n "$tag" ] || return 1
printf '%s' "$tag"
}
# Downloads the NetShift release assets matching the package-name prefixes for
# the active package manager into $dir. Echoes nothing; returns 0 if at least
# the core "netshift" package was downloaded, non-zero otherwise. The asset URL
# list comes from the same latest-release JSON, filtered to .ipk or .apk by the
# package manager (busybox grep -o, no jq array walk required).
# Echo the deterministic release asset filename for a package + tag + ext.
# ipk core/luci carry "-r1-all"; apk core/luci carry "-r1"; the i18n package
# carries neither suffix (just "<pkg>-<tag>.<ext>"). Single source of the asset
# naming pattern so it lives in one place, not scattered.
updates_netshift_asset_filename() {
local pkg="$1" tag="$2" ext="$3"
case "$pkg" in
"$UPDATES_NETSHIFT_PKG_I18N_RU") printf '%s-%s.%s' "$pkg" "$tag" "$ext" ;;
*)
if [ "$ext" = "ipk" ]; then
printf '%s-%s-r1-all.%s' "$pkg" "$tag" "$ext"
else
printf '%s-%s-r1.%s' "$pkg" "$tag" "$ext"
fi
;;
esac
}
# Downloads the NetShift release assets for the active package manager into $dir.
# Echoes nothing; returns 0 if at least the core "netshift" package was
# downloaded, non-zero otherwise.
# PRIMARY: resolve the latest tag (redirect-based, rate-limit-free) and build the
# deterministic github.com/<repo>/releases/download/<tag>/<asset> URLs — the
# CDN 302 is followed by updates_download_to_file (curl -L / wget both follow it).
# FALLBACK: if the tag can't be resolved, scrape the api.github.com release JSON
# for .ipk/.apk URLs (busybox grep -o) as before, so a curl-less box still works.
_updates_self_update_download_assets() {
local dir="$1"
local response ext pattern url filename dest attempt got_core=0
response="$(updates_http_get_once "$NETSHIFT_RELEASE_API_URL" "")"
if [ -z "$response" ]; then
return 1
fi
local tag pkg
if updates_pkg_is_apk; then
ext="apk"
else
ext="ipk"
fi
tag="$(updates_netshift_latest_tag)"
if [ -n "$tag" ]; then
# Direct deterministic asset URLs (no API). Core + luci always; the RU
# i18n package only if already installed.
for pkg in "$UPDATES_NETSHIFT_PKG_CORE" "$UPDATES_NETSHIFT_PKG_LUCI" "$UPDATES_NETSHIFT_PKG_I18N_RU"; do
if [ "$pkg" = "$UPDATES_NETSHIFT_PKG_I18N_RU" ]; then
updates_pkg_is_installed "$UPDATES_NETSHIFT_PKG_I18N_RU" || continue
fi
filename="$(updates_netshift_asset_filename "$pkg" "$tag" "$ext")"
url="$NETSHIFT_REPO_RELEASES_DOWNLOAD_BASE/$tag/$filename"
dest="$dir/$filename"
attempt=0
while [ "$attempt" -lt 3 ]; do
if updates_download_to_file "$url" "$dest"; then
break
fi
rm -f "$dest" 2>/dev/null
attempt=$((attempt + 1))
done
if [ "$pkg" = "$UPDATES_NETSHIFT_PKG_CORE" ] && [ -s "$dest" ]; then
got_core=1
fi
done
[ "$got_core" -eq 1 ]
return $?
fi
# FALLBACK: scrape the API release JSON for direct asset URLs.
response="$(updates_http_get_once "$NETSHIFT_RELEASE_API_URL" "")"
if [ -z "$response" ]; then
return 1
fi
pattern="https://[^\"[:space:]]*\.${ext}"
# Iterate the matching browser_download_url values. Only keep assets whose
@ -1663,6 +1783,7 @@ _updates_self_update_download_assets() {
# failure so the wrapper still runs the restore epilogue).
_updates_self_update_netshift_core() {
local installed latest pkg file_path candidate_file
local core_installed core_installed_semver latest_semver
local backup_made=0
installed="$NETSHIFT_VERSION"
@ -1741,6 +1862,31 @@ _updates_self_update_netshift_core() {
fi
done
# Verify-after-install for the CORE package (authoritative success signal).
# opkg returns rc=0 for "already installed"/"up to date"/"Not downgrading",
# so the install rc above is NOT trustworthy. RE-READ the installed version
# and confirm it actually became the target before declaring success. apk's
# equal-version no-overwrite quirk is caught by this same belt.
core_installed="$(updates_pkg_installed_version "$UPDATES_NETSHIFT_PKG_CORE")"
# Normalize with the SAME rules the version-decision uses: drop a leading "v"
# and any "-rN"/"-suffix" so we compare semver-to-semver.
core_installed_semver="${core_installed#v}"
core_installed_semver="${core_installed_semver%%-*}"
latest_semver="${latest#v}"
latest_semver="${latest_semver%%-*}"
# The install took iff the installed semver equals the target, OR the
# installed semver is now >= the target (is_min_package_version current
# required → 0 when current >= required).
if [ "$core_installed_semver" != "$latest_semver" ] \
&& ! is_min_package_version "$core_installed_semver" "$latest_semver"; then
_updates_self_update_restore_config "$backup_made"
rm -rf "$UPDATES_NETSHIFT_DOWNLOAD_DIR" 2>/dev/null
updates_log "Self-update: core package version did not change after install (package manager reported success but no upgrade occurred)" "error"
echo '{"success":false,"message":"NetShift core package did not upgrade (package manager refused or no-op); configuration preserved"}'
return 1
fi
# Defensive: if the config got clobbered/emptied, restore from the backup.
_updates_self_update_restore_config "$backup_made"

View File

@ -9,9 +9,10 @@
# docker compose -f tests/docker-compose.yml run --rm netshift-test <test-name>
#
# Test names: all, deps, syntax, config, helpers, jq, cm, sb, nft,
# nftv6, selmark, isolation, monfd, unsupported, diagnostics, subscription, insecure, rejected,
# jobstate, selfheal, dnsdetour, globalproxy, stablecheck,
# extcheck, netshiftcheck, selfupdate, backupguard
# nftv6, selmark, isolation, monfd, unsupported, textlist, diagnostics, subscription, fastest, insecure, rejected,
# jobstate, selfheal, dnsdetour, suburlopt, globalproxy, stablecheck,
# extcheck, netshiftcheck, latesttag, ghredirect, selfupdate,
# backupguard
# ──────────────────────────────────────────────────────────────────
services:

File diff suppressed because it is too large Load Diff