9 Commits
0.8.5 ... 0.8.6

Author SHA1 Message Date
eb9cedb10a добавлена поддержка небезопасных подписок
Some checks failed
Build packages / OpenWrt rootfs smoke tests (push) Has been cancelled
Build packages / Setup build version (push) Has been cancelled
Build packages / Builder for apk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Builder for ipk netshift and luci-app-netshift (push) Has been cancelled
Build packages / Create Release (push) Has been cancelled
2026-06-07 01:16:42 +03:00
c860bf195c Убрал неактуальную проверку mangle output из диагностики 2026-06-07 00:33:07 +03:00
cbdc714ab8 фикс проверки версии extended ядра 2026-06-07 00:15:59 +03:00
f92408b136 Доп вкладка для управления компонентами и обновлениями 2026-06-06 22:44:52 +03:00
d391e32f4f Фиксы багов 2026-06-06 21:24:37 +03:00
343f8cc1cd Merge pull request #11 from spgsroot/sync/netshift-port
Синхронизация с netshift
2026-06-06 17:48:02 +03:00
03806d7b10 добавил ipv6 и doh-блокировку 2026-06-06 19:33:48 +08:00
7c7f7b15a0 новая фича: DNS через прокси 2026-06-05 22:47:05 +03:00
7783f3c27d пофиксил диагностику для extended ядра 2026-06-05 21:11:20 +03:00
52 changed files with 7717 additions and 1718 deletions

3
.gitignore vendored
View File

@ -13,4 +13,5 @@ yarn-debug.log*
yarn-error.log*
fe-app-netshift/.yarn/
fe-app-netshift/.yarnrc.yml
fe-app-netshift/.pnp.*
fe-app-netshift/.pnp.*
agent/

View File

