6 Commits
0.8.9 ... 0.9.2

Author SHA1 Message Date
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
23 changed files with 2584 additions and 514 deletions

145
README.md
View File

@ -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>
@ -113,14 +118,29 @@ sh <(wget -O - https://raw.githubusercontent.com/yandexru45/netshift/refs/heads/
Интерфейс появится в 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

@ -1276,3 +1276,176 @@ save+`sing-box check` -> cron jobs -> start sing-box -> dnsmasq_configure ->
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

@ -67,3 +67,9 @@ append recurring findings; keep under ~200 lines.
- 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

@ -861,3 +861,64 @@ append findings; keep under ~200 lines.
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

@ -1487,3 +1487,245 @@ findings; keep under ~200 lines.
`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:607"
"../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:695"
"../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:390"
"../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:667"
"../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:756"
"../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"
]
},
{
@ -165,14 +172,14 @@
"call": "By country flag",
"key": "By country flag",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:200"
"../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:201"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:203"
]
},
{
@ -272,7 +279,7 @@
"call": "Community Lists",
"key": "Community Lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:513"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:576"
]
},
{
@ -364,14 +371,14 @@
"call": "Custom domains",
"key": "Custom domains",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:606"
"../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:694"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:757"
]
},
{
@ -449,15 +456,15 @@
"call": "Disabled",
"key": "Disabled",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:611",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:699"
"../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"
]
},
{
@ -485,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:479",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:542",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:48"
]
},
@ -493,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:480",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:543",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:49"
]
},
@ -501,7 +508,7 @@
"call": "DNS Protocol Type",
"key": "DNS Protocol Type",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:476",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:539",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:45"
]
},
@ -516,7 +523,7 @@
"call": "DNS Server",
"key": "DNS Server",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:490",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:553",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:58"
]
},
@ -545,7 +552,7 @@
"call": "Domain Resolver",
"key": "Domain Resolver",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:465"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528"
]
},
{
@ -596,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:239"
"../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:612",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:700"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:675",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:763"
]
},
{
@ -618,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:466"
"../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:933"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:996"
]
},
{
@ -646,7 +653,7 @@
"call": "Enable Mixed Proxy",
"key": "Enable Mixed Proxy",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:902"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:965"
]
},
{
@ -660,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:903"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:966"
]
},
{
@ -681,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:649"
"../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:622"
"../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:710"
"../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:306"
"../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:307"
"../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:305"
"../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:308"
"../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"
]
},
{
@ -793,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:238"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:240"
]
},
{
@ -816,6 +823,12 @@
"call": "Failed to execute!",
"key": "Failed to execute!",
"places": [
"src/netshift/tabs/diagnostic/initController.ts:229",
"src/netshift/tabs/diagnostic/initController.ts:233",
"src/netshift/tabs/diagnostic/initController.ts:263",
"src/netshift/tabs/diagnostic/initController.ts:267",
"src/netshift/tabs/diagnostic/initController.ts:304",
"src/netshift/tabs/diagnostic/initController.ts:308",
"src/netshift/tabs/manager/initController.ts:122",
"src/netshift/tabs/manager/initController.ts:132",
"src/netshift/tabs/manager/initController.ts:150",
@ -823,13 +836,7 @@
"src/netshift/tabs/manager/initController.ts:188",
"src/netshift/tabs/manager/initController.ts:192",
"src/netshift/tabs/manager/initController.ts:225",
"src/netshift/tabs/manager/initController.ts:229",
"src/netshift/tabs/diagnostic/initController.ts:229",
"src/netshift/tabs/diagnostic/initController.ts:233",
"src/netshift/tabs/diagnostic/initController.ts:263",
"src/netshift/tabs/diagnostic/initController.ts:267",
"src/netshift/tabs/diagnostic/initController.ts:304",
"src/netshift/tabs/diagnostic/initController.ts:308"
"src/netshift/tabs/manager/initController.ts:229"
]
},
{
@ -846,7 +853,7 @@
"call": "Fully Routed IPs",
"key": "Fully Routed IPs",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:874"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:937"
]
},
{
@ -867,21 +874,21 @@
"call": "Global Proxy",
"key": "Global Proxy",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:400"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:463"
]
},
{
"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: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"
]
},
{
@ -895,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:226"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:228"
]
},
{
@ -1321,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:227"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:229"
]
},
{
@ -1339,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",
@ -1364,14 +1378,14 @@
"call": "Local Domain Lists",
"key": "Local Domain Lists",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:778"
"../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:802"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:865"
]
},
{
@ -1413,7 +1427,7 @@
"call": "Mixed Proxy Port",
"key": "Mixed Proxy Port",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:916"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:979"
]
},
{
@ -1427,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:343"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:405"
]
},
{
@ -1469,7 +1483,7 @@
"call": "Network Interface",
"key": "Network Interface",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:418"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481"
]
},
{
@ -1520,21 +1534,21 @@
"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:211"
"../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:199"
"../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:409"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472"
]
},
{
@ -1548,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"
]
},
{
@ -1616,7 +1630,7 @@
"call": "Prefix length",
"key": "Prefix length",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:210"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:212"
]
},
{
@ -1630,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"
]
},
{
@ -1651,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:547"
"../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:826"
"../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:850"
"../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:932"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:995"
]
},
{
@ -1686,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:401"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:464"
]
},
{
@ -1770,7 +1784,7 @@
"call": "Russia inside restrictions",
"key": "Russia inside restrictions",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:566"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629"
]
},
{
@ -1791,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:514"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:577"
]
},
{
@ -1826,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:419"
"../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:491",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:554",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:59"
]
},
@ -1855,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:477"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:540"
]
},
{
@ -1893,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:250"
"../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"
]
},
{
@ -1940,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"
]
},
@ -2018,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:875"
"../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:827"
"../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:851"
"../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:779",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:803"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:842",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:866"
]
},
{
@ -2062,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"
]
},
{
@ -2083,28 +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:194"
"../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"
]
},
{
@ -2167,8 +2195,8 @@
"call": "Text List",
"key": "Text List",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:613",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:701"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:676",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:764"
]
},
{
@ -2182,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:303"
"../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:318"
"../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:351"
"../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"
]
},
{
@ -2252,7 +2280,7 @@
"call": "UDP (Unprotected DNS)",
"key": "UDP (Unprotected DNS)",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:544",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/settings.js:50"
]
},
@ -2260,25 +2288,25 @@
"call": "UDP over TCP",
"key": "UDP over TCP",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:389"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:452"
]
},
{
"call": "unknown",
"key": "unknown",
"places": [
"src/netshift/tabs/manager/initController.ts:37",
"src/netshift/tabs/manager/initController.ts:38",
"src/netshift/tabs/manager/initController.ts:39",
"src/netshift/tabs/manager/initController.ts:40",
"src/netshift/tabs/manager/initController.ts:41",
"src/netshift/tabs/manager/initController.ts:42",
"src/netshift/tabs/diagnostic/initController.ts:39",
"src/netshift/tabs/diagnostic/initController.ts:40",
"src/netshift/tabs/diagnostic/initController.ts:41",
"src/netshift/tabs/diagnostic/initController.ts:42",
"src/netshift/tabs/diagnostic/initController.ts:43",
"src/netshift/tabs/diagnostic/initController.ts:44",
"src/netshift/tabs/manager/initController.ts:37",
"src/netshift/tabs/manager/initController.ts:38",
"src/netshift/tabs/manager/initController.ts:39",
"src/netshift/tabs/manager/initController.ts:40",
"src/netshift/tabs/manager/initController.ts:41",
"src/netshift/tabs/manager/initController.ts:42",
"src/netshift/tabs/diagnostic/helpers/getNetshiftVersionRow.ts:7"
]
},
@ -2354,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:302"
"../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:276"
"../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:350"
"../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:317"
"../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"
]
},
{
@ -2400,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:407"
"../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:621"
"../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:648"
"../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:709"
"../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:736"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:799"
]
},
{
@ -2460,8 +2502,8 @@
"call": "Validation errors:",
"key": "Validation errors:",
"places": [
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:681",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:768"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:744",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:831"
]
},
{
@ -2490,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:251",
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:277"
"../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:549"
"../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:568"
"../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:403"
"../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:466"
]
},
{
@ -2527,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-12 05:52+0300\n"
"PO-Revision-Date: 2026-06-12 05:52+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:607
#: ../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:695
#: ../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:390
#: ../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:667
#: 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:756
#: ../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,11 +113,11 @@ msgstr ""
msgid "Browser is using FakeIP correctly"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:200
#: ../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:201
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:203
msgid "By name prefix"
msgstr ""
@ -174,7 +178,7 @@ msgstr ""
msgid "Close"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:513
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:576
msgid "Community Lists"
msgstr ""
@ -227,11 +231,11 @@ msgstr ""
msgid "Currently unavailable"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:606
#: ../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:694
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:757
msgid "Custom subnets"
msgstr ""
@ -276,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:611
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:699
#: ../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 ""
@ -297,17 +301,17 @@ msgstr ""
msgid "DNS outbound section"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:479
#: ../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:480
#: ../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:476
#: ../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 ""
@ -316,7 +320,7 @@ msgstr ""
msgid "DNS Rewrite TTL"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:490
#: ../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 ""
@ -333,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:465
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528
msgid "Domain Resolver"
msgstr ""
@ -363,12 +367,12 @@ msgstr ""
msgid "Downloading all lists via specific Proxy/VPN"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:239
#: ../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:612
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:700
#: ../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 ""
@ -376,11 +380,11 @@ msgstr ""
msgid "Enable autostart"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:466
#: ../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:933
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:996
msgid "Enable DNS resolve to get real IP when routing"
msgstr ""
@ -392,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:902
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:965
msgid "Enable Mixed Proxy"
msgstr ""
@ -400,7 +404,7 @@ msgstr ""
msgid "Enable Output Network Interface"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:903
#: ../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 ""
@ -412,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:649
#: ../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:622
#: ../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:710
#: ../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:306
#: ../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:307
#: ../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:305
#: ../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:308
#: ../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 ""
@ -476,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:238
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:240
msgid "Exclude servers by keyword"
msgstr ""
@ -490,6 +494,12 @@ msgstr ""
msgid "Failed to copy!"
msgstr ""
#: src/netshift/tabs/diagnostic/initController.ts:229
#: src/netshift/tabs/diagnostic/initController.ts:233
#: src/netshift/tabs/diagnostic/initController.ts:263
#: src/netshift/tabs/diagnostic/initController.ts:267
#: src/netshift/tabs/diagnostic/initController.ts:304
#: src/netshift/tabs/diagnostic/initController.ts:308
#: src/netshift/tabs/manager/initController.ts:122
#: src/netshift/tabs/manager/initController.ts:132
#: src/netshift/tabs/manager/initController.ts:150
@ -498,12 +508,6 @@ msgstr ""
#: src/netshift/tabs/manager/initController.ts:192
#: src/netshift/tabs/manager/initController.ts:225
#: src/netshift/tabs/manager/initController.ts:229
#: src/netshift/tabs/diagnostic/initController.ts:229
#: src/netshift/tabs/diagnostic/initController.ts:233
#: src/netshift/tabs/diagnostic/initController.ts:263
#: src/netshift/tabs/diagnostic/initController.ts:267
#: src/netshift/tabs/diagnostic/initController.ts:304
#: src/netshift/tabs/diagnostic/initController.ts:308
msgid "Failed to execute!"
msgstr ""
@ -514,7 +518,7 @@ msgstr ""
msgid "Fastest"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:874
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:937
msgid "Fully Routed IPs"
msgstr ""
@ -526,15 +530,15 @@ msgstr ""
msgid "Global check"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:400
#: ../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:195
#: ../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:179
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:181
msgid "How often to automatically update the subscription"
msgstr ""
@ -542,7 +546,7 @@ msgstr ""
msgid "HTTP error"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:226
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:228
msgid "Include servers by keyword"
msgstr ""
@ -788,7 +792,7 @@ msgstr ""
msgid "Issues detected"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:227
#: ../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 ""
@ -801,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 ""
@ -813,11 +821,11 @@ msgstr ""
msgid "Lists & Updates"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:778
#: ../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:802
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:865
msgid "Local Subnet Lists"
msgstr ""
@ -841,7 +849,7 @@ msgstr ""
msgid "Mixed proxy and DNS resolution tuning"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:916
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:979
msgid "Mixed Proxy Port"
msgstr ""
@ -849,7 +857,7 @@ msgstr ""
msgid "Monitored Interfaces"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:343
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:405
msgid "Must be a number in the range of 50 - 1000"
msgstr ""
@ -873,7 +881,7 @@ msgstr ""
msgid "Network"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:418
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481
msgid "Network Interface"
msgstr ""
@ -906,15 +914,15 @@ msgstr ""
msgid "Not running"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:211
#: ../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:199
#: ../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:409
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472
msgid "Only one section can be global at a time."
msgstr ""
@ -922,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 ""
@ -963,7 +971,7 @@ msgstr ""
msgid "Pending"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:210
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:212
msgid "Prefix length"
msgstr ""
@ -971,7 +979,7 @@ msgstr ""
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 ""
@ -983,19 +991,19 @@ msgstr ""
msgid "Proxy traffic is routed via FakeIP"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:547
#: ../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:826
#: ../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:850
#: ../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:932
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:995
msgid "Resolve real IP for routing"
msgstr ""
@ -1003,7 +1011,7 @@ msgstr ""
msgid "Restart NetShift"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:401
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:464
msgid "Route all unmatched traffic through this section's outbound."
msgstr ""
@ -1051,7 +1059,7 @@ msgstr ""
msgid "Run Diagnostic"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:566
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629
msgid "Russia inside restrictions"
msgstr ""
@ -1063,7 +1071,7 @@ msgstr ""
msgid "Sections"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:514
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:577
msgid "Select a predefined list for routing"
msgstr ""
@ -1083,11 +1091,11 @@ msgstr ""
msgid "Select how to configure the proxy"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:419
#: ../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:491
#: ../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 ""
@ -1100,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:477
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:540
msgid "Select the DNS protocol type for the domain resolver"
msgstr ""
@ -1124,10 +1132,18 @@ msgstr ""
msgid "Selector"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:250
#: ../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 ""
@ -1149,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 ""
@ -1194,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:875
#: ../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:827
#: ../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:851
#: ../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:779
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:803
#: ../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 ""
@ -1220,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 ""
@ -1232,19 +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:194
#: ../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:178
#: ../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 ""
@ -1280,8 +1296,8 @@ msgstr ""
msgid "Test latency"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:613
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:701
#: ../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 ""
@ -1289,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:303
#: ../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:318
#: ../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:351
#: ../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 ""
@ -1329,27 +1345,27 @@ msgstr ""
msgid "TTL value cannot be empty"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481
#: ../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:389
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:452
msgid "UDP over TCP"
msgstr ""
#: src/netshift/tabs/manager/initController.ts:37
#: src/netshift/tabs/manager/initController.ts:38
#: src/netshift/tabs/manager/initController.ts:39
#: src/netshift/tabs/manager/initController.ts:40
#: src/netshift/tabs/manager/initController.ts:41
#: src/netshift/tabs/manager/initController.ts:42
#: src/netshift/tabs/diagnostic/initController.ts:39
#: src/netshift/tabs/diagnostic/initController.ts:40
#: src/netshift/tabs/diagnostic/initController.ts:41
#: src/netshift/tabs/diagnostic/initController.ts:42
#: src/netshift/tabs/diagnostic/initController.ts:43
#: src/netshift/tabs/diagnostic/initController.ts:44
#: src/netshift/tabs/manager/initController.ts:37
#: src/netshift/tabs/manager/initController.ts:38
#: src/netshift/tabs/manager/initController.ts:39
#: src/netshift/tabs/manager/initController.ts:40
#: src/netshift/tabs/manager/initController.ts:41
#: src/netshift/tabs/manager/initController.ts:42
#: src/netshift/tabs/diagnostic/helpers/getNetshiftVersionRow.ts:7
msgid "unknown"
msgstr ""
@ -1396,23 +1412,31 @@ msgstr ""
msgid "URLTest"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:302
#: ../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:276
#: ../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:350
#: ../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:317
#: ../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 ""
@ -1420,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:407
#: ../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:621
#: ../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:648
#: ../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:709
#: ../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:736
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:799
msgid "User Subnets List"
msgstr ""
@ -1462,8 +1486,8 @@ msgstr ""
msgid "Valid"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:681
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:768
#: ../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 ""
@ -1480,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:251
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:277
#: ../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:549
#: ../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:568
#: ../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:403
#: ../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 ""
@ -1502,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-12 08:52+0300\n"
"PO-Revision-Date: 2026-06-12 08:52+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 "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
@ -572,6 +575,9 @@ msgstr "Последняя"
msgid "Latest version is installed"
msgstr "Установлена последняя версия"
msgid "Line"
msgstr "Строка"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
@ -803,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 "Не удалось обновить"
@ -992,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 ссылка для проверки"
@ -1043,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

@ -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

@ -2,6 +2,12 @@
# shellcheck shell=dash
REPO="https://api.github.com/repos/yandexru45/netshift/releases/latest"
# github.com FRONTEND redirect path (NOT the rate-limited api.github.com).
# /releases/latest 302s to /releases/tag/<tag>; /releases/download/<tag>/<asset>
# 302s to the CDN. Primary install path so CGNAT / shared-IP routers avoid the
# 60/hour/IP API limit; REPO stays as the fallback.
RELEASES_LATEST_REDIRECT="https://github.com/yandexru45/netshift/releases/latest"
RELEASES_DOWNLOAD_BASE="https://github.com/yandexru45/netshift/releases/download"
DOWNLOAD_DIR="/tmp/netshift"
COUNT=3
@ -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 github.com 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";
@ -269,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,
@ -295,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,
@ -308,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(
@ -322,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) {
@ -366,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-12 08:52+0300\n"
"PO-Revision-Date: 2026-06-12 08:52+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 "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
@ -572,6 +575,9 @@ msgstr "Последняя"
msgid "Latest version is installed"
msgstr "Установлена последняя версия"
msgid "Line"
msgstr "Строка"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
@ -803,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 "Не удалось обновить"
@ -992,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 ссылка для проверки"
@ -1043,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-12 05:52+0300\n"
"PO-Revision-Date: 2026-06-12 05:52+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:607
#: ../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:695
#: ../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:390
#: ../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:667
#: 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:756
#: ../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,11 +113,11 @@ msgstr ""
msgid "Browser is using FakeIP correctly"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:200
#: ../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:201
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:203
msgid "By name prefix"
msgstr ""
@ -174,7 +178,7 @@ msgstr ""
msgid "Close"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:513
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:576
msgid "Community Lists"
msgstr ""
@ -227,11 +231,11 @@ msgstr ""
msgid "Currently unavailable"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:606
#: ../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:694
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:757
msgid "Custom subnets"
msgstr ""
@ -276,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:611
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:699
#: ../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 ""
@ -297,17 +301,17 @@ msgstr ""
msgid "DNS outbound section"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:479
#: ../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:480
#: ../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:476
#: ../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 ""
@ -316,7 +320,7 @@ msgstr ""
msgid "DNS Rewrite TTL"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:490
#: ../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 ""
@ -333,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:465
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:528
msgid "Domain Resolver"
msgstr ""
@ -363,12 +367,12 @@ msgstr ""
msgid "Downloading all lists via specific Proxy/VPN"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:239
#: ../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:612
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:700
#: ../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 ""
@ -376,11 +380,11 @@ msgstr ""
msgid "Enable autostart"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:466
#: ../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:933
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:996
msgid "Enable DNS resolve to get real IP when routing"
msgstr ""
@ -392,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:902
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:965
msgid "Enable Mixed Proxy"
msgstr ""
@ -400,7 +404,7 @@ msgstr ""
msgid "Enable Output Network Interface"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:903
#: ../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 ""
@ -412,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:649
#: ../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:622
#: ../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:710
#: ../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:306
#: ../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:307
#: ../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:305
#: ../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:308
#: ../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 ""
@ -476,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:238
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:240
msgid "Exclude servers by keyword"
msgstr ""
@ -490,6 +494,12 @@ msgstr ""
msgid "Failed to copy!"
msgstr ""
#: src/netshift/tabs/diagnostic/initController.ts:229
#: src/netshift/tabs/diagnostic/initController.ts:233
#: src/netshift/tabs/diagnostic/initController.ts:263
#: src/netshift/tabs/diagnostic/initController.ts:267
#: src/netshift/tabs/diagnostic/initController.ts:304
#: src/netshift/tabs/diagnostic/initController.ts:308
#: src/netshift/tabs/manager/initController.ts:122
#: src/netshift/tabs/manager/initController.ts:132
#: src/netshift/tabs/manager/initController.ts:150
@ -498,12 +508,6 @@ msgstr ""
#: src/netshift/tabs/manager/initController.ts:192
#: src/netshift/tabs/manager/initController.ts:225
#: src/netshift/tabs/manager/initController.ts:229
#: src/netshift/tabs/diagnostic/initController.ts:229
#: src/netshift/tabs/diagnostic/initController.ts:233
#: src/netshift/tabs/diagnostic/initController.ts:263
#: src/netshift/tabs/diagnostic/initController.ts:267
#: src/netshift/tabs/diagnostic/initController.ts:304
#: src/netshift/tabs/diagnostic/initController.ts:308
msgid "Failed to execute!"
msgstr ""
@ -514,7 +518,7 @@ msgstr ""
msgid "Fastest"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:874
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:937
msgid "Fully Routed IPs"
msgstr ""
@ -526,15 +530,15 @@ msgstr ""
msgid "Global check"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:400
#: ../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:195
#: ../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:179
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:181
msgid "How often to automatically update the subscription"
msgstr ""
@ -542,7 +546,7 @@ msgstr ""
msgid "HTTP error"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:226
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:228
msgid "Include servers by keyword"
msgstr ""
@ -788,7 +792,7 @@ msgstr ""
msgid "Issues detected"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:227
#: ../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 ""
@ -801,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 ""
@ -813,11 +821,11 @@ msgstr ""
msgid "Lists & Updates"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:778
#: ../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:802
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:865
msgid "Local Subnet Lists"
msgstr ""
@ -841,7 +849,7 @@ msgstr ""
msgid "Mixed proxy and DNS resolution tuning"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:916
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:979
msgid "Mixed Proxy Port"
msgstr ""
@ -849,7 +857,7 @@ msgstr ""
msgid "Monitored Interfaces"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:343
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:405
msgid "Must be a number in the range of 50 - 1000"
msgstr ""
@ -873,7 +881,7 @@ msgstr ""
msgid "Network"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:418
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481
msgid "Network Interface"
msgstr ""
@ -906,15 +914,15 @@ msgstr ""
msgid "Not running"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:211
#: ../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:199
#: ../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:409
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:472
msgid "Only one section can be global at a time."
msgstr ""
@ -922,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 ""
@ -963,7 +971,7 @@ msgstr ""
msgid "Pending"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:210
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:212
msgid "Prefix length"
msgstr ""
@ -971,7 +979,7 @@ msgstr ""
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 ""
@ -983,19 +991,19 @@ msgstr ""
msgid "Proxy traffic is routed via FakeIP"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:547
#: ../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:826
#: ../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:850
#: ../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:932
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:995
msgid "Resolve real IP for routing"
msgstr ""
@ -1003,7 +1011,7 @@ msgstr ""
msgid "Restart NetShift"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:401
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:464
msgid "Route all unmatched traffic through this section's outbound."
msgstr ""
@ -1051,7 +1059,7 @@ msgstr ""
msgid "Run Diagnostic"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:566
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:629
msgid "Russia inside restrictions"
msgstr ""
@ -1063,7 +1071,7 @@ msgstr ""
msgid "Sections"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:514
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:577
msgid "Select a predefined list for routing"
msgstr ""
@ -1083,11 +1091,11 @@ msgstr ""
msgid "Select how to configure the proxy"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:419
#: ../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:491
#: ../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 ""
@ -1100,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:477
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:540
msgid "Select the DNS protocol type for the domain resolver"
msgstr ""
@ -1124,10 +1132,18 @@ msgstr ""
msgid "Selector"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:250
#: ../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 ""
@ -1149,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 ""
@ -1194,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:875
#: ../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:827
#: ../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:851
#: ../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:779
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:803
#: ../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 ""
@ -1220,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 ""
@ -1232,19 +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:194
#: ../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:178
#: ../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 ""
@ -1280,8 +1296,8 @@ msgstr ""
msgid "Test latency"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:613
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:701
#: ../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 ""
@ -1289,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:303
#: ../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:318
#: ../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:351
#: ../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 ""
@ -1329,27 +1345,27 @@ msgstr ""
msgid "TTL value cannot be empty"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:481
#: ../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:389
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:452
msgid "UDP over TCP"
msgstr ""
#: src/netshift/tabs/manager/initController.ts:37
#: src/netshift/tabs/manager/initController.ts:38
#: src/netshift/tabs/manager/initController.ts:39
#: src/netshift/tabs/manager/initController.ts:40
#: src/netshift/tabs/manager/initController.ts:41
#: src/netshift/tabs/manager/initController.ts:42
#: src/netshift/tabs/diagnostic/initController.ts:39
#: src/netshift/tabs/diagnostic/initController.ts:40
#: src/netshift/tabs/diagnostic/initController.ts:41
#: src/netshift/tabs/diagnostic/initController.ts:42
#: src/netshift/tabs/diagnostic/initController.ts:43
#: src/netshift/tabs/diagnostic/initController.ts:44
#: src/netshift/tabs/manager/initController.ts:37
#: src/netshift/tabs/manager/initController.ts:38
#: src/netshift/tabs/manager/initController.ts:39
#: src/netshift/tabs/manager/initController.ts:40
#: src/netshift/tabs/manager/initController.ts:41
#: src/netshift/tabs/manager/initController.ts:42
#: src/netshift/tabs/diagnostic/helpers/getNetshiftVersionRow.ts:7
msgid "unknown"
msgstr ""
@ -1396,23 +1412,31 @@ msgstr ""
msgid "URLTest"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:302
#: ../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:276
#: ../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:350
#: ../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:317
#: ../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 ""
@ -1420,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:407
#: ../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:621
#: ../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:648
#: ../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:709
#: ../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:736
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:799
msgid "User Subnets List"
msgstr ""
@ -1462,8 +1486,8 @@ msgstr ""
msgid "Valid"
msgstr ""
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:681
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:768
#: ../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 ""
@ -1480,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:251
#: ../luci-app-netshift/htdocs/luci-static/resources/view/netshift/section.js:277
#: ../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:549
#: ../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:568
#: ../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:403
#: ../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 ""
@ -1502,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

@ -86,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"
}
@ -839,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
@ -1170,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"
@ -2227,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"
@ -2273,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"
@ -2282,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"
@ -2331,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")"
@ -2597,7 +2759,8 @@ configure_outbound_handler() {
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
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_groups "$subscription_outbound_tags_json" "$group_mode" "$prefix_len")"
if [ -z "$grouping_json" ]; then
@ -2644,6 +2807,34 @@ configure_outbound_handler() {
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=$(
@ -2657,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

@ -92,6 +92,15 @@ UPDATES_LIBCRONET_LIB="/usr/lib/libcronet.so"
# 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"
# GitHub FRONTEND (github.com, NOT the rate-limited api.github.com) redirect path
# for the NetShift repo. /releases/latest 302-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> 302s to the
# CDN 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://github.com/yandexru45/netshift/releases/latest"
NETSHIFT_REPO_RELEASES_DOWNLOAD_BASE="https://github.com/yandexru45/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"
@ -130,6 +139,12 @@ 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"

View File

@ -1626,15 +1626,45 @@ updates_self_update_netshift() {
return "$rc"
}
# Echoes the GitHub latest-release tag for NetShift (e.g. "0.8.8"), or nothing.
# Reuses the same API endpoint as get_system_info / install.sh. Parsed with jq
# (NOT grep/cut): GitHub may return the release object pretty-printed OR minified
# (single line); a field-positional grep|cut grabs the first key's value (the
# release "url") on minified JSON, which caused a false "outdated" + a self-update
# that downloaded a garbage "version". jq is format-independent.
updates_netshift_latest_tag() {
local response tag
# 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 github.com frontend redirect of /releases/latest — it
# 302s 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
# api.github.com release object parsed with jq (task-047) so a curl-less box or a
# changed-redirect github 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: github.com/<repo>/releases/latest 302-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
@ -1645,25 +1675,75 @@ updates_netshift_latest_tag() {
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

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, latesttag, 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:

View File

@ -1272,6 +1272,201 @@ USEOF
rm -f "$drv"
}
# ─────────────────────────────────────────────────────────────────
# Test: Text-list Selector / URLTest (task-051)
#
# Drives the SHIPPED configure_outbound_handler (awk-extracted verbatim) for the
# NEW selector_text / urltest_text branches with a table-driven config_get stub,
# the REAL facade/manager/helpers, and a real `sing-box check`. The textarea
# value is a multi-line blob: two synthetic vless:// + one ss:// + a blank line +
# one unsupported tuic://, plus a CRLF-suffixed line to prove trailing-\r
# tolerance. All values are synthetic placeholders (nothing private).
#
# IMPORTANT (gating): the driver writes name:OK/FAIL/SKIP tokens to a RESULT FILE
# and the assertions are consumed in the CURRENT shell via `while read < file`
# (NOT `cmd | while read`), so pass/fail mutate the real PASS/FAIL counters and
# this test actually GATES the suite.
test_text_list_outbound() {
header "Text-list Selector / URLTest (task-051)"
if ! command -v sing-box > /dev/null 2>&1; then
skip "sing-box not installed"
return
fi
local lib="${NETSHIFT_LIB_DIR}"
local bin="${NETSHIFT_SRC}/usr/bin/netshift"
local facade_lib="$lib/sing_box_config_facade.sh"
if [ ! -r "$facade_lib" ] || [ ! -r "$bin" ]; then
fail "facade lib / bin not found"
return
fi
# The facade hardcodes NETSHIFT_LIB="/usr/lib/netshift" for its own sourcing
# of helpers + manager; bind the bind-mounted sources to that path.
mkdir -p /usr/lib/netshift
ln -sf "$lib/helpers.sh" /usr/lib/netshift/helpers.sh
ln -sf "$lib/sing_box_config_manager.sh" /usr/lib/netshift/sing_box_config_manager.sh
local drv="/tmp/test-text-list-$$.sh"
local out="/tmp/test-text-list-out-$$.txt"
cat > "$drv" << 'TLEOF'
. "CONST_LIB"
. "FACADE_LIB"
WARN_LOG="/tmp/tl-warn-$$.log"
: > "$WARN_LOG"
log() { printf '%s|%s\n' "${2:-info}" "$1" >> "$WARN_LOG"; }
echolog() { printf '%s|%s\n' "${2:-info}" "$1" >> "$WARN_LOG"; }
nolog() { :; }
is_sing_box_extended() { return 0; }
# awk-extract the SHIPPED helper + handler + unavailable marker verbatim.
eval "$(awk '/^_build_proxy_member_outbounds\(\) \{/{p=1} p{print} p&&/^\}/{exit}' "BIN_PATH")"
eval "$(awk '/^configure_outbound_handler\(\) \{/{p=1} p{print} p&&/^\}/{exit}' "BIN_PATH")"
eval "$(awk '/^mark_section_outbound_unavailable\(\) \{/{p=1} p{print} p&&/^\}/{exit}' "BIN_PATH")"
_tl_key() { printf 'TL_%s_%s' "$(printf '%s' "$1" | tr '.-' '__')" "$2"; }
config_get() {
local _k _v
_k="$(_tl_key "$2" "$3")"
eval "_v=\"\${$_k:-}\""
[ -n "$_v" ] || _v="$4"
eval "$1=\"\$_v\""
return 0
}
warn_logged() { grep -q "$1" "$WARN_LOG"; }
check_full() {
local cfgjson="$1" label="$2" full
full="/tmp/tl-full-$$-${label}.json"
printf '%s' "$cfgjson" | jq '{
log: { level: "error" },
dns: { servers: [ { tag: "dns-server", type: "udp", server: "1.1.1.1" } ], final: "dns-server" },
inbounds: [ { type: "tproxy", tag: "tproxy-in", listen: "127.0.0.1", listen_port: 1602 } ],
outbounds: (.outbounds + [ { type: "direct", tag: "direct-out" } ]),
route: { rules: [], final: "direct-out" }
}' > "$full" 2>/dev/null
if sing-box -c "$full" check > /dev/null 2>&1; then
echo "${label}:OK"
else
echo "${label}:FAIL"
fi
rm -f "$full"
}
# Multi-line synthetic blob: vless (line1) + vless (line2) + blank line +
# ss+CRLF (line3 carries a trailing \r) + unsupported tuic (line4). The CRLF on
# the ss line proves the trailing \r is stripped: it sits right after the
# `:8388` port, so an un-stripped \r would corrupt the port and the member would
# NOT build (a decisive gate, unlike a CR buried in a query string). Built with
# printf so the \r and the blank line are real bytes inside one scalar value.
TL_BLOB="$(printf '%s\n%s\n\n%s\r\n%s\n' \
'vless://11111111-2222-3333-4444-555555555555@v1.example.com:443?security=tls&sni=v1.example.com' \
'vless://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@v2.example.com:443?security=tls&sni=v2.example.com' \
'ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@s1.example.com:8388' \
'tuic://uuid:pw@t1.example.com:443')"
# ── selector_text ────────────────────────────────────────────────────────────
config='{"outbounds":[]}'
SUBSCRIPTION_UNAVAILABLE_SECTIONS=""
TL_seltxt_connection_type="proxy"
TL_seltxt_proxy_config_type="selector_text"
TL_seltxt_selector_proxy_links_text="$TL_BLOB"
configure_outbound_handler "seltxt"
seltxt_rc=$?
[ "$seltxt_rc" = "0" ] && echo 'tl-seltxt-no-abort:OK' || echo "tl-seltxt-no-abort:FAIL (rc=$seltxt_rc)"
# 3 supported members built (seltxt-1 vless, seltxt-2 vless [CRLF line], seltxt-4 ss).
printf '%s' "$config" | jq -e '[.outbounds[] | select(.tag=="seltxt-1-out" and .type=="vless")] | length==1' >/dev/null 2>&1 \
&& echo 'tl-seltxt-vless1-present:OK' || echo 'tl-seltxt-vless1-present:FAIL'
printf '%s' "$config" | jq -e '[.outbounds[] | select(.tag=="seltxt-2-out" and .type=="vless")] | length==1' >/dev/null 2>&1 \
&& echo 'tl-seltxt-vless2-present:OK' || echo 'tl-seltxt-vless2-present:FAIL'
# The ss line carries a trailing CR (CRLF); it must still build with the \r
# stripped (decisive CRLF-tolerance gate).
printf '%s' "$config" | jq -e '[.outbounds[] | select(.tag=="seltxt-3-out" and .type=="shadowsocks")] | length==1' >/dev/null 2>&1 \
&& echo 'tl-seltxt-ss-crlf-present:OK' || echo 'tl-seltxt-ss-crlf-present:FAIL'
# Unsupported tuic (line5 → seltxt-4; blank line is collapsed by IFS so it does
# NOT consume an index) NOT created.
printf '%s' "$config" | jq -e '[.outbounds[] | select(.tag=="seltxt-4-out")] | length==0' >/dev/null 2>&1 \
&& echo 'tl-seltxt-tuic-absent:OK' || echo 'tl-seltxt-tuic-absent:FAIL'
# Selector references exactly the 3 real members, default = first (seltxt-1-out).
printf '%s' "$config" | jq -e '[.outbounds[] | select(.type=="selector")][0].outbounds | (index("seltxt-1-out")!=null and index("seltxt-2-out")!=null and index("seltxt-3-out")!=null and index("seltxt-4-out")==null)' >/dev/null 2>&1 \
&& echo 'tl-seltxt-members-clean:OK' || echo 'tl-seltxt-members-clean:FAIL'
printf '%s' "$config" | jq -e '[.outbounds[] | select(.type=="selector")][0].default=="seltxt-1-out"' >/dev/null 2>&1 \
&& echo 'tl-seltxt-default-first:OK' || echo 'tl-seltxt-default-first:FAIL'
warn_logged "unsupported scheme" && echo 'tl-seltxt-warning-logged:OK' || echo 'tl-seltxt-warning-logged:FAIL'
check_full "$config" "tl-seltxt-singbox-check"
# ── urltest_text ─────────────────────────────────────────────────────────────
: > "$WARN_LOG"
config='{"outbounds":[]}'
SUBSCRIPTION_UNAVAILABLE_SECTIONS=""
TL_urltxt_connection_type="proxy"
TL_urltxt_proxy_config_type="urltest_text"
TL_urltxt_urltest_proxy_links_text="$TL_BLOB"
configure_outbound_handler "urltxt"
urltxt_rc=$?
[ "$urltxt_rc" = "0" ] && echo 'tl-urltxt-no-abort:OK' || echo "tl-urltxt-no-abort:FAIL (rc=$urltxt_rc)"
# urltest built over the 3 real members (no dangling unsupported tag).
printf '%s' "$config" | jq -e '[.outbounds[] | select(.type=="urltest")][0].outbounds | (index("urltxt-1-out")!=null and index("urltxt-2-out")!=null and index("urltxt-3-out")!=null and index("urltxt-4-out")==null)' >/dev/null 2>&1 \
&& echo 'tl-urltxt-urltest-members-clean:OK' || echo 'tl-urltxt-urltest-members-clean:FAIL'
# selector built over [members + urltest tag].
printf '%s' "$config" | jq -e '[.outbounds[] | select(.type=="selector")][0].outbounds as $o | ($o | index("urltxt-1-out")!=null) and ($o | index("urltxt-urltest-out")!=null)' >/dev/null 2>&1 \
&& echo 'tl-urltxt-selector-over-urltest:OK' || echo 'tl-urltxt-selector-over-urltest:FAIL'
check_full "$config" "tl-urltxt-singbox-check"
# ── _check_outbound_section returns 0 for a non-empty text option ────────────
# Pull in the requirements-check chain verbatim and stub config_foreach to drive
# our single section through it.
eval "$(awk '/^section_has_configured_outbound\(\) \{/{p=1} p{print} p&&/^\}/{exit}' "BIN_PATH")"
eval "$(awk '/^_check_outbound_section\(\) \{/{p=1} p{print} p&&/^\}/{exit}' "BIN_PATH")"
eval "$(awk '/^has_outbound_section\(\) \{/{p=1} p{print} p&&/^\}/{exit}' "BIN_PATH")"
get_subscription_urls_for_section() { :; }
config_foreach() { "$1" "chk_seltxt"; "$1" "chk_urltxt"; }
TL_chk_seltxt_connection_type="proxy"
TL_chk_seltxt_proxy_config_type="selector_text"
TL_chk_seltxt_selector_proxy_links_text="vless://11111111-2222-3333-4444-555555555555@v1.example.com:443"
section_has_configured_outbound "chk_seltxt" \
&& echo 'tl-check-seltxt-found:OK' || echo 'tl-check-seltxt-found:FAIL'
TL_chk_urltxt_connection_type="proxy"
TL_chk_urltxt_proxy_config_type="urltest_text"
TL_chk_urltxt_urltest_proxy_links_text="vless://11111111-2222-3333-4444-555555555555@v1.example.com:443"
section_has_configured_outbound "chk_urltxt" \
&& echo 'tl-check-urltxt-found:OK' || echo 'tl-check-urltxt-found:FAIL'
rm -f "$WARN_LOG"
echo 'DONE'
TLEOF
sed -i "s|CONST_LIB|$lib/constants.sh|g; s|FACADE_LIB|$facade_lib|g; s|BIN_PATH|$bin|g" "$drv"
# Run the driver to a RESULT FILE, then consume tokens in the CURRENT shell
# (while read < file — NO pipe) so pass/fail mutate the real counters/gate.
sh "$drv" > "$out" 2>/dev/null
local saw_done=0 line
while IFS= read -r line; do
case "$line" in
*:OK) pass "$line" ;;
*:FAIL) fail "$line" ;;
*:SKIP) skip "$line" ;;
DONE) saw_done=1 ;;
*) ;;
esac
done < "$out"
[ "$saw_done" = "1" ] && pass "tl-driver-completed:OK" || fail "tl-driver-completed:FAIL (driver aborted early)"
rm -f "$drv" "$out"
}
# ─────────────────────────────────────────────────────────────────
# Test: Monitor procd-lock fd hygiene (task-035) + monitor-leak (task-036)
#
@ -3744,6 +3939,259 @@ CCEOF
rm -f "$cc" "$cc_out"
}
# ─────────────────────────────────────────────────────────────────
# Test: "Fastest" cross-group urltest of urltests (task-050)
#
# When subscription grouping is ON (country/prefix) and there are >= 2 groups,
# the grouped branch in bin/netshift adds a top-level urltest tagged
# $SB_SUBSCRIPTION_FASTEST_GROUP_TAG whose members are the per-group urltests
# ("<key> Fastest"), PREPENDS it to the main selector, and makes it the selector
# default. Groups + ungrouped stay selectable. groups==1 -> no nested layer
# (default = lone group). off -> flat urltest+selector unchanged.
#
# The grouped branch is inline shell inside configure_outbound_handler (not its
# own function), so we awk-extract that exact code region VERBATIM out of the
# live bin (from the branch's `local grouping_json ...` decl through the final
# grouped selector build) and wrap it in a driver function — the test exercises
# the SHIPPED logic, not a copy. We seed $config with synthetic flag-tagged
# shadowsocks outbounds (no real subscription data) so the generated config can
# be fed to `sing-box check`. Tokens use the name:OK/FAIL convention; the driver
# output is parsed in the CURRENT shell (no pipe) so the tokens GATE CI.
# ─────────────────────────────────────────────────────────────────
test_fastest_group() {
header "Fastest Cross-Group urltest (task-050)"
if ! command -v jq > /dev/null 2>&1; then
skip "jq not available"
return
fi
local bin="${NETSHIFT_SRC}/usr/bin/netshift"
local constants="${NETSHIFT_LIB_DIR}/constants.sh"
local manager="${NETSHIFT_LIB_DIR}/sing_box_config_manager.sh"
if [ ! -r "$bin" ] || [ ! -r "$constants" ] || [ ! -r "$manager" ]; then
skip "netshift bin / constants.sh / config_manager.sh not found"
return
fi
local work="/tmp/netshift-fastest-$$"
mkdir -p "$work"
local drv="$work/driver.sh"
cat > "$drv" << 'FGEOF'
# Quiet logger (the grouped branch logs at info/debug/fatal; never let a fatal
# log mask the real exit code — the branch calls `exit 1` itself on failure).
log() { :; }
echolog() { :; }
nolog() { :; }
# Real constant ($SB_SUBSCRIPTION_FASTEST_GROUP_TAG) + cm primitives.
. "CONSTANTS_PATH"
. "MANAGER_PATH"
# Pull the shipped helpers VERBATIM out of the live bin so we test shipped code.
eval "$(awk '/^sing_box_get_unique_outbound_tag\(\) \{/{p=1} p{print} p&&/^\}/{exit}' "BIN_PATH")"
eval "$(awk '/^sing_box_build_subscription_groups\(\) \{/{p=1} p{print} p&&/^\}/{exit}' "BIN_PATH")"
# Extract the WHOLE grouping if/else region VERBATIM (the grouped `then` branch
# AND the flat `off` `else` branch) and wrap it as a function so we can drive
# both modes against the SHIPPED code. The leading `if ...; then local ...`
# line is valid inside this wrapper.
_grouped_branch() {
EXTRACT_GROUPED
}
# The off branch derives its urltest tag via get_outbound_tag_by_section; the
# grouped branch derives only $selector_tag (which we set ourselves). Stub it
# deterministically (synthetic, no real data).
get_outbound_tag_by_section() { printf '%s-out' "$1"; }
run_case() {
# $1 = group_mode, $2 = prefix_len, $3 = tags-json, $4 = base config (with
# the synthetic outbounds), $5 = selector tag. Echoes the resulting config.
group_mode="$1"
prefix_len="$2"
subscription_outbound_tags_json="$3"
config="$4"
selector_tag="$5"
section="syn"
urltest_testing_url="https://www.gstatic.com/generate_204"
urltest_check_interval="3m0s"
urltest_tolerance="50"
selector_outbounds=""
selector_default=""
_grouped_branch
printf '%s' "$config"
}
FGEOF
# Build the synthetic outbound set: two country groups (RU/DE flags) with two
# nodes each + one ungrouped node. Flags are regional-indicator pairs built
# by codepoint so NO real subscription identifiers appear anywhere.
local synth_json
synth_json="$(jq -cn '
def flag($a; $b): ([127462 + $a, 127462 + $b] | implode);
(flag(17; 20)) as $ru # RU
| (flag(3; 4)) as $de # DE
| {
outbounds: [
{type:"shadowsocks", tag:($ru + " N1"), server:"10.0.0.1", server_port:443, method:"aes-256-gcm", password:"p"},
{type:"shadowsocks", tag:($ru + " N2"), server:"10.0.0.2", server_port:443, method:"aes-256-gcm", password:"p"},
{type:"shadowsocks", tag:($de + " N1"), server:"10.0.0.3", server_port:443, method:"aes-256-gcm", password:"p"},
{type:"shadowsocks", tag:($de + " N2"), server:"10.0.0.4", server_port:443, method:"aes-256-gcm", password:"p"},
{type:"shadowsocks", tag:"plain-node", server:"10.0.0.5", server_port:443, method:"aes-256-gcm", password:"p"},
{type:"direct", tag:"direct-out"}
]
}')"
local tags_json
tags_json="$(printf '%s' "$synth_json" | jq -c '[.outbounds[] | select(.type=="shadowsocks") | .tag]')"
# Single-group set: only RU nodes (no DE, no ungrouped).
local synth1_json synth1_tags
synth1_json="$(jq -cn '
def flag($a; $b): ([127462 + $a, 127462 + $b] | implode);
(flag(17; 20)) as $ru
| {
outbounds: [
{type:"shadowsocks", tag:($ru + " N1"), server:"10.0.1.1", server_port:443, method:"aes-256-gcm", password:"p"},
{type:"shadowsocks", tag:($ru + " N2"), server:"10.0.1.2", server_port:443, method:"aes-256-gcm", password:"p"},
{type:"direct", tag:"direct-out"}
]
}')"
synth1_tags="$(printf '%s' "$synth1_json" | jq -c '[.outbounds[] | select(.type=="shadowsocks") | .tag]')"
# Substitute the awk-extracted grouped-branch region into the driver. The
# region is plain shell statements; sed reads it from the live bin between
# the unique markers and writes it where EXTRACT_GROUPED sits.
local region="$work/region.sh"
# Capture from the `if [ "$group_mode" != "off" ]; then` opener through the
# off-branch's final selector build line and the immediately following `fi`
# that closes the if/else (q-flag stops after that fi).
awk '
/if \[ "\$group_mode" != "off" \]; then/{p=1}
p{print}
p && /"\$urltest_tag" "true"\)"/{seen_else_end=1; next}
seen_else_end && /^[[:space:]]*fi$/{exit}
' "$bin" > "$region"
# Confirm the region captured BOTH branches: the fastest prepend (grouped),
# the cm urltest call, and the off-branch closing.
if grep -q 'SB_SUBSCRIPTION_FASTEST_GROUP_TAG' "$region" \
&& grep -q 'sing_box_cm_add_urltest_outbound' "$region" \
&& grep -q 'Create urltest + selector' "$region"; then
pass "fastest-region-extracted:OK"
else
fail "fastest-region-extracted:FAIL" "$(head -5 "$region" 2>/dev/null)"
fi
# Splice region into the driver in place of the EXTRACT_GROUPED placeholder
# (use an r-command via a temp because the region contains arbitrary chars).
{
sed '/EXTRACT_GROUPED/q' "$drv" | sed '$d'
cat "$region"
sed -n '/EXTRACT_GROUPED/,$p' "$drv" | sed '1d'
} > "$drv.spliced"
mv "$drv.spliced" "$drv"
sed -i "s|CONSTANTS_PATH|$constants|g;s|MANAGER_PATH|$manager|g;s|BIN_PATH|$bin|g" "$drv"
# ── >= 2 groups (country mode): nested Fastest urltest + selector default ──
local out2="$work/out2.json"
{
echo ". \"$drv\""
echo "run_case country 2 '$tags_json' '$synth_json' 'syn-out'"
} > "$work/run2.sh"
ash "$work/run2.sh" > "$out2" 2>/dev/null || true
# The deduped fastest tag (the constant; no collision in our synthetic set).
local fastest_expected ru_tag de_tag
fastest_expected="$(. "$constants"; printf '%s' "$SB_SUBSCRIPTION_FASTEST_GROUP_TAG")"
ru_tag="$(printf '%s' "$synth_json" | jq -r '.outbounds[0].tag' | sed 's/ N1$//') Fastest"
de_tag="$(printf '%s' "$synth_json" | jq -r '.outbounds[2].tag' | sed 's/ N1$//') Fastest"
# (a) Top-level urltest tagged the fastest tag whose outbounds are EXACTLY
# the per-group urltest tags.
if jq -e --arg t "$fastest_expected" --arg g1 "$ru_tag" --arg g2 "$de_tag" '
([.outbounds[] | select(.type=="urltest" and .tag==$t)]) as $f
| ($f | length) == 1
and ($f[0].outbounds == [$g1, $g2])
' "$out2" > /dev/null 2>&1; then
pass "fastest-nested-urltest-members:OK"
else
fail "fastest-nested-urltest-members:FAIL" "$(jq -c '[.outbounds[]|select(.type=="urltest")|{tag,outbounds}]' "$out2" 2>/dev/null)"
fi
# (b) Main selector default == fastest tag, and outbounds ==
# [fastest, group1, group2, ungrouped...].
if jq -e --arg t "$fastest_expected" --arg g1 "$ru_tag" --arg g2 "$de_tag" '
([.outbounds[] | select(.type=="selector" and .tag=="syn-out")]) as $s
| ($s | length) == 1
and ($s[0].default == $t)
and ($s[0].outbounds == [$t, $g1, $g2, "plain-node"])
' "$out2" > /dev/null 2>&1; then
pass "fastest-selector-default-membership:OK"
else
fail "fastest-selector-default-membership:FAIL" "$(jq -c '.outbounds[]|select(.type=="selector")|{tag,default,outbounds}' "$out2" 2>/dev/null)"
fi
# (c) sing-box check PASSES on the generated config WITH the nested urltest.
if command -v sing-box > /dev/null 2>&1; then
local chk2="$work/check2.json"
# Wrap the outbounds into a minimal full config sing-box can validate.
jq '{
log: {disabled:true},
dns: {servers: [], rules: [], final: "direct"},
inbounds: [{type:"direct", tag:"dns-in", listen:"127.0.0.42", listen_port:53}],
outbounds: .outbounds,
route: {rules: [], rule_set: [], final: "direct-out", auto_detect_interface: true}
}' "$out2" > "$chk2" 2>/dev/null
if sing-box -c "$chk2" check > /dev/null 2>&1; then
pass "fastest-singbox-check-passes:OK"
else
fail "fastest-singbox-check-passes:FAIL" "$(sing-box -c "$chk2" check 2>&1 | head -3)"
fi
else
skip "fastest-singbox-check-passes (sing-box not installed)"
fi
# (d) groups==1 -> NO redundant nested urltest; default = lone group.
local out1="$work/out1.json"
{
echo ". \"$drv\""
echo "run_case country 2 '$synth1_tags' '$synth1_json' 'syn1-out'"
} > "$work/run1.sh"
ash "$work/run1.sh" > "$out1" 2>/dev/null || true
local lone_group
lone_group="$(printf '%s' "$synth1_json" | jq -r '.outbounds[0].tag' | sed 's/ N1$//') Fastest"
if jq -e --arg t "$fastest_expected" --arg lone "$lone_group" '
([.outbounds[] | select(.type=="urltest" and .tag==$t)] | length) == 0
and ([.outbounds[] | select(.type=="selector" and .tag=="syn1-out")][0].default == $lone)
' "$out1" > /dev/null 2>&1; then
pass "fastest-single-group-no-nest:OK"
else
fail "fastest-single-group-no-nest:FAIL" "$(jq -c '[.outbounds[]|select(.type=="urltest" or .type=="selector")|{type,tag,default}]' "$out1" 2>/dev/null)"
fi
# (e) off mode unchanged (regression): flat urltest + selector, NO fastest
# tag, selector default == the flat urltest tag (<section>-urltest-out).
local outoff="$work/outoff.json"
{
echo ". \"$drv\""
echo "run_case off 2 '$tags_json' '$synth_json' 'syn-out'"
} > "$work/runoff.sh"
ash "$work/runoff.sh" > "$outoff" 2>/dev/null || true
if jq -e --arg t "$fastest_expected" '
([.outbounds[] | select(.type=="urltest" and .tag==$t)] | length) == 0
and ([.outbounds[] | select(.type=="urltest")] | length) == 1
and ([.outbounds[] | select(.type=="selector" and .tag=="syn-out")][0].default
== "syn-urltest-out")
' "$outoff" > /dev/null 2>&1; then
pass "fastest-off-mode-unchanged:OK"
else
fail "fastest-off-mode-unchanged:FAIL" "$(jq -c '[.outbounds[]|select(.type=="urltest" or .type=="selector")|{type,tag,default}]' "$outoff" 2>/dev/null)"
fi
rm -rf "$work"
}
# ─────────────────────────────────────────────────────────────────
# Test: Insecure subscription fetch flag (task-021b)
#
@ -4836,6 +5284,187 @@ DDEOF
rm -f "$drv"
}
# ─────────────────────────────────────────────────────────────────
# Test: scalar `option subscription_url` read-fallback + option->list migration
# (task-048)
# ─────────────────────────────────────────────────────────────────
# REAL-UCI regression guard for the hardware bug: a section storing
# subscription_url as a scalar UCI `option` (legacy / CLI / podkop-migrated
# configs) made get_subscription_urls_for_section return EMPTY (config_list_foreach
# iterates ONLY list values), so has_outbound_section failed and sing-box never
# started. This must use the SHIPPED functions against an actual config_load — NOT
# the stubbed config_list_foreach in test_subscription (which honors MU_URLS
# directly and therefore cannot catch the broken primitive). Synthetic URL only.
test_sub_url_option() {
header "Scalar option subscription_url read-fallback + migration (task-048)"
local bin="${NETSHIFT_SRC}/usr/bin/netshift"
if [ ! -r "$bin" ]; then
skip "suburlopt — bin/netshift not found"
return
fi
if [ ! -r /lib/functions.sh ] || [ ! -r /lib/config/uci.sh ] || ! command -v uci > /dev/null 2>&1; then
skip "suburlopt — LuCI config_load / uci not available"
return
fi
local lib="${NETSHIFT_LIB_DIR}"
local drv="/tmp/netshift-suburlopt-$$.sh"
cat > "$drv" << 'SUBOPTEOF'
BIN="BIN_PATH_PLACEHOLDER"
LIB="LIB_DIR_PLACEHOLDER"
. /lib/functions.sh
. /lib/config/uci.sh 2>/dev/null || true
# shellcheck disable=SC1090
. "$LIB/constants.sh"
# shellcheck disable=SC1090
. "$LIB/helpers.sh"
log() { :; }
echolog() { :; }
nolog() { :; }
# Exercise the SHIPPED functions verbatim (awk-extracted) against a real
# config_load — this is the whole point: the real LuCI config_list_foreach /
# config_get primitives, not a stub.
for fn in get_subscription_urls_for_section _collect_subscription_url_handler \
section_has_configured_outbound \
migrate_legacy_subscription_url_option \
_migrate_legacy_subscription_url_option_handler; do
eval "$(awk -v f="$fn" '$0 ~ "^"f"\\(\\) \\{"{p=1} p{print} p&&/^\}/{exit}' "$BIN")"
done
mkdir -p /etc/config
# ── Fixture A: SCALAR option subscription_url (the exact broken shape) ──
cat > /etc/config/netshift_suboptscalar <<'CFGEOF'
config section 'main'
option connection_type 'proxy'
option proxy_config_type 'subscription'
option subscription_url 'https://example.com/sub'
CFGEOF
config_load netshift_suboptscalar
urls="$(get_subscription_urls_for_section main)"
[ "$urls" = "https://example.com/sub" ] && echo 'suburlopt:scalar-read:OK' || echo "suburlopt:scalar-read:FAIL [$urls]"
if section_has_configured_outbound main; then
echo 'suburlopt:scalar-hasoutbound:OK'
else
echo 'suburlopt:scalar-hasoutbound:FAIL'
fi
rm -f /etc/config/netshift_suboptscalar
# ── Fixture B: LIST subscription_url (must still work — no regression) ──
cat > /etc/config/netshift_suboptlist <<'CFGEOF'
config section 'main'
option connection_type 'proxy'
option proxy_config_type 'subscription'
list subscription_url 'https://example.com/sub'
CFGEOF
config_load netshift_suboptlist
urls="$(get_subscription_urls_for_section main)"
[ "$urls" = "https://example.com/sub" ] && echo 'suburlopt:list-read:OK' || echo "suburlopt:list-read:FAIL [$urls]"
rm -f /etc/config/netshift_suboptlist
# ── Migration: option -> list, idempotent. The migration function hardcodes
# the `netshift` config name, so write a throwaway /etc/config/netshift (the
# caller backs up + restores any real one). Two sections: a plain URL AND a URL
# with a query string containing `=`/`&`/`?` — the latter is the [B1] regression
# guard: the old `uci add_list "key=value"` CLI form splits on the first `=` and
# LOSES the value, while uci_add_list preserves it byte-for-byte. ──
NETSHIFT_CONFIG="netshift"
EQ_URL='https://example.com/sub?token=abc&x=1'
cat > /etc/config/netshift <<CFGEOF
config section 'main'
option connection_type 'proxy'
option proxy_config_type 'subscription'
option subscription_url 'https://example.com/sub'
config section 'query'
option connection_type 'proxy'
option proxy_config_type 'subscription'
option subscription_url '$EQ_URL'
CFGEOF
config_load netshift
# First run: must migrate both scalar options -> lists and flip the flag.
migrate_legacy_subscription_url_option
if [ "$SUBSCRIPTION_URL_OPTION_MIGRATED" = "1" ]; then
echo 'suburlopt:migrate-flag:OK'
else
echo "suburlopt:migrate-flag:FAIL [$SUBSCRIPTION_URL_OPTION_MIGRATED]"
fi
# The stored values must be preserved.
migrated_val="$(uci -q get netshift.main.subscription_url)"
[ "$migrated_val" = "https://example.com/sub" ] && echo 'suburlopt:migrate-value:OK' || echo "suburlopt:migrate-value:FAIL [$migrated_val]"
# [B1] regression guard: the `=`/`&` URL survives byte-for-byte.
migrated_eq="$(uci -q get netshift.query.subscription_url)"
[ "$migrated_eq" = "$EQ_URL" ] && echo 'suburlopt:migrate-equrl-preserved:OK' || echo "suburlopt:migrate-equrl-preserved:FAIL [$migrated_eq]"
# After a fresh config_load the LIST path (config_list_foreach) returns each URL,
# and the committed state must be a CLEAN single-element list (no leftover scalar
# option and no duplicate element).
config_load netshift
SUBSCRIPTION_URLS_COLLECTED=""
config_list_foreach main subscription_url _collect_subscription_url_handler
[ "$SUBSCRIPTION_URLS_COLLECTED" = "https://example.com/sub" ] && echo 'suburlopt:migrate-islist:OK' || echo "suburlopt:migrate-islist:FAIL [$SUBSCRIPTION_URLS_COLLECTED]"
SUBSCRIPTION_URLS_COLLECTED=""
config_list_foreach query subscription_url _collect_subscription_url_handler
[ "$SUBSCRIPTION_URLS_COLLECTED" = "$EQ_URL" ] && echo 'suburlopt:migrate-equrl-islist:OK' || echo "suburlopt:migrate-equrl-islist:FAIL [$SUBSCRIPTION_URLS_COLLECTED]"
# Clean single element: `uci show` must render exactly one list value per section
# (no leftover scalar option, no duplicate). uci renders a list element with the
# index-bearing `[0]` syntax; assert exactly one line each.
eq_lines="$(uci -q show netshift.query.subscription_url | grep -c "subscription_url")"
[ "$eq_lines" = "1" ] && echo 'suburlopt:migrate-equrl-single:OK' || echo "suburlopt:migrate-equrl-single:FAIL [$eq_lines]"
# Second run: idempotent no-op (already a list -> flag stays 0, no churn).
migrate_legacy_subscription_url_option
if [ "$SUBSCRIPTION_URL_OPTION_MIGRATED" = "0" ]; then
echo 'suburlopt:migrate-idempotent:OK'
else
echo "suburlopt:migrate-idempotent:FAIL [$SUBSCRIPTION_URL_OPTION_MIGRATED]"
fi
idem_val="$(uci -q get netshift.main.subscription_url)"
[ "$idem_val" = "https://example.com/sub" ] && echo 'suburlopt:migrate-idempotent-value:OK' || echo "suburlopt:migrate-idempotent-value:FAIL [$idem_val]"
idem_eq="$(uci -q get netshift.query.subscription_url)"
[ "$idem_eq" = "$EQ_URL" ] && echo 'suburlopt:migrate-idempotent-equrl:OK' || echo "suburlopt:migrate-idempotent-equrl:FAIL [$idem_eq]"
rm -f /etc/config/netshift
echo 'DONE'
SUBOPTEOF
sed -i "s|LIB_DIR_PLACEHOLDER|$lib|g; s|BIN_PATH_PLACEHOLDER|$bin|g" "$drv"
# Protect any real /etc/config/netshift the container may carry: the
# migration path writes a throwaway one under that exact name.
local netshift_cfg_backup=""
if [ -f /etc/config/netshift ]; then
netshift_cfg_backup="/tmp/netshift-cfg-backup-$$"
cp /etc/config/netshift "$netshift_cfg_backup"
fi
# Parse in the CURRENT shell (temp file + `while read < "$out"`, NO pipe) so
# pass/fail update the global counters and a suburlopt:*:FAIL actually gates
# the suite (a pipe would run the while-body in a subshell — non-gating).
local sub_out="/tmp/netshift-suburlopt-out-$$"
sh "$drv" > "$sub_out" 2>/dev/null
# FAIL/SKIP tokens carry a trailing " [diagnostic]" suffix, so match with a
# trailing glob (*:FAIL*) — a bare "*:FAIL)" would miss them and silently
# drop the failure, defeating S1's gating.
while IFS= read -r line; do
case "$line" in
*:FAIL*) fail "$line" ;;
*:SKIP*) skip "$line" ;;
*:OK) pass "$line" ;;
DONE) ;;
*) ;;
esac
done < "$sub_out"
rm -f "$drv" "$sub_out"
if [ -n "$netshift_cfg_backup" ]; then
mv "$netshift_cfg_backup" /etc/config/netshift
else
rm -f /etc/config/netshift
fi
}
# ─────────────────────────────────────────────────────────────────
# Test: global_proxy route rule semantics
# ─────────────────────────────────────────────────────────────────
@ -5348,6 +5977,9 @@ updates_log() { :; }
. "DRV_UPDATER"
NETSHIFT_RELEASE_API_URL="https://api.test/latest"
NETSHIFT_VERSION="$STUBLT_INSTALLED"
# Force the API-fallback path this test targets: the redirect resolver returns
# empty so updates_netshift_latest_tag falls back to the stubbed API body.
updates_github_resolve_redirect() { printf ''; }
updates_http_get_once() { printf '%s' "$STUBLT_BODY"; }
"$STUBLT_FN"
DRVEOF
@ -5422,6 +6054,138 @@ DRVEOF
rm -rf "$work"
}
# ─────────────────────────────────────────────────────────────────
# Test: GitHub redirect-based latest-tag + deterministic asset URLs (task-049)
# ─────────────────────────────────────────────────────────────────
# Sidestepping the api.github.com 60/hour/IP rate limit: the version-check + the
# self-update asset download now resolve github.com/<repo>/releases/latest via a
# redirect (curl -w '%{redirect_url}') → /releases/tag/<tag>, with the API + jq
# path kept only as a graceful fallback. The network boundary is STUBBED here
# (override updates_github_resolve_redirect / updates_http_get_once), so no curl
# shell-out and no real network in CI. Synthetic data only.
test_github_redirect_tag() {
header "GitHub redirect latest-tag + asset URLs (task-049)"
if ! command -v jq > /dev/null 2>&1; then
skip "jq not available"
return
fi
local updater="${NETSHIFT_LIB_DIR}/updater.sh"
if [ ! -r "$updater" ]; then
skip "updater.sh not found in ${NETSHIFT_LIB_DIR}"
return
fi
local work="/tmp/netshift-ghredirect-$$"
rm -rf "$work"
mkdir -p "$work"
# Driver: source helpers.sh + updater.sh, silence logging, pin the redirect
# + API constants, OVERRIDE the redirect resolver ($STUBGR_REDIRECT) and the
# API boundary ($STUBGR_BODY), then run the function/expr named in $STUBGR_FN.
local drv="$work/driver.sh"
cat > "$drv" << 'DRVEOF'
log() { :; }
echolog() { :; }
nolog() { :; }
updates_log() { :; }
. "DRV_HELPERS"
. "DRV_UPDATER"
NETSHIFT_REPO_RELEASES_LATEST_URL="https://github.com/yandexru45/netshift/releases/latest"
NETSHIFT_REPO_RELEASES_DOWNLOAD_BASE="https://github.com/yandexru45/netshift/releases/download"
NETSHIFT_RELEASE_API_URL="https://api.test/latest"
UPDATES_NETSHIFT_PKG_CORE="netshift"
UPDATES_NETSHIFT_PKG_LUCI="luci-app-netshift"
UPDATES_NETSHIFT_PKG_I18N_RU="luci-i18n-netshift-ru"
updates_github_resolve_redirect() { printf '%s' "$STUBGR_REDIRECT"; }
updates_http_get_once() { printf '%s' "$STUBGR_BODY"; }
eval "$STUBGR_FN"
DRVEOF
sed -i "s|DRV_UPDATER|$updater|g;s|DRV_HELPERS|${NETSHIFT_LIB_DIR}/helpers.sh|g" "$drv"
local out="$work/out.txt"
local rc_file="$work/rc.txt"
run_gr() {
ash "$drv" > "$out" 2>/dev/null && printf '0' > "$rc_file" || printf '%s' "$?" > "$rc_file"
}
export STUBGR_FN="updates_netshift_latest_tag"
export STUBGR_REDIRECT=""
export STUBGR_BODY=""
# ── Case 1: clean redirect → tag 0.8.9 (primary path, no API) ────────────
export STUBGR_REDIRECT="https://github.com/yandexru45/netshift/releases/tag/0.8.9"
export STUBGR_BODY=""
run_gr
if [ "$(cat "$out" 2>/dev/null)" = "0.8.9" ] && [ "$(cat "$rc_file" 2>/dev/null)" = "0" ]; then
pass "ghredirect:tag-from-redirect:OK"
else
fail "ghredirect:tag-from-redirect:FAIL" "got=[$(cat "$out" 2>/dev/null)] rc=$(cat "$rc_file" 2>/dev/null)"
fi
# ── Case 2: trailing-slash redirect → parse rejects (slash) → falls back ──
# A trailing slash makes the stripped tag contain "/", which the guard
# rejects; with NO API body it then yields empty + non-zero.
export STUBGR_REDIRECT="https://github.com/yandexru45/netshift/releases/tag/0.8.9/"
export STUBGR_BODY=""
run_gr
if [ -z "$(cat "$out" 2>/dev/null)" ] && [ "$(cat "$rc_file" 2>/dev/null)" != "0" ]; then
pass "ghredirect:tag-trailing-slash-rejected:OK"
else
fail "ghredirect:tag-trailing-slash-rejected:FAIL" "got=[$(cat "$out" 2>/dev/null)] rc=$(cat "$rc_file" 2>/dev/null)"
fi
# ── Case 3: non-matching redirect (login page) → primary empty → API
# FALLBACK returns the release object → still yields the tag. ─────────────
export STUBGR_REDIRECT="https://github.com/login?return_to=%2Fyandexru45%2Fnetshift"
export STUBGR_BODY='{"url":"https://api.github.com/repos/yandexru45/netshift/releases/1","tag_name":"0.8.9"}'
run_gr
if [ "$(cat "$out" 2>/dev/null)" = "0.8.9" ] && [ "$(cat "$rc_file" 2>/dev/null)" = "0" ]; then
pass "ghredirect:nonmatch-falls-back:OK"
else
fail "ghredirect:nonmatch-falls-back:FAIL" "got=[$(cat "$out" 2>/dev/null)] rc=$(cat "$rc_file" 2>/dev/null)"
fi
# ── Case 4: curl-absent (resolver empty) + API rate-limit object → empty +
# non-zero (honest failure, no false tag). ───────────────────────────────
export STUBGR_REDIRECT=""
export STUBGR_BODY='{"message":"API rate limit exceeded for 1.2.3.4"}'
run_gr
if [ -z "$(cat "$out" 2>/dev/null)" ] && [ "$(cat "$rc_file" 2>/dev/null)" != "0" ]; then
pass "ghredirect:ratelimit-empty:OK"
else
fail "ghredirect:ratelimit-empty:FAIL" "got=[$(cat "$out" 2>/dev/null)] rc=$(cat "$rc_file" 2>/dev/null)"
fi
# ── Case 5: asset-URL builder, ipk → deterministic names ─────────────────
export STUBGR_REDIRECT=""
export STUBGR_BODY=""
export STUBGR_FN='c="$(updates_netshift_asset_filename netshift 0.8.9 ipk)"; l="$(updates_netshift_asset_filename luci-app-netshift 0.8.9 ipk)"; i="$(updates_netshift_asset_filename luci-i18n-netshift-ru 0.8.9 ipk)"; printf "%s\n%s\n%s\n" "$c" "$l" "$i"'
run_gr
if [ "$(sed -n 1p "$out" 2>/dev/null)" = "netshift-0.8.9-r1-all.ipk" ] &&
[ "$(sed -n 2p "$out" 2>/dev/null)" = "luci-app-netshift-0.8.9-r1-all.ipk" ] &&
[ "$(sed -n 3p "$out" 2>/dev/null)" = "luci-i18n-netshift-ru-0.8.9.ipk" ]; then
pass "ghredirect:asset-ipk:OK"
else
fail "ghredirect:asset-ipk:FAIL" "got=[$(cat "$out" 2>/dev/null)]"
fi
# ── Case 6: asset-URL builder, apk → deterministic names ─────────────────
export STUBGR_FN='c="$(updates_netshift_asset_filename netshift 0.8.9 apk)"; l="$(updates_netshift_asset_filename luci-app-netshift 0.8.9 apk)"; i="$(updates_netshift_asset_filename luci-i18n-netshift-ru 0.8.9 apk)"; printf "%s\n%s\n%s\n" "$c" "$l" "$i"'
run_gr
if [ "$(sed -n 1p "$out" 2>/dev/null)" = "netshift-0.8.9-r1.apk" ] &&
[ "$(sed -n 2p "$out" 2>/dev/null)" = "luci-app-netshift-0.8.9-r1.apk" ] &&
[ "$(sed -n 3p "$out" 2>/dev/null)" = "luci-i18n-netshift-ru-0.8.9.apk" ]; then
pass "ghredirect:asset-apk:OK"
else
fail "ghredirect:asset-apk:FAIL" "got=[$(cat "$out" 2>/dev/null)]"
fi
unset STUBGR_FN STUBGR_REDIRECT STUBGR_BODY
rm -rf "$work"
}
# ─────────────────────────────────────────────────────────────────
# Test: NetShift self-update (task-017)
# ─────────────────────────────────────────────────────────────────
@ -5957,18 +6721,22 @@ main() {
test_section_isolation
test_monitor_fd_hygiene
test_unsupported_skip
test_text_list_outbound
test_diagnostics
test_subscription
test_fastest_group
test_insecure_fetch
test_rejected_hash
test_jobstate
test_selfheal
test_dns_via_outbound
test_sub_url_option
test_global_proxy
test_check_update_stable
test_check_update_extended
test_check_update_netshift
test_netshift_latest_tag
test_github_redirect_tag
test_self_update_netshift
test_backup_integrity
;;
@ -5982,18 +6750,22 @@ main() {
isolation) test_section_isolation ;;
monfd) test_monitor_fd_hygiene ;;
unsupported) test_unsupported_skip ;;
textlist) test_text_list_outbound ;;
diagnostics) test_diagnostics ;;
subscription) test_subscription ;;
fastest) test_fastest_group ;;
insecure) test_insecure_fetch ;;
rejected) test_rejected_hash ;;
jobstate) test_jobstate ;;
selfheal) test_selfheal ;;
dnsdetour) test_dns_via_outbound ;;
suburlopt) test_sub_url_option ;;
globalproxy) test_global_proxy ;;
stablecheck) test_check_update_stable ;;
extcheck) test_check_update_extended ;;
netshiftcheck) test_check_update_netshift ;;
latesttag) test_netshift_latest_tag ;;
ghredirect) test_github_redirect_tag ;;
selfupdate) test_self_update_netshift ;;
backupguard) test_backup_integrity ;;
jq) test_jq_helpers ;;
@ -6001,7 +6773,7 @@ main() {
sb) test_sing_box_config ;;
*)
echo "Unknown test: $target"
echo "Available: 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 latesttag selfupdate backupguard"
echo "Available: all deps syntax config helpers jq cm sb nft nftv6 selmark isolation monfd unsupported textlist diagnostics subscription fastest insecure rejected jobstate selfheal dnsdetour suburlopt globalproxy stablecheck extcheck netshiftcheck latesttag ghredirect selfupdate backupguard"
exit 1
;;
esac