Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb9cedb10a | |||
| c860bf195c | |||
| cbdc714ab8 | |||
| f92408b136 | |||
| d391e32f4f | |||
| 343f8cc1cd | |||
| 03806d7b10 | |||
| 7c7f7b15a0 | |||
| 7783f3c27d |
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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 1–3 (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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
@ -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-группы"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -46,9 +46,6 @@ export const initialDiagnosticStore: Pick<
|
||||
showSingBoxConfig: {
|
||||
loading: false,
|
||||
},
|
||||
singBoxInstall: {
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
diagnosticsRunAction: { loading: false },
|
||||
diagnosticsChecks: [
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './dashboard';
|
||||
export * from './diagnostic';
|
||||
export * from './manager';
|
||||
|
||||
262
fe-app-netshift/src/netshift/tabs/manager/cards.ts
Normal file
262
fe-app-netshift/src/netshift/tabs/manager/cards.ts
Normal 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),
|
||||
];
|
||||
}
|
||||
9
fe-app-netshift/src/netshift/tabs/manager/index.ts
Normal file
9
fe-app-netshift/src/netshift/tabs/manager/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { render } from './render';
|
||||
import { initController } from './initController';
|
||||
import { styles } from './styles';
|
||||
|
||||
export const ManagerTab = {
|
||||
render,
|
||||
initController,
|
||||
styles,
|
||||
};
|
||||
434
fe-app-netshift/src/netshift/tabs/manager/initController.ts
Normal file
434
fe-app-netshift/src/netshift/tabs/manager/initController.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
20
fe-app-netshift/src/netshift/tabs/manager/manager.store.ts
Normal file
20
fe-app-netshift/src/netshift/tabs/manager/manager.store.ts
Normal 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: '' },
|
||||
},
|
||||
};
|
||||
8
fe-app-netshift/src/netshift/tabs/manager/render.ts
Normal file
8
fe-app-netshift/src/netshift/tabs/manager/render.ts
Normal 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',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
109
fe-app-netshift/src/netshift/tabs/manager/styles.ts
Normal file
109
fe-app-netshift/src/netshift/tabs/manager/styles.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
207
fe-app-netshift/src/netshift/tabs/manager/tests/cards.test.js
Normal file
207
fe-app-netshift/src/netshift/tabs/manager/tests/cards.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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') };
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
@ -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
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
1062
tests/entrypoint.sh
1062
tests/entrypoint.sh
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user