@ -104,8 +104,8 @@ save+`sing-box check` -> cron jobs -> start sing-box -> dnsmasq_configure ->
`sing-box version` fine WITHOUT LD_LIBRARY_PATH (libcronet only needed at
runtime for naive); `chmod 0755` itself works under umask 0077. The code's
chmod/validate is correct; it just never gets to run.
- FIX DIRECTION (matches podkop-plus): make core-switch ASYNCHRONOUS — podkop-plus
has `component_action_async` (writes output to a file, forks the work) +
- FIX DIRECTION: make core-switch ASYNCHRONOUS — has `component_action_async`
(writes output to a file, forks the work) +
`component_action_status` (UI polls). NetShift's updater is synchronous and has
no async/status path. Port that model: fork the install, return immediately,
poll status; UI shows progress instead of hitting the 30s rpcd wall.
@ -183,6 +183,38 @@ save+`sing-box check` -> cron jobs -> start sing-box -> dnsmasq_configure ->
gate generation behind `is_sing_box_extended` and fail safe (warn + skip) when
stock sing-box is installed, exactly like xhttp does today.
## sing-box-extended version diagnostic (task-013 — done 2026-06-05)
- BUG: `check_sing_box` (usr/bin/netshift ~3276) showed "❌ version not compatible"
on the extended core. TWO coupled defects:
1. `awk '{print $3}'` on `sing-box version 1.13.12-extended-2.3.2` → patch via
`cut -d. -f3` = `12-extended-2` (non-numeric) → `[: bad number`.
2. The compare `if [ A ] || [ B ] && [ C ] || [ D ] && [ E ] && [ F ]` was
UNGROUPED. POSIX `&&`/`||` are EQUAL-precedence, LEFT-associative, so it
parses `(((((A||B)&&C)||D)&&E)&&F)` — the trailing E/F gate EVERY branch,
so 1.13.x AND 2.0.0 evaluate as not-compatible even with a numeric patch.
- FIX (Variant 2, operator-chosen): strip suffix `version=${version%%-*}` (gives
honest semver; extended author only bumps the trailing `-extended-X.Y.Z`,
leading major.minor.patch is true upstream sing-box) + regroup each AND-term in
`{ ...; }`. Kept threshold 1.12.4 + printed text. Did NOT touch check_requirements
(uses sort -V, already extended-safe). 1-file change, gates green.
- LANDMINE for future tasks: any `[ ] || [ ] && [ ]` chain in this repo without
`{ ...; }` grouping is suspect — equal precedence means trailing AND-terms leak
into prior OR-branches. Group every AND-term. (My first decomposition wrongly
assumed the strip alone fixed it; the dev caught the precedence bug on live
reasoning — TRUST dev "second defect" flags, re-derive the truth table myself.)
- Extended core real output (operator hardware, captured for the epic): version
`1.13.12-extended-2.3.2`, Tags include `with_quic,with_wireguard,with_utls,
with_masque,with_mtproxy,with_openvpn,with_trusttunnel,with_sudoku,
with_naive_outbound,with_gvisor`. So the shtorm-7 build SHIPS the build-tags for
nearly all of epic Tiers 13 (tuic/hysteria need with_quic ✅, AWG needs
with_wireguard ✅, sudoku/trusttunnel/openvpn ✅) — CX-4 build-tag uncertainty is
largely resolved EMPIRICALLY for this build; still gate generation behind
is_sing_box_extended + tolerate a per-protocol `sing-box check` rejection.
- SECOND hardcode of the version threshold confirmed: check_sing_box hardcodes
"1.12.4" (major/minor/patch literals + text) while SB_REQUIRED_VERSION=1.12.0 in
constants.sh. Known rassinkhron; left as-is per operator (out of task-013 scope).
## Subscription keyword filter — Cyrillic case bug (task-010, found on hardware 2026-06)
- REAL bug (not version skew): the keyword filter's "case-insensitive" claim only
@ -234,3 +266,145 @@ save+`sing-box check` -> cron jobs -> start sing-box -> dnsmasq_configure ->
- UI: two `form.DynamicList` in `section.js` after `subscription_group_by_countries`,
rmempty=true, NO validator (keep emoji/space verbatim); `string[]?` fields on
`ConfigProxySubscriptionSection` in types.ts; ru/en via locale tooling.
## PR review workflow + PR #11 findings (review-001, 2026-06-06)
- Reviewing an external PR (no `gh` CLI installed): fetch via API
`curl https://api.github.com/repos/yandexru45/netshift/pulls/N` (meta),
`.../files` (per-file stats), and `-H "Accept: application/vnd.github.v3.diff"`
for the raw diff. Then `git fetch origin pull/N/head:pr-N` to get a local ref
diffable vs `main`. Workspace `.pr-review/` + `*.txt` are gitignored (untracked).
- Decompose review by LAYER (backend / frontend+i18n / tests-packaging) into
separate diff txt files; launch one `explore` subagent per layer IN PARALLEL
(layers don't share files), then consolidate with the formal `code-reviewer`.
Give each subagent an architect "systemic notes" file of HYPOTHESES to verify.
- **nftables landmine (VERIFIED on nft v1.1.3):** `tproxy ip6 to <addr>:<port>`
REQUIRES bracketed `[addr]:port`. The unbracketed form (e.g. `::1:1603`) PASSES
`nft -c` AND `sing-box check`, but nft normalizes it to a BARE address with NO
port (`::1:1603` -> `[::0.1.22.3]`). Only on-device / `unshare -rn nft -f` +
`nft list ruleset` reveals it. IPv4 `addr:port` is fine unbracketed; v6 is not.
- **Local nft verification trick (no root):** `unshare -rn nft -c -f file` /
`unshare -rn sh -c 'nft -f f && nft list ruleset'` gives netlink in a private
netns so you can load+inspect normalized rules. Plain `nft -c` fails with
"cache initialization failed: Operation not permitted" without it.
- PR #11 ("Синхронизация с netshift", spgsroot, +2314/-1364, 23 files) verdict:
**REQUIRES CHANGES**. Doc at `.pr-review/REVIEW-pr-11.md` (canonical copy would
be `docs/tasks/sync-netshift-review-001.md`). Headline = IPv6 + DoH-block +
global_proxy + sing-box health monitor + check_proxy rework.
* BLOCKER B-01: unbracketed v6 tproxy rule (above).
* Majors: nft model shift (mangle now marks ALL interface traffic, split moved
to sing-box route rules) — `mangle_output` lost router-originated @common/
fakeip marking (regression); `@netshift_subnets`/@common still populated each
`list_update` but matched by NO rule (dead import path); 8x `SUBNETS_*_V6`
dead constants; `start()` spawns `monitor_sing_box` with no pidfile+kill-0
guard (orphan leak); over-permissive `validateIPV6` regex (accepts `:::`,
`1::2::3`, etc.) shared by subnet+dns validators, no negative tests; 3 new
flag descriptions concat'd inside `_()` -> ship untranslated.
* GOOD: generated `main.js` is a faithful DRIFT-FREE rebuild (CI no-diff should
pass); NO Oniguruma jq; UTF-8 emoji intact; i18n catalogs machine-consistent.
* Coverage gap: the nft model shift has NO smoke test (test_global_proxy only
checks sing-box route-rule SHAPE; test_nft byte-identical to base) — that's
why B-01 slipped. Any nft-rule PR should add an `nft list ruleset` assertion.
## PR #11 fix-to-perfect cycle (2026-06-06, after operator merged the PR)
- Operator merged PR #11 to main, then asked to fix everything to perfection.
Decomposed the review-doc issues into 3 task specs (docs/tasks/task-014 backend,
-015 frontend, -016 packaging) + delegated to the 3 dev subagents, ran the
dev<->code-reviewer loop per layer until all APPROVED. NOTE: `docs/tasks/` is
gitignored (line 7 `docs/tasks`), so task specs are session artifacts (like
.pr-review/), not committed — that's by project design (only TEMPLATE-*.md are
force-tracked).
- Operator design decisions for the nft model shift: B-02=A (router-originated
traffic stays DIRECT in the new mark-everything-in-prerouting model; document
only, don't restore mangle_output marking) and B-03/B-04=A (remove the dead
@netshift_subnets populate path + dead SUBNETS_*_V6). Rule: dead-code removal
for a SET requires first PROVING every populated source is carried by a sing-box
rule_set; the dev produced a coverage map (community->$SRS_MAIN_URL/<svc>.srs,
user/local/remote subnets->rule_sets). DISCORD set is RETAINED (it has a
dport-restricted mangle rule `udp dport {19000-20000,50000-65535}` that a
sing-box route rule cannot express). M1/M2 left as non-blocking follow-ups
(orphaned rulesets.sh helpers + unused IPv4 SUBNETS_* under SC2034).
- **dnsmasq "we-own-it" guard landmine (B-08):** a guard that infers netshift
ownership from the PRESENCE of `netshift_*` BACKUP markers is WRONG, because
`backup_dnsmasq_config_option` only writes a marker when the ORIGINAL value was
non-empty. On stock/default dnsmasq (empty server/noresolv/cachesize) NO markers
exist, so on the redundant `dnsmasq_configure force` path (monitor recovery /
double-start) the guard flips false, re-runs backup, and records netshift's OWN
live values (noresolv=1/cachesize=0) as the "backup" -> restore later sets
noresolv=1/cachesize=0 instead of defaults 0/150 -> router DNS broken after stop.
FIX: an explicit unconditional sentinel `netshift_configured=1` set in
dnsmasq_configure, gating the short-circuit, cleared in dnsmasq_restore.
- **nft v6 NEGATIVE-guard test landmine:** the buggy unbracketed `::1:1603`
normalizes DIFFERENTLY per nft build: `[::0.1.22.3]` on nftables v1.1.3 (WSL),
but `[::1:1603]` on OpenWRT 24.10.6's nft (the smoke container). So a negative
grep for `::0` OR even `\[::0` is a DEAD always-passing assertion in the smoke
env. ROBUST pattern: `grep 'tproxy ip6 to \[' | grep -qv '\[::1\]:1603'` (any
bracketed dest that ISN'T the correct one). Always self-prove a regression guard
by temporarily reintroducing the bug and confirming the test FAILS.
- Environment (WSL2 Debian 12): Docker daemon socket-activation can leave a
self-referential symlink (`/var/run/docker.sock -> /run/docker.sock` where
/var/run IS /run); fix = `sudo rm -f /run/docker.sock; sudo systemctl restart
docker.socket docker.service`. shellcheck not installed -> grab the static
binary to ~/.local/bin (koalaman release tar.xz). yarn is classic 1.22.x via
corepack (safe, no yarn.lock migration); deps install clean with --frozen-lockfile.
- FINAL integrated gates after the cycle: shellcheck (error) clean; yarn ci 439
tests pass (was 395) + main.js idempotent rebuild (two builds byte-identical);
smoke `all` = 84 passed / 0 failed (was 81; +3 nft v6 regression assertions);
whole-chain `unshare -rn` confirms v6 tproxy normalizes to [::1]:1603. All 3
layers code-reviewer APPROVED. Ready for human commit (agents never auto-commit).
## Component Manager feature (task-017 backend + task-018 frontend, 2026-06-06)
- New LuCI tab "Component Manager" (RU "Менеджер компонентов"): 3 cards
(NetShift / sing-box stock / sing-box extended) with installed version shown
immediately + on-demand "Check update" + status badges + update/core-switch/
self-update actions. Core-switch MOVED out of Diagnostics into here.
- Backend (task-017, updater.sh): two NEW component_action sub-cases (the
dispatcher is component_action() :1272, a `case "$comp:$action"`; that is the
ONLY extension point — component_action_async/_status are component-agnostic,
no dispatcher change for new actions). Added `sing_box:check_update_stable`
(sync) + `netshift:self_update` (async via component_action_async). Self-update
= Variant A: targeted pkg upgrade (download release .ipk/.apk + pkg_install),
NOT install.sh (interactive `read`). MUST mirror the updates_install_sing_box_
extended epilogue (:878-903): reset UPDATES_HEAL_* -> ensure_connectivity
"extended" -> _core to /tmp file + rc -> ALWAYS updates_restore_after_swap ->
re-emit JSON -> return rc. NEVER exit on recoverable fail (echo failure JSON +
return nonzero). Minimal /etc/config/netshift backup. RU i18n only if installed.
- SELF-REPLACEMENT (critical, verified safe): the netshift pkg replaces the very
/usr/bin/netshift running the worker. The async fork runs `"$0" component_action
netshift self_update` in `( trap '' HUP; ... ) &`; busybox ash holds the whole
script in memory, and updates_write_finished_job_state runs in the SAME subshell
AFTER the worker returns — both complete from memory despite the on-disk swap.
RULE: the self_update worker must contain NO exec / NO "$0" / NO re-invoke of
/usr/bin/netshift / NO updates_restart_netshift after pkg_install. (Only
/etc/init.d/netshift start via restore, AFTER install, as a fresh process — ok.)
- updater.sh does NOT source install.sh -> re-implement the tiny pkg helpers
locally with the `updates_` prefix (updates_pkg_is_apk/_install_file/
_is_installed/_candidate_version). pkg output parsed with cut/awk/grep (no
Oniguruma). Stock candidate via opkg info/list or apk list; >= compare via
is_min_package_version (sort -V) on leading semver ${v%%-*}.
- STABLE cross-layer contract: check_update_stable -> {success,current_version,
latest_version,status:"latest"|"outdated"|"not_installed"}; self_update finished
-> {success,version,message}; versions from get_system_info (netshift_version,
netshift_latest_version, sing_box_version "not installed" when absent,
sing_box_extended 0|1). ACL already allows fs.exec /usr/bin/netshift -> no ACL
change for component_action.
- FRONTEND landmine caught by review (C1): NetShift's "Check update" has NO
backend check action (there is no netshift:check_update). NetShift latest comes
ONLY from get_system_info.netshift_latest_version. A card whose "latest" comes
from a DIFFERENT source than a sibling MUST use a DISTINCT action kind
(`check_netshift`, no backendAction) that refreshes systemInfo — never route it
through the sing-box check method or write a sing-box result into its check
slice. Generalize: when mirroring a multi-card update pattern, verify EACH
card's check actually targets ITS OWN backend source.
- Lenient mid-job polling for self_update: the poll's fetchStatus swallows exec/
parse errors and returns synthetic {running:true} (NOT null) so the mid-job
binary swap isn't misreported as failure; scoped strictly AFTER a job_id is
obtained (a failed START still surfaces), bounded by MAX_POLLS; success ->
warning toast + window.location.reload().
- FINAL gates: shellcheck clean; yarn ci 465 tests; main.js idempotent (two builds
byte-identical) + no yarn pollution + i18n catalogs byte-identical (fe<->luci);
smoke all = 101 passed / 0 failed (84 -> +17 new: stablecheck x4 + selfupdate
x13). Both layers code-reviewer APPROVED (backend 1st pass; frontend after a
C1/S1 fix round). Ready for human commit.

View File

@ -186,3 +186,239 @@ append findings; keep under ~200 lines.
`#`) case proving base64-body whitespace is still rejected.
- main.js diff for this fix is exactly the `validateVmessUrl` function body
(body/b64 split + whitespace-on-b64 + pad b64) — expected-only.
## settings.js section-picker pattern + DNS-via-outbound (task-015)
- `settings.js` is a HAND-WRITTEN LuCI view (NOT bundled by tsup → editing it
yields NO main.js diff). The reusable "pick a proxy/vpn section" dropdown
pattern: `form.ListValue` + `o.depends(<flag>,'1')` + `o.cfgvalue` reading
`uci.get('netshift',section_id,<opt>)` + custom `o.load` that walks
`this.map?.data?.state?.values?.netshift ?? {}`, pushes secName to
`this.keylist`/`this.vallist` when `sec['.type']==='section'` AND
`connection_type` is NOT `block`/`exclusion`, returns `Promise.resolve()`.
Mirrors `download_lists_via_proxy_section` (settings.js ~294-321).
- `dns_outbound_section` uses `rmempty=true` (empty = backend falls back to
first outbound) — intentionally differs from the download-proxy clone's
`rmempty=false`. Don't "normalize" it.
- R3 trap: the spec called the diagnostic field "type-only ⇒ no main.js diff",
but adding the field to `types.ts` is type-only (erased at build) WHILE the
paired `runDnsCheck.ts` `insertIf` render IS RUNTIME CODE → it DOES produce a
legit main.js diff (the 10-line insertIf block). That regenerated main.js is
the deliverable; a second build is idempotent (no further diff). "No diff"
only holds if you skip the runDnsCheck.ts edit.
- locales: ran `node {extract-calls,generate-pot,generate-po ru,
distribute-locales}.js` (NOT yarn → no corepack). New ru text goes in the
SOURCE `locales/netshift.ru.po` empty `msgstr`, then `distribute-locales.js`
copies to `po/ru/netshift.po` + `po/templates/netshift.pot`. Regen touches
calls.json/pot broadly (line-ref reshuffle + POT-Creation-Date header) but is
PURELY ADDITIVE — verified at msgid level: 5 added, 0 removed.
- ENCODING TRAP (Windows/PS5.1): `git show HEAD:file > tmp` re-encodes the blob
to UTF-16 and MANGLES UTF-8 (emoji/Cyrillic) — gives FALSE "removed/added"
noise. Use `cmd /c "git ... > %TEMP%\f"` (preserves raw bytes) OR compare via
`git diff` directly. The committed .pot/.po ARE valid UTF-8; don't panic.
- Console prints po/pot Cyrillic as mojibake — that's PS display only; verify
on-disk via `node -e "require('./locales/calls.json')...includes(...)"` or
UTF-8 byte reads, not the terminal.
- New i18n keys (task-015) + ru: "Route main DNS through proxy/VPN"→"Основной
DNS через прокси/VPN"; "DNS outbound section"→"Секция outbound для DNS";
"Main DNS via outbound"→"Основной DNS через outbound"; long descriptions
translated equivalently.
## validateIPV6 rewrite + concat-_() i18n (task-015 PR#11 fixes)
- The OLD `validateIPV6` (two loose regexes + `colons>=2&&<=7` guard) WRONGLY
accepted `:::`, `1:2:::3`, `1::2::3` (multiple `::`), `1:2:3:4:5:6:7`
(incomplete 7-group). Replaced with a small functional checker (no `any`):
count `::` via `stripped.split('::').length-1` (reject >1); split into
head/tail, `countGroups(side)` splits on `:` and validates each as
`/^[0-9a-fA-F]{1,4}$/` hextet; group-count rule = exactly 8 when no `::`,
≤7 when one `::` (it stands for ≥1 zero group). `''` side → 0 groups (so `::`
unspecified and `::1` work). Helpers `isHextet`/`countGroups`/`isEmbeddedIPv4`
are module-private (NOT in barrel) → no `main.*` export leak; verified diff
has no new bare export lines.
- F-04 DECISION: ACCEPT IPv4-embedded IPv6 (`::ffff:192.168.1.1`,
`2001:db8::192.168.1.1`) — valid per RFC 4291. `countGroups` treats a LAST
group containing `.` as an embedded IPv4 (validated via `validateIPV4`) that
occupies TWO 16-bit groups (so it adds +1 to the group count). Tested both
positive and the malformed negatives.
- main.js diff for this is EXACTLY the validateIPV6 function body + 3 private
helpers (52+/7- lines); second build idempotent. Expected runtime-code diff.
- I18N CONCAT GOTCHA (F-02): `_('foo ' + 'bar')` is NOT extracted (gettext sees
only literal args; the `+` makes it an expression). The established repo
convention (see section.js community_lists ~415) is
`_('foo') + ' ' + _('bar')` — the SPACE lives OUTSIDE `_()`, and each literal
has NO trailing/leading space. `extract-calls.js` line 55 does `arg.value.trim()`
so a trailing space INSIDE `_('foo ')` is trimmed in the catalog → runtime
lookup of `'foo '` MISSES. So ALWAYS put separators outside `_()`. Fixed
global_proxy (section.js ~310), block_doh + enable_ipv6 (settings.js ~463/481).
- locales regen: ran `node {extract-calls,generate-pot,generate-po ru,
distribute-locales}.js` (NOT yarn). `generate-pot.js` calls
`git config user.name`/`user.email` and CRASHES if unset → set them locally
(`git config --local user.name "..."`, email may be empty string which returns
rc=0). POT header churns (POT-Creation-Date timezone + `<>` from empty email)
— cosmetic, accepted; msgid-level diff was PURELY ADDITIVE (10 added, 0
removed). 10 new ru msgstrs filled in SOURCE locales/netshift.ru.po then
distributed to po/ru + po/templates (both end up byte-identical to source).
- The 10 new ru fragments are the split sentences of the 3 flag descriptions
(global proxy / block DoH / enable IPv6) — translated formally/technically
matching neighbours. All catalogs LF, no empty non-header msgstr remained.
## Component Manager tab (task-018)
- NEW TAB = 5-file pattern mirroring `tabs/diagnostic`: `manager/{index,render,
initController,styles}.ts` + a hand-written `view/netshift/manager.js`
(`form.DummyValue _mount_node`, `o.rawhtml=true`, `cfgvalue` →
`main.ManagerTab.initController()` + `return main.ManagerTab.render()`).
Register in `netshift.js`: add `"require view.netshift.manager as manager"`
+ a `form.TypedSection` block (`anonymous=true`, `addremove=false`,
`cfgsections=()=>["manager"]`). NB the tab UCI section name (`manager`,
`diagnostic`, `dashboard`) need NOT exist in `/etc/config/netshift` — LuCI
renders the virtual TypedSection anyway (diagnostic/dashboard prove it).
- Barrel: add `export * from './manager';` to `tabs/index.ts` → `ManagerTab`
reaches `main.ManagerTab` (verified in the export block at the bottom of
main.js). Wire `ManagerTab.styles` into `src/styles.ts` `GlobalStyles` next to
Dashboard/Diagnostic. Styles use theme vars WITH fallbacks; CBI selector is
`#cbi-netshift-manager-_mount_node > div` + hide `#cbi-netshift-manager > h3`.
- LIFECYCLE: mirror diagnostic exactly but guard re-init with module-level
`*Registered`/`*Initialized`/`*Mounted` booleans since the
lazy-mount listener can fire repeatedly. `onMount('manager-status')` →
`registerLifecycleListeners()` (subscribe on `tabService.current==='manager'`)
→ `onPageMount` subscribes store + renders + fetches systemInfo;
`onPageUnmount` resets `['managerActions','managerChecks']`.
- INSTALLED-NOW / LATEST-ON-DEMAND: installed versions come from
`diagnosticsSystemInfo` (reuse the diagnostic `getSystemInfo()`→store slice);
latest is fetched ONLY on a "Check update" click. Pure card builder
`cards.ts:getComponentCards(systemInfo, checks)` + `getCheckTag(status)` +
`isSingBoxInstalled` are DOM/store-free (import only `normalizeCompiledVersion`
leaf + `NetShift` types) → unit-testable WITHOUT the MutationObserver collect
crash. Controller maps descriptors→DOM+click handlers. 3 cards: netshift,
sing_box_stock, sing_box_extended; inactive core → "Not installed" + switch.
- BACKEND CONTRACT (task-017, STABLE): both SING-BOX checks (`check_update`
extended / `check_update_stable` stock) return `{success,current_version,
latest_version,status:"latest"|"outdated"|"dev"|"not_installed"}`. The
PRE-EXISTING `singBoxComponentAction('check_update')` only parsed `{success,
version,message}` (DROPPED status/latest) — do NOT route the manager check
through it. Added ONE `singBoxCheckUpdate(action)` method parsing the FULL
contract via a pure `parseComponentCheckUpdate.ts` (types-only import, status
whitelisted). Install/switch reuse the existing async
`singBoxComponentAction('install_*')` + `pollSingBoxComponentAction`.
- C1 (review fix) — THERE IS NO `netshift:check_update` BACKEND ACTION. NetShift
latest comes ONLY from `get_system_info.netshift_latest_version`. So the
NetShift card's "Check update" must NOT route through `singBoxCheckUpdate` (that
would run the sing-box EXTENDED check and write its status into
`managerChecks.netshift` → wrong). Fix: give the NetShift check a DISTINCT
`kind:'check_netshift'` (no `backendAction`) so `handleManagerAction` routes it
to `runNetshiftCheck`, which RE-FETCHES systemInfo + `resetCheckResult
('netshift')` and derives the badge/toast from installed-vs-latest. NetShift
status is derived PURELY from systemInfo (`netshiftStatus(systemInfo)` no longer
reads `managerChecks.netshift`). Regression guards in cards.test.js: NetShift
action kind is `check_netshift`, has NO `backendAction`, and status ignores a
bogus `managerChecks.netshift`. LESSON: when a card's "latest" comes from a
DIFFERENT source than its siblings, give it its own action kind so the shared
dispatcher can't misroute it to the wrong backend method.
- S1 (review fix) — keep ONE check method per concern: removed the dead
`singBoxCheckUpdateStable()` (0 callers; controller uses
`singBoxCheckUpdate('check_update_stable')`). No dead exports.
- M2 (review fix) — `ManagerComponentKey` defined ONCE in `tabs/manager/cards.ts`
(the pure module) and `export type`-re-exported from `store.service.ts` (which
`import type`s it) so store consumers keep their path; safe because cards.ts
imports NO store (no cycle). M1 (self-update timeout reusing 'Core switch
timed out' wording) left as-is — non-blocking, and the shared
`pollSingBoxComponentAction` wording is approved for the core-switch path.
- SELF-UPDATE lenient polling: `netshiftSelfUpdate()` starts
`component_action_async netshift self_update`; once a `job_id` is returned, the
poll `fetchStatus` callback SWALLOWS exec/parse errors and returns a synthetic
`{running:true}` (instead of `null`, which the pure poll treats as terminal
failure). This prevents the mid-job `/usr/bin/netshift` binary swap from
misreporting success as failure; `MAX_POLLS` still bounds it. On success: a
warning-style toast then `window.location.reload()` after 1200ms.
- MOVED core-switch OUT of Diagnostics: removed `handleInstallSingBox` +
`singBoxInstall`/`singBoxExtended` from `diagnostic/initController.ts` and the
`singBoxInstall` block + props from `renderAvailableActions.ts`, and the
`singBoxInstall` slice from BOTH `StoreType` and `diagnostic.store.ts`. Net
i18n effect: msgids "Install stable"/"Install extended" were DROPPED (now-dead)
and replaced by manager's "Switch to stable"/"Switch to extended"/"Install %s"
— so the ru.po diff is NOT purely additive this time (2 removed, ~16 added);
that is correct. `renderRotateCcwIcon24` stays imported (still used by Restart).
- i18n: 16 new ru msgstrs filled in SOURCE `locales/netshift.ru.po`, then
`node distribute-locales.js`. "%s" placeholder strings ("Install %s") are
single literals (no concat); progress/result toasts concatenate the version
OUTSIDE `_()` (`` `${_('NetShift updated, version:')} ${v}` ``). Ran the
locales scripts via `node {extract-calls,generate-pot,generate-po ru,
distribute-locales}.js`. yarn here was classic 1.22.22 (NOT corepack) so
`yarn ci` was safe — verified yarn.lock unchanged + no `.yarn/.yarnrc.yml`.
- main.js: +756/-… runtime diff (new tab + methods + core-switch removal),
second build idempotent (byte-identical), banner + `return baseclass.extend`
intact, only `ManagerTab` added to the export block (pure helpers imported by
direct path → no leak). `tsc --noEmit` flags ONE pre-existing error in
`getNetshiftVersionRow.test.ts` (sing_box_extended optionality) — NOT in CI
(yarn ci = format/lint/vitest/build, no tsc), pre-existing, ignore.
## Drop stale nft "mangle output counters" check (task-020b)
- Backend 020a removed `rules_mangle_output_counters` from `check_nft` JSON
(router-output traffic is intentionally DIRECT now → that chain's counter is
legitimately 0, so the non-zero assertion was a FALSE positive). New STABLE
7-key shape: `{table_exist, rules_mangle_exist, rules_mangle_counters,
rules_mangle_output_exist, rules_proxy_exist, rules_proxy_counters,
rules_other_mark_exist}` — `rules_mangle_output_exist` KEPT.
- FE removal touched exactly 2 source files: `runNftCheck.ts` (drop the field
from the allGood `&&` chain, the atLeastOneGood `||` chain, and its `items[]`
row — keep the "Rules mangle output exist" row) + `types.ts`
`NftRulesCheckResult` (drop `rules_mangle_output_counters: 0 | 1;`).
- main.js: runtime diff is the removed Boolean()s + the dropped items[] row;
second build BYTE-IDENTICAL (idempotent), banner + `return baseclass.extend`
intact; `grep -c rules_mangle_output_counters main.js` == 0.
- locales: ran `node {extract-calls,generate-pot,generate-po ru,
distribute-locales}.js` (NOT yarn → no corepack). The unused
`_('Rules mangle output counters')` msgid dropped cleanly from ALL 5 catalogs
(calls.json, locales/netshift.{pot,ru.po}, po/{templates/netshift.pot,
ru/netshift.po}). msgid-level delta = PURELY a removal (1 removed, 0 added);
"Rules mangle output exist" stays. generate-po reported 325/323 (2 stale
translations retained in source ru.po — harmless, additive-preserving).
- yarn was classic 1.22.22 → `yarn ci` safe; verified `git diff --exit-code --
yarn.lock` clean and NO `.yarn`/`.yarnrc.yml`. No vitest referenced the field.
## validateUrl accepts IP host + subscription_insecure checkbox (task-021a)
- `validateUrl.ts` REWRITE: old regex required an ALPHA TLD so an IPv4/IPv6 host
was rejected ("Invalid URL format"). New approach mirrors `validateSocksUrl`:
keep the protocol check (default `['http:','https:']`), then a pure
module-private `extractHost(url)` strips `scheme://` (indexOf '://'), the
`/path?query#frag` (`rest.search(/[/?#]/)` — first of `/ ? #`), optional
`userinfo@` (`lastIndexOf('@')`), and the `:port`. BRACKETED IPv6:
if `rest.startsWith('[')`, return the substring between `[` and the first `]`
(so `[2001:db8::1]:2096` → `2001:db8::1`); else strip trailing `:port` via
`lastIndexOf(':')`. Then accept if `validateIPV4(host).valid ||
validateIPV6(host).valid || validateDomain(host).valid`. Kept the 3 existing
messages verbatim (`Invalid URL format`, the protocol message, `Valid`).
- NB `validateIPV6` ALREADY unwraps brackets internally (`.replace(/^\[/,'')`),
but extractHost must unwrap anyway so the `:port` after `]` is dropped before
the validator sees it. `extractHost` is NOT barrel-exported (module-private) →
no `main.*` leak; main.js diff is +30/-4 (the helper + the host-check), second
build BYTE-IDENTICAL, banner + `return baseclass.extend({` intact.
- This single fix covers ALL FOUR callers (subscription_url, urltest_testing_url,
remote_domain_lists, remote_subnet_lists) — callers unchanged.
- TESTS: `validateDomain` accepts a trailing path (`example.com/path` regex has
`(?:\/[^\s]*)?$`), so existing domain-with-path valid cases still pass through
the domain branch. Added valid: `https://91.199.111.52:2096/sub/abc`,
`http://10.0.0.1/x`, `https://[2001:db8::1]:2096/sub`. Added invalid:
`https://999.1.1.1/x` (bad IPv4 → not domain either), `ftp://1.2.3.4` (protocol
fails first), `https://` (extractHost → '' → "Invalid URL format"). Kept
`https://google` invalid (no TLD, not an IP).
- section.js (HAND-WRITTEN, NOT bundled → 0 in main.js): added `form.Flag`
`subscription_insecure` right AFTER subscription_url (~line 113), default `"0"`,
`rmempty=false`, `depends({connection_type:'proxy',proxy_config_type:
'subscription'})` exactly like its siblings (no `subscription_user_agent` exists
here despite the spec mention). Multi-sentence description = three `_()` calls
joined with `+ " " +` OUTSIDE `_()` (F-02 rule). UCI contract option name
`subscription_insecure` (0|1) consumed by backend 021b.
- locales: `node {extract-calls,generate-pot,generate-po ru,distribute-locales}.js`
(NOT yarn). 4 new msgids (1 label + 3 description sentences), PURELY additive
(4 added, 0 removed at msgid level). Filled RU in SOURCE `locales/netshift.ru.po`
then distributed → po/ru + po/templates byte-identical to source. Only the PO
HEADER msgstr stays empty (`grep -nB1 'msgstr ""'` shows just line 6/7).
- yarn classic 1.22.22 → `yarn ci` green (format/lint --max-warnings=0/471 tests/
build); verified yarn.lock unchanged + NO `.yarn`/`.yarnrc.yml`. The
`netshift/files/**` + `tests/**` changes in git status are 021b (other agent),
not mine.

View File

@ -52,6 +52,38 @@ artifacts out of the container -> **ipk underscore->dash rename**
- Add a test: `test_xyz()` (header/pass/fail/skip), add to `main()` `all)` list,
add `case` alias, update usage line + docker-compose comment. Keep the two
compose invocations (build.yml smoke vs openwrt-smoke-tests.yml) in sync.
- Smoke baselines drift as tests get added: 81 passed (pre task-016) -> 84
passed after adding `test_nft_ipv6` (3 v6 assertions). Re-confirm the baseline
from the actual run, don't trust a stale number in a task spec.
- `test_nft_ipv6` (alias `nftv6`, task-016): real-nft regression guard for the
B-01 IPv6 tproxy blocker. Builds the v6 tproxy rule from constants
(`SB_TPROXY_INBOUND_ADDRESS_V6`/`_PORT_V6`) in a throwaway `inet` table, lists
it back, and asserts it normalizes to bracketed `[::1]:1603` (positive) and
that no portless bare form (`tproxy ip6 to ::0`) appears (negative guard). The
unbracketed bug (`::1:1603`) is normalized by nft to a portless bare addr
(`[::1:1603]` / `[::0.1.22.3]` depending on nft version) — either way the
`\[::1\]:1603` grep fails, so the guard fires. Capability-gated: an
ip6-tproxy "not supported"/"operation not supported" kernel `skip`s; a
successful-but-wrong load `fail`s. SELF-PROVEN: temp scratch with unbracketed
rule -> 1 failed (guard caught it), reverted.
- Smoke test capability gating pattern: capture `add_err="$(nft add ... 2>&1)"`
inside the `if`; on success run asserts, on failure `case "$add_err"` for
*not supported* substrings -> `skip`, else `fail`. Avoids false-fails on
kernels lacking a feature while still catching real bugs.
- jq `index()` truthiness nit: `index("x") and index("y")` works (jq treats 0 as
truthy) but prefer `(index("x") != null) and (index("y") != null)` for intent
+ 0-index robustness. Was at entrypoint.sh:688.
- WSL2 kernel (6.6.x-microsoft-standard-WSL2) DOES support ip6 tproxy in the
smoke container, so the v6 assertions run (not skip) locally.
- nft v6 buggy-form normalization is VERSION-DEPENDENT: the PROOF doc saw
`[::0.1.22.3]` (nftables v1.1.3), but the OpenWRT 24.10.6 smoke container
re-prints unbracketed `::1:1603` as `[::1:1603]` (no `]:` port sep). A
negative guard that greps a single literal (`\[::0`) is therefore a DEAD
assertion on the smoke env. ROBUST pattern: flag any `tproxy ip6 to [...]`
line that is NOT the correct `[::1]:1603` ->
`grep 'tproxy ip6 to \[' | grep -qv '\[::1\]:1603'`. Catches both
normalizations + future variants. Self-proved: unbracketed scratch -> BOTH
positive (`bracketed`) and negative (`no-bare`) guards FAIL (2 failed).
## CI gates by path

View File

@ -137,8 +137,7 @@ findings; keep under ~200 lines.
the worker mid-extract (after `tar -O > /usr/bin/sing-box`, before
`chmod 0755`). The JS-side `timeout: 600000` does NOT help (server-side limit).
Fix = fork the worker detached; return a job_id in <<30s; poll status.
- Job-state machinery lives in `updater.sh` (jq, no ucode — podkop-plus uses
`json_utils_ucode` which we don't have). State dir `/var/run/netshift/
- Job-state machinery lives in `updater.sh` (jq, no ucode). State dir `/var/run/netshift/
component-actions` (tmpfs). Constants: `UPDATES_JOB_DIR`,
`UPDATES_JOB_FINISHED_TTL_MINUTES=60`, `UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES=60`,
`UPDATES_JOB_STALE_GRACE_SECONDS=15`.
@ -330,3 +329,325 @@ findings; keep under ~200 lines.
server/uuid/transport/tls on the generated outbound. The existing ws/tcp/plus
cases (no `#`) double as the no-fragment regression. shellcheck -S error clean;
`all` = 76 passed / 0 failed.
## task-013: sing-box-extended version diagnostic (build-suffix strip)
- Root cause: `check_sing_box()` (`bin/netshift`, ~:3276) does
`version=$(sing-box version | awk '{print $3}')` then `patch=$(... cut -d. -f3)`.
Extended core prints `1.13.12-extended-2.3.2`, so `patch` became
`12-extended-2` → non-numeric → `[ "$patch" -ge 4 ]` errors `bad number` →
`❌ not compatible`. Stock cores have numeric patch so they passed.
- Fix (Variant A, ONE line + comment): right after the existing
`version=$(echo "$version" | sed 's/^v//')`, add `version=${version%%-*}`
(POSIX longest-`-…`-suffix strip; no fork/jq/regex). `1.13.12-extended-2.3.2`
→ `1.13.12`; stock `1.12.0` has no `-` so unchanged; also tolerates future
`-beta`/`-rc`. `major`/`minor`/`patch` are already `local`; no new vars.
- **OUT-OF-SCOPE PRE-EXISTING BUG (left untouched per spec, but flag it):** the
comparison chain `if [ "$major" -gt 1 ] || [ "$major" -eq 1 ] && [ "$minor"
-gt 12 ] || ... && [ "$patch" -ge 4 ]` has wrong precedence — POSIX `[]`
`&&`/`||` are equal-precedence left-associative, so it evaluates as
`(...) && [ "$patch" -ge 4 ]`, making the final patch test gate EVERY branch.
Result: `1.13.12` and even `2.0.0` evaluate to version_ok=0 (only `1.12.x>=4`
passes). The spec (task-013) explicitly says do NOT rewrite the chain — it
only fixes the non-numeric `bad number` crash. So the extended diagnostic no
longer errors, but a TRUE fix of "newer than 1.12.4 ⇒ compatible" needs a
follow-up task to correct the chain (e.g. parenthesize each branch in a
single `[ ]` per term or use `sort -V` like `check_requirements` does).
- Smoke: NO new test (pure string strip, no new control flow — per spec). Reran
`shellcheck -S error` clean on `bin/netshift`; `smoke-tests all` = 76 passed /
0 failed.
## task-014: route the MAIN DNS server through a proxy outbound (detour)
- The cm/cf DNS primitives ALREADY accept `detour` as the last arg and merge it
conditionally (`+ (if $detour != "" then {detour:$detour} else {} end)`):
`sing_box_cf_add_dns_server` $6, `sing_box_cm_add_udp/tls_dns_server` $6,
`_add_https_dns_server` $8. So an EMPTY detour tag => byte-identical to the
pre-feature output (proven in smoke via `jq -cS` object compare of the
empty-tag main server vs a no-detour-arg call). Do NOT touch cm/cf for this.
- New helper `_get_dns_detour_tag()` (bin/netshift, next to
`_determine_first_outbound_section`/`get_first_outbound_section`) echoes the
tag or "" = direct. NEVER `exit`; every fallback logs `warn` and degrades to
direct. Cascade: (1) `dns_via_outbound`!=1 -> "" silent; (2) explicit
`dns_outbound_section` valid + `section_has_configured_outbound` -> it, else
warn(if non-empty) + `get_first_outbound_section`; (3) no candidate -> warn+"";
(4) candidate connection_type block/exclusion -> warn+""; (5)
`subscription_outbound_is_unavailable` -> warn+"" (self-heal on fresh boot /
failed sub); (6) else `get_outbound_tag_by_section "$candidate"`. Mirrors
`get_subscription_download_proxy_address` (toggle + section + fail-safe).
- Wired ONLY into the main `SB_DNS_SERVER_TAG` server in `sing_box_configure_dns`
(6th arg). Bootstrap (`SB_BOOTSTRAP_SERVER_TAG`) + FakeIP stay direct on
purpose (chicken-and-egg: bootstrap resolves the DoH/DoT host before the tunnel
is up; it's also the `domain_resolver` for a hostname main DNS). Two new UCI
opts documented (commented) in `etc/config/netshift`: `dns_via_outbound`(bool,
default 0) + `dns_outbound_section`. Read with `config_get_bool`/`config_get`
+ safe defaults — never required live.
- Did Req 4 (low-risk, observable): `check_dns_available` JSON gains
`"dns_via_outbound_tag"` (via `_get_dns_detour_tag`); `global_check` prints
` Main DNS via outbound: <tag>` or ` Main DNS: direct` (valid-UTF-8 emoji).
- **LuCI `config_get` always returns 0** (assign-and-succeed even when the option
is unset, leaving the var empty). So step-2's `config_get ... && [ -n "$var" ]`
detects a non-existent section purely via the EMPTY connection_type, not via rc.
Test stubs must mimic this (assign-then-`return 0`).
- New top-level smoke test `test_dns_via_outbound` (alias `dnsdetour`): builds
on/off configs through the real cf/cm path (asserts main-has-detour,
bootstrap/fakeip no-detour, off no-detour, off byte-parity, both pass live
`sing-box check`), then awk-extracts `_get_dns_detour_tag` VERBATIM from the bin
and runs the 8-case cascade table with stubbed UCI + reused helpers. Registered
in `all)` + case alias + usage "Available:" line + docker-compose comment.
shellcheck -S error clean on bin + libs + install.sh; `smoke-tests all` = 76
passed / 0 failed (suite total unchanged because the per-line `pass` runs in a
piped `while` subshell — same counter quirk as test_subscription; the per-test
✓ marks are the source of truth, here 15 green for dnsdetour).
## task-014 (PR#11 backend fixes): nft v6 bracket + dead-code removal
- **nft IPv6 `tproxy ... to` MUST bracket the address** — `tproxy ip6 to
"$ADDR_V6:$PORT_V6"` expands to `::1:1603`, which nftables v1.1.3 parses as a
BARE IPv6 address (`[::0.1.22.3]`, port 1603 read as 0x1603 hextet) with NO
port. `nft -c` PASSES and `sing-box check` is unrelated — neither gate catches
it; only on-device IPv6 breaks. Fix: `tproxy ip6 to "[$ADDR_V6]:$PORT_V6"`.
Verify with the no-root trick: write the rule to /tmp/t.nft and
`unshare -rn sh -c 'nft -f /tmp/t.nft && nft list ruleset' | grep tproxy` —
bracketed form normalizes to `tproxy ip6 to [::1]:1603` (correct). The IPv4
`tproxy ip to "$ADDR:$PORT"` is fine (IPv4 has no `:` ambiguity). sing-box
inbounds (`sing_box_cm_add_*_inbound` address+port as SEPARATE jq args ->
JSON `listen`/`listen_port`) have NO bracket defect — don't "fix" them.
- **Router-originated traffic is DIRECT by design** (operator decision A). The
PR's model marks only LAN/forwarded traffic in `mangle` (prerouting) and
splits proxy/direct in sing-box; `mangle_output` only carries local/loopback
daddr returns + the `NFT_OUTBOUND_MARK` return (so sing-box-originated packets
don't loop back into tproxy). Documented with a comment; no behavior change.
- **The `@netshift_subnets` (`NFT_COMMON_SET_NAME`) nft set was fully dead** —
created + populated at 6 sites but matched by NO nft rule after PR#11. SAFE to
remove because every subnet source is independently carried into a sing-box
rule_set: user_subnets -> `patch_source_ruleset_rules ip_cidr` + local source
ruleset; local_subnet_lists -> `import_plain_subnet_list_to_local_source_ruleset_chunked`;
community_lists -> `configure_community_list_handler` (`$SRS_MAIN_URL/<svc>.srs`
remote ruleset); remote json/srs subnets -> `configure_remote_domain_or_subnet_list_handler`
(`sing_box_cm_add_remote_ruleset`); remote plain -> `prepare_source_ruleset` +
plain import. DISCORD is the ONE exception that still needs an nft set
(`NFT_DISCORD_SET_NAME`) — it has a live dport-restricted mangle rule
(`@netshift_discord_subnets udp dport {19000-20000,50000-65535}`) that a
sing-box route rule can't express. Removed: set creation (~972), all 6
`nft_add_set_elements*` populate calls, the now-orphaned
`import_subnets_from_remote_json_file`/`_srs_file` (json/srs now log
"sing-box manages updates" like the domains path), `netshift_subnets` from the
diagnostics `sets` list, and the `NFT_COMMON_SET_NAME` constant. Left the 9
IPv4 `SUBNETS_*` constants (only `SUBNETS_DISCORD` used) in place — constants.sh
is `# shellcheck disable=SC2034` so unused-looking vars don't fail lint, and
trimming them was out of declared scope.
- **8 `SUBNETS_*_V6` constants had zero consumers** (`git grep` only matched
definitions + a memory doc) — removed.
- **B-09 dead predicates**: `is_ip`/`is_ipv6_cidr`/`is_ipv6` in helpers.sh were
all unused (`is_ipv6` only called by the other two; tests use only `is_ipv4`/
`url_is_ipv6_literal`/`is_ipv4_ip_or_ipv4_cidr`). Removed all three.
- **Monitor spawn guard (B-05)**: extracted `start_sing_box_monitor` mirroring
the `start_subscription_startup_retry_worker` pidfile-guard — if
`/var/run/netshift_monitor.pid` exists and `kill -0 "$pid"` succeeds, skip the
spawn (else `rm` stale pidfile then spawn). Prevents a procd double-start from
orphaning a monitor that `stop()` can no longer kill.
- **B-08 dnsmasq guard (review-001 FIX — sentinel, not markers)**: my first B-08
attempt gated `dnsmasq_is_configured_for_netshift` on the presence of a private
backup marker (`netshift_server`/`netshift_noresolv`/`netshift_cachesize`).
That was WRONG and regressed STOCK dnsmasq: on a default box with no original
server/noresolv/cachesize, `dnsmasq_configure` writes NO markers
(`backup_dnsmasq_config_option` only writes when the original value is
non-empty; the server-backup loop is skipped when current servers are empty).
So the guard returned false, and the redundant `dnsmasq_configure force` path
(monitor recovery restart, double-start) re-ran "backup" — but the LIVE values
were now netshift's OWN (noresolv=1, cachesize=0), so it captured those as the
backup -> `dnsmasq_restore` later restored 1/0 instead of the OpenWRT defaults
(0/150) -> router DNS broken after stop/uninstall.
CORRECT fix = an explicit netshift-owned SENTINEL: `dnsmasq_configure` does
`uci_set "dhcp" "@dnsmasq[0]" "netshift_configured" 1` UNCONDITIONALLY right
after applying our config (before the commit); `dnsmasq_is_configured_for_netshift`
short-circuits iff `netshift_configured == 1` (authoritative ownership flag, no
value/marker inference); `dnsmasq_restore` clears it with `uci_remove_quiet`
before its commit so a fresh future configure re-establishes ownership. The
sentinel is a distinct option name (not in the `server` list), so it never
leaks into the server/backup iteration. Verified all 3 scenarios via an
awk-extracted-functions harness (use an EXACT-match UCI stub — `awk -F'\t'
$1==k`, NOT grep/sed, because the literal `@dnsmasq[0]` key contains `[0]`
which a regex reads as a char class and silently mis-reads every lookup):
(A) stock -> sentinel set, no spurious backup, force-again short-circuits,
restore=0/150, sentinel cleared; (B) admin-had-config -> real values backed up
& restored intact; (C) coincidental admin match w/o sentinel -> NOT treated as
owned. shellcheck clean; smoke 81/0.
- shellcheck -S error clean (bin + libs + install.sh); `smoke-tests all` = 81
passed / 0 failed (unchanged baseline). No new smoke test (separate packaging
task owns nft/v6 coverage per spec). No sacred constant VALUES changed.
## task-017: Component Manager backend (stock latest, NetShift self-update)
- **Two new component_action() cases** (`updater.sh` ~:1612): `sing_box:
check_update_stable) updates_check_sing_box_stable` (SYNC) and
`netshift:self_update) updates_self_update_netshift` (async via the existing
`component_action_async`). NO dispatcher (bin/netshift) change — both are
sub-cases of the already-routed `component_action`; `component_action_async`/
`_status` are component-agnostic. NO ACL change.
- **pkg-manager abstraction re-implemented locally** (updater.sh does NOT source
install.sh): `updates_pkg_is_apk` (`command -v apk`), `updates_pkg_install_file`
(apk add --allow-untrusted / opkg install, `</dev/null` non-interactive),
`updates_pkg_is_installed` (apk/opkg list grep), `updates_pkg_candidate_version`
(FEED version). Candidate parse, busybox-safe, NO Oniguruma: opkg `list <pkg>`
→ `"<name> - <ver>"`, `awk -F' - ' '{print $2}'`; apk `list <pkg>` → first
token `<name>-<ver>`, strip `"<pkg>-"` prefix via `${line#"$pkg"-}`.
- **Stock check `updates_check_sing_box_stable`**: mirrors the extended-check JSON
shape. Runs `opkg/apk update` best-effort first (`|| true`). status: candidate
empty → `success:false` (feed unreachable, return 1); sing-box absent
(`command -v`) → `not_installed`; else compare on LEADING semver `${v%%-*}`
(drops `-r1`/`-extended-…`) via `is_min_package_version` (sort -V) →
`latest`/`outdated`. NEVER exits. STABLE JSON: `{success,current_version,
latest_version,status:"latest"|"outdated"|"not_installed"}`.
- **NetShift self-update = Variant A** (targeted pkg upgrade, NOT install.sh).
`updates_self_update_netshift` (public wrapper) COPIES the
`updates_install_sing_box_extended` epilogue EXACTLY: reset UPDATES_HEAL_*,
`updates_ensure_connectivity "extended"` (GitHub dir) else restore+fail JSON,
run `_updates_self_update_netshift_core >"$out"`, capture rc+json, rm, ALWAYS
`updates_restore_after_swap`, re-emit, `return $rc`. Single cleanup path; no
trap. Core is NON-interactive, all `local`, NEVER `exit`: idempotent guard
(`${installed#v}` == `${latest#v}` → "Already up to date"); minimal
`/etc/config/netshift` tmpfs backup; download assets matching pkg-name prefixes
(`netshift`,`luci-app-netshift`, RU i18n ONLY if `updates_pkg_is_installed`)
filtered to `.ipk`/`.apk` by pkg-mgr via `grep -o 'https://[^"[:space:]]*\.ext'`
(mirrors install.sh:269-274, busybox-safe); install core→luci→ru; core-install
fail is fatal-to-the-op (success:false + restore config), luci/ru fail is
non-critical (warn+continue); defensive config restore if live file empty.
- **Self-replacement CONFIRMED safe**: the `netshift` pkg overwrites
`/usr/bin/netshift` (this very script). busybox ash reads the whole script into
memory before pkg_install; the async fork (`( trap '' HUP; "$0" component_action
netshift self_update >out; updates_write_finished_job_state ... )`) + the
finished-state write complete from memory. The self-update core has ZERO live
re-exec after install: NO `updates_restart_netshift`, NO `"$0"`, NO `exec`, NO
direct `/usr/bin/netshift` or `/etc/init.d/netshift` call. (Only path that runs
the init script is `updates_restore_after_swap`'s `/etc/init.d/netshift start`,
which fires ONLY if the heal tore the redirect down, AFTER install completes,
as a fresh subprocess that safely loads the on-disk binary.) UI (task-018)
reloads the page after success. Verified via `grep` of the core's line range.
- **New constants** (constants.sh, NO ports/marks): `NETSHIFT_RELEASE_API_URL`
(= install.sh REPO / get_system_info :3347 endpoint),
`UPDATES_NETSHIFT_DOWNLOAD_DIR=/tmp/netshift/selfupdate`,
`UPDATES_NETSHIFT_CONFIG_BACKUP=/tmp/netshift/config.bak`,
`UPDATES_NETSHIFT_PKG_CORE/LUCI/I18N_RU`. `get_system_info` UNCHANGED (UI gets
versions there; stock check is a separate action — no missing field).
- **Subshell-piped `while read url` loop can't set parent vars** (task-007
variant): `_updates_self_update_download_assets` re-checks the dir
(`ls "$dir/netshift"*`) AFTER the loop to decide success, not a flag set inside.
- **Smoke tests `test_check_update_stable` (alias `stablecheck`, 4 cases) +
`test_self_update_netshift` (alias `selfupdate`, 13 assertions)**. Both source
the REAL updater.sh + helpers.sh, re-pin paths/constants, stub via markers.
`test_check_update_stable` KEY GOTCHA: `command -v sing-box` finds the real
`/usr/bin/sing-box`; to test `not_installed` I built an ISOLATED PATH dir of
symlinks to just the needed coreutils (NO /usr/bin in PATH) and linked the fake
sing-box in/out per scenario. `test_self_update_netshift` overrides
`updates_http_get_once` (GitHub JSON) + `updates_download_to_file` in the driver
+ stubs opkg `install`/`list-installed` (logs installs) + fake `/etc/init.d/
netshift` (absolute write+restore). Registered all 5 points (all)/case/usage/
compose). Used task-009 `... || true` set -e guard. shellcheck -S error clean;
`smoke-tests all` = 101 passed / 0 failed (was 84 baseline; +17 new).
## task-019: extended-check false "outdated" — v-prefix mismatch (Variant A)
- Root cause: `updates_check_sing_box_extended` (updater.sh ~:1245) compared
installed `get_sing_box_version` (`1.13.12-extended-2.3.2`, NO v) against the
GitHub `.tag_name` (`v1.13.12-extended-2.3.2`, WITH v) via `case
"$current_version" in *"$tag"*)`. The `v` prefix means the substring never
matched → fell through to `outdated` for a user ALREADY on the latest. (Stock
check + self-update were already correct: stock candidates have no v;
`_updates_self_update_netshift_core` already does `${installed#v}` ==
`${latest#v}`.)
- Fix (Variant A, ONLY this function): strip a single leading v off BOTH sides
(`cur_norm="${current_version#v}"; tag_norm="${tag#v}"`; `${x#v}` removes one
leading v if present, no-op otherwise), then EXACT-compare `[ "$cur_norm" =
"$tag_norm" ]` (the extended version is the full token — exact-after-v-strip is
correct and avoids the partial matches the old `case *"$tag"*` allowed). Emit
BOTH `current_version` and `latest_version` v-stripped so the UI shows a
consistent string. JSON shape/keys/order unchanged (STABLE for task-018);
`success:false` branches (fetch fail, no tag) untouched. New vars `local`,
POSIX ash, never exits.
- CRITICAL isolation: the install/asset path is a SEPARATE function
(`_updates_install_sing_box_extended_core`, ~:942-957) that re-derives its OWN
`tag` from `updates_extended_release_tag` (raw, WITH v) and feeds it to
`updates_extended_release_object` (`.tag_name == $t`) + `updates_extended_asset_url`.
In the check, `tag` (raw) is NO LONGER fed anywhere downstream — only `cur_norm`/
`tag_norm`. Did NOT touch `_release_tag`/`_release_object`/`_asset_url`/wrappers.
- Smoke: NEW top-level `test_check_update_extended` (alias `extcheck`, 3 cases).
updater.sh is a sourceable lib, so the driver sources updater.sh + helpers.sh,
silences log/echolog/nolog, then OVERRIDES the 3 deps AFTER sourcing
(`get_sing_box_version`, `updates_fetch_sing_box_extended_releases`,
`updates_extended_release_tag`) reading marker env (`STUBEXT_INSTALLED/RELEASES/TAG`)
— simpler than awk-extract since it's not in bin/netshift. Cases: (1) installed
== latest, only tag has v → latest + both v-stripped+equal (THE regression);
(2) installed older → outdated; (3) empty releases → success:false. Registered
all 5 points (all)/case/usage/docker-compose comment). shellcheck -S error clean
(bin+libs+install.sh); `smoke-tests all` = 104 passed / 0 failed (101 baseline
+ 3 new). `extcheck` alone = 3/0.
## task-020a: drop stale "mangle output counters" diagnostic (PR#11 B-02 align)
- The diagnostic function the spec calls `check_nft` is actually named
**`check_nft_rules`** in bin/netshift. After PR#11 (router-originated traffic
intentionally DIRECT) the `mangle_output` chain's only counter rule
(`meta mark 0x00200000 counter return`) is essentially never hit → counter
legitimately 0 → the old non-zero-counter assertion produced a FALSE ⚠️.
Operator decision Variant A = REMOVE the "mangle output counters" check
entirely; KEEP "mangle output exist".
- Backend fix (bin/netshift ONLY, 6 deletions): in `check_nft_rules` removed the
`rules_mangle_output_counters` local, the inner `grep -qv "packets 0 bytes 0"`
block that set it, and the key from the emitted JSON echo. In `global_check`
removed it from the `local` decl, its `jq -r '.rules_mangle_output_counters //
0'` read, and the `if ... ✅/⚠️ Rules mangle output counters` print block.
KEPT the existence check (`grep -q "counter" → rules_mangle_output_exist=1`)
and its ✅/❌ print. Did NOT touch mangle(prerouting)/proxy/other_mark or
`create_nft_rules`.
- **STABLE check_nft_rules JSON shape (cross-layer contract for frontend 020b),
exactly ONE key removed, order otherwise unchanged:** `{table_exist,
rules_mangle_exist, rules_mangle_counters, rules_mangle_output_exist,
rules_proxy_exist, rules_proxy_counters, rules_other_mark_exist}`.
- **No smoke test referenced the field** — `tests/entrypoint.sh` has no
`test_diagnostics`/nft-check assertion on the check_nft_rules JSON at all (the
`nft` category tests rule installation, not the diagnostic JSON keys). So no
smoke change and no registration change. Diagnostics-only edit (read-only
checks), NOT a routing/config change — nft model unchanged. shellcheck -S error
clean; `smoke-tests all` = 104 passed / 0 failed (unchanged baseline); UTF-8
intact (iconv round-trip OK, 0 рџ/в”/†mojibake).
## task-021b: opt-in insecure subscription fetch (--no-check-certificate)
- Cross-layer UCI contract (STABLE, shared with 021a frontend):
`option subscription_insecure '0'` (0|1), per `config section`. Default OFF =
unchanged secure behavior. On device wget=uclient-fetch supports
`--no-check-certificate` (confirmed) — for IP-host panels with invalid/
self-signed/missing-SAN HTTPS certs.
- `download_subscription` (helpers.sh) had SIX identical wget invocations (4 in
the main loop: ipv4/normal × proxy/no-proxy + 2 in the IPv4 retry), each with
the same 7 `--header` set. Refactored ALL six through a new private helper
`_wget_subscription_request "$cert_flag" UA HWID MODEL KERNEL OUT ERR URL --
<leading flags>`: it runs `wget $cert_flag "$@" -O "$out" <headers> "$url"
2>"$err"`. The `$cert_flag` is the ONE intentional unquoted expansion
(`# shellcheck disable=SC2086` on that line): empty string word-splits to ZERO
args (byte-identical secure default), `--no-check-certificate` adds exactly
one. NO eval. Per-branch `-4`/`-T <timeout>` are passed as the trailing
`"$@"` flags; proxy env (`http_proxy=`/`https_proxy=`) still set on the call
line. Retry/fallback/rc/mv/errfile logic untouched.
- 8th positional `insecure="${8:-0}"`; `cert_flag` derived once at top
(`[ "$insecure" = "1" ]`).
- bin/netshift `download_subscription_into_cache`: read
`subscription_insecure="$(uci -q get "netshift.${section}.subscription_insecure"
2>/dev/null)"`, default 0, log ONE redacted `warn`
(`...uses --no-check-certificate (TLS verification disabled): url=$(redact_url_for_log ...)`)
when =1, pass as the NEW 8th arg after the existing
`... 3 2 10 "$effective_user_agent"`. Declared `local subscription_insecure`.
- UCI example: added commented `#option subscription_insecure '0'` + 3-line
comment near the `subscription_url` example in `etc/config/netshift`.
- Smoke: NEW top-level `test_insecure_fetch` (alias `insecure`, 6 cases). A
PATH-prepended fake `wget` records full argv (`printf '%s\n' "$*"`) and writes
a dummy body to its `-O` target so attempt-1 succeeds (no retry). Driver
sources REAL helpers.sh, stubs log/metadata helpers + `should_force_wget_ipv4`
(per-scenario normal vs ipv4) + inert `has_ipv4_default_route`/
`wget_supports_ipv4_flag`. Asserts `--no-check-certificate` ABSENT@insecure=0 /
PRESENT@insecure=1 across normal+proxy+ipv4 branches (`-4` co-present on ipv4).
Registered all 5 points (all)/case alias/usage line/docker-compose comment).
shellcheck -S error clean; `smoke-tests all` = 110 passed / 0 failed (104
baseline + 6 new); UTF-8/LF intact. Additive, NO runtime-contract change.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: NETSHIFT\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-05 09:28+0300\n"
"PO-Revision-Date: 2026-06-05 09:28+0300\n"
"POT-Creation-Date: 2026-06-06 00:53+0300\n"
"PO-Revision-Date: 2026-06-06 00:53+0300\n"
"Last-Translator: yandexru45\n"
"Language-Team: none\n"
"Language: ru\n"
@ -29,18 +29,18 @@ msgstr "✘ Отключено"
msgid "✘ Stopped"
msgstr "✘ Остановлен"
msgid "Группировать по странам"
msgstr ""
msgid "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
msgstr ""
msgid "Active Connections"
msgstr "Активные соединения"
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 "Allow insecure TLS for subscription fetch"
msgstr "Разрешить небезопасный TLS при загрузке подписки"
msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."
msgstr "Обеспечивает доступ к YACD из WAN. Убедитесь, что в брандмауэре открыт соответствующий порт."
@ -56,6 +56,12 @@ msgstr "Необходимо указать хотя бы одну действ
msgid "Available actions"
msgstr "Доступные действия"
msgid "Block direct connections to known public DNS-over-HTTPS (DoH) servers."
msgstr "Блокирует прямые подключения к известным публичным серверам DNS-over-HTTPS (DoH)."
msgid "Block DoH Servers"
msgstr "Блокировать DoH-серверы"
msgid "Bootsrap DNS"
msgstr "Bootstrap DNS"
@ -77,6 +83,9 @@ msgstr "Путь к файлу кэша не может быть пустым"
msgid "Cannot receive checks result"
msgstr "Не удалось получить результаты проверки"
msgid "Check update"
msgstr "Проверить обновление"
msgid "Checking, please wait"
msgstr "Проверяем, пожалуйста подождите"
@ -98,11 +107,14 @@ msgstr "Закрыть"
msgid "Community Lists"
msgstr "Списки сообщества"
msgid "Component Manager"
msgstr "Менеджер компонентов"
msgid "Config File Path"
msgstr "Путь к файлу конфигурации"
msgid "Configuration for NetShift service"
msgstr ""
msgstr "Конфигурация службы NetShift"
msgid "Configuration Type"
msgstr "Тип конфигурации"
@ -132,11 +144,14 @@ msgid "Dashboard currently unavailable"
msgstr "Дашборд сейчас недоступен"
msgid "Delay in milliseconds before reloading NetShift after interface UP"
msgstr ""
msgstr "Задержка в миллисекундах перед перезагрузкой NetShift после поднятия интерфейса"
msgid "Delay value cannot be empty"
msgstr "Значение задержки не может быть пустым"
msgid "Dev"
msgstr "Dev"
msgid "DHCP has DNS server"
msgstr "DHCP содержит DNS сервер"
@ -155,9 +170,15 @@ msgstr "Отключить QUIC протокол для улучшения со
msgid "Disabled"
msgstr "Отключено"
msgid "Disables TLS certificate verification when downloading the subscription."
msgstr "Отключает проверку TLS-сертификата при загрузке подписки."
msgid "DNS on router"
msgstr "DNS на роутере"
msgid "DNS outbound section"
msgstr "Секция outbound для DNS"
msgid "DNS over HTTPS (DoH)"
msgstr "DNS через HTTPS (DoH)"
@ -215,6 +236,12 @@ msgstr "Включить встроенный DNS-резолвер для дом
msgid "Enable DNS resolve to get real IP when routing"
msgstr "Разрешать домены в реальные IP-адреса перед маршрутизацией в outbound"
msgid "Enable IPv6 Support"
msgstr "Включить поддержку IPv6"
msgid "Enable IPv6 TProxy routing, IPv6 DNS inbound, and IPv6 FakeIP support."
msgstr "Включить маршрутизацию TProxy по IPv6, входящий DNS по IPv6 и поддержку FakeIP для IPv6."
msgid "Enable Mixed Proxy"
msgstr "Включить смешанный прокси"
@ -243,22 +270,22 @@ msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addres
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
msgid "Enter the subscription URL to fetch proxy configurations from your provider"
msgstr ""
msgstr "Введите URL подписки для получения конфигураций прокси от вашего провайдера"
msgid "Every 1 minute"
msgstr "Каждую минуту"
msgid "Every 12 hours"
msgstr ""
msgstr "Каждые 12 часов"
msgid "Every 3 hours"
msgstr ""
msgstr "Каждые 3 часа"
msgid "Every 3 minutes"
msgstr "Каждые 3 минуты"
msgid "Every 30 minutes"
msgstr ""
msgstr "Каждые 30 минут"
msgid "Every 30 seconds"
msgstr "Каждые 30 секунд"
@ -267,13 +294,13 @@ msgid "Every 5 minutes"
msgstr "Каждые 5 минут"
msgid "Every 6 hours"
msgstr ""
msgstr "Каждые 6 часов"
msgid "Every day"
msgstr ""
msgstr "Каждый день"
msgid "Every hour"
msgstr ""
msgstr "Каждый час"
msgid "Exclude NTP"
msgstr "Исключить NTP"
@ -302,8 +329,11 @@ msgstr "Получить глобальную проверку"
msgid "Global check"
msgstr "Глобальная проверка"
msgid "Global Proxy"
msgstr "Глобальный прокси"
msgid "How often to automatically update the subscription"
msgstr ""
msgstr "Как часто автоматически обновлять подписку"
msgid "HTTP error"
msgstr "Ошибка HTTP"
@ -311,11 +341,11 @@ msgstr "Ошибка HTTP"
msgid "Include servers by keyword"
msgstr "Включать серверы по ключевому слову"
msgid "Install extended"
msgstr "Установить extended"
msgid "Install %s"
msgstr "Установить %s"
msgid "Install stable"
msgstr "Установить stable"
msgid "Installed version is newer than release"
msgstr "Установленная версия новее релиза"
msgid "Interface Monitoring"
msgstr "Мониторинг интерфейса"
@ -326,14 +356,14 @@ msgstr "Задержка при мониторинге интерфейсов"
msgid "Interface monitoring for Bad WAN"
msgstr "Мониторинг интерфейса для Bad WAN"
msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH"
msgid "Invalid DNS server format. Examples: 8.8.8.8, [::1], dns.example.com, or dns.example.com/dns-query for DoH"
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, [::1], dns.example.com или dns.example.com/dns-query для DoH"
msgid "Invalid domain address"
msgstr "Неверный домен"
msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y"
msgstr "Неверный формат. Используйте X.X.X.X или X.X.X.X/Y"
msgid "Invalid format. Use X.X.X.X/Y or IPv6/Y"
msgstr "Неверный формат. Используйте X.X.X.X/Y или IPv6/Y"
msgid "Invalid HY2 URL: insecure must be 0 or 1"
msgstr "Неверный URL Hysteria2: параметр insecure должен быть 0 или 1"
@ -377,6 +407,9 @@ msgstr "Неверный URL Hysteria2: неподдерживаемый тип
msgid "Invalid IP address"
msgstr "Неверный IP-адрес"
msgid "Invalid IPv6 address"
msgstr "Неверный IPv6-адрес"
msgid "Invalid JSON format"
msgstr "Неверный формат JSON"
@ -479,6 +512,9 @@ msgstr "Неверный URL VMess: должен начинаться с vmess:/
msgid "IP address 0.0.0.0 is not allowed"
msgstr "IP-адрес 0.0.0.0 не допускается"
msgid "IPv6 CIDR must be between 0 and 128"
msgstr "IPv6 CIDR должен быть от 0 до 128"
msgid "Issues detected"
msgstr "Обнаружены проблемы"
@ -488,6 +524,12 @@ msgstr "Оставлять только серверы подписки, имя
msgid "Latest"
msgstr "Последняя"
msgid "Latest version is installed"
msgstr "Установлена последняя версия"
msgid "Latest version is unknown"
msgstr "Последняя версия неизвестна"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
@ -503,6 +545,9 @@ msgstr "Уровень логов"
msgid "Main DNS"
msgstr "Основной DNS"
msgid "Main DNS via outbound"
msgstr "Основной DNS через outbound"
msgid "Memory Usage"
msgstr "Использование памяти"
@ -516,13 +561,16 @@ msgid "Must be a number in the range of 50 - 1000"
msgstr "Должно быть числом от 50 до 1000"
msgid "NetShift"
msgstr ""
msgstr "NetShift"
msgid "NetShift Settings"
msgstr ""
msgstr "Настройки NetShift"
msgid "NetShift updated, version:"
msgstr "NetShift обновлён, версия:"
msgid "NetShift will not modify your DHCP configuration"
msgstr ""
msgstr "NetShift не будет изменять вашу конфигурацию DHCP"
msgid "Network Interface"
msgstr "Сетевой интерфейс"
@ -533,12 +581,21 @@ msgstr "Другие правила маркировки не найдены"
msgid "Not implement yet"
msgstr "Ещё не реализовано"
msgid "Not installed"
msgstr "Не установлено"
msgid "Not responding"
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 "Only one section can be global at a time."
msgstr "Только одна секция может быть глобальной одновременно."
msgid "Operation timed out"
msgstr "Время ожидания истекло"
@ -591,7 +648,13 @@ msgid "Resolve real IP for routing"
msgstr "Разрешение реальных IP-адресов"
msgid "Restart NetShift"
msgstr ""
msgstr "Перезапустить NetShift"
msgid "Route all unmatched traffic through this section's outbound."
msgstr "Направлять весь несовпавший трафик через outbound этой секции."
msgid "Route main DNS through proxy/VPN"
msgstr "Основной DNS через прокси/VPN"
msgid "Router DNS is not routed through sing-box"
msgstr "DNS роутера не проходит через sing-box"
@ -608,9 +671,6 @@ msgstr "Счётчики правил mangle"
msgid "Rules mangle exist"
msgstr "Правила mangle существуют"
msgid "Rules mangle output counters"
msgstr "Счётчики правил mangle output"
msgid "Rules mangle output exist"
msgstr "Правила mangle output существуют"
@ -686,6 +746,12 @@ msgstr "Selector"
msgid "Selector Proxy Links"
msgstr "Ссылки прокси для Selector"
msgid "Self-update failed"
msgstr "Не удалось обновить"
msgid "Send upstream DNS queries through a proxy/VPN outbound instead of directly. Bootstrap DNS always stays direct."
msgstr "Отправлять запросы к основному DNS через outbound прокси/VPN вместо прямого подключения. Bootstrap DNS всегда остаётся прямым."
msgid "Services info"
msgstr "Информация о сервисах"
@ -738,23 +804,29 @@ msgid "Specify the path to the list file located on the router filesystem"
msgstr "Укажите путь к файлу списка, расположенному в файловой системе маршрутизатора."
msgid "Start NetShift"
msgstr ""
msgstr "Запустить NetShift"
msgid "Stop NetShift"
msgstr ""
msgstr "Остановить NetShift"
msgid "Subscription"
msgstr ""
msgstr "Подписка"
msgid "Subscription Update Interval"
msgstr ""
msgstr "Интервал обновления подписки"
msgid "Subscription URL"
msgstr ""
msgstr "URL подписки"
msgid "Successfully copied!"
msgstr "Успешно скопировано!"
msgid "Switch to extended"
msgstr "Переключить на extended"
msgid "Switch to stable"
msgstr "Переключить на stable"
msgid "Switching sing-box core, this may take a few minutes…"
msgstr "Переключение ядра sing-box, это может занять несколько минут…"
@ -785,6 +857,12 @@ msgstr "Максимально допустимая разница во врем
msgid "The URL used to test server connectivity"
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)"
@ -815,6 +893,18 @@ msgstr "неизвестно"
msgid "Unknown error"
msgstr "Неизвестная ошибка"
msgid "Update"
msgstr "Обновить"
msgid "Update is available"
msgstr "Доступно обновление"
msgid "Update NetShift"
msgstr "Обновить NetShift"
msgid "Updating NetShift, this may take a few minutes; the page will reload…"
msgstr "Обновление NetShift, это может занять несколько минут; страница перезагрузится…"
msgid "Uplink"
msgstr "Исходящий"
@ -839,6 +929,15 @@ msgstr "URLTest ссылка для проверки"
msgid "URLTest Tolerance"
msgstr "URLTest допустимое отклонение"
msgid "Use only for IP-host panels that serve an invalid or self-signed certificate."
msgstr "Используйте только для панелей с IP-адресом, у которых недействительный или самоподписанный сертификат."
msgid "Use this only when the router has working IPv6 connectivity."
msgstr "Используйте это только если на роутере есть рабочее подключение по IPv6."
msgid "Use with Exclusion sections to route specific domains directly."
msgstr "Используйте вместе с секциями исключений для прямой маршрутизации определённых доменов."
msgid "User Domain List Type"
msgstr "Тип пользовательского списка доменов"
@ -863,6 +962,9 @@ msgstr "Валидно"
msgid "Validation errors:"
msgstr "Ошибки валидации:"
msgid "Version"
msgstr "Версия"
msgid "View logs"
msgstr "Посмотреть логи"
@ -878,8 +980,20 @@ msgstr "Предупреждение: %s нельзя использовать
msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr "Предупреждение: Russia inside может быть использован только с %s. %s уже есть в Russia inside и будет удален из выбранных."
msgid "When enabled, traffic not matching any other section's lists will go through this proxy."
msgstr "Когда включено, трафик, не совпадающий со списками других секций, будет идти через этот прокси."
msgid "Which proxy/VPN section carries the DNS. Leave unset to use the first configured outbound."
msgstr "Какая секция прокси/VPN обслуживает DNS. Оставьте пустым, чтобы использовать первый настроенный outbound."
msgid "YACD Secret Key"
msgstr "Секретный ключ YACD"
msgid "You can select Output Network Interface, by default autodetect"
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
msgid "Группировать по странам"
msgstr "Группировать по странам"
msgid "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
msgstr "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"

