Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3c917f8a | |||
| a70339bf32 | |||
| da72c64b50 | |||
| 883811bd55 | |||
| ba75930510 | |||
| f63e0b9fd9 | |||
| 0ac0a36598 | |||
| 996eb7ab29 | |||
| 8e40c49fa4 | |||
| a7a9f720e1 | |||
| a062f41a2e | |||
| 5b55b3e935 | |||
| 41a0bfa59d | |||
| 48fa5d6bed | |||
| c6fb96254a | |||
| aa377d56fd | |||
| 9fee5f283b | |||
| 053680a695 | |||
| 76ac754acd | |||
| 904fd64911 | |||
| 7ebdd96bcf |
@ -1,11 +1,11 @@
|
||||
FROM itdoginfo/openwrt-sdk-ipk:24.10.6
|
||||
|
||||
ARG NETSHIFT_VERSION
|
||||
ENV NETSHIFT_VERSION=${NETSHIFT_VERSION}
|
||||
|
||||
COPY ./netshift /builder/package/feeds/utilities/netshift
|
||||
COPY ./luci-app-netshift /builder/package/feeds/luci/luci-app-netshift
|
||||
|
||||
RUN export NETSHIFT_VERSION="v${NETSHIFT_VERSION}" && \
|
||||
make defconfig && \
|
||||
RUN make defconfig && \
|
||||
make package/netshift/compile V=s -j4 && \
|
||||
make package/luci-app-netshift/compile V=s -j4
|
||||
|
||||
145
README.md
145
README.md
@ -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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -64,3 +64,12 @@ append recurring findings; keep under ~200 lines.
|
||||
- Frontend barrel exposure: anything added to src/helpers/index.ts (or any export* barrel reaching main.ts) AND actually used appears in the generated main.js baseclass.extend block as a main.* symbol; unused re-exports get tree-shaken. So internal-only helper + added to barrel + used = it WILL leak to main.*. To keep a helper truly internal, place it in the consuming module, not the barrel.
|
||||
|
||||
- OpenWrt jq ascii_downcase only folds ASCII A-Z; case-insensitive matching on Cyrillic/Unicode names needs an inline codepoint fold (explode/map/implode: ASCII 65-90 +32, Cyrillic 1040-1071 +32, Yo 1025->1105). When reviewing such a fold: (a) already-lowercase ranges excluded (no double-fold), (b) def before first use when the program does NOT import helpers.jq, (c) a pure-emoji-keyword exact-match test proves non-folded codepoints pass through unchanged on both sides. (task-010)
|
||||
|
||||
- Package-manager rc is NOT a reliable success signal on opkg: rc=0 for "Not downgrading"/"already installed"/"up to date". A self-update/install that trusts only rc silently no-ops (the v→no-v rename trap: legacy `v0.8.6` sorts ABOVE `0.8.7` in opkg's compare, so `opkg install` refuses the "downgrade" and returns 0). When reviewing a package-install path, require: (a) `--force-downgrade --force-reinstall` on the opkg branch (apk overwrites by default); AND (b) verify-after-install — RE-READ the installed version (opkg `list-installed | grep "^pkg "`, apk `list --installed`; grep/awk only, NO Oniguruma jq) and compare v-stripped semver (`${x#v}`, `${x%%-*}`) with `==` OR `is_min_package_version installed target`; empty-installed must fail-safe to success:false. Keep install.sh `pkg_install` and updater.sh `updates_pkg_install_file` opkg branches ALIGNED. (task-041/042)
|
||||
- Async self-update worker landmine: the `_*_core` worker MUST `return 1` (NEVER `exit`) on failure so the public wrapper's always-run `updates_restore_after_swap` epilogue + finished-job-state write still execute. Verify the wrapper captures core rc/JSON to a temp file then unconditionally restores. Smoke assertions for these must be in the MAIN shell body (direct `if…pass/fail`), never inside `cmd | while read` (subshell swallows PASS/FAIL — harness-wide landmine). (task-041)
|
||||
|
||||
- UCI option→list rewrites: the `uci add_list "key=value"` CLI form splits on the FIRST `=` and SILENTLY LOSES query-string URLs (`?token=abc&x=1`) — reproduced on hardware (rc=1, list empty). Require the `uci_add_list <cfg> <sec> <opt> "<val>"` SHELL HELPER (separate-arg, preserves `=`/`&`). For delete-then-add rewrites, verify a failed add RESTORES the scalar AND that the change-flag gates the `uci commit` (an uncommitted in-memory delete must never persist). (task-048 [B1])
|
||||
- When RE-reviewing a fix round, also diff the developer's MEMORY note: it is frequently written against the PRE-fix code and re-seeds the very anti-pattern that was just fixed (task-048 [M2]: note still showed the `key=value` form + "non-gating piped-while" after both were fixed). Flag a stale memory note as a (minor) condition.
|
||||
- Test-gating landmine: a smoke test whose assertions run on the RHS of a pipe (`cmd | while read; pass/fail`) does NOT gate CI (subshell counter loss) — a FAIL token prints red but the suite exits 0. Require current-shell parsing (`while read < tmpfile`). The 178→190 count jump when task-048 fixed this is the tell. (task-048 [S1])
|
||||
|
||||
- Rate-limit avoidance via redirect path (task-049): version-check/self-update/install can read the latest tag from `github.com/<repo>/releases/latest` (302 -> /releases/tag/<tag>, served by the github.com FRONTEND, NOT the 60/hr-per-IP api.github.com) instead of the API. Tag extracted with `curl -sI -o /dev/null -w '%{redirect_url}'` then `case`/param-expansion `${r##*/releases/tag/}` — when reviewing such code REQUIRE: (a) the tag is rejected if empty OR `/`-containing (path-traversal/injection guard) via `case "$tag" in ''|*/*) tag="" ;;`; (b) the tag is only used quoted inside a URL string / passed quoted to helpers, never `eval`'d or used as a bare filesystem path; (c) curl-absent / non-match degrades to the API fallback (no hard-fail/exit); (d) the file-download helper uses `curl -fsSL`/`-L` so the CDN 302 on `releases/download/<tag>/<asset>` is followed. busybox wget on-device is STRIPPED (no -S/--max-redirect/header read) so redirect reading MUST use curl (hard +curl dep). Keep the sing-box-EXTENDED releases-LIST path on the API (a redirect can't give draft/prerelease/per-arch).
|
||||
|
||||
@ -422,3 +422,503 @@ append findings; keep under ~200 lines.
|
||||
build); verified yarn.lock unchanged + NO `.yarn`/`.yarnrc.yml`. The
|
||||
`netshift/files/**` + `tests/**` changes in git status are 021b (other agent),
|
||||
not mine.
|
||||
|
||||
## subscription_url → form.DynamicList (multi-URL) (task-023)
|
||||
|
||||
- Converted `subscription_url` from `form.Value` to `form.DynamicList` in
|
||||
section.js (~88-111), modelled EXACTLY on `remote_domain_lists` (:721-742):
|
||||
same per-row validate (`!value||value.length===0 → true`, else
|
||||
`main.validateUrl(value)`), `rmempty=true` (was `false`; the empty-row guard
|
||||
already short-circuited so emptiness was never enforced; backend keeps the
|
||||
"no URL" guard). Kept option name `subscription_url`, depends
|
||||
`{connection_type:'proxy',proxy_config_type:'subscription'}`, placeholder
|
||||
`https://example.com/api/sub`. Title → plural `_("Subscription URLs")`;
|
||||
description → single literal `_("Add one or more subscription URLs to fetch
|
||||
proxy configurations from. All feeds are downloaded and merged.")`.
|
||||
- types.ts:120 `subscription_url: string` → `string[]` (kept required, matches
|
||||
sibling list fields `selector_proxy_links`/`urltest_proxy_links`).
|
||||
- PURE TYPE-ONLY CHANGE: nothing in the FE reads `subscription_url` back (verified
|
||||
repo-wide) → `tsup` build produced ZERO main.js diff (confirmed via
|
||||
`git diff --exit-code main.js`). This is correct, NOT a missed rebuild. Still
|
||||
ran the build to confirm. (Same lesson as the type-only note in i18n section.)
|
||||
- locales: `node {extract-calls,generate-pot,generate-po ru,distribute-locales}.js`
|
||||
(NOT yarn → no corepack). msgid delta = clean SWAP: removed "Subscription URL"
|
||||
+ "Enter the subscription URL...provider"; added "Subscription URLs" + the new
|
||||
merged-feeds description. Filled 2 ru msgstr in SOURCE locales/netshift.ru.po
|
||||
("URL подписок" / "Добавьте один или несколько URL подписок...объединяются.")
|
||||
then distribute → po/ru + po/templates byte-identical to source (verified via
|
||||
diff). Only header msgstr empty (line 7). 5 catalog files touched: calls.json,
|
||||
locales/netshift.{pot,ru.po}, po/{templates/netshift.pot,ru/netshift.po}.
|
||||
- yarn classic 1.22.22 again but ran inner gate via node_modules/.bin
|
||||
(prettier/eslint/vitest/tsup) to be safe; yarn.lock unchanged, no .yarn/.yarnrc.
|
||||
|
||||
## UI design-system foundation: .card + tokens + toasts (task-024)
|
||||
|
||||
- DESIGN TOKENS (STABLE — task-025/026 reference these; do NOT rename) defined
|
||||
in `src/styles.ts` `GlobalStyles` on `:root, .cbi-map`:
|
||||
`--ns-card-border` (var(--background-color-low, lightgray)),
|
||||
`--ns-card-border-width` (2px), `--ns-card-radius` (4px), `--ns-gap` (10px),
|
||||
`--ns-card-padding` (var(--ns-gap)), `--ns-success`/`--ns-warning`/`--ns-error`/
|
||||
`--ns-info` (layered over success/warn/error-color-medium + primary-color-high
|
||||
with hex fallbacks #28a745/#f0ad4e/#dc3545/#2196f3).
|
||||
- `.card` primitive = `border: var(--ns-card-border-width) solid
|
||||
var(--ns-card-border); border-radius: var(--ns-card-radius); padding:
|
||||
var(--ns-card-padding); min-width:0`. Mirrors Manager's component card EXACTLY
|
||||
(2px/4px/10px/min-width:0) — that's the standardised look, NOT the 1px/8px from
|
||||
the spec's illustrative example.
|
||||
- CASCADE RULE: `.card` MUST be defined in GlobalStyles BEFORE the
|
||||
`${DashboardTab.styles}${DiagnosticTab.styles}${ManagerTab.styles}`
|
||||
interpolations. The per-tab colored-border MODIFIERS (`.pdk_diagnostic_alert
|
||||
--warning/--error/--loading/--success`, `__wiki--warning/--error`, outbound-grid
|
||||
`--active`/`--selectable:hover`) are same-specificity single-class rules that
|
||||
win ONLY via source order. Since injectGlobalStyles emits ONE `<style>` with
|
||||
GlobalStyles, and the template renders tokens+`.card` first THEN the interpolated
|
||||
tab CSS, the modifiers correctly override `.card`'s neutral border. (File
|
||||
byte-offset of `.card` in main.js is LATER than the modifiers because
|
||||
`DashboardTab.styles` is a separate `var stylesN` module — but runtime template
|
||||
concatenation order is what matters, and that's correct.)
|
||||
- REFACTOR PATTERN: removed the duplicated `border/border-radius/padding` (and
|
||||
manager's `min-width:0`) from the per-tab `styles.ts`, added `class:'card …'` in
|
||||
the RENDER `.ts`. Card boxes touched (more than the spec's "4" — there were
|
||||
these render sites): dashboard renderWidget (3 states), renderSections
|
||||
(failed/loading/default outbound-section + outbound-grid item), diagnostic
|
||||
renderWikiDisclaimer (className array — prepend 'card'), renderAvailableActions,
|
||||
renderSystemInfo, renderCheckSection (all 5 alert states incl. `--skipped` which
|
||||
has NO modifier so it relies on `.card`), manager initController component.
|
||||
`.card` adds `min-width:0` to dashboard/diagnostic boxes (was absent) — harmless
|
||||
overflow hardening, visually identical.
|
||||
- showToast union widened to `'success'|'error'|'warning'|'info'`
|
||||
(showToast.ts:3). Added `.toast-warning`(--ns-warning) + `.toast-info`(--ns-info)
|
||||
CSS; converted existing `.toast-success/.toast-error` to `var(--ns-success/error,
|
||||
#hex)` (themeable, same fallback hex → visually identical). The
|
||||
PREVIOUS memory note "showToast type is only success|error — use 'success' for
|
||||
in-progress" is now SUPERSEDED: use `'info'` for in-progress, `'warning'` for
|
||||
long/destructive-ish. Converted the 2 abuse sites in manager/initController.ts I
|
||||
was already in: "Switching sing-box core…"→'info', "Updating NetShift…page will
|
||||
reload"→'warning'. DEFERRED (not in touched files / debatable): manager line
|
||||
~155 "Latest version is unknown" still 'success' (check-result, not in-progress);
|
||||
diagnostic/initController.ts had only 'error' toasts (nothing to fix).
|
||||
- RAW BUTTON KILLED: dashboard renderSections.ts "Test latency" raw
|
||||
`<button class="btn">` → `renderButton({text:_('Test latency'), onClick:
|
||||
()=>testLatency(), classNames:['dashboard-sections-grid-item-test-latency']})`.
|
||||
renderButton already adds `btn`, so only the custom class goes in classNames.
|
||||
- IMPORT-ORDER MAIN.JS CHURN (IMPORTANT): adding `import {renderButton} from
|
||||
'../../../../partials'` into the DASHBOARD subtree (which previously never
|
||||
imported the global `src/partials` barrel) makes esbuild REORDER ~every bundled
|
||||
module block → a huge SYMMETRIC main.js diff (~1600/1600 lines) that is PURELY
|
||||
cosmetic module reordering. Verified safe: build is IDEMPOTENT (same md5 twice),
|
||||
banner intact, `return baseclass.extend({` intact, and the export-symbol SET is
|
||||
BYTE-IDENTICAL to HEAD (diff /tmp/exports_old vs new = empty) → no barrel leak,
|
||||
no new public API. Direct-path import (`…/partials/button/renderButton`) barely
|
||||
reduced churn — the reorder is inherent to introducing the cross-subtree dep, so
|
||||
I kept the barrel import for consistency with the 3 diagnostic callers. When a
|
||||
reviewer sees a giant main.js diff for a tiny TS change, CHECK export-set
|
||||
equality + idempotency before worrying.
|
||||
- TAB REORDER: netshift.js (hand-written, edit directly) — moved the Dashboard
|
||||
`form.TypedSection` block to FIRST. New order: Dashboard · Sections · Settings ·
|
||||
Component Manager · Diagnostics. ONLY block order changed; all 5 sections + their
|
||||
cfgsections/anonymous/addremove wiring identical. coreService/TabService track by
|
||||
`data-tab` (the active section name e.g. `current==='dashboard'`), NOT
|
||||
registration index (tab.service.ts getActiveTabId reads `.cbi-tab:not(
|
||||
.cbi-tab-disabled)` dataset.tab; dashboard initController keys on
|
||||
`tabService.current==='dashboard'`) → reorder is SAFE, tracking unaffected.
|
||||
- VISUAL VERIFY caveat: no chromium available in this env (playwright launch
|
||||
failed: chrome not found), so screenshots were NOT possible. Verified instead by
|
||||
CSS-cascade reasoning + programmatic checks: `.card` precedes modifiers in the
|
||||
runtime-concatenated GlobalStyles, no `background-color-low` base border remains
|
||||
in any per-tab styles.ts (all neutral borders now come from `.card`), colored
|
||||
modifiers keep their 2px width matching `.card`. FLAG: visual confirmation is
|
||||
reasoned, not screenshotted.
|
||||
- yarn classic 1.22.22; ran gate via node_modules/.bin (prettier --write src clean
|
||||
/ eslint --max-warnings=0 / vitest 471 pass / tsup build). yarn.lock unchanged,
|
||||
no `.yarn`/`.yarnrc.yml`. No locales change (no NEW user-facing literals — the
|
||||
switching/updating toast strings already existed).
|
||||
|
||||
## task-025 — section.js → 4 native CBI tabs (taboption)
|
||||
|
||||
- CBI native tabs: `section.tab('name', _('Title'), _('descr'))` defines a tab,
|
||||
then EVERY field MUST be `section.taboption('name', form.X, 'key', ...)`. HARD
|
||||
RULE confirmed: once a section has `.tab()`, any leftover `section.option(...)`
|
||||
silently renders nothing. Verified count: 36 taboption, 0 plain option (the
|
||||
only `section.option(` grep hit was my own comment line).
|
||||
- Conversion is mechanical & low-risk: only the constructor call line changes
|
||||
(`section.option(\n form.X,` → `section.taboption(\n "tab",\n form.X,`).
|
||||
All `.depends()` (33), `.validate` (17), `.value()`, defaults, placeholders,
|
||||
and the `community_lists.onchange` (REGIONAL_OPTIONS/ALLOWED_WITH_RUSSIA_INSIDE
|
||||
/DOMAIN_LIST_OPTIONS/getUIElement) stayed byte-identical. depends() works
|
||||
across tabs; an all-depends-hidden tab auto-hides from the strip (Subscription
|
||||
tab hides for proxy/url) — desired, no extra code.
|
||||
- `widgets.DeviceSelect` (`interface`) works fine inside a taboption — just pass
|
||||
the widget class as the 2nd arg after the tab name.
|
||||
- Tab map (4 tabs, 36 fields): connection=11, subscription=10, routing=12,
|
||||
advanced=3.
|
||||
- SMART-LIST UNIFICATION: did the LOW-RISK visual grouping (NOT a single-widget
|
||||
merge). Kept all 4 UCI keys + 2 *_list_type selectors. Achieved "one control"
|
||||
feel by renaming the two list-type selector TITLES to group headings
|
||||
("Custom domains"/"Custom subnets") with descriptions naming the modes;
|
||||
depends() already shows only the chosen input below. Deeper merge deferred
|
||||
(would risk UCI/validator changes). NOTE: renaming a selector title drops its
|
||||
old msgid from catalogs — fill the new ones.
|
||||
- RU-HARDCODE FIX: `subscription_group_by_countries` had `_("Группировать по
|
||||
странам")` as the SOURCE literal (msgid). Replaced with English `_("Group by
|
||||
countries")` + English descr; moved the Russian into the ru.po msgstr. After
|
||||
this the Cyrillic appears ONLY as msgstr, never as msgid.
|
||||
- section.js is NOT in the `yarn ci` prettier scope (CI formats only `src`).
|
||||
section.js uses DOUBLE QUOTES (LuCI convention) and does NOT pass the project
|
||||
`.prettierrc` (singleQuote) — confirmed the ORIGINAL also failed prettier.
|
||||
So: match the file's existing double-quote/2-space style; do NOT run prettier
|
||||
on section.js (it would fight the whole file).
|
||||
- i18n flow: `node extract-calls.js && node generate-pot.js && node
|
||||
generate-po.js ru && node distribute-locales.js`. generate-po keys by msgid &
|
||||
carries forward old msgstr; NEW/renamed msgids land empty → fill them in
|
||||
fe-app-netshift/locales/netshift.ru.po, then RE-RUN distribute-locales.js to
|
||||
copy into luci-app-netshift/po/{ru/netshift.po, templates/netshift.pot}.
|
||||
Verify byte-consistency with `diff -q` (both fe↔luci pairs). 13 new strings
|
||||
this task; all ru filled; 0 empty msgstr after.
|
||||
- main.js drift rule confirmed: section.js-only + catalog changes need NO main.js
|
||||
rebuild. I touched styles.ts so rebuilt — the ONLY main.js delta vs the
|
||||
task-024 baseline was my new CSS block (#cbi-netshift-section
|
||||
.cbi-section-node-tabbed card + ul.cbi-tabmenu margin). Build reproducible.
|
||||
- styles.ts: reused task-024 --ns-* tokens; added `#cbi-netshift-section
|
||||
.cbi-section-node-tabbed` (card border/radius/padding) + `ul.cbi-tabmenu`
|
||||
margin. Existing h3-hide (`> h3:nth-child(1)`) and remove-button hack
|
||||
(`> .cbi-section-remove { margin-bottom:-32px }`) left intact (new rules added
|
||||
after them; both still valid — remove button is a direct child, unaffected by
|
||||
the tabbed pane styling).
|
||||
- VISUAL VERIFY caveat persists: no browser in env. Confirmed structurally
|
||||
(taboption count/mapping, depends/validate counts == original, onchange grep
|
||||
intact, catalog diff). FLAG for human: actual tab-strip/card rendering +
|
||||
auto-hide behaviour of the Subscription/Advanced tabs not screenshot-verified.
|
||||
|
||||
## task-026 — settings.js → 5 native CBI tabs (taboption)
|
||||
|
||||
- Same mechanics as task-025. Converted ALL 27 settings options to
|
||||
`section.taboption('tab', form.X, 'key', …)`. Verified: 27 taboption, 0 plain
|
||||
`section.option(` (the 1 grep hit is my comment line). Tab map (5 tabs, 27):
|
||||
dns(6)=dns_type,dns_server,bootstrap_dns_server,dns_via_outbound,
|
||||
dns_outbound_section,dns_rewrite_ttl · network(6)=source_network_interfaces,
|
||||
enable_output_network_interface,output_network_interface,
|
||||
enable_badwan_interface_monitoring,badwan_monitored_interfaces,
|
||||
badwan_reload_delay · lists(4)=update_interval,download_lists_via_proxy,
|
||||
download_lists_via_proxy_section,routing_excluded_ips · yacd(3)=enable_yacd,
|
||||
enable_yacd_wan_access,yacd_secret_key · advanced(8)=disable_quic,
|
||||
dont_touch_dhcp,exclude_ntp,block_doh,enable_ipv6,config_path,cache_path,
|
||||
log_level.
|
||||
- YACD DECISION: kept as its OWN tab (3 fields), NOT folded into Advanced.
|
||||
Rationale: self-contained feature w/ clean depends() chain
|
||||
(enable_yacd→wan_access→secret_key); folding into an 11-field Advanced would
|
||||
recreate the wall. Tab title is `_("Dashboard")` (already-existing msgid),
|
||||
internal tab name "yacd". Documented.
|
||||
- All 7 depends() preserved verbatim (only line order changed — irrelevant):
|
||||
dns_outbound_section dep dns_via_outbound=1; output_network_interface dep
|
||||
enable_output_network_interface=1; badwan_monitored_interfaces +
|
||||
badwan_reload_delay dep enable_badwan_interface_monitoring=1;
|
||||
enable_yacd_wan_access dep enable_yacd=1; yacd_secret_key dep
|
||||
enable_yacd_wan_access=1; download_lists_via_proxy_section dep
|
||||
download_lists_via_proxy=1. 6 validators + 3 custom widgets (2 DeviceSelect,
|
||||
1 NetworkSelect) intact; cfgvalue/load section-picker closures unchanged.
|
||||
- HELP TRIM: block_doh was a 4-paragraph `_()+ " " +_()…` concat. Replaced with
|
||||
ONE single-literal description "Block direct connections to known public DoH
|
||||
servers (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex) so apps cannot
|
||||
bypass router DNS filtering." The caveat ("enable only after switching to
|
||||
UDP/DoT") moved into the ADVANCED tab description (section.tab 3rd arg).
|
||||
enable_ipv6's 2-sentence concat LEFT inline (short, the 2nd sentence is a
|
||||
genuine 1-line caveat; not bloating) — documented choice.
|
||||
- BACKTICK TRAP IN styles.ts: GlobalStyles is a template literal. Putting a
|
||||
backtick inside a CSS COMMENT (e.g. `#cbi-... > h3`) prematurely closes the
|
||||
template → ESLint "Parsing error: ',' expected". NEVER use backticks anywhere
|
||||
inside the styles.ts CSS string, even in comments. (Cost me one lint cycle.)
|
||||
- styles.ts: added `#cbi-netshift-settings .cbi-section-node-tabbed` (card
|
||||
border/radius/padding/min-width:0) + `#cbi-netshift-settings ul.cbi-tabmenu`
|
||||
(margin-bottom:var(--ns-gap)) — exact mirror of the task-025 section block,
|
||||
reusing task-024 --ns-* tokens (did NOT redefine tokens). The existing
|
||||
`#cbi-netshift-settings > h3 { display:none }` rule stays valid (added new
|
||||
rules after it). main.js delta = exactly these 2 CSS rules; build IDEMPOTENT
|
||||
(md5 a7300a2… across 3 builds), banner + `return baseclass.extend` intact,
|
||||
no new export symbol (only-loss vs HEAD is task-024's `styles` leak removal,
|
||||
not mine).
|
||||
- i18n: msgid delta = 9 added (tab titles "DNS"/"Network"/"Lists & Updates" —
|
||||
"Dashboard"+"Advanced" already existed; 4 tab descriptions; 1 reworded
|
||||
block_doh) / 4 removed (old block_doh fragments). Filled 9 ru msgstr in SOURCE
|
||||
locales/netshift.ru.po then `node distribute-locales.js`. fe↔luci ru.po AND
|
||||
pot byte-identical (diff -q); all LF; valid UTF-8 w/ Cyrillic; 0 empty
|
||||
non-header msgstr. Ran scripts via `node {extract-calls,generate-pot,
|
||||
generate-po ru,distribute-locales}.js` (generate-po reported 335/339 but 9
|
||||
were genuinely new — its count metric differs).
|
||||
- yarn classic 1.22.22; ran gate via node_modules/.bin (prettier/eslint/vitest/
|
||||
tsup). yarn.lock unchanged, no .yarn/.yarnrc.yml. NB: working tree already
|
||||
carried UNCOMMITTED task-024 + task-025 changes (showToast, dashboard/diag/
|
||||
manager styles+renders, section.js, netshift.js, #cbi-netshift-section CSS) —
|
||||
so `git diff -- src` is large but only my settings block + the 4 catalogs +
|
||||
main.js belong to task-026. format reported all-unchanged → no new churn.
|
||||
|
||||
## task-030 — NetShift update check ON-DEMAND (retires C1's systemInfo-refresh)
|
||||
|
||||
- REVERSES task-018's C1 decision. task-029 (backend, APPROVED) ADDED a real
|
||||
`component_action netshift check_update` action returning the STANDARD check
|
||||
JSON `{success,current_version,latest_version,status}` (v-normalized
|
||||
server-side, SAME shape as the sing-box cores) AND removed the latest-fetch
|
||||
from `get_system_info` (now returns `netshift_latest_version:"unknown"`). So
|
||||
the NetShift card is now a TRUE peer of the cores: on-demand check writes
|
||||
`managerChecks.netshift`, mount does NO network check.
|
||||
- SHELL METHOD: added `netshiftCheckUpdate()` to `methods/shell/index.ts` —
|
||||
copy of `singBoxCheckUpdate` but args `['component_action','netshift',
|
||||
'check_update']`, parsed by the EXISTING `parseComponentCheckUpdate`, returns
|
||||
`NetShift.ComponentCheckUpdateResult`. SYNC path (fast call), timeout 600000.
|
||||
It's a PROPERTY on `NetShiftShellMethods` (not a top-level export) → NO new
|
||||
symbol in the baseclass.extend export block (verified byte-identical to HEAD).
|
||||
- runNetshiftCheck (manager/initController.ts): now MIRRORS runSingBoxCheck
|
||||
exactly — call `netshiftCheckUpdate()`, `if(!parsed.success)` error toast +
|
||||
return, `status=parsed.status??null`, `setCheckResult('netshift',status,
|
||||
parsed.latest_version||'')`, `showToast(getCheckToastMessage(status),
|
||||
'success')`, catch→error toast, finally→reset loading. STOPPED calling
|
||||
`fetchSystemInfo()`+`resetCheckResult` as the "check".
|
||||
- cards.ts: `netshiftStatus(systemInfo, check)` now RETURNS `check.status`
|
||||
(the on-demand result; null until checked → neutral). KEPT the dev guard
|
||||
(`normalizeCompiledVersion(...)==='dev' → null`). REMOVED the
|
||||
`installed===latest` string compare AND the systemInfo.netshift_latest_version
|
||||
dependency. `netshiftCard` takes the check too; "Install %s" `latest` now
|
||||
comes from `check.latest_version`. `getComponentCards` passes `checks.netshift`
|
||||
to `netshiftCard`. The `check_netshift` kind NOW carries
|
||||
`backendAction:'check_update'` (still a DISTINCT kind so the dispatcher routes
|
||||
it to runNetshiftCheck, never to the sing-box check method).
|
||||
- DIAGNOSTIC: NO code change needed. `getNetshiftVersionRow.ts` already treats
|
||||
`netshift_latest_version === 'unknown'` (and `'loading'`) as
|
||||
`!hasActualVersion` → returns the plain neutral row (no Outdated/Latest tag).
|
||||
Since task-029 makes the backend return "unknown", the row auto-degrades to
|
||||
neutral. Mount's `fetchSystemInfo()` is now network-free (backend change), so
|
||||
diagnostic entry triggers no GitHub call. Existing
|
||||
getNetshiftVersionRow.test.ts (passes real versions) stays green unchanged.
|
||||
- TESTS: rewrote the 5 NetShift cases in manager/tests/cards.test.js to derive
|
||||
from `managerChecks.netshift` instead of systemInfo: null-status→neutral+
|
||||
check_update; check 'outdated'→self_update + Install <check.latest_version>;
|
||||
'latest'→Latest badge; dev-build stays neutral even with a check 'outdated';
|
||||
systemInfo latest mismatch is IGNORED. 472 tests pass (cards 19).
|
||||
- LOCALES: removing the `runNetshiftCheck` body ORPHANED `_('Latest version is
|
||||
unknown')` (no longer referenced anywhere). Ran `node {extract-calls,
|
||||
generate-pot,generate-po ru,distribute-locales}.js`. msgid delta = PURELY the
|
||||
1 removed msgid (calls.json/pot/ru.po) + `#:` line-ref reshuffle + POT header
|
||||
date. fe↔luci pairs byte-identical (diff -q). No new strings added (all toasts
|
||||
reused existing msgids). generate-po reported 340/338 (2 stale retained).
|
||||
- main.js: +36/-26 runtime diff = exactly (new method block + netshiftStatus/
|
||||
Card signature change + runNetshiftCheck rewrite). IDEMPOTENT (md5
|
||||
9ce13d2… across 2 builds), banner + `return baseclass.extend({` intact,
|
||||
export block byte-identical to HEAD. yarn classic 1.22.22; ran via
|
||||
node_modules/.bin; yarn.lock unchanged, no .yarn/.yarnrc.yml.
|
||||
- FLAG (no browser in env): the neutral→checked card transition + toast were
|
||||
verified by reasoning + the pure cards.test.js, NOT screenshotted.
|
||||
|
||||
## task-032 — subscription_format_preference dropdown (Subscription tab)
|
||||
|
||||
- Added a `form.ListValue` `subscription_format_preference` in section.js
|
||||
(HAND-WRITTEN, NOT bundled) right AFTER `subscription_url` (~144-157),
|
||||
modelled EXACTLY on `subscription_update_interval`: `.value('auto',_('Auto'))`
|
||||
/ `.value('xray',_('Xray JSON (Happ)'))` / `.value('singbox',_('Sing-box'))`,
|
||||
`o.default='auto'`, same `depends({connection_type:'proxy',proxy_config_type:
|
||||
'subscription'})`. NO explicit `rmempty` — like the interval field, LuCI
|
||||
ListValue defaults `rmempty=true`, so selecting the default ('auto') does NOT
|
||||
write a spurious UCI value (matches backend task-031 which treats empty/
|
||||
unknown as auto). Single-literal `_()` description (no concat).
|
||||
- types.ts: added optional union `subscription_format_preference?: 'auto' |
|
||||
'xray' | 'singbox';` to `ConfigProxySubscriptionSection` (after
|
||||
subscription_url). Pure type-only → erased at build.
|
||||
- BACKEND CONTRACT (task-031, in working tree): UCI option name EXACTLY
|
||||
`subscription_format_preference`, values auto/xray/singbox; netshift bin reads
|
||||
it (`uci -q get …subscription_format_preference`, empty→auto). Confirmed via
|
||||
grep before editing.
|
||||
- main.js: NO diff (section.js hand-written + type-only types.ts), like
|
||||
task-023. Build still run to confirm; `git diff --stat main.js` empty.
|
||||
- locales: `node {extract-calls,generate-pot,generate-po ru,distribute-
|
||||
locales}.js`. msgid delta PURELY ADDITIVE — 4 added (Auto / Subscription
|
||||
format / Xray JSON (Happ) / the description), 0 removed; "Sing-box" REUSED an
|
||||
existing msgid (so generate-po reported 339/342, only 3 truly-new beyond the
|
||||
reused one). Filled 4 ru msgstr in SOURCE locales/netshift.ru.po (Auto→Авто,
|
||||
Subscription format→Формат подписки, Xray JSON (Happ)→Xray JSON (Happ),
|
||||
description translated) then distribute → po/ru + po/templates byte-identical
|
||||
to source (diff -q). Only header msgstr empty. 5 catalog files touched.
|
||||
- yarn classic 1.22.22; ran gate via node_modules/.bin (prettier --write src /
|
||||
eslint src --ext .ts,.tsx --max-warnings=0 / vitest 472 pass / tsup). format
|
||||
diff on src = ONLY my 1 types.ts line (no churn). yarn.lock unchanged, no
|
||||
.yarn/.yarnrc.yml. FLAG (no browser): dropdown rendering/auto-hide not
|
||||
screenshotted — verified structurally.
|
||||
|
||||
## task-040 — "Clear subscription cache" button in Diagnostics (async)
|
||||
|
||||
- BACKEND CONTRACT (task-039, APPROVED): `component_action subscription
|
||||
clear_cache` deletes all subscription caches + redownloads (restarts service
|
||||
on change), driven via the EXISTING async job machinery
|
||||
`component_action_async subscription clear_cache` → `{success,job_id,message}`,
|
||||
poll `component_action_status <job>`. ACL already allows `/usr/bin/netshift`
|
||||
exec — NO ACL change. Action strings are EXACTLY component='subscription',
|
||||
action='clear_cache'.
|
||||
- SHELL METHOD: added `clearSubscriptionCache()` to `methods/shell/index.ts` as
|
||||
a COPY of `netshiftSelfUpdate`'s start-then-poll shape BUT with the STRICT
|
||||
(non-lenient) poll callback used by `singBoxComponentAction` install path
|
||||
(return `null` on empty stdout — no binary swap here, so a parse/exec failure
|
||||
IS terminal). args `['component_action_async','subscription','clear_cache']`,
|
||||
REUSES the component-agnostic `pollSingBoxComponentAction` (NO new poll loop).
|
||||
Returns `SingBoxComponentActionResult {success,version?,message?}`. It's a
|
||||
PROPERTY on `NetShiftShellMethods` → NO new top-level export symbol (the
|
||||
baseclass.extend export block is byte-identical to HEAD). NO new
|
||||
`AvailableMethods` enum entry needed — the existing async actions pass
|
||||
`'component_action_async'`/`'component_action_status'` + the component/action
|
||||
as RAW string-literal args (not enum members), so I mirrored that exactly.
|
||||
- HANDLER: `handleClearSubscriptionCache` in diagnostic/initController.ts mirrors
|
||||
`handleRestart`'s service-mutation idiom + globalCheck's toast idiom: set
|
||||
`clearSubscriptionCache.loading=true` → `showToast(_('Clearing subscription
|
||||
cache and re-downloading… this may take a minute'),'info')` → await the async
|
||||
method → success→`showToast(...,'success')` else logger.error+error toast →
|
||||
catch→logger.error+error toast → finally→`await fetchServicesInfo()` +
|
||||
loading=false + `store.reset(['diagnosticsChecks'])`. NB: did NOT use
|
||||
handleRestart's `setTimeout(...,5000)` — the async method ALREADY polls to
|
||||
completion (service restart finished by the time it resolves), so refresh
|
||||
immediately in finally. Wired into `renderDiagnosticAvailableActionsWidget`
|
||||
(visible:true, disabled:atLeastOneServiceCommandLoading).
|
||||
- BUTTON: added `clearSubscriptionCache: ActionProps` to renderAvailableActions.ts
|
||||
+ an `insertIf(visible,[renderButton(...)])` block using `renderRotateCcwIcon24`
|
||||
(already imported for Restart — rotate/refresh fits "clear+redownload"; the
|
||||
icon set has NO trash icon). Label `_('Clear subscription cache')`. No custom
|
||||
classNames (neutral btn, like globalCheck/viewLogs/showSingBoxConfig).
|
||||
- STORE: added `clearSubscriptionCache: { loading: boolean }` to
|
||||
`diagnosticsActions` in store.service.ts type AND
|
||||
`clearSubscriptionCache: { loading: false }` to initialDiagnosticStore in
|
||||
diagnostic.store.ts (after showSingBoxConfig in both).
|
||||
- i18n: 4 NEW msgids (PURELY additive): 'Clear subscription cache', 'Clearing
|
||||
subscription cache and re-downloading… this may take a minute', 'Failed to
|
||||
clear subscription cache' (used in BOTH the shell method fallback + handler →
|
||||
same msgid), 'Subscription cache cleared and re-downloaded'. NB the ellipsis is
|
||||
a real `…` char (U+2026), not three dots — kept literal-consistent fe↔ru. Ran
|
||||
`node {extract-calls,generate-pot,generate-po ru,distribute-locales}.js` (NOT
|
||||
yarn). generate-po reported 343/346 (its count metric undercounts; there were
|
||||
4 truly-new empty msgstr + the header). Filled ru in SOURCE
|
||||
locales/netshift.ru.po (Очистить кеш подписок / Очистка кеша подписок и
|
||||
повторная загрузка… это может занять минуту / Не удалось очистить кеш подписок
|
||||
/ Кеш подписок очищен и загружен заново), re-ran distribute → po/ru +
|
||||
po/templates byte-identical to source (diff -q). Only header msgstr empty.
|
||||
- main.js: REAL +98/-1 runtime diff (new method block + handler + button +
|
||||
widget wiring). IDEMPOTENT (md5 aa89dfc… across 2 builds), banner +
|
||||
`return baseclass.extend({` intact, top-level export block byte-identical to
|
||||
HEAD (no barrel leak — clearSubscriptionCache is a NetShiftShellMethods
|
||||
property). Confirmed action args in main.js are exactly
|
||||
`["component_action_async","subscription","clear_cache"]` + poll via
|
||||
`component_action_status`.
|
||||
- NO new test: reused existing `pollSingBoxComponentAction` (already
|
||||
table-tested); the method+handler is wiring (DOM/store untestable in node
|
||||
env). vitest 472 pass unchanged.
|
||||
- yarn classic 1.22.22; ran gate via node_modules/.bin (prettier --check src
|
||||
clean / eslint src --ext .ts,.tsx --max-warnings=0 / vitest 472 / tsup).
|
||||
yarn.lock unchanged, no .yarn/.yarnrc.yml. Working tree also carried UNRELATED
|
||||
task-039 backend changes (netshift bin, updater.sh, tests/entrypoint.sh) +
|
||||
.opencode/agent edits — NOT mine. FLAG (no browser): button render + toast
|
||||
sequence verified by reasoning + the gate, NOT screenshotted.
|
||||
|
||||
## task-045 — universal subscription grouper (mode dropdown + prefix length)
|
||||
|
||||
- REPLACED the single `subscription_group_by_countries` form.Flag (section.js
|
||||
~190-201) with TWO taboptions in the SAME `subscription` tab (mandatory —
|
||||
tabbed section, a plain option() renders nothing):
|
||||
(1) `form.ListValue subscription_group_mode` — values off/country/prefix
|
||||
(`_("Off")`/`_("By country flag")`/`_("By name prefix")`), `o.default="off"`,
|
||||
`o.rmempty=false`, depends `{connection_type:"proxy",proxy_config_type:
|
||||
"subscription"}`; title `_("Subscription grouping")` + single-literal help.
|
||||
(2) `form.Value subscription_group_prefix_len` — title `_("Prefix length")`,
|
||||
`o.default="2"`, `o.datatype="and(uinteger,min(1))"`, `o.rmempty=false`,
|
||||
depends ADDS `subscription_group_mode:"prefix"` (3-key object) so it shows
|
||||
ONLY when mode=prefix. CBI cross-field depends within the same section/tab
|
||||
works fine; a fully-hidden field is OK.
|
||||
- CROSS-LAYER CONTRACT (task-044 backend, DONE): UCI options EXACTLY
|
||||
`subscription_group_mode` ∈ {off,country,prefix} default off, and
|
||||
`subscription_group_prefix_len` positive-int string default 2 (meaningful
|
||||
only when mode=prefix). Backend falls back to the LEGACY
|
||||
`subscription_group_by_countries` boolean ONLY when the new option is ABSENT
|
||||
→ the UI writes only the NEW options; NO JS migration written.
|
||||
- types.ts: swapped `subscription_group_by_countries?: '0'|'1'` →
|
||||
`subscription_group_mode?: 'off'|'country'|'prefix'` +
|
||||
`subscription_group_prefix_len?: string`. Grepped src first — NOTHING in TS
|
||||
reads the old key (only the type decl), so removing it is safe (backend reads
|
||||
the legacy UCI key directly, not via UI). Pure type-only → erased at build.
|
||||
- main.js: ZERO diff (section.js hand-written + not bundled; types.ts type-only).
|
||||
md5 unchanged across the build (aa89dfc5…). Confirmed via
|
||||
`git diff --exit-code main.js`. This is the EXPECTED/correct outcome — a diff
|
||||
there would mean an unexpected src change.
|
||||
- i18n: ran `node {extract-calls,generate-pot,generate-po ru,distribute-
|
||||
locales}.js` (yarn classic 1.22.22, but used node to avoid corepack). msgid
|
||||
delta = clean SWAP: removed 2 (`Group by countries` + its long description),
|
||||
added 7 (Off / By country flag / By name prefix / Subscription grouping /
|
||||
Prefix length / the grouping description / the prefix-length description).
|
||||
Filled 7 RU msgstr in SOURCE locales/netshift.ru.po then re-ran distribute →
|
||||
po/ru + po/templates byte-identical to source (diff -q both pairs). 0 empty
|
||||
non-header msgstr after. RU: Off→Выключено, By country flag→По флагу страны,
|
||||
By name prefix→По префиксу имени, Subscription grouping→Группировка подписки,
|
||||
Prefix length→Длина префикса.
|
||||
- yarn ci GREEN: format no-diff, eslint --max-warnings=0, vitest 472 pass, tsup
|
||||
build. yarn.lock unchanged, no .yarn/.yarnrc.yml. No new vitest (no new pure
|
||||
TS logic — datatype validation is LuCI client-side).
|
||||
- PRIVACY: no subscription-identifying data (hosts/IPs/URLs/keys/node names) in
|
||||
any code/comment/i18n/test/memory — generic "proxy name"/"country flag"
|
||||
wording only.
|
||||
- FLAG (no browser in env): the rendered Subscription tab (dropdown +
|
||||
conditional prefix-length field appearing only on mode=prefix, taboption
|
||||
auto-hide) needs a HUMAN VISUAL CHECK before merge — verified structurally
|
||||
only (taboption completeness, depends preserved).
|
||||
|
||||
## task-051 — text-list Selector/URLTest (paste links, one per line)
|
||||
|
||||
- CROSS-LAYER CONTRACT (backend done first): proxy_config_type values
|
||||
`selector_text` / `urltest_text`; scalar UCI options (textarea, one link per
|
||||
line) `selector_proxy_links_text` / `urltest_proxy_links_text`. Matched VERBATIM.
|
||||
- section.js (HAND-WRITTEN, NOT bundled → 0 main.js diff): (a) 2 new
|
||||
`o.value("selector_text",_("Selector (text list)"))` /
|
||||
`o.value("urltest_text",_("URLTest (text list)"))` after the `urltest` value.
|
||||
(b) 2 `form.TextValue` textareas modelled on the `url`-type `proxy_string`
|
||||
one (`o.textarea=true; o.rows=5; o.wrap="soft"; o.rmempty=false`): placed
|
||||
`selector_proxy_links_text` in the **connection** tab next to the existing
|
||||
`selector_proxy_links` DynamicList, and `urltest_proxy_links_text` in the
|
||||
**subscription** tab next to `urltest_proxy_links` (mirror the tab each
|
||||
list-typed sibling already lives in — they differ!). Each `o.validate` calls
|
||||
`main.validateProxyUrlList`.
|
||||
- URLTEST-TWIN GATING: the 3 urltest tuning fields (urltest_check_interval,
|
||||
urltest_tolerance, urltest_testing_url) each had `depends urltest` + `depends
|
||||
subscription`; added a 3rd `o.depends({connection_type:"proxy",
|
||||
proxy_config_type:"urltest_text"})` to each (CBI ORs depends). DID NOT touch
|
||||
`enable_udp_over_tcp` (gated on `connection_type:"proxy"` only → already shows
|
||||
for urltest_text) nor the subscription-only grouping/filter fields. The
|
||||
`urltest_proxy_links` DynamicList itself stays urltest-only (its text variant
|
||||
is the NEW separate field) — grep `proxy_config_type:"urltest"` leaves exactly
|
||||
4 hits: the DynamicList + 3 tuning fields.
|
||||
- NEW VALIDATOR `validateProxyUrlList(value:string):ValidationResult` — splits on
|
||||
`\n`, `.trim()` each line (so CRLF `\r` is stripped), skips blank lines, runs
|
||||
the EXISTING `validateProxyUrl` per line, returns first failure as
|
||||
`{valid:false, message:`${_('Line')} ${i+1}: ${msg}`}` (1-based incl. blank
|
||||
lines in the count) or `{valid:true,message:''}`. Empty/blank-only →
|
||||
`_('At least one proxy link must be specified.')`. ValidationResult REQUIRES
|
||||
`message:string` so valid branch sets `message:''`. BARREL-EXPORTED via
|
||||
`validators/index.ts` (`export * from './validateProxyUrlList'`) → reaches
|
||||
`main.validateProxyUrlList`. This is an EXPORTED leaf (NOT dispatcher-only like
|
||||
validateHysteria2Url/validateVmessUrl) because section.js calls it directly.
|
||||
- main.js: EXPECTED +28-line diff (the bundled validator fn + 1 export-block
|
||||
entry). Export-symbol set delta vs HEAD = EXACTLY `+ validateProxyUrlList`
|
||||
(no leak). Build IDEMPOTENT (md5 e5273ea1… across 2 builds), banner +
|
||||
`return baseclass.extend({` intact. The regenerated main.js IS the deliverable.
|
||||
- TEST `validators/tests/validateProxyUrlList.test.js`: table-driven describe.each
|
||||
(valid blobs incl. CRLF/blank-line/whitespace; invalid incl. empty/unsupported/
|
||||
garbage) + line-number-context assertions. SS fixture is the KNOWN-VALID
|
||||
`ss://2022-blake3-aes-256-gcm:dmCly/…=@127.0.0.1:27214?type=tcp` form copied
|
||||
from validateShadowsocksUrl.test.js (do NOT invent base64 that may fail). VLESS
|
||||
fixture copied from validateVlessUrl.test.js. 13 tests; total 485 pass.
|
||||
- i18n: 7 NEW msgids (Selector (text list); URLTest (text list); Selector Proxy
|
||||
Links (one per line); URLTest Proxy Links (one per line); the shared scheme-doc
|
||||
desc "…links — one per line"; "Line"; "At least one proxy link must be
|
||||
specified."). RU filled in SOURCE locales/netshift.ru.po then distribute →
|
||||
po/ru + po/templates byte-identical (diff -q). msgid count 352→359 purely
|
||||
additive. Ran `node {extract-calls,generate-pot,generate-po ru,distribute}.js`
|
||||
(generate-pot needs git user.name set). 1 empty msgstr remains = header only.
|
||||
- PRIVACY: all link strings synthetic (`127.0.0.1` hosts + scheme-doc literals);
|
||||
no real proxy/subscription data anywhere.
|
||||
- GATES GREEN: prettier --write src (all unchanged → no format churn beyond my
|
||||
files), eslint --max-warnings=0, vitest 485 pass, tsup build. yarn classic
|
||||
1.22.22 → ran via node_modules/.bin; yarn.lock unchanged, no .yarn/.yarnrc.yml.
|
||||
- FLAG (no browser in env): the rendered Connection/Subscription tabs (2 new
|
||||
dropdown choices, the 2 textareas appearing only for their type, the urltest
|
||||
tuning fields now appearing for urltest_text) need a HUMAN VISUAL CHECK —
|
||||
verified structurally only (taboption completeness, depends grep).
|
||||
|
||||
@ -105,3 +105,12 @@ artifacts out of the container -> **ipk underscore->dash rename**
|
||||
unsupported; needs >=15 MB on `/overlay`; NO uninstall path (removal lives in
|
||||
package `prerm`). GitHub API rate-limit is a known fragility (wget path has no
|
||||
guard).
|
||||
- `pkg_install` opkg branch uses `opkg install --force-downgrade
|
||||
--force-reinstall "$pkg_file"` (task-042). Plain `opkg install` silently
|
||||
no-op'd (rc=0) when re-run on a router with an older build: the legacy
|
||||
v-prefixed version (`v0.8.6-r1`) sorts ABOVE the no-v release (`0.8.7-r1`) so
|
||||
opkg "won't downgrade", and equal versions report "up to date". Both force
|
||||
flags make opkg remove+reinstall (proven on OWRT 24.10.5 aarch64). apk branch
|
||||
unchanged — `apk add --allow-untrusted` overwrites by default. This is the
|
||||
install.sh twin of the task-041 `updates_pkg_install_file` updater.sh fix;
|
||||
keep both upgrade paths (README script + in-app self-update) aligned.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -68,18 +68,34 @@ best-effort). It uses the same `PKG_VERSION` expression and
|
||||
updated, `luci-base` installed, feed dirs created; the apk one also runs
|
||||
`./setup.sh`).
|
||||
|
||||
### KNOWN INCONSISTENCY — respect it, do not "fix" blindly
|
||||
### Version passing — symmetric, both RAW (no `v` prefix)
|
||||
|
||||
The two release Dockerfiles pass the version differently:
|
||||
Both release Dockerfiles now pass the version **raw**, with no `v` prefix:
|
||||
|
||||
- `Dockerfile-ipk`: `RUN export NETSHIFT_VERSION="v${NETSHIFT_VERSION}" && ...`
|
||||
— it **prepends `v`**.
|
||||
- `Dockerfile-ipk`: `ENV NETSHIFT_VERSION=${NETSHIFT_VERSION}` — **raw, no
|
||||
`v`**.
|
||||
- `Dockerfile-apk`: `ENV NETSHIFT_VERSION=${NETSHIFT_VERSION}` — **raw, no
|
||||
`v`**.
|
||||
|
||||
This asymmetry is intentional/load-bearing for the current artifact names. Do
|
||||
not normalize one to match the other without verifying the whole release flow
|
||||
(§4) and `install.sh` matching (§6).
|
||||
> Historical note (task-028): `Dockerfile-ipk` used to **prepend `v`**
|
||||
> (`RUN export NETSHIFT_VERSION="v${NETSHIFT_VERSION}" && ...`) while the apk
|
||||
> file passed it raw. That asymmetry stamped a leading `v` into the ipk
|
||||
> package/control version and the runtime `constants.sh` `NETSHIFT_VERSION`,
|
||||
> so on OWRT24/ipk the installed version (`v0.8.6`) never matched the no-`v`
|
||||
> GitHub tag (`0.8.6`) and the LuCI UI falsely reported "outdated". The `v`
|
||||
> prepend was removed (ipk normalized to the apk shape) after verifying the
|
||||
> whole release flow (§4) and `install.sh` matching (§6): the `_`→`-` rename,
|
||||
> the 3-package filter, the i18n `-${VERSION}` naming, and the release tag all
|
||||
> derive from the git tag, and `install.sh` matches assets by **name prefix**
|
||||
> (the `v` lived in the version segment, not the name prefix) — so dropping it
|
||||
> does not affect either. Keep both Dockerfiles passing the version raw.
|
||||
>
|
||||
> Residual fragility (out of scope, on record): the UI version-equality check
|
||||
> in `fe-app-netshift` does **not** normalize a leading `v`. If a future
|
||||
> release is tagged **with** a `v` (e.g. `v0.8.7`), `netshift_latest_version`
|
||||
> would carry `v` while the installed (no-`v`) version would not, re-triggering
|
||||
> the false-"outdated" mismatch. **Tag releases WITHOUT a `v`** to keep
|
||||
> ipk / apk / tag all consistent.
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
docs/screenshot.png
Normal file
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: NETSHIFT\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-06-06 00:53+0300\n"
|
||||
"PO-Revision-Date: 2026-06-06 00:53+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"
|
||||
@ -32,11 +32,20 @@ msgstr "✘ Остановлен"
|
||||
msgid "Active Connections"
|
||||
msgstr "Активные соединения"
|
||||
|
||||
msgid "Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged."
|
||||
msgstr "Добавьте один или несколько URL подписок для получения конфигураций прокси. Все источники загружаются и объединяются."
|
||||
|
||||
msgid "Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
|
||||
msgstr "Добавьте свои домены: выберите Динамический список (по одному в строке) или Текстовый список (свободный ввод), либо Отключено, чтобы пропустить"
|
||||
|
||||
msgid "Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
|
||||
msgstr "Добавьте свои подсети или IP: выберите Динамический список (по одному в строке) или Текстовый список (свободный ввод), либо Отключено, чтобы пропустить"
|
||||
|
||||
msgid "Additional marking rules found"
|
||||
msgstr "Найдены дополнительные правила маркировки"
|
||||
|
||||
msgid "Affects Cloudflare, Google, Quad9, OpenDNS, AdGuard, and Yandex public DoH servers."
|
||||
msgstr "Затрагивает публичные DoH-серверы Cloudflare, Google, Quad9, OpenDNS, AdGuard и Yandex."
|
||||
msgid "Advanced"
|
||||
msgstr "Дополнительно"
|
||||
|
||||
msgid "Allow insecure TLS for subscription fetch"
|
||||
msgstr "Разрешить небезопасный TLS при загрузке подписки"
|
||||
@ -47,17 +56,23 @@ 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 "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
|
||||
|
||||
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
|
||||
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Available actions"
|
||||
msgstr "Доступные действия"
|
||||
|
||||
msgid "Block direct connections to known public DNS-over-HTTPS (DoH) servers."
|
||||
msgstr "Блокирует прямые подключения к известным публичным серверам DNS-over-HTTPS (DoH)."
|
||||
msgid "Block direct connections to known public DoH servers (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex) so apps cannot bypass router DNS filtering."
|
||||
msgstr "Блокировать прямые подключения к известным публичным DoH-серверам (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex), чтобы приложения не могли обойти DNS-фильтрацию роутера."
|
||||
|
||||
msgid "Block DoH Servers"
|
||||
msgstr "Блокировать DoH-серверы"
|
||||
@ -74,6 +89,12 @@ msgstr "Браузер не использует FakeIP"
|
||||
msgid "Browser is using FakeIP correctly"
|
||||
msgstr "Браузер использует FakeIP"
|
||||
|
||||
msgid "By country flag"
|
||||
msgstr "По флагу страны"
|
||||
|
||||
msgid "By name prefix"
|
||||
msgstr "По префиксу имени"
|
||||
|
||||
msgid "Cache File Path"
|
||||
msgstr "Путь к файлу кэша"
|
||||
|
||||
@ -101,6 +122,12 @@ msgstr "Проверки пройдены"
|
||||
msgid "CIDR must be between 0 and 32"
|
||||
msgstr "CIDR должен быть между 0 и 32"
|
||||
|
||||
msgid "Clear subscription cache"
|
||||
msgstr "Очистить кеш подписок"
|
||||
|
||||
msgid "Clearing subscription cache and re-downloading… this may take a minute"
|
||||
msgstr "Очистка кеша подписок и повторная загрузка… это может занять минуту"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
@ -119,9 +146,15 @@ msgstr "Конфигурация службы NetShift"
|
||||
msgid "Configuration Type"
|
||||
msgstr "Тип конфигурации"
|
||||
|
||||
msgid "Connection"
|
||||
msgstr "Подключение"
|
||||
|
||||
msgid "Connection Type"
|
||||
msgstr "Тип подключения"
|
||||
|
||||
msgid "Connection type, transport and DNS resolver for this section"
|
||||
msgstr "Тип подключения, транспорт и DNS-резолвер для этой секции"
|
||||
|
||||
msgid "Connection URL"
|
||||
msgstr "URL подключения"
|
||||
|
||||
@ -137,6 +170,12 @@ msgstr "Истекло время ожидания переключения яд
|
||||
msgid "Currently unavailable"
|
||||
msgstr "Временно недоступно"
|
||||
|
||||
msgid "Custom domains"
|
||||
msgstr "Свои домены"
|
||||
|
||||
msgid "Custom subnets"
|
||||
msgstr "Свои подсети"
|
||||
|
||||
msgid "Dashboard"
|
||||
msgstr "Дашборд"
|
||||
|
||||
@ -173,6 +212,9 @@ msgstr "Отключено"
|
||||
msgid "Disables TLS certificate verification when downloading the subscription."
|
||||
msgstr "Отключает проверку TLS-сертификата при загрузке подписки."
|
||||
|
||||
msgid "DNS"
|
||||
msgstr "DNS"
|
||||
|
||||
msgid "DNS on router"
|
||||
msgstr "DNS на роутере"
|
||||
|
||||
@ -200,6 +242,9 @@ msgstr "Адрес DNS-сервера не может быть пустым"
|
||||
msgid "Do not panic, everything can be fixed, just..."
|
||||
msgstr "Не паникуйте, всё можно исправить, просто..."
|
||||
|
||||
msgid "Domain and subnet lists that decide which traffic uses this section"
|
||||
msgstr "Списки доменов и подсетей, определяющие, какой трафик идёт через эту секцию"
|
||||
|
||||
msgid "Domain Resolver"
|
||||
msgstr "Резолвер доменов"
|
||||
|
||||
@ -269,9 +314,6 @@ msgstr "Введите доменные имена без протоколов,
|
||||
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
|
||||
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
|
||||
|
||||
msgid "Enter the subscription URL to fetch proxy configurations from your provider"
|
||||
msgstr "Введите URL подписки для получения конфигураций прокси от вашего провайдера"
|
||||
|
||||
msgid "Every 1 minute"
|
||||
msgstr "Каждую минуту"
|
||||
|
||||
@ -311,6 +353,9 @@ msgstr "Исключите трафик протокола NTP из туннел
|
||||
msgid "Exclude servers by keyword"
|
||||
msgstr "Исключать серверы по ключевому слову"
|
||||
|
||||
msgid "Failed to clear subscription cache"
|
||||
msgstr "Не удалось очистить кеш подписок"
|
||||
|
||||
msgid "Failed to copy!"
|
||||
msgstr "Не удалось скопировать!"
|
||||
|
||||
@ -332,6 +377,9 @@ msgstr "Глобальная проверка"
|
||||
msgid "Global Proxy"
|
||||
msgstr "Глобальный прокси"
|
||||
|
||||
msgid "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters."
|
||||
msgstr "Группировать прокси из подписки в группы URLTest. «По флагу страны» использует эмодзи флага в начале каждого имени; «По префиксу имени» группирует по первым N символам."
|
||||
|
||||
msgid "How often to automatically update the subscription"
|
||||
msgstr "Как часто автоматически обновлять подписку"
|
||||
|
||||
@ -527,12 +575,18 @@ msgstr "Последняя"
|
||||
msgid "Latest version is installed"
|
||||
msgstr "Установлена последняя версия"
|
||||
|
||||
msgid "Latest version is unknown"
|
||||
msgstr "Последняя версия неизвестна"
|
||||
msgid "Line"
|
||||
msgstr "Строка"
|
||||
|
||||
msgid "List Update Frequency"
|
||||
msgstr "Частота обновления списков"
|
||||
|
||||
msgid "List update schedule, download routing, and routing exclusions"
|
||||
msgstr "Расписание обновления списков, маршрутизация загрузок и исключения из маршрутизации"
|
||||
|
||||
msgid "Lists & Updates"
|
||||
msgstr "Списки и обновления"
|
||||
|
||||
msgid "Local Domain Lists"
|
||||
msgstr "Локальные списки доменов"
|
||||
|
||||
@ -551,6 +605,9 @@ msgstr "Основной DNS через outbound"
|
||||
msgid "Memory Usage"
|
||||
msgstr "Использование памяти"
|
||||
|
||||
msgid "Mixed proxy and DNS resolution tuning"
|
||||
msgstr "Настройка смешанного прокси и разрешения DNS"
|
||||
|
||||
msgid "Mixed Proxy Port"
|
||||
msgstr "Порт смешанного прокси"
|
||||
|
||||
@ -572,6 +629,9 @@ msgstr "NetShift обновлён, версия:"
|
||||
msgid "NetShift will not modify your DHCP configuration"
|
||||
msgstr "NetShift не будет изменять вашу конфигурацию DHCP"
|
||||
|
||||
msgid "Network"
|
||||
msgstr "Сеть"
|
||||
|
||||
msgid "Network Interface"
|
||||
msgstr "Сетевой интерфейс"
|
||||
|
||||
@ -590,8 +650,11 @@ msgstr "Не отвечает"
|
||||
msgid "Not running"
|
||||
msgstr "Не запущено"
|
||||
|
||||
msgid "Note: if your upstream DNS type is set to 'DoH', enable this only after switching to UDP or DoT."
|
||||
msgstr "Примечание: если тип вышестоящего DNS установлен в «DoH», включайте это только после переключения на UDP или DoT."
|
||||
msgid "Number of leading characters of each proxy name to group by."
|
||||
msgstr "Количество начальных символов имени каждого прокси для группировки."
|
||||
|
||||
msgid "Off"
|
||||
msgstr "Выключено"
|
||||
|
||||
msgid "Only one section can be global at a time."
|
||||
msgstr "Только одна секция может быть глобальной одновременно."
|
||||
@ -626,6 +689,12 @@ msgstr "Путь должен заканчиваться на cache.db"
|
||||
msgid "Pending"
|
||||
msgstr "Ожидает запуска"
|
||||
|
||||
msgid "Prefix length"
|
||||
msgstr "Длина префикса"
|
||||
|
||||
msgid "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."
|
||||
msgstr "Переключатели протоколов, пути к файлам и журналирование. Включайте блокировку DoH только после переключения вышестоящего DNS на UDP или DoT."
|
||||
|
||||
msgid "Proxy Configuration URL"
|
||||
msgstr "URL конфигурации прокси"
|
||||
|
||||
@ -662,6 +731,9 @@ msgstr "DNS роутера не проходит через sing-box"
|
||||
msgid "Router DNS is routed through sing-box"
|
||||
msgstr "DNS роутера проходит через sing-box"
|
||||
|
||||
msgid "Routing"
|
||||
msgstr "Маршрутизация"
|
||||
|
||||
msgid "Routing Excluded IPs"
|
||||
msgstr "Исключённые из маршрутизации IP-адреса"
|
||||
|
||||
@ -722,12 +794,6 @@ msgstr "Выберите путь к файлу конфигурации sing-bo
|
||||
msgid "Select the DNS protocol type for the domain resolver"
|
||||
msgstr "Выберите тип протокола DNS для резолвера доменов"
|
||||
|
||||
msgid "Select the list type for adding custom domains"
|
||||
msgstr "Выберите тип списка для добавления пользовательских доменов"
|
||||
|
||||
msgid "Select the list type for adding custom subnets"
|
||||
msgstr "Выберите тип списка для добавления пользовательских подсетей"
|
||||
|
||||
msgid "Select the log level for sing-box"
|
||||
msgstr "Выберите уровень логов для sing-box"
|
||||
|
||||
@ -743,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 "Не удалось обновить"
|
||||
|
||||
@ -785,6 +857,9 @@ msgstr "Сервис sing-box существует"
|
||||
msgid "Sing-box version is compatible (newer than 1.12.4)"
|
||||
msgstr "Версия Sing-box совместима (новее 1.12.4)"
|
||||
|
||||
msgid "Source and output interfaces, and Bad WAN interface monitoring"
|
||||
msgstr "Входящий и исходящий интерфейсы, а также мониторинг интерфейсов Bad WAN"
|
||||
|
||||
msgid "Source Network Interface"
|
||||
msgstr "Сетевой интерфейс источника"
|
||||
|
||||
@ -812,11 +887,23 @@ msgstr "Остановить NetShift"
|
||||
msgid "Subscription"
|
||||
msgstr "Подписка"
|
||||
|
||||
msgid "Subscription cache cleared and re-downloaded"
|
||||
msgstr "Кеш подписок очищен и загружен заново"
|
||||
|
||||
msgid "Subscription feeds, server filters and URLTest tuning"
|
||||
msgstr "Источники подписок, фильтры серверов и настройка URLTest"
|
||||
|
||||
msgid "Subscription format"
|
||||
msgstr "Формат подписки"
|
||||
|
||||
msgid "Subscription grouping"
|
||||
msgstr "Группировка подписки"
|
||||
|
||||
msgid "Subscription Update Interval"
|
||||
msgstr "Интервал обновления подписки"
|
||||
|
||||
msgid "Subscription URL"
|
||||
msgstr "URL подписки"
|
||||
msgid "Subscription URLs"
|
||||
msgstr "URL подписок"
|
||||
|
||||
msgid "Successfully copied!"
|
||||
msgstr "Успешно скопировано!"
|
||||
@ -860,9 +947,6 @@ msgstr "URL-адрес, используемый для проверки под
|
||||
msgid "This is a security trade-off: an attacker could intercept the fetch."
|
||||
msgstr "Это компромисс в безопасности: злоумышленник может перехватить загрузку."
|
||||
|
||||
msgid "This prevents applications from bypassing the router's DNS filtering by using their own encrypted DNS."
|
||||
msgstr "Это не позволяет приложениям обходить DNS-фильтрацию роутера за счёт использования собственного шифрованного DNS."
|
||||
|
||||
msgid "Time in seconds for DNS record caching (default: 60)"
|
||||
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
|
||||
|
||||
@ -908,6 +992,9 @@ msgstr "Обновление NetShift, это может занять неско
|
||||
msgid "Uplink"
|
||||
msgstr "Исходящий"
|
||||
|
||||
msgid "Upstream and bootstrap DNS resolvers, and optional DNS-over-proxy"
|
||||
msgstr "Вышестоящий и начальный (bootstrap) DNS-резолверы и опциональный DNS через прокси"
|
||||
|
||||
msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4/5://, or hysteria2://hy2://"
|
||||
msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4/5:// или hysteria2:// hy2://"
|
||||
|
||||
@ -917,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 ссылка для проверки"
|
||||
|
||||
@ -938,18 +1031,12 @@ msgstr "Используйте это только если на роутере
|
||||
msgid "Use with Exclusion sections to route specific domains directly."
|
||||
msgstr "Используйте вместе с секциями исключений для прямой маршрутизации определённых доменов."
|
||||
|
||||
msgid "User Domain List Type"
|
||||
msgstr "Тип пользовательского списка доменов"
|
||||
|
||||
msgid "User Domains"
|
||||
msgstr "Пользовательские домены"
|
||||
|
||||
msgid "User Domains List"
|
||||
msgstr "Список пользовательских доменов"
|
||||
|
||||
msgid "User Subnet List Type"
|
||||
msgstr "Тип пользовательского списка подсетей"
|
||||
|
||||
msgid "User Subnets"
|
||||
msgstr "Пользовательские подсети"
|
||||
|
||||
@ -974,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. Предыдущие варианты были удалены."
|
||||
|
||||
@ -986,14 +1076,17 @@ msgstr "Когда включено, трафик, не совпадающий
|
||||
msgid "Which proxy/VPN section carries the DNS. Leave unset to use the first configured outbound."
|
||||
msgstr "Какая секция прокси/VPN обслуживает DNS. Оставьте пустым, чтобы использовать первый настроенный outbound."
|
||||
|
||||
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 "Какой формат подписки (клиент) запрашивать первым. «Авто» использует порядок по умолчанию. Выберите «Xray JSON (Happ)», если ваша панель отдаёт некоторые узлы (например, xhttp) только под клиентом вроде Happ, или «Sing-box», чтобы предпочесть формат sing-box."
|
||||
|
||||
msgid "Xray JSON (Happ)"
|
||||
msgstr "Xray JSON (Happ)"
|
||||
|
||||
msgid "YACD Secret Key"
|
||||
msgstr "Секретный ключ YACD"
|
||||
|
||||
msgid "YACD web dashboard access and remote-access protection"
|
||||
msgstr "Доступ к веб-панели YACD и защита удалённого доступа"
|
||||
|
||||
msgid "You can select Output Network Interface, by default autodetect"
|
||||
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
|
||||
|
||||
msgid "Группировать по странам"
|
||||
msgstr "Группировать по странам"
|
||||
|
||||
msgid "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
|
||||
msgstr "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export function showToast(
|
||||
message: string,
|
||||
type: 'success' | 'error',
|
||||
type: 'success' | 'error' | 'warning' | 'info',
|
||||
duration: number = 3000,
|
||||
) {
|
||||
let container = document.querySelector('.toast-container');
|
||||
|
||||
@ -197,6 +197,79 @@ export const NetShiftShellMethods = {
|
||||
message: response.stderr || '',
|
||||
};
|
||||
},
|
||||
// NetShift update check (sync) — task-029/030 contract:
|
||||
// component_action netshift check_update
|
||||
// → {success, current_version, latest_version, status}. Same shape as the
|
||||
// sing-box cores (parsed by parseComponentCheckUpdate). The status is already
|
||||
// v-normalized server-side, so the caller TRUSTS result.status (no string
|
||||
// compare in TS). Stays on the SYNC component_action path (fast call).
|
||||
netshiftCheckUpdate:
|
||||
async (): Promise<NetShift.ComponentCheckUpdateResult> => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action', 'netshift', 'check_update'],
|
||||
timeout: 600000,
|
||||
});
|
||||
|
||||
if (response.stdout) {
|
||||
return parseComponentCheckUpdate(response.stdout);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: response.stderr || '',
|
||||
};
|
||||
},
|
||||
// Clear subscription cache (async) — task-039/040 contract:
|
||||
// component_action_async subscription clear_cache + component_action_status
|
||||
// <job>. Deletes all subscription caches then re-downloads, which restarts
|
||||
// the service and can exceed the rpcd ~30s wall — so it MUST run through the
|
||||
// SAME async start+poll mechanism as the sing-box core switch (reusing the
|
||||
// component-agnostic `pollSingBoxComponentAction`). The component/action
|
||||
// strings are EXACTLY 'subscription'/'clear_cache' (match task-039's router).
|
||||
clearSubscriptionCache: async (): Promise<SingBoxComponentActionResult> => {
|
||||
const startResponse = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action_async', 'subscription', 'clear_cache'],
|
||||
});
|
||||
|
||||
let start: ComponentActionStartResponse | null = null;
|
||||
|
||||
if (startResponse.stdout) {
|
||||
try {
|
||||
start = JSON.parse(
|
||||
startResponse.stdout,
|
||||
) as ComponentActionStartResponse;
|
||||
} catch (_e) {
|
||||
start = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!start || start.success !== true || !start.job_id) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
start?.message ||
|
||||
startResponse.stderr ||
|
||||
_('Failed to clear subscription cache'),
|
||||
};
|
||||
}
|
||||
|
||||
const jobId = start.job_id;
|
||||
|
||||
return pollSingBoxComponentAction(async () => {
|
||||
const statusResponse = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action_status', jobId],
|
||||
});
|
||||
|
||||
if (!statusResponse.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseComponentActionStatus(statusResponse.stdout);
|
||||
});
|
||||
},
|
||||
// NetShift self-update (async) — STABLE task-017 contract:
|
||||
// component_action_async netshift self_update + component_action_status <job>.
|
||||
// Reuses the component-agnostic poll. Because the package install swaps
|
||||
|
||||
@ -186,6 +186,7 @@ export interface StoreType {
|
||||
globalCheck: { loading: boolean };
|
||||
viewLogs: { loading: boolean };
|
||||
showSingBoxConfig: { loading: boolean };
|
||||
clearSubscriptionCache: { loading: boolean };
|
||||
};
|
||||
diagnosticsSystemInfo: {
|
||||
loading: boolean;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { renderButton } from '../../../../partials';
|
||||
import { NetShift } from '../../../types';
|
||||
|
||||
interface IRenderSectionsProps {
|
||||
@ -13,7 +14,7 @@ function renderFailedState() {
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
class: 'pdk_dashboard-page__outbound-section centered',
|
||||
class: 'card pdk_dashboard-page__outbound-section centered',
|
||||
style: 'height: 127px',
|
||||
},
|
||||
E('span', {}, [E('span', {}, _('Dashboard currently unavailable'))]),
|
||||
@ -23,7 +24,7 @@ function renderFailedState() {
|
||||
function renderLoadingState() {
|
||||
return E('div', {
|
||||
id: 'dashboard-sections-grid-skeleton',
|
||||
class: 'pdk_dashboard-page__outbound-section skeleton',
|
||||
class: 'card pdk_dashboard-page__outbound-section skeleton',
|
||||
style: 'height: 127px',
|
||||
});
|
||||
}
|
||||
@ -64,7 +65,7 @@ export function renderDefaultState({
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''} ${section.withTagSelect ? 'pdk_dashboard-page__outbound-grid__item--selectable' : ''}`,
|
||||
class: `card pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''} ${section.withTagSelect ? 'pdk_dashboard-page__outbound-grid__item--selectable' : ''}`,
|
||||
click: () =>
|
||||
section.withTagSelect &&
|
||||
onChooseOutbound(section.code, outbound.code),
|
||||
@ -87,7 +88,7 @@ export function renderDefaultState({
|
||||
);
|
||||
}
|
||||
|
||||
return E('div', { class: 'pdk_dashboard-page__outbound-section' }, [
|
||||
return E('div', { class: 'card pdk_dashboard-page__outbound-section' }, [
|
||||
// Title with test latency
|
||||
E('div', { class: 'pdk_dashboard-page__outbound-section__title-section' }, [
|
||||
E(
|
||||
@ -99,14 +100,11 @@ export function renderDefaultState({
|
||||
),
|
||||
latencyFetching
|
||||
? E('div', { class: 'skeleton', style: 'width: 99px; height: 28px' })
|
||||
: E(
|
||||
'button',
|
||||
{
|
||||
class: 'btn dashboard-sections-grid-item-test-latency',
|
||||
click: () => testLatency(),
|
||||
},
|
||||
_('Test latency'),
|
||||
),
|
||||
: renderButton({
|
||||
text: _('Test latency'),
|
||||
onClick: () => testLatency(),
|
||||
classNames: ['dashboard-sections-grid-item-test-latency'],
|
||||
}),
|
||||
]),
|
||||
E(
|
||||
'div',
|
||||
|
||||
@ -17,7 +17,7 @@ function renderFailedState() {
|
||||
{
|
||||
id: '',
|
||||
style: 'height: 78px',
|
||||
class: 'pdk_dashboard-page__widgets-section__item centered',
|
||||
class: 'card pdk_dashboard-page__widgets-section__item centered',
|
||||
},
|
||||
_('Currently unavailable'),
|
||||
);
|
||||
@ -29,14 +29,14 @@ function renderLoadingState() {
|
||||
{
|
||||
id: '',
|
||||
style: 'height: 78px',
|
||||
class: 'pdk_dashboard-page__widgets-section__item skeleton',
|
||||
class: 'card pdk_dashboard-page__widgets-section__item skeleton',
|
||||
},
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
function renderDefaultState({ title, items }: IRenderWidgetProps) {
|
||||
return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [
|
||||
return E('div', { class: 'card pdk_dashboard-page__widgets-section__item' }, [
|
||||
E(
|
||||
'b',
|
||||
{ class: 'pdk_dashboard-page__widgets-section__item__title' },
|
||||
|
||||
@ -27,9 +27,6 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_dashboard-page__widgets-section__item {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pdk_dashboard-page__widgets-section__item__title {}
|
||||
@ -50,9 +47,6 @@ export const styles = `
|
||||
|
||||
.pdk_dashboard-page__outbound-section {
|
||||
margin-top: 10px;
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pdk_dashboard-page__outbound-section__title-section {
|
||||
@ -74,9 +68,6 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_dashboard-page__outbound-grid__item {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
transition: border 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@ -46,6 +46,9 @@ export const initialDiagnosticStore: Pick<
|
||||
showSingBoxConfig: {
|
||||
loading: false,
|
||||
},
|
||||
clearSubscriptionCache: {
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
diagnosticsRunAction: { loading: false },
|
||||
diagnosticsChecks: [
|
||||
|
||||
@ -316,6 +316,48 @@ async function handleShowSingBoxConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearSubscriptionCache() {
|
||||
const diagnosticsActions = store.get().diagnosticsActions;
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
clearSubscriptionCache: { loading: true },
|
||||
},
|
||||
});
|
||||
|
||||
showToast(
|
||||
_('Clearing subscription cache and re-downloading… this may take a minute'),
|
||||
'info',
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await NetShiftShellMethods.clearSubscriptionCache();
|
||||
|
||||
if (result.success) {
|
||||
showToast(_('Subscription cache cleared and re-downloaded'), 'success');
|
||||
} else {
|
||||
logger.error(
|
||||
'[DIAGNOSTIC]',
|
||||
'handleClearSubscriptionCache - result',
|
||||
result,
|
||||
);
|
||||
showToast(_('Failed to clear subscription cache'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[DIAGNOSTIC]', 'handleClearSubscriptionCache - e', e);
|
||||
showToast(_('Failed to clear subscription cache'), 'error');
|
||||
} finally {
|
||||
await fetchServicesInfo();
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
clearSubscriptionCache: { loading: false },
|
||||
},
|
||||
});
|
||||
store.reset(['diagnosticsChecks']);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWikiDisclaimerWidget() {
|
||||
const diagnosticsChecks = store.get().diagnosticsChecks;
|
||||
|
||||
@ -404,6 +446,12 @@ function renderDiagnosticAvailableActionsWidget() {
|
||||
onClick: handleShowSingBoxConfig,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
clearSubscriptionCache: {
|
||||
loading: diagnosticsActions.clearSubscriptionCache.loading,
|
||||
visible: true,
|
||||
onClick: handleClearSubscriptionCache,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
});
|
||||
|
||||
return preserveScrollForPage(() => {
|
||||
|
||||
@ -27,6 +27,7 @@ interface IRenderAvailableActionsProps {
|
||||
globalCheck: ActionProps;
|
||||
viewLogs: ActionProps;
|
||||
showSingBoxConfig: ActionProps;
|
||||
clearSubscriptionCache: ActionProps;
|
||||
}
|
||||
|
||||
export function renderAvailableActions({
|
||||
@ -38,8 +39,9 @@ export function renderAvailableActions({
|
||||
globalCheck,
|
||||
viewLogs,
|
||||
showSingBoxConfig,
|
||||
clearSubscriptionCache,
|
||||
}: IRenderAvailableActionsProps) {
|
||||
return E('div', { class: 'pdk_diagnostic-page__right-bar__actions' }, [
|
||||
return E('div', { class: 'card pdk_diagnostic-page__right-bar__actions' }, [
|
||||
E('b', {}, _('Available actions')),
|
||||
...insertIf(restart.visible, [
|
||||
renderButton({
|
||||
@ -118,5 +120,14 @@ export function renderAvailableActions({
|
||||
disabled: showSingBoxConfig.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(clearSubscriptionCache.visible, [
|
||||
renderButton({
|
||||
onClick: clearSubscriptionCache.onClick,
|
||||
icon: renderRotateCcwIcon24,
|
||||
text: _('Clear subscription cache'),
|
||||
loading: clearSubscriptionCache.loading,
|
||||
disabled: clearSubscriptionCache.disabled,
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -56,7 +56,7 @@ function renderLoadingState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--loading' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--loading' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
@ -79,7 +79,7 @@ function renderWarningState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--warning' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--warning' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
@ -102,7 +102,7 @@ function renderErrorState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--error' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--error' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
@ -125,7 +125,7 @@ function renderSuccessState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--success' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--success' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
@ -148,7 +148,7 @@ function renderSkippedState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--skipped' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--skipped' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
|
||||
@ -14,36 +14,40 @@ interface IRenderSystemInfoProps {
|
||||
}
|
||||
|
||||
export function renderSystemInfo({ items }: IRenderSystemInfoProps) {
|
||||
return E('div', { class: 'pdk_diagnostic-page__right-bar__system-info' }, [
|
||||
E(
|
||||
'b',
|
||||
{ class: 'pdk_diagnostic-page__right-bar__system-info__title' },
|
||||
_('System information'),
|
||||
),
|
||||
...items.map((item) => {
|
||||
const tagClass = [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag',
|
||||
...insertIf(item.tag?.kind === 'warning', [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag--warning',
|
||||
]),
|
||||
...insertIf(item.tag?.kind === 'success', [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag--success',
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic-page__right-bar__system-info__row' },
|
||||
[
|
||||
E('b', {}, item.key),
|
||||
E('div', {}, [
|
||||
E('span', {}, item.value),
|
||||
E('span', { class: tagClass }, item?.tag?.label),
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'card pdk_diagnostic-page__right-bar__system-info' },
|
||||
[
|
||||
E(
|
||||
'b',
|
||||
{ class: 'pdk_diagnostic-page__right-bar__system-info__title' },
|
||||
_('System information'),
|
||||
),
|
||||
...items.map((item) => {
|
||||
const tagClass = [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag',
|
||||
...insertIf(item.tag?.kind === 'warning', [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag--warning',
|
||||
]),
|
||||
],
|
||||
);
|
||||
}),
|
||||
]);
|
||||
...insertIf(item.tag?.kind === 'success', [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag--success',
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic-page__right-bar__system-info__row' },
|
||||
[
|
||||
E('b', {}, item.key),
|
||||
E('div', {}, [
|
||||
E('span', {}, item.value),
|
||||
E('span', { class: tagClass }, item?.tag?.label),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ export function renderWikiDisclaimer(kind: 'default' | 'error' | 'warning') {
|
||||
iconWrap.appendChild(renderBookOpenTextIcon24());
|
||||
|
||||
const className = [
|
||||
'card',
|
||||
'pdk_diagnostic-page__right-bar__wiki',
|
||||
...insertIf(kind === 'error', [
|
||||
'pdk_diagnostic-page__right-bar__wiki--error',
|
||||
|
||||
@ -29,10 +29,6 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_diagnostic-page__right-bar__wiki {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
@ -54,21 +50,12 @@ export const styles = `
|
||||
.pdk_diagnostic-page__right-bar__wiki__texts {}
|
||||
|
||||
.pdk_diagnostic-page__right-bar__actions {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
|
||||
}
|
||||
|
||||
.pdk_diagnostic-page__right-bar__system-info {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
@ -120,14 +107,10 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_diagnostic_alert {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
grid-column-gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pdk_diagnostic_alert--loading {
|
||||
|
||||
@ -7,11 +7,11 @@ export type ManagerComponentKey =
|
||||
| 'sing_box_extended';
|
||||
|
||||
// `check` = a sing-box update check (routed to the sing-box check method);
|
||||
// `check_netshift` = the NetShift card's on-demand check, which is a
|
||||
// systemInfo REFRESH (the backend has NO netshift:check_update action — the
|
||||
// NetShift latest version comes only from get_system_info.netshift_latest_version).
|
||||
// Keeping it a DISTINCT kind guarantees a NetShift check can never be routed to
|
||||
// the sing-box check method.
|
||||
// `check_netshift` = the NetShift card's on-demand check (task-030), which now
|
||||
// calls the dedicated `component_action netshift check_update` action and writes
|
||||
// `managerChecks.netshift` — exactly like the sing-box cores. Keeping it a
|
||||
// DISTINCT kind guarantees a NetShift check can never be routed to the sing-box
|
||||
// check method (the dispatcher routes it to runNetshiftCheck).
|
||||
export type ManagerActionKind =
|
||||
| 'check'
|
||||
| 'check_netshift'
|
||||
@ -31,8 +31,8 @@ export interface ManagerActionDescriptor {
|
||||
kind: ManagerActionKind;
|
||||
text: string;
|
||||
// For `update`/`switch`: the backend install action; for `self_update`:
|
||||
// 'self_update'; for `check`: the sing-box check action. The NetShift
|
||||
// `check_netshift` kind has NO backend action (it just refreshes systemInfo).
|
||||
// 'self_update'; for `check`: the sing-box check action; for `check_netshift`:
|
||||
// the NetShift check action (routed to the dedicated NetShift check method).
|
||||
backendAction?:
|
||||
| 'check_update'
|
||||
| 'check_update_stable'
|
||||
@ -101,39 +101,40 @@ export function getCheckTag(
|
||||
return { label: _('Dev'), kind: 'neutral' };
|
||||
}
|
||||
|
||||
// NetShift status is derived PURELY from systemInfo (installed vs latest).
|
||||
// There is no NetShift check write into managerChecks — the on-demand check is
|
||||
// a systemInfo refresh, after which this re-derives.
|
||||
// NetShift status is derived from the on-demand check result (task-030):
|
||||
// `managerChecks.netshift.status` is null until the user presses "Check update"
|
||||
// → neutral card (no badge, no update button). The backend already computes the
|
||||
// v-normalized status, so we TRUST it (no installed-vs-latest string compare).
|
||||
// The `dev`-build guard is kept locally: a dev/placeholder build never shows an
|
||||
// update prompt regardless of any check result.
|
||||
function netshiftStatus(
|
||||
systemInfo: ManagerSystemInfo,
|
||||
check: ManagerCheckState,
|
||||
): NetShift.ComponentUpdateStatus | null {
|
||||
const installed = normalizeCompiledVersion(systemInfo.netshift_version);
|
||||
const latest = systemInfo.netshift_latest_version;
|
||||
|
||||
if (!latest || latest === 'loading' || latest === _('unknown')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (installed === 'dev') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return installed === latest ? 'latest' : 'outdated';
|
||||
return check.status;
|
||||
}
|
||||
|
||||
function netshiftCard(systemInfo: ManagerSystemInfo): ManagerCardDescriptor {
|
||||
const status = netshiftStatus(systemInfo);
|
||||
const latest = systemInfo.netshift_latest_version;
|
||||
function netshiftCard(
|
||||
systemInfo: ManagerSystemInfo,
|
||||
check: ManagerCheckState,
|
||||
): ManagerCardDescriptor {
|
||||
const status = netshiftStatus(systemInfo, check);
|
||||
const latest = check.latest_version;
|
||||
const actions: ManagerActionDescriptor[] = [];
|
||||
|
||||
if (status === 'outdated') {
|
||||
actions.push({
|
||||
loadingKey: 'netshiftUpdate',
|
||||
kind: 'self_update',
|
||||
text:
|
||||
latest && latest !== 'loading'
|
||||
? _('Install %s').replace('%s', latest)
|
||||
: _('Update NetShift'),
|
||||
text: latest
|
||||
? _('Install %s').replace('%s', latest)
|
||||
: _('Update NetShift'),
|
||||
backendAction: 'self_update',
|
||||
});
|
||||
} else {
|
||||
@ -141,6 +142,7 @@ function netshiftCard(systemInfo: ManagerSystemInfo): ManagerCardDescriptor {
|
||||
loadingKey: 'netshiftCheck',
|
||||
kind: 'check_netshift',
|
||||
text: _('Check update'),
|
||||
backendAction: 'check_update',
|
||||
});
|
||||
}
|
||||
|
||||
@ -255,7 +257,7 @@ export function getComponentCards(
|
||||
checks: Record<ManagerComponentKey, ManagerCheckState>,
|
||||
): ManagerCardDescriptor[] {
|
||||
return [
|
||||
netshiftCard(systemInfo),
|
||||
netshiftCard(systemInfo, checks.netshift),
|
||||
singBoxStockCard(systemInfo, checks.sing_box_stock),
|
||||
singBoxExtendedCard(systemInfo, checks.sing_box_extended),
|
||||
];
|
||||
|
||||
@ -135,32 +135,26 @@ async function runSingBoxCheck(
|
||||
}
|
||||
}
|
||||
|
||||
// NetShift check: the backend has NO netshift:check_update action — NetShift's
|
||||
// latest version comes only from get_system_info.netshift_latest_version. So an
|
||||
// on-demand NetShift check is a systemInfo REFRESH; the card then re-derives its
|
||||
// status from the refreshed installed-vs-latest comparison. We never write a
|
||||
// sing-box check result into managerChecks.netshift.
|
||||
// NetShift check (task-030): on-demand call to the dedicated
|
||||
// `component_action netshift check_update` action, which returns the same
|
||||
// {success, current_version, latest_version, status} contract as the sing-box
|
||||
// cores (status already v-normalized server-side). We TRUST result.status and
|
||||
// write it into managerChecks.netshift — mirroring runSingBoxCheck precisely.
|
||||
async function runNetshiftCheck(button: ManagerActionDescriptor) {
|
||||
setActionLoading(button.loadingKey, true);
|
||||
|
||||
try {
|
||||
await fetchSystemInfo();
|
||||
resetCheckResult('netshift');
|
||||
const parsed = await NetShiftShellMethods.netshiftCheckUpdate();
|
||||
|
||||
const status = store.get().diagnosticsSystemInfo;
|
||||
const installed = normalizeCompiledVersion(status.netshift_version);
|
||||
const latest = status.netshift_latest_version;
|
||||
|
||||
if (!latest || latest === 'loading' || latest === _('unknown')) {
|
||||
showToast(_('Latest version is unknown'), 'success');
|
||||
} else if (installed === 'dev') {
|
||||
showToast(getCheckToastMessage('dev'), 'success');
|
||||
} else {
|
||||
showToast(
|
||||
getCheckToastMessage(installed === latest ? 'latest' : 'outdated'),
|
||||
'success',
|
||||
);
|
||||
if (!parsed.success) {
|
||||
showToast(parsed.message || _('Failed to execute!'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = parsed.status ?? null;
|
||||
|
||||
setCheckResult('netshift', status, parsed.latest_version || '');
|
||||
showToast(getCheckToastMessage(status), 'success');
|
||||
} catch (error) {
|
||||
logger.error('[MANAGER]', 'runNetshiftCheck failed', error);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
@ -174,10 +168,7 @@ async function runSingBoxMutation(
|
||||
button: ManagerActionDescriptor,
|
||||
) {
|
||||
setActionLoading(button.loadingKey, true);
|
||||
showToast(
|
||||
_('Switching sing-box core, this may take a few minutes…'),
|
||||
'success',
|
||||
);
|
||||
showToast(_('Switching sing-box core, this may take a few minutes…'), 'info');
|
||||
|
||||
try {
|
||||
const result = await NetShiftShellMethods.singBoxComponentAction(
|
||||
@ -215,7 +206,7 @@ async function runNetshiftSelfUpdate(button: ManagerActionDescriptor) {
|
||||
// Warning-style toast: self-update is long and ends in a page reload.
|
||||
showToast(
|
||||
_('Updating NetShift, this may take a few minutes; the page will reload…'),
|
||||
'success',
|
||||
'warning',
|
||||
6000,
|
||||
);
|
||||
|
||||
@ -306,7 +297,7 @@ function renderComponentCard(card: ManagerCardDescriptor) {
|
||||
);
|
||||
}
|
||||
|
||||
return E('div', { class: 'pdk_manager-page__component' }, [
|
||||
return E('div', { class: 'card pdk_manager-page__component' }, [
|
||||
E('div', { class: 'pdk_manager-page__component__header' }, headerChildren),
|
||||
E('div', { class: 'pdk_manager-page__component__version' }, [
|
||||
E(
|
||||
|
||||
@ -25,10 +25,6 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_manager-page__component {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-row-gap: 10px;
|
||||
|
||||
@ -120,13 +120,30 @@ describe('getComponentCards', () => {
|
||||
expect(stock.actions[0].text).toBe('Install 1.12.9');
|
||||
});
|
||||
|
||||
it('derives an outdated NetShift card from systemInfo latest mismatch', () => {
|
||||
it('is neutral until checked — null managerChecks.netshift status', () => {
|
||||
// task-030: mount does NO network check; managerChecks.netshift.status is
|
||||
// null → no badge, the "Check update" action (no outdated/update button).
|
||||
const cards = getComponentCards(makeSystemInfo(), emptyChecks);
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.tag).toBeUndefined();
|
||||
expect(netshift.actions[0].kind).toBe('check_netshift');
|
||||
expect(netshift.actions[0].backendAction).toBe('check_update');
|
||||
});
|
||||
|
||||
it('derives an outdated NetShift card from the on-demand check result', () => {
|
||||
// task-030: status comes from managerChecks.netshift (the check result), NOT
|
||||
// from a systemInfo installed-vs-latest string compare. The latest_version
|
||||
// for the "Install %s" text also comes from the check result.
|
||||
const cards = getComponentCards(
|
||||
makeSystemInfo({
|
||||
netshift_version: '1.0.0',
|
||||
netshift_latest_version: '1.1.0',
|
||||
netshift_latest_version: '1.0.0',
|
||||
}),
|
||||
emptyChecks,
|
||||
{
|
||||
...emptyChecks,
|
||||
netshift: { status: 'outdated', latest_version: '1.1.0' },
|
||||
},
|
||||
);
|
||||
const netshift = cards[0];
|
||||
|
||||
@ -136,14 +153,11 @@ describe('getComponentCards', () => {
|
||||
expect(netshift.actions[0].text).toBe('Install 1.1.0');
|
||||
});
|
||||
|
||||
it('keeps the NetShift card on Check update when versions match', () => {
|
||||
const cards = getComponentCards(
|
||||
makeSystemInfo({
|
||||
netshift_version: '1.1.0',
|
||||
netshift_latest_version: '1.1.0',
|
||||
}),
|
||||
emptyChecks,
|
||||
);
|
||||
it('shows the Latest badge + Check update when the check says latest', () => {
|
||||
const cards = getComponentCards(makeSystemInfo(), {
|
||||
...emptyChecks,
|
||||
netshift: { status: 'latest', latest_version: '1.0.0' },
|
||||
});
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.tag).toEqual({ label: 'Latest', kind: 'success' });
|
||||
@ -152,34 +166,25 @@ describe('getComponentCards', () => {
|
||||
expect(netshift.actions[0].kind).toBe('check_netshift');
|
||||
});
|
||||
|
||||
it('NetShift check action carries NO sing-box backendAction', () => {
|
||||
// C1 regression guard: the NetShift "Check update" must never be a sing-box
|
||||
// check (the backend has no netshift:check_update action). Its action has no
|
||||
// backendAction at all — it triggers a systemInfo refresh in the controller.
|
||||
const cards = getComponentCards(
|
||||
makeSystemInfo({
|
||||
netshift_version: '1.0.0',
|
||||
netshift_latest_version: '1.0.0',
|
||||
}),
|
||||
emptyChecks,
|
||||
);
|
||||
it('NetShift check action carries its own (non-sing-box) backendAction', () => {
|
||||
// The NetShift "Check update" routes to runNetshiftCheck (distinct kind) and
|
||||
// calls `component_action netshift check_update` — NOT a sing-box check
|
||||
// action. Guard against accidentally reusing a sing-box check action.
|
||||
const cards = getComponentCards(makeSystemInfo(), emptyChecks);
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.actions[0].kind).toBe('check_netshift');
|
||||
expect(netshift.actions[0].backendAction).toBeUndefined();
|
||||
expect(['check_update', 'check_update_stable']).not.toContain(
|
||||
expect(netshift.actions[0].backendAction).toBe('check_update');
|
||||
expect(['check_update_stable']).not.toContain(
|
||||
netshift.actions[0].backendAction,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives NetShift status purely from systemInfo, ignoring managerChecks', () => {
|
||||
// Even if a (bogus) sing-box-style status leaked into managerChecks.netshift,
|
||||
// the NetShift card must derive its status from systemInfo versions only.
|
||||
it('keeps a dev build neutral even if a check result says outdated', () => {
|
||||
// The dev-build guard: a placeholder/dev install never shows an update
|
||||
// prompt regardless of any check result.
|
||||
const cards = getComponentCards(
|
||||
makeSystemInfo({
|
||||
netshift_version: '1.0.0',
|
||||
netshift_latest_version: '1.0.0',
|
||||
}),
|
||||
makeSystemInfo({ netshift_version: 'COMPILED_VERSION' }),
|
||||
{
|
||||
...emptyChecks,
|
||||
netshift: { status: 'outdated', latest_version: '9.9.9' },
|
||||
@ -187,15 +192,18 @@ describe('getComponentCards', () => {
|
||||
);
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.tag).toEqual({ label: 'Latest', kind: 'success' });
|
||||
expect(netshift.version).toBe('dev');
|
||||
expect(netshift.tag).toBeUndefined();
|
||||
expect(netshift.actions[0].kind).toBe('check_netshift');
|
||||
});
|
||||
|
||||
it('treats an unknown NetShift latest as no status (Check update, no badge)', () => {
|
||||
it('ignores systemInfo netshift_latest_version for status (now on-demand)', () => {
|
||||
// task-030: a stale/unknown systemInfo latest must NOT drive the badge — only
|
||||
// the on-demand managerChecks.netshift result does.
|
||||
const cards = getComponentCards(
|
||||
makeSystemInfo({
|
||||
netshift_version: '1.0.0',
|
||||
netshift_latest_version: 'unknown',
|
||||
netshift_latest_version: '9.9.9',
|
||||
}),
|
||||
emptyChecks,
|
||||
);
|
||||
|
||||
@ -117,9 +117,11 @@ export namespace NetShift {
|
||||
export interface ConfigProxySubscriptionSection {
|
||||
connection_type: 'proxy';
|
||||
proxy_config_type: 'subscription';
|
||||
subscription_url: string;
|
||||
subscription_url: string[];
|
||||
subscription_format_preference?: 'auto' | 'xray' | 'singbox';
|
||||
subscription_update_interval?: string;
|
||||
subscription_group_by_countries?: '0' | '1';
|
||||
subscription_group_mode?: 'off' | 'country' | 'prefix';
|
||||
subscription_group_prefix_len?: string;
|
||||
subscription_filter_include_keywords?: string[];
|
||||
subscription_filter_exclude_keywords?: string[];
|
||||
}
|
||||
|
||||
@ -3,6 +3,38 @@ import { DashboardTab, DiagnosticTab, ManagerTab } from './netshift';
|
||||
import { PartialStyles } from './partials';
|
||||
|
||||
export const GlobalStyles = `
|
||||
/*
|
||||
* NetShift design tokens (Stage 1 foundation — task-024).
|
||||
* Each token layers over the LuCI theme var (with a hardcoded fallback) so
|
||||
* themes still win. Reused by the custom tabs and the form redesigns
|
||||
* (task-025/026). Keep these names stable.
|
||||
*/
|
||||
:root,
|
||||
.cbi-map {
|
||||
--ns-card-border: var(--background-color-low, lightgray);
|
||||
--ns-card-border-width: 2px;
|
||||
--ns-card-radius: 4px;
|
||||
--ns-gap: 10px;
|
||||
--ns-card-padding: var(--ns-gap);
|
||||
--ns-success: var(--success-color-medium, #28a745);
|
||||
--ns-warning: var(--warn-color-medium, #f0ad4e);
|
||||
--ns-error: var(--error-color-medium, #dc3545);
|
||||
--ns-info: var(--primary-color-high, #2196f3);
|
||||
}
|
||||
|
||||
/*
|
||||
* Shared card primitive. Mirrors the Manager component card look
|
||||
* (2px solid border, 4px radius, 10px padding, overflow-safe min-width:0).
|
||||
* Defined BEFORE the per-tab styles so colored-border modifiers
|
||||
* (e.g. .pdk_diagnostic_alert--warning) still win via source order.
|
||||
*/
|
||||
.card {
|
||||
border: var(--ns-card-border-width) solid var(--ns-card-border);
|
||||
border-radius: var(--ns-card-radius);
|
||||
padding: var(--ns-card-padding);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
${DashboardTab.styles}
|
||||
${DiagnosticTab.styles}
|
||||
${ManagerTab.styles}
|
||||
@ -24,6 +56,42 @@ ${PartialStyles}
|
||||
margin-bottom: -32px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sections (connection) form — native CBI option-group tabs styled as a
|
||||
* card (task-025). Reuses task-024's --ns-* tokens. The tab strip
|
||||
* (ul.cbi-tabmenu) sits on top; each tab pane (.cbi-section-node-tabbed)
|
||||
* reads as the card body. depends()-driven auto-hide of tabs is unaffected.
|
||||
*/
|
||||
#cbi-netshift-section .cbi-section-node-tabbed {
|
||||
border: var(--ns-card-border-width) solid var(--ns-card-border);
|
||||
border-radius: var(--ns-card-radius);
|
||||
padding: var(--ns-card-padding);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#cbi-netshift-section ul.cbi-tabmenu {
|
||||
margin-bottom: var(--ns-gap);
|
||||
}
|
||||
|
||||
/*
|
||||
* Settings form — native CBI option-group tabs styled as a card (task-026).
|
||||
* Reuses task-024's --ns-* tokens and mirrors the #cbi-netshift-section
|
||||
* pattern above. The tab strip (ul.cbi-tabmenu) sits on top; each tab pane
|
||||
* (.cbi-section-node-tabbed) reads as the card body. depends()-driven
|
||||
* auto-hide of tabs is unaffected. The existing
|
||||
* #cbi-netshift-settings > h3 hide rule above stays valid.
|
||||
*/
|
||||
#cbi-netshift-settings .cbi-section-node-tabbed {
|
||||
border: var(--ns-card-border-width) solid var(--ns-card-border);
|
||||
border-radius: var(--ns-card-radius);
|
||||
padding: var(--ns-card-padding);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#cbi-netshift-settings ul.cbi-tabmenu {
|
||||
margin-bottom: var(--ns-gap);
|
||||
}
|
||||
|
||||
/* Centered class helper */
|
||||
.centered {
|
||||
display: flex;
|
||||
@ -99,11 +167,19 @@ ${PartialStyles}
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: #28a745;
|
||||
background-color: var(--ns-success, #28a745);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: #dc3545;
|
||||
background-color: var(--ns-error, #dc3545);
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background-color: var(--ns-warning, #f0ad4e);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background-color: var(--ns-info, #2196f3);
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
|
||||
@ -10,4 +10,5 @@ export * from './validateVlessUrl';
|
||||
export * from './validateOutboundJson';
|
||||
export * from './validateTrojanUrl';
|
||||
export * from './validateProxyUrl';
|
||||
export * from './validateProxyUrlList';
|
||||
export * from './validateSocksUrl';
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
44
fe-app-netshift/src/validators/validateProxyUrlList.ts
Normal file
44
fe-app-netshift/src/validators/validateProxyUrlList.ts
Normal 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: '' };
|
||||
}
|
||||
111
install.sh
111
install.sh
@ -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
|
||||
|
||||
@ -57,7 +63,7 @@ pkg_install() {
|
||||
# If you're installing a non-standard (self-built) package, use the --allow-untrusted option:
|
||||
apk add --allow-untrusted "$pkg_file"
|
||||
else
|
||||
opkg install "$pkg_file"
|
||||
opkg install --force-downgrade --force-reinstall "$pkg_file"
|
||||
fi
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -32,6 +32,21 @@ const EntryPoint = {
|
||||
// Enable tab views
|
||||
netshiftMap.tabbed = true;
|
||||
|
||||
// Dashboard tab (first / landing tab)
|
||||
const dashboardSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
"dashboard",
|
||||
_("Dashboard"),
|
||||
);
|
||||
dashboardSection.anonymous = true;
|
||||
dashboardSection.addremove = false;
|
||||
dashboardSection.cfgsections = function () {
|
||||
return ["dashboard"];
|
||||
};
|
||||
|
||||
// Render dashboard content
|
||||
dashboard.createDashboardContent(dashboardSection);
|
||||
|
||||
// Sections tab
|
||||
const sectionsSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
@ -61,21 +76,6 @@ const EntryPoint = {
|
||||
// Render settings content
|
||||
settings.createSettingsContent(settingsSection);
|
||||
|
||||
// Diagnostic tab
|
||||
const diagnosticSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
"diagnostic",
|
||||
_("Diagnostics"),
|
||||
);
|
||||
diagnosticSection.anonymous = true;
|
||||
diagnosticSection.addremove = false;
|
||||
diagnosticSection.cfgsections = function () {
|
||||
return ["diagnostic"];
|
||||
};
|
||||
|
||||
// Render diagnostic content
|
||||
diagnostic.createDiagnosticContent(diagnosticSection);
|
||||
|
||||
// Component Manager tab
|
||||
const managerSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
@ -91,20 +91,20 @@ const EntryPoint = {
|
||||
// Render Component Manager content
|
||||
manager.createManagerContent(managerSection);
|
||||
|
||||
// Dashboard tab
|
||||
const dashboardSection = netshiftMap.section(
|
||||
// Diagnostic tab
|
||||
const diagnosticSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
"dashboard",
|
||||
_("Dashboard"),
|
||||
"diagnostic",
|
||||
_("Diagnostics"),
|
||||
);
|
||||
dashboardSection.anonymous = true;
|
||||
dashboardSection.addremove = false;
|
||||
dashboardSection.cfgsections = function () {
|
||||
return ["dashboard"];
|
||||
diagnosticSection.anonymous = true;
|
||||
diagnosticSection.addremove = false;
|
||||
diagnosticSection.cfgsections = function () {
|
||||
return ["diagnostic"];
|
||||
};
|
||||
|
||||
// Render dashboard content
|
||||
dashboard.createDashboardContent(dashboardSection);
|
||||
// Render diagnostic content
|
||||
diagnostic.createDiagnosticContent(diagnosticSection);
|
||||
|
||||
// Inject core service
|
||||
main.coreService();
|
||||
|
||||
@ -6,7 +6,34 @@
|
||||
"require view.netshift.main as main";
|
||||
|
||||
function createSectionContent(section) {
|
||||
let o = section.option(
|
||||
// Group the 36 connection options into 4 native CBI option-group tabs.
|
||||
// HARD RULE: once a section has tab(), every option MUST be added via
|
||||
// taboption() — any leftover section.option(...) renders nothing.
|
||||
// depends() works across tabs; a tab whose options are all depends-hidden
|
||||
// auto-hides from the strip (desired, e.g. Subscription for proxy/url).
|
||||
section.tab(
|
||||
"connection",
|
||||
_("Connection"),
|
||||
_("Connection type, transport and DNS resolver for this section"),
|
||||
);
|
||||
section.tab(
|
||||
"subscription",
|
||||
_("Subscription"),
|
||||
_("Subscription feeds, server filters and URLTest tuning"),
|
||||
);
|
||||
section.tab(
|
||||
"routing",
|
||||
_("Routing"),
|
||||
_("Domain and subnet lists that decide which traffic uses this section"),
|
||||
);
|
||||
section.tab(
|
||||
"advanced",
|
||||
_("Advanced"),
|
||||
_("Mixed proxy and DNS resolution tuning"),
|
||||
);
|
||||
|
||||
let o = section.taboption(
|
||||
"connection",
|
||||
form.ListValue,
|
||||
"connection_type",
|
||||
_("Connection Type"),
|
||||
@ -17,7 +44,8 @@ function createSectionContent(section) {
|
||||
o.value("block", "Block");
|
||||
o.value("exclusion", "Exclusion");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.ListValue,
|
||||
"proxy_config_type",
|
||||
_("Configuration Type"),
|
||||
@ -26,12 +54,15 @@ 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";
|
||||
o.depends("connection_type", "proxy");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.TextValue,
|
||||
"proxy_string",
|
||||
_("Proxy Configuration URL"),
|
||||
@ -62,7 +93,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.TextValue,
|
||||
"outbound_json",
|
||||
_("Outbound Configuration"),
|
||||
@ -85,17 +117,18 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
form.Value,
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.DynamicList,
|
||||
"subscription_url",
|
||||
_("Subscription URL"),
|
||||
_("Subscription URLs"),
|
||||
_(
|
||||
"Enter the subscription URL to fetch proxy configurations from your provider",
|
||||
"Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged.",
|
||||
),
|
||||
);
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
o.placeholder = "https://example.com/api/sub";
|
||||
o.rmempty = false;
|
||||
o.rmempty = true;
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
@ -110,7 +143,23 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.ListValue,
|
||||
"subscription_format_preference",
|
||||
_("Subscription format"),
|
||||
_(
|
||||
"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.",
|
||||
),
|
||||
);
|
||||
o.value("auto", _("Auto"));
|
||||
o.value("xray", _("Xray JSON (Happ)"));
|
||||
o.value("singbox", _("Sing-box"));
|
||||
o.default = "auto";
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.Flag,
|
||||
"subscription_insecure",
|
||||
_("Allow insecure TLS for subscription fetch"),
|
||||
@ -124,7 +173,8 @@ function createSectionContent(section) {
|
||||
o.rmempty = false;
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.ListValue,
|
||||
"subscription_update_interval",
|
||||
_("Subscription Update Interval"),
|
||||
@ -139,19 +189,40 @@ function createSectionContent(section) {
|
||||
o.default = "1h";
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"subscription_group_by_countries",
|
||||
_("Группировать по странам"),
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.ListValue,
|
||||
"subscription_group_mode",
|
||||
_("Subscription grouping"),
|
||||
_(
|
||||
"Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы",
|
||||
"Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters.",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.value("off", _("Off"));
|
||||
o.value("country", _("By country flag"));
|
||||
o.value("prefix", _("By name prefix"));
|
||||
o.default = "off";
|
||||
o.rmempty = false;
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.Value,
|
||||
"subscription_group_prefix_len",
|
||||
_("Prefix length"),
|
||||
_("Number of leading characters of each proxy name to group by."),
|
||||
);
|
||||
o.default = "2";
|
||||
o.datatype = "and(uinteger,min(1))";
|
||||
o.rmempty = false;
|
||||
o.depends({
|
||||
connection_type: "proxy",
|
||||
proxy_config_type: "subscription",
|
||||
subscription_group_mode: "prefix",
|
||||
});
|
||||
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.DynamicList,
|
||||
"subscription_filter_include_keywords",
|
||||
_("Include servers by keyword"),
|
||||
@ -162,7 +233,8 @@ function createSectionContent(section) {
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
o.rmempty = true;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.DynamicList,
|
||||
"subscription_filter_exclude_keywords",
|
||||
_("Exclude servers by keyword"),
|
||||
@ -173,7 +245,8 @@ function createSectionContent(section) {
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
o.rmempty = true;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.DynamicList,
|
||||
"selector_proxy_links",
|
||||
_("Selector Proxy Links"),
|
||||
@ -198,7 +271,37 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
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,
|
||||
"urltest_proxy_links",
|
||||
_("URLTest Proxy Links"),
|
||||
@ -223,7 +326,37 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
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,
|
||||
"urltest_check_interval",
|
||||
_("URLTest Check Interval"),
|
||||
@ -235,9 +368,11 @@ 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.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.Value,
|
||||
"urltest_tolerance",
|
||||
_("URLTest Tolerance"),
|
||||
@ -248,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) {
|
||||
@ -269,7 +405,8 @@ function createSectionContent(section) {
|
||||
return _("Must be a number in the range of 50 - 1000");
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.Value,
|
||||
"urltest_testing_url",
|
||||
_("URLTest Testing URL"),
|
||||
@ -291,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) {
|
||||
@ -307,7 +445,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.Flag,
|
||||
"enable_udp_over_tcp",
|
||||
_("UDP over TCP"),
|
||||
@ -317,7 +456,8 @@ function createSectionContent(section) {
|
||||
o.depends("connection_type", "proxy");
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.Flag,
|
||||
"global_proxy",
|
||||
_("Global Proxy"),
|
||||
@ -334,7 +474,8 @@ function createSectionContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
widgets.DeviceSelect,
|
||||
"interface",
|
||||
_("Network Interface"),
|
||||
@ -380,7 +521,8 @@ function createSectionContent(section) {
|
||||
return !isWireless;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.Flag,
|
||||
"domain_resolver_enabled",
|
||||
_("Domain Resolver"),
|
||||
@ -390,7 +532,8 @@ function createSectionContent(section) {
|
||||
o.rmempty = false;
|
||||
o.depends("connection_type", "vpn");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.ListValue,
|
||||
"domain_resolver_dns_type",
|
||||
_("DNS Protocol Type"),
|
||||
@ -403,7 +546,8 @@ function createSectionContent(section) {
|
||||
o.rmempty = false;
|
||||
o.depends("domain_resolver_enabled", "1");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.Value,
|
||||
"domain_resolver_dns_server",
|
||||
_("DNS Server"),
|
||||
@ -425,7 +569,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"community_lists",
|
||||
_("Community Lists"),
|
||||
@ -513,11 +658,18 @@ function createSectionContent(section) {
|
||||
}
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
// --- Custom domains group (mode selector + the matching input below) ---
|
||||
// Three UCI keys kept (user_domain_list_type, user_domains,
|
||||
// user_domains_text); depends() shows only the input for the chosen mode,
|
||||
// so the trio reads as one "list-or-text" control.
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.ListValue,
|
||||
"user_domain_list_type",
|
||||
_("User Domain List Type"),
|
||||
_("Select the list type for adding custom domains"),
|
||||
_("Custom domains"),
|
||||
_(
|
||||
"Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip",
|
||||
),
|
||||
);
|
||||
o.value("disabled", _("Disabled"));
|
||||
o.value("dynamic", _("Dynamic List"));
|
||||
@ -525,7 +677,8 @@ function createSectionContent(section) {
|
||||
o.default = "disabled";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"user_domains",
|
||||
_("User Domains"),
|
||||
@ -551,7 +704,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.TextValue,
|
||||
"user_domains_text",
|
||||
_("User Domains List"),
|
||||
@ -593,11 +747,17 @@ function createSectionContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
// --- Custom subnets group (mode selector + the matching input below) ---
|
||||
// Same pattern as the domains group; keeps user_subnet_list_type,
|
||||
// user_subnets and user_subnets_text as separate UCI keys.
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.ListValue,
|
||||
"user_subnet_list_type",
|
||||
_("User Subnet List Type"),
|
||||
_("Select the list type for adding custom subnets"),
|
||||
_("Custom subnets"),
|
||||
_(
|
||||
"Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip",
|
||||
),
|
||||
);
|
||||
o.value("disabled", _("Disabled"));
|
||||
o.value("dynamic", _("Dynamic List"));
|
||||
@ -605,7 +765,8 @@ function createSectionContent(section) {
|
||||
o.default = "disabled";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"user_subnets",
|
||||
_("User Subnets"),
|
||||
@ -631,7 +792,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.TextValue,
|
||||
"user_subnets_text",
|
||||
_("User Subnets List"),
|
||||
@ -672,7 +834,8 @@ function createSectionContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"local_domain_lists",
|
||||
_("Local Domain Lists"),
|
||||
@ -695,7 +858,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"local_subnet_lists",
|
||||
_("Local Subnet Lists"),
|
||||
@ -718,7 +882,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"remote_domain_lists",
|
||||
_("Remote Domain Lists"),
|
||||
@ -741,7 +906,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"remote_subnet_lists",
|
||||
_("Remote Subnet Lists"),
|
||||
@ -764,7 +930,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"fully_routed_ips",
|
||||
_("Fully Routed IPs"),
|
||||
@ -791,7 +958,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"mixed_proxy_enabled",
|
||||
_("Enable Mixed Proxy"),
|
||||
@ -804,7 +972,8 @@ function createSectionContent(section) {
|
||||
o.depends("connection_type", "proxy");
|
||||
o.depends("connection_type", "vpn");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Value,
|
||||
"mixed_proxy_port",
|
||||
_("Mixed Proxy Port"),
|
||||
@ -819,7 +988,8 @@ function createSectionContent(section) {
|
||||
o.rmempty = true;
|
||||
o.depends("mixed_proxy_enabled", "1");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"resolve_real_ip_for_routing",
|
||||
_("Resolve real IP for routing"),
|
||||
|
||||
@ -6,7 +6,40 @@
|
||||
"require view.netshift.main as main";
|
||||
|
||||
function createSettingsContent(section) {
|
||||
let o = section.option(
|
||||
// Group the 27 settings options into 5 native CBI option-group tabs.
|
||||
// HARD RULE: once a section has tab(), every option MUST be added via
|
||||
// taboption() — any leftover section.option(...) renders nothing.
|
||||
// depends() works across tabs; a tab whose options are all depends-hidden
|
||||
// auto-hides from the strip.
|
||||
section.tab(
|
||||
"dns",
|
||||
_("DNS"),
|
||||
_("Upstream and bootstrap DNS resolvers, and optional DNS-over-proxy"),
|
||||
);
|
||||
section.tab(
|
||||
"network",
|
||||
_("Network"),
|
||||
_("Source and output interfaces, and Bad WAN interface monitoring"),
|
||||
);
|
||||
section.tab(
|
||||
"lists",
|
||||
_("Lists & Updates"),
|
||||
_("List update schedule, download routing, and routing exclusions"),
|
||||
);
|
||||
section.tab(
|
||||
"yacd",
|
||||
_("Dashboard"),
|
||||
_("YACD web dashboard access and remote-access protection"),
|
||||
);
|
||||
section.tab(
|
||||
"advanced",
|
||||
_("Advanced"),
|
||||
_("Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."),
|
||||
);
|
||||
|
||||
// --- DNS tab ---
|
||||
let o = section.taboption(
|
||||
"dns",
|
||||
form.ListValue,
|
||||
"dns_type",
|
||||
_("DNS Protocol Type"),
|
||||
@ -18,7 +51,8 @@ function createSettingsContent(section) {
|
||||
o.default = "udp";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"dns",
|
||||
form.Value,
|
||||
"dns_server",
|
||||
_("DNS Server"),
|
||||
@ -39,7 +73,8 @@ function createSettingsContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"dns",
|
||||
form.Value,
|
||||
"bootstrap_dns_server",
|
||||
_("Bootstrap DNS server"),
|
||||
@ -62,7 +97,8 @@ function createSettingsContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"dns",
|
||||
form.Flag,
|
||||
"dns_via_outbound",
|
||||
_("Route main DNS through proxy/VPN"),
|
||||
@ -73,7 +109,8 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"dns",
|
||||
form.ListValue,
|
||||
"dns_outbound_section",
|
||||
_("DNS outbound section"),
|
||||
@ -107,7 +144,8 @@ function createSettingsContent(section) {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"dns",
|
||||
form.Value,
|
||||
"dns_rewrite_ttl",
|
||||
_("DNS Rewrite TTL"),
|
||||
@ -128,7 +166,9 @@ function createSettingsContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
// --- Network tab ---
|
||||
o = section.taboption(
|
||||
"network",
|
||||
widgets.DeviceSelect,
|
||||
"source_network_interfaces",
|
||||
_("Source Network Interface"),
|
||||
@ -165,7 +205,8 @@ function createSettingsContent(section) {
|
||||
return !isWireless;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
form.Flag,
|
||||
"enable_output_network_interface",
|
||||
_("Enable Output Network Interface"),
|
||||
@ -174,7 +215,8 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
widgets.DeviceSelect,
|
||||
"output_network_interface",
|
||||
_("Output Network Interface"),
|
||||
@ -226,7 +268,8 @@ function createSettingsContent(section) {
|
||||
return !isWireless;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
form.Flag,
|
||||
"enable_badwan_interface_monitoring",
|
||||
_("Interface Monitoring"),
|
||||
@ -235,7 +278,8 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
widgets.NetworkSelect,
|
||||
"badwan_monitored_interfaces",
|
||||
_("Monitored Interfaces"),
|
||||
@ -258,7 +302,8 @@ function createSettingsContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
form.Value,
|
||||
"badwan_reload_delay",
|
||||
_("Interface Monitoring Delay"),
|
||||
@ -274,50 +319,9 @@ function createSettingsContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"enable_yacd",
|
||||
_("Enable YACD"),
|
||||
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"enable_yacd_wan_access",
|
||||
_("Enable YACD WAN Access"),
|
||||
_(
|
||||
"Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall.",
|
||||
),
|
||||
);
|
||||
o.depends("enable_yacd", "1");
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Value,
|
||||
"yacd_secret_key",
|
||||
_("YACD Secret Key"),
|
||||
_(
|
||||
"Secret key for authenticating remote access to YACD when WAN access is enabled.",
|
||||
),
|
||||
);
|
||||
o.depends("enable_yacd_wan_access", "1");
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"disable_quic",
|
||||
_("Disable QUIC"),
|
||||
_(
|
||||
"Disable the QUIC protocol to improve compatibility or fix issues with video streaming",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
// --- Lists & Updates tab ---
|
||||
o = section.taboption(
|
||||
"lists",
|
||||
form.ListValue,
|
||||
"update_interval",
|
||||
_("List Update Frequency"),
|
||||
@ -329,7 +333,8 @@ function createSettingsContent(section) {
|
||||
o.default = "1d";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"lists",
|
||||
form.Flag,
|
||||
"download_lists_via_proxy",
|
||||
_("Download Lists via Proxy/VPN"),
|
||||
@ -338,7 +343,8 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"lists",
|
||||
form.ListValue,
|
||||
"download_lists_via_proxy_section",
|
||||
_("Download Lists via specific proxy section"),
|
||||
@ -371,7 +377,81 @@ function createSettingsContent(section) {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"lists",
|
||||
form.DynamicList,
|
||||
"routing_excluded_ips",
|
||||
_("Routing Excluded IPs"),
|
||||
_("Specify a local IP address to be excluded from routing"),
|
||||
);
|
||||
o.placeholder = "IP";
|
||||
o.rmempty = true;
|
||||
o.validate = function (section_id, value) {
|
||||
// Optional
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const validation = main.validateIP(value);
|
||||
|
||||
if (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
// --- Dashboard / YACD tab ---
|
||||
o = section.taboption(
|
||||
"yacd",
|
||||
form.Flag,
|
||||
"enable_yacd",
|
||||
_("Enable YACD"),
|
||||
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"yacd",
|
||||
form.Flag,
|
||||
"enable_yacd_wan_access",
|
||||
_("Enable YACD WAN Access"),
|
||||
_(
|
||||
"Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall.",
|
||||
),
|
||||
);
|
||||
o.depends("enable_yacd", "1");
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"yacd",
|
||||
form.Value,
|
||||
"yacd_secret_key",
|
||||
_("YACD Secret Key"),
|
||||
_(
|
||||
"Secret key for authenticating remote access to YACD when WAN access is enabled.",
|
||||
),
|
||||
);
|
||||
o.depends("enable_yacd_wan_access", "1");
|
||||
o.rmempty = false;
|
||||
|
||||
// --- Advanced tab ---
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"disable_quic",
|
||||
_("Disable QUIC"),
|
||||
_(
|
||||
"Disable the QUIC protocol to improve compatibility or fix issues with video streaming",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"dont_touch_dhcp",
|
||||
_("Dont Touch My DHCP!"),
|
||||
@ -380,7 +460,44 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"exclude_ntp",
|
||||
_("Exclude NTP"),
|
||||
_(
|
||||
"Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"block_doh",
|
||||
_("Block DoH Servers"),
|
||||
_(
|
||||
"Block direct connections to known public DoH servers (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex) so apps cannot bypass router DNS filtering.",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"enable_ipv6",
|
||||
_("Enable IPv6 Support"),
|
||||
_("Enable IPv6 TProxy routing, IPv6 DNS inbound, and IPv6 FakeIP support.") +
|
||||
" " +
|
||||
_("Use this only when the router has working IPv6 connectivity."),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.ListValue,
|
||||
"config_path",
|
||||
_("Config File Path"),
|
||||
@ -393,7 +510,8 @@ function createSettingsContent(section) {
|
||||
o.default = "/etc/sing-box/config.json";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Value,
|
||||
"cache_path",
|
||||
_("Cache File Path"),
|
||||
@ -429,7 +547,8 @@ function createSettingsContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.ListValue,
|
||||
"log_level",
|
||||
_("Log Level"),
|
||||
@ -444,72 +563,6 @@ function createSettingsContent(section) {
|
||||
o.value("panic", "Panic");
|
||||
o.default = "warn";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"exclude_ntp",
|
||||
_("Exclude NTP"),
|
||||
_(
|
||||
"Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"block_doh",
|
||||
_("Block DoH Servers"),
|
||||
_("Block direct connections to known public DNS-over-HTTPS (DoH) servers.") +
|
||||
" " +
|
||||
_(
|
||||
"This prevents applications from bypassing the router's DNS filtering by using their own encrypted DNS.",
|
||||
) +
|
||||
" " +
|
||||
_(
|
||||
"Affects Cloudflare, Google, Quad9, OpenDNS, AdGuard, and Yandex public DoH servers.",
|
||||
) +
|
||||
" " +
|
||||
_(
|
||||
"Note: if your upstream DNS type is set to 'DoH', enable this only after switching to UDP or DoT.",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"enable_ipv6",
|
||||
_("Enable IPv6 Support"),
|
||||
_("Enable IPv6 TProxy routing, IPv6 DNS inbound, and IPv6 FakeIP support.") +
|
||||
" " +
|
||||
_("Use this only when the router has working IPv6 connectivity."),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.DynamicList,
|
||||
"routing_excluded_ips",
|
||||
_("Routing Excluded IPs"),
|
||||
_("Specify a local IP address to be excluded from routing"),
|
||||
);
|
||||
o.placeholder = "IP";
|
||||
o.rmempty = true;
|
||||
o.validate = function (section_id, value) {
|
||||
// Optional
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const validation = main.validateIP(value);
|
||||
|
||||
if (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validation.message;
|
||||
};
|
||||
}
|
||||
|
||||
const EntryPoint = {
|
||||
|
||||
@ -7,8 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: NETSHIFT\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-06-06 00:53+0300\n"
|
||||
"PO-Revision-Date: 2026-06-06 00:53+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"
|
||||
@ -32,11 +32,20 @@ msgstr "✘ Остановлен"
|
||||
msgid "Active Connections"
|
||||
msgstr "Активные соединения"
|
||||
|
||||
msgid "Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged."
|
||||
msgstr "Добавьте один или несколько URL подписок для получения конфигураций прокси. Все источники загружаются и объединяются."
|
||||
|
||||
msgid "Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
|
||||
msgstr "Добавьте свои домены: выберите Динамический список (по одному в строке) или Текстовый список (свободный ввод), либо Отключено, чтобы пропустить"
|
||||
|
||||
msgid "Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
|
||||
msgstr "Добавьте свои подсети или IP: выберите Динамический список (по одному в строке) или Текстовый список (свободный ввод), либо Отключено, чтобы пропустить"
|
||||
|
||||
msgid "Additional marking rules found"
|
||||
msgstr "Найдены дополнительные правила маркировки"
|
||||
|
||||
msgid "Affects Cloudflare, Google, Quad9, OpenDNS, AdGuard, and Yandex public DoH servers."
|
||||
msgstr "Затрагивает публичные DoH-серверы Cloudflare, Google, Quad9, OpenDNS, AdGuard и Yandex."
|
||||
msgid "Advanced"
|
||||
msgstr "Дополнительно"
|
||||
|
||||
msgid "Allow insecure TLS for subscription fetch"
|
||||
msgstr "Разрешить небезопасный TLS при загрузке подписки"
|
||||
@ -47,17 +56,23 @@ 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 "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
|
||||
|
||||
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
|
||||
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Available actions"
|
||||
msgstr "Доступные действия"
|
||||
|
||||
msgid "Block direct connections to known public DNS-over-HTTPS (DoH) servers."
|
||||
msgstr "Блокирует прямые подключения к известным публичным серверам DNS-over-HTTPS (DoH)."
|
||||
msgid "Block direct connections to known public DoH servers (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex) so apps cannot bypass router DNS filtering."
|
||||
msgstr "Блокировать прямые подключения к известным публичным DoH-серверам (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex), чтобы приложения не могли обойти DNS-фильтрацию роутера."
|
||||
|
||||
msgid "Block DoH Servers"
|
||||
msgstr "Блокировать DoH-серверы"
|
||||
@ -74,6 +89,12 @@ msgstr "Браузер не использует FakeIP"
|
||||
msgid "Browser is using FakeIP correctly"
|
||||
msgstr "Браузер использует FakeIP"
|
||||
|
||||
msgid "By country flag"
|
||||
msgstr "По флагу страны"
|
||||
|
||||
msgid "By name prefix"
|
||||
msgstr "По префиксу имени"
|
||||
|
||||
msgid "Cache File Path"
|
||||
msgstr "Путь к файлу кэша"
|
||||
|
||||
@ -101,6 +122,12 @@ msgstr "Проверки пройдены"
|
||||
msgid "CIDR must be between 0 and 32"
|
||||
msgstr "CIDR должен быть между 0 и 32"
|
||||
|
||||
msgid "Clear subscription cache"
|
||||
msgstr "Очистить кеш подписок"
|
||||
|
||||
msgid "Clearing subscription cache and re-downloading… this may take a minute"
|
||||
msgstr "Очистка кеша подписок и повторная загрузка… это может занять минуту"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
@ -119,9 +146,15 @@ msgstr "Конфигурация службы NetShift"
|
||||
msgid "Configuration Type"
|
||||
msgstr "Тип конфигурации"
|
||||
|
||||
msgid "Connection"
|
||||
msgstr "Подключение"
|
||||
|
||||
msgid "Connection Type"
|
||||
msgstr "Тип подключения"
|
||||
|
||||
msgid "Connection type, transport and DNS resolver for this section"
|
||||
msgstr "Тип подключения, транспорт и DNS-резолвер для этой секции"
|
||||
|
||||
msgid "Connection URL"
|
||||
msgstr "URL подключения"
|
||||
|
||||
@ -137,6 +170,12 @@ msgstr "Истекло время ожидания переключения яд
|
||||
msgid "Currently unavailable"
|
||||
msgstr "Временно недоступно"
|
||||
|
||||
msgid "Custom domains"
|
||||
msgstr "Свои домены"
|
||||
|
||||
msgid "Custom subnets"
|
||||
msgstr "Свои подсети"
|
||||
|
||||
msgid "Dashboard"
|
||||
msgstr "Дашборд"
|
||||
|
||||
@ -173,6 +212,9 @@ msgstr "Отключено"
|
||||
msgid "Disables TLS certificate verification when downloading the subscription."
|
||||
msgstr "Отключает проверку TLS-сертификата при загрузке подписки."
|
||||
|
||||
msgid "DNS"
|
||||
msgstr "DNS"
|
||||
|
||||
msgid "DNS on router"
|
||||
msgstr "DNS на роутере"
|
||||
|
||||
@ -200,6 +242,9 @@ msgstr "Адрес DNS-сервера не может быть пустым"
|
||||
msgid "Do not panic, everything can be fixed, just..."
|
||||
msgstr "Не паникуйте, всё можно исправить, просто..."
|
||||
|
||||
msgid "Domain and subnet lists that decide which traffic uses this section"
|
||||
msgstr "Списки доменов и подсетей, определяющие, какой трафик идёт через эту секцию"
|
||||
|
||||
msgid "Domain Resolver"
|
||||
msgstr "Резолвер доменов"
|
||||
|
||||
@ -269,9 +314,6 @@ msgstr "Введите доменные имена без протоколов,
|
||||
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
|
||||
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
|
||||
|
||||
msgid "Enter the subscription URL to fetch proxy configurations from your provider"
|
||||
msgstr "Введите URL подписки для получения конфигураций прокси от вашего провайдера"
|
||||
|
||||
msgid "Every 1 minute"
|
||||
msgstr "Каждую минуту"
|
||||
|
||||
@ -311,6 +353,9 @@ msgstr "Исключите трафик протокола NTP из туннел
|
||||
msgid "Exclude servers by keyword"
|
||||
msgstr "Исключать серверы по ключевому слову"
|
||||
|
||||
msgid "Failed to clear subscription cache"
|
||||
msgstr "Не удалось очистить кеш подписок"
|
||||
|
||||
msgid "Failed to copy!"
|
||||
msgstr "Не удалось скопировать!"
|
||||
|
||||
@ -332,6 +377,9 @@ msgstr "Глобальная проверка"
|
||||
msgid "Global Proxy"
|
||||
msgstr "Глобальный прокси"
|
||||
|
||||
msgid "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters."
|
||||
msgstr "Группировать прокси из подписки в группы URLTest. «По флагу страны» использует эмодзи флага в начале каждого имени; «По префиксу имени» группирует по первым N символам."
|
||||
|
||||
msgid "How often to automatically update the subscription"
|
||||
msgstr "Как часто автоматически обновлять подписку"
|
||||
|
||||
@ -527,12 +575,18 @@ msgstr "Последняя"
|
||||
msgid "Latest version is installed"
|
||||
msgstr "Установлена последняя версия"
|
||||
|
||||
msgid "Latest version is unknown"
|
||||
msgstr "Последняя версия неизвестна"
|
||||
msgid "Line"
|
||||
msgstr "Строка"
|
||||
|
||||
msgid "List Update Frequency"
|
||||
msgstr "Частота обновления списков"
|
||||
|
||||
msgid "List update schedule, download routing, and routing exclusions"
|
||||
msgstr "Расписание обновления списков, маршрутизация загрузок и исключения из маршрутизации"
|
||||
|
||||
msgid "Lists & Updates"
|
||||
msgstr "Списки и обновления"
|
||||
|
||||
msgid "Local Domain Lists"
|
||||
msgstr "Локальные списки доменов"
|
||||
|
||||
@ -551,6 +605,9 @@ msgstr "Основной DNS через outbound"
|
||||
msgid "Memory Usage"
|
||||
msgstr "Использование памяти"
|
||||
|
||||
msgid "Mixed proxy and DNS resolution tuning"
|
||||
msgstr "Настройка смешанного прокси и разрешения DNS"
|
||||
|
||||
msgid "Mixed Proxy Port"
|
||||
msgstr "Порт смешанного прокси"
|
||||
|
||||
@ -572,6 +629,9 @@ msgstr "NetShift обновлён, версия:"
|
||||
msgid "NetShift will not modify your DHCP configuration"
|
||||
msgstr "NetShift не будет изменять вашу конфигурацию DHCP"
|
||||
|
||||
msgid "Network"
|
||||
msgstr "Сеть"
|
||||
|
||||
msgid "Network Interface"
|
||||
msgstr "Сетевой интерфейс"
|
||||
|
||||
@ -590,8 +650,11 @@ msgstr "Не отвечает"
|
||||
msgid "Not running"
|
||||
msgstr "Не запущено"
|
||||
|
||||
msgid "Note: if your upstream DNS type is set to 'DoH', enable this only after switching to UDP or DoT."
|
||||
msgstr "Примечание: если тип вышестоящего DNS установлен в «DoH», включайте это только после переключения на UDP или DoT."
|
||||
msgid "Number of leading characters of each proxy name to group by."
|
||||
msgstr "Количество начальных символов имени каждого прокси для группировки."
|
||||
|
||||
msgid "Off"
|
||||
msgstr "Выключено"
|
||||
|
||||
msgid "Only one section can be global at a time."
|
||||
msgstr "Только одна секция может быть глобальной одновременно."
|
||||
@ -626,6 +689,12 @@ msgstr "Путь должен заканчиваться на cache.db"
|
||||
msgid "Pending"
|
||||
msgstr "Ожидает запуска"
|
||||
|
||||
msgid "Prefix length"
|
||||
msgstr "Длина префикса"
|
||||
|
||||
msgid "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."
|
||||
msgstr "Переключатели протоколов, пути к файлам и журналирование. Включайте блокировку DoH только после переключения вышестоящего DNS на UDP или DoT."
|
||||
|
||||
msgid "Proxy Configuration URL"
|
||||
msgstr "URL конфигурации прокси"
|
||||
|
||||
@ -662,6 +731,9 @@ msgstr "DNS роутера не проходит через sing-box"
|
||||
msgid "Router DNS is routed through sing-box"
|
||||
msgstr "DNS роутера проходит через sing-box"
|
||||
|
||||
msgid "Routing"
|
||||
msgstr "Маршрутизация"
|
||||
|
||||
msgid "Routing Excluded IPs"
|
||||
msgstr "Исключённые из маршрутизации IP-адреса"
|
||||
|
||||
@ -722,12 +794,6 @@ msgstr "Выберите путь к файлу конфигурации sing-bo
|
||||
msgid "Select the DNS protocol type for the domain resolver"
|
||||
msgstr "Выберите тип протокола DNS для резолвера доменов"
|
||||
|
||||
msgid "Select the list type for adding custom domains"
|
||||
msgstr "Выберите тип списка для добавления пользовательских доменов"
|
||||
|
||||
msgid "Select the list type for adding custom subnets"
|
||||
msgstr "Выберите тип списка для добавления пользовательских подсетей"
|
||||
|
||||
msgid "Select the log level for sing-box"
|
||||
msgstr "Выберите уровень логов для sing-box"
|
||||
|
||||
@ -743,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 "Не удалось обновить"
|
||||
|
||||
@ -785,6 +857,9 @@ msgstr "Сервис sing-box существует"
|
||||
msgid "Sing-box version is compatible (newer than 1.12.4)"
|
||||
msgstr "Версия Sing-box совместима (новее 1.12.4)"
|
||||
|
||||
msgid "Source and output interfaces, and Bad WAN interface monitoring"
|
||||
msgstr "Входящий и исходящий интерфейсы, а также мониторинг интерфейсов Bad WAN"
|
||||
|
||||
msgid "Source Network Interface"
|
||||
msgstr "Сетевой интерфейс источника"
|
||||
|
||||
@ -812,11 +887,23 @@ msgstr "Остановить NetShift"
|
||||
msgid "Subscription"
|
||||
msgstr "Подписка"
|
||||
|
||||
msgid "Subscription cache cleared and re-downloaded"
|
||||
msgstr "Кеш подписок очищен и загружен заново"
|
||||
|
||||
msgid "Subscription feeds, server filters and URLTest tuning"
|
||||
msgstr "Источники подписок, фильтры серверов и настройка URLTest"
|
||||
|
||||
msgid "Subscription format"
|
||||
msgstr "Формат подписки"
|
||||
|
||||
msgid "Subscription grouping"
|
||||
msgstr "Группировка подписки"
|
||||
|
||||
msgid "Subscription Update Interval"
|
||||
msgstr "Интервал обновления подписки"
|
||||
|
||||
msgid "Subscription URL"
|
||||
msgstr "URL подписки"
|
||||
msgid "Subscription URLs"
|
||||
msgstr "URL подписок"
|
||||
|
||||
msgid "Successfully copied!"
|
||||
msgstr "Успешно скопировано!"
|
||||
@ -860,9 +947,6 @@ msgstr "URL-адрес, используемый для проверки под
|
||||
msgid "This is a security trade-off: an attacker could intercept the fetch."
|
||||
msgstr "Это компромисс в безопасности: злоумышленник может перехватить загрузку."
|
||||
|
||||
msgid "This prevents applications from bypassing the router's DNS filtering by using their own encrypted DNS."
|
||||
msgstr "Это не позволяет приложениям обходить DNS-фильтрацию роутера за счёт использования собственного шифрованного DNS."
|
||||
|
||||
msgid "Time in seconds for DNS record caching (default: 60)"
|
||||
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
|
||||
|
||||
@ -908,6 +992,9 @@ msgstr "Обновление NetShift, это может занять неско
|
||||
msgid "Uplink"
|
||||
msgstr "Исходящий"
|
||||
|
||||
msgid "Upstream and bootstrap DNS resolvers, and optional DNS-over-proxy"
|
||||
msgstr "Вышестоящий и начальный (bootstrap) DNS-резолверы и опциональный DNS через прокси"
|
||||
|
||||
msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4/5://, or hysteria2://hy2://"
|
||||
msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4/5:// или hysteria2:// hy2://"
|
||||
|
||||
@ -917,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 ссылка для проверки"
|
||||
|
||||
@ -938,18 +1031,12 @@ msgstr "Используйте это только если на роутере
|
||||
msgid "Use with Exclusion sections to route specific domains directly."
|
||||
msgstr "Используйте вместе с секциями исключений для прямой маршрутизации определённых доменов."
|
||||
|
||||
msgid "User Domain List Type"
|
||||
msgstr "Тип пользовательского списка доменов"
|
||||
|
||||
msgid "User Domains"
|
||||
msgstr "Пользовательские домены"
|
||||
|
||||
msgid "User Domains List"
|
||||
msgstr "Список пользовательских доменов"
|
||||
|
||||
msgid "User Subnet List Type"
|
||||
msgstr "Тип пользовательского списка подсетей"
|
||||
|
||||
msgid "User Subnets"
|
||||
msgstr "Пользовательские подсети"
|
||||
|
||||
@ -974,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. Предыдущие варианты были удалены."
|
||||
|
||||
@ -986,14 +1076,17 @@ msgstr "Когда включено, трафик, не совпадающий
|
||||
msgid "Which proxy/VPN section carries the DNS. Leave unset to use the first configured outbound."
|
||||
msgstr "Какая секция прокси/VPN обслуживает DNS. Оставьте пустым, чтобы использовать первый настроенный outbound."
|
||||
|
||||
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 "Какой формат подписки (клиент) запрашивать первым. «Авто» использует порядок по умолчанию. Выберите «Xray JSON (Happ)», если ваша панель отдаёт некоторые узлы (например, xhttp) только под клиентом вроде Happ, или «Sing-box», чтобы предпочесть формат sing-box."
|
||||
|
||||
msgid "Xray JSON (Happ)"
|
||||
msgstr "Xray JSON (Happ)"
|
||||
|
||||
msgid "YACD Secret Key"
|
||||
msgstr "Секретный ключ YACD"
|
||||
|
||||
msgid "YACD web dashboard access and remote-access protection"
|
||||
msgstr "Доступ к веб-панели YACD и защита удалённого доступа"
|
||||
|
||||
msgid "You can select Output Network Interface, by default autodetect"
|
||||
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
|
||||
|
||||
msgid "Группировать по странам"
|
||||
msgstr "Группировать по странам"
|
||||
|
||||
msgid "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
|
||||
msgstr "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -47,12 +47,35 @@ config section 'main'
|
||||
#config section 'subscription_example'
|
||||
# option connection_type 'proxy'
|
||||
# option proxy_config_type 'subscription'
|
||||
# # subscription_url may be a single option (legacy, still supported) or a
|
||||
# # UCI list of one or more feed URLs. With multiple URLs every feed is
|
||||
# # downloaded/validated independently and all nodes are MERGED into one
|
||||
# # selector/urltest group (same-named nodes auto-deduped). A dead feed
|
||||
# # never breaks the others; the section is blocked only if ALL feeds fail.
|
||||
# option subscription_url 'https://example.com/api/sub'
|
||||
# #list subscription_url 'https://example.com/api/sub'
|
||||
# #list subscription_url 'https://another-panel.example/api/sub'
|
||||
# # Allow insecure TLS for the subscription fetch (default 0). Set to 1 to
|
||||
# # add wget --no-check-certificate for IP-host panels whose HTTPS cert is
|
||||
# # invalid/self-signed/missing-SAN. Disables certificate verification.
|
||||
# #option subscription_insecure '0'
|
||||
# # Force a specific client User-Agent for the fetch. When set, ONLY this
|
||||
# # UA is used (no probing). Leave unset to auto-probe well-known clients.
|
||||
# #option subscription_user_agent 'Happ'
|
||||
# # Preferred body FORMAT to probe first (auto|xray|singbox, default auto).
|
||||
# # Panels often serve a different config per User-Agent. 'xray' tries the
|
||||
# # Xray-JSON UAs (Happ/v2rayN) FIRST to recover xhttp nodes a panel only
|
||||
# # exposes in its Xray JSON; 'singbox' keeps the singbox/<ver> UA first;
|
||||
# # 'auto' preserves the default order. Ignored when subscription_user_agent
|
||||
# # is set (an explicit UA always wins).
|
||||
# #option subscription_format_preference 'auto'
|
||||
# option subscription_update_interval '1h'
|
||||
# # Node grouping: off | country | prefix. 'country' clusters by leading
|
||||
# # flag emoji; 'prefix' clusters by the first N codepoints of each node
|
||||
# # name (N = subscription_group_prefix_len). subscription_group_mode
|
||||
# # outranks the legacy subscription_group_by_countries boolean below.
|
||||
# #option subscription_group_mode 'off'
|
||||
# #option subscription_group_prefix_len '2'
|
||||
# #option subscription_group_by_countries '0'
|
||||
# #option urltest_check_interval '3m'
|
||||
# #option urltest_tolerance '50'
|
||||
@ -63,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'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,12 @@ TMP_RULESET_FOLDER="$TMP_SING_BOX_FOLDER/rulesets"
|
||||
TMP_SUBSCRIPTION_FOLDER="$TMP_SING_BOX_FOLDER/subscriptions"
|
||||
SUBSCRIPTION_CACHE_FOLDER="$NETSHIFT_STATE_DIR/subscriptions"
|
||||
TMP_SUBSCRIPTION_DOWNLOAD_FOLDER="$TMP_SING_BOX_FOLDER/subscription-downloads"
|
||||
# A section may list MULTIPLE subscription_url feeds. At config generation the
|
||||
# usable per-URL caches are concatenated into one merged subscription JSON in
|
||||
# this folder, then passed ONCE through the facade (keyword filter + global
|
||||
# tag-dedup + sing-box check bisection). Per-feed cache files are keyed
|
||||
# "${section}.<md5(url)>.<ext>" under SUBSCRIPTION_CACHE_FOLDER.
|
||||
TMP_SUBSCRIPTION_MERGE_FOLDER="$TMP_SING_BOX_FOLDER/subscription-merge"
|
||||
# Subscription User-Agent fallback. Many panels return a DIFFERENT body format
|
||||
# depending on the client User-Agent (sing-box JSON vs base64 URI list vs Clash
|
||||
# vs Xray JSON, or an HTML/403 stub for unknown clients). When no User-Agent is
|
||||
@ -22,15 +28,39 @@ TMP_SUBSCRIPTION_DOWNLOAD_FOLDER="$TMP_SING_BOX_FOLDER/subscription-downloads"
|
||||
# "singbox/<version>" candidate is prepended at runtime (it depends on the
|
||||
# installed sing-box). Order matters: most-likely-to-work first.
|
||||
SUBSCRIPTION_USER_AGENT_CANDIDATES="v2rayN Happ Hiddify Clash.Meta ClashMetaForAndroid"
|
||||
# Versioned client UAs that well-known panels answer with an Xray JSON body
|
||||
# (which carries xhttp/transport nodes the default sing-box JSON may omit). Used
|
||||
# by build_subscription_user_agent_candidates when a section's
|
||||
# subscription_format_preference is "xray": these UAs are probed FIRST so an
|
||||
# Xray-JSON feed is recovered before a sing-box JSON under the default UA wins.
|
||||
# Panels commonly gate their Xray branch on a "<client>/<version>" UA shape, so
|
||||
# these are VERSIONED (a bare/version-less UA can be rejected, e.g. with a 502).
|
||||
# Order matters: a versioned Happ is first (empirically yields the Xray-JSON
|
||||
# array body), then versioned v2rayN/v2rayNG forms as panel-agnostic fallbacks.
|
||||
SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES="Happ/1.0.0 v2rayN/7.0.0 v2rayNG/1.9.0"
|
||||
CLOUDFLARE_OCTETS="8.47 162.159 188.114" # Endpoints https://github.com/ampetelin/warp-endpoint-checker
|
||||
JQ_REQUIRED_VERSION="1.7.1"
|
||||
COREUTILS_BASE64_REQUIRED_VERSION="9.7"
|
||||
RT_TABLE_NAME="netshift"
|
||||
# Pidfile of the detached sing-box health monitor (task-035). The monitor is a
|
||||
# long-lived `while true` loop launched via setsid with the procd lock fd (1000)
|
||||
# closed, so it does NOT hold the procd service lock and consecutive
|
||||
# reload/restart never block on flock. The monitor writes its own pid here.
|
||||
MONITOR_PIDFILE="/var/run/netshift_monitor.pid"
|
||||
|
||||
## nft
|
||||
NFT_TABLE_NAME="NetShiftTable"
|
||||
NFT_LOCALV4_SET_NAME="localv4"
|
||||
NFT_LOCALV6_SET_NAME="localv6"
|
||||
# Destination set holding the UNION of every proxy section's proxied IPv4
|
||||
# subnets (user/local/remote/community subnet lists). Used by the prerouting
|
||||
# `mangle` chain to mark ONLY proxied destinations into the tproxy path, so
|
||||
# non-proxied traffic (e.g. a torrent to a random direct IP) never enters
|
||||
# sing-box (task-034). The per-section outbound is still selected by sing-box
|
||||
# route rules — nft only decides enter-or-not, so a single union set is enough.
|
||||
NFT_COMMON_SET_NAME="netshift_subnets"
|
||||
# IPv6 mirror of NFT_COMMON_SET_NAME (only created/used when IPv6 is enabled).
|
||||
NFT_COMMON_SET_NAME_V6="netshift_subnets_v6"
|
||||
NFT_DISCORD_SET_NAME="netshift_discord_subnets"
|
||||
NFT_INTERFACE_SET_NAME="interfaces"
|
||||
NFT_FAKEIP_MARK="0x00100000"
|
||||
@ -62,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"
|
||||
@ -97,6 +136,15 @@ SB_SERVICE_MIXED_INBOUND_ADDRESS="127.0.0.1"
|
||||
SB_SERVICE_MIXED_INBOUND_PORT=4534
|
||||
# Outbounds
|
||||
SB_DIRECT_OUTBOUND_TAG="direct-out"
|
||||
# Subscription grouping (task-044). Default codepoint count for prefix-mode
|
||||
# grouping when subscription_group_prefix_len is unset/invalid.
|
||||
SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN=2
|
||||
# Subscription grouping (task-050). Tag/label for the top-level "Fastest"
|
||||
# urltest that probes ACROSS the per-group urltests (a urltest of urltests)
|
||||
# when grouping is on. Valid UTF-8 emoji + English; deliberately distinct from
|
||||
# a per-group "<flag> Fastest" tag so the cross-group auto choice is tellable
|
||||
# apart in the dashboard. Single source for the tag (keep this file UTF-8).
|
||||
SB_SUBSCRIPTION_FASTEST_GROUP_TAG="⚡ Fastest"
|
||||
# Route
|
||||
SB_REJECT_RULE_TAG="reject-rule-tag"
|
||||
SB_EXCLUSION_RULE_TAG="exclusion-rule-tag"
|
||||
|
||||
@ -572,6 +572,58 @@ convert_crlf_to_lf() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Best-effort, in-place gzip decompression of a downloaded subscription body.
|
||||
#
|
||||
# Some panels unconditionally return a gzip-compressed HTTP body (busybox wget
|
||||
# does NOT transparently decompress and we send no Accept-Encoding), so the raw
|
||||
# bytes are binary and every downstream consumer (validate/normalize) chokes.
|
||||
# This decompresses once at download time so all consumers see text.
|
||||
#
|
||||
# Detection is attempt-based (no od/hexdump/xxd, none of which exist on device):
|
||||
# we try `gzip -dc` (busybox built-in) into a temp file and accept the result
|
||||
# ONLY if (a) gzip returned 0, (b) the result is non-empty, and (c) the result
|
||||
# is NUL-free. gzip -dc on plain-text input returns rc!=0 cleanly, so a
|
||||
# plain-text body is left byte-for-byte untouched; this can never corrupt text.
|
||||
# Modeled on convert_crlf_to_lf: mktemp -> transform -> mv on success / rm on
|
||||
# failure. Best-effort: always returns 0 (never aborts the caller).
|
||||
maybe_gunzip_subscription_file() {
|
||||
local filepath="$1"
|
||||
local tmpfile
|
||||
|
||||
[ -s "$filepath" ] || return 0
|
||||
|
||||
tmpfile=$(mktemp)
|
||||
if gzip -dc "$filepath" > "$tmpfile" 2>/dev/null &&
|
||||
[ -s "$tmpfile" ] &&
|
||||
! subscription_body_is_binary "$tmpfile"; then
|
||||
log "Decompressed gzip subscription body for '$filepath'" "debug"
|
||||
mv "$tmpfile" "$filepath"
|
||||
else
|
||||
rm -f "$tmpfile"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Returns 0 (true) if the file contains at least one NUL byte (i.e. it is
|
||||
# binary / undecodable, not text). Busybox-safe, no od/hexdump/xxd: `tr -d`
|
||||
# strips NUL bytes and we compare the resulting byte count to the original; a
|
||||
# difference means a NUL was present. All vars local.
|
||||
subscription_body_is_binary() {
|
||||
local filepath="$1"
|
||||
local raw_count stripped_count
|
||||
|
||||
[ -s "$filepath" ] || return 1
|
||||
|
||||
raw_count="$(wc -c < "$filepath" 2>/dev/null | tr -d ' ')"
|
||||
stripped_count="$(tr -d '\000' < "$filepath" 2>/dev/null | wc -c 2>/dev/null | tr -d ' ')"
|
||||
|
||||
[ -n "$raw_count" ] || raw_count=0
|
||||
[ -n "$stripped_count" ] || stripped_count=0
|
||||
|
||||
[ "$raw_count" != "$stripped_count" ]
|
||||
}
|
||||
|
||||
#######################################
|
||||
# Parses a whitespace-separated string, validates items as either domains
|
||||
# or IPv4 addresses/subnets, and returns a comma-separated string of valid items.
|
||||
@ -729,13 +781,24 @@ get_subscription_user_agent() {
|
||||
# Arguments:
|
||||
# $1 - configured User-Agent (empty for auto mode)
|
||||
# $2 - preferred User-Agent (e.g. the previously cached winner; tried early)
|
||||
# $3 - format preference: "auto" (default) | "xray" | "singbox". Reorders the
|
||||
# auto-mode candidates so the preferred FORMAT's UA is probed first; the
|
||||
# probe loop still keeps the first body that yields valid outbounds.
|
||||
# Behavior:
|
||||
# - configured non-empty: emit ONLY that value (respect the user's choice).
|
||||
# - auto: emit "singbox/<ver>", then the preferred one, then the whitelist
|
||||
# from constants (SUBSCRIPTION_USER_AGENT_CANDIDATES), skipping duplicates.
|
||||
# - configured non-empty: emit ONLY that value (respect the user's choice;
|
||||
# an explicit UA always outranks the format preference).
|
||||
# - auto/empty/unrecognised: emit "singbox/<ver>", then the preferred one,
|
||||
# then the whitelist (SUBSCRIPTION_USER_AGENT_CANDIDATES) — today's order.
|
||||
# - xray: emit the Xray-JSON UAs (SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES)
|
||||
# FIRST (outranking the cached winner + default), then "singbox/<ver>",
|
||||
# then the preferred one, then the rest of the whitelist.
|
||||
# - singbox: same as auto (singbox/<ver> first); the explicit name for the
|
||||
# current default ordering.
|
||||
# All orderings are de-duplicated with the newline "seen" set below.
|
||||
build_subscription_user_agent_candidates() {
|
||||
local configured_user_agent="${1:-}"
|
||||
local preferred_user_agent="${2:-}"
|
||||
local format_preference="${3:-}"
|
||||
local default_user_agent candidate seen
|
||||
|
||||
if [ -n "$configured_user_agent" ]; then
|
||||
@ -745,8 +808,21 @@ build_subscription_user_agent_candidates() {
|
||||
|
||||
default_user_agent="$(get_subscription_user_agent)"
|
||||
seen=""
|
||||
# shellcheck disable=SC2086 # word-splitting of the candidate list is intentional
|
||||
for candidate in "$default_user_agent" "$preferred_user_agent" $SUBSCRIPTION_USER_AGENT_CANDIDATES; do
|
||||
|
||||
# Order the auto-mode candidate stream by the requested format preference.
|
||||
# "xray" front-loads the Xray-JSON-yielding UAs (so they outrank the cached
|
||||
# winner and the default); "singbox"/"auto"/empty/unknown keep today's order
|
||||
# (default UA -> cached winner -> whitelist). Any unknown value falls through
|
||||
# to the default ordering (forward-compatible).
|
||||
if [ "$format_preference" = "xray" ]; then
|
||||
# shellcheck disable=SC2086 # word-splitting of the candidate lists is intentional
|
||||
set -- $SUBSCRIPTION_USER_AGENT_XRAY_CANDIDATES "$default_user_agent" "$preferred_user_agent" $SUBSCRIPTION_USER_AGENT_CANDIDATES
|
||||
else
|
||||
# shellcheck disable=SC2086 # word-splitting of the candidate list is intentional
|
||||
set -- "$default_user_agent" "$preferred_user_agent" $SUBSCRIPTION_USER_AGENT_CANDIDATES
|
||||
fi
|
||||
|
||||
for candidate in "$@"; do
|
||||
[ -n "$candidate" ] || continue
|
||||
# Skip a candidate already emitted. Wrap stored names in newlines so the
|
||||
# substring test matches whole entries only.
|
||||
@ -1160,17 +1236,34 @@ xray_json_to_uri_lines() {
|
||||
| (.outbounds // [])[]
|
||||
| select(type == "object")
|
||||
| select(.protocol == "vless" or .protocol == "trojan"
|
||||
or .protocol == "shadowsocks")
|
||||
or .protocol == "shadowsocks"
|
||||
or .protocol == "hysteria")
|
||||
# Skip chained / multi-hop outbounds: not representable as one URI.
|
||||
| select((.streamSettings.sockopt.dialerProxy // "") == "")
|
||||
# Hysteria here is always Hysteria2 (hysteriaSettings.version == 2);
|
||||
# the facade has no Hysteria v1 parser, so skip v1/missing-version
|
||||
# silently (no fatal). vless/trojan/shadowsocks are unaffected.
|
||||
| select(.protocol != "hysteria"
|
||||
or ((.streamSettings.hysteriaSettings.version // 0) == 2))
|
||||
| . as $ob
|
||||
| (.streamSettings // {}) as $ss
|
||||
| ($ss.network // "tcp") as $net
|
||||
# splithttp is the pre-rename name of the xhttp transport (sing-box
|
||||
# renamed it). Normalize it to xhttp so the emitted URI uses the modern
|
||||
# name and the facade xhttp branch handles it. No regex.
|
||||
| ($ss.network // "tcp") as $net_raw
|
||||
| (if $net_raw == "splithttp" then "xhttp" else $net_raw end) as $net
|
||||
# xhttp transport settings live under xhttpSettings, or the pre-rename
|
||||
# splithttpSettings alias.
|
||||
| ($ss.xhttpSettings // $ss.splithttpSettings // {}) as $xs
|
||||
| ($ss.security // "") as $sec
|
||||
| ($ss.realitySettings // {}) as $reality
|
||||
| ($ss.tlsSettings // $ss.realitySettings // {}) as $tls
|
||||
# vnext (vless/vmess) vs servers (trojan/shadowsocks) addressing.
|
||||
| ($ob.settings.vnext[0] // $ob.settings.servers[0] // {}) as $peer
|
||||
# Addressing: vnext (vless/vmess) vs servers (trojan/shadowsocks);
|
||||
# hysteria carries the peer directly in settings.address/settings.port
|
||||
# (no vnext/servers), so branch the peer derivation on protocol.
|
||||
| (if $ob.protocol == "hysteria"
|
||||
then {address: $ob.settings.address, port: $ob.settings.port}
|
||||
else ($ob.settings.vnext[0] // $ob.settings.servers[0] // {}) end) as $peer
|
||||
| ($peer.users[0] // {}) as $user
|
||||
| ($peer.address // "") as $host
|
||||
| ($peer.port // "") as $port
|
||||
@ -1196,6 +1289,18 @@ xray_json_to_uri_lines() {
|
||||
(if $sec != "" then ("security=" + ($sec)) else "security=tls" end),
|
||||
kv("sni"; ($tls.serverName // "")),
|
||||
kv("fp"; ($tls.fingerprint // "")) ]
|
||||
elif $ob.protocol == "hysteria" then
|
||||
# Hysteria2: no stream transport, so DO NOT emit type=. The
|
||||
# facade defaults security to tls for hysteria2 and reads
|
||||
# sni/insecure (via _add_outbound_security), obfs/obfs-password.
|
||||
($ss.hysteriaSettings // {}) as $hy
|
||||
| [ kv("sni"; ($tls.serverName // "")),
|
||||
(if (($tls.allowInsecure // $tls.insecure // false) == true)
|
||||
then "insecure=1" else empty end) ]
|
||||
+ (if ($hy.obfs // "") != "" then
|
||||
[ "obfs=salamander",
|
||||
kv("obfs-password"; ($hy.obfsPassword // $hy.obfs_password // "")) ]
|
||||
else [] end)
|
||||
else
|
||||
[ ("type=" + $net) ]
|
||||
end
|
||||
@ -1206,9 +1311,12 @@ xray_json_to_uri_lines() {
|
||||
[ kv("path"; ($ss.wsSettings.path // "")),
|
||||
kv("host"; ($ss.wsSettings.headers.Host // "")) ]
|
||||
elif $net == "xhttp" then
|
||||
[ kv("path"; ($ss.xhttpSettings.path // "")),
|
||||
kv("host"; ($ss.xhttpSettings.host // "")),
|
||||
kv("mode"; ($ss.xhttpSettings.mode // "")) ]
|
||||
# Accept both the modern xhttpSettings and the pre-rename
|
||||
# splithttpSettings key (network was normalized to xhttp above).
|
||||
# $xs binds to whichever settings object is present.
|
||||
[ kv("path"; ($xs.path // "")),
|
||||
kv("host"; ($xs.host // "")),
|
||||
kv("mode"; ($xs.mode // "")) ]
|
||||
elif $net == "grpc" then
|
||||
[ kv("serviceName"; ($ss.grpcSettings.serviceName // "")) ]
|
||||
else [] end
|
||||
@ -1218,12 +1326,17 @@ xray_json_to_uri_lines() {
|
||||
| ($base + $transport
|
||||
+ (if $alpn_str != "" then [ kv("alpn"; $alpn_str) ] else [] end)
|
||||
| map(select(. != null and . != ""))) as $query
|
||||
# Credential: uuid for vless, password for trojan/shadowsocks.
|
||||
# Credential: uuid for vless, hysteriaSettings.auth for hysteria,
|
||||
# password for trojan/shadowsocks.
|
||||
| (if $ob.protocol == "vless" then ($user.id // "")
|
||||
elif $ob.protocol == "hysteria" then
|
||||
($ss.hysteriaSettings.auth // "")
|
||||
else ($peer.password // $ob.settings.password // "") end) as $cred
|
||||
| select($cred != "")
|
||||
| ($ob.protocol
|
||||
| if . == "shadowsocks" then "ss" else . end) as $scheme
|
||||
| if . == "shadowsocks" then "ss"
|
||||
elif . == "hysteria" then "hysteria2"
|
||||
else . end) as $scheme
|
||||
# The connection part (no #fragment) is the dedup key: providers that
|
||||
# ship one server set across many "profiles" repeat identical nodes
|
||||
# with only the display name differing, which would otherwise inflate
|
||||
|
||||
@ -6,6 +6,24 @@ nft_create_table() {
|
||||
nft add table inet "$name"
|
||||
}
|
||||
|
||||
# Delete an nftables inet table if it exists (idempotent, fail-open).
|
||||
# create_nft_rules rebuilds the whole table from scratch, so it MUST start from
|
||||
# a clean slate: `nft add table` is idempotent but `nft add rule`/`nft add
|
||||
# chain` only ever APPEND. Without this flush, a table left behind by a previous
|
||||
# start that was not cleanly stopped (a procd respawn, an in-place package
|
||||
# upgrade, or a crash) keeps its stale rules and the freshly-added rules pile on
|
||||
# top of them. In particular a stale mark-EVERYTHING rule (from global_proxy or
|
||||
# an older mark-all build) would sit at the top of the prerouting chain and mark
|
||||
# all traffic before the new destination-selective rules are ever evaluated,
|
||||
# silently re-introducing the "everything proxied / 100% CPU" regression.
|
||||
nft_delete_table() {
|
||||
local name="$1"
|
||||
|
||||
if nft list table inet "$name" > /dev/null 2>&1; then
|
||||
nft delete table inet "$name" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Create a set within a table for storing IPv4 addresses
|
||||
nft_create_ipv4_set() {
|
||||
local table="$1"
|
||||
@ -76,3 +94,51 @@ nft_add_set_elements_from_file_chunked() {
|
||||
nft_add_set_elements "$nft_table_name" "$nft_set_name" "$array"
|
||||
fi
|
||||
}
|
||||
|
||||
# IPv6 counterpart of nft_add_set_elements_from_file_chunked. Adds only the
|
||||
# IPv6 / IPv6-CIDR lines from the file into an ipv6_addr set. A line is treated
|
||||
# as IPv6 when it contains a ':' (after trimming); anything else (IPv4, blank,
|
||||
# comment) is skipped. Fail-open: a malformed line is simply not added, so the
|
||||
# corresponding traffic goes direct rather than blackholing.
|
||||
nft_add_set_elements_from_file_chunked_v6() {
|
||||
local filepath="$1"
|
||||
local nft_table_name="$2"
|
||||
local nft_set_name="$3"
|
||||
local chunk_size="${4:-5000}"
|
||||
|
||||
local array count
|
||||
count=0
|
||||
while IFS= read -r line; do
|
||||
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
[ -z "$line" ] && continue
|
||||
|
||||
case "$line" in
|
||||
*:*) ;;
|
||||
*)
|
||||
log "'$line' is not IPv6 or IPv6 CIDR" "debug"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$array" ]; then
|
||||
array="$line"
|
||||
else
|
||||
array="$array,$line"
|
||||
fi
|
||||
|
||||
count=$((count + 1))
|
||||
|
||||
if [ "$count" = "$chunk_size" ]; then
|
||||
log "Adding $count elements to nft set $nft_set_name" "debug"
|
||||
nft_add_set_elements "$nft_table_name" "$nft_set_name" "$array"
|
||||
array=""
|
||||
count=0
|
||||
fi
|
||||
done < "$filepath"
|
||||
|
||||
if [ -n "$array" ]; then
|
||||
log "Adding $count elements to nft set $nft_set_name" "debug"
|
||||
nft_add_set_elements "$nft_table_name" "$nft_set_name" "$array"
|
||||
fi
|
||||
}
|
||||
|
||||
@ -174,7 +174,17 @@ sing_box_cf_add_proxy_outbound() {
|
||||
# Generation is gated behind sing-box-extended. On a stock sing-box build
|
||||
# we log a clear message and return the config UNCHANGED (no exit 1, no
|
||||
# outbound added) so generation degrades safely and keeps the last-good
|
||||
# config. tuic/hysteria1/anytls/shadowtls reuse this exact block.
|
||||
# config.
|
||||
#
|
||||
# Schemes this dispatcher PARSES: socks4/socks4a/socks5, vless, ss,
|
||||
# trojan, hysteria2/hy2, and vmess (vmess is extended-gated above). Any
|
||||
# OTHER scheme (tuic, hysteria v1, anytls, shadowtls, wireguard, http,
|
||||
# or a typo) is NOT parsed here: it hits the default `*)` arm below,
|
||||
# which logs a WARNING and returns the config UNCHANGED (rc 1, skip) so a
|
||||
# single bad link in a url/selector/urltest input never aborts the whole
|
||||
# config. Such schemes are reachable only via a raw `outbound_json`
|
||||
# connection (proxy_config_type=outbound) or a native sing-box-JSON
|
||||
# subscription, which bypass this URL dispatcher entirely.
|
||||
if ! is_sing_box_extended; then
|
||||
log "VMess requires sing-box-extended. Install sing-box-extended and retry." "error"
|
||||
echo "$config"
|
||||
@ -221,8 +231,17 @@ sing_box_cf_add_proxy_outbound() {
|
||||
"$vm_tls" "$vm_sni" "$vm_alpn" "$vm_fp" "$vm_server")
|
||||
;;
|
||||
*)
|
||||
log "Unsupported proxy $scheme type. Aborted." "fatal"
|
||||
exit 1
|
||||
# Unsupported scheme: downgrade from fatal to a WARNING + skip. This
|
||||
# dispatcher is shared by the single-URL, selector-loop and urltest-loop
|
||||
# callers, so a single unsupported/typo'd link must NOT abort generation
|
||||
# of the whole config. Echo the input config UNCHANGED (never empty — an
|
||||
# empty echo would wipe the caller's `config=$(...)`) and return non-zero
|
||||
# so the caller knows no outbound was added and can skip this node and
|
||||
# continue with the remaining links. The subscription normalize caller
|
||||
# already understands this non-zero "skip" contract.
|
||||
log "Unsupported proxy scheme '$scheme'; skipping this link (supported: socks/vless/ss/trojan/hysteria2/vmess)" "warn"
|
||||
echo "$config"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@ -255,8 +274,9 @@ _add_outbound_security() {
|
||||
short_id=$(url_get_query_param "$url" "sid")
|
||||
|
||||
# XHTTP transport defaults its ALPN to h2/http/1.1 when none is provided.
|
||||
# `splithttp` is the pre-rename alias of `xhttp` (see _add_outbound_transport).
|
||||
transport_type=$(url_get_query_param "$url" "type")
|
||||
if [ "$transport_type" = "xhttp" ] && [ "$alpn" = "[]" ]; then
|
||||
if { [ "$transport_type" = "xhttp" ] || [ "$transport_type" = "splithttp" ]; } && [ "$alpn" = "[]" ]; then
|
||||
alpn='["h2","http/1.1"]'
|
||||
fi
|
||||
|
||||
@ -325,7 +345,11 @@ _add_outbound_transport() {
|
||||
sing_box_cm_set_grpc_transport_for_outbound "$config" "$outbound_tag" "$grpc_service_name"
|
||||
)
|
||||
;;
|
||||
xhttp)
|
||||
xhttp | splithttp)
|
||||
# `splithttp` is the pre-rename name of the `xhttp` transport (sing-box
|
||||
# renamed it). Accept it as an alias and normalize to xhttp downstream:
|
||||
# sing_box_cm_set_xhttp_transport_for_outbound emits the modern `xhttp`
|
||||
# key, so the config sing-box sees always uses the current name.
|
||||
if ! is_sing_box_extended; then
|
||||
log "XHTTP transport requires sing-box-extended. Install sing-box-extended and retry." "error"
|
||||
echo "$config"
|
||||
|
||||
@ -1197,10 +1197,13 @@ sing_box_cm_add_selector_outbound() {
|
||||
# auto_detect_interface: boolean, enable or disable automatic interface detection
|
||||
# default_domain_resolver: string, default DNS resolver for domain-based routing
|
||||
# default_interface: string, default network interface to use when auto detection is disabled (optional)
|
||||
# default_mark: integer, routing mark stamped on sing-box's own egress
|
||||
# connections so the nft/ip-rule tproxy path does not re-capture them
|
||||
# (optional; empty -> omitted, byte-identical to the no-mark output)
|
||||
# Outputs:
|
||||
# Writes updated JSON configuration to stdout
|
||||
# Example:
|
||||
# CONFIG=$(sing_box_cm_configure_route "$CONFIG" "direct-out" true "udp-server")
|
||||
# CONFIG=$(sing_box_cm_configure_route "$CONFIG" "direct-out" true "udp-server" "" 2097152)
|
||||
#######################################
|
||||
sing_box_cm_configure_route() {
|
||||
local config="$1"
|
||||
@ -1208,12 +1211,14 @@ sing_box_cm_configure_route() {
|
||||
local auto_detect_interface="$3"
|
||||
local default_domain_resolver="$4"
|
||||
local default_interface="$5"
|
||||
local default_mark="$6"
|
||||
|
||||
echo "$config" | jq \
|
||||
--arg final "$final" \
|
||||
--argjson auto_detect_interface "$auto_detect_interface" \
|
||||
--arg default_domain_resolver "$default_domain_resolver" \
|
||||
--arg default_interface "$default_interface" \
|
||||
--arg default_mark "$default_mark" \
|
||||
'.route = {
|
||||
rules: (.route.rules // []),
|
||||
rule_set: (.route.rule_set // []),
|
||||
@ -1222,6 +1227,7 @@ sing_box_cm_configure_route() {
|
||||
default_domain_resolver: $default_domain_resolver
|
||||
}
|
||||
+ (if $default_interface != "" then { default_interface: $default_interface } else {} end)
|
||||
+ (if $default_mark != "" then { default_mark: ($default_mark | tonumber) } else {} end)
|
||||
'
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,42 @@ updates_log() {
|
||||
log "Updater: $message" "$level"
|
||||
}
|
||||
|
||||
# Verify that a backup copy is byte-complete, guarding the core-swap rollback
|
||||
# against a TRUNCATED backup written under tmpfs ENOSPC (busybox `cp` does not
|
||||
# reliably return non-zero on a partial write). Returns 0 iff $dst exists and
|
||||
# its byte size equals $src's size. A size match is sufficient here: this is a
|
||||
# same-machine copy of the same file and we are guarding truncation, not bit-rot
|
||||
# — do NOT md5/sha a ~40 MB binary on a slow armv7 router. If $src is absent
|
||||
# there is nothing to back up, so verification trivially succeeds (0).
|
||||
updates_verify_copy() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
local ssz dsz
|
||||
|
||||
[ -f "$src" ] || return 0
|
||||
[ -f "$dst" ] || return 1
|
||||
ssz="$(wc -c < "$src" 2>/dev/null)" || return 1
|
||||
dsz="$(wc -c < "$dst" 2>/dev/null)" || return 1
|
||||
[ -n "$ssz" ] && [ "$ssz" = "$dsz" ]
|
||||
}
|
||||
|
||||
# Verify that a stashed backup is still byte-complete BEFORE a rollback restores
|
||||
# it over the live path. Compares the backup's current byte size against the
|
||||
# expected source size recorded at backup time. Returns 0 iff $backup exists and
|
||||
# its size equals $expected_size. Refusing to restore a truncated backup is
|
||||
# safer than installing a segfaulting core as the "safe" fallback.
|
||||
updates_backup_is_complete() {
|
||||
local backup="$1"
|
||||
local expected_size="$2"
|
||||
local bsz
|
||||
|
||||
[ -n "$backup" ] || return 1
|
||||
[ -f "$backup" ] || return 1
|
||||
[ -n "$expected_size" ] || return 1
|
||||
bsz="$(wc -c < "$backup" 2>/dev/null)" || return 1
|
||||
[ -n "$bsz" ] && [ "$bsz" = "$expected_size" ]
|
||||
}
|
||||
|
||||
# ── Async component-action job state (jq, atomic) ───────────────────
|
||||
#
|
||||
# The UI starts long-running component actions (e.g. switching the sing-box
|
||||
@ -918,6 +954,7 @@ _updates_install_sing_box_extended_core() {
|
||||
local tmp_dir archive releases tag rel asset_url
|
||||
local binary_path cronet_path
|
||||
local backup_binary="" backup_cronet="" new_version
|
||||
local backup_binary_size="" backup_cronet_size=""
|
||||
|
||||
# Interruption-tolerant heal: a run killed mid-flight (e.g. the old rpcd 30s
|
||||
# timeout) could leave a non-executable /usr/bin/sing-box behind. Such a
|
||||
@ -996,21 +1033,29 @@ _updates_install_sing_box_extended_core() {
|
||||
# no room for a second copy of the binary.
|
||||
if [ -e /usr/bin/sing-box ]; then
|
||||
backup_binary="$tmp_dir/sing-box.backup"
|
||||
if ! cp -p /usr/bin/sing-box "$backup_binary" 2>/dev/null; then
|
||||
# Gate on a byte-complete backup, not just cp's exit code: a partial
|
||||
# write under tmpfs ENOSPC could otherwise pass and later be restored as
|
||||
# a truncated, segfaulting "safe" fallback. Abort here — the live binary
|
||||
# has NOT been touched yet, so the working core is left intact.
|
||||
if ! cp -p /usr/bin/sing-box "$backup_binary" 2>/dev/null ||
|
||||
! updates_verify_copy /usr/bin/sing-box "$backup_binary"; then
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Failed to backup current sing-box binary" "error"
|
||||
echo "{\"success\":false,\"message\":\"Failed to backup current sing-box binary\"}"
|
||||
return 1
|
||||
fi
|
||||
backup_binary_size="$(wc -c < "$backup_binary" 2>/dev/null)"
|
||||
fi
|
||||
if [ -n "$cronet_path" ] && [ -e /usr/lib/libcronet.so ]; then
|
||||
backup_cronet="$tmp_dir/libcronet.so.backup"
|
||||
if ! cp -p /usr/lib/libcronet.so "$backup_cronet" 2>/dev/null; then
|
||||
if ! cp -p /usr/lib/libcronet.so "$backup_cronet" 2>/dev/null ||
|
||||
! updates_verify_copy /usr/lib/libcronet.so "$backup_cronet"; then
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Failed to backup current libcronet.so" "error"
|
||||
echo "{\"success\":false,\"message\":\"Failed to backup current libcronet.so\"}"
|
||||
return 1
|
||||
fi
|
||||
backup_cronet_size="$(wc -c < "$backup_cronet" 2>/dev/null)"
|
||||
fi
|
||||
|
||||
# Free overlay space by removing the live binary BEFORE extracting, then
|
||||
@ -1019,7 +1064,16 @@ _updates_install_sing_box_extended_core() {
|
||||
rm -f /usr/bin/sing-box
|
||||
if ! tar -xzf "$archive" -O "$binary_path" > /usr/bin/sing-box 2>/dev/null || [ ! -s /usr/bin/sing-box ]; then
|
||||
rm -f /usr/bin/sing-box
|
||||
[ -n "$backup_binary" ] && mv -f "$backup_binary" /usr/bin/sing-box
|
||||
# Only restore from a backup that is still byte-complete — restoring a
|
||||
# truncated backup would install a segfaulting core as the "safe"
|
||||
# fallback (worse than leaving the path absent).
|
||||
if [ -n "$backup_binary" ]; then
|
||||
if updates_backup_is_complete "$backup_binary" "$backup_binary_size"; then
|
||||
mv -f "$backup_binary" /usr/bin/sing-box
|
||||
else
|
||||
updates_log "Rollback: sing-box backup is corrupt/incomplete; NOT restoring to avoid installing a broken core" "error"
|
||||
fi
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Failed to extract sing-box-extended binary (out of space on overlay?)" "error"
|
||||
echo "{\"success\":false,\"message\":\"Failed to extract sing-box-extended binary (not enough free space on the router?)\"}"
|
||||
@ -1031,8 +1085,20 @@ _updates_install_sing_box_extended_core() {
|
||||
rm -f /usr/lib/libcronet.so
|
||||
if ! tar -xzf "$archive" -O "$cronet_path" > /usr/lib/libcronet.so 2>/dev/null || [ ! -s /usr/lib/libcronet.so ]; then
|
||||
rm -f /usr/bin/sing-box /usr/lib/libcronet.so
|
||||
[ -n "$backup_binary" ] && mv -f "$backup_binary" /usr/bin/sing-box
|
||||
[ -n "$backup_cronet" ] && mv -f "$backup_cronet" /usr/lib/libcronet.so
|
||||
if [ -n "$backup_binary" ]; then
|
||||
if updates_backup_is_complete "$backup_binary" "$backup_binary_size"; then
|
||||
mv -f "$backup_binary" /usr/bin/sing-box
|
||||
else
|
||||
updates_log "Rollback: sing-box backup is corrupt/incomplete; NOT restoring to avoid installing a broken core" "error"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$backup_cronet" ]; then
|
||||
if updates_backup_is_complete "$backup_cronet" "$backup_cronet_size"; then
|
||||
mv -f "$backup_cronet" /usr/lib/libcronet.so
|
||||
else
|
||||
updates_log "Rollback: libcronet.so backup is corrupt/incomplete; NOT restoring" "error"
|
||||
fi
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Failed to extract libcronet.so" "error"
|
||||
echo "{\"success\":false,\"message\":\"Failed to extract libcronet.so\"}"
|
||||
@ -1049,9 +1115,21 @@ _updates_install_sing_box_extended_core() {
|
||||
*extended*) ;;
|
||||
*)
|
||||
rm -f /usr/bin/sing-box
|
||||
[ -n "$backup_binary" ] && mv -f "$backup_binary" /usr/bin/sing-box
|
||||
if [ -n "$backup_binary" ]; then
|
||||
if updates_backup_is_complete "$backup_binary" "$backup_binary_size"; then
|
||||
mv -f "$backup_binary" /usr/bin/sing-box
|
||||
else
|
||||
updates_log "Rollback: sing-box backup is corrupt/incomplete; NOT restoring to avoid installing a broken core" "error"
|
||||
fi
|
||||
fi
|
||||
[ -n "$cronet_path" ] && rm -f /usr/lib/libcronet.so
|
||||
[ -n "$backup_cronet" ] && mv -f "$backup_cronet" /usr/lib/libcronet.so
|
||||
if [ -n "$backup_cronet" ]; then
|
||||
if updates_backup_is_complete "$backup_cronet" "$backup_cronet_size"; then
|
||||
mv -f "$backup_cronet" /usr/lib/libcronet.so
|
||||
else
|
||||
updates_log "Rollback: libcronet.so backup is corrupt/incomplete; NOT restoring" "error"
|
||||
fi
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Installed sing-box failed extended validation; previous binary restored" "error"
|
||||
echo "{\"success\":false,\"message\":\"Installed sing-box failed extended validation; previous binary restored\"}"
|
||||
@ -1122,6 +1200,7 @@ updates_install_sing_box_stable() {
|
||||
_updates_install_sing_box_stable_core() {
|
||||
local new_version installed=1
|
||||
local tmp_dir backup_binary="" backup_cronet=""
|
||||
local backup_binary_size="" backup_cronet_size=""
|
||||
|
||||
# Remove stale temp dirs from an interrupted earlier run (tmpfs is small).
|
||||
rm -rf /tmp/netshift-sbstable.* 2>/dev/null
|
||||
@ -1137,21 +1216,29 @@ _updates_install_sing_box_stable_core() {
|
||||
# touches anything, so a failed install can be rolled back to a working core.
|
||||
if [ -e "$UPDATES_SING_BOX_BIN" ]; then
|
||||
backup_binary="$tmp_dir/sing-box.backup"
|
||||
if ! cp -p "$UPDATES_SING_BOX_BIN" "$backup_binary" 2>/dev/null; then
|
||||
# Gate on a byte-complete backup, not just cp's exit code (busybox cp can
|
||||
# truncate under tmpfs ENOSPC and still return 0). Abort here — the
|
||||
# package manager has not touched the binary yet, so the working core
|
||||
# stays intact.
|
||||
if ! cp -p "$UPDATES_SING_BOX_BIN" "$backup_binary" 2>/dev/null ||
|
||||
! updates_verify_copy "$UPDATES_SING_BOX_BIN" "$backup_binary"; then
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Failed to backup current sing-box binary" "error"
|
||||
echo "{\"success\":false,\"message\":\"Failed to backup current sing-box binary\"}"
|
||||
return 1
|
||||
fi
|
||||
backup_binary_size="$(wc -c < "$backup_binary" 2>/dev/null)"
|
||||
fi
|
||||
if [ -e "$UPDATES_LIBCRONET_LIB" ]; then
|
||||
backup_cronet="$tmp_dir/libcronet.so.backup"
|
||||
if ! cp -p "$UPDATES_LIBCRONET_LIB" "$backup_cronet" 2>/dev/null; then
|
||||
if ! cp -p "$UPDATES_LIBCRONET_LIB" "$backup_cronet" 2>/dev/null ||
|
||||
! updates_verify_copy "$UPDATES_LIBCRONET_LIB" "$backup_cronet"; then
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Failed to backup current libcronet.so" "error"
|
||||
echo "{\"success\":false,\"message\":\"Failed to backup current libcronet.so\"}"
|
||||
return 1
|
||||
fi
|
||||
backup_cronet_size="$(wc -c < "$backup_cronet" 2>/dev/null)"
|
||||
fi
|
||||
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
@ -1179,7 +1266,7 @@ _updates_install_sing_box_stable_core() {
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
# Package install failed (it may have already removed/half-replaced the
|
||||
# binary). Restore the tmpfs backup so a working core remains.
|
||||
updates_stable_rollback "$backup_binary" "$backup_cronet"
|
||||
updates_stable_rollback "$backup_binary" "$backup_cronet" "$backup_binary_size" "$backup_cronet_size"
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Failed to install stable sing-box via package manager; previous binary restored" "error"
|
||||
echo "{\"success\":false,\"message\":\"Failed to install stable sing-box (package manager error); previous binary restored\"}"
|
||||
@ -1193,7 +1280,7 @@ _updates_install_sing_box_stable_core() {
|
||||
# longer be an "extended" build. If it still is, the install did not land —
|
||||
# restore the backup so the router keeps a known-good core.
|
||||
if is_sing_box_extended "$new_version"; then
|
||||
updates_stable_rollback "$backup_binary" "$backup_cronet"
|
||||
updates_stable_rollback "$backup_binary" "$backup_cronet" "$backup_binary_size" "$backup_cronet_size"
|
||||
rm -rf "$tmp_dir"
|
||||
updates_log "Stable install reported success but sing-box is still extended ($new_version); previous binary restored" "error"
|
||||
echo "{\"success\":false,\"message\":\"sing-box is still the extended build after install; rollback did not take effect (previous binary restored)\"}"
|
||||
@ -1217,25 +1304,42 @@ _updates_install_sing_box_stable_core() {
|
||||
# Restores the tmpfs backup of /usr/bin/sing-box (and libcronet.so) into place.
|
||||
# Used by the stable path when the package install or validation fails so the
|
||||
# router never ends core-less. Best-effort; logs the outcome.
|
||||
#
|
||||
# Args 3/4 are the byte sizes recorded at backup time. The restore is performed
|
||||
# ONLY if the backup is still byte-complete (size match) — a truncated backup
|
||||
# (tmpfs ENOSPC) is refused rather than restored as a segfaulting core.
|
||||
updates_stable_rollback() {
|
||||
local backup_binary="$1"
|
||||
local backup_cronet="$2"
|
||||
local backup_binary_size="$3"
|
||||
local backup_cronet_size="$4"
|
||||
|
||||
if [ -n "$backup_binary" ] && [ -e "$backup_binary" ]; then
|
||||
rm -f "$UPDATES_SING_BOX_BIN" 2>/dev/null
|
||||
if mv -f "$backup_binary" "$UPDATES_SING_BOX_BIN" 2>/dev/null; then
|
||||
chmod 0755 "$UPDATES_SING_BOX_BIN" 2>/dev/null || true
|
||||
updates_log "Rollback: restored previous sing-box binary from tmpfs backup"
|
||||
if [ -n "$backup_binary" ]; then
|
||||
# Only restore a byte-complete backup: a truncated backup (tmpfs ENOSPC)
|
||||
# would otherwise be installed as a segfaulting "safe" core, which is
|
||||
# worse than not restoring. Surface a loud error and leave the live path.
|
||||
if updates_backup_is_complete "$backup_binary" "$backup_binary_size"; then
|
||||
rm -f "$UPDATES_SING_BOX_BIN" 2>/dev/null
|
||||
if mv -f "$backup_binary" "$UPDATES_SING_BOX_BIN" 2>/dev/null; then
|
||||
chmod 0755 "$UPDATES_SING_BOX_BIN" 2>/dev/null || true
|
||||
updates_log "Rollback: restored previous sing-box binary from tmpfs backup"
|
||||
else
|
||||
updates_log "Rollback: FAILED to restore sing-box binary from backup" "error"
|
||||
fi
|
||||
else
|
||||
updates_log "Rollback: FAILED to restore sing-box binary from backup" "error"
|
||||
updates_log "Rollback: sing-box backup is corrupt/incomplete; NOT restoring to avoid installing a broken core" "error"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$backup_cronet" ] && [ -e "$backup_cronet" ]; then
|
||||
rm -f "$UPDATES_LIBCRONET_LIB" 2>/dev/null
|
||||
if mv -f "$backup_cronet" "$UPDATES_LIBCRONET_LIB" 2>/dev/null; then
|
||||
chmod 0644 "$UPDATES_LIBCRONET_LIB" 2>/dev/null || true
|
||||
updates_log "Rollback: restored previous libcronet.so from tmpfs backup"
|
||||
if [ -n "$backup_cronet" ]; then
|
||||
if updates_backup_is_complete "$backup_cronet" "$backup_cronet_size"; then
|
||||
rm -f "$UPDATES_LIBCRONET_LIB" 2>/dev/null
|
||||
if mv -f "$backup_cronet" "$UPDATES_LIBCRONET_LIB" 2>/dev/null; then
|
||||
chmod 0644 "$UPDATES_LIBCRONET_LIB" 2>/dev/null || true
|
||||
updates_log "Rollback: restored previous libcronet.so from tmpfs backup"
|
||||
fi
|
||||
else
|
||||
updates_log "Rollback: libcronet.so backup is corrupt/incomplete; NOT restoring" "error"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@ -1303,7 +1407,15 @@ updates_pkg_install_file() {
|
||||
if updates_pkg_is_apk; then
|
||||
apk add --allow-untrusted "$pkg_file" </dev/null >/dev/null 2>&1
|
||||
else
|
||||
opkg install "$pkg_file" </dev/null >/dev/null 2>&1
|
||||
# --force-downgrade: a legacy v-prefixed build (e.g. v0.8.6) sorts ABOVE
|
||||
# the no-v target (0.8.7) in opkg's dpkg-style compare, so a plain
|
||||
# `opkg install` returns rc=0 and refuses ("Not downgrading ..."). The
|
||||
# flag forces the v->no-v transition to actually land.
|
||||
# --force-reinstall: covers the "already installed at this exact version"
|
||||
# no-op. opkg rc is NOT a reliable success signal either way — the
|
||||
# verify-after-install belt in _updates_self_update_netshift_core is the
|
||||
# authoritative check.
|
||||
opkg install --force-downgrade --force-reinstall "$pkg_file" </dev/null >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
@ -1343,6 +1455,32 @@ updates_pkg_candidate_version() {
|
||||
printf '%s' "$version"
|
||||
}
|
||||
|
||||
# Echoes the INSTALLED version of a package (what is on the system right now),
|
||||
# or nothing if the package is not installed. Distinct from
|
||||
# updates_pkg_candidate_version (that reads the FEED candidate). Parsed with
|
||||
# grep/awk only — NEVER Oniguruma jq. Mirrors updates_pkg_is_installed.
|
||||
# opkg list-installed -> "<name> - <version>" (field after " - ")
|
||||
# apk list --installed <pkg> -> "<name>-<version> <arch> {...} ..." (strip "<name>-")
|
||||
updates_pkg_installed_version() {
|
||||
local pkg_name="$1"
|
||||
local line version=""
|
||||
|
||||
if updates_pkg_is_apk; then
|
||||
# First installed-list token is "<name>-<version>"; strip the leading
|
||||
# "<pkg>-" so only the version (e.g. "0.8.7-r1") remains.
|
||||
line="$(apk list --installed "$pkg_name" 2>/dev/null | awk '{print $1}' | head -n1)"
|
||||
case "$line" in
|
||||
"$pkg_name"-*) version="${line#"$pkg_name"-}" ;;
|
||||
esac
|
||||
else
|
||||
# opkg list-installed prints "<name> - <version>"; take the field after
|
||||
# " - " for the exact package name.
|
||||
version="$(opkg list-installed 2>/dev/null | grep "^${pkg_name} " | head -n1 | awk -F' - ' '{print $2}')"
|
||||
fi
|
||||
|
||||
printf '%s' "$version"
|
||||
}
|
||||
|
||||
# Checks whether a newer STOCK (stable) sing-box is available via the system
|
||||
# package manager. SYNC (quick call → stays on the synchronous component_action
|
||||
# path). Graceful on an unreachable feed / parse failure: echoes
|
||||
@ -1392,6 +1530,65 @@ updates_check_sing_box_stable() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Checks whether a newer NetShift release is available on GitHub. ON-DEMAND
|
||||
# (the "Check for updates" button) — mirrors the sing-box cores so get_system_info
|
||||
# never touches the network. SYNC (quick call → component_action path). Graceful
|
||||
# on an unreachable/rate-limited API: echoes {"success":false,"message":"..."}
|
||||
# and returns non-zero. NEVER exits (runs via component_action → JSON + rc).
|
||||
#
|
||||
# Output (mirrors updates_check_sing_box_stable):
|
||||
# {"success":true,"current_version":"...","latest_version":"...",
|
||||
# "status":"latest"|"outdated"}
|
||||
#
|
||||
# v-normalization: a single leading "v" is stripped from BOTH the installed
|
||||
# version and the GitHub tag before comparing (task-028 dropped the v from the
|
||||
# build, but a v-tagged release would still break a raw compare). The compare is
|
||||
# on the leading semver (drop any "-..." suffix) via the same sort -V based
|
||||
# is_min_package_version the cores use.
|
||||
updates_check_netshift() {
|
||||
local current_version latest cur_norm latest_norm cur_semver latest_semver status
|
||||
|
||||
current_version="$NETSHIFT_VERSION"
|
||||
|
||||
# Dev/unstamped build: the placeholder __COMPILED_VERSION_VARIABLE__ contains
|
||||
# "COMPILED" and is not a real semver. Report it honestly as "latest" (a dev
|
||||
# build is never "outdated"; the UI also guards dev separately) and still fetch
|
||||
# the real latest tag for display.
|
||||
case "$current_version" in
|
||||
*COMPILED*)
|
||||
latest="$(updates_netshift_latest_tag)"
|
||||
if [ -z "$latest" ]; then
|
||||
echo "{\"success\":false,\"message\":\"Could not determine the latest NetShift release (GitHub API unreachable or rate-limited)\"}"
|
||||
return 1
|
||||
fi
|
||||
echo "{\"success\":true,\"current_version\":\"$current_version\",\"latest_version\":\"$latest\",\"status\":\"latest\"}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
latest="$(updates_netshift_latest_tag)"
|
||||
if [ -z "$latest" ]; then
|
||||
echo "{\"success\":false,\"message\":\"Could not determine the latest NetShift release (GitHub API unreachable or rate-limited)\"}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Strip a single leading "v" from both sides (no-op if absent), then compare
|
||||
# on the leading semver only.
|
||||
cur_norm="${current_version#v}"
|
||||
latest_norm="${latest#v}"
|
||||
cur_semver="${cur_norm%%-*}"
|
||||
latest_semver="${latest_norm%%-*}"
|
||||
|
||||
if is_min_package_version "$cur_semver" "$latest_semver"; then
|
||||
status="latest"
|
||||
else
|
||||
status="outdated"
|
||||
fi
|
||||
|
||||
echo "{\"success\":true,\"current_version\":\"$current_version\",\"latest_version\":\"$latest\",\"status\":\"$status\"}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── NetShift self-update (Component Manager, task-017) ──────────────
|
||||
#
|
||||
# Variant A: a targeted package upgrade (download the release .ipk/.apk from
|
||||
@ -1429,38 +1626,124 @@ updates_self_update_netshift() {
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# Echoes the GitHub latest-release tag for NetShift (e.g. "v0.8.1"), or nothing.
|
||||
# Reuses the same API endpoint as get_system_info / install.sh; parsed with
|
||||
# grep/cut (the tag is needed only as a display/compare string, no jq array).
|
||||
updates_netshift_latest_tag() {
|
||||
local response
|
||||
# Resolve a URL's HTTP redirect target via curl WITHOUT hitting the rate-limited
|
||||
# API or downloading the body. Echoes the redirect URL (empty if curl absent or
|
||||
# no redirect). Stubbable in tests.
|
||||
updates_github_resolve_redirect() {
|
||||
local url="$1"
|
||||
command -v curl >/dev/null 2>&1 || return 1
|
||||
curl -sI -o /dev/null -w '%{redirect_url}' --connect-timeout 5 -m 15 -A 'netshift-updater' "$url" 2>/dev/null
|
||||
}
|
||||
|
||||
# Echoes the GitHub latest-release tag for NetShift (e.g. "0.8.8"), or nothing.
|
||||
# PRIMARY: resolve the 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
|
||||
fi
|
||||
printf '%s' "$response" | grep '"tag_name":' | head -n1 | cut -d'"' -f4
|
||||
|
||||
tag="$(printf '%s' "$response" | jq -r '.tag_name // empty' 2>/dev/null)"
|
||||
[ -n "$tag" ] || return 1
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
# Downloads the NetShift release assets matching the package-name prefixes for
|
||||
# the active package manager into $dir. Echoes nothing; returns 0 if at least
|
||||
# the core "netshift" package was downloaded, non-zero otherwise. The asset URL
|
||||
# list comes from the same latest-release JSON, filtered to .ipk or .apk by the
|
||||
# package manager (busybox grep -o, no jq array walk required).
|
||||
# Echo the deterministic release asset filename for a package + tag + ext.
|
||||
# ipk core/luci carry "-r1-all"; apk core/luci carry "-r1"; the i18n package
|
||||
# carries neither suffix (just "<pkg>-<tag>.<ext>"). Single source of the asset
|
||||
# naming pattern so it lives in one place, not scattered.
|
||||
updates_netshift_asset_filename() {
|
||||
local pkg="$1" tag="$2" ext="$3"
|
||||
case "$pkg" in
|
||||
"$UPDATES_NETSHIFT_PKG_I18N_RU") printf '%s-%s.%s' "$pkg" "$tag" "$ext" ;;
|
||||
*)
|
||||
if [ "$ext" = "ipk" ]; then
|
||||
printf '%s-%s-r1-all.%s' "$pkg" "$tag" "$ext"
|
||||
else
|
||||
printf '%s-%s-r1.%s' "$pkg" "$tag" "$ext"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Downloads the NetShift release assets for the active package manager into $dir.
|
||||
# Echoes nothing; returns 0 if at least the core "netshift" package was
|
||||
# downloaded, non-zero otherwise.
|
||||
# PRIMARY: resolve the latest tag (redirect-based, rate-limit-free) and build the
|
||||
# deterministic github.com/<repo>/releases/download/<tag>/<asset> URLs — the
|
||||
# CDN 302 is followed by updates_download_to_file (curl -L / wget both follow it).
|
||||
# FALLBACK: if the tag can't be resolved, scrape the api.github.com release JSON
|
||||
# for .ipk/.apk URLs (busybox grep -o) as before, so a curl-less box still works.
|
||||
_updates_self_update_download_assets() {
|
||||
local dir="$1"
|
||||
local response ext pattern url filename dest attempt got_core=0
|
||||
|
||||
response="$(updates_http_get_once "$NETSHIFT_RELEASE_API_URL" "")"
|
||||
if [ -z "$response" ]; then
|
||||
return 1
|
||||
fi
|
||||
local tag pkg
|
||||
|
||||
if updates_pkg_is_apk; then
|
||||
ext="apk"
|
||||
else
|
||||
ext="ipk"
|
||||
fi
|
||||
|
||||
tag="$(updates_netshift_latest_tag)"
|
||||
if [ -n "$tag" ]; then
|
||||
# Direct deterministic asset URLs (no API). Core + luci always; the RU
|
||||
# i18n package only if already installed.
|
||||
for pkg in "$UPDATES_NETSHIFT_PKG_CORE" "$UPDATES_NETSHIFT_PKG_LUCI" "$UPDATES_NETSHIFT_PKG_I18N_RU"; do
|
||||
if [ "$pkg" = "$UPDATES_NETSHIFT_PKG_I18N_RU" ]; then
|
||||
updates_pkg_is_installed "$UPDATES_NETSHIFT_PKG_I18N_RU" || continue
|
||||
fi
|
||||
filename="$(updates_netshift_asset_filename "$pkg" "$tag" "$ext")"
|
||||
url="$NETSHIFT_REPO_RELEASES_DOWNLOAD_BASE/$tag/$filename"
|
||||
dest="$dir/$filename"
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 3 ]; do
|
||||
if updates_download_to_file "$url" "$dest"; then
|
||||
break
|
||||
fi
|
||||
rm -f "$dest" 2>/dev/null
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
if [ "$pkg" = "$UPDATES_NETSHIFT_PKG_CORE" ] && [ -s "$dest" ]; then
|
||||
got_core=1
|
||||
fi
|
||||
done
|
||||
[ "$got_core" -eq 1 ]
|
||||
return $?
|
||||
fi
|
||||
|
||||
# FALLBACK: scrape the API release JSON for direct asset URLs.
|
||||
response="$(updates_http_get_once "$NETSHIFT_RELEASE_API_URL" "")"
|
||||
if [ -z "$response" ]; then
|
||||
return 1
|
||||
fi
|
||||
pattern="https://[^\"[:space:]]*\.${ext}"
|
||||
|
||||
# Iterate the matching browser_download_url values. Only keep assets whose
|
||||
@ -1500,6 +1783,7 @@ _updates_self_update_download_assets() {
|
||||
# failure so the wrapper still runs the restore epilogue).
|
||||
_updates_self_update_netshift_core() {
|
||||
local installed latest pkg file_path candidate_file
|
||||
local core_installed core_installed_semver latest_semver
|
||||
local backup_made=0
|
||||
|
||||
installed="$NETSHIFT_VERSION"
|
||||
@ -1578,6 +1862,31 @@ _updates_self_update_netshift_core() {
|
||||
fi
|
||||
done
|
||||
|
||||
# Verify-after-install for the CORE package (authoritative success signal).
|
||||
# opkg returns rc=0 for "already installed"/"up to date"/"Not downgrading",
|
||||
# so the install rc above is NOT trustworthy. RE-READ the installed version
|
||||
# and confirm it actually became the target before declaring success. apk's
|
||||
# equal-version no-overwrite quirk is caught by this same belt.
|
||||
core_installed="$(updates_pkg_installed_version "$UPDATES_NETSHIFT_PKG_CORE")"
|
||||
# Normalize with the SAME rules the version-decision uses: drop a leading "v"
|
||||
# and any "-rN"/"-suffix" so we compare semver-to-semver.
|
||||
core_installed_semver="${core_installed#v}"
|
||||
core_installed_semver="${core_installed_semver%%-*}"
|
||||
latest_semver="${latest#v}"
|
||||
latest_semver="${latest_semver%%-*}"
|
||||
|
||||
# The install took iff the installed semver equals the target, OR the
|
||||
# installed semver is now >= the target (is_min_package_version current
|
||||
# required → 0 when current >= required).
|
||||
if [ "$core_installed_semver" != "$latest_semver" ] \
|
||||
&& ! is_min_package_version "$core_installed_semver" "$latest_semver"; then
|
||||
_updates_self_update_restore_config "$backup_made"
|
||||
rm -rf "$UPDATES_NETSHIFT_DOWNLOAD_DIR" 2>/dev/null
|
||||
updates_log "Self-update: core package version did not change after install (package manager reported success but no upgrade occurred)" "error"
|
||||
echo '{"success":false,"message":"NetShift core package did not upgrade (package manager refused or no-op); configuration preserved"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Defensive: if the config got clobbered/emptied, restore from the backup.
|
||||
_updates_self_update_restore_config "$backup_made"
|
||||
|
||||
@ -1626,9 +1935,21 @@ component_action() {
|
||||
sing_box:check_update_stable)
|
||||
updates_check_sing_box_stable
|
||||
;;
|
||||
netshift:check_update)
|
||||
updates_check_netshift
|
||||
;;
|
||||
netshift:self_update)
|
||||
updates_self_update_netshift
|
||||
;;
|
||||
subscription:clear_cache)
|
||||
# Worker lives in bin/netshift (where subscription_update + the cache-path
|
||||
# builders + SUBSCRIPTION_CACHE_FOLDER are in scope). updater.sh is sourced
|
||||
# by bin/netshift, and the async fork re-execs "$0" component_action ...,
|
||||
# so the function is always defined when this arm dispatches. Reachable via
|
||||
# BOTH the sync `component_action subscription clear_cache` and the async
|
||||
# component_action_async/component_action_status paths.
|
||||
subscription_clear_cache_and_redownload
|
||||
;;
|
||||
*)
|
||||
echo '{"success":false,"message":"Unknown component action"}'
|
||||
return 1
|
||||
|
||||
@ -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, diagnostics, subscription, insecure, rejected,
|
||||
# jobstate, selfheal, dnsdetour, globalproxy, stablecheck,
|
||||
# extcheck, selfupdate
|
||||
# nftv6, selmark, isolation, monfd, unsupported, textlist, diagnostics, subscription, fastest, insecure, rejected,
|
||||
# jobstate, selfheal, dnsdetour, suburlopt, globalproxy, stablecheck,
|
||||
# extcheck, netshiftcheck, latesttag, ghredirect, selfupdate,
|
||||
# backupguard
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
|
||||
3486
tests/entrypoint.sh
3486
tests/entrypoint.sh
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user