View File

@ -83,6 +83,9 @@ export const DNS_SERVER_OPTIONS = {
'unfiltered.adguard-dns.com':
'unfiltered.adguard-dns.com (AdGuard Unfiltered)',
'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)',
'2001:4860:4860::8888': '2001:4860:4860::8888 (Google IPv6)',
'2606:4700:4700::1111': '2606:4700:4700::1111 (Cloudflare IPv6)',
'2620:fe::fe': '2620:fe::fe (Quad9 IPv6)',
};
export const BOOTSTRAP_DNS_SERVER_OPTIONS = {
'77.88.8.8': '77.88.8.8 (Yandex DNS)',
@ -93,6 +96,8 @@ export const BOOTSTRAP_DNS_SERVER_OPTIONS = {
'8.8.4.4': '8.8.4.4 (Google DNS)',
'9.9.9.9': '9.9.9.9 (Quad9 DNS)',
'9.9.9.11': '9.9.9.11 (Quad9 DNS)',
'2001:4860:4860::8888': '2001:4860:4860::8888 (Google DNS IPv6)',
'2606:4700:4700::1111': '2606:4700:4700::1111 (Cloudflare DNS IPv6)',
};
export const DIAGNOSTICS_UPDATE_INTERVAL = 10000; // 10 seconds

View File

@ -3,10 +3,12 @@ import { ClashAPI, NetShift } from '../../types';
import { executeShellCommand } from '../../../helpers';
import {
ComponentActionStartResponse,
ComponentActionStatus,
SingBoxComponentActionResult,
parseComponentActionStatus,
pollSingBoxComponentAction,
} from './pollSingBoxComponentAction';
import { parseComponentCheckUpdate } from './parseComponentCheckUpdate';
export const NetShiftShellMethods = {
checkDNSAvailable: async () =>
@ -173,4 +175,85 @@ export const NetShiftShellMethods = {
return parseComponentActionStatus(statusResponse.stdout);
});
},
// Sing-box update checks (sync) — STABLE task-017 contract:
// component_action sing_box check_update (extended)
// component_action sing_box check_update_stable (stock)
// → {success, current_version, latest_version, status}.
singBoxCheckUpdate: async (
action: 'check_update' | 'check_update_stable',
): Promise<NetShift.ComponentCheckUpdateResult> => {
const response = await executeShellCommand({
command: '/usr/bin/netshift',
args: ['component_action', 'sing_box', action],
timeout: 600000,
});
if (response.stdout) {
return parseComponentCheckUpdate(response.stdout);
}
return {
success: false,
message: response.stderr || '',
};
},
// 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
// /usr/bin/netshift mid-job, status polls can transiently fail (rpcd / binary
// swap); once the job has STARTED we treat such failures leniently — keep
// polling (return a synthetic running status) instead of aborting hard, so a
// successful self-update is not misreported as a failure. The UI reloads the
// page on success.
netshiftSelfUpdate: async (): Promise<SingBoxComponentActionResult> => {
const startResponse = await executeShellCommand({
command: '/usr/bin/netshift',
args: ['component_action_async', 'netshift', 'self_update'],
});
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 || _('Self-update failed'),
};
}
const jobId = start.job_id;
return pollSingBoxComponentAction(async () => {
// Lenient mid-job polling: any exec/parse error AFTER a successful start
// is reported as "still running" so the binary swap doesn't end the loop
// prematurely. The MAX_POLLS backstop still bounds the loop.
try {
const statusResponse = await executeShellCommand({
command: '/usr/bin/netshift',
args: ['component_action_status', jobId],
});
if (!statusResponse.stdout) {
return { running: true } as ComponentActionStatus;
}
return (
parseComponentActionStatus(statusResponse.stdout) ??
({ running: true } as ComponentActionStatus)
);
} catch (_e) {
return { running: true } as ComponentActionStatus;
}
});
},
};

View File

@ -0,0 +1,58 @@
import { NetShift } from '../../types';
const VALID_STATUSES: NetShift.ComponentUpdateStatus[] = [
'latest',
'outdated',
'dev',
'not_installed',
];
function normalizeStatus(
status: unknown,
): NetShift.ComponentUpdateStatus | undefined {
if (
typeof status === 'string' &&
(VALID_STATUSES as string[]).includes(status)
) {
return status as NetShift.ComponentUpdateStatus;
}
return undefined;
}
/**
* Parse the STABLE JSON echoed by the sync update-check actions
* (`component_action sing_box check_update` /
* `component_action sing_box check_update_stable`):
* `{success, current_version, latest_version, status}`.
*
* Pure (types-only import) so it is unit-testable without dragging in the
* helpers barrel (which pulls TabService → MutationObserver and crashes the
* node test env at collect time).
*/
export function parseComponentCheckUpdate(
stdout: string,
): NetShift.ComponentCheckUpdateResult {
try {
const parsed = JSON.parse(stdout) as Record<string, unknown>;
return {
success: Boolean(parsed.success),
current_version:
typeof parsed.current_version === 'string'
? parsed.current_version
: undefined,
latest_version:
typeof parsed.latest_version === 'string'
? parsed.latest_version
: undefined,
status: normalizeStatus(parsed.status),
message: typeof parsed.message === 'string' ? parsed.message : undefined,
};
} catch (_e) {
return {
success: false,
message: stdout,
};
}
}

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { parseComponentCheckUpdate } from '../parseComponentCheckUpdate';
describe('parseComponentCheckUpdate', () => {
it('parses the STABLE check-update JSON', () => {
const result = parseComponentCheckUpdate(
JSON.stringify({
success: true,
current_version: '1.12.0',
latest_version: '1.12.9',
status: 'outdated',
}),
);
expect(result).toEqual({
success: true,
current_version: '1.12.0',
latest_version: '1.12.9',
status: 'outdated',
message: undefined,
});
});
it.each(['latest', 'outdated', 'dev', 'not_installed'])(
'accepts the valid status %s',
(status) => {
const result = parseComponentCheckUpdate(
JSON.stringify({ success: true, status }),
);
expect(result.status).toBe(status);
},
);
it('drops an unknown status to undefined', () => {
const result = parseComponentCheckUpdate(
JSON.stringify({ success: true, status: 'weird' }),
);
expect(result.status).toBeUndefined();
});
it('returns success:false with the raw stdout on invalid JSON', () => {
const result = parseComponentCheckUpdate('not json');
expect(result).toEqual({ success: false, message: 'not json' });
});
it('coerces a missing success to false', () => {
const result = parseComponentCheckUpdate(JSON.stringify({}));
expect(result.success).toBe(false);
});
});

View File

@ -1,5 +1,11 @@
import { NetShift } from '../types';
import { initialDiagnosticStore } from '../tabs/diagnostic/diagnostic.store';
import { initialManagerStore } from '../tabs/manager/manager.store';
import type { ManagerComponentKey } from '../tabs/manager/cards';
// Single source of truth for the component key union lives in `tabs/manager/cards`;
// re-export it here so store consumers keep their existing import path.
export type { ManagerComponentKey };
function jsonStableStringify<T, V>(obj: T): string {
return JSON.stringify(obj, (_, value) => {
@ -180,7 +186,6 @@ export interface StoreType {
globalCheck: { loading: boolean };
viewLogs: { loading: boolean };
showSingBoxConfig: { loading: boolean };
singBoxInstall: { loading: boolean };
};
diagnosticsSystemInfo: {
loading: boolean;
@ -192,6 +197,21 @@ export interface StoreType {
device_model: string;
sing_box_extended: 0 | 1;
};
managerActions: {
netshiftCheck: { loading: boolean };
netshiftUpdate: { loading: boolean };
singBoxStockCheck: { loading: boolean };
singBoxStockAction: { loading: boolean };
singBoxExtendedCheck: { loading: boolean };
singBoxExtendedAction: { loading: boolean };
};
managerChecks: Record<
ManagerComponentKey,
{
status: NetShift.ComponentUpdateStatus | null;
latest_version: string;
}
>;
}
const initialStore: StoreType = {
@ -226,6 +246,7 @@ const initialStore: StoreType = {
data: [],
},
...initialDiagnosticStore,
...initialManagerStore,
};
export const store = new StoreService<StoreType>(initialStore);

View File

@ -72,6 +72,17 @@ export async function runDnsCheck() {
key: _('Main DNS'),
value: `${data.dns_server} [${data.dns_type}]`,
},
...insertIf<IDiagnosticsChecksItem>(
typeof data.dns_via_outbound_tag === 'string' &&
data.dns_via_outbound_tag.length > 0,
[
{
state: 'success',
key: _('Main DNS via outbound'),
value: data.dns_via_outbound_tag ?? '',
},
],
),
{
state: data.dns_on_router ? 'success' : 'error',
key: _('DNS on router'),

View File

@ -40,7 +40,6 @@ export async function runNftCheck() {
Boolean(data.rules_mangle_exist) &&
Boolean(data.rules_mangle_counters) &&
Boolean(data.rules_mangle_output_exist) &&
Boolean(data.rules_mangle_output_counters) &&
Boolean(data.rules_proxy_exist) &&
Boolean(data.rules_proxy_counters) &&
!data.rules_other_mark_exist;
@ -50,7 +49,6 @@ export async function runNftCheck() {
Boolean(data.rules_mangle_exist) ||
Boolean(data.rules_mangle_counters) ||
Boolean(data.rules_mangle_output_exist) ||
Boolean(data.rules_mangle_output_counters) ||
Boolean(data.rules_proxy_exist) ||
Boolean(data.rules_proxy_counters) ||
!data.rules_other_mark_exist;
@ -84,11 +82,6 @@ export async function runNftCheck() {
key: _('Rules mangle output exist'),
value: '',
},
{
state: data.rules_mangle_output_counters ? 'success' : 'error',
key: _('Rules mangle output counters'),
value: '',
},
{
state: data.rules_proxy_exist ? 'success' : 'error',
key: _('Rules proxy exist'),

View File

@ -46,9 +46,6 @@ export const initialDiagnosticStore: Pick<
showSingBoxConfig: {
loading: false,
},
singBoxInstall: {
loading: false,
},
},
diagnosticsRunAction: { loading: false },
diagnosticsChecks: [

View File

@ -316,50 +316,6 @@ async function handleShowSingBoxConfig() {
}
}
async function handleInstallSingBox() {
const diagnosticsActions = store.get().diagnosticsActions;
store.set({
diagnosticsActions: {
...diagnosticsActions,
singBoxInstall: { loading: true },
},
});
const isExtended = store.get().diagnosticsSystemInfo.sing_box_extended === 1;
showToast(
_('Switching sing-box core, this may take a few minutes…'),
'success',
);
try {
const result = await NetShiftShellMethods.singBoxComponentAction(
isExtended ? 'install_stable' : 'install_extended',
);
if (result.success) {
showToast(
_('Sing-box core changed, version: ') + (result.version || ''),
'success',
);
} else {
logger.error('[DIAGNOSTIC]', 'handleInstallSingBox - e', result);
showToast(result.message || _('Failed to execute!'), 'error');
}
} catch (e) {
logger.error('[DIAGNOSTIC]', 'handleInstallSingBox - e', e);
showToast(_('Failed to execute!'), 'error');
} finally {
store.set({
diagnosticsActions: {
...diagnosticsActions,
singBoxInstall: { loading: false },
},
});
await fetchSystemInfo();
}
}
function renderWikiDisclaimerWidget() {
const diagnosticsChecks = store.get().diagnosticsChecks;
@ -448,15 +404,6 @@ function renderDiagnosticAvailableActionsWidget() {
onClick: handleShowSingBoxConfig,
disabled: atLeastOneServiceCommandLoading,
},
singBoxInstall: {
loading: diagnosticsActions.singBoxInstall.loading,
visible: true,
onClick: handleInstallSingBox,
disabled:
atLeastOneServiceCommandLoading ||
diagnosticsActions.singBoxInstall.loading,
},
singBoxExtended: store.get().diagnosticsSystemInfo.sing_box_extended,
});
return preserveScrollForPage(() => {

View File

@ -27,8 +27,6 @@ interface IRenderAvailableActionsProps {
globalCheck: ActionProps;
viewLogs: ActionProps;
showSingBoxConfig: ActionProps;
singBoxInstall: ActionProps;
singBoxExtended: 0 | 1;
}
export function renderAvailableActions({
@ -40,8 +38,6 @@ export function renderAvailableActions({
globalCheck,
viewLogs,
showSingBoxConfig,
singBoxInstall,
singBoxExtended,
}: IRenderAvailableActionsProps) {
return E('div', { class: 'pdk_diagnostic-page__right-bar__actions' }, [
E('b', {}, _('Available actions')),
@ -122,14 +118,5 @@ export function renderAvailableActions({
disabled: showSingBoxConfig.disabled,
}),
]),
...insertIf(singBoxInstall.visible, [
renderButton({
onClick: singBoxInstall.onClick,
icon: renderRotateCcwIcon24,
text: singBoxExtended ? _('Install stable') : _('Install extended'),
loading: singBoxInstall.loading,
disabled: singBoxInstall.disabled,
}),
]),
]);
}

View File

@ -1,2 +1,3 @@
export * from './dashboard';
export * from './diagnostic';
export * from './manager';

View File

@ -0,0 +1,262 @@
import { NetShift } from '../../types';
import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion';
export type ManagerComponentKey =
| 'netshift'
| 'sing_box_stock'
| '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.
export type ManagerActionKind =
| 'check'
| 'check_netshift'
| 'update'
| 'switch'
| 'self_update';
export interface ManagerActionDescriptor {
// The store-slice key driving this button's loading flag.
loadingKey:
| 'netshiftCheck'
| 'netshiftUpdate'
| 'singBoxStockCheck'
| 'singBoxStockAction'
| 'singBoxExtendedCheck'
| 'singBoxExtendedAction';
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).
backendAction?:
| 'check_update'
| 'check_update_stable'
| 'install_stable'
| 'install_extended'
| 'self_update';
}
export interface ManagerCardTag {
label: string;
kind: 'neutral' | 'success' | 'warning';
}
export interface ManagerCardDescriptor {
key: ManagerComponentKey;
title: string;
version: string;
installed: boolean;
tag?: ManagerCardTag;
actions: ManagerActionDescriptor[];
}
export type ManagerSystemInfo = {
netshift_version: string;
netshift_latest_version: string;
sing_box_version: string;
sing_box_extended: 0 | 1;
};
export type ManagerCheckState = {
status: NetShift.ComponentUpdateStatus | null;
latest_version: string;
};
const NOT_INSTALLED = 'not installed';
export function isSingBoxInstalled(systemInfo: ManagerSystemInfo): boolean {
const version = systemInfo.sing_box_version;
return Boolean(version) && version !== NOT_INSTALLED;
}
/**
* Map a check status to a status badge. Pure — same logic as podkop-plus
* `getCheckTag`, extended with `not_installed`.
*/
export function getCheckTag(
status: NetShift.ComponentUpdateStatus | null,
): ManagerCardTag | undefined {
if (!status) {
return undefined;
}
if (status === 'latest') {
return { label: _('Latest'), kind: 'success' };
}
if (status === 'outdated') {
return { label: _('Outdated'), kind: 'warning' };
}
if (status === 'not_installed') {
return { label: _('Not installed'), kind: 'neutral' };
}
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.
function netshiftStatus(
systemInfo: ManagerSystemInfo,
): 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';
}
function netshiftCard(systemInfo: ManagerSystemInfo): ManagerCardDescriptor {
const status = netshiftStatus(systemInfo);
const latest = systemInfo.netshift_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'),
backendAction: 'self_update',
});
} else {
actions.push({
loadingKey: 'netshiftCheck',
kind: 'check_netshift',
text: _('Check update'),
});
}
return {
key: 'netshift',
title: 'NetShift',
version: normalizeCompiledVersion(systemInfo.netshift_version),
installed: true,
tag: getCheckTag(status),
actions,
};
}
function singBoxStockCard(
systemInfo: ManagerSystemInfo,
check: ManagerCheckState,
): ManagerCardDescriptor {
const installed = isSingBoxInstalled(systemInfo);
const isActive = installed && systemInfo.sing_box_extended === 0;
const actions: ManagerActionDescriptor[] = [];
if (isActive) {
if (check.status === 'outdated') {
const latest = check.latest_version;
actions.push({
loadingKey: 'singBoxStockAction',
kind: 'update',
text: latest ? _('Install %s').replace('%s', latest) : _('Update'),
backendAction: 'install_stable',
});
} else {
actions.push({
loadingKey: 'singBoxStockCheck',
kind: 'check',
text: _('Check update'),
backendAction: 'check_update_stable',
});
}
} else {
// Either extended is active, or no sing-box at all → offer switch-to-stable.
actions.push({
loadingKey: 'singBoxStockAction',
kind: 'switch',
text: _('Switch to stable'),
backendAction: 'install_stable',
});
}
return {
key: 'sing_box_stock',
title: 'sing-box (stock)',
version: isActive ? systemInfo.sing_box_version : _('Not installed'),
installed: isActive,
tag: isActive ? getCheckTag(check.status) : getCheckTag('not_installed'),
actions,
};
}
function singBoxExtendedCard(
systemInfo: ManagerSystemInfo,
check: ManagerCheckState,
): ManagerCardDescriptor {
const installed = isSingBoxInstalled(systemInfo);
const isActive = installed && systemInfo.sing_box_extended === 1;
const actions: ManagerActionDescriptor[] = [];
if (isActive) {
if (check.status === 'outdated') {
const latest = check.latest_version;
actions.push({
loadingKey: 'singBoxExtendedAction',
kind: 'update',
text: latest ? _('Install %s').replace('%s', latest) : _('Update'),
backendAction: 'install_extended',
});
} else {
actions.push({
loadingKey: 'singBoxExtendedCheck',
kind: 'check',
text: _('Check update'),
backendAction: 'check_update',
});
}
} else {
actions.push({
loadingKey: 'singBoxExtendedAction',
kind: 'switch',
text: _('Switch to extended'),
backendAction: 'install_extended',
});
}
return {
key: 'sing_box_extended',
title: 'sing-box (extended)',
version: isActive ? systemInfo.sing_box_version : _('Not installed'),
installed: isActive,
tag: isActive ? getCheckTag(check.status) : getCheckTag('not_installed'),
actions,
};
}
/**
* Build the three Component Manager cards from systemInfo + per-component check
* state. Pure (no DOM, no store) so it is unit-testable; the controller maps
* descriptors to DOM + click handlers.
*/
export function getComponentCards(
systemInfo: ManagerSystemInfo,
checks: Record<ManagerComponentKey, ManagerCheckState>,
): ManagerCardDescriptor[] {
return [
netshiftCard(systemInfo),
singBoxStockCard(systemInfo, checks.sing_box_stock),
singBoxExtendedCard(systemInfo, checks.sing_box_extended),
];
}

View File

@ -0,0 +1,9 @@
import { render } from './render';
import { initController } from './initController';
import { styles } from './styles';
export const ManagerTab = {
render,
initController,
styles,
};

View File

@ -0,0 +1,434 @@
import { onMount, preserveScrollForPage } from '../../../helpers';
import { normalizeCompiledVersion } from '../../../helpers/normalizeCompiledVersion';
import { showToast } from '../../../helpers/showToast';
import { renderRotateCcwIcon24, renderSearchIcon24 } from '../../../icons';
import { renderButton } from '../../../partials';
import { NetShiftShellMethods } from '../../methods';
import { logger, store, StoreType } from '../../services';
import { NetShift } from '../../types';
import {
ManagerActionDescriptor,
ManagerCardDescriptor,
ManagerComponentKey,
getComponentCards,
} from './cards';
type ManagerActionKey = keyof StoreType['managerActions'];
let managerLifecycleRegistered = false;
let managerControllerInitialized = false;
let managerMounted = false;
async function fetchSystemInfo() {
const systemInfo = await NetShiftShellMethods.getSystemInfo();
if (systemInfo.success) {
store.set({
diagnosticsSystemInfo: {
loading: false,
...systemInfo.data,
sing_box_extended: systemInfo.data.sing_box_extended === 1 ? 1 : 0,
},
});
} else {
store.set({
diagnosticsSystemInfo: {
loading: false,
netshift_version: _('unknown'),
netshift_latest_version: _('unknown'),
luci_app_version: _('unknown'),
sing_box_version: _('unknown'),
openwrt_version: _('unknown'),
device_model: _('unknown'),
sing_box_extended: 0,
},
});
}
}
function isAnyActionLoading() {
return Object.values(store.get().managerActions).some((item) => item.loading);
}
function isSystemInfoLoading() {
return store.get().diagnosticsSystemInfo.loading;
}
function setActionLoading(action: ManagerActionKey, loading: boolean) {
const managerActions = store.get().managerActions;
store.set({
managerActions: {
...managerActions,
[action]: { loading },
},
});
}
function setCheckResult(
component: ManagerComponentKey,
status: NetShift.ComponentUpdateStatus | null,
latestVersion: string,
) {
const managerChecks = store.get().managerChecks;
store.set({
managerChecks: {
...managerChecks,
[component]: {
status,
latest_version: latestVersion,
},
},
});
}
function resetCheckResult(component: ManagerComponentKey) {
setCheckResult(component, null, '');
}
function getCheckToastMessage(status: NetShift.ComponentUpdateStatus | null) {
if (status === 'outdated') {
return _('Update is available');
}
if (status === 'dev') {
return _('Installed version is newer than release');
}
if (status === 'not_installed') {
return _('Not installed');
}
return _('Latest version is installed');
}
// Sing-box check: routes to the sing-box check method (stock or extended) and
// stores the result into the matching managerChecks slice.
async function runSingBoxCheck(
component: ManagerComponentKey,
button: ManagerActionDescriptor,
) {
setActionLoading(button.loadingKey, true);
try {
const parsed = await NetShiftShellMethods.singBoxCheckUpdate(
button.backendAction === 'check_update_stable'
? 'check_update_stable'
: 'check_update',
);
if (!parsed.success) {
showToast(parsed.message || _('Failed to execute!'), 'error');
return;
}
const status = parsed.status ?? null;
setCheckResult(component, status, parsed.latest_version || '');
showToast(getCheckToastMessage(status), 'success');
} catch (error) {
logger.error('[MANAGER]', 'runSingBoxCheck failed', error);
showToast(_('Failed to execute!'), 'error');
} finally {
setActionLoading(button.loadingKey, false);
}
}
// 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.
async function runNetshiftCheck(button: ManagerActionDescriptor) {
setActionLoading(button.loadingKey, true);
try {
await fetchSystemInfo();
resetCheckResult('netshift');
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',
);
}
} catch (error) {
logger.error('[MANAGER]', 'runNetshiftCheck failed', error);
showToast(_('Failed to execute!'), 'error');
} finally {
setActionLoading(button.loadingKey, false);
}
}
async function runSingBoxMutation(
component: ManagerComponentKey,
button: ManagerActionDescriptor,
) {
setActionLoading(button.loadingKey, true);
showToast(
_('Switching sing-box core, this may take a few minutes…'),
'success',
);
try {
const result = await NetShiftShellMethods.singBoxComponentAction(
button.backendAction === 'install_stable'
? 'install_stable'
: 'install_extended',
);
if (result.success) {
const changed = _('Sing-box core changed, version:');
showToast(`${changed} ${result.version || ''}`.trim(), 'success');
resetCheckResult(component);
await fetchSystemInfo();
} else {
logger.error('[MANAGER]', 'runSingBoxMutation failed', result);
showToast(result.message || _('Failed to execute!'), 'error');
}
} catch (error) {
logger.error('[MANAGER]', 'runSingBoxMutation failed', error);
showToast(_('Failed to execute!'), 'error');
} finally {
setActionLoading(button.loadingKey, false);
}
}
function reloadPageAfterSelfUpdate() {
window.setTimeout(() => {
window.location.reload();
}, 1200);
}
async function runNetshiftSelfUpdate(button: ManagerActionDescriptor) {
setActionLoading(button.loadingKey, true);
// 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',
6000,
);
try {
const result = await NetShiftShellMethods.netshiftSelfUpdate();
if (result.success) {
const updated = _('NetShift updated, version:');
showToast(`${updated} ${result.version || ''}`.trim(), 'success', 1200);
reloadPageAfterSelfUpdate();
return;
}
logger.error('[MANAGER]', 'runNetshiftSelfUpdate failed', result);
showToast(result.message || _('Failed to execute!'), 'error');
setActionLoading(button.loadingKey, false);
} catch (error) {
logger.error('[MANAGER]', 'runNetshiftSelfUpdate failed', error);
showToast(_('Failed to execute!'), 'error');
setActionLoading(button.loadingKey, false);
}
}
function handleManagerAction(
card: ManagerCardDescriptor,
button: ManagerActionDescriptor,
) {
if (isAnyActionLoading()) {
return;
}
if (button.kind === 'check_netshift') {
void runNetshiftCheck(button);
return;
}
if (button.kind === 'check') {
void runSingBoxCheck(card.key, button);
return;
}
if (button.kind === 'self_update') {
void runNetshiftSelfUpdate(button);
return;
}
// `update` / `switch` — both drive the async sing-box install contract.
void runSingBoxMutation(card.key, button);
}
function renderComponentTag(card: ManagerCardDescriptor) {
if (!card.tag) {
return null;
}
return E(
'span',
{
class: [
'pdk_manager-page__component__tag',
card.tag.kind === 'success'
? 'pdk_manager-page__component__tag--success'
: '',
card.tag.kind === 'warning'
? 'pdk_manager-page__component__tag--warning'
: '',
]
.filter(Boolean)
.join(' '),
},
card.tag.label,
);
}
function renderComponentCard(card: ManagerCardDescriptor) {
const managerActions = store.get().managerActions;
const anyActionLoading = isAnyActionLoading();
const systemInfoLoading = isSystemInfoLoading();
const tag = renderComponentTag(card);
const headerChildren: Node[] = [
E('b', { class: 'pdk_manager-page__component__title' }, card.title),
];
if (tag) {
headerChildren.push(
E('div', { class: 'pdk_manager-page__component__status' }, [tag]),
);
}
return E('div', { class: 'pdk_manager-page__component' }, [
E('div', { class: 'pdk_manager-page__component__header' }, headerChildren),
E('div', { class: 'pdk_manager-page__component__version' }, [
E(
'span',
{ class: 'pdk_manager-page__component__version__label' },
_('Version'),
),
E(
'span',
{ class: 'pdk_manager-page__component__version__value' },
card.version,
),
]),
E(
'div',
{ class: 'pdk_manager-page__component__actions' },
card.actions.map((action) => {
const loading = managerActions[action.loadingKey].loading;
return renderButton({
text: action.text,
icon:
action.kind === 'check' || action.kind === 'check_netshift'
? renderSearchIcon24
: renderRotateCcwIcon24,
loading,
disabled: systemInfoLoading || (anyActionLoading && !loading),
onClick: () => handleManagerAction(card, action),
});
}),
),
]);
}
function renderManagerComponents() {
const container = document.getElementById('pdk_manager-components');
if (!container) {
return;
}
const { diagnosticsSystemInfo, managerChecks } = store.get();
const renderedComponents = getComponentCards(
{
netshift_version: normalizeCompiledVersion(
diagnosticsSystemInfo.netshift_version,
),
netshift_latest_version: diagnosticsSystemInfo.netshift_latest_version,
sing_box_version: diagnosticsSystemInfo.sing_box_version,
sing_box_extended: diagnosticsSystemInfo.sing_box_extended,
},
managerChecks,
).map(renderComponentCard);
return preserveScrollForPage(() => {
container.replaceChildren(...renderedComponents);
});
}
function onStoreUpdate(
_next: StoreType,
_prev: StoreType,
diff: Partial<StoreType>,
) {
if (diff.diagnosticsSystemInfo || diff.managerActions || diff.managerChecks) {
renderManagerComponents();
}
}
function onPageMount() {
onPageUnmount();
managerMounted = true;
store.subscribe(onStoreUpdate);
renderManagerComponents();
void fetchSystemInfo();
}
function onPageUnmount() {
managerMounted = false;
store.unsubscribe(onStoreUpdate);
store.reset(['managerActions', 'managerChecks']);
}
function registerLifecycleListeners() {
if (managerLifecycleRegistered) {
return;
}
managerLifecycleRegistered = true;
store.subscribe((next, prev, diff) => {
if (
diff.tabService &&
next.tabService.current !== prev.tabService.current
) {
const isManagerVisible = next.tabService.current === 'manager';
if (isManagerVisible) {
return onPageMount();
}
if (managerMounted) {
return onPageUnmount();
}
}
});
}
export async function initController(): Promise<void> {
if (managerControllerInitialized) {
return;
}
managerControllerInitialized = true;
onMount('manager-status').then(() => {
logger.debug('[MANAGER]', 'initController', 'onMount');
registerLifecycleListeners();
if (store.get().tabService.current === 'manager') {
onPageMount();
}
});
}

View File

@ -0,0 +1,20 @@
import { StoreType } from '../../services';
export const initialManagerStore: Pick<
StoreType,
'managerActions' | 'managerChecks'
> = {
managerActions: {
netshiftCheck: { loading: false },
netshiftUpdate: { loading: false },
singBoxStockCheck: { loading: false },
singBoxStockAction: { loading: false },
singBoxExtendedCheck: { loading: false },
singBoxExtendedAction: { loading: false },
},
managerChecks: {
netshift: { status: null, latest_version: '' },
sing_box_stock: { status: null, latest_version: '' },
sing_box_extended: { status: null, latest_version: '' },
},
};

View File

@ -0,0 +1,8 @@
export function render() {
return E('div', { id: 'manager-status', class: 'pdk_manager-page' }, [
E('div', {
id: 'pdk_manager-components',
class: 'pdk_manager-page__components',
}),
]);
}

View File

@ -0,0 +1,109 @@
// language=CSS
export const styles = `
#cbi-netshift-manager-_mount_node > div {
width: 100%;
}
#cbi-netshift-manager > h3 {
display: none;
}
.pdk_manager-page {
width: 100%;
}
.pdk_manager-page__components {
display: grid;
grid-template-columns: repeat(2, minmax(240px, 1fr));
grid-gap: 10px;
}
@media (max-width: 760px) {
.pdk_manager-page__components {
grid-template-columns: 1fr;
}
}
.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;
}
.pdk_manager-page__component__header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
align-items: start;
gap: 8px;
min-width: 0;
}
.pdk_manager-page__component__title {
color: var(--text-color-high);
line-height: 1.25;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdk_manager-page__component__status {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
max-width: 180px;
overflow: hidden;
}
.pdk_manager-page__component__version {
display: grid;
grid-template-columns: auto 1fr;
grid-column-gap: 6px;
align-items: baseline;
min-width: 0;
}
.pdk_manager-page__component__version__label {
color: var(--text-color-medium);
}
.pdk_manager-page__component__version__value {
min-width: 0;
overflow-wrap: anywhere;
}
.pdk_manager-page__component__tag {
flex: 0 0 auto;
padding: 2px 5px;
border: 1px var(--background-color-high, gray) solid;
border-radius: 4px;
color: var(--text-color-medium, gray);
line-height: 1.2;
}
.pdk_manager-page__component__tag--success {
border-color: var(--success-color-medium, green);
color: var(--success-color-medium, green);
}
.pdk_manager-page__component__tag--warning {
border-color: var(--warn-color-medium, orange);
color: var(--warn-color-medium, orange);
}
.pdk_manager-page__component__actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pdk_manager-page__component__actions > .pdk-partial-button {
margin-left: 0;
}
`;

View File

@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest';
import { getCheckTag, getComponentCards, isSingBoxInstalled } from '../cards';
const emptyChecks = {
netshift: { status: null, latest_version: '' },
sing_box_stock: { status: null, latest_version: '' },
sing_box_extended: { status: null, latest_version: '' },
};
function makeSystemInfo(patch = {}) {
return {
netshift_version: '1.0.0',
netshift_latest_version: '1.0.0',
sing_box_version: '1.12.0',
sing_box_extended: 0,
...patch,
};
}
describe('getCheckTag', () => {
it.each([
['latest', { label: 'Latest', kind: 'success' }],
['outdated', { label: 'Outdated', kind: 'warning' }],
['dev', { label: 'Dev', kind: 'neutral' }],
['not_installed', { label: 'Not installed', kind: 'neutral' }],
])('maps status %s to the right badge', (status, expected) => {
expect(getCheckTag(status)).toEqual(expected);
});
it('returns undefined for a null status', () => {
expect(getCheckTag(null)).toBeUndefined();
});
});
describe('isSingBoxInstalled', () => {
it.each([
['1.12.0', true],
['not installed', false],
['', false],
])('treats %s as installed=%s', (version, expected) => {
expect(
isSingBoxInstalled(makeSystemInfo({ sing_box_version: version })),
).toBe(expected);
});
});
describe('getComponentCards', () => {
it('always builds exactly three cards in order', () => {
const cards = getComponentCards(makeSystemInfo(), emptyChecks);
expect(cards.map((c) => c.key)).toEqual([
'netshift',
'sing_box_stock',
'sing_box_extended',
]);
});
it('shows the stock card as installed/active when sing_box_extended=0', () => {
const cards = getComponentCards(
makeSystemInfo({ sing_box_extended: 0, sing_box_version: '1.12.0' }),
emptyChecks,
);
const [, stock, extended] = cards;
expect(stock.installed).toBe(true);
expect(stock.version).toBe('1.12.0');
// No check yet → no badge for the active card.
expect(stock.tag).toBeUndefined();
expect(stock.actions[0].kind).toBe('check');
expect(stock.actions[0].backendAction).toBe('check_update_stable');
// Inactive extended card → "Not installed" + switch-to-extended.
expect(extended.installed).toBe(false);
expect(extended.version).toBe('Not installed');
expect(extended.actions[0].kind).toBe('switch');
expect(extended.actions[0].backendAction).toBe('install_extended');
});
it('mirrors the layout when sing_box_extended=1', () => {
const cards = getComponentCards(
makeSystemInfo({ sing_box_extended: 1, sing_box_version: '1.12.5' }),
emptyChecks,
);
const [, stock, extended] = cards;
expect(extended.installed).toBe(true);
expect(extended.actions[0].backendAction).toBe('check_update');
expect(stock.installed).toBe(false);
expect(stock.actions[0].kind).toBe('switch');
expect(stock.actions[0].backendAction).toBe('install_stable');
});
it('offers switch-to on both cores when sing-box is absent', () => {
const cards = getComponentCards(
makeSystemInfo({ sing_box_version: 'not installed' }),
emptyChecks,
);
const [, stock, extended] = cards;
expect(stock.installed).toBe(false);
expect(stock.actions[0].kind).toBe('switch');
expect(extended.installed).toBe(false);
expect(extended.actions[0].kind).toBe('switch');
});
it('turns an outdated stock check into an Install %s update action', () => {
const cards = getComponentCards(
makeSystemInfo({ sing_box_extended: 0, sing_box_version: '1.12.0' }),
{
...emptyChecks,
sing_box_stock: { status: 'outdated', latest_version: '1.12.9' },
},
);
const stock = cards[1];
expect(stock.tag).toEqual({ label: 'Outdated', kind: 'warning' });
expect(stock.actions[0].kind).toBe('update');
expect(stock.actions[0].backendAction).toBe('install_stable');
expect(stock.actions[0].text).toBe('Install 1.12.9');
});
it('derives an outdated NetShift card from systemInfo latest mismatch', () => {
const cards = getComponentCards(
makeSystemInfo({
netshift_version: '1.0.0',
netshift_latest_version: '1.1.0',
}),
emptyChecks,
);
const netshift = cards[0];
expect(netshift.tag).toEqual({ label: 'Outdated', kind: 'warning' });
expect(netshift.actions[0].kind).toBe('self_update');
expect(netshift.actions[0].backendAction).toBe('self_update');
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,
);
const netshift = cards[0];
expect(netshift.tag).toEqual({ label: 'Latest', kind: 'success' });
// The NetShift check is a DISTINCT kind so it can never be routed to the
// sing-box check method.
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,
);
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(
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.
const cards = getComponentCards(
makeSystemInfo({
netshift_version: '1.0.0',
netshift_latest_version: '1.0.0',
}),
{
...emptyChecks,
netshift: { status: 'outdated', latest_version: '9.9.9' },
},
);
const netshift = cards[0];
expect(netshift.tag).toEqual({ label: 'Latest', kind: 'success' });
expect(netshift.actions[0].kind).toBe('check_netshift');
});
it('treats an unknown NetShift latest as no status (Check update, no badge)', () => {
const cards = getComponentCards(
makeSystemInfo({
netshift_version: '1.0.0',
netshift_latest_version: 'unknown',
}),
emptyChecks,
);
const netshift = cards[0];
expect(netshift.tag).toBeUndefined();
expect(netshift.actions[0].kind).toBe('check_netshift');
});
});

View File

@ -175,6 +175,7 @@ export namespace NetShift {
bootstrap_dns_server: string;
bootstrap_dns_status: 0 | 1;
dhcp_config_status: 0 | 1;
dns_via_outbound_tag?: string;
}
export interface NftRulesCheckResult {
@ -182,7 +183,6 @@ export namespace NetShift {
rules_mangle_exist: 0 | 1;
rules_mangle_counters: 0 | 1;
rules_mangle_output_exist: 0 | 1;
rules_mangle_output_counters: 0 | 1;
rules_proxy_exist: 0 | 1;
rules_proxy_counters: 0 | 1;
rules_other_mark_exist: 0 | 1;
@ -229,4 +229,23 @@ export namespace NetShift {
}
export type GetClashApiGroupLatency = Record<string, number>;
// Component Manager (task-018) — consumes the STABLE backend contract from
// task-017. Status union returned by the sync update-check actions.
export type ComponentUpdateStatus =
| 'latest'
| 'outdated'
| 'dev'
| 'not_installed';
// Shape echoed by the sync update-check actions:
// component_action sing_box check_update (extended)
// component_action sing_box check_update_stable (stock)
export interface ComponentCheckUpdateResult {
success: boolean;
current_version?: string;
latest_version?: string;
status?: ComponentUpdateStatus;
message?: string;
}
}

View File

@ -1,10 +1,11 @@
// language=CSS
import { DashboardTab, DiagnosticTab } from './netshift';
import { DashboardTab, DiagnosticTab, ManagerTab } from './netshift';
import { PartialStyles } from './partials';
export const GlobalStyles = `
${DashboardTab.styles}
${DiagnosticTab.styles}
${ManagerTab.styles}
${PartialStyles}

View File

@ -12,11 +12,23 @@ export const additionalValidDns = [
['DoH IP with port 443', '1.1.1.1:443/dns-query'],
['DoH domain', 'cloudflare-dns.com/dns-query'],
['DoH domain with port 443', 'cloudflare-dns.com:443/dns-query'],
['IPv6 address', '2001:db8::1'],
['Bracketed IPv6', '[2001:db8::1]'],
['Bracketed IPv6 with port', '[2001:db8::1]:853'],
['IPv6 DoH path', '2001:db8::1/dns-query'],
['Bracketed IPv6 DoH path with port', '[2001:db8::1]:443/dns-query'],
];
export const additionalInvalidDns = [
['IPv6 invalid hex', '2001:db8::zzzz'],
['IPv6 group too long', '12345::1'],
['IPv6 triple colon only', ':::'],
['IPv6 multiple compressions', '1::2::3'],
];
const validDns = [...validIPs, ...validDomains, ...additionalValidDns];
const invalidDns = [...invalidIPs, ...invalidDomains];
const invalidDns = [...invalidIPs, ...invalidDomains, ...additionalInvalidDns];
describe('validateDns', () => {
describe.each(validDns)('Valid dns: %s', (_desc, domain) => {

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { validateIPV4 } from '../validateIp';
import { validateIP, validateIPV4, validateIPV6 } from '../validateIp';
export const validIPs = [
['Private LAN', '192.168.1.1'],
@ -21,6 +21,29 @@ export const invalidIPs = [
['Trailing dot', '1.2.3.'],
];
export const validIPv6 = [
['Loopback', '::1'],
['Compressed', '2001:db8::1'],
['Full form', '2001:0db8:85a3:0000:0000:8a2e:0370:7334'],
['Bracketed', '[2001:db8::1]'],
['Unspecified', '::'],
['Compressed middle', '2001:db8:0:0:1::1'],
['IPv4-mapped', '::ffff:192.168.1.1'],
['IPv4-embedded', '2001:db8::192.168.1.1'],
];
export const invalidIPv6 = [
['Invalid hex', '2001:db8::zzzz'],
['Group too long', '12345::1'],
['Too many groups', '2001:db8:85a3:0:0:8a2e:370:7334:1234'],
['Triple colon only', ':::'],
['Colon run inside', '1:2:::3'],
['Multiple compressions', '1::2::3'],
['Incomplete (7 groups, no ::)', '1:2:3:4:5:6:7'],
['Bad hex group', 'gggg::1'],
['Too many groups (9)', '1:2:3:4:5:6:7:8:9'],
];
describe('validateIPV4', () => {
describe.each(validIPs)('Valid IP: %s', (_desc, ip) => {
it(`returns {valid:true} for "${ip}"`, () => {
@ -36,3 +59,41 @@ describe('validateIPV4', () => {
});
});
});
describe('validateIPV6', () => {
describe.each(validIPv6)('Valid IPv6: %s', (_desc, ip) => {
it(`returns {valid:true} for "${ip}"`, () => {
const res = validateIPV6(ip);
expect(res.valid).toBe(true);
});
});
describe.each([...invalidIPv6, ['IPv4 address', '192.168.1.1']])(
'Invalid IPv6: %s',
(_desc, ip) => {
it(`returns {valid:false} for "${ip}"`, () => {
const res = validateIPV6(ip);
expect(res.valid).toBe(false);
});
},
);
});
describe('validateIP', () => {
describe.each([...validIPs, ...validIPv6])('Valid IP: %s', (_desc, ip) => {
it(`returns {valid:true} for "${ip}"`, () => {
const res = validateIP(ip);
expect(res.valid).toBe(true);
});
});
describe.each([...invalidIPs, ...invalidIPv6])(
'Invalid IP: %s',
(_desc, ip) => {
it(`returns {valid:false} for "${ip}"`, () => {
const res = validateIP(ip);
expect(res.valid).toBe(false);
});
},
);
});

View File

@ -8,6 +8,10 @@ export const validSubnets = [
['CIDR /32', '172.16.0.1/32'],
['Loopback', '127.0.0.1'],
['Broadcast with mask', '255.255.255.255/32'],
['IPv6 loopback', '::1'],
['IPv6 with CIDR /0', '::/0'],
['IPv6 with CIDR /32', '2001:db8::/32'],
['IPv6 with CIDR /128', '2001:db8::1/128'],
];
export const invalidSubnets = [
@ -22,6 +26,12 @@ export const invalidSubnets = [
['Invalid CIDR (negative)', '192.168.1.1/-1'],
['CIDR not number', '192.168.1.1/abc'],
['Forbidden 0.0.0.0', '0.0.0.0'],
['IPv6 invalid hex', '2001:db8::zzzz'],
['IPv6 CIDR too high', '2001:db8::1/129'],
['IPv6 CIDR negative', '2001:db8::1/-1'],
['IPv6 CIDR not number', '2001:db8::1/abc'],
['IPv6 triple colon with CIDR', ':::/64'],
['IPv6 multiple compressions with CIDR', '1::2::3/64'],
];
describe('validateSubnet', () => {

View File

@ -8,6 +8,9 @@ const validUrls = [
['With query', 'https://example.com/?q=test'],
['With port', 'http://example.com:8080'],
['With subdomain', 'https://sub.example.com'],
['IPv4 host with port and path', 'https://91.199.111.52:2096/sub/abc'],
['IPv4 host with path', 'http://10.0.0.1/x'],
['Bracketed IPv6 host with port and path', 'https://[2001:db8::1]:2096/sub'],
];
const invalidUrls = [
@ -17,6 +20,9 @@ const invalidUrls = [
['Unsupported protocol (ws)', 'ws://example.com'],
['Empty string', ''],
['Without tld', 'https://google'],
['Bad IPv4 host', 'https://999.1.1.1/x'],
['Bad protocol with IP host', 'ftp://1.2.3.4'],
['No host', 'https://'],
];
describe('validateUrl', () => {

View File

@ -1,5 +1,5 @@
import { validateDomain } from './validateDomain';
import { validateIPV4 } from './validateIp';
import { validateIPV4, validateIPV6 } from './validateIp';
import { ValidationResult } from './types';
export function validateDNS(value: string): ValidationResult {
@ -7,13 +7,29 @@ export function validateDNS(value: string): ValidationResult {
return { valid: false, message: _('DNS server address cannot be empty') };
}
const cleanedValueWithoutPort = value.replace(/:(\d+)(?=\/|$)/, '');
const cleanedIpWithoutPath = cleanedValueWithoutPort.split('/')[0];
const valueBeforePath = value.split('/')[0];
let cleanedValueWithoutPort = value;
let cleanedIpWithoutPath = valueBeforePath;
if (valueBeforePath.startsWith('[')) {
const closingBracketIndex = valueBeforePath.indexOf(']');
if (closingBracketIndex > 0) {
cleanedIpWithoutPath = valueBeforePath.slice(1, closingBracketIndex);
}
} else if ((valueBeforePath.match(/:/g) || []).length < 2) {
cleanedValueWithoutPort = value.replace(/:(\d+)(?=\/|$)/, '');
cleanedIpWithoutPath = cleanedValueWithoutPort.split('/')[0];
}
if (validateIPV4(cleanedIpWithoutPath).valid) {
return { valid: true, message: _('Valid') };
}
if (validateIPV6(cleanedIpWithoutPath).valid) {
return { valid: true, message: _('Valid') };
}
if (validateDomain(cleanedValueWithoutPort).valid) {
return { valid: true, message: _('Valid') };
}
@ -21,7 +37,7 @@ export function validateDNS(value: string): ValidationResult {
return {
valid: false,
message: _(
'Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH',
'Invalid DNS server format. Examples: 8.8.8.8, [::1], dns.example.com, or dns.example.com/dns-query for DoH',
),
};
}

View File

@ -10,3 +10,102 @@ export function validateIPV4(ip: string): ValidationResult {
return { valid: false, message: _('Invalid IP address') };
}
const HEXTET_REGEX = /^[0-9a-fA-F]{1,4}$/;
function isHextet(group: string): boolean {
return HEXTET_REGEX.test(group);
}
function isEmbeddedIPv4(group: string): boolean {
return validateIPV4(group).valid;
}
// Validates one side (the part before or after "::") as a list of hextets.
// The trailing group may be a dotted IPv4 (embedded/IPv4-mapped IPv6), which
// counts as TWO 16-bit groups. Returns the 16-bit group count, or null on any
// invalid group.
function countGroups(side: string, allowEmbeddedIPv4: boolean): number | null {
if (side === '') {
return 0;
}
const groups = side.split(':');
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
const isLast = i === groups.length - 1;
if (allowEmbeddedIPv4 && isLast && group.includes('.')) {
if (!isEmbeddedIPv4(group)) {
return null;
}
continue;
}
if (!isHextet(group)) {
return null;
}
}
// An embedded IPv4 tail occupies two 16-bit groups instead of one.
const lastGroup = groups[groups.length - 1];
const embeddedExtra =
allowEmbeddedIPv4 && lastGroup.includes('.') && isEmbeddedIPv4(lastGroup)
? 1
: 0;
return groups.length + embeddedExtra;
}
export function validateIPV6(ip: string): ValidationResult {
const stripped = ip.replace(/^\[/, '').replace(/\]$/, '');
const invalid: ValidationResult = {
valid: false,
message: _('Invalid IPv6 address'),
};
// At most one "::" compression is allowed.
const doubleColonCount = stripped.split('::').length - 1;
if (doubleColonCount > 1) {
return invalid;
}
if (doubleColonCount === 1) {
const [head, tail] = stripped.split('::');
const headGroups = countGroups(head, true);
const tailGroups = countGroups(tail, true);
if (headGroups === null || tailGroups === null) {
return invalid;
}
// "::" must replace at least one group, so the explicit groups can total
// at most 7 (it stands in for one or more zero groups).
if (headGroups + tailGroups > 7) {
return invalid;
}
return { valid: true, message: _('Valid') };
}
// No "::" → must be exactly 8 groups, all explicit.
const totalGroups = countGroups(stripped, true);
if (totalGroups === 8) {
return { valid: true, message: _('Valid') };
}
return invalid;
}
export function validateIP(ip: string): ValidationResult {
const ipv4 = validateIPV4(ip);
if (ipv4.valid) {
return ipv4;
}
return validateIPV6(ip);
}

View File

@ -1,39 +1,59 @@
import { ValidationResult } from './types';
import { validateIPV4 } from './validateIp';
import { validateIPV4, validateIPV6 } from './validateIp';
export function validateSubnet(value: string): ValidationResult {
// Must be in form X.X.X.X or X.X.X.X/Y
const subnetRegex = /^(\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?$/;
if (!subnetRegex.test(value)) {
return {
valid: false,
message: _('Invalid format. Use X.X.X.X or X.X.X.X/Y'),
};
}
if (subnetRegex.test(value)) {
const [ip, cidr] = value.split('/');
const [ip, cidr] = value.split('/');
if (ip === '0.0.0.0') {
return { valid: false, message: _('IP address 0.0.0.0 is not allowed') };
}
const ipCheck = validateIPV4(ip);
if (!ipCheck.valid) {
return ipCheck;
}
// Validate CIDR if present
if (cidr) {
const cidrNum = parseInt(cidr, 10);
if (cidrNum < 0 || cidrNum > 32) {
return {
valid: false,
message: _('CIDR must be between 0 and 32'),
};
if (ip === '0.0.0.0') {
return { valid: false, message: _('IP address 0.0.0.0 is not allowed') };
}
const ipCheck = validateIPV4(ip);
if (!ipCheck.valid) {
return ipCheck;
}
if (cidr) {
const cidrNum = parseInt(cidr, 10);
if (cidrNum < 0 || cidrNum > 32) {
return {
valid: false,
message: _('CIDR must be between 0 and 32'),
};
}
}
return { valid: true, message: _('Valid') };
}
return { valid: true, message: _('Valid') };
const ipv6CidrRegex = /^([0-9a-fA-F:]+(?:\/[0-9]{1,3})?)$/;
if (ipv6CidrRegex.test(value)) {
const [ip, cidr] = value.split('/');
const ipCheck = validateIPV6(ip);
if (!ipCheck.valid) {
return ipCheck;
}
if (cidr) {
const cidrNum = parseInt(cidr, 10);
if (cidrNum < 0 || cidrNum > 128) {
return {
valid: false,
message: _('IPv6 CIDR must be between 0 and 128'),
};
}
}
return { valid: true, message: _('Valid') };
}
return {
valid: false,
message: _('Invalid format. Use X.X.X.X/Y or IPv6/Y'),
};
}

View File

@ -1,4 +1,45 @@
import { ValidationResult } from './types';
import { validateDomain } from './validateDomain';
import { validateIPV4, validateIPV6 } from './validateIp';
// Extracts the bare host from a URL, stripping the scheme, optional userinfo,
// the port, and the path/query/fragment. A bracketed IPv6 literal (e.g.
// "[2001:db8::1]") is unwrapped to "2001:db8::1".
function extractHost(url: string): string {
// Strip scheme://
const schemeIndex = url.indexOf('://');
let rest = schemeIndex === -1 ? url : url.slice(schemeIndex + 3);
// Strip path/query/fragment (everything from the first '/', '?' or '#').
const pathIndex = rest.search(/[/?#]/);
if (pathIndex !== -1) {
rest = rest.slice(0, pathIndex);
}
// Strip optional userinfo ("user:pass@").
const atIndex = rest.lastIndexOf('@');
if (atIndex !== -1) {
rest = rest.slice(atIndex + 1);
}
// Bracketed IPv6 literal: "[2001:db8::1]:2096" -> "2001:db8::1".
if (rest.startsWith('[')) {
const closeIndex = rest.indexOf(']');
if (closeIndex !== -1) {
return rest.slice(1, closeIndex);
}
// Unterminated bracket: drop the leading '[' and any ':port' suffix.
return rest.slice(1).split(':')[0];
}
// Bare host: strip a trailing ":port".
const colonIndex = rest.lastIndexOf(':');
if (colonIndex !== -1) {
rest = rest.slice(0, colonIndex);
}
return rest;
}
export function validateUrl(
url: string,
@ -19,12 +60,18 @@ export function validateUrl(
protocols.join(', '),
};
const regex = new RegExp(
`^(?:${protocols.map((p) => p.replace(':', '')).join('|')})://` +
`(?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?$`,
);
const host = extractHost(url);
if (regex.test(url)) {
if (!host) {
return { valid: false, message: _('Invalid URL format') };
}
const isValidHost =
validateIPV4(host).valid ||
validateIPV6(host).valid ||
validateDomain(host).valid;
if (isValidHost) {
return { valid: true, message: _('Valid') };
}

View File

@ -0,0 +1,22 @@
"use strict";
"require baseclass";
"require form";
"require ui";
"require uci";
"require fs";
"require view.netshift.main as main";
function createManagerContent(section) {
const o = section.option(form.DummyValue, "_mount_node");
o.rawhtml = true;
o.cfgvalue = () => {
main.ManagerTab.initController();
return main.ManagerTab.render();
};
}
const EntryPoint = {
createManagerContent,
};
return baseclass.extend(EntryPoint);

View File

@ -17,6 +17,9 @@
// Diagnostic content
"require view.netshift.diagnostic as diagnostic";
// Component Manager content
"require view.netshift.manager as manager";
const EntryPoint = {
async render() {
main.injectGlobalStyles();
@ -73,6 +76,21 @@ const EntryPoint = {
// Render diagnostic content
diagnostic.createDiagnosticContent(diagnosticSection);
// Component Manager tab
const managerSection = netshiftMap.section(
form.TypedSection,
"manager",
_("Component Manager"),
);
managerSection.anonymous = true;
managerSection.addremove = false;
managerSection.cfgsections = function () {
return ["manager"];
};
// Render Component Manager content
manager.createManagerContent(managerSection);
// Dashboard tab
const dashboardSection = netshiftMap.section(
form.TypedSection,

View File

@ -35,7 +35,9 @@ function createSectionContent(section) {
form.TextValue,
"proxy_string",
_("Proxy Configuration URL"),
_("vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links")
_(
"vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links",
),
);
o.depends({ connection_type: "proxy", proxy_config_type: "url" });
o.rows = 5;
@ -87,7 +89,9 @@ function createSectionContent(section) {
form.Value,
"subscription_url",
_("Subscription URL"),
_("Enter the subscription URL to fetch proxy configurations from your provider"),
_(
"Enter the subscription URL to fetch proxy configurations from your provider",
),
);
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
o.placeholder = "https://example.com/api/sub";
@ -106,6 +110,20 @@ function createSectionContent(section) {
return validation.message;
};
o = section.option(
form.Flag,
"subscription_insecure",
_("Allow insecure TLS for subscription fetch"),
_("Disables TLS certificate verification when downloading the subscription.") +
" " +
_("Use only for IP-host panels that serve an invalid or self-signed certificate.") +
" " +
_("This is a security trade-off: an attacker could intercept the fetch."),
);
o.default = "0";
o.rmempty = false;
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
o = section.option(
form.ListValue,
"subscription_update_interval",
@ -125,7 +143,9 @@ function createSectionContent(section) {
form.Flag,
"subscription_group_by_countries",
_("Группировать по странам"),
_("Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"),
_(
"Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы",
),
);
o.default = "0";
o.rmempty = false;
@ -135,7 +155,9 @@ function createSectionContent(section) {
form.DynamicList,
"subscription_filter_include_keywords",
_("Include servers by keyword"),
_("Keep only subscription servers whose name contains at least one of these keywords (case-insensitive). Leave empty to keep all."),
_(
"Keep only subscription servers whose name contains at least one of these keywords (case-insensitive). Leave empty to keep all.",
),
);
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
o.rmempty = true;
@ -144,7 +166,9 @@ function createSectionContent(section) {
form.DynamicList,
"subscription_filter_exclude_keywords",
_("Exclude servers by keyword"),
_("Drop subscription servers whose name contains any of these keywords (case-insensitive)."),
_(
"Drop subscription servers whose name contains any of these keywords (case-insensitive).",
),
);
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
o.rmempty = true;
@ -153,7 +177,9 @@ function createSectionContent(section) {
form.DynamicList,
"selector_proxy_links",
_("Selector Proxy Links"),
_("vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links")
_(
"vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links",
),
);
o.depends({ connection_type: "proxy", proxy_config_type: "selector" });
o.rmempty = false;
@ -176,7 +202,9 @@ function createSectionContent(section) {
form.DynamicList,
"urltest_proxy_links",
_("URLTest Proxy Links"),
_("vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links")
_(
"vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links",
),
);
o.depends({ connection_type: "proxy", proxy_config_type: "urltest" });
o.rmempty = false;
@ -199,7 +227,7 @@ function createSectionContent(section) {
form.ListValue,
"urltest_check_interval",
_("URLTest Check Interval"),
_("The interval between connectivity tests")
_("The interval between connectivity tests"),
);
o.value("30s", _("Every 30 seconds"));
o.value("1m", _("Every 1 minute"));
@ -213,7 +241,9 @@ function createSectionContent(section) {
form.Value,
"urltest_tolerance",
_("URLTest Tolerance"),
_("The maximum difference in response times (ms) allowed when comparing servers")
_(
"The maximum difference in response times (ms) allowed when comparing servers",
),
);
o.default = "50";
o.rmempty = false;
@ -226,23 +256,38 @@ function createSectionContent(section) {
const parsed = parseFloat(value);
if (/^[0-9]+$/.test(value) && !isNaN(parsed) && isFinite(parsed) && parsed >= 50 && parsed <= 1000) {
if (
/^[0-9]+$/.test(value) &&
!isNaN(parsed) &&
isFinite(parsed) &&
parsed >= 50 &&
parsed <= 1000
) {
return true;
}
return _('Must be a number in the range of 50 - 1000');
return _("Must be a number in the range of 50 - 1000");
};
o = section.option(
form.Value,
"urltest_testing_url",
_("URLTest Testing URL"),
_("The URL used to test server connectivity")
_("The URL used to test server connectivity"),
);
o.value(
"https://www.gstatic.com/generate_204",
"https://www.gstatic.com/generate_204 (Google)",
);
o.value(
"https://cp.cloudflare.com/generate_204",
"https://cp.cloudflare.com/generate_204 (Cloudflare)",
);
o.value("https://www.gstatic.com/generate_204", "https://www.gstatic.com/generate_204 (Google)");
o.value("https://cp.cloudflare.com/generate_204", "https://cp.cloudflare.com/generate_204 (Cloudflare)");
o.value("https://captive.apple.com", "https://captive.apple.com (Apple)");
o.value("https://connectivity-check.ubuntu.com", "https://connectivity-check.ubuntu.com (Ubuntu)")
o.value(
"https://connectivity-check.ubuntu.com",
"https://connectivity-check.ubuntu.com (Ubuntu)",
);
o.default = "https://www.gstatic.com/generate_204";
o.rmempty = false;
o.depends({ connection_type: "proxy", proxy_config_type: "urltest" });
@ -272,6 +317,23 @@ function createSectionContent(section) {
o.depends("connection_type", "proxy");
o.rmempty = false;
o = section.option(
form.Flag,
"global_proxy",
_("Global Proxy"),
_("Route all unmatched traffic through this section's outbound.") +
" " +
_(
"When enabled, traffic not matching any other section's lists will go through this proxy.",
) +
" " +
_("Use with Exclusion sections to route specific domains directly.") +
" " +
_("Only one section can be global at a time."),
);
o.default = "0";
o.rmempty = false;
o = section.option(
widgets.DeviceSelect,
"interface",
@ -368,7 +430,7 @@ function createSectionContent(section) {
"community_lists",
_("Community Lists"),
_("Select a predefined list for routing") +
' <a href="https://github.com/itdoginfo/allow-domains" target="_blank">github.com/itdoginfo/allow-domains</a>',
' <a href="https://github.com/itdoginfo/allow-domains" target="_blank">github.com/itdoginfo/allow-domains</a>',
);
o.placeholder = "Service list";
Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => {
@ -575,7 +637,7 @@ function createSectionContent(section) {
_("User Subnets List"),
_(
"Enter subnets in CIDR notation or single IP addresses, separated by commas, spaces, or newlines. " +
"You can add comments using //",
"You can add comments using //",
),
);
o.placeholder =
@ -748,7 +810,7 @@ function createSectionContent(section) {
_("Mixed Proxy Port"),
_(
"Specify the port number on which the mixed proxy will run for this section. " +
"Make sure the selected port is not used by another service",
"Make sure the selected port is not used by another service",
),
);
o.default = "2080";

View File

@ -62,6 +62,51 @@ function createSettingsContent(section) {
return validation.message;
};
o = section.option(
form.Flag,
"dns_via_outbound",
_("Route main DNS through proxy/VPN"),
_(
"Send upstream DNS queries through a proxy/VPN outbound instead of directly. Bootstrap DNS always stays direct.",
),
);
o.default = "0";
o.rmempty = false;
o = section.option(
form.ListValue,
"dns_outbound_section",
_("DNS outbound section"),
_(
"Which proxy/VPN section carries the DNS. Leave unset to use the first configured outbound.",
),
);
o.rmempty = true;
o.depends("dns_via_outbound", "1");
o.cfgvalue = function (section_id) {
return uci.get("netshift", section_id, "dns_outbound_section");
};
o.load = function () {
const sections = this.map?.data?.state?.values?.netshift ?? {};
this.keylist = [];
this.vallist = [];
for (const secName in sections) {
const sec = sections[secName];
if (
sec[".type"] === "section" &&
sec["connection_type"] !== "block" &&
sec["connection_type"] !== "exclusion"
) {
this.keylist.push(secName);
this.vallist.push(secName);
}
}
return Promise.resolve();
};
o = section.option(
form.Value,
"dns_rewrite_ttl",
@ -148,9 +193,7 @@ function createSettingsContent(section) {
}
// Reject lan*
if (
value.startsWith("lan")
) {
if (value.startsWith("lan")) {
return false;
}
@ -244,7 +287,9 @@ function createSettingsContent(section) {
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."),
_(
"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";
@ -254,7 +299,9 @@ function createSettingsContent(section) {
form.Value,
"yacd_secret_key",
_("YACD Secret Key"),
_("Secret key for authenticating remote access to YACD when WAN access is enabled."),
_(
"Secret key for authenticating remote access to YACD when WAN access is enabled.",
),
);
o.depends("enable_yacd_wan_access", "1");
o.rmempty = false;
@ -311,7 +358,11 @@ function createSettingsContent(section) {
for (const secName in sections) {
const sec = sections[secName];
if (sec[".type"] === "section" && sec['connection_type'] !== 'block' && sec['connection_type'] !== 'exclusion') {
if (
sec[".type"] === "section" &&
sec["connection_type"] !== "block" &&
sec["connection_type"] !== "exclusion"
) {
this.keylist.push(secName);
this.vallist.push(secName);
}
@ -382,9 +433,7 @@ function createSettingsContent(section) {
form.ListValue,
"log_level",
_("Log Level"),
_(
"Select the log level for sing-box",
),
_("Select the log level for sing-box"),
);
o.value("trace", "Trace");
o.value("debug", "Debug");
@ -407,6 +456,38 @@ function createSettingsContent(section) {
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",
@ -421,7 +502,7 @@ function createSettingsContent(section) {
return true;
}
const validation = main.validateIPV4(value);
const validation = main.validateIP(value);
if (validation.valid) {
return true;

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: NETSHIFT\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-05 09:28+0300\n"
"PO-Revision-Date: 2026-06-05 09:28+0300\n"
"POT-Creation-Date: 2026-06-06 00:53+0300\n"
"PO-Revision-Date: 2026-06-06 00:53+0300\n"
"Last-Translator: yandexru45\n"
"Language-Team: none\n"
"Language: ru\n"
@ -29,18 +29,18 @@ msgstr "✘ Отключено"
msgid "✘ Stopped"
msgstr "✘ Остановлен"
msgid "Группировать по странам"
msgstr ""
msgid "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
msgstr ""
msgid "Active Connections"
msgstr "Активные соединения"
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 "Allow insecure TLS for subscription fetch"
msgstr "Разрешить небезопасный TLS при загрузке подписки"
msgid "Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."
msgstr "Обеспечивает доступ к YACD из WAN. Убедитесь, что в брандмауэре открыт соответствующий порт."
@ -56,6 +56,12 @@ msgstr "Необходимо указать хотя бы одну действ
msgid "Available actions"
msgstr "Доступные действия"
msgid "Block direct connections to known public DNS-over-HTTPS (DoH) servers."
msgstr "Блокирует прямые подключения к известным публичным серверам DNS-over-HTTPS (DoH)."
msgid "Block DoH Servers"
msgstr "Блокировать DoH-серверы"
msgid "Bootsrap DNS"
msgstr "Bootstrap DNS"
@ -77,6 +83,9 @@ msgstr "Путь к файлу кэша не может быть пустым"
msgid "Cannot receive checks result"
msgstr "Не удалось получить результаты проверки"
msgid "Check update"
msgstr "Проверить обновление"
msgid "Checking, please wait"
msgstr "Проверяем, пожалуйста подождите"
@ -98,11 +107,14 @@ msgstr "Закрыть"
msgid "Community Lists"
msgstr "Списки сообщества"
msgid "Component Manager"
msgstr "Менеджер компонентов"
msgid "Config File Path"
msgstr "Путь к файлу конфигурации"
msgid "Configuration for NetShift service"
msgstr ""
msgstr "Конфигурация службы NetShift"
msgid "Configuration Type"
msgstr "Тип конфигурации"
@ -132,11 +144,14 @@ msgid "Dashboard currently unavailable"
msgstr "Дашборд сейчас недоступен"
msgid "Delay in milliseconds before reloading NetShift after interface UP"
msgstr ""
msgstr "Задержка в миллисекундах перед перезагрузкой NetShift после поднятия интерфейса"
msgid "Delay value cannot be empty"
msgstr "Значение задержки не может быть пустым"
msgid "Dev"
msgstr "Dev"
msgid "DHCP has DNS server"
msgstr "DHCP содержит DNS сервер"
@ -155,9 +170,15 @@ msgstr "Отключить QUIC протокол для улучшения со
msgid "Disabled"
msgstr "Отключено"
msgid "Disables TLS certificate verification when downloading the subscription."
msgstr "Отключает проверку TLS-сертификата при загрузке подписки."
msgid "DNS on router"
msgstr "DNS на роутере"
msgid "DNS outbound section"
msgstr "Секция outbound для DNS"
msgid "DNS over HTTPS (DoH)"
msgstr "DNS через HTTPS (DoH)"
@ -215,6 +236,12 @@ msgstr "Включить встроенный DNS-резолвер для дом
msgid "Enable DNS resolve to get real IP when routing"
msgstr "Разрешать домены в реальные IP-адреса перед маршрутизацией в outbound"
msgid "Enable IPv6 Support"
msgstr "Включить поддержку IPv6"
msgid "Enable IPv6 TProxy routing, IPv6 DNS inbound, and IPv6 FakeIP support."
msgstr "Включить маршрутизацию TProxy по IPv6, входящий DNS по IPv6 и поддержку FakeIP для IPv6."
msgid "Enable Mixed Proxy"
msgstr "Включить смешанный прокси"
@ -243,22 +270,22 @@ msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addres
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
msgid "Enter the subscription URL to fetch proxy configurations from your provider"
msgstr ""
msgstr "Введите URL подписки для получения конфигураций прокси от вашего провайдера"
msgid "Every 1 minute"
msgstr "Каждую минуту"
msgid "Every 12 hours"
msgstr ""
msgstr "Каждые 12 часов"
msgid "Every 3 hours"
msgstr ""
msgstr "Каждые 3 часа"
msgid "Every 3 minutes"
msgstr "Каждые 3 минуты"
msgid "Every 30 minutes"
msgstr ""
msgstr "Каждые 30 минут"
msgid "Every 30 seconds"
msgstr "Каждые 30 секунд"
@ -267,13 +294,13 @@ msgid "Every 5 minutes"
msgstr "Каждые 5 минут"
msgid "Every 6 hours"
msgstr ""
msgstr "Каждые 6 часов"
msgid "Every day"
msgstr ""
msgstr "Каждый день"
msgid "Every hour"
msgstr ""
msgstr "Каждый час"
msgid "Exclude NTP"
msgstr "Исключить NTP"
@ -302,8 +329,11 @@ msgstr "Получить глобальную проверку"
msgid "Global check"
msgstr "Глобальная проверка"
msgid "Global Proxy"
msgstr "Глобальный прокси"
msgid "How often to automatically update the subscription"
msgstr ""
msgstr "Как часто автоматически обновлять подписку"
msgid "HTTP error"
msgstr "Ошибка HTTP"
@ -311,11 +341,11 @@ msgstr "Ошибка HTTP"
msgid "Include servers by keyword"
msgstr "Включать серверы по ключевому слову"
msgid "Install extended"
msgstr "Установить extended"
msgid "Install %s"
msgstr "Установить %s"
msgid "Install stable"
msgstr "Установить stable"
msgid "Installed version is newer than release"
msgstr "Установленная версия новее релиза"
msgid "Interface Monitoring"
msgstr "Мониторинг интерфейса"
@ -326,14 +356,14 @@ msgstr "Задержка при мониторинге интерфейсов"
msgid "Interface monitoring for Bad WAN"
msgstr "Мониторинг интерфейса для Bad WAN"
msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH"
msgid "Invalid DNS server format. Examples: 8.8.8.8, [::1], dns.example.com, or dns.example.com/dns-query for DoH"
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, [::1], dns.example.com или dns.example.com/dns-query для DoH"
msgid "Invalid domain address"
msgstr "Неверный домен"
msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y"
msgstr "Неверный формат. Используйте X.X.X.X или X.X.X.X/Y"
msgid "Invalid format. Use X.X.X.X/Y or IPv6/Y"
msgstr "Неверный формат. Используйте X.X.X.X/Y или IPv6/Y"
msgid "Invalid HY2 URL: insecure must be 0 or 1"
msgstr "Неверный URL Hysteria2: параметр insecure должен быть 0 или 1"
@ -377,6 +407,9 @@ msgstr "Неверный URL Hysteria2: неподдерживаемый тип
msgid "Invalid IP address"
msgstr "Неверный IP-адрес"
msgid "Invalid IPv6 address"
msgstr "Неверный IPv6-адрес"
msgid "Invalid JSON format"
msgstr "Неверный формат JSON"
@ -479,6 +512,9 @@ msgstr "Неверный URL VMess: должен начинаться с vmess:/
msgid "IP address 0.0.0.0 is not allowed"
msgstr "IP-адрес 0.0.0.0 не допускается"
msgid "IPv6 CIDR must be between 0 and 128"
msgstr "IPv6 CIDR должен быть от 0 до 128"
msgid "Issues detected"
msgstr "Обнаружены проблемы"
@ -488,6 +524,12 @@ msgstr "Оставлять только серверы подписки, имя
msgid "Latest"
msgstr "Последняя"
msgid "Latest version is installed"
msgstr "Установлена последняя версия"
msgid "Latest version is unknown"
msgstr "Последняя версия неизвестна"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
@ -503,6 +545,9 @@ msgstr "Уровень логов"
msgid "Main DNS"
msgstr "Основной DNS"
msgid "Main DNS via outbound"
msgstr "Основной DNS через outbound"
msgid "Memory Usage"
msgstr "Использование памяти"
@ -516,13 +561,16 @@ msgid "Must be a number in the range of 50 - 1000"
msgstr "Должно быть числом от 50 до 1000"
msgid "NetShift"
msgstr ""
msgstr "NetShift"
msgid "NetShift Settings"
msgstr ""
msgstr "Настройки NetShift"
msgid "NetShift updated, version:"
msgstr "NetShift обновлён, версия:"
msgid "NetShift will not modify your DHCP configuration"
msgstr ""
msgstr "NetShift не будет изменять вашу конфигурацию DHCP"
msgid "Network Interface"
msgstr "Сетевой интерфейс"
@ -533,12 +581,21 @@ msgstr "Другие правила маркировки не найдены"
msgid "Not implement yet"
msgstr "Ещё не реализовано"
msgid "Not installed"
msgstr "Не установлено"
msgid "Not responding"
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 "Only one section can be global at a time."
msgstr "Только одна секция может быть глобальной одновременно."
msgid "Operation timed out"
msgstr "Время ожидания истекло"
@ -591,7 +648,13 @@ msgid "Resolve real IP for routing"
msgstr "Разрешение реальных IP-адресов"
msgid "Restart NetShift"
msgstr ""
msgstr "Перезапустить NetShift"
msgid "Route all unmatched traffic through this section's outbound."
msgstr "Направлять весь несовпавший трафик через outbound этой секции."
msgid "Route main DNS through proxy/VPN"
msgstr "Основной DNS через прокси/VPN"
msgid "Router DNS is not routed through sing-box"
msgstr "DNS роутера не проходит через sing-box"
@ -608,9 +671,6 @@ msgstr "Счётчики правил mangle"
msgid "Rules mangle exist"
msgstr "Правила mangle существуют"
msgid "Rules mangle output counters"
msgstr "Счётчики правил mangle output"
msgid "Rules mangle output exist"
msgstr "Правила mangle output существуют"
@ -686,6 +746,12 @@ msgstr "Selector"
msgid "Selector Proxy Links"
msgstr "Ссылки прокси для Selector"
msgid "Self-update failed"
msgstr "Не удалось обновить"
msgid "Send upstream DNS queries through a proxy/VPN outbound instead of directly. Bootstrap DNS always stays direct."
msgstr "Отправлять запросы к основному DNS через outbound прокси/VPN вместо прямого подключения. Bootstrap DNS всегда остаётся прямым."
msgid "Services info"
msgstr "Информация о сервисах"
@ -738,23 +804,29 @@ msgid "Specify the path to the list file located on the router filesystem"
msgstr "Укажите путь к файлу списка, расположенному в файловой системе маршрутизатора."
msgid "Start NetShift"
msgstr ""
msgstr "Запустить NetShift"
msgid "Stop NetShift"
msgstr ""
msgstr "Остановить NetShift"
msgid "Subscription"
msgstr ""
msgstr "Подписка"
msgid "Subscription Update Interval"
msgstr ""
msgstr "Интервал обновления подписки"
msgid "Subscription URL"
msgstr ""
msgstr "URL подписки"
msgid "Successfully copied!"
msgstr "Успешно скопировано!"
msgid "Switch to extended"
msgstr "Переключить на extended"
msgid "Switch to stable"
msgstr "Переключить на stable"
msgid "Switching sing-box core, this may take a few minutes…"
msgstr "Переключение ядра sing-box, это может занять несколько минут…"
@ -785,6 +857,12 @@ msgstr "Максимально допустимая разница во врем
msgid "The URL used to test server connectivity"
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)"
@ -815,6 +893,18 @@ msgstr "неизвестно"
msgid "Unknown error"
msgstr "Неизвестная ошибка"
msgid "Update"
msgstr "Обновить"
msgid "Update is available"
msgstr "Доступно обновление"
msgid "Update NetShift"
msgstr "Обновить NetShift"
msgid "Updating NetShift, this may take a few minutes; the page will reload…"
msgstr "Обновление NetShift, это может занять несколько минут; страница перезагрузится…"
msgid "Uplink"
msgstr "Исходящий"
@ -839,6 +929,15 @@ msgstr "URLTest ссылка для проверки"
msgid "URLTest Tolerance"
msgstr "URLTest допустимое отклонение"
msgid "Use only for IP-host panels that serve an invalid or self-signed certificate."
msgstr "Используйте только для панелей с IP-адресом, у которых недействительный или самоподписанный сертификат."
msgid "Use this only when the router has working IPv6 connectivity."
msgstr "Используйте это только если на роутере есть рабочее подключение по IPv6."
msgid "Use with Exclusion sections to route specific domains directly."
msgstr "Используйте вместе с секциями исключений для прямой маршрутизации определённых доменов."
msgid "User Domain List Type"
msgstr "Тип пользовательского списка доменов"
@ -863,6 +962,9 @@ msgstr "Валидно"
msgid "Validation errors:"
msgstr "Ошибки валидации:"
msgid "Version"
msgstr "Версия"
msgid "View logs"
msgstr "Посмотреть логи"
@ -878,8 +980,20 @@ msgstr "Предупреждение: %s нельзя использовать
msgid "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr "Предупреждение: Russia inside может быть использован только с %s. %s уже есть в Russia inside и будет удален из выбранных."
msgid "When enabled, traffic not matching any other section's lists will go through this proxy."
msgstr "Когда включено, трафик, не совпадающий со списками других секций, будет идти через этот прокси."
msgid "Which proxy/VPN section carries the DNS. Leave unset to use the first configured outbound."
msgstr "Какая секция прокси/VPN обслуживает DNS. Оставьте пустым, чтобы использовать первый настроенный outbound."
msgid "YACD Secret Key"
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

View File

@ -13,12 +13,16 @@ config settings 'settings'
option disable_quic '0'
option update_interval '1d'
option download_lists_via_proxy '0'
#option dns_via_outbound '0'
#option dns_outbound_section 'main'
option dont_touch_dhcp '0'
option config_path '/etc/sing-box/config.json'
option cache_path '/tmp/sing-box/cache.db'
option log_level 'warn'
option exclude_ntp '0'
option shutdown_correctly '0'
option block_doh '0'
option enable_ipv6 '0'
#list routing_excluded_ips '192.168.1.3'
config section 'main'
@ -26,6 +30,7 @@ config section 'main'
option proxy_config_type 'url'
option proxy_string ''
option enable_udp_over_tcp '0'
option global_proxy '0'
list community_lists 'russia_inside'
#option user_domain_list_type 'dynamic'
#list user_domains '2ip.ru'
@ -43,6 +48,10 @@ config section 'main'
# option connection_type 'proxy'
# option proxy_config_type 'subscription'
# option subscription_url 'https://example.com/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'
# option subscription_update_interval '1h'
# #option subscription_group_by_countries '0'
# #option urltest_check_interval '3m'

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,7 @@ RT_TABLE_NAME="netshift"
## nft
NFT_TABLE_NAME="NetShiftTable"
NFT_LOCALV4_SET_NAME="localv4"
NFT_COMMON_SET_NAME="netshift_subnets"
NFT_LOCALV6_SET_NAME="localv6"
NFT_DISCORD_SET_NAME="netshift_discord_subnets"
NFT_INTERFACE_SET_NAME="interfaces"
NFT_FAKEIP_MARK="0x00100000"
@ -38,6 +38,11 @@ NFT_OUTBOUND_MARK="0x00200000"
## sing-box
SB_REQUIRED_VERSION="1.12.0"
# Monitoring
MONITOR_CHECK_INTERVAL=10
MONITOR_MAX_CRASHES=5
MONITOR_BACKOFF_BASE=10
MONITOR_BACKOFF_MAX=300
# Core-switch connectivity self-heal (task-009). Hosts probed before a core
# swap, depending on direction: the stable (stock) install pulls from the
# OpenWrt package feeds, the extended install pulls from the GitHub API.
@ -53,10 +58,26 @@ UPDATES_RESOLV_BACKUP="/tmp/netshift-resolv.conf.bak"
# locations; tests override them.
UPDATES_SING_BOX_BIN="/usr/bin/sing-box"
UPDATES_LIBCRONET_LIB="/usr/lib/libcronet.so"
# Component Manager — NetShift self-update (task-017). The GitHub latest-release
# API for NetShift itself (same endpoint install.sh and get_system_info use);
# the self-update worker downloads the release .ipk/.apk assets from it.
NETSHIFT_RELEASE_API_URL="https://api.github.com/repos/yandexru45/netshift/releases/latest"
# 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"
# tmpfs backup of /etc/config/netshift taken before the self-update package
# install (conffiles normally preserve it; this is the defensive belt).
UPDATES_NETSHIFT_CONFIG_BACKUP="/tmp/netshift/config.bak"
# NetShift package names handled by the self-update (in install order). The RU
# i18n package is upgraded ONLY if already installed (never newly installed).
UPDATES_NETSHIFT_PKG_CORE="netshift"
UPDATES_NETSHIFT_PKG_LUCI="luci-app-netshift"
UPDATES_NETSHIFT_PKG_I18N_RU="luci-i18n-netshift-ru"
# DNS
SB_DNS_SERVER_TAG="dns-server"
SB_FAKEIP_DNS_SERVER_TAG="fakeip-server"
SB_FAKEIP_INET4_RANGE="198.18.0.0/15"
SB_FAKEIP_INET6_RANGE="fd00:ec3a::/32"
SB_BOOTSTRAP_SERVER_TAG="bootstrap-dns-server"
SB_FAKEIP_DNS_RULE_TAG="fakeip-dns-rule-tag"
SB_INVERT_FAKEIP_DNS_RULE_TAG="invert-fakeip-dns-rule-tag"
@ -64,9 +85,13 @@ SB_INVERT_FAKEIP_DNS_RULE_TAG="invert-fakeip-dns-rule-tag"
SB_TPROXY_INBOUND_TAG="tproxy-in"
SB_TPROXY_INBOUND_ADDRESS="127.0.0.1"
SB_TPROXY_INBOUND_PORT=1602
SB_TPROXY_INBOUND_ADDRESS_V6="::1"
SB_TPROXY_INBOUND_PORT_V6=1603
SB_DNS_INBOUND_TAG="dns-in"
SB_DNS_INBOUND_ADDRESS="127.0.0.42"
SB_DNS_INBOUND_PORT=53
SB_DNS_INBOUND_ADDRESS_V6="::1"
SB_DNS_INBOUND_PORT_V6=5354
SB_SERVICE_MIXED_INBOUND_TAG="service-mixed-in"
SB_SERVICE_MIXED_INBOUND_ADDRESS="127.0.0.1"
SB_SERVICE_MIXED_INBOUND_PORT=4534
@ -75,9 +100,14 @@ SB_DIRECT_OUTBOUND_TAG="direct-out"
# Route
SB_REJECT_RULE_TAG="reject-rule-tag"
SB_EXCLUSION_RULE_TAG="exclusion-rule-tag"
SB_DOH_BLOCK_RULE_TAG="doh-block-rule-tag"
# Experimental
SB_CLASH_API_CONTROLLER_PORT=9090
## DoH blocking
DOH_BLOCK_IPV4_CIDRS="1.1.1.1/32 1.0.0.1/32 8.8.8.8/32 8.8.4.4/32 9.9.9.9/32 9.9.9.11/32 149.112.112.112/32 208.67.222.222/32 208.67.220.220/32 94.140.14.14/32 94.140.15.15/32 77.88.8.8/32 77.88.8.1/32"
DOH_BLOCK_IPV6_CIDRS="2606:4700:4700::1111/128 2606:4700:4700::1001/128 2001:4860:4860::8888/128 2001:4860:4860::8844/128 2620:fe::fe/128 2620:fe::9/128 2620:119:35::35/128 2620:119:53::53/128 2a10:50c0::ad1:ff/128 2a10:50c0::ad2:ff/128 2a02:6b8::feed:0ff/128 2a02:6b8:0:1::feed:0ff/128"
## Lists
GITHUB_RAW_URL="https://raw.githubusercontent.com/itdoginfo/allow-domains/main"
SRS_MAIN_URL="https://github.com/itdoginfo/allow-domains/releases/latest/download"

View File

@ -147,7 +147,11 @@ url_get_host() {
url="${url#*@}"
url="${url%%[/?#]*}"
echo "${url%%:*}"
case "$url" in
\[*\]) echo "${url#\[}" | sed 's/\]$//' ;;
\[*\]*) echo "${url#\[}" | sed 's/\].*//' ;;
*) echo "${url%%:*}" ;;
esac
}
# Extracts the port number from a URL
@ -159,6 +163,7 @@ url_get_port() {
url="${url%%[/?#]*}"
case "$url" in
\[*\]:*) echo "${url##*]:}" ;;
*:*) echo "${url#*:}" ;;
*) echo "" ;;
esac
@ -757,6 +762,43 @@ $candidate
done
}
# Runs a single wget subscription request with the shared client-mimicking
# headers and an optional --no-check-certificate flag, so all branches of
# download_subscription stay byte-identical.
# Arguments:
# $1 - cert flag ("" or "--no-check-certificate")
# $2 - User-Agent header value
# $3 - X-HWID header value
# $4 - X-Device-Model header value
# $5 - X-Ver-OS header value
# $6 - output file path (passed to wget -O)
# $7 - error file path (wget stderr is redirected here)
# $8 - subscription URL
# $9.. - leading wget flags (e.g. -4, -T, <timeout>)
# Caller is responsible for exporting http_proxy/https_proxy when needed.
_wget_subscription_request() {
local cert_flag="$1"
local req_user_agent="$2"
local req_hwid="$3"
local req_device_model="$4"
local req_kernel_version="$5"
local req_outfile="$6"
local req_errfile="$7"
local req_url="$8"
shift 8
# shellcheck disable=SC2086
wget $cert_flag "$@" -O "$req_outfile" \
--header "User-Agent: $req_user_agent" \
--header "X-HWID: $req_hwid" \
--header "X-Device-OS: OpenWrt Linux" \
--header "X-Device-Model: $req_device_model" \
--header "X-Ver-OS: $req_kernel_version" \
--header "Accept-Language: ru-RU,en,*" \
--header "X-Device-Locale: EN" \
"$req_url" 2>"$req_errfile"
}
# Downloads a subscription body from the given URL with client-mimicking headers
# Arguments:
# $1 - subscription URL
@ -766,6 +808,7 @@ $candidate
# $5 - wait between retries (optional, default 2)
# $6 - timeout seconds (optional, default 10)
# $7 - User-Agent (optional; default "singbox/<version>")
# $8 - insecure (optional, default 0; when 1 adds --no-check-certificate)
download_subscription() {
local url="$1"
local filepath="$2"
@ -774,6 +817,7 @@ download_subscription() {
local wait="${5:-2}"
local timeout="${6:-10}"
local user_agent="${7:-}"
local insecure="${8:-0}"
local sb_version device_model kernel_version hwid
sb_version="$(get_sing_box_version)"
@ -782,6 +826,14 @@ download_subscription() {
hwid="$(generate_hwid)"
[ -n "$user_agent" ] || user_agent="$(get_subscription_user_agent)"
# Optional TLS-verification bypass for IP-host panels with broken certs.
# Empty string keeps the secure default; word-splitting it into the wget
# argv (via _wget_subscription_request) yields zero extra args when off.
local cert_flag=""
if [ "$insecure" = "1" ]; then
cert_flag="--no-check-certificate"
fi
local tmpfile errfile rc family
tmpfile="${filepath}.part.$$"
errfile="${filepath}.err.$$"
@ -793,48 +845,24 @@ download_subscription() {
family="ipv4"
if [ -n "$http_proxy_address" ]; then
http_proxy="http://$http_proxy_address" https_proxy="http://$http_proxy_address" \
wget -4 -T "$timeout" -O "$tmpfile" \
--header "User-Agent: $user_agent" \
--header "X-HWID: $hwid" \
--header "X-Device-OS: OpenWrt Linux" \
--header "X-Device-Model: $device_model" \
--header "X-Ver-OS: $kernel_version" \
--header "Accept-Language: ru-RU,en,*" \
--header "X-Device-Locale: EN" \
"$url" 2>"$errfile"
_wget_subscription_request "$cert_flag" "$user_agent" "$hwid" \
"$device_model" "$kernel_version" "$tmpfile" "$errfile" "$url" \
-4 -T "$timeout"
else
wget -4 -T "$timeout" -O "$tmpfile" \
--header "User-Agent: $user_agent" \
--header "X-HWID: $hwid" \
--header "X-Device-OS: OpenWrt Linux" \
--header "X-Device-Model: $device_model" \
--header "X-Ver-OS: $kernel_version" \
--header "Accept-Language: ru-RU,en,*" \
--header "X-Device-Locale: EN" \
"$url" 2>"$errfile"
_wget_subscription_request "$cert_flag" "$user_agent" "$hwid" \
"$device_model" "$kernel_version" "$tmpfile" "$errfile" "$url" \
-4 -T "$timeout"
fi
else
if [ -n "$http_proxy_address" ]; then
http_proxy="http://$http_proxy_address" https_proxy="http://$http_proxy_address" \
wget -T "$timeout" -O "$tmpfile" \
--header "User-Agent: $user_agent" \
--header "X-HWID: $hwid" \
--header "X-Device-OS: OpenWrt Linux" \
--header "X-Device-Model: $device_model" \
--header "X-Ver-OS: $kernel_version" \
--header "Accept-Language: ru-RU,en,*" \
--header "X-Device-Locale: EN" \
"$url" 2>"$errfile"
_wget_subscription_request "$cert_flag" "$user_agent" "$hwid" \
"$device_model" "$kernel_version" "$tmpfile" "$errfile" "$url" \
-T "$timeout"
else
wget -T "$timeout" -O "$tmpfile" \
--header "User-Agent: $user_agent" \
--header "X-HWID: $hwid" \
--header "X-Device-OS: OpenWrt Linux" \
--header "X-Device-Model: $device_model" \
--header "X-Ver-OS: $kernel_version" \
--header "Accept-Language: ru-RU,en,*" \
--header "X-Device-Locale: EN" \
"$url" 2>"$errfile"
_wget_subscription_request "$cert_flag" "$user_agent" "$hwid" \
"$device_model" "$kernel_version" "$tmpfile" "$errfile" "$url" \
-T "$timeout"
fi
fi
@ -861,25 +889,13 @@ download_subscription() {
log "Retrying subscription download over IPv4-only" "warn"
if [ -n "$http_proxy_address" ]; then
http_proxy="http://$http_proxy_address" https_proxy="http://$http_proxy_address" \
wget -4 -T "$timeout" -O "$tmpfile" \
--header "User-Agent: $user_agent" \
--header "X-HWID: $hwid" \
--header "X-Device-OS: OpenWrt Linux" \
--header "X-Device-Model: $device_model" \
--header "X-Ver-OS: $kernel_version" \
--header "Accept-Language: ru-RU,en,*" \
--header "X-Device-Locale: EN" \
"$url" 2>"$errfile"
_wget_subscription_request "$cert_flag" "$user_agent" "$hwid" \
"$device_model" "$kernel_version" "$tmpfile" "$errfile" "$url" \
-4 -T "$timeout"
else
wget -4 -T "$timeout" -O "$tmpfile" \
--header "User-Agent: $user_agent" \
--header "X-HWID: $hwid" \
--header "X-Device-OS: OpenWrt Linux" \
--header "X-Device-Model: $device_model" \
--header "X-Ver-OS: $kernel_version" \
--header "Accept-Language: ru-RU,en,*" \
--header "X-Device-Locale: EN" \
"$url" 2>"$errfile"
_wget_subscription_request "$cert_flag" "$user_agent" "$hwid" \
"$device_model" "$kernel_version" "$tmpfile" "$errfile" "$url" \
-4 -T "$timeout"
fi
rc=$?
if [ "$rc" -eq 0 ] && [ -s "$tmpfile" ]; then

View File

@ -14,6 +14,13 @@ nft_create_ipv4_set() {
nft add set inet "$table" "$name" '{ type ipv4_addr; flags interval; auto-merge; }'
}
nft_create_ipv6_set() {
local table="$1"
local name="$2"
nft add set inet "$table" "$name" '{ type ipv6_addr; flags interval; auto-merge; }'
}
nft_create_ifname_set() {
local table="$1"
local name="$2"
@ -68,4 +75,4 @@ nft_add_set_elements_from_file_chunked() {
log "Adding $count elements to nft set $nft_set_name" "debug"
nft_add_set_elements "$nft_table_name" "$nft_set_name" "$array"
fi
}
}

View File

@ -223,15 +223,20 @@ sing_box_cm_add_fakeip_dns_server() {
local config="$1"
local tag="$2"
local inet4_range="$3"
local inet6_range="$4"
echo "$config" | jq \
--arg tag "$tag" \
--arg inet4_range "$inet4_range" \
'.dns.servers += [{
type: "fakeip",
tag: $tag,
inet4_range: $inet4_range,
}]'
--arg inet6_range "$inet6_range" \
'.dns.servers += [(
{
type: "fakeip",
tag: $tag,
inet4_range: $inet4_range
}
+ (if $inet6_range != "" then { inet6_range: $inet6_range } else {} end)
)]'
}
#######################################
@ -1353,6 +1358,51 @@ sing_box_cm_add_reject_route_rule() {
}]'
}
#######################################
# Add a DoH blocking reject route rule with an inline ruleset containing known
# public DoH server IP ranges.
# Arguments:
# config: string (JSON), sing-box configuration to modify
# tag: string, identifier for the route rule and ruleset
# inbound: string, inbound tag to match
# doh_ipv4_cidrs: string, space-separated IPv4 CIDRs to block
# doh_ipv6_cidrs: string, space-separated IPv6 CIDRs to block
# Outputs:
# Writes updated JSON configuration to stdout
#######################################
sing_box_cm_add_doh_block_route_rule() {
local config="$1"
local tag="$2"
local inbound="$3"
local doh_ipv4_cidrs="$4"
local doh_ipv6_cidrs="${5:-}"
local ruleset_tag cidrs_json
ruleset_tag="${tag}-ruleset"
cidrs_json=$(printf '%s %s' "$doh_ipv4_cidrs" "$doh_ipv6_cidrs" | jq -R 'split(" ") | map(select(. != ""))')
config=$(echo "$config" | jq \
--arg tag "$ruleset_tag" \
--argjson ip_cidr "$cidrs_json" \
'.route.rule_set += [{
type: "inline",
tag: $tag,
rules: [{ ip_cidr: $ip_cidr }]
}]')
echo "$config" | jq \
--arg service_tag "$SERVICE_TAG" \
--arg tag "$tag" \
--arg inbound "$inbound" \
--arg ruleset_tag "$ruleset_tag" \
'.route.rules += [{
action: "reject",
inbound: $inbound,
rule_set: $ruleset_tag,
$service_tag: $tag
}]'
}
#######################################
# Add a hijack-dns rule to the route section of a sing-box JSON configuration.
# Arguments:

View File

@ -1243,7 +1243,7 @@ updates_stable_rollback() {
# Checks whether a newer sing-box-extended release is available.
# Echoes a JSON status (latest|outdated) on stdout.
updates_check_sing_box_extended() {
local current_version releases tag status
local current_version releases tag status cur_norm tag_norm
current_version="$(get_sing_box_version)"
@ -1259,15 +1259,355 @@ updates_check_sing_box_extended() {
return 1
fi
status="outdated"
case "$current_version" in
*"$tag"*) status="latest" ;;
esac
# Normalize a single leading "v" off BOTH sides before comparing/emitting.
# get_sing_box_version yields "1.13.12-extended-2.3.2" (no v) while the
# GitHub .tag_name is "v1.13.12-extended-2.3.2" (with v), so the old
# substring match never fired and reported a false "outdated". ${x#v} strips
# exactly one leading "v" if present and leaves the string otherwise — safe
# for both forms. NB: `tag` itself (with v) is untouched and is NOT used by
# the install/asset path here; the installer re-derives its own tag.
cur_norm="${current_version#v}"
tag_norm="${tag#v}"
echo "{\"success\":true,\"current_version\":\"$current_version\",\"latest_version\":\"$tag\",\"status\":\"$status\"}"
# EXACT equality after the v-strip: the extended version string is the full
# token (e.g. "1.13.12-extended-2.3.2"), so an exact match is correct and
# avoids the accidental partial matches the old `case *"$tag"*` form allowed.
status="outdated"
if [ "$cur_norm" = "$tag_norm" ]; then
status="latest"
fi
# Emit BOTH versions v-stripped so the UI shows a consistent string.
echo "{\"success\":true,\"current_version\":\"$cur_norm\",\"latest_version\":\"$tag_norm\",\"status\":\"$status\"}"
return 0
}
# ── Package-manager abstraction (Component Manager, task-017) ───────
#
# updater.sh does NOT source install.sh, so these are the tiny `updates_`-prefixed
# equivalents of install.sh's pkg_is_apk / pkg_install / pkg_is_installed. On a
# real device exactly ONE of apk/opkg exists. Package output is parsed with
# cut/awk/grep only — NEVER Oniguruma jq.
# Returns 0 if the device uses apk (the apk binary is present), non-zero for opkg.
updates_pkg_is_apk() {
command -v apk >/dev/null 2>&1
}
# Installs a package FILE (downloaded .ipk/.apk) non-interactively. Returns the
# package manager's exit status. apk needs --allow-untrusted for self-built
# packages; opkg install handles the local file path directly.
updates_pkg_install_file() {
local pkg_file="$1"
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
fi
}
# Returns 0 if a package NAME is currently installed. Mirrors install.sh's
# pkg_is_installed grep-based detection (busybox-safe; no regex needed).
updates_pkg_is_installed() {
local pkg_name="$1"
if updates_pkg_is_apk; then
apk list --installed 2>/dev/null | grep -q "$pkg_name"
else
opkg list-installed 2>/dev/null | grep -q "$pkg_name"
fi
}
# Echoes the FEED/candidate version of a package (the version the package
# manager would install), or nothing if unavailable. Parsed with cut/awk only.
# opkg list <pkg> -> "<name> - <version>" (field after " - ")
# apk list <pkg> -> "<name>-<version> <arch> {...} ..." (strip "<name>-")
updates_pkg_candidate_version() {
local pkg_name="$1"
local line version=""
if updates_pkg_is_apk; then
# First matching list line; the token is "<name>-<version>". Strip the
# leading "<pkg>-" so only the version (e.g. "1.12.22-r1") remains.
line="$(apk list "$pkg_name" 2>/dev/null | grep -v '\[installed\]' | awk '{print $1}' | head -n1)"
[ -n "$line" ] || line="$(apk list "$pkg_name" 2>/dev/null | awk '{print $1}' | head -n1)"
case "$line" in
"$pkg_name"-*) version="${line#"$pkg_name"-}" ;;
esac
else
# opkg list prints "<name> - <version>"; take the field after " - ".
version="$(opkg list "$pkg_name" 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
# {"success":false,"message":"..."} and returns non-zero. NEVER exits.
#
# Output (STABLE, mirrors updates_check_sing_box_extended):
# {"success":true,"current_version":"...","latest_version":"...",
# "status":"latest"|"outdated"|"not_installed"}
updates_check_sing_box_stable() {
local current_version candidate cur_semver cand_semver status
# Refresh the package index so the candidate version reflects the feed.
# Best-effort: a failure here just means we compare against whatever index
# is cached; the candidate-empty branch below reports the unreachable feed.
if updates_pkg_is_apk; then
apk update </dev/null >/dev/null 2>&1 || true
else
opkg update </dev/null >/dev/null 2>&1 || true
fi
candidate="$(updates_pkg_candidate_version "sing-box")"
if [ -z "$candidate" ]; then
echo "{\"success\":false,\"message\":\"Could not determine the stock sing-box version from the package feed (feed unreachable or package not found)\"}"
return 1
fi
# sing-box absent → not_installed (no running binary to compare).
if ! command -v sing-box >/dev/null 2>&1; then
echo "{\"success\":true,\"current_version\":\"not installed\",\"latest_version\":\"$candidate\",\"status\":\"not_installed\"}"
return 0
fi
current_version="$(get_sing_box_version)"
# Compare on the leading semver only (drop any "-r1"/"-extended-..." suffix)
# so the sort -V based >= test in is_min_package_version is well-defined.
cur_semver="${current_version%%-*}"
cand_semver="${candidate%%-*}"
if is_min_package_version "$cur_semver" "$cand_semver"; then
status="latest"
else
status="outdated"
fi
echo "{\"success\":true,\"current_version\":\"$current_version\",\"latest_version\":\"$candidate\",\"status\":\"$status\"}"
return 0
}
# ── NetShift self-update (Component Manager, task-017) ──────────────
#
# Variant A: a targeted package upgrade (download the release .ipk/.apk from
# GitHub and pkg_install them) — NOT install.sh (interactive). Runs as the async
# worker `component_action netshift self_update`.
#
# Public wrapper — EXACTLY mirrors the updates_install_sing_box_extended epilogue
# (single cleanup path): reset heal flags → ensure GitHub connectivity (preflight
# + self-heal) → run the private core capturing JSON to a tmpfs file + rc →
# ALWAYS updates_restore_after_swap → re-emit the JSON → return rc. No early
# return skips the restore; no trap needed.
updates_self_update_netshift() {
local rc out json
UPDATES_HEAL_RESOLV_REPLACED=0
UPDATES_HEAL_REDIRECT_DOWN=0
if ! updates_ensure_connectivity "extended"; then
# Heal failed BEFORE anything was touched: NetShift is left fully intact.
updates_restore_after_swap
updates_log "Aborting NetShift self-update: GitHub unreachable and self-heal failed (NetShift left intact)" "error"
echo '{"success":false,"message":"GitHub unreachable and self-heal failed; self-update aborted (NetShift left intact)"}'
return 1
fi
out="/tmp/netshift-selfupdate-result.$$"
_updates_self_update_netshift_core >"$out" 2>/dev/null
rc=$?
json="$(cat "$out" 2>/dev/null)"
rm -f "$out" 2>/dev/null
updates_restore_after_swap
[ -n "$json" ] && printf '%s\n' "$json"
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
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
}
# 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).
_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
if updates_pkg_is_apk; then
ext="apk"
else
ext="ipk"
fi
pattern="https://[^\"[:space:]]*\.${ext}"
# Iterate the matching browser_download_url values. Only keep assets whose
# filename starts with one of the NetShift package-name prefixes; the RU
# i18n package is kept ONLY if already installed.
printf '%s' "$response" | grep -o "$pattern" | while read -r url; do
filename="$(basename "$url")"
case "$filename" in
"$UPDATES_NETSHIFT_PKG_CORE"* | "$UPDATES_NETSHIFT_PKG_LUCI"*) ;;
"$UPDATES_NETSHIFT_PKG_I18N_RU"*)
updates_pkg_is_installed "$UPDATES_NETSHIFT_PKG_I18N_RU" || continue
;;
*) continue ;;
esac
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
done
# Verify the core package landed (the subshell-piped loop can't set a parent
# var, so re-check the directory contents here).
if ls "$dir/$UPDATES_NETSHIFT_PKG_CORE"* >/dev/null 2>&1; then
got_core=1
fi
[ "$got_core" -eq 1 ]
}
# Private core of the self-update. Runs NON-interactively; every variable local.
# Echoes a single JSON object; NEVER exits (returns non-zero on recoverable
# failure so the wrapper still runs the restore epilogue).
_updates_self_update_netshift_core() {
local installed latest pkg file_path candidate_file
local backup_made=0
installed="$NETSHIFT_VERSION"
latest="$(updates_netshift_latest_tag)"
if [ -z "$latest" ]; then
updates_log "Self-update: could not determine the latest NetShift release tag" "error"
echo '{"success":false,"message":"Could not determine the latest NetShift release (GitHub API unreachable or rate-limited)"}'
return 1
fi
# Idempotent defense: compare ignoring a leading "v" so "v0.8.1" vs "0.8.1"
# still match (the UI also gates on the "outdated" status).
if [ "${installed#v}" = "${latest#v}" ]; then
updates_log "Self-update: NetShift already at the latest version ($installed)"
echo "{\"success\":true,\"message\":\"Already up to date\",\"version\":\"$installed\"}"
return 0
fi
# Minimal backup: /etc/config/netshift to tmpfs (conffiles normally preserve
# it; this is the defensive belt).
rm -rf "$UPDATES_NETSHIFT_DOWNLOAD_DIR" 2>/dev/null
if ! mkdir -p "$UPDATES_NETSHIFT_DOWNLOAD_DIR"; then
updates_log "Self-update: failed to create download directory" "error"
echo '{"success":false,"message":"Failed to create the self-update download directory"}'
return 1
fi
mkdir -p "$(dirname "$UPDATES_NETSHIFT_CONFIG_BACKUP")" 2>/dev/null || true
if [ -f "$NETSHIFT_CONFIG" ]; then
if cp -p "$NETSHIFT_CONFIG" "$UPDATES_NETSHIFT_CONFIG_BACKUP" 2>/dev/null; then
backup_made=1
else
updates_log "Self-update: failed to back up $NETSHIFT_CONFIG (continuing; conffiles preserve it)" "warn"
fi
fi
# Download the release assets (.ipk/.apk) for this package manager.
updates_log "Self-update: downloading NetShift $latest release packages"
if ! _updates_self_update_download_assets "$UPDATES_NETSHIFT_DOWNLOAD_DIR"; then
rm -rf "$UPDATES_NETSHIFT_DOWNLOAD_DIR" 2>/dev/null
updates_log "Self-update: failed to download the NetShift release packages" "error"
echo '{"success":false,"message":"Failed to download the NetShift release packages (GitHub unreachable or no matching assets)"}'
return 1
fi
# Install core, then LuCI app, then RU i18n if applicable (already filtered
# to "installed-only" at download time). NON-interactive. The netshift
# package replaces /usr/bin/netshift (this very script) — busybox ash has
# already read the whole script into memory, so the in-flight worker and the
# subsequent updates_write_finished_job_state complete from memory. We MUST
# NOT re-exec /usr/bin/netshift after this install (no updates_restart that
# re-runs the CLI; only /etc/init.d/netshift restart, which spawns a fresh
# process that may safely load the new binary).
for pkg in "$UPDATES_NETSHIFT_PKG_CORE" "$UPDATES_NETSHIFT_PKG_LUCI" "$UPDATES_NETSHIFT_PKG_I18N_RU"; do
file_path=""
for candidate_file in "$UPDATES_NETSHIFT_DOWNLOAD_DIR/$pkg"*; do
if [ -f "$candidate_file" ]; then
file_path="$candidate_file"
break
fi
done
[ -n "$file_path" ] || continue
updates_log "Self-update: installing $(basename "$file_path")"
if ! updates_pkg_install_file "$file_path"; then
# The core is the critical package; if it fails, surface the failure.
# conffiles preserve /etc/config/netshift; restore defensively below.
if [ "$pkg" = "$UPDATES_NETSHIFT_PKG_CORE" ]; then
_updates_self_update_restore_config "$backup_made"
rm -rf "$UPDATES_NETSHIFT_DOWNLOAD_DIR" 2>/dev/null
updates_log "Self-update: failed to install the NetShift core package" "error"
echo '{"success":false,"message":"Failed to install the NetShift core package; configuration preserved"}'
return 1
fi
updates_log "Self-update: failed to install $pkg (non-critical; continuing)" "warn"
fi
done
# Defensive: if the config got clobbered/emptied, restore from the backup.
_updates_self_update_restore_config "$backup_made"
# Success cleanup: drop the download dir and the config backup.
rm -rf "$UPDATES_NETSHIFT_DOWNLOAD_DIR" 2>/dev/null
[ "$backup_made" -eq 1 ] && rm -f "$UPDATES_NETSHIFT_CONFIG_BACKUP" 2>/dev/null
updates_log "Self-update: NetShift updated to $latest"
echo "{\"success\":true,\"version\":\"$latest\",\"message\":\"NetShift updated to $latest\"}"
return 0
}
# Restores /etc/config/netshift from the tmpfs backup IF the live file is missing
# or empty (conffiles normally keep it; this is the defensive belt). $1 = 1 when
# a backup was taken.
_updates_self_update_restore_config() {
local backup_made="$1"
[ "$backup_made" -eq 1 ] || return 0
[ -f "$UPDATES_NETSHIFT_CONFIG_BACKUP" ] || return 0
if [ ! -s "$NETSHIFT_CONFIG" ]; then
if cp -p "$UPDATES_NETSHIFT_CONFIG_BACKUP" "$NETSHIFT_CONFIG" 2>/dev/null; then
updates_log "Self-update: restored $NETSHIFT_CONFIG from backup (live file was missing/empty)" "warn"
else
updates_log "Self-update: FAILED to restore $NETSHIFT_CONFIG from backup" "error"
fi
fi
}
# Dispatcher for component-related actions.
component_action() {
local component="$1"
@ -1283,6 +1623,12 @@ component_action() {
sing_box:check_update)
updates_check_sing_box_extended
;;
sing_box:check_update_stable)
updates_check_sing_box_stable
;;
netshift:self_update)
updates_self_update_netshift
;;
*)
echo '{"success":false,"message":"Unknown component action"}'
return 1

View File

@ -9,7 +9,9 @@
# docker compose -f tests/docker-compose.yml run --rm netshift-test <test-name>
#
# Test names: all, deps, syntax, config, helpers, jq, cm, sb, nft,
# diagnostics, subscription, rejected, jobstate, selfheal
# nftv6, diagnostics, subscription, insecure, rejected,
# jobstate, selfheal, dnsdetour, globalproxy, stablecheck,
# extcheck, selfupdate
# ──────────────────────────────────────────────────────────────────
services:

File diff suppressed because it is too large Load Diff