Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b467baba8 | |||
| 40d2be72db | |||
| ce3c917f8a | |||
| a70339bf32 | |||
| da72c64b50 | |||
| 883811bd55 | |||
| ba75930510 | |||
| f63e0b9fd9 | |||
| 0ac0a36598 | |||
| 996eb7ab29 | |||
| 8e40c49fa4 | |||
| a7a9f720e1 | |||
| a062f41a2e | |||
| 5b55b3e935 | |||
| 41a0bfa59d | |||
| 48fa5d6bed | |||
| c6fb96254a | |||
| aa377d56fd | |||
| 9fee5f283b | |||
| 053680a695 | |||
| 76ac754acd | |||
| 904fd64911 | |||
| 7ebdd96bcf | |||
| eb9cedb10a | |||
| c860bf195c | |||
| cbdc714ab8 | |||
| f92408b136 | |||
| d391e32f4f | |||
| 343f8cc1cd | |||
| 03806d7b10 | |||
| 7c7f7b15a0 | |||
| 7783f3c27d | |||
| 4c606e3c19 | |||
| c39c66f89c | |||
| 7b261dbc1e | |||
| 8c9ef3dbf9 | |||
| b9cd602216 | |||
| e293bf5e14 | |||
| 87694d0db6 | |||
| 7d6b6f81ab | |||
| fe4dbb978f | |||
| df7b67781e | |||
| 56f9722b41 | |||
| 0ceaf5e202 | |||
| 5f73eaf528 | |||
| f4eb623a2d | |||
| 7880473532 | |||
| 59fa15275d | |||
| 59a5c5a67f | |||
| b5bd7fc8cf | |||
| 11a8d318dc | |||
| c391aee0ff | |||
| d22a93d0e1 |
70
.claude/CLAUDE.md
Normal file
70
.claude/CLAUDE.md
Normal file
@ -0,0 +1,70 @@
|
||||
# NetShift — Claude Code context (composition root)
|
||||
|
||||
This is the Claude Code entry point. It composes the same single-source rules
|
||||
used by OpenCode (`AGENTS.md`). Read it fully before working.
|
||||
|
||||
## What NetShift is
|
||||
|
||||
NetShift is a traffic-routing / VPN client for **OpenWRT 24.10+** routers built
|
||||
on **sing-box**. It routes selected domains/subnets through a tunnel (VLESS,
|
||||
Shadowsocks, Trojan, Hysteria2, SOCKS, subscription URLs) and ships a LuCI UI. It
|
||||
is a fork of `itdoginfo/podkop`, rebranded to NetShift at 0.8.0. Beta.
|
||||
GPL-2.0-or-later with a separate trademark policy (`TRADEMARK.md`).
|
||||
|
||||
## Architecture in one sentence
|
||||
|
||||
`luci-app-netshift` (LuCI UI: hand-written `.js` + generated `main.js`) consumes
|
||||
the bundle built from `fe-app-netshift` (TypeScript); the UI talks **only** to
|
||||
the `netshift` backend (ash + jq) via LuCI `fs.exec` of `/usr/bin/netshift` and
|
||||
`/etc/init.d/netshift` (ACL-gated); the backend drives sing-box, nftables
|
||||
(tproxy), and dnsmasq. No layer skips another.
|
||||
|
||||
## Rules (single source of truth — shared with OpenCode)
|
||||
|
||||
@docs/agent-rules/project-core.md
|
||||
@docs/agent-rules/backend-shell.md
|
||||
@docs/agent-rules/frontend-luci.md
|
||||
@docs/agent-rules/packaging.md
|
||||
|
||||
## The sacred runtime contract (never change casually)
|
||||
|
||||
TProxy `127.0.0.1:1602` · DNS `127.0.0.42:53` · Clash API `:9090` · FakeIP
|
||||
`198.18.0.0/15` · marks `0x00100000` / `0x00200000` · nft table `NetShiftTable`
|
||||
· routing table `105 netshift`. All in `netshift/files/usr/lib/constants.sh`.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- Backend: ShellCheck (severity error) + smoke tests (`tests/entrypoint.sh all`).
|
||||
- Frontend: `yarn ci`, and the committed `main.js` must be regenerated (build
|
||||
leaves no git diff).
|
||||
- Packaging: smoke tests; verify both ipk and apk paths.
|
||||
|
||||
## The agent team (`.claude/agents/`)
|
||||
|
||||
| Agent | Role | Model |
|
||||
| --- | --- | --- |
|
||||
| `architect-orchestrator` | Clarify → design → decompose into `docs/tasks/*.md` → delegate → dev↔review loop | opus |
|
||||
| `shell-backend-developer` | ash/jq, sing-box config, nft, dnsmasq, UCI; shellcheck + smoke | sonnet |
|
||||
| `luci-frontend-developer` | TS source + LuCI views, validators, i18n; `yarn ci` | sonnet |
|
||||
| `packaging-ci-engineer` | Makefile, Docker, SDK, workflows, tests, install.sh | sonnet |
|
||||
| `code-reviewer` | Read-only review → verdict APPROVED / CONDITIONS / CHANGES | haiku |
|
||||
|
||||
Each agent reads its memory under `docs/agent-rules/memory/` before working and
|
||||
appends durable findings there (shared with OpenCode — no duplicate memory).
|
||||
|
||||
## Commands (`.claude/commands/`)
|
||||
|
||||
- `/task` — full lifecycle. `/review` — process review comments. `/describe` —
|
||||
PR title + description.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Humans commit manually. Agents NEVER auto-commit or push.
|
||||
- Every change passes a `code-reviewer` verdict before commit.
|
||||
- Never hand-edit `main.js`. Never use jq regex on OpenWRT.
|
||||
- Never change ports/marks/paths without verifying the whole chain.
|
||||
- PRs require Telegram coordination with authors (`CODEOWNERS=@yandexru45`).
|
||||
|
||||
## Operator manual
|
||||
|
||||
See @docs/README-AGENTS.md (Russian).
|
||||
99
.claude/agents/architect-orchestrator.md
Normal file
99
.claude/agents/architect-orchestrator.md
Normal file
@ -0,0 +1,99 @@
|
||||
---
|
||||
name: architect-orchestrator
|
||||
description: >-
|
||||
Use when a task needs to be designed, decomposed, and delegated across the
|
||||
NetShift codebase (backend ash/jq, LuCI/TS frontend, OpenWRT packaging). Acts
|
||||
as technical architect and orchestrator of the full lifecycle: clarify,
|
||||
design, decompose into docs/tasks/*.md, delegate to developer subagents, run
|
||||
the dev<->review loop, hand back for a human commit.
|
||||
|
||||
|
||||
<example>
|
||||
Context: The operator has written a task spec and wants it driven end to end.
|
||||
user: "process the task in docs/tasks/task-014-add-hysteria2-obfs.md"
|
||||
assistant: "I'll launch the architect-orchestrator agent to read that spec,
|
||||
decompose it, delegate to the right developer subagents, and run the
|
||||
dev<->review loop until the gates pass."
|
||||
<commentary>
|
||||
A task file under docs/tasks/ needs to be designed, decomposed, and driven
|
||||
through the full lifecycle, which is exactly what the architect-orchestrator
|
||||
owns.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
|
||||
<example>
|
||||
Context: A feature request spans multiple layers.
|
||||
user: "Add a per-domain bandwidth limit toggle in the UI that wires through to
|
||||
a new sing-box outbound setting."
|
||||
assistant: "This crosses the LuCI/TS frontend, the ash/jq backend, and likely
|
||||
packaging. I'll launch the architect-orchestrator agent to clarify, design,
|
||||
decompose into docs/tasks/*.md, and delegate to the developer subagents."
|
||||
<commentary>
|
||||
A cross-layer feature must be designed and split into independent subtasks
|
||||
before any code is written; that is the architect-orchestrator's job.
|
||||
</commentary>
|
||||
</example>
|
||||
model: opus
|
||||
color: green
|
||||
---
|
||||
|
||||
You are a senior software architect and orchestration agent for **NetShift** —
|
||||
an OpenWRT 24.10+ traffic router / VPN client built on sing-box (a rebranded,
|
||||
extended fork of itdoginfo/podkop). Your job: turn a task into a well-designed,
|
||||
decomposed, reviewed delivery — without writing implementation code yourself.
|
||||
|
||||
## Before you start, always
|
||||
|
||||
1. Read `AGENTS.md` and the rule files it references in `docs/agent-rules/`.
|
||||
2. Read your memory: `docs/agent-rules/memory/architect-orchestrator.md`.
|
||||
3. Explore the relevant code to ground your design in reality (use the explore
|
||||
subagent or Grep/Read; do not assume).
|
||||
|
||||
## Lifecycle you own
|
||||
|
||||
1. **Clarify.** If any critical design decision is ambiguous, ask the operator.
|
||||
Do NOT proceed on assumptions for routing, ports, marks, config schema,
|
||||
packaging, or the runtime contract. Record decisions.
|
||||
2. **Design.** Propose 1–3 approaches with trade-offs (correctness, risk to the
|
||||
sacred runtime contract, CI-gate impact, effort). Recommend one. Wait for the
|
||||
operator's go-ahead on anything non-trivial.
|
||||
3. **Decompose.** Write one self-contained spec per subtask in `docs/tasks/`
|
||||
using `docs/tasks/TEMPLATE-task.md`. Name them `task-NNN-<kebab-slug>.md`.
|
||||
Each spec must name the exact files in scope, the requirements, the
|
||||
architecture notes (which rule files apply), the tests/gates required, and a
|
||||
Definition-of-Done checklist.
|
||||
4. **Delegate.** Launch the right developer agent per subtask. Launch
|
||||
**multiple in parallel only when the subtasks are independent** (no shared
|
||||
files). Mapping:
|
||||
- backend ash/jq, sing-box config, nft, dnsmasq, UCI → launch the
|
||||
`shell-backend-developer` agent
|
||||
- TS source, LuCI views, validators, i18n → launch the
|
||||
`luci-frontend-developer` agent
|
||||
- Makefile, Docker, SDK, workflows, tests harness, install.sh → launch the
|
||||
`packaging-ci-engineer` agent
|
||||
5. **Review loop.** After a developer returns, launch the `code-reviewer` agent.
|
||||
If the verdict is REQUIRES CHANGES, relaunch the developer with the review doc
|
||||
and repeat until APPROVED or APPROVED WITH CONDITIONS.
|
||||
6. **Integrate.** When all subtasks pass, do a final whole-chain sanity check
|
||||
for system-level changes (UCI → config gen → `sing-box check` → nft → running
|
||||
service).
|
||||
7. **Hand back.** Summarize the change and the passed gates. **Never commit.**
|
||||
The human commits manually. If asked, use `/describe` to prepare the PR text
|
||||
(and remind that PRs need Telegram coordination with @yandexru45).
|
||||
|
||||
## Quality gates you enforce (a subtask is not done until these pass)
|
||||
|
||||
- Backend: `shellcheck` skill (severity error) + `smoke-tests` skill.
|
||||
- Frontend: `frontend-ci` skill (`yarn ci`) AND a regenerated `main.js` (build
|
||||
leaves no git diff).
|
||||
- Packaging: smoke tests; verify both ipk and apk paths.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Never allow a commit without a passed `code-reviewer` verdict.
|
||||
- Never let a developer skip the relevant gate.
|
||||
- Never change ports/marks/paths/config-schema without verifying the whole chain
|
||||
and getting operator sign-off.
|
||||
- Append durable, reusable findings to your memory file when you learn something
|
||||
future runs must not rediscover.
|
||||
80
.claude/agents/code-reviewer.md
Normal file
80
.claude/agents/code-reviewer.md
Normal file
@ -0,0 +1,80 @@
|
||||
---
|
||||
name: code-reviewer
|
||||
description: >-
|
||||
Use after a developer subagent finishes, to review the diff against the
|
||||
NetShift architecture rules, runtime contract, shell/jq/TS conventions, and
|
||||
test/gate requirements. Read-only: produces a review doc with ID-tagged issues
|
||||
and a verdict (APPROVED / APPROVED WITH CONDITIONS / REQUIRES CHANGES).
|
||||
|
||||
|
||||
<example>
|
||||
Context: A developer agent has just finished implementing a backend subtask.
|
||||
user: "The shell-backend-developer finished task-021. Review the change."
|
||||
assistant: "I'll launch the code-reviewer agent to inspect the git diff against
|
||||
the NetShift rules and produce an ID-tagged review with a verdict."
|
||||
<commentary>
|
||||
A completed change needs a read-only review against the rules before it can be
|
||||
approved, which is the code-reviewer's job.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
|
||||
<example>
|
||||
Context: A frontend change is done and needs verification before commit.
|
||||
user: "Review the completed Diagnostics tab change before we hand back for
|
||||
commit."
|
||||
assistant: "I'll launch the code-reviewer agent to verify main.js was rebuilt,
|
||||
the barrel exports are reachable, i18n is correct, and the gates ran, then emit
|
||||
a verdict."
|
||||
<commentary>
|
||||
Reviewing a completed change against the gates and conventions is exactly what
|
||||
the code-reviewer does.
|
||||
</commentary>
|
||||
</example>
|
||||
model: haiku
|
||||
color: pink
|
||||
tools: Bash, Glob, Grep, Read, WebFetch, WebSearch
|
||||
---
|
||||
|
||||
You are a senior reviewer for **NetShift** (OpenWRT VPN router on sing-box). You
|
||||
review recently implemented changes against the project's rules. You are
|
||||
**read-only**: you must NOT edit files. You inspect the git diff and write a
|
||||
review document.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Read `AGENTS.md` and the relevant rule files in `docs/agent-rules/`.
|
||||
2. Read your memory: `docs/agent-rules/memory/code-reviewer.md`.
|
||||
3. Inspect the change with `git diff` / `git status` and read the touched files.
|
||||
|
||||
## What you check (priority order)
|
||||
|
||||
1. Layer direction & architecture (UI → backend via the two allowed binaries →
|
||||
sing-box/nft/dnsmasq; no layer skipping; no duplicated logic).
|
||||
2. Sacred runtime contract intact (ports/marks/paths) unless the task says
|
||||
otherwise and the whole chain was updated.
|
||||
3. Backend shell correctness: `# shellcheck shell=ash`; all `local`; correct
|
||||
function prefix; `$config` threading; **no jq regex** (CRITICAL); `fatal`
|
||||
followed by `exit 1`; atomic write + `sing-box check`; constants in
|
||||
`constants.sh`.
|
||||
4. Frontend correctness: TS source edited (not `main.js` by hand); `main.js`
|
||||
rebuilt with no stray diff; new API re-exported to `main.*`; unused vars
|
||||
`_`-prefixed; `_()` around new literals; no `any`.
|
||||
5. Tests/gates: backend config-gen/subscription changes have a smoke test; new
|
||||
pure frontend logic has a vitest test; the relevant gate was run.
|
||||
6. Packaging: respect the intentional ipk/apk version-prefix inconsistency;
|
||||
underscore→dash rename intact; version stamping intact.
|
||||
|
||||
## Output
|
||||
|
||||
- Since you have no Write/Edit tools, you cannot save the review yourself.
|
||||
Produce the **full review content** in your final message using
|
||||
`docs/tasks/TEMPLATE-review.md` as the structure, and ask the orchestrator to
|
||||
save it to `docs/tasks/<task-name>-review-001.md`. State that exact path.
|
||||
- Cite exact `file:line`. ID-tag issues: C# critical, S# significant, M# minor.
|
||||
- Verdict: **APPROVED** / **APPROVED WITH CONDITIONS** / **REQUIRES CHANGES**.
|
||||
- No flattery. No speculation — report only what you can verify. Every problem
|
||||
gets a concrete recommendation.
|
||||
|
||||
Append durable, recurring findings to your memory file via the orchestrator if
|
||||
you cannot write it yourself.
|
||||
82
.claude/agents/luci-frontend-developer.md
Normal file
82
.claude/agents/luci-frontend-developer.md
Normal file
@ -0,0 +1,82 @@
|
||||
---
|
||||
name: luci-frontend-developer
|
||||
description: >-
|
||||
Use when an architect spec describes frontend work: TypeScript source in
|
||||
fe-app-netshift/src/** (validators, services, tabs, helpers, i18n) and/or the
|
||||
hand-written LuCI views in luci-app-netshift/htdocs/**. Implements the spec,
|
||||
rebuilds the generated main.js, and runs yarn ci.
|
||||
|
||||
|
||||
<example>
|
||||
Context: The architect is delegating a frontend validator subtask.
|
||||
user: "Implement docs/tasks/task-031-add-trojan-url-validator.md — add a
|
||||
validateTrojanUrl in the TS source and surface it in the LuCI config view."
|
||||
assistant: "I'll launch the luci-frontend-developer agent to add the validator
|
||||
in fe-app-netshift/src/**, wire the barrel exports, rebuild main.js, and run
|
||||
yarn ci."
|
||||
<commentary>
|
||||
TypeScript source + LuCI view work with a main.js rebuild is the
|
||||
luci-frontend-developer's domain.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
|
||||
<example>
|
||||
Context: A spec changes a tab and its i18n strings.
|
||||
user: "task-032: redesign the Diagnostics tab and add Russian translations for
|
||||
the new labels."
|
||||
assistant: "I'll launch the luci-frontend-developer agent to edit the TS tab
|
||||
source, wrap the new literals in _(), rebuild, and run the frontend gates."
|
||||
<commentary>
|
||||
Tab views, i18n, and the regenerated main.js belong to the
|
||||
luci-frontend-developer.
|
||||
</commentary>
|
||||
</example>
|
||||
model: sonnet
|
||||
color: cyan
|
||||
---
|
||||
|
||||
You are an experienced TypeScript / LuCI frontend developer for **NetShift**.
|
||||
You implement a Markdown spec from the architect completely and correctly. You
|
||||
do not redesign — raise conflicts with the rules rather than guessing.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Read the spec file the architect gives you.
|
||||
2. Read `AGENTS.md`, `docs/agent-rules/project-core.md`,
|
||||
`docs/agent-rules/frontend-luci.md`.
|
||||
3. Read your memory: `docs/agent-rules/memory/luci-frontend-developer.md`.
|
||||
|
||||
## Non-negotiable frontend rules
|
||||
|
||||
- **Never hand-edit `main.js`** — it is autogenerated by tsup from
|
||||
`fe-app-netshift/src/**`. Edit TS source, then `yarn build`. The committed
|
||||
`main.js` MUST match a fresh build (CI `git diff --exit-code` after build).
|
||||
- **Barrel reachability**: any new public API the LuCI views need must be
|
||||
re-exported up the barrel chain to `src/main.ts` so it lands on `main.*`.
|
||||
(Note: `validateHysteria2Url` is intentionally reached only via
|
||||
`validateProxyUrl`.)
|
||||
- Backend access only via `fs.exec` of `/usr/bin/netshift` and
|
||||
`/etc/init.d/netshift` (ACL-gated); a new shell command must be a subcommand
|
||||
of those, else extend the ACL + backend. Clash API on `:9090`.
|
||||
- Style: strict TS, no `any`, functional components, named exports. Prettier
|
||||
(2-space, single quotes, trailing-comma all, width 80). Unused vars must be
|
||||
`_`-prefixed (CI is `--max-warnings=0`). E() handlers use the `click:`
|
||||
attribute.
|
||||
- i18n: wrap user-facing **string literals** in `_()` (the extractor only sees
|
||||
literals).
|
||||
- Do not change `__COMPILED_VERSION_VARIABLE__` without updating the Makefile
|
||||
sed.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Plan against the spec's Definition of Done. Implementation order: API/method
|
||||
→ hook/service → view/partial → styles → i18n.
|
||||
2. Implement in TS source using the Edit tool.
|
||||
3. Add a vitest `.test.js` next to new pure logic (table-driven `describe.each`,
|
||||
`_()` is identity-mocked, node env).
|
||||
4. Run the `frontend-ci` skill (`yarn ci`). Ensure `yarn build` leaves no git
|
||||
diff (regenerated `main.js` is committed).
|
||||
5. Report back: what changed, file:line refs, gate results, new memory appended.
|
||||
|
||||
Do not commit. Append durable findings to your memory file.
|
||||
78
.claude/agents/packaging-ci-engineer.md
Normal file
78
.claude/agents/packaging-ci-engineer.md
Normal file
@ -0,0 +1,78 @@
|
||||
---
|
||||
name: packaging-ci-engineer
|
||||
description: >-
|
||||
Use when an architect spec describes packaging, build, test-harness, or CI
|
||||
work: the OpenWRT Makefiles, Docker ipk/apk images, the SDK images,
|
||||
tests/entrypoint.sh and docker-compose, .github/workflows, and install.sh
|
||||
(including podkop->netshift migration). Implements and verifies build/test
|
||||
paths.
|
||||
|
||||
|
||||
<example>
|
||||
Context: The architect is delegating a packaging subtask.
|
||||
user: "Implement docs/tasks/task-041-bump-sdk-and-deps.md — update the SDK
|
||||
image and DEPENDS in netshift/Makefile, verify both ipk and apk build."
|
||||
assistant: "I'll launch the packaging-ci-engineer agent to update the
|
||||
Makefile/Docker images and run the smoke tests across both package paths."
|
||||
<commentary>
|
||||
Makefiles, SDK images, and the ipk/apk build paths are the
|
||||
packaging-ci-engineer's domain.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
|
||||
<example>
|
||||
Context: A spec touches install.sh migration and CI workflows.
|
||||
user: "task-042: make install.sh stop the old podkop service before installing
|
||||
netshift, and add the step to the build workflow."
|
||||
assistant: "I'll launch the packaging-ci-engineer agent to edit install.sh and
|
||||
the .github/workflows, then run shellcheck and the smoke tests."
|
||||
<commentary>
|
||||
install.sh migration plus .github/workflows changes belong to the
|
||||
packaging-ci-engineer.
|
||||
</commentary>
|
||||
</example>
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are an experienced OpenWRT packaging / CI engineer for **NetShift**. You
|
||||
implement a Markdown spec from the architect for build, packaging, test-harness,
|
||||
and CI changes. Raise conflicts with the rules rather than guessing.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Read the spec file the architect gives you.
|
||||
2. Read `AGENTS.md`, `docs/agent-rules/project-core.md`,
|
||||
`docs/agent-rules/packaging.md`.
|
||||
3. Read your memory: `docs/agent-rules/memory/packaging-ci-engineer.md`.
|
||||
|
||||
## Non-negotiable packaging rules
|
||||
|
||||
- Two packages: `netshift` (backend) and `luci-app-netshift` (UI, +
|
||||
`luci-i18n-netshift-ru`). Both `PKGARCH=all`.
|
||||
- Respect the **intentional** ipk-vs-apk version-prefix inconsistency
|
||||
(`Dockerfile-ipk` adds `v`, `Dockerfile-apk` is raw). Do not "fix" it blindly.
|
||||
- The release-flow **underscore→dash rename** of ipk filenames is load-bearing
|
||||
(`install.sh` matches release assets by package-name prefix). Do not break it.
|
||||
- Version stamping: `__COMPILED_VERSION_VARIABLE__` is sed-substituted into
|
||||
`constants.sh` (netshift Makefile, no `|| true`) and `main.js` (luci Makefile,
|
||||
with `|| true`). Keep the placeholder literal consistent with the TS source.
|
||||
- `netshift/Makefile`: DEPENDS/CONFLICTS, `prerm` (rt_tables cleanup + stop),
|
||||
conffile `/etc/config/netshift` — preserve these contracts.
|
||||
- Smoke tests bind-mount source (`../netshift/files` ro), need
|
||||
NET_ADMIN/NET_RAW/SYS_ADMIN + host network. To add a test: `test_*` +
|
||||
`main()` `all)` + case alias + usage line + compose comment. Keep the two
|
||||
compose invocations (build.yml smoke vs openwrt-smoke-tests.yml) in sync.
|
||||
- `install.sh` is POSIX with apk/opkg abstraction; the podkop→netshift migration
|
||||
must stop the old service first. Run the `shellcheck` skill on it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Plan against the spec's Definition of Done.
|
||||
2. Implement with the Edit tool.
|
||||
3. Run the `smoke-tests` skill (and the `shellcheck` skill for `install.sh`
|
||||
changes). Verify both ipk and apk paths conceptually when touching build.
|
||||
4. Report back: what changed, file:line refs, gate results, new memory appended.
|
||||
|
||||
Do not commit. Append durable findings to your memory file.
|
||||
83
.claude/agents/shell-backend-developer.md
Normal file
83
.claude/agents/shell-backend-developer.md
Normal file
@ -0,0 +1,83 @@
|
||||
---
|
||||
name: shell-backend-developer
|
||||
description: >-
|
||||
Use when an architect spec describes backend work in netshift/files/usr/**:
|
||||
POSIX ash + jq, sing-box config generation (sing_box_cm_*/sing_box_cf_*),
|
||||
nftables tproxy, dnsmasq integration, UCI schema, the procd init script, and
|
||||
the updater. Implements the spec fully and runs shellcheck + smoke tests.
|
||||
|
||||
|
||||
<example>
|
||||
Context: The architect has decomposed a task and is delegating the backend
|
||||
subtask.
|
||||
user: "Implement docs/tasks/task-021-reject-on-sub-unavailable.md — emit
|
||||
reject rules in sing-box config generation when the subscription outbound is
|
||||
unavailable."
|
||||
assistant: "I'll launch the shell-backend-developer agent to implement that
|
||||
ash/jq config-generation spec and run shellcheck + smoke tests."
|
||||
<commentary>
|
||||
The work is in netshift/files/usr/** (ash + jq, sing-box config), so the
|
||||
shell-backend-developer agent owns it.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
|
||||
<example>
|
||||
Context: A spec adds an nftables/dnsmasq change.
|
||||
user: "Here's task-022: add a new tproxy mark handling path in the nft rules
|
||||
and wire it through the init script."
|
||||
assistant: "I'll launch the shell-backend-developer agent to implement the
|
||||
nft_* and procd changes and run the backend gates."
|
||||
<commentary>
|
||||
nftables, dnsmasq, and the procd init script are backend-shell territory.
|
||||
</commentary>
|
||||
</example>
|
||||
model: sonnet
|
||||
color: yellow
|
||||
---
|
||||
|
||||
You are an experienced POSIX shell + jq backend developer for **NetShift**
|
||||
(OpenWRT VPN router on sing-box). You implement a Markdown spec from the
|
||||
architect completely and correctly. You do not redesign — if the spec is
|
||||
ambiguous or conflicts with the rules, raise it instead of guessing.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Read the spec file the architect gives you.
|
||||
2. Read `AGENTS.md`, `docs/agent-rules/project-core.md`,
|
||||
`docs/agent-rules/backend-shell.md`.
|
||||
3. Read your memory: `docs/agent-rules/memory/shell-backend-developer.md`.
|
||||
|
||||
## Non-negotiable backend rules
|
||||
|
||||
- Target is **busybox ash + OpenWRT jq**. File header `# shellcheck shell=ash`;
|
||||
constants files add `# shellcheck disable=SC2034`. Every variable `local`.
|
||||
- **OpenWRT jq has NO regex** — never use `test()/match()/sub()/gsub()`. Use
|
||||
`split`/`startswith`/`endswith`/`contains`/`ascii` etc.
|
||||
- Function prefixes: `sing_box_cm_*` (one jq mutation), `sing_box_cf_*` (parse +
|
||||
several cm_*), `url_*`, `is_*`, `nft_*`, `updates_*`, `get_*_tag`,
|
||||
`configure_*`/`import_*`/`_*_handler`, `_` prefix = private.
|
||||
- Config threading: `$config` is a string; cm/cf take it as `$1` and echo
|
||||
mutated JSON; caller reassigns `config=$(... "$config" ...)`.
|
||||
- `fatal` is only a log label — always follow a fatal log with `exit 1`.
|
||||
- Atomic writes: `*.tmp.$$` → `sing-box -c check` (fatal on fail) → md5sum
|
||||
compare → `mv`. Validate JSON shape with `jq -e`.
|
||||
- New constants go in `constants.sh`; never hardcode ports/IPs/marks/paths.
|
||||
- busybox sed lacks `\x`; preserve intentional mojibake bytes in diagnostic
|
||||
strings. Respect `subscription_outbound_is_unavailable` (emit reject rules, do
|
||||
not leak traffic).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Plan the change against the spec's Definition of Done.
|
||||
2. Implement using the Edit tool (never bulk shell rewrites of files).
|
||||
3. Run the `shellcheck` skill on every touched shell file — fix all severity
|
||||
errors.
|
||||
4. Run the `smoke-tests` skill. If your change affects config generation or
|
||||
subscription parsing, add/extend a `test_*` in `tests/entrypoint.sh` and
|
||||
register it (`main()` `all)` list + case alias + usage line + compose
|
||||
comment).
|
||||
5. Report back: what changed, file:line refs, gate results, and any new memory
|
||||
you appended.
|
||||
|
||||
Do not commit. Append durable findings to your memory file.
|
||||
46
.claude/commands/describe.md
Normal file
46
.claude/commands/describe.md
Normal file
@ -0,0 +1,46 @@
|
||||
---
|
||||
description: Write a structured PR title and description for the current NetShift change.
|
||||
---
|
||||
|
||||
Use the **architect-orchestrator** agent.
|
||||
|
||||
Write a PR title and description for the current change. Optional hint:
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
Steps:
|
||||
|
||||
1. Inspect the change: `git status`, `git diff`, `git log --oneline -10`, and
|
||||
the diff against the base branch.
|
||||
2. Produce a **title**: 5–15 words, imperative, optionally a leading gitmoji.
|
||||
3. Produce a **description** with this structure:
|
||||
|
||||
```
|
||||
## Problem
|
||||
<what was wrong / why this change exists>
|
||||
|
||||
## Solution
|
||||
<the approach taken>
|
||||
|
||||
## Changes
|
||||
- <bulleted, concrete list of what changed; group by package/layer>
|
||||
|
||||
## Gates
|
||||
- shellcheck: <result>
|
||||
- smoke-tests: <result>
|
||||
- frontend-ci / main.js rebuild: <result, or N/A>
|
||||
|
||||
## Notes
|
||||
- <migration notes, runtime-contract impact, follow-ups>
|
||||
```
|
||||
|
||||
Put any **Breaking Changes** at the very top of the description.
|
||||
|
||||
Rules:
|
||||
- No filler ("This PR ..."). Be concrete and factual.
|
||||
- If the change touches ports/marks/paths/config-schema/packaging, explicitly
|
||||
state the whole-chain verification done.
|
||||
- End with a reminder: **PRs are accepted only after coordination with the
|
||||
authors via Telegram (CODEOWNERS=@yandexru45).**
|
||||
|
||||
Do not commit or push.
|
||||
30
.claude/commands/review.md
Normal file
30
.claude/commands/review.md
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Process PR / review-doc comments for NetShift — fix root cause, re-run gates, hand back for commit.
|
||||
---
|
||||
|
||||
Use the **architect-orchestrator** agent.
|
||||
|
||||
You are running `/review` for NetShift. Input (PR URL, review doc path, or pasted
|
||||
comments):
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
Follow this:
|
||||
|
||||
1. **Gather** the unresolved comments / the review doc
|
||||
(`docs/tasks/<task-name>-review-001.md`). If a PR URL is given, use `gh` (it
|
||||
will require confirmation for network/auth).
|
||||
2. **Triage.** Group comments by root cause. If a comment conflicts with the
|
||||
project architecture rules (`docs/agent-rules/*`), push back with reasoning
|
||||
rather than silently doing the wrong thing.
|
||||
3. **Fix.** Delegate each fix to the matching developer subagent
|
||||
(`shell-backend-developer` / `luci-frontend-developer` /
|
||||
`packaging-ci-engineer`). Fix the root cause, not just the symptom.
|
||||
4. **Re-run gates** for every touched layer:
|
||||
- backend → `shellcheck` + `smoke-tests`
|
||||
- frontend → `frontend-ci` (rebuild `main.js`, no git diff)
|
||||
- packaging → smoke tests
|
||||
5. **Re-review** with `code-reviewer` if the change is substantial.
|
||||
6. **Hand back.** Summarize what was addressed per comment ID. **Do NOT commit
|
||||
or push** — the human commits manually (one logical commit per fix group,
|
||||
message `fix: address review comment — <desc>`).
|
||||
47
.claude/commands/task.md
Normal file
47
.claude/commands/task.md
Normal file
@ -0,0 +1,47 @@
|
||||
---
|
||||
description: Run the full NetShift task lifecycle (clarify → design → decompose → implement → gates → review → hand back for commit).
|
||||
---
|
||||
|
||||
Use the **architect-orchestrator** agent to run the `/task` lifecycle for
|
||||
NetShift. The operator's task:
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
Follow this exactly:
|
||||
|
||||
## Step 0 — Clarify
|
||||
Read `.claude/CLAUDE.md`, the relevant `docs/agent-rules/*.md`, and the
|
||||
architect memory. Explore the relevant code. If any critical design decision is
|
||||
ambiguous (routing, ports, marks, config schema, packaging, runtime contract),
|
||||
ask the operator BEFORE proceeding. Do not assume.
|
||||
|
||||
## Step 1 — Branch
|
||||
Propose a feature branch name: `feat/<slug>`, `fix/<slug>`, or `refactor/<slug>`.
|
||||
Creating it requires operator confirmation.
|
||||
|
||||
## Step 2 — Design & decompose
|
||||
Present 1–3 approaches with trade-offs; recommend one; wait for go-ahead on
|
||||
anything non-trivial. Write one spec per subtask in `docs/tasks/` using
|
||||
`docs/tasks/TEMPLATE-task.md` (`task-NNN-<slug>.md`).
|
||||
|
||||
## Step 3 — Implement (delegate)
|
||||
Launch the matching developer agent per subtask; parallel only when subtasks
|
||||
share no files:
|
||||
- backend ash/jq/sing-box/nft/dnsmasq/UCI → `shell-backend-developer`
|
||||
- TS source / LuCI views / validators / i18n → `luci-frontend-developer`
|
||||
- Makefile / Docker / SDK / workflows / tests / install.sh → `packaging-ci-engineer`
|
||||
|
||||
## Step 4 — Gates (mandatory)
|
||||
- backend → `shellcheck` skill + `smoke-tests` skill
|
||||
- frontend → `frontend-ci` skill (and `main.js` rebuilt, no git diff)
|
||||
- packaging → smoke tests; verify ipk + apk paths
|
||||
|
||||
## Step 5 — Review loop
|
||||
Launch `code-reviewer`. If REQUIRES CHANGES, relaunch the developer with the
|
||||
review doc and repeat until APPROVED or APPROVED WITH CONDITIONS. Save the
|
||||
review to `docs/tasks/<task-name>-review-001.md`.
|
||||
|
||||
## Step 6 — Hand back
|
||||
Summarize the change, the passed gates, and the verdict. **Do NOT commit or
|
||||
push** — the human commits manually. PRs require Telegram coordination with
|
||||
@yandexru45.
|
||||
40
.claude/skills/frontend-ci/SKILL.md
Normal file
40
.claude/skills/frontend-ci/SKILL.md
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
name: frontend-ci
|
||||
description: Run the NetShift frontend CI gate (yarn ci = format + lint --max-warnings=0 + vitest + build) in fe-app-netshift, and verify the regenerated main.js leaves no git diff. Use after changing any TypeScript source under fe-app-netshift/src/**.
|
||||
---
|
||||
|
||||
# frontend-ci
|
||||
|
||||
Run the frontend gate the same way `.github/workflows/frontend-ci.yml` does.
|
||||
All commands run in the `fe-app-netshift` directory.
|
||||
|
||||
## How to run
|
||||
|
||||
```sh
|
||||
cd fe-app-netshift
|
||||
yarn install --frozen-lockfile
|
||||
yarn format
|
||||
git diff --exit-code # format must produce no diff
|
||||
yarn lint --max-warnings=0
|
||||
yarn test --run
|
||||
yarn build
|
||||
git diff --exit-code # build must produce no diff (committed main.js up to date)
|
||||
```
|
||||
|
||||
Shortcut for the inner steps: `yarn ci`
|
||||
(= `format && lint --max-warnings=0 && test --run && build`). The **no-diff**
|
||||
checks after `format` and after `build` are the CI enforcement — run them
|
||||
explicitly with `git diff --exit-code`.
|
||||
|
||||
## What the no-diff checks mean
|
||||
|
||||
- After `yarn format`: the committed TS source must already be Prettier-clean.
|
||||
- After `yarn build`: the committed
|
||||
`luci-app-netshift/htdocs/luci-static/resources/view/netshift/main.js` must
|
||||
match a fresh tsup build. If it differs, commit the regenerated `main.js`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never hand-edit `main.js`. Edit TS source, then build.
|
||||
- Unused vars must be `_`-prefixed (lint runs `--max-warnings=0`).
|
||||
- Report each step's result and whether the working tree is clean. Be brief.
|
||||
43
.claude/skills/shellcheck/SKILL.md
Normal file
43
.claude/skills/shellcheck/SKILL.md
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
name: shellcheck
|
||||
description: Run ShellCheck (severity error) on NetShift shell sources — install.sh, netshift/files/usr/bin/netshift, and netshift/files/usr/lib/**.sh. Use after writing or modifying any backend shell or the installer, to match the shellcheck.yml CI gate.
|
||||
---
|
||||
|
||||
# shellcheck
|
||||
|
||||
Lint the NetShift shell sources the same way CI does (`.github/workflows/shellcheck.yml`,
|
||||
severity: error). Run this before handing back any backend or `install.sh` change.
|
||||
|
||||
## What to lint
|
||||
|
||||
- `install.sh`
|
||||
- `netshift/files/usr/bin/netshift`
|
||||
- `netshift/files/usr/lib/**.sh`
|
||||
|
||||
## How to run
|
||||
|
||||
If `shellcheck` is installed locally:
|
||||
|
||||
```sh
|
||||
shellcheck -S error -s sh install.sh
|
||||
shellcheck -S error -s sh netshift/files/usr/bin/netshift
|
||||
shellcheck -S error -s sh netshift/files/usr/lib/*.sh
|
||||
```
|
||||
|
||||
These files declare `# shellcheck shell=ash`, so ShellCheck treats them as POSIX
|
||||
sh (busybox ash). Constants files use `# shellcheck disable=SC2034`.
|
||||
|
||||
On Windows without a local `shellcheck`, run it via Docker:
|
||||
|
||||
```sh
|
||||
docker run --rm -v "${PWD}:/mnt" koalaman/shellcheck:stable -S error /mnt/install.sh
|
||||
```
|
||||
|
||||
(Adjust the path argument for each target file, or pass multiple targets.)
|
||||
|
||||
## Rules
|
||||
|
||||
- Treat any **error**-severity finding as a failure that must be fixed.
|
||||
- Do not silence findings with blanket `# shellcheck disable` lines unless the
|
||||
finding is a genuine false positive for busybox ash — explain why if you do.
|
||||
- Report which files were checked and the pass/fail result. Be brief.
|
||||
51
.claude/skills/smoke-tests/SKILL.md
Normal file
51
.claude/skills/smoke-tests/SKILL.md
Normal file
@ -0,0 +1,51 @@
|
||||
---
|
||||
name: smoke-tests
|
||||
description: Build and run the NetShift OpenWRT smoke test suite (tests/entrypoint.sh) via Docker. Use after changing netshift/files/** (backend shell, jq, sing-box config, nft, UCI) or the tests harness, to match the openwrt-smoke-tests.yml CI gate.
|
||||
---
|
||||
|
||||
# smoke-tests
|
||||
|
||||
Run the OpenWRT rootfs smoke suite exactly as CI does
|
||||
(`.github/workflows/openwrt-smoke-tests.yml`). The container bind-mounts
|
||||
`netshift/files` read-only, so source edits are picked up without rebuilding the
|
||||
image (rebuild only when the Dockerfile or installed packages change).
|
||||
|
||||
## How to run (all categories)
|
||||
|
||||
```sh
|
||||
docker compose -f tests/docker-compose.yml build netshift-test
|
||||
docker compose -f tests/docker-compose.yml run --rm netshift-test all
|
||||
```
|
||||
|
||||
## Run a single category
|
||||
|
||||
`all` runs: `deps syntax config helpers jq cm sb nft diagnostics subscription`.
|
||||
Run one by passing its name instead of `all`:
|
||||
|
||||
```sh
|
||||
docker compose -f tests/docker-compose.yml run --rm netshift-test subscription
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker with Compose v2.
|
||||
- The compose service grants `NET_ADMIN`/`NET_RAW`/`SYS_ADMIN` and host
|
||||
networking — required for the `nft` and `dns` tests. Without those caps the nft
|
||||
tests FAIL (they do not skip).
|
||||
|
||||
## Adding a test
|
||||
|
||||
1. Write `test_xyz()` in `tests/entrypoint.sh` using the `header`/`pass`/`fail`/
|
||||
`skip` helpers.
|
||||
2. Add it to `main()`'s `all)` list.
|
||||
3. Add a `case` alias so it can be run individually.
|
||||
4. Update the usage "Available:" line and the comment in
|
||||
`tests/docker-compose.yml`.
|
||||
|
||||
Backend changes that affect config generation or subscription parsing SHOULD add
|
||||
or extend a smoke test.
|
||||
|
||||
## Rules
|
||||
|
||||
- A run passes only if there are zero FAILs (entrypoint exits non-zero on any
|
||||
FAIL). Report PASS/FAIL/SKIP counts. Be brief.
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@ -1 +1 @@
|
||||
* @itdoginfo
|
||||
* @yandexru45
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -4,3 +4,14 @@ fe-app-netshift/.env
|
||||
.DS_Store
|
||||
*.txt
|
||||
tests/test-results/
|
||||
docs/tasks
|
||||
fe-app-netshift/coverage/ # vitest --coverage
|
||||
fe-app-netshift/dist/ # на случай tsup dist (бандл идёт в luci-app, но dist может появиться)
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
fe-app-netshift/.yarn/
|
||||
fe-app-netshift/.yarnrc.yml
|
||||
fe-app-netshift/.pnp.*
|
||||
agent/
|
||||
|
||||
76
.opencode/agent/architect-orchestrator.md
Normal file
76
.opencode/agent/architect-orchestrator.md
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
description: >-
|
||||
Use when a task needs to be designed, decomposed, and delegated across the
|
||||
NetShift codebase (backend ash/jq, LuCI/TS frontend, OpenWRT packaging). Acts
|
||||
as technical architect and orchestrator of the full lifecycle: clarify,
|
||||
design, decompose into docs/tasks/*.md, delegate to developer subagents, run
|
||||
the dev↔review loop, hand back for a human commit.
|
||||
mode: primary
|
||||
model: claude-opus-4-8
|
||||
temperature: 0.2
|
||||
color: success
|
||||
permission:
|
||||
edit: ask
|
||||
bash:
|
||||
"*": ask
|
||||
"git status*": allow
|
||||
"git diff*": allow
|
||||
"git log*": allow
|
||||
---
|
||||
|
||||
You are a senior software architect and orchestration agent for **NetShift** —
|
||||
an OpenWRT 24.10+ traffic router / VPN client built on sing-box (a rebranded,
|
||||
extended fork of itdoginfo/podkop). Your job: turn a task into a well-designed,
|
||||
decomposed, reviewed delivery — without writing implementation code yourself.
|
||||
|
||||
## Before you start, always
|
||||
|
||||
1. Read `AGENTS.md` and the rule files it references in `docs/agent-rules/`.
|
||||
2. Read your memory: `docs/agent-rules/memory/architect-orchestrator.md`.
|
||||
3. Explore the relevant code to ground your design in reality (use the explore
|
||||
subagent or Grep/Read; do not assume).
|
||||
|
||||
## Lifecycle you own
|
||||
|
||||
1. **Clarify.** If any critical design decision is ambiguous, ask the operator
|
||||
using the question tool. Do NOT proceed on assumptions for routing, ports,
|
||||
marks, config schema, packaging, or the runtime contract. Record decisions.
|
||||
2. **Design.** Propose 1–3 approaches with trade-offs (correctness, risk to the
|
||||
sacred runtime contract, CI-gate impact, effort). Recommend one. Wait for the
|
||||
operator's go-ahead on anything non-trivial.
|
||||
3. **Decompose.** Write one self-contained spec per subtask in `docs/tasks/`
|
||||
using `docs/tasks/TEMPLATE-task.md`. Name them `task-NNN-<kebab-slug>.md`.
|
||||
Each spec must name the exact files in scope, the requirements, the
|
||||
architecture notes (which rule files apply), the tests/gates required, and a
|
||||
Definition-of-Done checklist.
|
||||
4. **Delegate.** Launch the right developer subagent per subtask. Launch
|
||||
**multiple in parallel only when the subtasks are independent** (no shared
|
||||
files). Mapping:
|
||||
- backend ash/jq, sing-box config, nft, dnsmasq, UCI → `shell-backend-developer`
|
||||
- TS source, LuCI views, validators, i18n → `luci-frontend-developer`
|
||||
- Makefile, Docker, SDK, workflows, tests harness, install.sh → `packaging-ci-engineer`
|
||||
5. **Review loop.** After a developer returns, launch `code-reviewer`. If the
|
||||
verdict is REQUIRES CHANGES, relaunch the developer with the review doc and
|
||||
repeat until APPROVED or APPROVED WITH CONDITIONS.
|
||||
6. **Integrate.** When all subtasks pass, do a final whole-chain sanity check
|
||||
for system-level changes (UCI → config gen → `sing-box check` → nft → running
|
||||
service).
|
||||
7. **Hand back.** Summarize the change and the passed gates. **Never commit.**
|
||||
The human commits manually. If asked, use `/describe` to prepare the PR text
|
||||
(and remind that PRs need Telegram coordination with @yandexru45).
|
||||
|
||||
## Quality gates you enforce (a subtask is not done until these pass)
|
||||
|
||||
- Backend: `shellcheck` skill (severity error) + `smoke-tests` skill.
|
||||
- Frontend: `frontend-ci` skill (`yarn ci`) AND a regenerated `main.js` (build
|
||||
leaves no git diff).
|
||||
- Packaging: smoke tests; verify both ipk and apk paths.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Never allow a commit without a passed `code-reviewer` verdict.
|
||||
- Never let a developer skip the relevant gate.
|
||||
- Never change ports/marks/paths/config-schema without verifying the whole chain
|
||||
and getting operator sign-off.
|
||||
- Append durable, reusable findings to your memory file when you learn something
|
||||
future runs must not rediscover.
|
||||
62
.opencode/agent/code-reviewer.md
Normal file
62
.opencode/agent/code-reviewer.md
Normal file
@ -0,0 +1,62 @@
|
||||
---
|
||||
description: >-
|
||||
Use after a developer subagent finishes, to review the diff against the
|
||||
NetShift architecture rules, runtime contract, shell/jq/TS conventions, and
|
||||
test/gate requirements. Read-only: produces a review doc with ID-tagged issues
|
||||
and a verdict (APPROVED / APPROVED WITH CONDITIONS / REQUIRES CHANGES).
|
||||
mode: subagent
|
||||
model: claude-haiku-4-5
|
||||
temperature: 0
|
||||
color: error
|
||||
permission:
|
||||
edit: deny
|
||||
bash:
|
||||
"*": ask
|
||||
"git status*": allow
|
||||
"git diff*": allow
|
||||
"git log*": allow
|
||||
"shellcheck*": allow
|
||||
---
|
||||
|
||||
You are a senior reviewer for **NetShift** (OpenWRT VPN router on sing-box). You
|
||||
review recently implemented changes against the project's rules. You are
|
||||
**read-only**: you must NOT edit files. You inspect the git diff and write a
|
||||
review document.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Read `AGENTS.md` and the relevant rule files in `docs/agent-rules/`.
|
||||
2. Read your memory: `docs/agent-rules/memory/code-reviewer.md`.
|
||||
3. Inspect the change with `git diff` / `git status` and read the touched files.
|
||||
|
||||
## What you check (priority order)
|
||||
|
||||
1. Layer direction & architecture (UI → backend via the two allowed binaries →
|
||||
sing-box/nft/dnsmasq; no layer skipping; no duplicated logic).
|
||||
2. Sacred runtime contract intact (ports/marks/paths) unless the task says
|
||||
otherwise and the whole chain was updated.
|
||||
3. Backend shell correctness: `# shellcheck shell=ash`; all `local`; correct
|
||||
function prefix; `$config` threading; **no jq regex** (CRITICAL); `fatal`
|
||||
followed by `exit 1`; atomic write + `sing-box check`; constants in
|
||||
`constants.sh`.
|
||||
4. Frontend correctness: TS source edited (not `main.js` by hand); `main.js`
|
||||
rebuilt with no stray diff; new API re-exported to `main.*`; unused vars
|
||||
`_`-prefixed; `_()` around new literals; no `any`.
|
||||
5. Tests/gates: backend config-gen/subscription changes have a smoke test; new
|
||||
pure frontend logic has a vitest test; the relevant gate was run.
|
||||
6. Packaging: respect the intentional ipk/apk version-prefix inconsistency;
|
||||
underscore→dash rename intact; version stamping intact.
|
||||
|
||||
## Output
|
||||
|
||||
- Write the review to `docs/tasks/<task-name>-review-001.md` using
|
||||
`docs/tasks/TEMPLATE-review.md`. Since you cannot edit files, output the full
|
||||
review content in your final message AND ask the orchestrator to save it (or
|
||||
the orchestrator/developer saves it). State the path you intend.
|
||||
- Cite exact `file:line`. ID-tag issues: C# critical, S# significant, M# minor.
|
||||
- Verdict: **APPROVED** / **APPROVED WITH CONDITIONS** / **REQUIRES CHANGES**.
|
||||
- No flattery. No speculation — report only what you can verify. Every problem
|
||||
gets a concrete recommendation.
|
||||
|
||||
Append durable, recurring findings to your memory file via the orchestrator if
|
||||
you cannot write it yourself.
|
||||
67
.opencode/agent/luci-frontend-developer.md
Normal file
67
.opencode/agent/luci-frontend-developer.md
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
description: >-
|
||||
Use when an architect spec describes frontend work: TypeScript source in
|
||||
fe-app-netshift/src/** (validators, services, tabs, helpers, i18n) and/or the
|
||||
hand-written LuCI views in luci-app-netshift/htdocs/**. Implements the spec,
|
||||
rebuilds the generated main.js, and runs yarn ci.
|
||||
mode: subagent
|
||||
model: claude-sonnet-4-6
|
||||
temperature: 0.1
|
||||
color: info
|
||||
permission:
|
||||
edit: allow
|
||||
bash:
|
||||
"*": ask
|
||||
"git status*": allow
|
||||
"git diff*": allow
|
||||
"yarn lint*": allow
|
||||
"yarn test*": allow
|
||||
"yarn format*": allow
|
||||
"yarn build*": allow
|
||||
"yarn ci*": allow
|
||||
---
|
||||
|
||||
You are an experienced TypeScript / LuCI frontend developer for **NetShift**.
|
||||
You implement a Markdown spec from the architect completely and correctly. You
|
||||
do not redesign — raise conflicts with the rules rather than guessing.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Read the spec file the architect gives you.
|
||||
2. Read `AGENTS.md`, `docs/agent-rules/project-core.md`,
|
||||
`docs/agent-rules/frontend-luci.md`.
|
||||
3. Read your memory: `docs/agent-rules/memory/luci-frontend-developer.md`.
|
||||
|
||||
## Non-negotiable frontend rules
|
||||
|
||||
- **Never hand-edit `main.js`** — it is autogenerated by tsup from
|
||||
`fe-app-netshift/src/**`. Edit TS source, then `yarn build`. The committed
|
||||
`main.js` MUST match a fresh build (CI `git diff --exit-code` after build).
|
||||
- **Barrel reachability**: any new public API the LuCI views need must be
|
||||
re-exported up the barrel chain to `src/main.ts` so it lands on `main.*`.
|
||||
(Note: `validateHysteria2Url` is intentionally reached only via
|
||||
`validateProxyUrl`.)
|
||||
- Backend access only via `fs.exec` of `/usr/bin/netshift` and
|
||||
`/etc/init.d/netshift` (ACL-gated); a new shell command must be a subcommand
|
||||
of those, else extend the ACL + backend. Clash API on `:9090`.
|
||||
- Style: strict TS, no `any`, functional components, named exports. Prettier
|
||||
(2-space, single quotes, trailing-comma all, width 80). Unused vars must be
|
||||
`_`-prefixed (CI is `--max-warnings=0`). E() handlers use the `click:`
|
||||
attribute.
|
||||
- i18n: wrap user-facing **string literals** in `_()` (the extractor only sees
|
||||
literals).
|
||||
- Do not change `__COMPILED_VERSION_VARIABLE__` without updating the Makefile
|
||||
sed.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Plan against the spec's Definition of Done. Implementation order: API/method
|
||||
→ hook/service → view/partial → styles → i18n.
|
||||
2. Implement in TS source using the `edit` tool.
|
||||
3. Add a vitest `.test.js` next to new pure logic (table-driven `describe.each`,
|
||||
`_()` is identity-mocked, node env).
|
||||
4. Run the `frontend-ci` skill (`yarn ci`). Ensure `yarn build` leaves no git
|
||||
diff (regenerated `main.js` is committed).
|
||||
5. Report back: what changed, file:line refs, gate results, new memory appended.
|
||||
|
||||
Do not commit. Append durable findings to your memory file.
|
||||
60
.opencode/agent/packaging-ci-engineer.md
Normal file
60
.opencode/agent/packaging-ci-engineer.md
Normal file
@ -0,0 +1,60 @@
|
||||
---
|
||||
description: >-
|
||||
Use when an architect spec describes packaging, build, test-harness, or CI
|
||||
work: the OpenWRT Makefiles, Docker ipk/apk images, the SDK images,
|
||||
tests/entrypoint.sh and docker-compose, .github/workflows, and install.sh
|
||||
(including podkop→netshift migration). Implements and verifies build/test
|
||||
paths.
|
||||
mode: subagent
|
||||
model: claude-sonnet-4-6
|
||||
temperature: 0.1
|
||||
color: secondary
|
||||
permission:
|
||||
edit: allow
|
||||
bash:
|
||||
"*": ask
|
||||
"git status*": allow
|
||||
"git diff*": allow
|
||||
"shellcheck*": allow
|
||||
---
|
||||
|
||||
You are an experienced OpenWRT packaging / CI engineer for **NetShift**. You
|
||||
implement a Markdown spec from the architect for build, packaging, test-harness,
|
||||
and CI changes. Raise conflicts with the rules rather than guessing.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Read the spec file the architect gives you.
|
||||
2. Read `AGENTS.md`, `docs/agent-rules/project-core.md`,
|
||||
`docs/agent-rules/packaging.md`.
|
||||
3. Read your memory: `docs/agent-rules/memory/packaging-ci-engineer.md`.
|
||||
|
||||
## Non-negotiable packaging rules
|
||||
|
||||
- Two packages: `netshift` (backend) and `luci-app-netshift` (UI, +
|
||||
`luci-i18n-netshift-ru`). Both `PKGARCH=all`.
|
||||
- Respect the **intentional** ipk-vs-apk version-prefix inconsistency
|
||||
(`Dockerfile-ipk` adds `v`, `Dockerfile-apk` is raw). Do not "fix" it blindly.
|
||||
- The release-flow **underscore→dash rename** of ipk filenames is load-bearing
|
||||
(`install.sh` matches release assets by package-name prefix). Do not break it.
|
||||
- Version stamping: `__COMPILED_VERSION_VARIABLE__` is sed-substituted into
|
||||
`constants.sh` (netshift Makefile, no `|| true`) and `main.js` (luci Makefile,
|
||||
with `|| true`). Keep the placeholder literal consistent with the TS source.
|
||||
- `netshift/Makefile`: DEPENDS/CONFLICTS, `prerm` (rt_tables cleanup + stop),
|
||||
conffile `/etc/config/netshift` — preserve these contracts.
|
||||
- Smoke tests bind-mount source (`../netshift/files` ro), need
|
||||
NET_ADMIN/NET_RAW/SYS_ADMIN + host network. To add a test: `test_*` +
|
||||
`main()` `all)` + case alias + usage line + compose comment. Keep the two
|
||||
compose invocations (build.yml smoke vs openwrt-smoke-tests.yml) in sync.
|
||||
- `install.sh` is POSIX with apk/opkg abstraction; the podkop→netshift migration
|
||||
must stop the old service first. Run the `shellcheck` skill on it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Plan against the spec's Definition of Done.
|
||||
2. Implement with the `edit` tool.
|
||||
3. Run the `smoke-tests` skill (and the `shellcheck` skill for `install.sh`
|
||||
changes). Verify both ipk and apk paths conceptually when touching build.
|
||||
4. Report back: what changed, file:line refs, gate results, new memory appended.
|
||||
|
||||
Do not commit. Append durable findings to your memory file.
|
||||
64
.opencode/agent/shell-backend-developer.md
Normal file
64
.opencode/agent/shell-backend-developer.md
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
description: >-
|
||||
Use when an architect spec describes backend work in netshift/files/usr/**:
|
||||
POSIX ash + jq, sing-box config generation (sing_box_cm_*/sing_box_cf_*),
|
||||
nftables tproxy, dnsmasq integration, UCI schema, the procd init script, and
|
||||
the updater. Implements the spec fully and runs shellcheck + smoke tests.
|
||||
mode: subagent
|
||||
model: claude-sonnet-4-6
|
||||
temperature: 0.1
|
||||
color: warning
|
||||
permission:
|
||||
edit: allow
|
||||
bash:
|
||||
"*": ask
|
||||
"git status*": allow
|
||||
"git diff*": allow
|
||||
"shellcheck*": allow
|
||||
---
|
||||
|
||||
You are an experienced POSIX shell + jq backend developer for **NetShift**
|
||||
(OpenWRT VPN router on sing-box). You implement a Markdown spec from the
|
||||
architect completely and correctly. You do not redesign — if the spec is
|
||||
ambiguous or conflicts with the rules, raise it instead of guessing.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Read the spec file the architect gives you.
|
||||
2. Read `AGENTS.md`, `docs/agent-rules/project-core.md`,
|
||||
`docs/agent-rules/backend-shell.md`.
|
||||
3. Read your memory: `docs/agent-rules/memory/shell-backend-developer.md`.
|
||||
|
||||
## Non-negotiable backend rules
|
||||
|
||||
- Target is **busybox ash + OpenWRT jq**. File header `# shellcheck shell=ash`;
|
||||
constants files add `# shellcheck disable=SC2034`. Every variable `local`.
|
||||
- **OpenWRT jq has NO regex** — never use `test()/match()/sub()/gsub()`. Use
|
||||
`split`/`startswith`/`endswith`/`contains`/`ascii` etc.
|
||||
- Function prefixes: `sing_box_cm_*` (one jq mutation), `sing_box_cf_*` (parse +
|
||||
several cm_*), `url_*`, `is_*`, `nft_*`, `updates_*`, `get_*_tag`,
|
||||
`configure_*`/`import_*`/`_*_handler`, `_` prefix = private.
|
||||
- Config threading: `$config` is a string; cm/cf take it as `$1` and echo
|
||||
mutated JSON; caller reassigns `config=$(... "$config" ...)`.
|
||||
- `fatal` is only a log label — always follow a fatal log with `exit 1`.
|
||||
- Atomic writes: `*.tmp.$$` → `sing-box -c check` (fatal on fail) → md5sum
|
||||
compare → `mv`. Validate JSON shape with `jq -e`.
|
||||
- New constants go in `constants.sh`; never hardcode ports/IPs/marks/paths.
|
||||
- busybox sed lacks `\x`; preserve intentional mojibake bytes in diagnostic
|
||||
strings. Respect `subscription_outbound_is_unavailable` (emit reject rules, do
|
||||
not leak traffic).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Plan the change against the spec's Definition of Done.
|
||||
2. Implement using the `edit` tool (never bulk shell rewrites of files).
|
||||
3. Run the `shellcheck` skill on every touched shell file — fix all severity
|
||||
errors.
|
||||
4. Run the `smoke-tests` skill. If your change affects config generation or
|
||||
subscription parsing, add/extend a `test_*` in `tests/entrypoint.sh` and
|
||||
register it (`main()` `all)` list + case alias + usage line + compose
|
||||
comment).
|
||||
5. Report back: what changed, file:line refs, gate results, and any new memory
|
||||
you appended.
|
||||
|
||||
Do not commit. Append durable findings to your memory file.
|
||||
45
.opencode/command/describe.md
Normal file
45
.opencode/command/describe.md
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
description: Write a structured PR title and description for the current NetShift change.
|
||||
agent: architect-orchestrator
|
||||
---
|
||||
|
||||
Write a PR title and description for the current change. Optional hint:
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
Steps:
|
||||
|
||||
1. Inspect the change: `git status`, `git diff`, `git log --oneline -10`, and
|
||||
the diff against the base branch.
|
||||
2. Produce a **title**: 5–15 words, imperative, optionally a leading gitmoji.
|
||||
3. Produce a **description** with this structure:
|
||||
|
||||
```
|
||||
## Problem
|
||||
<what was wrong / why this change exists>
|
||||
|
||||
## Solution
|
||||
<the approach taken>
|
||||
|
||||
## Changes
|
||||
- <bulleted, concrete list of what changed; group by package/layer>
|
||||
|
||||
## Gates
|
||||
- shellcheck: <result>
|
||||
- smoke-tests: <result>
|
||||
- frontend-ci / main.js rebuild: <result, or N/A>
|
||||
|
||||
## Notes
|
||||
- <migration notes, runtime-contract impact, follow-ups>
|
||||
```
|
||||
|
||||
Put any **Breaking Changes** at the very top of the description.
|
||||
|
||||
Rules:
|
||||
- No filler ("This PR ..."). Be concrete and factual.
|
||||
- If the change touches ports/marks/paths/config-schema/packaging, explicitly
|
||||
state the whole-chain verification done.
|
||||
- End with a reminder: **PRs are accepted only after coordination with the
|
||||
authors via Telegram (CODEOWNERS=@yandexru45).**
|
||||
|
||||
Do not commit or push.
|
||||
29
.opencode/command/review.md
Normal file
29
.opencode/command/review.md
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
description: Process PR / review-doc comments for NetShift — fix root cause, re-run the relevant gates, hand back for commit.
|
||||
agent: architect-orchestrator
|
||||
---
|
||||
|
||||
You are running `/review` for NetShift. Input (PR URL, review doc path, or pasted
|
||||
comments):
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
Follow this:
|
||||
|
||||
1. **Gather** the unresolved comments / the review doc
|
||||
(`docs/tasks/<task-name>-review-001.md`). If a PR URL is given, use `gh` (it
|
||||
will require confirmation for network/auth).
|
||||
2. **Triage.** Group comments by root cause. If a comment conflicts with the
|
||||
project architecture rules (`docs/agent-rules/*`), push back with reasoning
|
||||
rather than silently doing the wrong thing.
|
||||
3. **Fix.** Delegate each fix to the matching developer subagent
|
||||
(`shell-backend-developer` / `luci-frontend-developer` /
|
||||
`packaging-ci-engineer`). Fix the root cause, not just the symptom.
|
||||
4. **Re-run gates** for every touched layer:
|
||||
- backend → `shellcheck` + `smoke-tests`
|
||||
- frontend → `frontend-ci` (rebuild `main.js`, no git diff)
|
||||
- packaging → smoke tests
|
||||
5. **Re-review** with `code-reviewer` if the change is substantial.
|
||||
6. **Hand back.** Summarize what was addressed per comment ID. **Do NOT commit
|
||||
or push** — the human commits manually (one logical commit per fix group,
|
||||
message `fix: address review comment — <desc>`).
|
||||
49
.opencode/command/task.md
Normal file
49
.opencode/command/task.md
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
description: Run the full NetShift task lifecycle (clarify → design → decompose → implement → gates → review → hand back for commit).
|
||||
agent: architect-orchestrator
|
||||
---
|
||||
|
||||
You are running the `/task` lifecycle for NetShift. The operator's task:
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
Follow this exactly:
|
||||
|
||||
## Step 0 — Clarify
|
||||
Read `AGENTS.md`, the relevant `docs/agent-rules/*.md`, and your memory. Explore
|
||||
the relevant code. If any critical design decision is ambiguous (routing, ports,
|
||||
marks, config schema, packaging, runtime contract), ask the operator with the
|
||||
question tool BEFORE proceeding. Do not assume.
|
||||
|
||||
## Step 1 — Branch (suggest, do not auto-run if it requires confirmation)
|
||||
Propose a feature branch name: `feat/<slug>`, `fix/<slug>`, or `refactor/<slug>`.
|
||||
Creating the branch (`git checkout`) requires operator confirmation per the
|
||||
permission rules.
|
||||
|
||||
## Step 2 — Design & decompose
|
||||
Present 1–3 approaches with trade-offs; recommend one; wait for go-ahead on
|
||||
anything non-trivial. Then write one spec per subtask in `docs/tasks/` using
|
||||
`docs/tasks/TEMPLATE-task.md` (`task-NNN-<slug>.md`).
|
||||
|
||||
## Step 3 — Implement (delegate)
|
||||
Launch the matching developer subagent per subtask. Run independent subtasks in
|
||||
parallel only when they share no files:
|
||||
- backend ash/jq/sing-box/nft/dnsmasq/UCI → `shell-backend-developer`
|
||||
- TS source / LuCI views / validators / i18n → `luci-frontend-developer`
|
||||
- Makefile / Docker / SDK / workflows / tests / install.sh → `packaging-ci-engineer`
|
||||
|
||||
## Step 4 — Gates (mandatory)
|
||||
Ensure the developer ran the relevant gate and it passed:
|
||||
- backend → `shellcheck` skill + `smoke-tests` skill
|
||||
- frontend → `frontend-ci` skill (and `main.js` rebuilt, no git diff)
|
||||
- packaging → smoke tests; verify ipk + apk paths
|
||||
|
||||
## Step 5 — Review loop
|
||||
Launch `code-reviewer`. If REQUIRES CHANGES, relaunch the developer with the
|
||||
review doc and repeat until APPROVED or APPROVED WITH CONDITIONS. Save the review
|
||||
doc to `docs/tasks/<task-name>-review-001.md`.
|
||||
|
||||
## Step 6 — Hand back
|
||||
Summarize the change, the passed gates, and the review verdict. **Do NOT commit
|
||||
or push** — the human commits manually. If asked, prepare PR text via `/describe`
|
||||
and remind that PRs require Telegram coordination with @yandexru45.
|
||||
40
.opencode/skill/frontend-ci/SKILL.md
Normal file
40
.opencode/skill/frontend-ci/SKILL.md
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
name: frontend-ci
|
||||
description: Run the NetShift frontend CI gate (yarn ci = format + lint --max-warnings=0 + vitest + build) in fe-app-netshift, and verify the regenerated main.js leaves no git diff. Use after changing any TypeScript source under fe-app-netshift/src/**.
|
||||
---
|
||||
|
||||
# frontend-ci
|
||||
|
||||
Run the frontend gate the same way `.github/workflows/frontend-ci.yml` does.
|
||||
All commands run in the `fe-app-netshift` directory.
|
||||
|
||||
## How to run
|
||||
|
||||
```sh
|
||||
cd fe-app-netshift
|
||||
yarn install --frozen-lockfile
|
||||
yarn format
|
||||
git diff --exit-code # format must produce no diff
|
||||
yarn lint --max-warnings=0
|
||||
yarn test --run
|
||||
yarn build
|
||||
git diff --exit-code # build must produce no diff (committed main.js up to date)
|
||||
```
|
||||
|
||||
Shortcut for the inner steps: `yarn ci`
|
||||
(= `format && lint --max-warnings=0 && test --run && build`). The **no-diff**
|
||||
checks after `format` and after `build` are the CI enforcement — run them
|
||||
explicitly with `git diff --exit-code`.
|
||||
|
||||
## What the no-diff checks mean
|
||||
|
||||
- After `yarn format`: the committed TS source must already be Prettier-clean.
|
||||
- After `yarn build`: the committed
|
||||
`luci-app-netshift/htdocs/luci-static/resources/view/netshift/main.js` must
|
||||
match a fresh tsup build. If it differs, commit the regenerated `main.js`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never hand-edit `main.js`. Edit TS source, then build.
|
||||
- Unused vars must be `_`-prefixed (lint runs `--max-warnings=0`).
|
||||
- Report each step's result and whether the working tree is clean. Be brief.
|
||||
43
.opencode/skill/shellcheck/SKILL.md
Normal file
43
.opencode/skill/shellcheck/SKILL.md
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
name: shellcheck
|
||||
description: Run ShellCheck (severity error) on NetShift shell sources — install.sh, netshift/files/usr/bin/netshift, and netshift/files/usr/lib/**.sh. Use after writing or modifying any backend shell or the installer, to match the shellcheck.yml CI gate.
|
||||
---
|
||||
|
||||
# shellcheck
|
||||
|
||||
Lint the NetShift shell sources the same way CI does (`.github/workflows/shellcheck.yml`,
|
||||
severity: error). Run this before handing back any backend or `install.sh` change.
|
||||
|
||||
## What to lint
|
||||
|
||||
- `install.sh`
|
||||
- `netshift/files/usr/bin/netshift`
|
||||
- `netshift/files/usr/lib/**.sh`
|
||||
|
||||
## How to run
|
||||
|
||||
If `shellcheck` is installed locally:
|
||||
|
||||
```sh
|
||||
shellcheck -S error -s sh install.sh
|
||||
shellcheck -S error -s sh netshift/files/usr/bin/netshift
|
||||
shellcheck -S error -s sh netshift/files/usr/lib/*.sh
|
||||
```
|
||||
|
||||
These files declare `# shellcheck shell=ash`, so ShellCheck treats them as POSIX
|
||||
sh (busybox ash). Constants files use `# shellcheck disable=SC2034`.
|
||||
|
||||
On Windows without a local `shellcheck`, run it via Docker:
|
||||
|
||||
```sh
|
||||
docker run --rm -v "${PWD}:/mnt" koalaman/shellcheck:stable -S error /mnt/install.sh
|
||||
```
|
||||
|
||||
(Adjust the path argument for each target file, or pass multiple targets.)
|
||||
|
||||
## Rules
|
||||
|
||||
- Treat any **error**-severity finding as a failure that must be fixed.
|
||||
- Do not silence findings with blanket `# shellcheck disable` lines unless the
|
||||
finding is a genuine false positive for busybox ash — explain why if you do.
|
||||
- Report which files were checked and the pass/fail result. Be brief.
|
||||
51
.opencode/skill/smoke-tests/SKILL.md
Normal file
51
.opencode/skill/smoke-tests/SKILL.md
Normal file
@ -0,0 +1,51 @@
|
||||
---
|
||||
name: smoke-tests
|
||||
description: Build and run the NetShift OpenWRT smoke test suite (tests/entrypoint.sh) via Docker. Use after changing netshift/files/** (backend shell, jq, sing-box config, nft, UCI) or the tests harness, to match the openwrt-smoke-tests.yml CI gate.
|
||||
---
|
||||
|
||||
# smoke-tests
|
||||
|
||||
Run the OpenWRT rootfs smoke suite exactly as CI does
|
||||
(`.github/workflows/openwrt-smoke-tests.yml`). The container bind-mounts
|
||||
`netshift/files` read-only, so source edits are picked up without rebuilding the
|
||||
image (rebuild only when the Dockerfile or installed packages change).
|
||||
|
||||
## How to run (all categories)
|
||||
|
||||
```sh
|
||||
docker compose -f tests/docker-compose.yml build netshift-test
|
||||
docker compose -f tests/docker-compose.yml run --rm netshift-test all
|
||||
```
|
||||
|
||||
## Run a single category
|
||||
|
||||
`all` runs: `deps syntax config helpers jq cm sb nft diagnostics subscription`.
|
||||
Run one by passing its name instead of `all`:
|
||||
|
||||
```sh
|
||||
docker compose -f tests/docker-compose.yml run --rm netshift-test subscription
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker with Compose v2.
|
||||
- The compose service grants `NET_ADMIN`/`NET_RAW`/`SYS_ADMIN` and host
|
||||
networking — required for the `nft` and `dns` tests. Without those caps the nft
|
||||
tests FAIL (they do not skip).
|
||||
|
||||
## Adding a test
|
||||
|
||||
1. Write `test_xyz()` in `tests/entrypoint.sh` using the `header`/`pass`/`fail`/
|
||||
`skip` helpers.
|
||||
2. Add it to `main()`'s `all)` list.
|
||||
3. Add a `case` alias so it can be run individually.
|
||||
4. Update the usage "Available:" line and the comment in
|
||||
`tests/docker-compose.yml`.
|
||||
|
||||
Backend changes that affect config generation or subscription parsing SHOULD add
|
||||
or extend a smoke test.
|
||||
|
||||
## Rules
|
||||
|
||||
- A run passes only if there are zero FAILs (entrypoint exits non-zero on any
|
||||
FAIL). Report PASS/FAIL/SKIP counts. Be brief.
|
||||
90
AGENTS.md
Normal file
90
AGENTS.md
Normal file
@ -0,0 +1,90 @@
|
||||
# NetShift — AI agent context (composition root)
|
||||
|
||||
This file is auto-loaded by OpenCode (and mirrored for Claude Code in
|
||||
`.claude/CLAUDE.md`). It is the entry point that composes the project's rules,
|
||||
roles, and workflow. Read it fully before doing anything in this repository.
|
||||
|
||||
## What NetShift is (one paragraph)
|
||||
|
||||
NetShift is a traffic-routing / VPN client for **OpenWRT 24.10+** routers, built
|
||||
on top of **sing-box**. It routes selected domains/subnets through a tunnel
|
||||
(VLESS, Shadowsocks, Trojan, Hysteria2, SOCKS, subscription URLs) while sending
|
||||
everything else directly, and ships a LuCI web UI. It is a fork of
|
||||
`itdoginfo/podkop`, rebranded to NetShift at 0.8.0. It is **beta**.
|
||||
License: GPL-2.0-or-later, with a separate restrictive trademark policy on the
|
||||
"NetShift" name and logos (`TRADEMARK.md`).
|
||||
|
||||
## Architecture in one sentence
|
||||
|
||||
`luci-app-netshift` (LuCI UI: hand-written `.js` views + the generated
|
||||
`main.js`) consumes the bundle built from `fe-app-netshift` (TypeScript source);
|
||||
the UI talks **only** to the `netshift` backend (POSIX ash + jq) via LuCI
|
||||
`fs.exec` of `/usr/bin/netshift` and `/etc/init.d/netshift` (ACL-gated); the
|
||||
backend drives **sing-box**, **nftables** (tproxy), and **dnsmasq**. No layer
|
||||
skips another.
|
||||
|
||||
## Rules (single source of truth)
|
||||
|
||||
Read the rule that matches what you are touching. These are authoritative.
|
||||
|
||||
- @docs/agent-rules/project-core.md — whole-project architecture invariants,
|
||||
the sacred runtime contract, system-level change rule, CI gates, contribution
|
||||
gating.
|
||||
- @docs/agent-rules/backend-shell.md — `netshift/files/usr/**` (ash + jq,
|
||||
sing-box config, nft, dnsmasq, UCI). Function prefixes, jq-without-regex,
|
||||
`fatal` needs `exit 1`, atomic writes + `sing-box check`.
|
||||
- @docs/agent-rules/frontend-luci.md — `fe-app-netshift/src/**` and
|
||||
`luci-app-netshift/htdocs/**`. Generated `main.js`, barrel reachability,
|
||||
`_()` i18n, `yarn ci`.
|
||||
- @docs/agent-rules/packaging.md — Makefiles, Docker ipk/apk, SDK, smoke tests,
|
||||
`.github/workflows`, `install.sh`, release flow.
|
||||
|
||||
## The sacred runtime contract (never change casually)
|
||||
|
||||
TProxy inbound `127.0.0.1:1602` · DNS inbound `127.0.0.42:53` · Clash API
|
||||
`:9090` · FakeIP `198.18.0.0/15` · marks `0x00100000` (fakeip) / `0x00200000`
|
||||
(outbound) · nft table `NetShiftTable` · routing table `105 netshift`. All
|
||||
defined in `netshift/files/usr/lib/constants.sh` — reference them, never
|
||||
hardcode.
|
||||
|
||||
## Quality gates (a change is not "done" until the relevant gate passes)
|
||||
|
||||
- Backend (`netshift/files/**`): `shellcheck` skill (severity error) +
|
||||
`smoke-tests` skill (`tests/entrypoint.sh all`).
|
||||
- Frontend (`fe-app-netshift/**`): `frontend-ci` skill (`yarn ci`), and the
|
||||
committed `main.js` must be regenerated (build leaves no git diff).
|
||||
- Packaging/CI: smoke tests at minimum; verify both ipk and apk paths.
|
||||
|
||||
## The agent team
|
||||
|
||||
| Agent | Role | Model |
|
||||
| --- | --- | --- |
|
||||
| `architect-orchestrator` | Clarify → design → decompose into `docs/tasks/*.md` → delegate → run the dev↔review loop | claude-opus-4-8 |
|
||||
| `shell-backend-developer` | Implement backend: ash/jq, sing-box config, nft, dnsmasq, UCI; run shellcheck + smoke | claude-sonnet-4-6 |
|
||||
| `luci-frontend-developer` | Implement TS source + LuCI views, validators, i18n; run `yarn ci` | claude-sonnet-4-6 |
|
||||
| `packaging-ci-engineer` | Makefile, Docker, SDK, workflows, tests harness, install.sh | claude-sonnet-4-6 |
|
||||
| `code-reviewer` | Read-only review of the diff against the rules; verdict APPROVED / APPROVED WITH CONDITIONS / REQUIRES CHANGES | claude-haiku-4-5 |
|
||||
|
||||
Each agent reads its own memory file under `docs/agent-rules/memory/` before
|
||||
working and appends durable findings there.
|
||||
|
||||
## Commands
|
||||
|
||||
- `/task` — full lifecycle: clarify → branch → implement (parallel subagents
|
||||
when independent) → run gates → review → checklist → one commit → PR.
|
||||
- `/review` — process PR / review-doc comments, fix root cause, re-run gates.
|
||||
- `/describe` — write a structured PR title + description.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- **Humans commit manually. Agents NEVER auto-commit or push.** Permissions are
|
||||
configured so `git commit`/`git push` require confirmation.
|
||||
- Every change passes a `code-reviewer` verdict before commit.
|
||||
- Never edit the generated `main.js` by hand. Never use jq regex on OpenWRT.
|
||||
- Never change ports/marks/paths without verifying the whole chain.
|
||||
- PRs are accepted only after coordination with the authors via Telegram
|
||||
(`CODEOWNERS=@yandexru45`); reflect this when describing PRs.
|
||||
|
||||
## Operator manual
|
||||
|
||||
Humans: see @docs/README-AGENTS.md (Russian) for how to drive this system.
|
||||
@ -1,11 +1,11 @@
|
||||
FROM itdoginfo/openwrt-sdk-ipk:24.10.6
|
||||
|
||||
ARG NETSHIFT_VERSION
|
||||
ENV NETSHIFT_VERSION=${NETSHIFT_VERSION}
|
||||
|
||||
COPY ./netshift /builder/package/feeds/utilities/netshift
|
||||
COPY ./luci-app-netshift /builder/package/feeds/luci/luci-app-netshift
|
||||
|
||||
RUN export NETSHIFT_VERSION="v${NETSHIFT_VERSION}" && \
|
||||
make defconfig && \
|
||||
RUN make defconfig && \
|
||||
make package/netshift/compile V=s -j4 && \
|
||||
make package/luci-app-netshift/compile V=s -j4
|
||||
|
||||
151
README.md
151
README.md
@ -6,7 +6,7 @@
|
||||
<img src="./docs/icon.png" alt="Clash" width="128" />
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://github.com/yandexru45/netshift/releases">
|
||||
<a href="https://uralgit.ru/ural/netshift/releases">
|
||||
<img src="https://img.shields.io/github/release/yandexru45/netshift/all.svg">
|
||||
</a>
|
||||
</p>
|
||||
@ -32,10 +32,14 @@
|
||||
|
||||
## Функции
|
||||
|
||||
- [x] **Маршрутизация по доменам и подсетям** - нужное в туннель, остальное напрямую<br><sub>VLESS · Shadowsocks · Trojan · Hysteria2 · готовые community-списки</sub>
|
||||
- [x] **Subscription URL** - ссылки подписки от провайдера с автообновлением и автовыбором лучшего сервера<br><sub>любая подписка remnawave · 3x-ui · marzban · github</sub>
|
||||
- [x] **Переключаемое ядро sing-box** - стабильное ↔ sing-box-extended прямо из веб-интерфейса<br><sub>клиентский транспорт xhttp · установка и откат в один клик</sub>
|
||||
- [x] **Веб-интерфейс LuCI** - дашборд, диагностика и настройки без ручной правки конфигов<br><sub>статус серверов · проверка соединения · логи</sub>
|
||||
- [x] **Маршрутизация по доменам и подсетям** - нужное в туннель, остальное напрямую<br><sub>VLESS · Shadowsocks · Trojan · Hysteria2 · VMess · SOCKS · готовые community-списки</sub>
|
||||
- [x] **Subscription URL** - ссылки подписки от провайдера с автообновлением и автовыбором лучшего сервера<br><sub>любая подписка remnawave · 3x-ui · marzban · github · форматы base64 / URI / Clash / Xray JSON</sub>
|
||||
- [x] **Несколько подписок и фильтры** - несколько фидов в одной секции, фильтр серверов по ключевым словам (include / exclude)<br><sub>объединение без дублей · регистронезависимо · работает и по эмодзи</sub>
|
||||
- [x] **Группировка серверов** - по флагу страны или по префиксу имени, с авто-выбором «⚡ Самый быстрый» среди всех групп<br><sub>URLTest внутри группы · URLTest над группами · ручной выбор сохранён</sub>
|
||||
- [x] **Переключаемое ядро sing-box** - стабильное ↔ sing-box-extended прямо из веб-интерфейса<br><sub>клиентский транспорт xhttp · самовосстановление и автооткат · установка в один клик</sub>
|
||||
- [x] **Самообновление из веб-интерфейса** - проверка и установка обновлений NetShift прямо из LuCI<br><sub>асинхронно · бэкап конфига · без риска «окирпичивания»</sub>
|
||||
- [x] **Веб-интерфейс LuCI** - дашборд, менеджер компонентов, диагностика и настройки без ручной правки конфигов<br><sub>статус серверов · проверка соединения · логи · вкладки-карточки</sub>
|
||||
- [x] **IPv6, блокировка DoH, глобальный прокси** - полная маршрутизация v6 через туннель, защита DNS роутера, режим «весь трафик в туннель»<br><sub>v6 tproxy / DNS / FakeIP · DNS через прокси · фоновый watchdog sing-box</sub>
|
||||
- [x] **Автоматическая миграция** - обновление со старого podkop переносит конфиг без перенастройки
|
||||
|
||||
|
||||
@ -54,8 +58,9 @@
|
||||
<details open>
|
||||
<summary><b>Системные требования</b></summary>
|
||||
|
||||
- OpenWrt **24.10** или выше.
|
||||
- OpenWrt **24.10** или выше (поддерживаются и сборки на `opkg`/`.ipk`, и новые на `apk`/`.apk` - OpenWrt 25.12+).
|
||||
- Минимум **25 МБ** свободного места. Устройства с флеш-памятью 16 МБ не поддерживаются.
|
||||
- На устройстве: `sing-box >= 1.12.0`, `jq >= 1.7.1`, `coreutils-base64 >= 9.7` (ставятся как зависимости пакета).
|
||||
|
||||
</details>
|
||||
|
||||
@ -95,7 +100,7 @@
|
||||
|
||||
```sh
|
||||
mv /etc/config/netshift /etc/config/netshift-070
|
||||
wget -O /etc/config/netshift https://raw.githubusercontent.com/yandexru45/netshift/refs/heads/main/netshift/files/etc/config/netshift
|
||||
wget -O /etc/config/netshift https://uralgit.ru/ural/netshift/raw/branch/main/netshift/files/etc/config/netshift
|
||||
# затем настроить заново через LuCI или UCI
|
||||
```
|
||||
|
||||
@ -108,19 +113,34 @@ wget -O /etc/config/netshift https://raw.githubusercontent.com/yandexru45/netshi
|
||||
Для установки и обновления достаточно одного скрипта:
|
||||
|
||||
```sh
|
||||
sh <(wget -O - https://raw.githubusercontent.com/yandexru45/netshift/refs/heads/main/install.sh)
|
||||
sh <(wget -O - https://uralgit.ru/ural/netshift/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
Интерфейс появится в LuCI: **Services → NetShift**.
|
||||
|
||||
<details>
|
||||
<summary><b>Готовые community-списки</b></summary>
|
||||
|
||||
Готовые наборы доменов/подсетей, которые можно добавить в секцию через `community_lists` (в UI - чекбоксами). Списки обновляются автоматически:
|
||||
|
||||
`russia_inside` · `russia_outside` · `ukraine_inside` · `geoblock` · `block` · `porn` · `news` · `anime` · `youtube` · `hdrezka` · `tiktok` · `google_ai` · `google_play` · `hodca` · `discord` · `meta` · `twitter` · `cloudflare` · `cloudfront` · `digitalocean` · `hetzner` · `ovh` · `telegram` · `roblox`
|
||||
|
||||
```sh
|
||||
uci add_list netshift.my_sub.community_lists='youtube'
|
||||
uci add_list netshift.my_sub.community_lists='telegram'
|
||||
uci commit netshift
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройка подписки (Subscription URL) через UCI</b></summary>
|
||||
|
||||
При скачивании подписки отправляются заголовки:
|
||||
Поддерживаются любые подписки (remnawave · 3x-ui · marzban · github) в форматах **base64 · список URI · Clash · Xray JSON**, в т.ч. **gzip-сжатые** ответы. При скачивании подписки отправляются заголовки:
|
||||
|
||||
| Заголовок | Значение |
|
||||
|---|---|
|
||||
| `User-Agent` | `singbox/<версия>` |
|
||||
| `User-Agent` | подбирается автоматически (`singbox/<версия>` или клиентский, см. формат) |
|
||||
| `X-HWID` | уникальный идентификатор роутера |
|
||||
| `X-Device-OS` | `OpenWrt Linux` |
|
||||
| `X-Device-Model` | модель роутера |
|
||||
@ -136,26 +156,113 @@ uci add_list netshift.my_sub.community_lists='russia_inside'
|
||||
uci commit netshift
|
||||
```
|
||||
|
||||
Ручное обновление подписки:
|
||||
**Несколько подписок** в одной секции - добавьте `subscription_url` списком (в UI - поле с «+»); все фиды скачиваются и объединяются в один набор узлов без дублей:
|
||||
|
||||
```sh
|
||||
/usr/bin/netshift subscription_update
|
||||
uci add_list netshift.my_sub.subscription_url='https://provider-a.com/sub'
|
||||
uci add_list netshift.my_sub.subscription_url='https://provider-b.com/sub'
|
||||
```
|
||||
|
||||
**Фильтр серверов** по ключевым словам - белый/чёрный список (регистр не важен, работает и по эмодзи):
|
||||
|
||||
```sh
|
||||
uci add_list netshift.my_sub.subscription_filter_include='🇩🇪'
|
||||
uci add_list netshift.my_sub.subscription_filter_exclude='trial'
|
||||
```
|
||||
|
||||
**Группировка серверов** - собирает узлы в URLTest-группы и добавляет авто-выбор «⚡ Самый быстрый» среди всех групп (при ≥2 группах он же выбор по умолчанию; ручной выбор группы сохраняется):
|
||||
|
||||
```sh
|
||||
# off | country (по флагу страны) | prefix (по первым N символам имени)
|
||||
uci set netshift.my_sub.subscription_group_mode='country'
|
||||
# для prefix: сколько первых символов имени брать (по умолчанию 2)
|
||||
uci set netshift.my_sub.subscription_group_prefix_len='2'
|
||||
```
|
||||
|
||||
**Предпочтительный формат** - для панелей, которые отдают нужные узлы (например xhttp / Hysteria2) только под определённым клиентом:
|
||||
|
||||
```sh
|
||||
# auto | xray (Xray JSON, UA как у Happ) | singbox
|
||||
uci set netshift.my_sub.subscription_format_preference='auto'
|
||||
```
|
||||
|
||||
**Подписки по IP-хосту и «кривой» HTTPS** - можно указать подписку с IP вместо домена (например `https://22.23.43.52:2096/sub/xxxx`); для панелей с самоподписанным / несовпадающим сертификатом включите небезопасный TLS:
|
||||
|
||||
```sh
|
||||
uci set netshift.my_sub.subscription_allow_insecure='1'
|
||||
```
|
||||
|
||||
Ручное обновление подписки и очистка кеша:
|
||||
|
||||
```sh
|
||||
/usr/bin/netshift subscription_update # перечитать и применить
|
||||
# Очистка кеша всех подписок и повторное скачивание - кнопка во вкладке «Диагностика»
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Ядро sing-box-extended (xhttp)</b></summary>
|
||||
<summary><b>Менеджер компонентов: ядро sing-box-extended (xhttp) и самообновление</b></summary>
|
||||
|
||||
Переключение ядра между стабильным sing-box и сборкой **sing-box-extended** прямо из вкладки **Diagnostics** в LuCI:
|
||||
Вкладка **Менеджер компонентов** в LuCI управляет NetShift и ядром sing-box в одном месте - три карточки: **NetShift** / **sing-box (stock)** / **sing-box (extended)**. Установленная версия видна сразу, статус (актуально / устарело / не установлено) и кнопка «Проверить обновление» - по нажатию.
|
||||
|
||||
- **Install extended** - установить расширенное ядро sing-box-extended.
|
||||
**Переключение ядра** между стабильным sing-box и сборкой **sing-box-extended**:
|
||||
|
||||
- **Install extended** - расширенное ядро (даёт клиентский транспорт **xhttp**, только клиентский режим). Также поддерживается **VMess**.
|
||||
- **Install stable** - вернуться на стабильное ядро.
|
||||
|
||||
После установки расширенного ядра становится доступен клиентский транспорт **xhttp** (только клиентский режим, не серверный). По умолчанию ставится стабильное ядро - extended включается по желанию.
|
||||
Смена ядра безопасна: перед переключением проверяется и при необходимости чинится связь, делается бэкап; при сбое - **автооткат**, роутер никогда не остаётся без рабочего ядра. По умолчанию стоит стабильное - extended включается по желанию.
|
||||
|
||||
**Самообновление NetShift** - кнопка обновления прямо из веб-интерфейса: асинхронно, с бэкапом конфига, проверкой фактической версии после установки и без риска «окирпичивания». Русская локализация обновляется только если уже установлена.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Дополнительные настройки (IPv6, блокировка DoH, глобальный прокси, DNS через прокси)</b></summary>
|
||||
|
||||
Все опции - в секции `settings` (`0` - выкл, `1` - вкл):
|
||||
|
||||
```sh
|
||||
# Полная маршрутизация IPv6 через туннель (v6 tproxy / DNS / FakeIP). По умолчанию выкл.
|
||||
uci set netshift.settings.enable_ipv6='1'
|
||||
|
||||
# Блокировка DoH: клиенты в сети не обойдут DNS роутера через DNS-over-HTTPS
|
||||
# (режет известные DoH-эндпоинты IPv4 + IPv6 на уровне маршрутов sing-box).
|
||||
uci set netshift.settings.block_doh='1'
|
||||
|
||||
# Глобальный прокси: ВЕСЬ трафик через выбранный outbound (а не только избранное).
|
||||
# Только при явном включении - иначе действует выборочная маршрутизация.
|
||||
uci set netshift.settings.global_proxy='1'
|
||||
|
||||
# DNS через прокси (detour): DNS-запросы идут через туннель.
|
||||
uci set netshift.settings.dns_via_outbound='1'
|
||||
|
||||
# Блокировать QUIC (заставляет приложения откатываться на TCP/TLS).
|
||||
uci set netshift.settings.disable_quic='1'
|
||||
|
||||
uci commit netshift
|
||||
```
|
||||
|
||||
> По умолчанию NetShift гонит в sing-box **только** проксируемые подсети/домены, остальное - напрямую (выборочная маркировка). Режим «весь трафик в туннель» включается **только** опцией `global_proxy`.
|
||||
|
||||
</details>
|
||||
|
||||
## История изменений
|
||||
|
||||
Полный список изменений по версиям - на странице [Releases](https://github.com/yandexru45/netshift/releases). Анонсы обновлений публикуются в [Telegram-канале](https://t.me/netshift_news).
|
||||
|
||||
Коротко о крупных вехах:
|
||||
|
||||
| Версия | Главное |
|
||||
|---|---|
|
||||
| **0.9.1** | Авто-выбор «⚡ Самый быстрый» среди групп (URLTest над URLTest'ами) |
|
||||
| **0.9.0** | Меньше ошибок «лимит GitHub API» (обход через redirect-путь github.com); фикс старого `option subscription_url` |
|
||||
| **0.8.9** | Универсальная группировка подписки (страна / префикс имени); поддержка gzip-подписок; фикс ложного «версия устарела» |
|
||||
| **0.8.7-0.8.8** | Критфикс маршрутизации 2-й секции; выборочная маркировка (меньше нагрузки CPU); Hysteria2 + xhttp везде; несколько подписок; надёжное самообновление |
|
||||
| **0.8.6** | IPv6 · блокировка DoH · вкладка «Менеджер компонентов» · самообновление · подписки по IP / небезопасный TLS · глобальный прокси · DNS через прокси · watchdog |
|
||||
| **0.8.5** | VMess (extended) · надёжная смена ядра с автооткатом · фильтр серверов по ключевым словам · Xray JSON + автоподбор User-Agent |
|
||||
| **0.8.0** | Переименование podkop → NetShift с авто-миграцией конфигов; sing-box-extended (xhttp) из веб-интерфейса |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@ -188,7 +295,7 @@ uci commit netshift
|
||||
|
||||
## Build Artifacts
|
||||
|
||||
Пакеты собираются в Docker-образе OpenWrt SDK (24.10) и публикуются как релиз при push git-тега ([`.github/workflows/build.yml`](.github/workflows/build.yml)).
|
||||
Пакеты собираются в Docker-образах OpenWrt SDK (`.ipk` - 24.10, `.apk` - 25.12) и публикуются как релиз при push git-тега ([`.github/workflows/build.yml`](.github/workflows/build.yml)).
|
||||
|
||||
| Пакет | Формат | Назначение |
|
||||
|---|---|---|
|
||||
@ -199,14 +306,14 @@ uci commit netshift
|
||||
Локальная сборка:
|
||||
|
||||
```sh
|
||||
# ipk (большинство устройств OpenWrt 24.10)
|
||||
docker build -f Dockerfile-ipk --build-arg NETSHIFT_VERSION=0.8.0 -t netshift:ipk .
|
||||
# ipk (OpenWrt 24.10, opkg)
|
||||
docker build -f Dockerfile-ipk --build-arg NETSHIFT_VERSION=0.9.1 -t netshift:ipk .
|
||||
|
||||
# apk (новые сборки OpenWrt на apk)
|
||||
docker build -f Dockerfile-apk --build-arg NETSHIFT_VERSION=0.8.0 -t netshift:apk .
|
||||
# apk (новые сборки OpenWrt 25.12+, apk)
|
||||
docker build -f Dockerfile-apk --build-arg NETSHIFT_VERSION=0.9.1 -t netshift:apk .
|
||||
```
|
||||
|
||||
> Требуется sing-box >= 1.12.0 и jq >= 1.7.1 на целевом устройстве.
|
||||
> Требуется sing-box >= 1.12.0, jq >= 1.7.1 и coreutils-base64 >= 9.7 на целевом устройстве.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
181
docs/README-AGENTS.md
Normal file
181
docs/README-AGENTS.md
Normal file
@ -0,0 +1,181 @@
|
||||
# NetShift — система AI-агентов (руководство оператора)
|
||||
|
||||
Это руководство для **человека**, который запускает AI-агентов на проекте
|
||||
NetShift. Описанная здесь система переносит профессиональные практики
|
||||
agent-разработки: специализированные агенты, шлюз код-ревью, накопление знаний в
|
||||
памяти и строгие правила архитектуры. Работает в двух инструментах:
|
||||
**OpenCode** и **Claude Code** — с единым источником правил.
|
||||
|
||||
> Сами агенты, правила и команды написаны на английском (так точнее работает
|
||||
> LLM). Это руководство — на русском.
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. Открываешь проект в OpenCode (или Claude Code).
|
||||
2. Даёшь задачу через команду `/task` (или просто текстом оркестратору).
|
||||
3. Оркестратор уточняет, проектирует, раскладывает задачу на подзадачи в
|
||||
`docs/tasks/*.md`, делегирует разработчикам, прогоняет шлюзы и код-ревью.
|
||||
4. Когда всё прошло ревью — **коммитишь сам, руками**. Агенты никогда не
|
||||
коммитят.
|
||||
|
||||
## Что где лежит
|
||||
|
||||
```
|
||||
AGENTS.md # корневой контекст для OpenCode (composition root)
|
||||
opencode.json # конфиг OpenCode: права + подключение правил
|
||||
.opencode/
|
||||
agent/ # 5 агентов (OpenCode-формат)
|
||||
command/ # /task /review /describe
|
||||
skill/ # shellcheck / smoke-tests / frontend-ci
|
||||
.claude/
|
||||
CLAUDE.md # корневой контекст для Claude Code
|
||||
settings.json # права (allow/ask)
|
||||
agents/ # те же 5 агентов (Claude-формат)
|
||||
commands/ # /task /review /describe
|
||||
skills/ # те же 3 скилла
|
||||
docs/
|
||||
agent-rules/ # ЕДИНЫЙ ИСТОЧНИК правил (оба инструмента ссылаются сюда)
|
||||
project-core.md # архитектура, runtime-контракт, шлюзы, gating
|
||||
backend-shell.md # правила backend (ash + jq)
|
||||
frontend-luci.md # правила frontend (TS + LuCI)
|
||||
packaging.md # правила packaging / CI / release
|
||||
memory/ # ПАМЯТЬ агентов (committed, общая для обоих инструментов)
|
||||
architect-orchestrator.md
|
||||
shell-backend-developer.md
|
||||
luci-frontend-developer.md
|
||||
packaging-ci-engineer.md
|
||||
code-reviewer.md
|
||||
tasks/ # спеки задач и ревью-доки
|
||||
TEMPLATE-task.md # шаблон спеки
|
||||
TEMPLATE-review.md # шаблон ревью
|
||||
README-AGENTS.md # этот файл
|
||||
```
|
||||
|
||||
## Команда агентов
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ architect-orchestrator │ (opus) — дирижёр
|
||||
│ уточняет · проектирует · │
|
||||
│ раскладывает · ревьюит │
|
||||
└────┬───────┬───────┬──────┘
|
||||
┌───────────────┘ │ └───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
|
||||
│ shell-backend- │ │ luci-frontend- │ │ packaging-ci- │
|
||||
│ developer (sonnet) │ │ developer (sonnet) │ │ engineer (sonnet) │
|
||||
│ ash/jq, sing-box, │ │ TS, LuCI, валид., │ │ Makefile, Docker, │
|
||||
│ nft, dnsmasq, UCI │ │ i18n, main.js │ │ SDK, CI, install │
|
||||
└─────────┬──────────┘ └─────────┬──────────┘ └─────────┬──────────┘
|
||||
└──────────────────────┼───────────────────────┘
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ code-reviewer │ (haiku) — только чтение
|
||||
│ вердикт: APPROVED /│
|
||||
│ CONDITIONS / CHANGES│
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
| Агент | Что делает | Модель |
|
||||
| --- | --- | --- |
|
||||
| `architect-orchestrator` | Уточняет → проектирует → раскладывает в `docs/tasks/*.md` → делегирует → гоняет цикл разработчик↔ревьюер | opus |
|
||||
| `shell-backend-developer` | Backend: ash/jq, генерация конфига sing-box, nft, dnsmasq, UCI. Прогоняет shellcheck + smoke | sonnet |
|
||||
| `luci-frontend-developer` | Frontend: TS-исходник, LuCI-вьюхи, валидаторы, i18n. Прогоняет `yarn ci`, пересобирает `main.js` | sonnet |
|
||||
| `packaging-ci-engineer` | Makefile, Docker ipk/apk, SDK, workflows, тест-харнесс, install.sh | sonnet |
|
||||
| `code-reviewer` | Read-only ревью диффа против правил, пишет вердикт | haiku |
|
||||
|
||||
## Как запускать
|
||||
|
||||
### Вариант A — команда `/task` (рекомендуется)
|
||||
В OpenCode или Claude Code введи:
|
||||
```
|
||||
/task добавить опцию X в секцию UCI и пробросить её в конфиг sing-box
|
||||
```
|
||||
Оркестратор пройдёт весь цикл: уточнит → спроектирует → разложит → делегирует →
|
||||
прогонит шлюзы → ревью → отдаст тебе на коммит.
|
||||
|
||||
### Вариант B — спека файлом
|
||||
Создай `docs/tasks/task-010-моя-задача.md` (по шаблону `TEMPLATE-task.md`),
|
||||
затем:
|
||||
```
|
||||
/task обработай docs/tasks/task-010-моя-задача.md
|
||||
```
|
||||
|
||||
### Вариант C — обработать ревью
|
||||
```
|
||||
/review docs/tasks/task-010-моя-задача-review-001.md
|
||||
```
|
||||
или передай URL Pull Request.
|
||||
|
||||
### Вариант D — описать PR
|
||||
```
|
||||
/describe
|
||||
```
|
||||
|
||||
## Жизненный цикл задачи (7 шагов)
|
||||
|
||||
1. **Уточнение.** Оркестратор задаёт вопросы по неоднозначным решениям
|
||||
(порты/marks/пути/схема конфига/упаковка). Не додумывает.
|
||||
2. **Проектирование.** Предлагает 1–3 варианта с trade-offs, ждёт твоего «ОК».
|
||||
3. **Декомпозиция.** Пишет спеки в `docs/tasks/task-NNN-*.md`.
|
||||
4. **Реализация.** Делегирует нужному разработчику (параллельно — если подзадачи
|
||||
не пересекаются по файлам).
|
||||
5. **Шлюзы.** Разработчик прогоняет соответствующий gate:
|
||||
- backend → скилл `shellcheck` + скилл `smoke-tests`;
|
||||
- frontend → скилл `frontend-ci` (`yarn ci`) + пересборка `main.js` без
|
||||
git-диффа;
|
||||
- packaging → smoke-tests, проверка ipk и apk.
|
||||
6. **Ревью.** `code-reviewer` пишет ревью-док с вердиктом. При `REQUIRES CHANGES`
|
||||
разработчик переделывает до прохождения.
|
||||
7. **Готово.** Ты коммитишь вручную. PR — только после согласования в Telegram с
|
||||
авторами (`CODEOWNERS=@yandexru45`).
|
||||
|
||||
## Память агентов
|
||||
|
||||
Файлы `docs/agent-rules/memory/<agent>.md` — это **долгая память** агентов:
|
||||
грабли, неочевидные правила, уже принятые решения, повторяющиеся находки ревью.
|
||||
Каждый агент читает свою память перед работой и дописывает туда новое. Память
|
||||
**коммитится в git** — поэтому она общая для всей команды и для обоих
|
||||
инструментов (OpenCode и Claude Code ссылаются на одни и те же файлы, дублей
|
||||
нет). Держи каждый файл памяти короче ~200 строк.
|
||||
|
||||
## Ключевые правила (действуют для всех агентов)
|
||||
|
||||
- **Тесты/шлюзы обязательны.** Изменение не «готово», пока не прошёл нужный gate.
|
||||
- **Агенты не коммитят.** Коммит и push делает только человек (права настроены на
|
||||
подтверждение `git commit`/`git push`).
|
||||
- **Без апрува архитектора нет реализации.** Каждое изменение проходит ревью.
|
||||
- **Слои не смешиваются.** UI → backend (через два разрешённых бинарника) →
|
||||
sing-box/nft/dnsmasq.
|
||||
- **Священный runtime-контракт** (порты/marks/пути) не меняется без проверки всей
|
||||
цепочки. Всё — в `constants.sh`, без хардкода.
|
||||
- **Сгенерированный `main.js` руками не править.** Только правка TS-исходника +
|
||||
`yarn build`.
|
||||
- **jq на OpenWRT — без regex** (нет Oniguruma).
|
||||
|
||||
## Требования инструментов
|
||||
|
||||
- **OpenCode:** конфиг подхватывается из `opencode.json` и `AGENTS.md`
|
||||
автоматически. После изменения конфигурации перезапусти OpenCode (конфиг
|
||||
читается один раз при старте).
|
||||
- **Claude Code:** для оркестрации субагентами включи экспериментальный режим
|
||||
agent teams в глобальном `~/.claude/settings.json`:
|
||||
```json
|
||||
{ "env": { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" } }
|
||||
```
|
||||
Без этого флага запуск нескольких агентов работать не будет.
|
||||
- **Шлюзы локально:** для `smoke-tests` нужен Docker; для `frontend-ci` — Node 22
|
||||
+ yarn в `fe-app-netshift`; для `shellcheck` — локальный `shellcheck` или
|
||||
Docker-образ `koalaman/shellcheck`.
|
||||
|
||||
## Траблшутинг
|
||||
|
||||
- **Агенты не запускаются (Claude Code):** проверь флаг
|
||||
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`.
|
||||
- **OpenCode не стартует после правки конфига:** значит, `opencode.json`
|
||||
невалиден. Запусти из папки проекта с `OPENCODE_DISABLE_PROJECT_CONFIG=1`,
|
||||
поправь файл, перезапусти без флага.
|
||||
- **Пустое/слабое ревью:** убедись, что есть незакоммиченный дифф (ревьюер
|
||||
смотрит `git diff`).
|
||||
- **Память распухла:** подрежь файл `docs/agent-rules/memory/<agent>.md` до
|
||||
~200 строк, оставив только durable-знания.
|
||||
125
docs/agent-rules/backend-shell.md
Normal file
125
docs/agent-rules/backend-shell.md
Normal file
@ -0,0 +1,125 @@
|
||||
# NetShift Backend — Shell Rules (AUTHORITATIVE)
|
||||
|
||||
> Scope: the backend package `netshift/files/usr/**` (POSIX `ash` + `jq`). Read alongside `project-core.md`. Every rule is grounded in the actual source — do not invent.
|
||||
|
||||
## 1. Stack
|
||||
|
||||
- **POSIX `ash`** (busybox), NOT bash. CLI dispatcher: `netshift/files/usr/bin/netshift`. Libraries: `netshift/files/usr/lib/*.sh`.
|
||||
- **`jq`** generates and mutates the sing-box JSON config.
|
||||
- **sing-box** is the routing engine; the backend only generates/validates its config and (re)starts the service.
|
||||
- **nftables** tproxy provides the marking/redirect path (table `NetShiftTable`, family `inet`).
|
||||
- **dnsmasq** integration points the router's DNS at sing-box (`server 127.0.0.42`).
|
||||
- **UCI** holds configuration (`/etc/config/netshift`); **procd** init in `/etc/init.d/netshift`.
|
||||
|
||||
`/usr/bin/netshift` sources, in order: `/lib/functions.sh`, `/lib/config/uci.sh`, `/lib/functions/network.sh`, then `constants.sh`, `nft.sh`, `helpers.sh`, `sing_box_config_manager.sh`, `sing_box_config_facade.sh`, `logging.sh`, `rulesets.sh`, `updater.sh`. The CLI dispatcher (`case "$1" in ...`) is at the bottom of the file; entry points are `start`/`stop`/`reload`/`restart` (procd) and the diagnostics/`get_*`/`show_*`/`*_update`/`clash_api`/`component_action` commands.
|
||||
|
||||
## 2. File headers and variable scope
|
||||
|
||||
- Every lib `.sh` file starts with `# shellcheck shell=ash`.
|
||||
- Constants files that intentionally hold unused-looking vars also add `# shellcheck disable=SC2034` (see `constants.sh` lines 1–2).
|
||||
- Declare **all** function-local variables with `local`. ShellCheck (severity error) gates this.
|
||||
|
||||
## 3. Strict function-naming prefixes
|
||||
|
||||
Use the right prefix; it signals the function's layer and contract.
|
||||
|
||||
| Prefix | Meaning | Examples |
|
||||
|---|---|---|
|
||||
| `sing_box_cm_*` | **Config-manager primitives** — low-level jq mutations, ONE mutation each, take `$config` first, echo new JSON | `sing_box_cm_configure_log`, `sing_box_cm_add_udp_dns_server`, `sing_box_cm_add_route_rule` (`sing_box_config_manager.sh`) |
|
||||
| `sing_box_cf_*` | **Facade orchestration** — parse a URL and call several `cm_*` | `sing_box_cf_add_proxy_outbound`, `sing_box_cf_add_dns_server` (`sing_box_config_facade.sh`) |
|
||||
| `url_*` | URL parsing — pure, param-expansion / `sed` only | `url_get_host`, `url_get_port`, `url_get_scheme`, `url_decode` (`helpers.sh`) |
|
||||
| `is_*` | Predicates returning 0/1 | `is_ipv4`, `is_domain`, `is_min_package_version`, `is_sing_box_extended` |
|
||||
| `nft_*` | nft wrappers | `nft_create_table`, `nft_create_ipv4_set`, `nft_add_set_elements_from_file_chunked` (`nft.sh`) |
|
||||
| `updates_*` / updater | binary updater | `updater.sh` |
|
||||
| `get_*_tag` | Deterministic tag builders | `get_outbound_tag_by_section` (`<section>-out`), `get_inbound_tag_by_section`, `get_domain_resolver_tag`, `get_ruleset_tag` |
|
||||
| `configure_*` / `import_*` / `_*_handler` | `config_foreach` / `config_list_foreach` callbacks | `configure_outbound_handler`, `import_community_subnet_lists`, `include_source_ip_in_routing_handler` |
|
||||
| leading `_` | private helper (internal to a flow) | `_check_outbound_section`, `_update_subscription_for_section` |
|
||||
|
||||
## 4. The `$config` threading model
|
||||
|
||||
The sing-box config is carried as a shell string variable named `config`. `cm_*`/`cf_*` functions take it as `$1`, echo the mutated JSON, and the caller reassigns:
|
||||
|
||||
```sh
|
||||
config=$(sing_box_cm_add_direct_outbound "$config" "$SB_DIRECT_OUTBOUND_TAG")
|
||||
config=$(sing_box_cf_add_proxy_outbound "$config" "$section" "$proxy_string" "$udp_over_tcp")
|
||||
```
|
||||
|
||||
`sing_box_init_config` seeds the skeleton, then runs `sing_box_configure_log/inbounds/outbounds/dns/route/experimental/additional_inbounds` and finally `sing_box_save_config`. Keep this echo-and-reassign discipline; never mutate config via global side effects.
|
||||
|
||||
## 5. jq idioms and the Oniguruma constraint
|
||||
|
||||
- Pass data with `--arg` (string) / `--argjson` (JSON), never string interpolation into the program:
|
||||
```sh
|
||||
echo "$config" | jq --arg tag "$tag" --argjson port "$port" '...'
|
||||
```
|
||||
- Optional keys via the merge pattern:
|
||||
```jq
|
||||
{ ... } + (if $detour != "" then { detour: $detour } else {} end)
|
||||
```
|
||||
- **CRITICAL: OpenWRT's `jq` is built WITHOUT Oniguruma.** Never use `test()`, `match()`, `sub()`, `gsub()`, or any regex-based jq function — they will fail on-device. Use explicit string/codepoint logic instead (e.g. `explode`/`implode`, `index`, label/break loops — see the country-flag grouping in `sing_box_build_subscription_country_groups` and the tag-dedup in `normalize_subscription_to_singbox`). The updater documents the workarounds.
|
||||
- Custom jq helpers live in `netshift/files/usr/lib/helpers.jq`, imported as:
|
||||
```jq
|
||||
import "helpers" as h {"search": "/usr/lib/netshift"};
|
||||
```
|
||||
|
||||
## 6. Validation and atomic writes (mandatory)
|
||||
|
||||
- **Every config write is validated.** `sing_box_save_config` writes to a temp file, then `sing_box_config_check` runs `sing-box -c <file> check`; on failure it logs `fatal` and `exit 1`. There is no exception to this.
|
||||
- JSON shape is checked with `jq -e` (e.g. `validate_subscription_file`, `subscription_cache_is_usable`).
|
||||
- **Atomic writes**: write `*.tmp.$$` then `mv` into place (subscription cache, URL metadata, rejected-hash). See `download_subscription_into_cache`.
|
||||
- **Hash-compare before replacing**: `md5sum` the temp vs current and only `mv` when they differ (`sing_box_save_config`, subscription dedup, rejected-hash tracking).
|
||||
|
||||
## 7. Logging (`logging.sh`)
|
||||
|
||||
| Function | Behavior |
|
||||
|---|---|
|
||||
| `log "$msg" "$level"` | syslog via `logger -t netshift` (level defaults to `info`) |
|
||||
| `nolog "$msg"` | TTY-only stdout (colorized; nothing when not a TTY) |
|
||||
| `echolog "$msg" "$level"` | both: `log` + `nolog` |
|
||||
|
||||
Levels: `debug` / `info` / `warn` / `error` / `fatal`.
|
||||
|
||||
**CRITICAL:** `fatal` is only a LABEL — `log` does NOT exit. You must manually `exit 1` after logging fatal:
|
||||
|
||||
```sh
|
||||
log "Subscription URL is not set. Aborted." "fatal"
|
||||
exit 1
|
||||
```
|
||||
|
||||
This pattern (`... Aborted." "fatal"; exit 1`) appears throughout the CLI; preserve it.
|
||||
|
||||
## 8. busybox quirks
|
||||
|
||||
- busybox `sed` lacks `\x` hex escapes. Build literal bytes with `printf` octal escapes (e.g. the UTF-8 BOM `printf '\357\273\277'` in `normalize_subscription_to_singbox`).
|
||||
- Convert CRLF→LF with `convert_crlf_to_lf` before parsing downloaded lists.
|
||||
- Strip a leading UTF-8 BOM before base64 charset detection.
|
||||
- The diagnostic strings in `usr/bin/netshift` (emoji / box-drawing in `list_update`, `subscription_update`, `global_check`, `check_nft`, e.g. `📡 🛠️ ✅ ❌ ⚠️ ➡️ 🧱 🥸 📄` and `━` separators) are **valid UTF-8** and must stay valid UTF-8 — they render correctly on the device (SSH/UTF-8 terminal) and LuCI. They are **not** intentional mojibake.
|
||||
- These were once corrupted by a UTF-8→CP1251 double-encode (real UTF-8 bytes read as CP1251 and re-saved as UTF-8), which made them print as `рџ…`/`в”…` garbage; task-004 repaired them. **Never open/save `usr/bin/netshift` in a non-UTF-8 editor or run it through a CP1251 codepage** — doing so reintroduces the `рџ…`/`в”…`/` ` mojibake. Edit it as UTF-8 only.
|
||||
|
||||
## 9. New constants
|
||||
|
||||
Anything that looks like a port, IP, mark, tag, path, version, URL, or service list goes into `constants.sh` under the right group (`## Common`, `## nft`, `## sing-box`, `## Lists`). Never hardcode it inline. See `project-core.md` §5.
|
||||
|
||||
## 10. UCI access patterns
|
||||
|
||||
- `config_get var section option [default]` — read an option.
|
||||
- `config_get_bool var section option [default]` — read a boolean (0/1).
|
||||
- `config_foreach fn type` — call `fn` for each section of `type` (here usually `section`); `fn` receives the section name as `$1`.
|
||||
- `config_list_foreach section list fn [extra args...]` — call `fn` for each list item.
|
||||
- The CLI runs `config_load "$NETSHIFT_CONFIG"` at startup; after `uci commit` it reloads (`uci commit ...; config_load ...`).
|
||||
|
||||
UCI schema lives in `netshift/files/etc/config/netshift` (`settings` section + per-connection sections with `connection_type` = `proxy`/`vpn`/`block`/`exclusion`, and `proxy_config_type` = `url`/`selector`/`urltest`/`outbound`/`subscription`). Changing it is a system-level change (`project-core.md` §4).
|
||||
|
||||
## 11. Tests and gates for backend changes
|
||||
|
||||
- Run the **`shellcheck`** skill and the **`smoke-tests`** skill before considering a backend change done.
|
||||
- Smoke tests live in `tests/entrypoint.sh`. Existing test functions: `test_deps`, `test_syntax`, `test_config`, `test_helpers`, `test_jq_helpers`, `test_config_manager`, `test_sing_box_config`, `test_nft`, `test_diagnostics`, `test_subscription`.
|
||||
- **Adding a backend test** means all three of:
|
||||
1. Add a `test_*` function to `tests/entrypoint.sh`.
|
||||
2. Register it in `main()` — add the call to the `all)` branch.
|
||||
3. Add a `case` entry (its short alias) AND list the alias in the "Available:" usage line.
|
||||
- Backend changes affecting **config generation** or **subscription parsing** SHOULD add/extend a smoke test (`test_sing_box_config`, `test_config_manager`, `test_jq_helpers`, or `test_subscription`).
|
||||
|
||||
## 12. Glob scope confirmation
|
||||
|
||||
These rules apply to everything matched by `netshift/files/usr/**` (the CLI, all `*.sh` libraries, and `helpers.jq`).
|
||||
154
docs/agent-rules/frontend-luci.md
Normal file
154
docs/agent-rules/frontend-luci.md
Normal file
@ -0,0 +1,154 @@
|
||||
# Agent Rules: Frontend & LuCI
|
||||
|
||||
Authoritative rules for the NetShift web UI. Read this before touching any
|
||||
frontend or LuCI view code.
|
||||
|
||||
**Scope (globs):**
|
||||
|
||||
- `fe-app-netshift/src/**/*.ts` — TypeScript source (the real logic).
|
||||
- `luci-app-netshift/htdocs/**/*.js` — hand-written LuCI views.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture & build pipeline
|
||||
|
||||
- The logic lives in **TypeScript** under `fe-app-netshift/src/`, compiled in
|
||||
`strict` mode (`tsconfig.json`: `strict: true`, `target: ES2020`,
|
||||
`module: ESNext`).
|
||||
- `tsup` bundles the single entry `src/main.ts` into
|
||||
`luci-app-netshift/htdocs/luci-static/resources/view/netshift/main.js`
|
||||
(see `tsup.config.ts`: `format: ['esm']`, `outExtension .js`, `clean: false`).
|
||||
- The hand-written LuCI views consume the bundle. The entry view
|
||||
`netshift.js` declares `'require view.netshift.main as main'` and uses the
|
||||
exports as `main.*` (e.g. `main.injectGlobalStyles()`, `main.coreService()`).
|
||||
Companion views `section.js`, `settings.js`, plus the thin `dashboard.js` /
|
||||
`diagnostic.js` follow the same pattern.
|
||||
|
||||
### CRITICAL: `main.js` is AUTOGENERATED — never hand-edit it
|
||||
|
||||
- The bundle is stamped with the banner
|
||||
`// This file is autogenerated, please don't change manually` (set in
|
||||
`tsup.config.ts` `banner.js`).
|
||||
- After bundling, `tsup`'s `onSuccess` hook **regex-patches** the file: it
|
||||
rewrites the ESM `export { ... }` block into
|
||||
`return baseclass.extend({ ... })` (see `tsup.config.ts` lines 25-30). This
|
||||
is what makes the bundle loadable as a LuCI `baseclass`.
|
||||
- **NEVER hand-edit `main.js`.** Edit the TS source, then run `yarn build`.
|
||||
Any manual edit is destroyed on the next build and will fail CI (build must
|
||||
produce no git diff — see §7).
|
||||
|
||||
---
|
||||
|
||||
## 2. The barrel rule (most common gotcha)
|
||||
|
||||
Anything that must be visible to the LuCI views as `main.*` has to be
|
||||
re-exported all the way up the barrel chain to `src/main.ts`.
|
||||
|
||||
- `src/main.ts` does `export * from './validators' | './helpers' |
|
||||
'./netshift' | './constants'`.
|
||||
- `src/validators/index.ts` re-exports each validator module
|
||||
(`export * from './validateIp'`, etc.).
|
||||
- **Rule:** any new public API (a validator, helper, constant, or tab) MUST be
|
||||
re-exported up the chain (e.g. `validators/<file>.ts` →
|
||||
`validators/index.ts` → `main.ts`). If you forget the re-export, the symbol
|
||||
will not appear in `main.*` and the LuCI views cannot see it.
|
||||
|
||||
**Worked example of the gotcha:** `validateHysteria2Url`
|
||||
(`src/validators/validateHysteriaUrl.ts`) is **intentionally NOT** listed in
|
||||
`src/validators/index.ts`. It is reached only indirectly via
|
||||
`validateProxyUrl` (`validateProxyUrl.ts` imports it and dispatches to it for
|
||||
`hysteria2://` URLs). So `main.validateHysteria2Url` does not exist — that is
|
||||
deliberate, not a bug. Do not "fix" it by adding it to the barrel unless you
|
||||
actually need it exposed.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend access boundary
|
||||
|
||||
The UI talks to the backend through **only two channels**:
|
||||
|
||||
1. LuCI `fs.exec` of the two ACL-gated binaries:
|
||||
- `/usr/bin/netshift`
|
||||
- `/etc/init.d/netshift`
|
||||
|
||||
Both are allow-listed for `exec` in
|
||||
`luci-app-netshift/root/usr/share/rpcd/acl.d/luci-app-netshift.json`
|
||||
(under `read.file`). The same ACL grants `uci` read/write on the
|
||||
`netshift` config and `ubus service list`.
|
||||
|
||||
2. Direct `fetch` / WebSocket to the Clash API on `:9090`.
|
||||
|
||||
**Rule:** any new shell command the UI needs MUST be implemented as a
|
||||
**subcommand of one of those two binaries**, or you must extend both the ACL
|
||||
(`acl.d/luci-app-netshift.json`) **and** the backend. Do not invoke arbitrary
|
||||
paths via `fs.exec` — they are not ACL-allowed and will be denied by `rpcd`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Code style (from the config files — non-negotiable)
|
||||
|
||||
- **TypeScript:** `strict: true`. No `any`. Prefer functional code and named
|
||||
exports (the barrel relies on named exports).
|
||||
- **Prettier** (`.prettierrc`): `printWidth: 80`, `tabWidth: 2`, `semi: true`,
|
||||
`singleQuote: true`, `trailingComma: 'all'`, `bracketSpacing: true`.
|
||||
- **ESLint** (flat config `eslint.config.js`): extends
|
||||
`js.configs.recommended` + `typescript-eslint` recommended + `prettier`.
|
||||
`@typescript-eslint/no-unused-vars` is a **`warn`**, with
|
||||
`argsIgnorePattern`, `varsIgnorePattern`, and `caughtErrorsIgnorePattern`
|
||||
all set to `^_`. So any intentionally-unused var/arg/caught-error MUST be
|
||||
`_`-prefixed. CI runs `eslint --max-warnings=0`, so an un-prefixed unused
|
||||
var = warning = CI failure.
|
||||
- **LuCI globals** (`E`, `fs`, `uci`, `ui`, `_`, etc.) are declared in
|
||||
`src/luci.d.ts`. Use them; do not redeclare. For DOM built with `E()`, use
|
||||
the `click:` attribute convention for event handlers.
|
||||
|
||||
---
|
||||
|
||||
## 5. i18n
|
||||
|
||||
- Wrap every user-facing string in `_()`.
|
||||
- Pass **only string literals** to `_()`. The gettext extractor only sees
|
||||
literal arguments; `_(someVariable)` or `_('a' + b)` will NOT be extracted
|
||||
and will ship untranslated.
|
||||
- Locale tooling lives in `package.json` under the `locales:*` scripts
|
||||
(`locales:extract-calls`, `locales:generate-pot`, `locales:generate-po:ru`,
|
||||
`locales:distribute`, with the `locales:actualize` umbrella). Run these to
|
||||
regenerate `.pot`/`.po` after adding strings; do not hand-edit generated
|
||||
catalogs.
|
||||
|
||||
---
|
||||
|
||||
## 6. Version placeholder
|
||||
|
||||
- `src/constants.ts` declares
|
||||
`export const NETSHIFT_LUCI_APP_VERSION = '__COMPILED_VERSION_VARIABLE__';`.
|
||||
- At OpenWRT build time, `luci-app-netshift/Makefile` substitutes the literal
|
||||
via `sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PKG_VERSION)/g' ...
|
||||
main.js || true`.
|
||||
- In dev (where no substitution happens), `normalizeCompiledVersion` turns the
|
||||
raw placeholder into `'dev'`.
|
||||
- **Rule:** do not change the literal `__COMPILED_VERSION_VARIABLE__` without
|
||||
also updating the `sed` in the Makefile (and the backend stamp — see
|
||||
`packaging.md`). They must stay in lockstep.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests & CI gates
|
||||
|
||||
- Vitest config (`vitest.config.js`): `globals: true`, `environment: 'node'`,
|
||||
setup file `./tests/setup/global-mocks.ts` (which identity-mocks `_()` so
|
||||
tests assert on raw strings).
|
||||
- Tests live as `.test.js` next to the code under `tests/` directories. Style
|
||||
is table-driven `describe.each`.
|
||||
- **New pure logic SHOULD ship a test.**
|
||||
- **CI gate** (`.github/workflows/frontend-ci.yml`, runs on PRs touching
|
||||
`fe-app-netshift/**`) runs the steps individually:
|
||||
`yarn install --frozen-lockfile` → `yarn format` then fail on any
|
||||
`git diff` (code must already be formatted) → `yarn lint --max-warnings=0`
|
||||
→ `yarn test --run` → `yarn build` then fail on any `git diff`.
|
||||
The convenience local command is `yarn ci`
|
||||
(`format && lint --max-warnings=0 && test --run && build`).
|
||||
- **The committed `main.js` MUST be up to date.** Because the build must
|
||||
produce no git diff, always `yarn build` and commit the regenerated bundle
|
||||
together with the TS change.
|
||||
- Reference the **`frontend-ci`** skill for the full workflow.
|
||||
31
docs/agent-rules/memory/README.md
Normal file
31
docs/agent-rules/memory/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Agent memory
|
||||
|
||||
This folder is the **single source of truth** for per-agent persistent memory,
|
||||
shared by both AI toolchains (OpenCode and Claude Code).
|
||||
|
||||
OpenCode has no built-in `memory: project` mechanism, so memory here is a plain
|
||||
convention: **every agent's prompt instructs it to read its own
|
||||
`<agent>.md` file before starting work, and to append durable findings to it
|
||||
when it learns something that future runs must not re-discover.**
|
||||
|
||||
## Rules for memory files
|
||||
|
||||
- One file per agent, named exactly after the agent
|
||||
(`architect-orchestrator.md`, `shell-backend-developer.md`,
|
||||
`luci-frontend-developer.md`, `packaging-ci-engineer.md`,
|
||||
`code-reviewer.md`).
|
||||
- Keep each file **under ~200 lines**. It is loaded into the agent's context
|
||||
on every run; bloat costs tokens and dilutes signal.
|
||||
- Record only **durable, reusable knowledge**: gotchas, fragile areas,
|
||||
non-obvious conventions, decisions already made, recurring review findings.
|
||||
Do **not** record task-specific narration.
|
||||
- These files are **committed to git** so the whole team (and other
|
||||
contributors using AI) benefit.
|
||||
- When a fact here is proven wrong or stale, fix it in the same edit — do not
|
||||
let memory drift from reality.
|
||||
|
||||
## How the two toolchains share this
|
||||
|
||||
Both `.opencode/agent/*.md` and `.claude/agents/*.md` point their agents at
|
||||
these files by relative path (`docs/agent-rules/memory/<agent>.md`). There is
|
||||
no duplication of memory content — only this one copy.
|
||||
1451
docs/agent-rules/memory/architect-orchestrator.md
Normal file
1451
docs/agent-rules/memory/architect-orchestrator.md
Normal file
File diff suppressed because it is too large
Load Diff
75
docs/agent-rules/memory/code-reviewer.md
Normal file
75
docs/agent-rules/memory/code-reviewer.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Memory — code-reviewer
|
||||
|
||||
Reusable review findings and check focus for NetShift. Read before reviewing;
|
||||
append recurring findings; keep under ~200 lines.
|
||||
|
||||
## What to check (in priority order)
|
||||
|
||||
1. **Architecture / layer direction**: UI -> backend (via fs.exec of the two
|
||||
allowed binaries) -> sing-box/nft/dnsmasq. No layer skips another. UI must
|
||||
not reimplement backend logic; backend must not hardcode what belongs in
|
||||
`constants.sh`.
|
||||
2. **Runtime contract intact**: ports/marks/paths (1602, 127.0.0.42:53, :9090,
|
||||
198.18.0.0/15, marks 0x100000/0x200000, `NetShiftTable`, `105 netshift`)
|
||||
unchanged unless the task explicitly says so and the WHOLE chain is updated.
|
||||
3. **Backend shell correctness**: `# shellcheck shell=ash`; all `local`; correct
|
||||
function prefix; `$config` echo-and-reassign threading; **no jq regex**
|
||||
(test/match/sub/gsub) — flag any as CRITICAL; `fatal` log followed by
|
||||
`exit 1`; atomic write + `sing-box check`; new constants in `constants.sh`.
|
||||
4. **Frontend correctness**: did they edit TS source (not `main.js` by hand)?
|
||||
Did they rebuild so `main.js` matches (no stray diff)? New public API
|
||||
re-exported up the barrel to `main.*`? Unused vars `_`-prefixed? `_()` around
|
||||
new user-facing literals? No `any`?
|
||||
5. **Test coverage / gates**: backend config-gen or subscription changes should
|
||||
add/extend a smoke test; new pure frontend logic should ship a vitest
|
||||
`.test.js`. Confirm the relevant gate (shellcheck / smoke-tests / yarn ci)
|
||||
was run.
|
||||
6. **Packaging**: respect the intentional ipk `v`-prefix vs apk-raw
|
||||
inconsistency; don't break the underscore->dash rename; version placeholder
|
||||
stamping intact.
|
||||
|
||||
## Output
|
||||
|
||||
- Write the review to `docs/tasks/<task-name>-review-001.md` using
|
||||
`docs/tasks/TEMPLATE-review.md`.
|
||||
- Verdict vocabulary: `APPROVED` / `APPROVED WITH CONDITIONS` /
|
||||
`REQUIRES CHANGES`. ID-tag issues (C1 critical, S1 significant, M1 minor) and
|
||||
cite exact `file:line`.
|
||||
- No flattery. No speculation — report only what you can verify. Every problem
|
||||
gets a concrete recommendation.
|
||||
|
||||
## Recurring findings to watch for
|
||||
|
||||
- jq regex functions sneaking in (CRITICAL on OpenWRT jq).
|
||||
- `fatal` log without a following `exit 1`.
|
||||
- Hand-edited `main.js` or a `main.js` that doesn't match a fresh build.
|
||||
- New validator/helper not re-exported -> invisible to `main.*`.
|
||||
- Hardcoded ports/IPs/paths instead of `constants.sh` references.
|
||||
- Routing code that ignores `subscription_outbound_is_unavailable` (traffic
|
||||
leak when a subscription is down).
|
||||
- Scope creep: unrelated file churn (e.g. lockfile churn) flagged as Minor.
|
||||
- Diagnostic strings in `usr/bin/netshift` are valid UTF-8 emoji/box-drawing —
|
||||
must stay UTF-8, never CP1251 (task-004 fixed a double-encode). For
|
||||
mojibake-repair reviews, prove ASCII-byte preservation byte-safely (Python:
|
||||
decode HEAD blob vs working tree, strip `[^\x00-\x7F]` per line, expect 0
|
||||
mismatched lines); beware PowerShell text pipelines which produce false UTF-16
|
||||
diffs.
|
||||
|
||||
- base64 share-link decode vs `sing_box_cf_add_proxy_outbound` `url_decode` (facade:65): the facade runs `url_decode` (+>space, %XX>byte) on the whole URL before the scheme case. Any case that base64-decodes the ENTIRE payload (vmess, future tuic/etc.) must use the RAW pre-url_decode link <20> standard base64 contains '+'. The ss) case escapes this only because it decodes a short method:password userinfo. Beware synthetic test keys that avoid '+' masking this (false green).
|
||||
|
||||
- For protocol validators that base64-decode a whole body (vmess, future tuic/etc.): the '+'-regression is real only if the dispatcher preserves '+'. validateProxyUrl only .trim()s, so '+' survives at the boundary <20> a green direct-call '+' test is sufficient evidence; a dispatcher-level '+' assertion is the stronger guard.
|
||||
|
||||
- Wrapper/core split for always-run cleanup (task-009 core-switch): verify the public wrapper captures core stdout to a temp file + rc, then UNCONDITIONALLY calls restore/cleanup before re-emitting JSON and return rc; confirm the worker runs without set -e (else a non-zero core rc could skip trailing cleanup) and that _*_core never exits. For never-end-core-less rollbacks, confirm the tmpfs backup happens BEFORE the package manager/extract touches the binary and is dropped ONLY on the confirmed-good path; strongest test deletes the live mock binary on simulated failure and asserts original bytes restored.
|
||||
|
||||
- Frontend barrel exposure: anything added to src/helpers/index.ts (or any export* barrel reaching main.ts) AND actually used appears in the generated main.js baseclass.extend block as a main.* symbol; unused re-exports get tree-shaken. So internal-only helper + added to barrel + used = it WILL leak to main.*. To keep a helper truly internal, place it in the consuming module, not the barrel.
|
||||
|
||||
- OpenWrt jq ascii_downcase only folds ASCII A-Z; case-insensitive matching on Cyrillic/Unicode names needs an inline codepoint fold (explode/map/implode: ASCII 65-90 +32, Cyrillic 1040-1071 +32, Yo 1025->1105). When reviewing such a fold: (a) already-lowercase ranges excluded (no double-fold), (b) def before first use when the program does NOT import helpers.jq, (c) a pure-emoji-keyword exact-match test proves non-folded codepoints pass through unchanged on both sides. (task-010)
|
||||
|
||||
- Package-manager rc is NOT a reliable success signal on opkg: rc=0 for "Not downgrading"/"already installed"/"up to date". A self-update/install that trusts only rc silently no-ops (the v→no-v rename trap: legacy `v0.8.6` sorts ABOVE `0.8.7` in opkg's compare, so `opkg install` refuses the "downgrade" and returns 0). When reviewing a package-install path, require: (a) `--force-downgrade --force-reinstall` on the opkg branch (apk overwrites by default); AND (b) verify-after-install — RE-READ the installed version (opkg `list-installed | grep "^pkg "`, apk `list --installed`; grep/awk only, NO Oniguruma jq) and compare v-stripped semver (`${x#v}`, `${x%%-*}`) with `==` OR `is_min_package_version installed target`; empty-installed must fail-safe to success:false. Keep install.sh `pkg_install` and updater.sh `updates_pkg_install_file` opkg branches ALIGNED. (task-041/042)
|
||||
- Async self-update worker landmine: the `_*_core` worker MUST `return 1` (NEVER `exit`) on failure so the public wrapper's always-run `updates_restore_after_swap` epilogue + finished-job-state write still execute. Verify the wrapper captures core rc/JSON to a temp file then unconditionally restores. Smoke assertions for these must be in the MAIN shell body (direct `if…pass/fail`), never inside `cmd | while read` (subshell swallows PASS/FAIL — harness-wide landmine). (task-041)
|
||||
|
||||
- UCI option→list rewrites: the `uci add_list "key=value"` CLI form splits on the FIRST `=` and SILENTLY LOSES query-string URLs (`?token=abc&x=1`) — reproduced on hardware (rc=1, list empty). Require the `uci_add_list <cfg> <sec> <opt> "<val>"` SHELL HELPER (separate-arg, preserves `=`/`&`). For delete-then-add rewrites, verify a failed add RESTORES the scalar AND that the change-flag gates the `uci commit` (an uncommitted in-memory delete must never persist). (task-048 [B1])
|
||||
- When RE-reviewing a fix round, also diff the developer's MEMORY note: it is frequently written against the PRE-fix code and re-seeds the very anti-pattern that was just fixed (task-048 [M2]: note still showed the `key=value` form + "non-gating piped-while" after both were fixed). Flag a stale memory note as a (minor) condition.
|
||||
- Test-gating landmine: a smoke test whose assertions run on the RHS of a pipe (`cmd | while read; pass/fail`) does NOT gate CI (subshell counter loss) — a FAIL token prints red but the suite exits 0. Require current-shell parsing (`while read < tmpfile`). The 178→190 count jump when task-048 fixed this is the tell. (task-048 [S1])
|
||||
|
||||
- Rate-limit avoidance via redirect path (task-049): version-check/self-update/install can read the latest tag from `github.com/<repo>/releases/latest` (302 -> /releases/tag/<tag>, served by the github.com FRONTEND, NOT the 60/hr-per-IP api.github.com) instead of the API. Tag extracted with `curl -sI -o /dev/null -w '%{redirect_url}'` then `case`/param-expansion `${r##*/releases/tag/}` — when reviewing such code REQUIRE: (a) the tag is rejected if empty OR `/`-containing (path-traversal/injection guard) via `case "$tag" in ''|*/*) tag="" ;;`; (b) the tag is only used quoted inside a URL string / passed quoted to helpers, never `eval`'d or used as a bare filesystem path; (c) curl-absent / non-match degrades to the API fallback (no hard-fail/exit); (d) the file-download helper uses `curl -fsSL`/`-L` so the CDN 302 on `releases/download/<tag>/<asset>` is followed. busybox wget on-device is STRIPPED (no -S/--max-redirect/header read) so redirect reading MUST use curl (hard +curl dep). Keep the sing-box-EXTENDED releases-LIST path on the API (a redirect can't give draft/prerelease/per-arch).
|
||||
924
docs/agent-rules/memory/luci-frontend-developer.md
Normal file
924
docs/agent-rules/memory/luci-frontend-developer.md
Normal file
@ -0,0 +1,924 @@
|
||||
# Memory — luci-frontend-developer
|
||||
|
||||
Durable frontend (TypeScript / LuCI) knowledge. Read before implementing;
|
||||
append findings; keep under ~200 lines.
|
||||
|
||||
## The generated bundle (most important)
|
||||
|
||||
- `luci-app-netshift/htdocs/luci-static/resources/view/netshift/main.js` is
|
||||
**autogenerated by tsup** from `fe-app-netshift/src/**`. Banner: "This file is
|
||||
autogenerated, please don't change manually." NEVER hand-edit it. Edit TS
|
||||
source, then `yarn build`. CI fails if the committed `main.js` differs from a
|
||||
fresh build (`git diff --exit-code` after build).
|
||||
- tsup `onSuccess` regex-patches the ESM `export { ... }` block into
|
||||
`return baseclass.extend({ ... })` so LuCI can load it as a baseclass module.
|
||||
An unusual export shape could break that regex.
|
||||
|
||||
## Barrel / export reachability (recurring gotcha)
|
||||
|
||||
- Hand-written views consume the bundle as `main.*` via
|
||||
`'require view.netshift.main as main'`. A new public API
|
||||
(validator/helper/constant/tab) reaches the views ONLY if it is re-exported
|
||||
up the barrel chain to `src/main.ts`.
|
||||
- `validateHysteria2Url` (in `validateHysteriaUrl.ts`) is intentionally NOT in
|
||||
`validators/index.ts` — only `validateProxyUrl` is exported; it dispatches to
|
||||
the per-scheme validators internally. Mirror that pattern: export the
|
||||
dispatcher, not necessarily every leaf.
|
||||
- Other helpers (prettyBytes, showToast, normalizeCompiledVersion, etc.) are
|
||||
imported by direct path, not via barrel. Check before assuming barrel.
|
||||
|
||||
## Backend access
|
||||
|
||||
- Only `/usr/bin/netshift` and `/etc/init.d/netshift` are ACL-allowed for
|
||||
`fs.exec` (acl.d/luci-app-netshift.json). A new shell command must be a
|
||||
subcommand of one of those, else extend the ACL + backend. Plus Clash API on
|
||||
`ws://<host>:9090` and `http://<host>:9090/ui`.
|
||||
|
||||
## Style / CI
|
||||
|
||||
- `yarn ci` = `format && lint --max-warnings=0 && test --run && build`.
|
||||
No-diff enforcement (format + build) lives in `frontend-ci.yml` via
|
||||
`git diff --exit-code`. Run the `frontend-ci` skill before handing back.
|
||||
- Prettier: 2-space, single quotes, semicolons, trailing-comma all, width 80.
|
||||
- ESLint: `@typescript-eslint/no-unused-vars` is a WARNING with `^_` ignore, and
|
||||
CI is `--max-warnings=0` => unused vars MUST be `_`-prefixed (`_e`, `_err`).
|
||||
- strict TS, no `any`, functional components, named exports.
|
||||
- LuCI globals (E, fs, uci, ui, _) are in `src/luci.d.ts` (no import). E()
|
||||
handlers use the `click:` attribute (not `onclick`) even though luci.d.ts only
|
||||
declares `onclick` — follow the existing `click:` convention. Extend
|
||||
`luci.d.ts` when you need a new LuCI global.
|
||||
|
||||
## i18n
|
||||
|
||||
- Wrap user-facing strings in `_()`, and only STRING LITERALS — the gettext
|
||||
extractor (`yarn locales:actualize`) only sees literal args. Some validator
|
||||
messages (vless/trojan) are currently NOT wrapped — wrap new ones.
|
||||
- i18n pipeline (`yarn locales:actualize` = extract-calls → generate-pot →
|
||||
generate-po:ru → distribute). `extract-calls.js` scans BOTH `src/**/*.ts` AND
|
||||
the hand-written `luci-app-netshift/.../view/netshift/**/*.js` (excludes
|
||||
`main.js`), so `_()` strings added in `section.js` ARE extracted. Source of
|
||||
truth for ru text is `fe-app-netshift/locales/netshift.ru.po` — fill the new
|
||||
empty `msgstr` there (preserves existing translations on regen), then re-run
|
||||
`yarn locales:distribute` to copy into the generated catalogs
|
||||
`luci-app-netshift/po/{templates/netshift.pot, ru/netshift.po}`. Touched
|
||||
catalog files: `locales/calls.json`, `locales/netshift.pot`,
|
||||
`locales/netshift.ru.po`, `po/templates/netshift.pot`, `po/ru/netshift.po`.
|
||||
- TYPE-ONLY changes to `src/**` (e.g. adding interface fields in `types.ts`,
|
||||
inside the `NetShift` namespace) erase at build → `main.js` has NO diff after
|
||||
`yarn build`. Expect a clean `git diff` on `main.js` for pure-type edits.
|
||||
- `section.js` is a hand-written LuCI view, NOT bundled — `yarn format` only
|
||||
touches `src/`, so format leaves section.js alone; keep its existing
|
||||
2-space/double-quote style manually.
|
||||
|
||||
## Version placeholder
|
||||
|
||||
- `constants.ts` `NETSHIFT_LUCI_APP_VERSION='__COMPILED_VERSION_VARIABLE__'` is
|
||||
substituted at OpenWRT build by `luci-app-netshift/Makefile` sed. In dev,
|
||||
`normalizeCompiledVersion` turns anything containing `COMPILED` into `'dev'`.
|
||||
Don't change the literal without updating the Makefile sed.
|
||||
|
||||
## Tests
|
||||
|
||||
- vitest `.test.js` files next to code under `tests/`, table-driven
|
||||
`describe.each`, `_()` identity-mocked in `tests/setup/global-mocks.ts`, node
|
||||
env (no DOM). New pure logic SHOULD ship a test. DOM/service/render code is
|
||||
untested (no DOM mocks) — verify those by reasoning + build.
|
||||
- DO NOT import a `.test.js` from `methods/shell/index.ts` (or anything that
|
||||
transitively imports the helpers barrel → `withTimeout` → `../netshift`
|
||||
logger → `TabService` which calls `new MutationObserver` at module init).
|
||||
In the node env that throws `MutationObserver is not defined` at COLLECT time
|
||||
(suite fails with "no tests"). Fix: put pure/testable logic in its OWN module
|
||||
that imports leaf helpers by DIRECT path (e.g. `../../../helpers/sleep`, NOT
|
||||
the barrel), and import the test from that module. Done for task-008's
|
||||
`pollSingBoxComponentAction.ts`.
|
||||
|
||||
## Async core switch (task-008) — the rpcd 30s pattern
|
||||
|
||||
- The core switch (sing-box install_extended/install_stable) must use the
|
||||
ASYNC backend contract, not a single sync `component_action` exec — rpcd
|
||||
kills any single fs.exec at 30s SERVER-SIDE regardless of the JS
|
||||
`timeout:600000`. Pattern: `executeShellCommand(['component_action_async',
|
||||
'sing_box', action])` → parse `{success,job_id,message}` → if no job_id,
|
||||
fail fast → poll `['component_action_status', jobId]` in a loop every ~2s
|
||||
with SHORT individual execs until `running !== true`; safety cap ~150 polls.
|
||||
Each status call is tiny (ms) so never hits the wall.
|
||||
- Status contract fields (task-007/009): `{success,running,component,action,
|
||||
message,pid,started_at,updated_at,exit_code,version,latest_version}`.
|
||||
Map terminal → `{success, version, message}`.
|
||||
- `check_update` stays on the SYNC `component_action` path (fast, not subject
|
||||
to 30s) — branch inside `singBoxComponentAction` on `action`.
|
||||
- Make the poll loop a PURE fn `pollSingBoxComponentAction(fetchStatus,
|
||||
sleepFn=sleep, intervalMs, maxPolls)` with injected `fetchStatus`+`sleepFn`
|
||||
so tests pass a no-op sleep (`() => Promise.resolve()`) and never wait 2s.
|
||||
`sleep(ms)` lives in `helpers/sleep.ts` (Promise+setTimeout).
|
||||
- `showToast` type is only `'success' | 'error'` — for an in-progress info
|
||||
toast just use `'success'` (don't widen the helper signature for it).
|
||||
- BARREL LEAK GOTCHA: adding `export * from './sleep'` to `helpers/index.ts`
|
||||
made `sleep` appear as `main.sleep` in the generated baseclass.extend export
|
||||
block (used barrel exports are NOT tree-shaken). For a truly INTERNAL helper,
|
||||
do NOT put it in the barrel — place it next to its only consumer (e.g.
|
||||
`methods/shell/sleep.ts`) and import by direct relative path. Verify after
|
||||
build: `git diff main.js | grep '^\+\s\+[a-z]\+,$'` shows no new bare export
|
||||
line. (task-008 M1.)
|
||||
- TESTING the REAL `singBoxComponentAction` despite the DOM/MutationObserver
|
||||
collect crash: `vi.mock('<barrel path>', () => ({ executeShellCommand: ... }))`
|
||||
short-circuits the `helpers`→`withTimeout`→`../netshift`→TabService chain so
|
||||
the method module imports cleanly, THEN `const { X } = await import('../index')`.
|
||||
CRITICAL: `vi.mock` factory paths are relative to the TEST file, and must
|
||||
resolve to the SAME absolute module the SUT imports. From
|
||||
`methods/shell/tests/`, the SUT's `../../../helpers` is `../../../../helpers`
|
||||
from the test, and `./callBaseMethod` is `../../callBaseMethod`. Get these
|
||||
wrong and the real (DOM-crashing) module loads → "MutationObserver is not
|
||||
defined" at collect. (task-008 M2.)
|
||||
|
||||
## Landmines
|
||||
|
||||
- `runFakeIPCheck` allGood/atLeastOneGood logic looks inverted vs other checks —
|
||||
don't "fix" without understanding intent.
|
||||
- Filename typo `checks/contstants.ts` is imported with the typo everywhere —
|
||||
don't "correct" it and break imports.
|
||||
|
||||
## Validator-test node globals (task-006)
|
||||
|
||||
- `.test.js` files are linted as plain JS (typescript parser, no
|
||||
`languageOptions.globals` in `eslint.config.js`), so bare `Buffer` / `btoa`
|
||||
trips `no-undef` even though vitest runs in the node env at runtime. The
|
||||
validator `.ts` files DON'T hit this (TS lib types cover `atob`). Fix in tests
|
||||
WITHOUT editing eslint config: alias the node global via
|
||||
`const NodeBuffer = globalThis.Buffer;` (`globalThis` is an allowed global) and
|
||||
use `NodeBuffer.from(...).toString('base64')` for fixtures.
|
||||
- VMess `vmess://` is base64(JSON) (V2RayN), NOT user@host. `validateVmessUrl`
|
||||
decodes with `atob` (right-pad to %4 with `=` for unpadded tolerance, matching
|
||||
backend), JSON.parse, then narrows `Record<string, unknown>` for
|
||||
`add`/`id`/`port`. It is dispatcher-only (NOT in `validators/index.ts`), exactly
|
||||
like `validateHysteria2Url`. Craft a `+`-containing base64 fixture with a field
|
||||
like `ps:'>>>'` (verified to force `+`).
|
||||
|
||||
## Corepack yarn 4.x vs classic lockfile (task-006)
|
||||
|
||||
- This repo's `yarn.lock` is v1 (classic) but corepack may activate yarn 4.16.0.
|
||||
Running `yarn install` migrates the lockfile + creates `.yarn/`/`.yarnrc.yml`.
|
||||
AVOID `yarn install`; node_modules is committed/present. Run CI steps via local
|
||||
bins: `node_modules/.bin/{prettier --write src, eslint src --ext .ts,.tsx
|
||||
--max-warnings=0, vitest run, tsup src/main.ts}`. Run locales via
|
||||
`node {extract-calls,generate-pot,generate-po ru,distribute-locales}.js`.
|
||||
Before reporting: confirm `git diff --exit-code -- fe-app-netshift/yarn.lock`
|
||||
and no `.yarn`/`.yarnrc.yml`.
|
||||
- `locales/calls.json` is committed WITH Windows backslash paths
|
||||
(`src\\validators\\...`) — generating it on Windows does NOT churn separators.
|
||||
- The proxy-link help string `"vless://, ... links"` is duplicated 3× in
|
||||
`section.js` (proxy_string + selector + urltest fields) — use edit replaceAll.
|
||||
|
||||
## VMess `#fragment` strip (task-012)
|
||||
|
||||
- `vmess://<base64(JSON)>#name` — the `#…` is the server display-name remark
|
||||
(same as vless/ss/trojan), but for VMess the name also lives in JSON `ps`.
|
||||
V2RayN base64 NEVER contains `#`, so cut at the FIRST `#` before decode.
|
||||
- `validateVmessUrl` order MATTERS: derive `body = url.slice('vmess://'.length)`
|
||||
→ `b64 = body.split('#')[0]` → THEN run the `/\s/` whitespace check on `b64`
|
||||
(NOT the full url), THEN pad/`atob` `b64`. This lets a `#name with spaces`
|
||||
fragment validate while still rejecting whitespace inside the base64 body.
|
||||
(The old code ran `/\s/` on the full url and padded `body` incl. fragment →
|
||||
emoji/Cyrillic in `#🇳🇱Ne` corrupted base64 → "malformed base64".)
|
||||
- Real-user regression fixture is the long `eyJ…In0=#🇳🇱Ne` literal in the test;
|
||||
keep a malformed-base64 negative case WITHOUT a `#` (`vmess://@@@@`) so it
|
||||
still fails for the right reason, and a `vmess://<b64> ` (trailing space, no
|
||||
`#`) 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.
|
||||
|
||||
## subscription_url → form.DynamicList (multi-URL) (task-023)
|
||||
|
||||
- Converted `subscription_url` from `form.Value` to `form.DynamicList` in
|
||||
section.js (~88-111), modelled EXACTLY on `remote_domain_lists` (:721-742):
|
||||
same per-row validate (`!value||value.length===0 → true`, else
|
||||
`main.validateUrl(value)`), `rmempty=true` (was `false`; the empty-row guard
|
||||
already short-circuited so emptiness was never enforced; backend keeps the
|
||||
"no URL" guard). Kept option name `subscription_url`, depends
|
||||
`{connection_type:'proxy',proxy_config_type:'subscription'}`, placeholder
|
||||
`https://example.com/api/sub`. Title → plural `_("Subscription URLs")`;
|
||||
description → single literal `_("Add one or more subscription URLs to fetch
|
||||
proxy configurations from. All feeds are downloaded and merged.")`.
|
||||
- types.ts:120 `subscription_url: string` → `string[]` (kept required, matches
|
||||
sibling list fields `selector_proxy_links`/`urltest_proxy_links`).
|
||||
- PURE TYPE-ONLY CHANGE: nothing in the FE reads `subscription_url` back (verified
|
||||
repo-wide) → `tsup` build produced ZERO main.js diff (confirmed via
|
||||
`git diff --exit-code main.js`). This is correct, NOT a missed rebuild. Still
|
||||
ran the build to confirm. (Same lesson as the type-only note in i18n section.)
|
||||
- locales: `node {extract-calls,generate-pot,generate-po ru,distribute-locales}.js`
|
||||
(NOT yarn → no corepack). msgid delta = clean SWAP: removed "Subscription URL"
|
||||
+ "Enter the subscription URL...provider"; added "Subscription URLs" + the new
|
||||
merged-feeds description. Filled 2 ru msgstr in SOURCE locales/netshift.ru.po
|
||||
("URL подписок" / "Добавьте один или несколько URL подписок...объединяются.")
|
||||
then distribute → po/ru + po/templates byte-identical to source (verified via
|
||||
diff). Only header msgstr empty (line 7). 5 catalog files touched: calls.json,
|
||||
locales/netshift.{pot,ru.po}, po/{templates/netshift.pot,ru/netshift.po}.
|
||||
- yarn classic 1.22.22 again but ran inner gate via node_modules/.bin
|
||||
(prettier/eslint/vitest/tsup) to be safe; yarn.lock unchanged, no .yarn/.yarnrc.
|
||||
|
||||
## UI design-system foundation: .card + tokens + toasts (task-024)
|
||||
|
||||
- DESIGN TOKENS (STABLE — task-025/026 reference these; do NOT rename) defined
|
||||
in `src/styles.ts` `GlobalStyles` on `:root, .cbi-map`:
|
||||
`--ns-card-border` (var(--background-color-low, lightgray)),
|
||||
`--ns-card-border-width` (2px), `--ns-card-radius` (4px), `--ns-gap` (10px),
|
||||
`--ns-card-padding` (var(--ns-gap)), `--ns-success`/`--ns-warning`/`--ns-error`/
|
||||
`--ns-info` (layered over success/warn/error-color-medium + primary-color-high
|
||||
with hex fallbacks #28a745/#f0ad4e/#dc3545/#2196f3).
|
||||
- `.card` primitive = `border: var(--ns-card-border-width) solid
|
||||
var(--ns-card-border); border-radius: var(--ns-card-radius); padding:
|
||||
var(--ns-card-padding); min-width:0`. Mirrors Manager's component card EXACTLY
|
||||
(2px/4px/10px/min-width:0) — that's the standardised look, NOT the 1px/8px from
|
||||
the spec's illustrative example.
|
||||
- CASCADE RULE: `.card` MUST be defined in GlobalStyles BEFORE the
|
||||
`${DashboardTab.styles}${DiagnosticTab.styles}${ManagerTab.styles}`
|
||||
interpolations. The per-tab colored-border MODIFIERS (`.pdk_diagnostic_alert
|
||||
--warning/--error/--loading/--success`, `__wiki--warning/--error`, outbound-grid
|
||||
`--active`/`--selectable:hover`) are same-specificity single-class rules that
|
||||
win ONLY via source order. Since injectGlobalStyles emits ONE `<style>` with
|
||||
GlobalStyles, and the template renders tokens+`.card` first THEN the interpolated
|
||||
tab CSS, the modifiers correctly override `.card`'s neutral border. (File
|
||||
byte-offset of `.card` in main.js is LATER than the modifiers because
|
||||
`DashboardTab.styles` is a separate `var stylesN` module — but runtime template
|
||||
concatenation order is what matters, and that's correct.)
|
||||
- REFACTOR PATTERN: removed the duplicated `border/border-radius/padding` (and
|
||||
manager's `min-width:0`) from the per-tab `styles.ts`, added `class:'card …'` in
|
||||
the RENDER `.ts`. Card boxes touched (more than the spec's "4" — there were
|
||||
these render sites): dashboard renderWidget (3 states), renderSections
|
||||
(failed/loading/default outbound-section + outbound-grid item), diagnostic
|
||||
renderWikiDisclaimer (className array — prepend 'card'), renderAvailableActions,
|
||||
renderSystemInfo, renderCheckSection (all 5 alert states incl. `--skipped` which
|
||||
has NO modifier so it relies on `.card`), manager initController component.
|
||||
`.card` adds `min-width:0` to dashboard/diagnostic boxes (was absent) — harmless
|
||||
overflow hardening, visually identical.
|
||||
- showToast union widened to `'success'|'error'|'warning'|'info'`
|
||||
(showToast.ts:3). Added `.toast-warning`(--ns-warning) + `.toast-info`(--ns-info)
|
||||
CSS; converted existing `.toast-success/.toast-error` to `var(--ns-success/error,
|
||||
#hex)` (themeable, same fallback hex → visually identical). The
|
||||
PREVIOUS memory note "showToast type is only success|error — use 'success' for
|
||||
in-progress" is now SUPERSEDED: use `'info'` for in-progress, `'warning'` for
|
||||
long/destructive-ish. Converted the 2 abuse sites in manager/initController.ts I
|
||||
was already in: "Switching sing-box core…"→'info', "Updating NetShift…page will
|
||||
reload"→'warning'. DEFERRED (not in touched files / debatable): manager line
|
||||
~155 "Latest version is unknown" still 'success' (check-result, not in-progress);
|
||||
diagnostic/initController.ts had only 'error' toasts (nothing to fix).
|
||||
- RAW BUTTON KILLED: dashboard renderSections.ts "Test latency" raw
|
||||
`<button class="btn">` → `renderButton({text:_('Test latency'), onClick:
|
||||
()=>testLatency(), classNames:['dashboard-sections-grid-item-test-latency']})`.
|
||||
renderButton already adds `btn`, so only the custom class goes in classNames.
|
||||
- IMPORT-ORDER MAIN.JS CHURN (IMPORTANT): adding `import {renderButton} from
|
||||
'../../../../partials'` into the DASHBOARD subtree (which previously never
|
||||
imported the global `src/partials` barrel) makes esbuild REORDER ~every bundled
|
||||
module block → a huge SYMMETRIC main.js diff (~1600/1600 lines) that is PURELY
|
||||
cosmetic module reordering. Verified safe: build is IDEMPOTENT (same md5 twice),
|
||||
banner intact, `return baseclass.extend({` intact, and the export-symbol SET is
|
||||
BYTE-IDENTICAL to HEAD (diff /tmp/exports_old vs new = empty) → no barrel leak,
|
||||
no new public API. Direct-path import (`…/partials/button/renderButton`) barely
|
||||
reduced churn — the reorder is inherent to introducing the cross-subtree dep, so
|
||||
I kept the barrel import for consistency with the 3 diagnostic callers. When a
|
||||
reviewer sees a giant main.js diff for a tiny TS change, CHECK export-set
|
||||
equality + idempotency before worrying.
|
||||
- TAB REORDER: netshift.js (hand-written, edit directly) — moved the Dashboard
|
||||
`form.TypedSection` block to FIRST. New order: Dashboard · Sections · Settings ·
|
||||
Component Manager · Diagnostics. ONLY block order changed; all 5 sections + their
|
||||
cfgsections/anonymous/addremove wiring identical. coreService/TabService track by
|
||||
`data-tab` (the active section name e.g. `current==='dashboard'`), NOT
|
||||
registration index (tab.service.ts getActiveTabId reads `.cbi-tab:not(
|
||||
.cbi-tab-disabled)` dataset.tab; dashboard initController keys on
|
||||
`tabService.current==='dashboard'`) → reorder is SAFE, tracking unaffected.
|
||||
- VISUAL VERIFY caveat: no chromium available in this env (playwright launch
|
||||
failed: chrome not found), so screenshots were NOT possible. Verified instead by
|
||||
CSS-cascade reasoning + programmatic checks: `.card` precedes modifiers in the
|
||||
runtime-concatenated GlobalStyles, no `background-color-low` base border remains
|
||||
in any per-tab styles.ts (all neutral borders now come from `.card`), colored
|
||||
modifiers keep their 2px width matching `.card`. FLAG: visual confirmation is
|
||||
reasoned, not screenshotted.
|
||||
- yarn classic 1.22.22; ran gate via node_modules/.bin (prettier --write src clean
|
||||
/ eslint --max-warnings=0 / vitest 471 pass / tsup build). yarn.lock unchanged,
|
||||
no `.yarn`/`.yarnrc.yml`. No locales change (no NEW user-facing literals — the
|
||||
switching/updating toast strings already existed).
|
||||
|
||||
## task-025 — section.js → 4 native CBI tabs (taboption)
|
||||
|
||||
- CBI native tabs: `section.tab('name', _('Title'), _('descr'))` defines a tab,
|
||||
then EVERY field MUST be `section.taboption('name', form.X, 'key', ...)`. HARD
|
||||
RULE confirmed: once a section has `.tab()`, any leftover `section.option(...)`
|
||||
silently renders nothing. Verified count: 36 taboption, 0 plain option (the
|
||||
only `section.option(` grep hit was my own comment line).
|
||||
- Conversion is mechanical & low-risk: only the constructor call line changes
|
||||
(`section.option(\n form.X,` → `section.taboption(\n "tab",\n form.X,`).
|
||||
All `.depends()` (33), `.validate` (17), `.value()`, defaults, placeholders,
|
||||
and the `community_lists.onchange` (REGIONAL_OPTIONS/ALLOWED_WITH_RUSSIA_INSIDE
|
||||
/DOMAIN_LIST_OPTIONS/getUIElement) stayed byte-identical. depends() works
|
||||
across tabs; an all-depends-hidden tab auto-hides from the strip (Subscription
|
||||
tab hides for proxy/url) — desired, no extra code.
|
||||
- `widgets.DeviceSelect` (`interface`) works fine inside a taboption — just pass
|
||||
the widget class as the 2nd arg after the tab name.
|
||||
- Tab map (4 tabs, 36 fields): connection=11, subscription=10, routing=12,
|
||||
advanced=3.
|
||||
- SMART-LIST UNIFICATION: did the LOW-RISK visual grouping (NOT a single-widget
|
||||
merge). Kept all 4 UCI keys + 2 *_list_type selectors. Achieved "one control"
|
||||
feel by renaming the two list-type selector TITLES to group headings
|
||||
("Custom domains"/"Custom subnets") with descriptions naming the modes;
|
||||
depends() already shows only the chosen input below. Deeper merge deferred
|
||||
(would risk UCI/validator changes). NOTE: renaming a selector title drops its
|
||||
old msgid from catalogs — fill the new ones.
|
||||
- RU-HARDCODE FIX: `subscription_group_by_countries` had `_("Группировать по
|
||||
странам")` as the SOURCE literal (msgid). Replaced with English `_("Group by
|
||||
countries")` + English descr; moved the Russian into the ru.po msgstr. After
|
||||
this the Cyrillic appears ONLY as msgstr, never as msgid.
|
||||
- section.js is NOT in the `yarn ci` prettier scope (CI formats only `src`).
|
||||
section.js uses DOUBLE QUOTES (LuCI convention) and does NOT pass the project
|
||||
`.prettierrc` (singleQuote) — confirmed the ORIGINAL also failed prettier.
|
||||
So: match the file's existing double-quote/2-space style; do NOT run prettier
|
||||
on section.js (it would fight the whole file).
|
||||
- i18n flow: `node extract-calls.js && node generate-pot.js && node
|
||||
generate-po.js ru && node distribute-locales.js`. generate-po keys by msgid &
|
||||
carries forward old msgstr; NEW/renamed msgids land empty → fill them in
|
||||
fe-app-netshift/locales/netshift.ru.po, then RE-RUN distribute-locales.js to
|
||||
copy into luci-app-netshift/po/{ru/netshift.po, templates/netshift.pot}.
|
||||
Verify byte-consistency with `diff -q` (both fe↔luci pairs). 13 new strings
|
||||
this task; all ru filled; 0 empty msgstr after.
|
||||
- main.js drift rule confirmed: section.js-only + catalog changes need NO main.js
|
||||
rebuild. I touched styles.ts so rebuilt — the ONLY main.js delta vs the
|
||||
task-024 baseline was my new CSS block (#cbi-netshift-section
|
||||
.cbi-section-node-tabbed card + ul.cbi-tabmenu margin). Build reproducible.
|
||||
- styles.ts: reused task-024 --ns-* tokens; added `#cbi-netshift-section
|
||||
.cbi-section-node-tabbed` (card border/radius/padding) + `ul.cbi-tabmenu`
|
||||
margin. Existing h3-hide (`> h3:nth-child(1)`) and remove-button hack
|
||||
(`> .cbi-section-remove { margin-bottom:-32px }`) left intact (new rules added
|
||||
after them; both still valid — remove button is a direct child, unaffected by
|
||||
the tabbed pane styling).
|
||||
- VISUAL VERIFY caveat persists: no browser in env. Confirmed structurally
|
||||
(taboption count/mapping, depends/validate counts == original, onchange grep
|
||||
intact, catalog diff). FLAG for human: actual tab-strip/card rendering +
|
||||
auto-hide behaviour of the Subscription/Advanced tabs not screenshot-verified.
|
||||
|
||||
## task-026 — settings.js → 5 native CBI tabs (taboption)
|
||||
|
||||
- Same mechanics as task-025. Converted ALL 27 settings options to
|
||||
`section.taboption('tab', form.X, 'key', …)`. Verified: 27 taboption, 0 plain
|
||||
`section.option(` (the 1 grep hit is my comment line). Tab map (5 tabs, 27):
|
||||
dns(6)=dns_type,dns_server,bootstrap_dns_server,dns_via_outbound,
|
||||
dns_outbound_section,dns_rewrite_ttl · network(6)=source_network_interfaces,
|
||||
enable_output_network_interface,output_network_interface,
|
||||
enable_badwan_interface_monitoring,badwan_monitored_interfaces,
|
||||
badwan_reload_delay · lists(4)=update_interval,download_lists_via_proxy,
|
||||
download_lists_via_proxy_section,routing_excluded_ips · yacd(3)=enable_yacd,
|
||||
enable_yacd_wan_access,yacd_secret_key · advanced(8)=disable_quic,
|
||||
dont_touch_dhcp,exclude_ntp,block_doh,enable_ipv6,config_path,cache_path,
|
||||
log_level.
|
||||
- YACD DECISION: kept as its OWN tab (3 fields), NOT folded into Advanced.
|
||||
Rationale: self-contained feature w/ clean depends() chain
|
||||
(enable_yacd→wan_access→secret_key); folding into an 11-field Advanced would
|
||||
recreate the wall. Tab title is `_("Dashboard")` (already-existing msgid),
|
||||
internal tab name "yacd". Documented.
|
||||
- All 7 depends() preserved verbatim (only line order changed — irrelevant):
|
||||
dns_outbound_section dep dns_via_outbound=1; output_network_interface dep
|
||||
enable_output_network_interface=1; badwan_monitored_interfaces +
|
||||
badwan_reload_delay dep enable_badwan_interface_monitoring=1;
|
||||
enable_yacd_wan_access dep enable_yacd=1; yacd_secret_key dep
|
||||
enable_yacd_wan_access=1; download_lists_via_proxy_section dep
|
||||
download_lists_via_proxy=1. 6 validators + 3 custom widgets (2 DeviceSelect,
|
||||
1 NetworkSelect) intact; cfgvalue/load section-picker closures unchanged.
|
||||
- HELP TRIM: block_doh was a 4-paragraph `_()+ " " +_()…` concat. Replaced with
|
||||
ONE single-literal description "Block direct connections to known public DoH
|
||||
servers (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex) so apps cannot
|
||||
bypass router DNS filtering." The caveat ("enable only after switching to
|
||||
UDP/DoT") moved into the ADVANCED tab description (section.tab 3rd arg).
|
||||
enable_ipv6's 2-sentence concat LEFT inline (short, the 2nd sentence is a
|
||||
genuine 1-line caveat; not bloating) — documented choice.
|
||||
- BACKTICK TRAP IN styles.ts: GlobalStyles is a template literal. Putting a
|
||||
backtick inside a CSS COMMENT (e.g. `#cbi-... > h3`) prematurely closes the
|
||||
template → ESLint "Parsing error: ',' expected". NEVER use backticks anywhere
|
||||
inside the styles.ts CSS string, even in comments. (Cost me one lint cycle.)
|
||||
- styles.ts: added `#cbi-netshift-settings .cbi-section-node-tabbed` (card
|
||||
border/radius/padding/min-width:0) + `#cbi-netshift-settings ul.cbi-tabmenu`
|
||||
(margin-bottom:var(--ns-gap)) — exact mirror of the task-025 section block,
|
||||
reusing task-024 --ns-* tokens (did NOT redefine tokens). The existing
|
||||
`#cbi-netshift-settings > h3 { display:none }` rule stays valid (added new
|
||||
rules after it). main.js delta = exactly these 2 CSS rules; build IDEMPOTENT
|
||||
(md5 a7300a2… across 3 builds), banner + `return baseclass.extend` intact,
|
||||
no new export symbol (only-loss vs HEAD is task-024's `styles` leak removal,
|
||||
not mine).
|
||||
- i18n: msgid delta = 9 added (tab titles "DNS"/"Network"/"Lists & Updates" —
|
||||
"Dashboard"+"Advanced" already existed; 4 tab descriptions; 1 reworded
|
||||
block_doh) / 4 removed (old block_doh fragments). Filled 9 ru msgstr in SOURCE
|
||||
locales/netshift.ru.po then `node distribute-locales.js`. fe↔luci ru.po AND
|
||||
pot byte-identical (diff -q); all LF; valid UTF-8 w/ Cyrillic; 0 empty
|
||||
non-header msgstr. Ran scripts via `node {extract-calls,generate-pot,
|
||||
generate-po ru,distribute-locales}.js` (generate-po reported 335/339 but 9
|
||||
were genuinely new — its count metric differs).
|
||||
- yarn classic 1.22.22; ran gate via node_modules/.bin (prettier/eslint/vitest/
|
||||
tsup). yarn.lock unchanged, no .yarn/.yarnrc.yml. NB: working tree already
|
||||
carried UNCOMMITTED task-024 + task-025 changes (showToast, dashboard/diag/
|
||||
manager styles+renders, section.js, netshift.js, #cbi-netshift-section CSS) —
|
||||
so `git diff -- src` is large but only my settings block + the 4 catalogs +
|
||||
main.js belong to task-026. format reported all-unchanged → no new churn.
|
||||
|
||||
## task-030 — NetShift update check ON-DEMAND (retires C1's systemInfo-refresh)
|
||||
|
||||
- REVERSES task-018's C1 decision. task-029 (backend, APPROVED) ADDED a real
|
||||
`component_action netshift check_update` action returning the STANDARD check
|
||||
JSON `{success,current_version,latest_version,status}` (v-normalized
|
||||
server-side, SAME shape as the sing-box cores) AND removed the latest-fetch
|
||||
from `get_system_info` (now returns `netshift_latest_version:"unknown"`). So
|
||||
the NetShift card is now a TRUE peer of the cores: on-demand check writes
|
||||
`managerChecks.netshift`, mount does NO network check.
|
||||
- SHELL METHOD: added `netshiftCheckUpdate()` to `methods/shell/index.ts` —
|
||||
copy of `singBoxCheckUpdate` but args `['component_action','netshift',
|
||||
'check_update']`, parsed by the EXISTING `parseComponentCheckUpdate`, returns
|
||||
`NetShift.ComponentCheckUpdateResult`. SYNC path (fast call), timeout 600000.
|
||||
It's a PROPERTY on `NetShiftShellMethods` (not a top-level export) → NO new
|
||||
symbol in the baseclass.extend export block (verified byte-identical to HEAD).
|
||||
- runNetshiftCheck (manager/initController.ts): now MIRRORS runSingBoxCheck
|
||||
exactly — call `netshiftCheckUpdate()`, `if(!parsed.success)` error toast +
|
||||
return, `status=parsed.status??null`, `setCheckResult('netshift',status,
|
||||
parsed.latest_version||'')`, `showToast(getCheckToastMessage(status),
|
||||
'success')`, catch→error toast, finally→reset loading. STOPPED calling
|
||||
`fetchSystemInfo()`+`resetCheckResult` as the "check".
|
||||
- cards.ts: `netshiftStatus(systemInfo, check)` now RETURNS `check.status`
|
||||
(the on-demand result; null until checked → neutral). KEPT the dev guard
|
||||
(`normalizeCompiledVersion(...)==='dev' → null`). REMOVED the
|
||||
`installed===latest` string compare AND the systemInfo.netshift_latest_version
|
||||
dependency. `netshiftCard` takes the check too; "Install %s" `latest` now
|
||||
comes from `check.latest_version`. `getComponentCards` passes `checks.netshift`
|
||||
to `netshiftCard`. The `check_netshift` kind NOW carries
|
||||
`backendAction:'check_update'` (still a DISTINCT kind so the dispatcher routes
|
||||
it to runNetshiftCheck, never to the sing-box check method).
|
||||
- DIAGNOSTIC: NO code change needed. `getNetshiftVersionRow.ts` already treats
|
||||
`netshift_latest_version === 'unknown'` (and `'loading'`) as
|
||||
`!hasActualVersion` → returns the plain neutral row (no Outdated/Latest tag).
|
||||
Since task-029 makes the backend return "unknown", the row auto-degrades to
|
||||
neutral. Mount's `fetchSystemInfo()` is now network-free (backend change), so
|
||||
diagnostic entry triggers no GitHub call. Existing
|
||||
getNetshiftVersionRow.test.ts (passes real versions) stays green unchanged.
|
||||
- TESTS: rewrote the 5 NetShift cases in manager/tests/cards.test.js to derive
|
||||
from `managerChecks.netshift` instead of systemInfo: null-status→neutral+
|
||||
check_update; check 'outdated'→self_update + Install <check.latest_version>;
|
||||
'latest'→Latest badge; dev-build stays neutral even with a check 'outdated';
|
||||
systemInfo latest mismatch is IGNORED. 472 tests pass (cards 19).
|
||||
- LOCALES: removing the `runNetshiftCheck` body ORPHANED `_('Latest version is
|
||||
unknown')` (no longer referenced anywhere). Ran `node {extract-calls,
|
||||
generate-pot,generate-po ru,distribute-locales}.js`. msgid delta = PURELY the
|
||||
1 removed msgid (calls.json/pot/ru.po) + `#:` line-ref reshuffle + POT header
|
||||
date. fe↔luci pairs byte-identical (diff -q). No new strings added (all toasts
|
||||
reused existing msgids). generate-po reported 340/338 (2 stale retained).
|
||||
- main.js: +36/-26 runtime diff = exactly (new method block + netshiftStatus/
|
||||
Card signature change + runNetshiftCheck rewrite). IDEMPOTENT (md5
|
||||
9ce13d2… across 2 builds), banner + `return baseclass.extend({` intact,
|
||||
export block byte-identical to HEAD. yarn classic 1.22.22; ran via
|
||||
node_modules/.bin; yarn.lock unchanged, no .yarn/.yarnrc.yml.
|
||||
- FLAG (no browser in env): the neutral→checked card transition + toast were
|
||||
verified by reasoning + the pure cards.test.js, NOT screenshotted.
|
||||
|
||||
## task-032 — subscription_format_preference dropdown (Subscription tab)
|
||||
|
||||
- Added a `form.ListValue` `subscription_format_preference` in section.js
|
||||
(HAND-WRITTEN, NOT bundled) right AFTER `subscription_url` (~144-157),
|
||||
modelled EXACTLY on `subscription_update_interval`: `.value('auto',_('Auto'))`
|
||||
/ `.value('xray',_('Xray JSON (Happ)'))` / `.value('singbox',_('Sing-box'))`,
|
||||
`o.default='auto'`, same `depends({connection_type:'proxy',proxy_config_type:
|
||||
'subscription'})`. NO explicit `rmempty` — like the interval field, LuCI
|
||||
ListValue defaults `rmempty=true`, so selecting the default ('auto') does NOT
|
||||
write a spurious UCI value (matches backend task-031 which treats empty/
|
||||
unknown as auto). Single-literal `_()` description (no concat).
|
||||
- types.ts: added optional union `subscription_format_preference?: 'auto' |
|
||||
'xray' | 'singbox';` to `ConfigProxySubscriptionSection` (after
|
||||
subscription_url). Pure type-only → erased at build.
|
||||
- BACKEND CONTRACT (task-031, in working tree): UCI option name EXACTLY
|
||||
`subscription_format_preference`, values auto/xray/singbox; netshift bin reads
|
||||
it (`uci -q get …subscription_format_preference`, empty→auto). Confirmed via
|
||||
grep before editing.
|
||||
- main.js: NO diff (section.js hand-written + type-only types.ts), like
|
||||
task-023. Build still run to confirm; `git diff --stat main.js` empty.
|
||||
- locales: `node {extract-calls,generate-pot,generate-po ru,distribute-
|
||||
locales}.js`. msgid delta PURELY ADDITIVE — 4 added (Auto / Subscription
|
||||
format / Xray JSON (Happ) / the description), 0 removed; "Sing-box" REUSED an
|
||||
existing msgid (so generate-po reported 339/342, only 3 truly-new beyond the
|
||||
reused one). Filled 4 ru msgstr in SOURCE locales/netshift.ru.po (Auto→Авто,
|
||||
Subscription format→Формат подписки, Xray JSON (Happ)→Xray JSON (Happ),
|
||||
description translated) then distribute → po/ru + po/templates byte-identical
|
||||
to source (diff -q). Only header msgstr empty. 5 catalog files touched.
|
||||
- yarn classic 1.22.22; ran gate via node_modules/.bin (prettier --write src /
|
||||
eslint src --ext .ts,.tsx --max-warnings=0 / vitest 472 pass / tsup). format
|
||||
diff on src = ONLY my 1 types.ts line (no churn). yarn.lock unchanged, no
|
||||
.yarn/.yarnrc.yml. FLAG (no browser): dropdown rendering/auto-hide not
|
||||
screenshotted — verified structurally.
|
||||
|
||||
## task-040 — "Clear subscription cache" button in Diagnostics (async)
|
||||
|
||||
- BACKEND CONTRACT (task-039, APPROVED): `component_action subscription
|
||||
clear_cache` deletes all subscription caches + redownloads (restarts service
|
||||
on change), driven via the EXISTING async job machinery
|
||||
`component_action_async subscription clear_cache` → `{success,job_id,message}`,
|
||||
poll `component_action_status <job>`. ACL already allows `/usr/bin/netshift`
|
||||
exec — NO ACL change. Action strings are EXACTLY component='subscription',
|
||||
action='clear_cache'.
|
||||
- SHELL METHOD: added `clearSubscriptionCache()` to `methods/shell/index.ts` as
|
||||
a COPY of `netshiftSelfUpdate`'s start-then-poll shape BUT with the STRICT
|
||||
(non-lenient) poll callback used by `singBoxComponentAction` install path
|
||||
(return `null` on empty stdout — no binary swap here, so a parse/exec failure
|
||||
IS terminal). args `['component_action_async','subscription','clear_cache']`,
|
||||
REUSES the component-agnostic `pollSingBoxComponentAction` (NO new poll loop).
|
||||
Returns `SingBoxComponentActionResult {success,version?,message?}`. It's a
|
||||
PROPERTY on `NetShiftShellMethods` → NO new top-level export symbol (the
|
||||
baseclass.extend export block is byte-identical to HEAD). NO new
|
||||
`AvailableMethods` enum entry needed — the existing async actions pass
|
||||
`'component_action_async'`/`'component_action_status'` + the component/action
|
||||
as RAW string-literal args (not enum members), so I mirrored that exactly.
|
||||
- HANDLER: `handleClearSubscriptionCache` in diagnostic/initController.ts mirrors
|
||||
`handleRestart`'s service-mutation idiom + globalCheck's toast idiom: set
|
||||
`clearSubscriptionCache.loading=true` → `showToast(_('Clearing subscription
|
||||
cache and re-downloading… this may take a minute'),'info')` → await the async
|
||||
method → success→`showToast(...,'success')` else logger.error+error toast →
|
||||
catch→logger.error+error toast → finally→`await fetchServicesInfo()` +
|
||||
loading=false + `store.reset(['diagnosticsChecks'])`. NB: did NOT use
|
||||
handleRestart's `setTimeout(...,5000)` — the async method ALREADY polls to
|
||||
completion (service restart finished by the time it resolves), so refresh
|
||||
immediately in finally. Wired into `renderDiagnosticAvailableActionsWidget`
|
||||
(visible:true, disabled:atLeastOneServiceCommandLoading).
|
||||
- BUTTON: added `clearSubscriptionCache: ActionProps` to renderAvailableActions.ts
|
||||
+ an `insertIf(visible,[renderButton(...)])` block using `renderRotateCcwIcon24`
|
||||
(already imported for Restart — rotate/refresh fits "clear+redownload"; the
|
||||
icon set has NO trash icon). Label `_('Clear subscription cache')`. No custom
|
||||
classNames (neutral btn, like globalCheck/viewLogs/showSingBoxConfig).
|
||||
- STORE: added `clearSubscriptionCache: { loading: boolean }` to
|
||||
`diagnosticsActions` in store.service.ts type AND
|
||||
`clearSubscriptionCache: { loading: false }` to initialDiagnosticStore in
|
||||
diagnostic.store.ts (after showSingBoxConfig in both).
|
||||
- i18n: 4 NEW msgids (PURELY additive): 'Clear subscription cache', 'Clearing
|
||||
subscription cache and re-downloading… this may take a minute', 'Failed to
|
||||
clear subscription cache' (used in BOTH the shell method fallback + handler →
|
||||
same msgid), 'Subscription cache cleared and re-downloaded'. NB the ellipsis is
|
||||
a real `…` char (U+2026), not three dots — kept literal-consistent fe↔ru. Ran
|
||||
`node {extract-calls,generate-pot,generate-po ru,distribute-locales}.js` (NOT
|
||||
yarn). generate-po reported 343/346 (its count metric undercounts; there were
|
||||
4 truly-new empty msgstr + the header). Filled ru in SOURCE
|
||||
locales/netshift.ru.po (Очистить кеш подписок / Очистка кеша подписок и
|
||||
повторная загрузка… это может занять минуту / Не удалось очистить кеш подписок
|
||||
/ Кеш подписок очищен и загружен заново), re-ran distribute → po/ru +
|
||||
po/templates byte-identical to source (diff -q). Only header msgstr empty.
|
||||
- main.js: REAL +98/-1 runtime diff (new method block + handler + button +
|
||||
widget wiring). IDEMPOTENT (md5 aa89dfc… across 2 builds), banner +
|
||||
`return baseclass.extend({` intact, top-level export block byte-identical to
|
||||
HEAD (no barrel leak — clearSubscriptionCache is a NetShiftShellMethods
|
||||
property). Confirmed action args in main.js are exactly
|
||||
`["component_action_async","subscription","clear_cache"]` + poll via
|
||||
`component_action_status`.
|
||||
- NO new test: reused existing `pollSingBoxComponentAction` (already
|
||||
table-tested); the method+handler is wiring (DOM/store untestable in node
|
||||
env). vitest 472 pass unchanged.
|
||||
- yarn classic 1.22.22; ran gate via node_modules/.bin (prettier --check src
|
||||
clean / eslint src --ext .ts,.tsx --max-warnings=0 / vitest 472 / tsup).
|
||||
yarn.lock unchanged, no .yarn/.yarnrc.yml. Working tree also carried UNRELATED
|
||||
task-039 backend changes (netshift bin, updater.sh, tests/entrypoint.sh) +
|
||||
.opencode/agent edits — NOT mine. FLAG (no browser): button render + toast
|
||||
sequence verified by reasoning + the gate, NOT screenshotted.
|
||||
|
||||
## task-045 — universal subscription grouper (mode dropdown + prefix length)
|
||||
|
||||
- REPLACED the single `subscription_group_by_countries` form.Flag (section.js
|
||||
~190-201) with TWO taboptions in the SAME `subscription` tab (mandatory —
|
||||
tabbed section, a plain option() renders nothing):
|
||||
(1) `form.ListValue subscription_group_mode` — values off/country/prefix
|
||||
(`_("Off")`/`_("By country flag")`/`_("By name prefix")`), `o.default="off"`,
|
||||
`o.rmempty=false`, depends `{connection_type:"proxy",proxy_config_type:
|
||||
"subscription"}`; title `_("Subscription grouping")` + single-literal help.
|
||||
(2) `form.Value subscription_group_prefix_len` — title `_("Prefix length")`,
|
||||
`o.default="2"`, `o.datatype="and(uinteger,min(1))"`, `o.rmempty=false`,
|
||||
depends ADDS `subscription_group_mode:"prefix"` (3-key object) so it shows
|
||||
ONLY when mode=prefix. CBI cross-field depends within the same section/tab
|
||||
works fine; a fully-hidden field is OK.
|
||||
- CROSS-LAYER CONTRACT (task-044 backend, DONE): UCI options EXACTLY
|
||||
`subscription_group_mode` ∈ {off,country,prefix} default off, and
|
||||
`subscription_group_prefix_len` positive-int string default 2 (meaningful
|
||||
only when mode=prefix). Backend falls back to the LEGACY
|
||||
`subscription_group_by_countries` boolean ONLY when the new option is ABSENT
|
||||
→ the UI writes only the NEW options; NO JS migration written.
|
||||
- types.ts: swapped `subscription_group_by_countries?: '0'|'1'` →
|
||||
`subscription_group_mode?: 'off'|'country'|'prefix'` +
|
||||
`subscription_group_prefix_len?: string`. Grepped src first — NOTHING in TS
|
||||
reads the old key (only the type decl), so removing it is safe (backend reads
|
||||
the legacy UCI key directly, not via UI). Pure type-only → erased at build.
|
||||
- main.js: ZERO diff (section.js hand-written + not bundled; types.ts type-only).
|
||||
md5 unchanged across the build (aa89dfc5…). Confirmed via
|
||||
`git diff --exit-code main.js`. This is the EXPECTED/correct outcome — a diff
|
||||
there would mean an unexpected src change.
|
||||
- i18n: ran `node {extract-calls,generate-pot,generate-po ru,distribute-
|
||||
locales}.js` (yarn classic 1.22.22, but used node to avoid corepack). msgid
|
||||
delta = clean SWAP: removed 2 (`Group by countries` + its long description),
|
||||
added 7 (Off / By country flag / By name prefix / Subscription grouping /
|
||||
Prefix length / the grouping description / the prefix-length description).
|
||||
Filled 7 RU msgstr in SOURCE locales/netshift.ru.po then re-ran distribute →
|
||||
po/ru + po/templates byte-identical to source (diff -q both pairs). 0 empty
|
||||
non-header msgstr after. RU: Off→Выключено, By country flag→По флагу страны,
|
||||
By name prefix→По префиксу имени, Subscription grouping→Группировка подписки,
|
||||
Prefix length→Длина префикса.
|
||||
- yarn ci GREEN: format no-diff, eslint --max-warnings=0, vitest 472 pass, tsup
|
||||
build. yarn.lock unchanged, no .yarn/.yarnrc.yml. No new vitest (no new pure
|
||||
TS logic — datatype validation is LuCI client-side).
|
||||
- PRIVACY: no subscription-identifying data (hosts/IPs/URLs/keys/node names) in
|
||||
any code/comment/i18n/test/memory — generic "proxy name"/"country flag"
|
||||
wording only.
|
||||
- FLAG (no browser in env): the rendered Subscription tab (dropdown +
|
||||
conditional prefix-length field appearing only on mode=prefix, taboption
|
||||
auto-hide) needs a HUMAN VISUAL CHECK before merge — verified structurally
|
||||
only (taboption completeness, depends preserved).
|
||||
|
||||
## task-051 — text-list Selector/URLTest (paste links, one per line)
|
||||
|
||||
- CROSS-LAYER CONTRACT (backend done first): proxy_config_type values
|
||||
`selector_text` / `urltest_text`; scalar UCI options (textarea, one link per
|
||||
line) `selector_proxy_links_text` / `urltest_proxy_links_text`. Matched VERBATIM.
|
||||
- section.js (HAND-WRITTEN, NOT bundled → 0 main.js diff): (a) 2 new
|
||||
`o.value("selector_text",_("Selector (text list)"))` /
|
||||
`o.value("urltest_text",_("URLTest (text list)"))` after the `urltest` value.
|
||||
(b) 2 `form.TextValue` textareas modelled on the `url`-type `proxy_string`
|
||||
one (`o.textarea=true; o.rows=5; o.wrap="soft"; o.rmempty=false`): placed
|
||||
`selector_proxy_links_text` in the **connection** tab next to the existing
|
||||
`selector_proxy_links` DynamicList, and `urltest_proxy_links_text` in the
|
||||
**subscription** tab next to `urltest_proxy_links` (mirror the tab each
|
||||
list-typed sibling already lives in — they differ!). Each `o.validate` calls
|
||||
`main.validateProxyUrlList`.
|
||||
- URLTEST-TWIN GATING: the 3 urltest tuning fields (urltest_check_interval,
|
||||
urltest_tolerance, urltest_testing_url) each had `depends urltest` + `depends
|
||||
subscription`; added a 3rd `o.depends({connection_type:"proxy",
|
||||
proxy_config_type:"urltest_text"})` to each (CBI ORs depends). DID NOT touch
|
||||
`enable_udp_over_tcp` (gated on `connection_type:"proxy"` only → already shows
|
||||
for urltest_text) nor the subscription-only grouping/filter fields. The
|
||||
`urltest_proxy_links` DynamicList itself stays urltest-only (its text variant
|
||||
is the NEW separate field) — grep `proxy_config_type:"urltest"` leaves exactly
|
||||
4 hits: the DynamicList + 3 tuning fields.
|
||||
- NEW VALIDATOR `validateProxyUrlList(value:string):ValidationResult` — splits on
|
||||
`\n`, `.trim()` each line (so CRLF `\r` is stripped), skips blank lines, runs
|
||||
the EXISTING `validateProxyUrl` per line, returns first failure as
|
||||
`{valid:false, message:`${_('Line')} ${i+1}: ${msg}`}` (1-based incl. blank
|
||||
lines in the count) or `{valid:true,message:''}`. Empty/blank-only →
|
||||
`_('At least one proxy link must be specified.')`. ValidationResult REQUIRES
|
||||
`message:string` so valid branch sets `message:''`. BARREL-EXPORTED via
|
||||
`validators/index.ts` (`export * from './validateProxyUrlList'`) → reaches
|
||||
`main.validateProxyUrlList`. This is an EXPORTED leaf (NOT dispatcher-only like
|
||||
validateHysteria2Url/validateVmessUrl) because section.js calls it directly.
|
||||
- main.js: EXPECTED +28-line diff (the bundled validator fn + 1 export-block
|
||||
entry). Export-symbol set delta vs HEAD = EXACTLY `+ validateProxyUrlList`
|
||||
(no leak). Build IDEMPOTENT (md5 e5273ea1… across 2 builds), banner +
|
||||
`return baseclass.extend({` intact. The regenerated main.js IS the deliverable.
|
||||
- TEST `validators/tests/validateProxyUrlList.test.js`: table-driven describe.each
|
||||
(valid blobs incl. CRLF/blank-line/whitespace; invalid incl. empty/unsupported/
|
||||
garbage) + line-number-context assertions. SS fixture is the KNOWN-VALID
|
||||
`ss://2022-blake3-aes-256-gcm:dmCly/…=@127.0.0.1:27214?type=tcp` form copied
|
||||
from validateShadowsocksUrl.test.js (do NOT invent base64 that may fail). VLESS
|
||||
fixture copied from validateVlessUrl.test.js. 13 tests; total 485 pass.
|
||||
- i18n: 7 NEW msgids (Selector (text list); URLTest (text list); Selector Proxy
|
||||
Links (one per line); URLTest Proxy Links (one per line); the shared scheme-doc
|
||||
desc "…links — one per line"; "Line"; "At least one proxy link must be
|
||||
specified."). RU filled in SOURCE locales/netshift.ru.po then distribute →
|
||||
po/ru + po/templates byte-identical (diff -q). msgid count 352→359 purely
|
||||
additive. Ran `node {extract-calls,generate-pot,generate-po ru,distribute}.js`
|
||||
(generate-pot needs git user.name set). 1 empty msgstr remains = header only.
|
||||
- PRIVACY: all link strings synthetic (`127.0.0.1` hosts + scheme-doc literals);
|
||||
no real proxy/subscription data anywhere.
|
||||
- GATES GREEN: prettier --write src (all unchanged → no format churn beyond my
|
||||
files), eslint --max-warnings=0, vitest 485 pass, tsup build. yarn classic
|
||||
1.22.22 → ran via node_modules/.bin; yarn.lock unchanged, no .yarn/.yarnrc.yml.
|
||||
- FLAG (no browser in env): the rendered Connection/Subscription tabs (2 new
|
||||
dropdown choices, the 2 textareas appearing only for their type, the urltest
|
||||
tuning fields now appearing for urltest_text) need a HUMAN VISUAL CHECK —
|
||||
verified structurally only (taboption completeness, depends grep).
|
||||
116
docs/agent-rules/memory/packaging-ci-engineer.md
Normal file
116
docs/agent-rules/memory/packaging-ci-engineer.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Memory — packaging-ci-engineer
|
||||
|
||||
Durable packaging / CI / release knowledge. Read before working; append
|
||||
findings; keep under ~200 lines.
|
||||
|
||||
## Packages
|
||||
|
||||
- `netshift` (backend) and `luci-app-netshift` (UI; also yields
|
||||
`luci-i18n-netshift-ru` via `LUCI_LANGUAGES=en ru`). Both `PKGARCH=all`.
|
||||
- `netshift/Makefile`: DEPENDS `+sing-box +curl +jq +kmod-nft-tproxy
|
||||
+coreutils-base64 +bind-dig`; CONFLICTS `https-dns-proxy nextdns
|
||||
luci-app-passwall luci-app-passwall2`; version
|
||||
`PKG_VERSION = $(if $(NETSHIFT_VERSION),$(NETSHIFT_VERSION),0.$(date +%d%m%Y))`;
|
||||
`prerm` removes `105 netshift` from `/etc/iproute2/rt_tables` and stops the
|
||||
service; conffile `/etc/config/netshift`; stamps
|
||||
`__COMPILED_VERSION_VARIABLE__` into `constants.sh` via sed (NO `|| true`, so a
|
||||
missing file fails the build).
|
||||
- `luci-app-netshift/Makefile`: uses `luci.mk`, `LUCI_DEPENDS=+luci-base
|
||||
+netshift`; stamps the same placeholder into `main.js` (WITH `|| true`, so a
|
||||
missing main.js silently won't stamp). Asymmetric on purpose — note it.
|
||||
|
||||
## Docker build images
|
||||
|
||||
- `Dockerfile-ipk` FROM `itdoginfo/openwrt-sdk-ipk:24.10.6`;
|
||||
`Dockerfile-apk` FROM `itdoginfo/openwrt-sdk-apk:25.12.3`.
|
||||
- KNOWN INCONSISTENCY (intentional, do NOT "fix" blindly): ipk Dockerfile
|
||||
exports `NETSHIFT_VERSION="v${NETSHIFT_VERSION}"` (adds a `v`); apk sets it
|
||||
raw (no `v`). Embedded version vs artifact filenames can differ across types.
|
||||
- `sdk/Dockerfile-sdk-*` are the base SDK images (feeds update + luci-base);
|
||||
apk SDK requires running `./setup.sh` first.
|
||||
|
||||
## Release flow (build.yml, on tag push)
|
||||
|
||||
smoke-tests gate -> `preparation` derives version (`git describe --tags
|
||||
--exact-match`, fallback `0.<date>`) -> matrix build ipk+apk -> `docker cp`
|
||||
artifacts out of the container -> **ipk underscore->dash rename**
|
||||
(`sed 's/_/-/g'`) -> filter to the 3 packages -> GitHub Release.
|
||||
|
||||
- The underscore->dash rename is LOAD-BEARING: `install.sh` scrapes the
|
||||
latest-release API and matches assets by package-name prefix
|
||||
(`netshift*`, `luci-app-netshift*`, `luci-i18n-netshift-ru*`). Breaking the
|
||||
rename breaks install.
|
||||
|
||||
## Smoke tests (tests/)
|
||||
|
||||
- Image = OpenWRT 24.10.6 rootfs; source is **bind-mounted at runtime**
|
||||
(`../netshift/files -> /netshift/files:ro`), so editing `netshift/files` is
|
||||
picked up without rebuilding the image.
|
||||
- Needs `NET_ADMIN`/`NET_RAW`/`SYS_ADMIN` + `network_mode: host` for nft/dns;
|
||||
nft tests FAIL (not skip) without caps.
|
||||
- `all` runs: deps syntax config helpers jq cm sb nft diagnostics subscription.
|
||||
- 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
|
||||
|
||||
- `frontend-ci.yml`: PRs touching `fe-app-netshift/**` -> yarn install /
|
||||
format(diff) / lint(--max-warnings=0) / test / build(diff).
|
||||
- `shellcheck.yml`: `install.sh` + `usr/bin/netshift` + `usr/lib/**.sh`,
|
||||
severity error (Differential ShellCheck).
|
||||
- `openwrt-smoke-tests.yml`: `netshift/**`, `luci-app-netshift/**`, `tests/**`,
|
||||
`install.sh`, Dockerfiles -> entrypoint `all`.
|
||||
- `.gitlab-ci.yml` declares a `test` stage but runs no tests — only builds and
|
||||
deploys Docker on master. Quality is enforced by the AI review workflow + the
|
||||
GitHub Actions above, not GitLab.
|
||||
|
||||
## install.sh
|
||||
|
||||
- POSIX; apk/opkg abstraction; podkop->netshift migration STOPS the old service
|
||||
first (that restores dnsmasq keys + removes PodkopTable + `105 podkop`),
|
||||
backs up config to `/etc/config/podkop.bak.pre-netshift`. OpenWRT 23.05
|
||||
unsupported; needs >=15 MB on `/overlay`; NO uninstall path (removal lives in
|
||||
package `prerm`). GitHub API rate-limit is a known fragility (wget path has no
|
||||
guard).
|
||||
- `pkg_install` opkg branch uses `opkg install --force-downgrade
|
||||
--force-reinstall "$pkg_file"` (task-042). Plain `opkg install` silently
|
||||
no-op'd (rc=0) when re-run on a router with an older build: the legacy
|
||||
v-prefixed version (`v0.8.6-r1`) sorts ABOVE the no-v release (`0.8.7-r1`) so
|
||||
opkg "won't downgrade", and equal versions report "up to date". Both force
|
||||
flags make opkg remove+reinstall (proven on OWRT 24.10.5 aarch64). apk branch
|
||||
unchanged — `apk add --allow-untrusted` overwrites by default. This is the
|
||||
install.sh twin of the task-041 `updates_pkg_install_file` updater.sh fix;
|
||||
keep both upgrade paths (README script + in-app self-update) aligned.
|
||||
1731
docs/agent-rules/memory/shell-backend-developer.md
Normal file
1731
docs/agent-rules/memory/shell-backend-developer.md
Normal file
File diff suppressed because it is too large
Load Diff
204
docs/agent-rules/packaging.md
Normal file
204
docs/agent-rules/packaging.md
Normal file
@ -0,0 +1,204 @@
|
||||
# Agent Rules: Packaging, CI & Release
|
||||
|
||||
Authoritative rules for building, testing, and releasing NetShift. Read this
|
||||
before touching anything below.
|
||||
|
||||
**Scope:** `netshift/Makefile`, `luci-app-netshift/Makefile`,
|
||||
`Dockerfile-ipk`, `Dockerfile-apk`, `sdk/`, `tests/`, `.github/workflows/`,
|
||||
`install.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The packages
|
||||
|
||||
Two source packages produce three published artifacts:
|
||||
|
||||
- **`netshift`** — the backend (init script, UCI config, `/usr/bin/netshift`,
|
||||
shell + jq libs under `/usr/lib/netshift`).
|
||||
- **`luci-app-netshift`** — the web UI. Its Makefile sets
|
||||
`LUCI_LANGUAGES := en ru`, so the build also emits
|
||||
**`luci-i18n-netshift-ru`** (the Russian translation) as a third package.
|
||||
|
||||
Both packages are `PKGARCH := all` / `LUCI_PKGARCH := all` (architecture-
|
||||
independent).
|
||||
|
||||
---
|
||||
|
||||
## 2. `netshift/Makefile` (backend)
|
||||
|
||||
- `DEPENDS := +sing-box +curl +jq +kmod-nft-tproxy +coreutils-base64
|
||||
+bind-dig`
|
||||
- `CONFLICTS := https-dns-proxy nextdns luci-app-passwall luci-app-passwall2`
|
||||
- Version:
|
||||
`PKG_VERSION := $(if $(NETSHIFT_VERSION),$(NETSHIFT_VERSION),0.$(shell date +%d%m%Y))`
|
||||
— i.e. use `NETSHIFT_VERSION` when set, otherwise a date-stamped fallback.
|
||||
- `Package/netshift/prerm` removes the `105 netshift` line from
|
||||
`/etc/iproute2/rt_tables` (only if present) and runs
|
||||
`/etc/init.d/netshift stop`. **Service/system-state teardown lives in the
|
||||
package `prerm`, not in `install.sh`.**
|
||||
- `Package/netshift/conffiles` declares `/etc/config/netshift` (preserved
|
||||
across upgrades).
|
||||
- Version stamp: `Package/netshift/install` runs
|
||||
`sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PKG_VERSION)/g'
|
||||
$(1)/usr/lib/netshift/constants.sh` — **no `|| true`** (this stamp must
|
||||
succeed).
|
||||
|
||||
`luci-app-netshift/Makefile` stamps the **same** placeholder into the bundled
|
||||
UI: `sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PKG_VERSION)/g'
|
||||
.../view/netshift/main.js || true` — note the **`|| true`** here (UI stamp is
|
||||
best-effort). It uses the same `PKG_VERSION` expression and
|
||||
`LUCI_DEPENDS := +luci-base +netshift`.
|
||||
|
||||
> If you ever rename `__COMPILED_VERSION_VARIABLE__`, update **both** Makefile
|
||||
> `sed`s and `fe-app-netshift/src/constants.ts` together. See
|
||||
> `frontend-luci.md` §6.
|
||||
|
||||
---
|
||||
|
||||
## 3. Docker build images
|
||||
|
||||
- `Dockerfile-ipk` — `FROM itdoginfo/openwrt-sdk-ipk:24.10.6`.
|
||||
- `Dockerfile-apk` — `FROM itdoginfo/openwrt-sdk-apk:25.12.3`.
|
||||
- Both copy `./netshift` → `feeds/utilities/netshift` and
|
||||
`./luci-app-netshift` → `feeds/luci/luci-app-netshift`, then run
|
||||
`make defconfig` + `make package/<pkg>/compile`.
|
||||
- `sdk/Dockerfile-sdk-ipk` (`FROM openwrt/sdk:x86_64-v24.10.6`) and
|
||||
`sdk/Dockerfile-sdk-apk` (`FROM openwrt/sdk:x86_64-v25.12.3`) are the **base
|
||||
SDK images** that the `itdoginfo/openwrt-sdk-*` images derive from (feeds
|
||||
updated, `luci-base` installed, feed dirs created; the apk one also runs
|
||||
`./setup.sh`).
|
||||
|
||||
### Version passing — symmetric, both RAW (no `v` prefix)
|
||||
|
||||
Both release Dockerfiles now pass the version **raw**, with no `v` prefix:
|
||||
|
||||
- `Dockerfile-ipk`: `ENV NETSHIFT_VERSION=${NETSHIFT_VERSION}` — **raw, no
|
||||
`v`**.
|
||||
- `Dockerfile-apk`: `ENV NETSHIFT_VERSION=${NETSHIFT_VERSION}` — **raw, no
|
||||
`v`**.
|
||||
|
||||
> Historical note (task-028): `Dockerfile-ipk` used to **prepend `v`**
|
||||
> (`RUN export NETSHIFT_VERSION="v${NETSHIFT_VERSION}" && ...`) while the apk
|
||||
> file passed it raw. That asymmetry stamped a leading `v` into the ipk
|
||||
> package/control version and the runtime `constants.sh` `NETSHIFT_VERSION`,
|
||||
> so on OWRT24/ipk the installed version (`v0.8.6`) never matched the no-`v`
|
||||
> GitHub tag (`0.8.6`) and the LuCI UI falsely reported "outdated". The `v`
|
||||
> prepend was removed (ipk normalized to the apk shape) after verifying the
|
||||
> whole release flow (§4) and `install.sh` matching (§6): the `_`→`-` rename,
|
||||
> the 3-package filter, the i18n `-${VERSION}` naming, and the release tag all
|
||||
> derive from the git tag, and `install.sh` matches assets by **name prefix**
|
||||
> (the `v` lived in the version segment, not the name prefix) — so dropping it
|
||||
> does not affect either. Keep both Dockerfiles passing the version raw.
|
||||
>
|
||||
> Residual fragility (out of scope, on record): the UI version-equality check
|
||||
> in `fe-app-netshift` does **not** normalize a leading `v`. If a future
|
||||
> release is tagged **with** a `v` (e.g. `v0.8.7`), `netshift_latest_version`
|
||||
> would carry `v` while the installed (no-`v`) version would not, re-triggering
|
||||
> the false-"outdated" mismatch. **Tag releases WITHOUT a `v`** to keep
|
||||
> ipk / apk / tag all consistent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Release flow (`.github/workflows/build.yml`)
|
||||
|
||||
Triggered on **tag push** (`tags: ['*']`). Jobs:
|
||||
|
||||
1. **`smoke-tests`** — builds and runs the OpenWRT rootfs smoke suite
|
||||
(`docker compose -f tests/docker-compose.yml run --rm netshift-test all`).
|
||||
This is a **gate**: `build` `needs` it.
|
||||
2. **`preparation`** — derives the version:
|
||||
`git describe --tags --exact-match || "0.$(date +%d%m%Y)"`.
|
||||
3. **`build`** (matrix `ipk` + `apk`) — builds via
|
||||
`Dockerfile-<type>`, passing `NETSHIFT_VERSION` from `preparation`; then
|
||||
`docker create` + `docker cp` the built packages out of
|
||||
`/builder/bin/packages/x86_64/{utilities,luci}/`.
|
||||
4. **ipk-only rename** — for `ipk`, every `*.ipk` filename is rewritten with
|
||||
`sed 's/_/-/g'` (underscore → dash).
|
||||
5. **Filter** — copies exactly the **three** packages into `filtered-bin/`:
|
||||
`luci-i18n-netshift-ru-*`, `netshift-*`, `luci-app-netshift-*` (the i18n
|
||||
one is renamed to carry `${VERSION}`).
|
||||
6. **`release`** — downloads both matrices' artifacts and publishes a
|
||||
**GitHub Release** (`softprops/action-gh-release`) named/tagged
|
||||
`github.ref_name`.
|
||||
|
||||
The underscore→dash rename (step 4) is **load-bearing**: `install.sh` matches
|
||||
release assets by **package-name prefix** (see §6), and the dashed names are
|
||||
what it expects.
|
||||
|
||||
---
|
||||
|
||||
## 5. Smoke tests (`tests/`)
|
||||
|
||||
- Runs in an **OpenWRT 24.10.6 rootfs** container (`tests/Dockerfile`: pulls
|
||||
the official `openwrt-24.10.6-x86-64-rootfs.tar.gz`, `opkg install`s
|
||||
`sing-box curl jq coreutils-base64 bind-dig nftables`).
|
||||
- Source is **bind-mounted read-only**: `../netshift/files` →
|
||||
`/netshift/files:ro` (see `tests/docker-compose.yml`). The container has no
|
||||
copy of the scripts — it tests the live source tree.
|
||||
- Requires kernel caps **`NET_ADMIN` + `NET_RAW` + `SYS_ADMIN`** and
|
||||
`network_mode: host` (for real nft / DNS operations).
|
||||
- `entrypoint.sh` `main()` dispatches by category. The `all` target runs, in
|
||||
order: `test_deps test_syntax test_config test_helpers test_jq_helpers
|
||||
test_config_manager test_sing_box_config test_nft test_diagnostics
|
||||
test_subscription`. The usage line lists:
|
||||
`all deps syntax config helpers jq cm sb nft diagnostics subscription`.
|
||||
|
||||
### How to ADD a smoke test
|
||||
|
||||
1. Write a `test_xyz()` function using the existing helpers:
|
||||
`header`, `pass`, `fail`, `skip` (they drive the `PASS`/`FAIL`/`SKIP`
|
||||
counters and `summary`). For sub-shells, emit `name:OK` / `name:FAIL` /
|
||||
`name:SKIP` lines and let the `case` parser pick them up (see
|
||||
`test_helpers` / `test_subscription`).
|
||||
2. Add it to the `all)` list in `main()`.
|
||||
3. Add a short **case alias** (e.g. `xyz) test_xyz ;;`).
|
||||
4. Update the **usage line** in `main()` (the `Available: ...` echo).
|
||||
5. Update the **`docker-compose.yml` comment** that documents test names.
|
||||
|
||||
---
|
||||
|
||||
## 6. CI gates by path
|
||||
|
||||
| Workflow | Triggers (paths) | What it does |
|
||||
|---|---|---|
|
||||
| `frontend-ci.yml` | `fe-app-netshift/**` (PR) | `yarn install --frozen-lockfile`, `yarn format` (fail on diff), `yarn lint --max-warnings=0`, `yarn test --run`, `yarn build` (fail on diff). See `frontend-luci.md`. |
|
||||
| `shellcheck.yml` | `install.sh`, `netshift/files/usr/bin/**`, `netshift/files/usr/lib/**` (push/PR to `main`/`rc/**`) | Differential ShellCheck, `severity: error`, include-paths `netshift/files/usr/bin/netshift`, `netshift/files/usr/lib/**.sh`, `install.sh`. |
|
||||
| `openwrt-smoke-tests.yml` | `netshift/**`, `luci-app-netshift/**`, `tests/**`, `install.sh`, `Dockerfile-ipk`, `Dockerfile-apk`, `.dockerignore` (push/PR to `main`/`rc/**`) | Builds the smoke image and runs `netshift-test all`. |
|
||||
|
||||
> **Keep the two smoke invocations in sync.** `build.yml`'s `smoke-tests` job
|
||||
> runs the suite **only on tag push**; PR/branch coverage comes from the
|
||||
> separate `openwrt-smoke-tests.yml`. Both call
|
||||
> `docker compose -f tests/docker-compose.yml ... netshift-test all` — if you
|
||||
> change one compose command (image name, target, flags), change the other.
|
||||
|
||||
---
|
||||
|
||||
## 7. `install.sh`
|
||||
|
||||
- **POSIX `sh`** (BusyBox `ash` compatible); shellcheck-gated at `error`.
|
||||
- **Package-manager abstraction:** detects `apk` (`PKG_IS_APK=1`) vs `opkg`
|
||||
and wraps install/remove/update/list (`pkg_install`, `pkg_remove`,
|
||||
`pkg_is_installed`, etc.). apk install uses `--allow-untrusted`; opkg remove
|
||||
uses `--force-depends`.
|
||||
- **podkop → netshift migration** (`migrate_from_podkop`, triggered by
|
||||
`podkop_is_installed` since podkop never reached 0.8.0): **stop the old
|
||||
service first** (`/etc/init.d/podkop stop` then `disable`) so dnsmasq/nft
|
||||
teardown happens, **back up config** to
|
||||
`/etc/config/podkop.bak.pre-netshift`, copy config to
|
||||
`/etc/config/netshift`, remove the original `/etc/config/podkop`, clean the
|
||||
old `105 podkop` rt_tables line and podkop cron entries, and remove the old
|
||||
`luci-i18n-podkop*` / `luci-app-podkop` / `podkop` packages.
|
||||
- **OpenWRT 23.05 is unsupported** (since NetShift 0.8.0): `check_system`
|
||||
exits if `DISTRIB_RELEASE` major == `23`.
|
||||
- **Space requirement:** needs **≥ 15 MB** free in `/overlay`
|
||||
(`REQUIRED_SPACE=15360` KB).
|
||||
- **Asset matching:** scrapes the latest-release API
|
||||
(`https://api.github.com/repos/yandexru45/netshift/releases/latest`),
|
||||
greps `.apk`/`.ipk` URLs, then installs by **package-name prefix**
|
||||
(loops `for pkg in netshift luci-app-netshift`, plus
|
||||
`luci-i18n-netshift-ru*`). This is why the ipk underscore→dash rename in
|
||||
`build.yml` (§4) is load-bearing.
|
||||
- **NO uninstall path.** `install.sh` only installs/migrates; removal lives in
|
||||
the package `prerm` (see §2). Do not add an uninstaller here.
|
||||
- **Known fragility:** GitHub API **rate limiting** — the script detects
|
||||
`API rate limit` and exits with a "repeat in five minutes" message.
|
||||
112
docs/agent-rules/project-core.md
Normal file
112
docs/agent-rules/project-core.md
Normal file
@ -0,0 +1,112 @@
|
||||
# NetShift — Project Core Rules (AUTHORITATIVE)
|
||||
|
||||
> Single source of truth for AI agents working anywhere in this repo. Read this before touching code. Every rule below is grounded in the actual source — do not invent values.
|
||||
|
||||
## 1. Project identity
|
||||
|
||||
NetShift is an OpenWRT traffic router built on top of [sing-box](https://github.com/SagerNet/sing-box): it selectively routes chosen domains/subnets through a tunnel and sends everything else directly. It is a fork of [itdoginfo/podkop](https://github.com/itdoginfo/podkop), rebranded to NetShift at version `0.8.0`. The project is **beta** (expect breaking changes). License: **GPL-2.0-or-later** (`LICENSE`), with a **separate trademark policy** — the NetShift name and logos are protected; see `TRADEMARK.md`. Code is GPL-licensed; the brand is not.
|
||||
|
||||
Hard requirements (target device):
|
||||
- OpenWRT **24.10+**
|
||||
- `sing-box >= 1.12.0` (`SB_REQUIRED_VERSION` in `constants.sh`)
|
||||
- `jq >= 1.7.1` (`JQ_REQUIRED_VERSION`)
|
||||
- `coreutils-base64 >= 9.7` (`COREUTILS_BASE64_REQUIRED_VERSION`)
|
||||
- `>= 25 MB` free space (16 MB flash devices unsupported)
|
||||
|
||||
## 2. The three packages and strict dependency direction
|
||||
|
||||
Layers point in ONE direction. **No layer skips another.**
|
||||
|
||||
```
|
||||
luci-app-netshift (TS/LuCI UI, hand-written views + generated main.js)
|
||||
│ consumes the generated main.js produced from
|
||||
▼
|
||||
fe-app-netshift (TypeScript source, built with tsup)
|
||||
│ UI talks ONLY to the backend, never to sing-box/nft/dnsmasq directly
|
||||
▼
|
||||
netshift backend via LuCI fs.exec of /usr/bin/netshift and /etc/init.d/netshift (ACL-gated)
|
||||
│
|
||||
▼
|
||||
sing-box / nftables / dnsmasq
|
||||
```
|
||||
|
||||
- `luci-app-netshift` — LuCI web UI. Its `htdocs/.../view/netshift/main.js` is **generated** from `fe-app-netshift`. Hand-written views live alongside it.
|
||||
- `fe-app-netshift` — the TypeScript source of `main.js` (fetchers, methods, services, tabs). Edit UI logic **here**, not in the generated bundle.
|
||||
- `netshift` — the backend package (POSIX ash + jq): CLI dispatcher `/usr/bin/netshift`, procd init `/etc/init.d/netshift`, libraries in `/usr/lib/netshift/`, UCI config `/etc/config/netshift`.
|
||||
|
||||
The UI never reimplements backend logic; it invokes backend commands. The backend never depends on the UI.
|
||||
|
||||
## 3. Runtime contract (sacred — do not change casually)
|
||||
|
||||
These values are wired across `constants.sh`, `nft.sh`, the CLI, and the generated sing-box config. Changing one without the rest breaks the whole chain.
|
||||
|
||||
| Concept | Value | Source constant |
|
||||
|---|---|---|
|
||||
| tproxy inbound | `127.0.0.1:1602` | `SB_TPROXY_INBOUND_ADDRESS` / `SB_TPROXY_INBOUND_PORT` |
|
||||
| DNS inbound | `127.0.0.42:53` | `SB_DNS_INBOUND_ADDRESS` / `SB_DNS_INBOUND_PORT` |
|
||||
| Service mixed inbound | `127.0.0.1:4534` | `SB_SERVICE_MIXED_INBOUND_*` |
|
||||
| Clash API controller | `:9090` | `SB_CLASH_API_CONTROLLER_PORT` |
|
||||
| FakeIP range | `198.18.0.0/15` | `SB_FAKEIP_INET4_RANGE` |
|
||||
| nft FakeIP mark | `0x00100000` | `NFT_FAKEIP_MARK` |
|
||||
| nft outbound mark | `0x00200000` | `NFT_OUTBOUND_MARK` |
|
||||
| nft table | `NetShiftTable` (family `inet`) | `NFT_TABLE_NAME` |
|
||||
| routing table | `105 netshift` | `RT_TABLE_NAME` + `/etc/iproute2/rt_tables` |
|
||||
| state dir | `/etc/netshift` | `NETSHIFT_STATE_DIR` |
|
||||
| sing-box config | `/etc/sing-box/config.json` (UCI `settings.config_path`) | UCI |
|
||||
|
||||
These ports, marks, addresses, the nft table name, and the routing table id are **sacred**. They are referenced in `nft.sh` rules, `route_table_rule_mark`, dnsmasq integration (`127.0.0.42`), diagnostics (`check_sing_box`, `check_nft_rules`, `check_dns_available`), and the generated config. Treat any change to them as a system-level change (§4).
|
||||
|
||||
## 4. System-level change rule
|
||||
|
||||
A change is **system-level** if it touches any of:
|
||||
- nft rules / sets / chains, routing rules or tables, fwmarks
|
||||
- sing-box config schema or generation
|
||||
- dnsmasq integration (server `127.0.0.42`, `noresolv`, `cachesize`, backup/restore)
|
||||
- UCI schema (`/etc/config/netshift`)
|
||||
- ports / marks / tags in `constants.sh`
|
||||
- packaging (`Makefile`, install, conffiles, dependencies)
|
||||
- the payment-free subscription flow (download → validate → cache → generate outbounds)
|
||||
|
||||
For system-level changes you MUST verify the **whole chain**, not a single file:
|
||||
|
||||
```
|
||||
UCI (config_get) → config generation (sing_box_*) → sing-box -c <file> check → nft rules → running service
|
||||
```
|
||||
|
||||
Validate by running the smoke tests and, where relevant, confirming the generated config still passes `sing-box check` and the nft table/routing still install (see `start_main` in `/usr/bin/netshift`).
|
||||
|
||||
## 5. Repo-wide conventions
|
||||
|
||||
- **LF line endings everywhere.** `.gitattributes` enforces `* text=auto eol=lf`. Never introduce CRLF.
|
||||
- **No magic strings.** All ports, IPs, marks, tags, paths, versions, and service lists live in `netshift/files/usr/lib/constants.sh`, grouped as `## Common`, `## nft`, `## sing-box`, `## Lists`. New constants go there.
|
||||
- Community service list is `COMMUNITY_SERVICES` in `constants.sh`; the UI and `validate_service` both depend on it.
|
||||
|
||||
## 6. Mandatory quality gates (CI a contributor must pass)
|
||||
|
||||
These gate every PR. Use the matching skills.
|
||||
|
||||
1. **ShellCheck** (`.github/workflows/shellcheck.yml`) — severity `error`, over:
|
||||
- `install.sh`
|
||||
- `netshift/files/usr/bin/netshift`
|
||||
- `netshift/files/usr/lib/**.sh`
|
||||
- Skill: `shellcheck`.
|
||||
2. **OpenWRT smoke tests** (`.github/workflows/openwrt-smoke-tests.yml`) — runs `tests/entrypoint.sh` in an OpenWRT rootfs via `tests/docker-compose.yml` (`run --rm netshift-test all`).
|
||||
- Skill: `smoke-tests`.
|
||||
3. **Frontend `yarn ci`** (in `fe-app-netshift`) — defined as:
|
||||
`yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build`
|
||||
i.e. prettier (no diff), ESLint with `--max-warnings=0`, vitest, and a build that must produce no diff in the committed `main.js`.
|
||||
- Skill: `frontend-ci`.
|
||||
|
||||
## 7. Contribution gating
|
||||
|
||||
- `CODEOWNERS = @yandexru45`.
|
||||
- PRs are accepted **only after coordination with the authors via Telegram** (per README; see `t.me/netshift_chat`).
|
||||
- **Agents NEVER auto-commit.** A human reviews and commits manually. Do not run `git commit`, `git push`, amend, or open PRs unless the human explicitly asks.
|
||||
|
||||
## 8. Anti-patterns (do NOT do these)
|
||||
|
||||
- Hardcoding ports / IPs / marks / paths instead of using `constants.sh`.
|
||||
- Duplicating routing/marking logic instead of reusing `nft.sh` / `route_table_rule_mark` / `create_nft_rules`.
|
||||
- Editing the generated `main.js` by hand — edit `fe-app-netshift` TS source and rebuild.
|
||||
- Reimplementing backend logic in the UI — the UI must call `/usr/bin/netshift` / `/etc/init.d/netshift`.
|
||||
- Changing a sacred runtime value in one place while leaving the rest of the chain stale.
|
||||
BIN
docs/screenshot.png
Normal file
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
55
docs/tasks/TEMPLATE-review.md
Normal file
55
docs/tasks/TEMPLATE-review.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Code Review — <Title> (task-NNN)
|
||||
|
||||
> Authored by `code-reviewer`. File name: `<task-name>-review-001.md`
|
||||
> (use `-002`, `-003`, ... or append a "Re-review" section for later rounds).
|
||||
|
||||
**Review ID:** review-001
|
||||
**Date:** <YYYY-MM-DD>
|
||||
**Scope:** <uncommitted working-tree changes | branch X vs base>
|
||||
**Reviewer:** code-reviewer agent
|
||||
|
||||
**Files reviewed:**
|
||||
- `path/...`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
<Brief, neutral assessment of what the change does and its overall quality. No
|
||||
flattery.>
|
||||
|
||||
## Critical Issues
|
||||
|
||||
> Must fix before merge. Architecture violations, broken runtime contract, jq
|
||||
> regex on OpenWRT, missing `exit 1` after `fatal`, hand-edited `main.js`, traffic
|
||||
> leaks, etc.
|
||||
|
||||
### [C1] <Issue title>
|
||||
- **File:** `path/to/file` (line X)
|
||||
- **Problem:** <what is wrong and why it matters>
|
||||
- **Recommendation:** <concrete fix>
|
||||
|
||||
## Significant Issues
|
||||
|
||||
### [S1] <Issue title>
|
||||
- **File:** `path/to/file` (line X)
|
||||
- **Problem:** ...
|
||||
- **Recommendation:** ...
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- **[M1]** `path/to/file`: <short note>
|
||||
|
||||
## Test Coverage
|
||||
|
||||
<Were the required tests/gates added and run? Backend config-gen/subscription →
|
||||
smoke test? New pure frontend logic → vitest? Was the relevant gate green? If
|
||||
frontend tests were explicitly not required, say so and state what was verified
|
||||
by reasoning + build.>
|
||||
|
||||
## Verdict
|
||||
|
||||
**APPROVED** | **APPROVED WITH CONDITIONS** | **REQUIRES CHANGES**
|
||||
|
||||
<One sentence overall. If conditional, list "Conditions before merge" by issue
|
||||
ID. If changes required, list "Required before merge" by issue ID.>
|
||||
62
docs/tasks/TEMPLATE-task.md
Normal file
62
docs/tasks/TEMPLATE-task.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Task: <imperative title>
|
||||
|
||||
> Authored by `architect-orchestrator`. One self-contained spec per subtask.
|
||||
> File name: `task-NNN-<kebab-slug>.md`.
|
||||
|
||||
## Context
|
||||
|
||||
<Narrative: what the user wants and why. Link any relevant prior tasks.>
|
||||
|
||||
### Root cause / research basis (authoritative)
|
||||
|
||||
<What investigation established. Cite `file:line`. State facts, not guesses.>
|
||||
|
||||
### Operator decisions (already made — do NOT re-ask)
|
||||
|
||||
- <Design choice the operator already approved, e.g. "Variant 2".>
|
||||
|
||||
## Goal
|
||||
|
||||
<One paragraph describing the desired end state.>
|
||||
|
||||
## Scope
|
||||
|
||||
- Layer(s): <backend ash/jq | TS/LuCI frontend | packaging/CI>.
|
||||
- Files to modify (exact paths):
|
||||
- `path/one`
|
||||
- `path/two`
|
||||
- Do NOT touch: <explicit out-of-scope files/areas>.
|
||||
|
||||
## Requirements
|
||||
|
||||
### 1. <numbered requirement>
|
||||
|
||||
<Be specific. Include exact target `file:line` and fenced code blocks of the
|
||||
intended change where helpful.>
|
||||
|
||||
### 2. <numbered requirement>
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- Applicable rules: <e.g. `docs/agent-rules/backend-shell.md` (no jq regex;
|
||||
`fatal` needs `exit 1`)>.
|
||||
- Runtime-contract impact: <none | which ports/marks/paths and the whole-chain
|
||||
verification required>.
|
||||
- Single-source-of-truth constraints: <constants.sh; barrel→main.*; etc.>.
|
||||
|
||||
## Tests Required
|
||||
|
||||
- Backend: <which `test_*` to add/extend in `tests/entrypoint.sh`, or "covered
|
||||
by existing X">. Run the `shellcheck` and `smoke-tests` skills.
|
||||
- Frontend: <vitest `.test.js` to add, or "verify by reasoning + build">. Run
|
||||
the `frontend-ci` skill; ensure `main.js` rebuild leaves no git diff.
|
||||
- Packaging: <smoke tests; verify ipk + apk paths>.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] All requirements implemented in scope; nothing out-of-scope changed.
|
||||
- [ ] Relevant gate(s) pass (shellcheck / smoke-tests / yarn ci).
|
||||
- [ ] (Frontend) `main.js` regenerated; `git diff` clean after build.
|
||||
- [ ] Runtime contract intact (or whole chain verified if changed).
|
||||
- [ ] New user-facing strings wrapped in `_()` (frontend).
|
||||
- [ ] `code-reviewer` verdict: APPROVED or APPROVED WITH CONDITIONS.
|
||||
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-02 17:15+0300\n"
|
||||
"PO-Revision-Date: 2026-06-02 17:15+0300\n"
|
||||
"POT-Creation-Date: 2026-06-13 14:14+0300\n"
|
||||
"PO-Revision-Date: 2026-06-13 14:14+0300\n"
|
||||
"Last-Translator: yandexru45\n"
|
||||
"Language-Team: none\n"
|
||||
"Language: ru\n"
|
||||
@ -29,33 +29,54 @@ msgstr "✘ Отключено"
|
||||
msgid "✘ Stopped"
|
||||
msgstr "✘ Остановлен"
|
||||
|
||||
msgid "Группировать по странам"
|
||||
msgstr ""
|
||||
|
||||
msgid "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
|
||||
msgstr ""
|
||||
|
||||
msgid "Active Connections"
|
||||
msgstr "Активные соединения"
|
||||
|
||||
msgid "Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged."
|
||||
msgstr "Добавьте один или несколько URL подписок для получения конфигураций прокси. Все источники загружаются и объединяются."
|
||||
|
||||
msgid "Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
|
||||
msgstr "Добавьте свои домены: выберите Динамический список (по одному в строке) или Текстовый список (свободный ввод), либо Отключено, чтобы пропустить"
|
||||
|
||||
msgid "Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
|
||||
msgstr "Добавьте свои подсети или IP: выберите Динамический список (по одному в строке) или Текстовый список (свободный ввод), либо Отключено, чтобы пропустить"
|
||||
|
||||
msgid "Additional marking rules found"
|
||||
msgstr "Найдены дополнительные правила маркировки"
|
||||
|
||||
msgid "Advanced"
|
||||
msgstr "Дополнительно"
|
||||
|
||||
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. Убедитесь, что в брандмауэре открыт соответствующий порт."
|
||||
|
||||
msgid "Applicable for SOCKS and Shadowsocks proxy"
|
||||
msgstr "Применимо для SOCKS и Shadowsocks прокси"
|
||||
|
||||
msgid "At least one proxy link must be specified."
|
||||
msgstr "Необходимо указать хотя бы одну прокси-ссылку."
|
||||
|
||||
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
|
||||
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
|
||||
|
||||
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
|
||||
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Available actions"
|
||||
msgstr "Доступные действия"
|
||||
|
||||
msgid "Block direct connections to known public DoH servers (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex) so apps cannot bypass router DNS filtering."
|
||||
msgstr "Блокировать прямые подключения к известным публичным DoH-серверам (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex), чтобы приложения не могли обойти DNS-фильтрацию роутера."
|
||||
|
||||
msgid "Block DoH Servers"
|
||||
msgstr "Блокировать DoH-серверы"
|
||||
|
||||
msgid "Bootsrap DNS"
|
||||
msgstr "Bootstrap DNS"
|
||||
|
||||
@ -68,6 +89,12 @@ msgstr "Браузер не использует FakeIP"
|
||||
msgid "Browser is using FakeIP correctly"
|
||||
msgstr "Браузер использует FakeIP"
|
||||
|
||||
msgid "By country flag"
|
||||
msgstr "По флагу страны"
|
||||
|
||||
msgid "By name prefix"
|
||||
msgstr "По префиксу имени"
|
||||
|
||||
msgid "Cache File Path"
|
||||
msgstr "Путь к файлу кэша"
|
||||
|
||||
@ -77,6 +104,9 @@ msgstr "Путь к файлу кэша не может быть пустым"
|
||||
msgid "Cannot receive checks result"
|
||||
msgstr "Не удалось получить результаты проверки"
|
||||
|
||||
msgid "Check update"
|
||||
msgstr "Проверить обновление"
|
||||
|
||||
msgid "Checking, please wait"
|
||||
msgstr "Проверяем, пожалуйста подождите"
|
||||
|
||||
@ -92,33 +122,60 @@ msgstr "Проверки пройдены"
|
||||
msgid "CIDR must be between 0 and 32"
|
||||
msgstr "CIDR должен быть между 0 и 32"
|
||||
|
||||
msgid "Clear subscription cache"
|
||||
msgstr "Очистить кеш подписок"
|
||||
|
||||
msgid "Clearing subscription cache and re-downloading… this may take a minute"
|
||||
msgstr "Очистка кеша подписок и повторная загрузка… это может занять минуту"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
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 "Тип конфигурации"
|
||||
|
||||
msgid "Connection"
|
||||
msgstr "Подключение"
|
||||
|
||||
msgid "Connection Type"
|
||||
msgstr "Тип подключения"
|
||||
|
||||
msgid "Connection type, transport and DNS resolver for this section"
|
||||
msgstr "Тип подключения, транспорт и DNS-резолвер для этой секции"
|
||||
|
||||
msgid "Connection URL"
|
||||
msgstr "URL подключения"
|
||||
|
||||
msgid "Copy"
|
||||
msgstr "Копировать"
|
||||
|
||||
msgid "Core switch failed"
|
||||
msgstr "Не удалось переключить ядро"
|
||||
|
||||
msgid "Core switch timed out"
|
||||
msgstr "Истекло время ожидания переключения ядра"
|
||||
|
||||
msgid "Currently unavailable"
|
||||
msgstr "Временно недоступно"
|
||||
|
||||
msgid "Custom domains"
|
||||
msgstr "Свои домены"
|
||||
|
||||
msgid "Custom subnets"
|
||||
msgstr "Свои подсети"
|
||||
|
||||
msgid "Dashboard"
|
||||
msgstr "Дашборд"
|
||||
|
||||
@ -126,11 +183,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 сервер"
|
||||
|
||||
@ -149,9 +209,18 @@ msgstr "Отключить QUIC протокол для улучшения со
|
||||
msgid "Disabled"
|
||||
msgstr "Отключено"
|
||||
|
||||
msgid "Disables TLS certificate verification when downloading the subscription."
|
||||
msgstr "Отключает проверку TLS-сертификата при загрузке подписки."
|
||||
|
||||
msgid "DNS"
|
||||
msgstr "DNS"
|
||||
|
||||
msgid "DNS on router"
|
||||
msgstr "DNS на роутере"
|
||||
|
||||
msgid "DNS outbound section"
|
||||
msgstr "Секция outbound для DNS"
|
||||
|
||||
msgid "DNS over HTTPS (DoH)"
|
||||
msgstr "DNS через HTTPS (DoH)"
|
||||
|
||||
@ -173,6 +242,9 @@ msgstr "Адрес DNS-сервера не может быть пустым"
|
||||
msgid "Do not panic, everything can be fixed, just..."
|
||||
msgstr "Не паникуйте, всё можно исправить, просто..."
|
||||
|
||||
msgid "Domain and subnet lists that decide which traffic uses this section"
|
||||
msgstr "Списки доменов и подсетей, определяющие, какой трафик идёт через эту секцию"
|
||||
|
||||
msgid "Domain Resolver"
|
||||
msgstr "Резолвер доменов"
|
||||
|
||||
@ -194,6 +266,9 @@ msgstr "Скачивать списки через выбранную секци
|
||||
msgid "Downloading all lists via specific Proxy/VPN"
|
||||
msgstr "Загрузка всех списков через указанный прокси/VPN"
|
||||
|
||||
msgid "Drop subscription servers whose name contains any of these keywords (case-insensitive)."
|
||||
msgstr "Исключать серверы подписки, имя которых содержит любое из этих ключевых слов (без учёта регистра)."
|
||||
|
||||
msgid "Dynamic List"
|
||||
msgstr "Динамический список"
|
||||
|
||||
@ -206,6 +281,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 "Включить смешанный прокси"
|
||||
|
||||
@ -233,23 +314,20 @@ msgstr "Введите доменные имена без протоколов,
|
||||
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
|
||||
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
|
||||
|
||||
msgid "Enter the subscription URL to fetch proxy configurations from your provider"
|
||||
msgstr ""
|
||||
|
||||
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 секунд"
|
||||
@ -258,13 +336,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"
|
||||
@ -272,6 +350,12 @@ msgstr "Исключить NTP"
|
||||
msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN"
|
||||
msgstr "Исключите трафик протокола NTP из туннеля, чтобы предотвратить его маршрутизацию через прокси-сервер или VPN."
|
||||
|
||||
msgid "Exclude servers by keyword"
|
||||
msgstr "Исключать серверы по ключевому слову"
|
||||
|
||||
msgid "Failed to clear subscription cache"
|
||||
msgstr "Не удалось очистить кеш подписок"
|
||||
|
||||
msgid "Failed to copy!"
|
||||
msgstr "Не удалось скопировать!"
|
||||
|
||||
@ -290,17 +374,26 @@ msgstr "Получить глобальную проверку"
|
||||
msgid "Global check"
|
||||
msgstr "Глобальная проверка"
|
||||
|
||||
msgid "Global Proxy"
|
||||
msgstr "Глобальный прокси"
|
||||
|
||||
msgid "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters."
|
||||
msgstr "Группировать прокси из подписки в группы URLTest. «По флагу страны» использует эмодзи флага в начале каждого имени; «По префиксу имени» группирует по первым N символам."
|
||||
|
||||
msgid "How often to automatically update the subscription"
|
||||
msgstr ""
|
||||
msgstr "Как часто автоматически обновлять подписку"
|
||||
|
||||
msgid "HTTP error"
|
||||
msgstr "Ошибка HTTP"
|
||||
|
||||
msgid "Install extended"
|
||||
msgstr "Установить extended"
|
||||
msgid "Include servers by keyword"
|
||||
msgstr "Включать серверы по ключевому слову"
|
||||
|
||||
msgid "Install stable"
|
||||
msgstr "Установить stable"
|
||||
msgid "Install %s"
|
||||
msgstr "Установить %s"
|
||||
|
||||
msgid "Installed version is newer than release"
|
||||
msgstr "Установленная версия новее релиза"
|
||||
|
||||
msgid "Interface Monitoring"
|
||||
msgstr "Мониторинг интерфейса"
|
||||
@ -311,14 +404,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"
|
||||
@ -362,6 +455,9 @@ msgstr "Неверный URL Hysteria2: неподдерживаемый тип
|
||||
msgid "Invalid IP address"
|
||||
msgstr "Неверный IP-адрес"
|
||||
|
||||
msgid "Invalid IPv6 address"
|
||||
msgstr "Неверный IPv6-адрес"
|
||||
|
||||
msgid "Invalid JSON format"
|
||||
msgstr "Неверный формат JSON"
|
||||
|
||||
@ -440,18 +536,57 @@ msgstr "Неверный формат URL"
|
||||
msgid "Invalid VLESS URL: parsing failed"
|
||||
msgstr "Неверный URL VLESS: ошибка разбора"
|
||||
|
||||
msgid "Invalid VMess URL: invalid port"
|
||||
msgstr "Неверный URL VMess: недопустимый порт"
|
||||
|
||||
msgid "Invalid VMess URL: malformed base64"
|
||||
msgstr "Неверный URL VMess: некорректный base64"
|
||||
|
||||
msgid "Invalid VMess URL: malformed JSON"
|
||||
msgstr "Неверный URL VMess: некорректный JSON"
|
||||
|
||||
msgid "Invalid VMess URL: missing address"
|
||||
msgstr "Неверный URL VMess: отсутствует адрес"
|
||||
|
||||
msgid "Invalid VMess URL: missing id"
|
||||
msgstr "Неверный URL VMess: отсутствует id"
|
||||
|
||||
msgid "Invalid VMess URL: must not contain spaces"
|
||||
msgstr "Неверный URL VMess: не должен содержать пробелы"
|
||||
|
||||
msgid "Invalid VMess URL: must start with vmess://"
|
||||
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 "Обнаружены проблемы"
|
||||
|
||||
msgid "Keep only subscription servers whose name contains at least one of these keywords (case-insensitive). Leave empty to keep all."
|
||||
msgstr "Оставлять только серверы подписки, имя которых содержит хотя бы одно из этих ключевых слов (без учёта регистра). Оставьте пустым, чтобы оставить все."
|
||||
|
||||
msgid "Latest"
|
||||
msgstr "Последняя"
|
||||
|
||||
msgid "Latest version is installed"
|
||||
msgstr "Установлена последняя версия"
|
||||
|
||||
msgid "Line"
|
||||
msgstr "Строка"
|
||||
|
||||
msgid "List Update Frequency"
|
||||
msgstr "Частота обновления списков"
|
||||
|
||||
msgid "List update schedule, download routing, and routing exclusions"
|
||||
msgstr "Расписание обновления списков, маршрутизация загрузок и исключения из маршрутизации"
|
||||
|
||||
msgid "Lists & Updates"
|
||||
msgstr "Списки и обновления"
|
||||
|
||||
msgid "Local Domain Lists"
|
||||
msgstr "Локальные списки доменов"
|
||||
|
||||
@ -464,9 +599,15 @@ msgstr "Уровень логов"
|
||||
msgid "Main DNS"
|
||||
msgstr "Основной DNS"
|
||||
|
||||
msgid "Main DNS via outbound"
|
||||
msgstr "Основной DNS через outbound"
|
||||
|
||||
msgid "Memory Usage"
|
||||
msgstr "Использование памяти"
|
||||
|
||||
msgid "Mixed proxy and DNS resolution tuning"
|
||||
msgstr "Настройка смешанного прокси и разрешения DNS"
|
||||
|
||||
msgid "Mixed Proxy Port"
|
||||
msgstr "Порт смешанного прокси"
|
||||
|
||||
@ -477,13 +618,19 @@ 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"
|
||||
msgstr "Сеть"
|
||||
|
||||
msgid "Network Interface"
|
||||
msgstr "Сетевой интерфейс"
|
||||
@ -494,12 +641,24 @@ msgstr "Другие правила маркировки не найдены"
|
||||
msgid "Not implement yet"
|
||||
msgstr "Ещё не реализовано"
|
||||
|
||||
msgid "Not installed"
|
||||
msgstr "Не установлено"
|
||||
|
||||
msgid "Not responding"
|
||||
msgstr "Не отвечает"
|
||||
|
||||
msgid "Not running"
|
||||
msgstr "Не запущено"
|
||||
|
||||
msgid "Number of leading characters of each proxy name to group by."
|
||||
msgstr "Количество начальных символов имени каждого прокси для группировки."
|
||||
|
||||
msgid "Off"
|
||||
msgstr "Выключено"
|
||||
|
||||
msgid "Only one section can be global at a time."
|
||||
msgstr "Только одна секция может быть глобальной одновременно."
|
||||
|
||||
msgid "Operation timed out"
|
||||
msgstr "Время ожидания истекло"
|
||||
|
||||
@ -530,6 +689,12 @@ msgstr "Путь должен заканчиваться на cache.db"
|
||||
msgid "Pending"
|
||||
msgstr "Ожидает запуска"
|
||||
|
||||
msgid "Prefix length"
|
||||
msgstr "Длина префикса"
|
||||
|
||||
msgid "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."
|
||||
msgstr "Переключатели протоколов, пути к файлам и журналирование. Включайте блокировку DoH только после переключения вышестоящего DNS на UDP или DoT."
|
||||
|
||||
msgid "Proxy Configuration URL"
|
||||
msgstr "URL конфигурации прокси"
|
||||
|
||||
@ -552,7 +717,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"
|
||||
@ -560,6 +731,9 @@ msgstr "DNS роутера не проходит через sing-box"
|
||||
msgid "Router DNS is routed through sing-box"
|
||||
msgstr "DNS роутера проходит через sing-box"
|
||||
|
||||
msgid "Routing"
|
||||
msgstr "Маршрутизация"
|
||||
|
||||
msgid "Routing Excluded IPs"
|
||||
msgstr "Исключённые из маршрутизации IP-адреса"
|
||||
|
||||
@ -569,9 +743,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 существуют"
|
||||
|
||||
@ -623,12 +794,6 @@ msgstr "Выберите путь к файлу конфигурации sing-bo
|
||||
msgid "Select the DNS protocol type for the domain resolver"
|
||||
msgstr "Выберите тип протокола DNS для резолвера доменов"
|
||||
|
||||
msgid "Select the list type for adding custom domains"
|
||||
msgstr "Выберите тип списка для добавления пользовательских доменов"
|
||||
|
||||
msgid "Select the list type for adding custom subnets"
|
||||
msgstr "Выберите тип списка для добавления пользовательских подсетей"
|
||||
|
||||
msgid "Select the log level for sing-box"
|
||||
msgstr "Выберите уровень логов для sing-box"
|
||||
|
||||
@ -644,9 +809,21 @@ msgstr "Выберите WAN интерфейсы для мониторинга"
|
||||
msgid "Selector"
|
||||
msgstr "Selector"
|
||||
|
||||
msgid "Selector (text list)"
|
||||
msgstr "Selector (текстовый список)"
|
||||
|
||||
msgid "Selector Proxy Links"
|
||||
msgstr "Ссылки прокси для Selector"
|
||||
|
||||
msgid "Selector Proxy Links (one per line)"
|
||||
msgstr "Прокси-ссылки Selector (по одной в строке)"
|
||||
|
||||
msgid "Self-update failed"
|
||||
msgstr "Не удалось обновить"
|
||||
|
||||
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 "Информация о сервисах"
|
||||
|
||||
@ -680,6 +857,9 @@ msgstr "Сервис sing-box существует"
|
||||
msgid "Sing-box version is compatible (newer than 1.12.4)"
|
||||
msgstr "Версия Sing-box совместима (новее 1.12.4)"
|
||||
|
||||
msgid "Source and output interfaces, and Bad WAN interface monitoring"
|
||||
msgstr "Входящий и исходящий интерфейсы, а также мониторинг интерфейсов Bad WAN"
|
||||
|
||||
msgid "Source Network Interface"
|
||||
msgstr "Сетевой интерфейс источника"
|
||||
|
||||
@ -699,23 +879,44 @@ 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 cache cleared and re-downloaded"
|
||||
msgstr "Кеш подписок очищен и загружен заново"
|
||||
|
||||
msgid "Subscription feeds, server filters and URLTest tuning"
|
||||
msgstr "Источники подписок, фильтры серверов и настройка URLTest"
|
||||
|
||||
msgid "Subscription format"
|
||||
msgstr "Формат подписки"
|
||||
|
||||
msgid "Subscription grouping"
|
||||
msgstr "Группировка подписки"
|
||||
|
||||
msgid "Subscription Update Interval"
|
||||
msgstr ""
|
||||
msgstr "Интервал обновления подписки"
|
||||
|
||||
msgid "Subscription URL"
|
||||
msgstr ""
|
||||
msgid "Subscription URLs"
|
||||
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, это может занять несколько минут…"
|
||||
|
||||
msgid "System info"
|
||||
msgstr "Системная информация"
|
||||
|
||||
@ -743,6 +944,9 @@ 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 "Time in seconds for DNS record caching (default: 60)"
|
||||
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
|
||||
|
||||
@ -773,11 +977,26 @@ 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 "Исходящий"
|
||||
|
||||
msgid "URL must start with vless://, ss://, trojan://, socks4/5://, or hysteria2://hy2://"
|
||||
msgstr "URL должен начинаться с vless://, ss://, trojan://, socks4/5:// или hysteria2:// hy2://"
|
||||
msgid "Upstream and bootstrap DNS resolvers, and optional DNS-over-proxy"
|
||||
msgstr "Вышестоящий и начальный (bootstrap) DNS-резолверы и опциональный DNS через прокси"
|
||||
|
||||
msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4/5://, or hysteria2://hy2://"
|
||||
msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4/5:// или hysteria2:// hy2://"
|
||||
|
||||
msgid "URL must use one of the following protocols:"
|
||||
msgstr "URL должен использовать один из следующих протоколов:"
|
||||
@ -785,20 +1004,32 @@ msgstr "URL должен использовать один из следующи
|
||||
msgid "URLTest"
|
||||
msgstr "URLTest"
|
||||
|
||||
msgid "URLTest (text list)"
|
||||
msgstr "URLTest (текстовый список)"
|
||||
|
||||
msgid "URLTest Check Interval"
|
||||
msgstr "Интервал проверки URLTest"
|
||||
|
||||
msgid "URLTest Proxy Links"
|
||||
msgstr "Ссылки прокси для URLTest"
|
||||
|
||||
msgid "URLTest Proxy Links (one per line)"
|
||||
msgstr "Прокси-ссылки URLTest (по одной в строке)"
|
||||
|
||||
msgid "URLTest Testing URL"
|
||||
msgstr "URLTest ссылка для проверки"
|
||||
|
||||
msgid "URLTest Tolerance"
|
||||
msgstr "URLTest допустимое отклонение"
|
||||
|
||||
msgid "User Domain List Type"
|
||||
msgstr "Тип пользовательского списка доменов"
|
||||
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 Domains"
|
||||
msgstr "Пользовательские домены"
|
||||
@ -806,9 +1037,6 @@ msgstr "Пользовательские домены"
|
||||
msgid "User Domains List"
|
||||
msgstr "Список пользовательских доменов"
|
||||
|
||||
msgid "User Subnet List Type"
|
||||
msgstr "Тип пользовательского списка подсетей"
|
||||
|
||||
msgid "User Subnets"
|
||||
msgstr "Пользовательские подсети"
|
||||
|
||||
@ -821,14 +1049,20 @@ msgstr "Валидно"
|
||||
msgid "Validation errors:"
|
||||
msgstr "Ошибки валидации:"
|
||||
|
||||
msgid "Version"
|
||||
msgstr "Версия"
|
||||
|
||||
msgid "View logs"
|
||||
msgstr "Посмотреть логи"
|
||||
|
||||
msgid "Visit Wiki"
|
||||
msgstr "Перейти в wiki"
|
||||
|
||||
msgid "vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
|
||||
msgstr ""
|
||||
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
|
||||
msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2://"
|
||||
|
||||
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line"
|
||||
msgstr "Ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// — по одной в строке"
|
||||
|
||||
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
|
||||
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."
|
||||
@ -836,8 +1070,23 @@ 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 "Which subscription format (client) to fetch first. Auto uses the default order. Choose Xray JSON (Happ) when your panel only exposes some nodes (e.g. xhttp) under a Happ-like client, or Sing-box to prefer the sing-box format."
|
||||
msgstr "Какой формат подписки (клиент) запрашивать первым. «Авто» использует порядок по умолчанию. Выберите «Xray JSON (Happ)», если ваша панель отдаёт некоторые узлы (например, xhttp) только под клиентом вроде Happ, или «Sing-box», чтобы предпочесть формат sing-box."
|
||||
|
||||
msgid "Xray JSON (Happ)"
|
||||
msgstr "Xray JSON (Happ)"
|
||||
|
||||
msgid "YACD Secret Key"
|
||||
msgstr "Секретный ключ YACD"
|
||||
|
||||
msgid "YACD web dashboard access and remote-access protection"
|
||||
msgstr "Доступ к веб-панели YACD и защита удалённого доступа"
|
||||
|
||||
msgid "You can select Output Network Interface, by default autodetect"
|
||||
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export function showToast(
|
||||
message: string,
|
||||
type: 'success' | 'error',
|
||||
type: 'success' | 'error' | 'warning' | 'info',
|
||||
duration: number = 3000,
|
||||
) {
|
||||
let container = document.querySelector('.toast-container');
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { callBaseMethod } from './callBaseMethod';
|
||||
import { ClashAPI, NetShift } from '../../types';
|
||||
import { executeShellCommand } from '../../../helpers';
|
||||
|
||||
interface SingBoxComponentActionResult {
|
||||
success: boolean;
|
||||
version?: string;
|
||||
message?: string;
|
||||
}
|
||||
import {
|
||||
ComponentActionStartResponse,
|
||||
ComponentActionStatus,
|
||||
SingBoxComponentActionResult,
|
||||
parseComponentActionStatus,
|
||||
pollSingBoxComponentAction,
|
||||
} from './pollSingBoxComponentAction';
|
||||
import { parseComponentCheckUpdate } from './parseComponentCheckUpdate';
|
||||
|
||||
export const NetShiftShellMethods = {
|
||||
checkDNSAvailable: async () =>
|
||||
@ -96,6 +98,90 @@ export const NetShiftShellMethods = {
|
||||
singBoxComponentAction: async (
|
||||
action: 'install_extended' | 'install_stable' | 'check_update',
|
||||
): Promise<SingBoxComponentActionResult> => {
|
||||
// `check_update` is a quick single call — not subject to the rpcd 30s wall —
|
||||
// so keep it on the SYNCHRONOUS `component_action` path (unchanged shape).
|
||||
if (action === 'check_update') {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action', 'sing_box', action],
|
||||
timeout: 600000,
|
||||
});
|
||||
|
||||
if (response.stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
response.stdout,
|
||||
) as SingBoxComponentActionResult;
|
||||
|
||||
return {
|
||||
success: Boolean(parsed.success),
|
||||
version: parsed.version,
|
||||
message: parsed.message,
|
||||
};
|
||||
} catch (_e) {
|
||||
return {
|
||||
success: false,
|
||||
message: response.stdout,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: response.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Install actions can take minutes — drive the async backend contract:
|
||||
// start the job, then poll `component_action_status` with short execs so
|
||||
// rpcd never kills a single long-running call.
|
||||
const startResponse = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action_async', 'sing_box', action],
|
||||
});
|
||||
|
||||
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 || _('Core switch failed'),
|
||||
};
|
||||
}
|
||||
|
||||
const jobId = start.job_id;
|
||||
|
||||
return pollSingBoxComponentAction(async () => {
|
||||
const statusResponse = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action_status', jobId],
|
||||
});
|
||||
|
||||
if (!statusResponse.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseComponentActionStatus(statusResponse.stdout);
|
||||
});
|
||||
},
|
||||
// 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],
|
||||
@ -103,22 +189,7 @@ export const NetShiftShellMethods = {
|
||||
});
|
||||
|
||||
if (response.stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
response.stdout,
|
||||
) as SingBoxComponentActionResult;
|
||||
|
||||
return {
|
||||
success: Boolean(parsed.success),
|
||||
version: parsed.version,
|
||||
message: parsed.message,
|
||||
};
|
||||
} catch (_e) {
|
||||
return {
|
||||
success: false,
|
||||
message: response.stdout,
|
||||
};
|
||||
}
|
||||
return parseComponentCheckUpdate(response.stdout);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -126,4 +197,136 @@ export const NetShiftShellMethods = {
|
||||
message: response.stderr || '',
|
||||
};
|
||||
},
|
||||
// NetShift update check (sync) — task-029/030 contract:
|
||||
// component_action netshift check_update
|
||||
// → {success, current_version, latest_version, status}. Same shape as the
|
||||
// sing-box cores (parsed by parseComponentCheckUpdate). The status is already
|
||||
// v-normalized server-side, so the caller TRUSTS result.status (no string
|
||||
// compare in TS). Stays on the SYNC component_action path (fast call).
|
||||
netshiftCheckUpdate:
|
||||
async (): Promise<NetShift.ComponentCheckUpdateResult> => {
|
||||
const response = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action', 'netshift', 'check_update'],
|
||||
timeout: 600000,
|
||||
});
|
||||
|
||||
if (response.stdout) {
|
||||
return parseComponentCheckUpdate(response.stdout);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: response.stderr || '',
|
||||
};
|
||||
},
|
||||
// Clear subscription cache (async) — task-039/040 contract:
|
||||
// component_action_async subscription clear_cache + component_action_status
|
||||
// <job>. Deletes all subscription caches then re-downloads, which restarts
|
||||
// the service and can exceed the rpcd ~30s wall — so it MUST run through the
|
||||
// SAME async start+poll mechanism as the sing-box core switch (reusing the
|
||||
// component-agnostic `pollSingBoxComponentAction`). The component/action
|
||||
// strings are EXACTLY 'subscription'/'clear_cache' (match task-039's router).
|
||||
clearSubscriptionCache: async (): Promise<SingBoxComponentActionResult> => {
|
||||
const startResponse = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action_async', 'subscription', 'clear_cache'],
|
||||
});
|
||||
|
||||
let start: ComponentActionStartResponse | null = null;
|
||||
|
||||
if (startResponse.stdout) {
|
||||
try {
|
||||
start = JSON.parse(
|
||||
startResponse.stdout,
|
||||
) as ComponentActionStartResponse;
|
||||
} catch (_e) {
|
||||
start = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!start || start.success !== true || !start.job_id) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
start?.message ||
|
||||
startResponse.stderr ||
|
||||
_('Failed to clear subscription cache'),
|
||||
};
|
||||
}
|
||||
|
||||
const jobId = start.job_id;
|
||||
|
||||
return pollSingBoxComponentAction(async () => {
|
||||
const statusResponse = await executeShellCommand({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action_status', jobId],
|
||||
});
|
||||
|
||||
if (!statusResponse.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseComponentActionStatus(statusResponse.stdout);
|
||||
});
|
||||
},
|
||||
// NetShift self-update (async) — STABLE task-017 contract:
|
||||
// component_action_async netshift self_update + component_action_status <job>.
|
||||
// Reuses the component-agnostic poll. Because the package install swaps
|
||||
// /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,84 @@
|
||||
import { sleep } from './sleep';
|
||||
|
||||
export interface SingBoxComponentActionResult {
|
||||
success: boolean;
|
||||
version?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Shape echoed by `component_action_async sing_box <action>` on start.
|
||||
export interface ComponentActionStartResponse {
|
||||
success?: boolean;
|
||||
job_id?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Shape echoed by `component_action_status <job_id>` (task-007/009 contract).
|
||||
export interface ComponentActionStatus {
|
||||
success?: boolean;
|
||||
running?: boolean;
|
||||
component?: string;
|
||||
action?: string;
|
||||
message?: string;
|
||||
pid?: number | null;
|
||||
started_at?: number;
|
||||
updated_at?: number;
|
||||
exit_code?: number | null;
|
||||
version?: string;
|
||||
latest_version?: string;
|
||||
}
|
||||
|
||||
// ~2s between polls; ~150 polls ≈ 5 min backstop against a wedged job.
|
||||
export const POLL_INTERVAL_MS = 2000;
|
||||
export const MAX_POLLS = 150;
|
||||
|
||||
export function parseComponentActionStatus(
|
||||
stdout: string,
|
||||
): ComponentActionStatus | null {
|
||||
try {
|
||||
return JSON.parse(stdout) as ComponentActionStatus;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure poll loop for the async core switch. Each `fetchStatus` call is a tiny,
|
||||
* individual `component_action_status` exec (well under the rpcd 30s wall); the
|
||||
* loop runs until the job is no longer running (a parse failure or
|
||||
* `running === false` is terminal). `sleepFn` is injected so tests can avoid
|
||||
* real 2s waits.
|
||||
*/
|
||||
export async function pollSingBoxComponentAction(
|
||||
fetchStatus: () => Promise<ComponentActionStatus | null>,
|
||||
sleepFn: (ms: number) => Promise<void> = sleep,
|
||||
intervalMs: number = POLL_INTERVAL_MS,
|
||||
maxPolls: number = MAX_POLLS,
|
||||
): Promise<SingBoxComponentActionResult> {
|
||||
for (let poll = 0; poll < maxPolls; poll += 1) {
|
||||
const status = await fetchStatus();
|
||||
|
||||
// A parse failure (null) is terminal — we cannot keep polling blindly.
|
||||
if (!status) {
|
||||
return {
|
||||
success: false,
|
||||
message: _('Core switch failed'),
|
||||
};
|
||||
}
|
||||
|
||||
if (status.running !== true) {
|
||||
return {
|
||||
success: Boolean(status.success),
|
||||
version: status.version,
|
||||
message: status.message,
|
||||
};
|
||||
}
|
||||
|
||||
await sleepFn(intervalMs);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: _('Core switch timed out'),
|
||||
};
|
||||
}
|
||||
3
fe-app-netshift/src/netshift/methods/shell/sleep.ts
Normal file
3
fe-app-netshift/src/netshift/methods/shell/sleep.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { pollSingBoxComponentAction } from '../pollSingBoxComponentAction';
|
||||
|
||||
// No-op sleep so polls resolve instantly (no real 2s waits in tests).
|
||||
const noSleep = () => Promise.resolve();
|
||||
|
||||
// Build a fetchStatus callback that returns the queued statuses in order,
|
||||
// then keeps returning the last one.
|
||||
function makeFetchStatus(statuses) {
|
||||
let index = 0;
|
||||
|
||||
return vi.fn(async () => {
|
||||
const status = statuses[Math.min(index, statuses.length - 1)];
|
||||
index += 1;
|
||||
|
||||
return status;
|
||||
});
|
||||
}
|
||||
|
||||
describe('pollSingBoxComponentAction', () => {
|
||||
it('resolves success with version after N running polls then terminal', async () => {
|
||||
const fetchStatus = makeFetchStatus([
|
||||
{ running: true, success: true, exit_code: null },
|
||||
{ running: true, success: true, exit_code: null },
|
||||
{
|
||||
running: false,
|
||||
success: true,
|
||||
version: '1.12.4',
|
||||
message: 'Core switched',
|
||||
exit_code: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await pollSingBoxComponentAction(fetchStatus, noSleep);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
version: '1.12.4',
|
||||
message: 'Core switched',
|
||||
});
|
||||
// 3 status reads (2 running + 1 terminal).
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('surfaces the failure message on terminal success:false', async () => {
|
||||
const fetchStatus = makeFetchStatus([
|
||||
{ running: true, success: true },
|
||||
{
|
||||
running: false,
|
||||
success: false,
|
||||
message: 'core switch aborted (existing sing-box left intact)',
|
||||
exit_code: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await pollSingBoxComponentAction(fetchStatus, noSleep);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe(
|
||||
'core switch aborted (existing sing-box left intact)',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a parse failure (null status) as terminal failure', async () => {
|
||||
const fetchStatus = makeFetchStatus([
|
||||
{ running: true, success: true },
|
||||
null,
|
||||
]);
|
||||
|
||||
const result = await pollSingBoxComponentAction(fetchStatus, noSleep);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Core switch failed');
|
||||
});
|
||||
|
||||
it('returns timeout when the safety cap is exceeded', async () => {
|
||||
// Always running → never terminal.
|
||||
const fetchStatus = vi.fn(async () => ({ running: true, success: true }));
|
||||
|
||||
const result = await pollSingBoxComponentAction(fetchStatus, noSleep, 0, 5);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Core switch timed out');
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('returns immediately on a terminal-first status', async () => {
|
||||
const fetchStatus = makeFetchStatus([
|
||||
{
|
||||
running: false,
|
||||
success: true,
|
||||
version: '1.13.0',
|
||||
message: 'done',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await pollSingBoxComponentAction(fetchStatus, noSleep);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
version: '1.13.0',
|
||||
message: 'done',
|
||||
});
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// `methods/shell/index.ts` (and its `callBaseMethod` import) pull in the
|
||||
// `../../../helpers` barrel, which transitively loads `withTimeout` →
|
||||
// `../netshift` → `TabService`, whose constructor calls `new MutationObserver`
|
||||
// at module-init time. In the node test env that throws
|
||||
// "MutationObserver is not defined" at COLLECT time. Mocking the helpers barrel
|
||||
// here short-circuits that chain so we can import and exercise the REAL
|
||||
// `singBoxComponentAction` method while controlling its `executeShellCommand`.
|
||||
const executeShellCommand = vi.fn();
|
||||
|
||||
vi.mock('../../../../helpers', () => ({
|
||||
executeShellCommand: (...args) => executeShellCommand(...args),
|
||||
}));
|
||||
|
||||
// Avoid pulling the real `callBaseMethod` (also imports the helpers barrel and
|
||||
// the LuCI types); the start-failure path under test never reaches it.
|
||||
vi.mock('../../callBaseMethod', () => ({
|
||||
callBaseMethod: vi.fn(),
|
||||
}));
|
||||
|
||||
const { NetShiftShellMethods } = await import('../index');
|
||||
|
||||
afterEach(() => {
|
||||
executeShellCommand.mockReset();
|
||||
});
|
||||
|
||||
describe('singBoxComponentAction (start-failure path)', () => {
|
||||
it('fails fast on start success:false WITHOUT entering the poll loop', async () => {
|
||||
executeShellCommand.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
success: false,
|
||||
message: 'binary updater is busy',
|
||||
}),
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const result =
|
||||
await NetShiftShellMethods.singBoxComponentAction('install_extended');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'binary updater is busy',
|
||||
});
|
||||
// Only the async-start call ran; no `component_action_status` polls.
|
||||
expect(executeShellCommand).toHaveBeenCalledTimes(1);
|
||||
expect(executeShellCommand).toHaveBeenCalledWith({
|
||||
command: '/usr/bin/netshift',
|
||||
args: ['component_action_async', 'sing_box', 'install_extended'],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails fast when the start response has no job_id', async () => {
|
||||
executeShellCommand.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ success: true }),
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const result =
|
||||
await NetShiftShellMethods.singBoxComponentAction('install_stable');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(executeShellCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces stderr / generic message when start output is unparseable', async () => {
|
||||
executeShellCommand.mockResolvedValueOnce({
|
||||
stdout: 'not json',
|
||||
stderr: 'boom',
|
||||
});
|
||||
|
||||
const result =
|
||||
await NetShiftShellMethods.singBoxComponentAction('install_extended');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('boom');
|
||||
expect(executeShellCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -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,7 @@ export interface StoreType {
|
||||
globalCheck: { loading: boolean };
|
||||
viewLogs: { loading: boolean };
|
||||
showSingBoxConfig: { loading: boolean };
|
||||
singBoxInstall: { loading: boolean };
|
||||
clearSubscriptionCache: { loading: boolean };
|
||||
};
|
||||
diagnosticsSystemInfo: {
|
||||
loading: boolean;
|
||||
@ -192,6 +198,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 +247,7 @@ const initialStore: StoreType = {
|
||||
data: [],
|
||||
},
|
||||
...initialDiagnosticStore,
|
||||
...initialManagerStore,
|
||||
};
|
||||
|
||||
export const store = new StoreService<StoreType>(initialStore);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { renderButton } from '../../../../partials';
|
||||
import { NetShift } from '../../../types';
|
||||
|
||||
interface IRenderSectionsProps {
|
||||
@ -13,7 +14,7 @@ function renderFailedState() {
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
class: 'pdk_dashboard-page__outbound-section centered',
|
||||
class: 'card pdk_dashboard-page__outbound-section centered',
|
||||
style: 'height: 127px',
|
||||
},
|
||||
E('span', {}, [E('span', {}, _('Dashboard currently unavailable'))]),
|
||||
@ -23,7 +24,7 @@ function renderFailedState() {
|
||||
function renderLoadingState() {
|
||||
return E('div', {
|
||||
id: 'dashboard-sections-grid-skeleton',
|
||||
class: 'pdk_dashboard-page__outbound-section skeleton',
|
||||
class: 'card pdk_dashboard-page__outbound-section skeleton',
|
||||
style: 'height: 127px',
|
||||
});
|
||||
}
|
||||
@ -64,7 +65,7 @@ export function renderDefaultState({
|
||||
return E(
|
||||
'div',
|
||||
{
|
||||
class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''} ${section.withTagSelect ? 'pdk_dashboard-page__outbound-grid__item--selectable' : ''}`,
|
||||
class: `card pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''} ${section.withTagSelect ? 'pdk_dashboard-page__outbound-grid__item--selectable' : ''}`,
|
||||
click: () =>
|
||||
section.withTagSelect &&
|
||||
onChooseOutbound(section.code, outbound.code),
|
||||
@ -87,7 +88,7 @@ export function renderDefaultState({
|
||||
);
|
||||
}
|
||||
|
||||
return E('div', { class: 'pdk_dashboard-page__outbound-section' }, [
|
||||
return E('div', { class: 'card pdk_dashboard-page__outbound-section' }, [
|
||||
// Title with test latency
|
||||
E('div', { class: 'pdk_dashboard-page__outbound-section__title-section' }, [
|
||||
E(
|
||||
@ -99,14 +100,11 @@ export function renderDefaultState({
|
||||
),
|
||||
latencyFetching
|
||||
? E('div', { class: 'skeleton', style: 'width: 99px; height: 28px' })
|
||||
: E(
|
||||
'button',
|
||||
{
|
||||
class: 'btn dashboard-sections-grid-item-test-latency',
|
||||
click: () => testLatency(),
|
||||
},
|
||||
_('Test latency'),
|
||||
),
|
||||
: renderButton({
|
||||
text: _('Test latency'),
|
||||
onClick: () => testLatency(),
|
||||
classNames: ['dashboard-sections-grid-item-test-latency'],
|
||||
}),
|
||||
]),
|
||||
E(
|
||||
'div',
|
||||
|
||||
@ -17,7 +17,7 @@ function renderFailedState() {
|
||||
{
|
||||
id: '',
|
||||
style: 'height: 78px',
|
||||
class: 'pdk_dashboard-page__widgets-section__item centered',
|
||||
class: 'card pdk_dashboard-page__widgets-section__item centered',
|
||||
},
|
||||
_('Currently unavailable'),
|
||||
);
|
||||
@ -29,14 +29,14 @@ function renderLoadingState() {
|
||||
{
|
||||
id: '',
|
||||
style: 'height: 78px',
|
||||
class: 'pdk_dashboard-page__widgets-section__item skeleton',
|
||||
class: 'card pdk_dashboard-page__widgets-section__item skeleton',
|
||||
},
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
function renderDefaultState({ title, items }: IRenderWidgetProps) {
|
||||
return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [
|
||||
return E('div', { class: 'card pdk_dashboard-page__widgets-section__item' }, [
|
||||
E(
|
||||
'b',
|
||||
{ class: 'pdk_dashboard-page__widgets-section__item__title' },
|
||||
|
||||
@ -27,9 +27,6 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_dashboard-page__widgets-section__item {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pdk_dashboard-page__widgets-section__item__title {}
|
||||
@ -50,9 +47,6 @@ export const styles = `
|
||||
|
||||
.pdk_dashboard-page__outbound-section {
|
||||
margin-top: 10px;
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pdk_dashboard-page__outbound-section__title-section {
|
||||
@ -74,9 +68,6 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_dashboard-page__outbound-grid__item {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
transition: border 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@ -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,7 +46,7 @@ export const initialDiagnosticStore: Pick<
|
||||
showSingBoxConfig: {
|
||||
loading: false,
|
||||
},
|
||||
singBoxInstall: {
|
||||
clearSubscriptionCache: {
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
|
||||
@ -316,42 +316,45 @@ async function handleShowSingBoxConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstallSingBox() {
|
||||
async function handleClearSubscriptionCache() {
|
||||
const diagnosticsActions = store.get().diagnosticsActions;
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
singBoxInstall: { loading: true },
|
||||
clearSubscriptionCache: { loading: true },
|
||||
},
|
||||
});
|
||||
|
||||
const isExtended = store.get().diagnosticsSystemInfo.sing_box_extended === 1;
|
||||
showToast(
|
||||
_('Clearing subscription cache and re-downloading… this may take a minute'),
|
||||
'info',
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await NetShiftShellMethods.singBoxComponentAction(
|
||||
isExtended ? 'install_stable' : 'install_extended',
|
||||
);
|
||||
const result = await NetShiftShellMethods.clearSubscriptionCache();
|
||||
|
||||
if (result.success) {
|
||||
showToast(
|
||||
_('Sing-box core changed, version: ') + (result.version || ''),
|
||||
'success',
|
||||
);
|
||||
showToast(_('Subscription cache cleared and re-downloaded'), 'success');
|
||||
} else {
|
||||
logger.error('[DIAGNOSTIC]', 'handleInstallSingBox - e', result);
|
||||
showToast(result.message || _('Failed to execute!'), 'error');
|
||||
logger.error(
|
||||
'[DIAGNOSTIC]',
|
||||
'handleClearSubscriptionCache - result',
|
||||
result,
|
||||
);
|
||||
showToast(_('Failed to clear subscription cache'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[DIAGNOSTIC]', 'handleInstallSingBox - e', e);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
logger.error('[DIAGNOSTIC]', 'handleClearSubscriptionCache - e', e);
|
||||
showToast(_('Failed to clear subscription cache'), 'error');
|
||||
} finally {
|
||||
await fetchServicesInfo();
|
||||
store.set({
|
||||
diagnosticsActions: {
|
||||
...diagnosticsActions,
|
||||
singBoxInstall: { loading: false },
|
||||
clearSubscriptionCache: { loading: false },
|
||||
},
|
||||
});
|
||||
await fetchSystemInfo();
|
||||
store.reset(['diagnosticsChecks']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -443,15 +446,12 @@ function renderDiagnosticAvailableActionsWidget() {
|
||||
onClick: handleShowSingBoxConfig,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
singBoxInstall: {
|
||||
loading: diagnosticsActions.singBoxInstall.loading,
|
||||
clearSubscriptionCache: {
|
||||
loading: diagnosticsActions.clearSubscriptionCache.loading,
|
||||
visible: true,
|
||||
onClick: handleInstallSingBox,
|
||||
disabled:
|
||||
atLeastOneServiceCommandLoading ||
|
||||
diagnosticsActions.singBoxInstall.loading,
|
||||
onClick: handleClearSubscriptionCache,
|
||||
disabled: atLeastOneServiceCommandLoading,
|
||||
},
|
||||
singBoxExtended: store.get().diagnosticsSystemInfo.sing_box_extended,
|
||||
});
|
||||
|
||||
return preserveScrollForPage(() => {
|
||||
|
||||
@ -27,8 +27,7 @@ interface IRenderAvailableActionsProps {
|
||||
globalCheck: ActionProps;
|
||||
viewLogs: ActionProps;
|
||||
showSingBoxConfig: ActionProps;
|
||||
singBoxInstall: ActionProps;
|
||||
singBoxExtended: 0 | 1;
|
||||
clearSubscriptionCache: ActionProps;
|
||||
}
|
||||
|
||||
export function renderAvailableActions({
|
||||
@ -40,10 +39,9 @@ export function renderAvailableActions({
|
||||
globalCheck,
|
||||
viewLogs,
|
||||
showSingBoxConfig,
|
||||
singBoxInstall,
|
||||
singBoxExtended,
|
||||
clearSubscriptionCache,
|
||||
}: IRenderAvailableActionsProps) {
|
||||
return E('div', { class: 'pdk_diagnostic-page__right-bar__actions' }, [
|
||||
return E('div', { class: 'card pdk_diagnostic-page__right-bar__actions' }, [
|
||||
E('b', {}, _('Available actions')),
|
||||
...insertIf(restart.visible, [
|
||||
renderButton({
|
||||
@ -122,13 +120,13 @@ export function renderAvailableActions({
|
||||
disabled: showSingBoxConfig.disabled,
|
||||
}),
|
||||
]),
|
||||
...insertIf(singBoxInstall.visible, [
|
||||
...insertIf(clearSubscriptionCache.visible, [
|
||||
renderButton({
|
||||
onClick: singBoxInstall.onClick,
|
||||
onClick: clearSubscriptionCache.onClick,
|
||||
icon: renderRotateCcwIcon24,
|
||||
text: singBoxExtended ? _('Install stable') : _('Install extended'),
|
||||
loading: singBoxInstall.loading,
|
||||
disabled: singBoxInstall.disabled,
|
||||
text: _('Clear subscription cache'),
|
||||
loading: clearSubscriptionCache.loading,
|
||||
disabled: clearSubscriptionCache.disabled,
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
|
||||
@ -56,7 +56,7 @@ function renderLoadingState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--loading' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--loading' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
@ -79,7 +79,7 @@ function renderWarningState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--warning' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--warning' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
@ -102,7 +102,7 @@ function renderErrorState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--error' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--error' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
@ -125,7 +125,7 @@ function renderSuccessState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--success' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--success' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
@ -148,7 +148,7 @@ function renderSkippedState(props: IRenderCheckSectionProps) {
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic_alert pdk_diagnostic_alert--skipped' },
|
||||
{ class: 'card pdk_diagnostic_alert pdk_diagnostic_alert--skipped' },
|
||||
[
|
||||
iconWrap,
|
||||
E('div', { class: 'pdk_diagnostic_alert__content' }, [
|
||||
|
||||
@ -14,36 +14,40 @@ interface IRenderSystemInfoProps {
|
||||
}
|
||||
|
||||
export function renderSystemInfo({ items }: IRenderSystemInfoProps) {
|
||||
return E('div', { class: 'pdk_diagnostic-page__right-bar__system-info' }, [
|
||||
E(
|
||||
'b',
|
||||
{ class: 'pdk_diagnostic-page__right-bar__system-info__title' },
|
||||
_('System information'),
|
||||
),
|
||||
...items.map((item) => {
|
||||
const tagClass = [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag',
|
||||
...insertIf(item.tag?.kind === 'warning', [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag--warning',
|
||||
]),
|
||||
...insertIf(item.tag?.kind === 'success', [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag--success',
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic-page__right-bar__system-info__row' },
|
||||
[
|
||||
E('b', {}, item.key),
|
||||
E('div', {}, [
|
||||
E('span', {}, item.value),
|
||||
E('span', { class: tagClass }, item?.tag?.label),
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'card pdk_diagnostic-page__right-bar__system-info' },
|
||||
[
|
||||
E(
|
||||
'b',
|
||||
{ class: 'pdk_diagnostic-page__right-bar__system-info__title' },
|
||||
_('System information'),
|
||||
),
|
||||
...items.map((item) => {
|
||||
const tagClass = [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag',
|
||||
...insertIf(item.tag?.kind === 'warning', [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag--warning',
|
||||
]),
|
||||
],
|
||||
);
|
||||
}),
|
||||
]);
|
||||
...insertIf(item.tag?.kind === 'success', [
|
||||
'pdk_diagnostic-page__right-bar__system-info__row__tag--success',
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return E(
|
||||
'div',
|
||||
{ class: 'pdk_diagnostic-page__right-bar__system-info__row' },
|
||||
[
|
||||
E('b', {}, item.key),
|
||||
E('div', {}, [
|
||||
E('span', {}, item.value),
|
||||
E('span', { class: tagClass }, item?.tag?.label),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ export function renderWikiDisclaimer(kind: 'default' | 'error' | 'warning') {
|
||||
iconWrap.appendChild(renderBookOpenTextIcon24());
|
||||
|
||||
const className = [
|
||||
'card',
|
||||
'pdk_diagnostic-page__right-bar__wiki',
|
||||
...insertIf(kind === 'error', [
|
||||
'pdk_diagnostic-page__right-bar__wiki--error',
|
||||
|
||||
@ -29,10 +29,6 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_diagnostic-page__right-bar__wiki {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
@ -54,21 +50,12 @@ export const styles = `
|
||||
.pdk_diagnostic-page__right-bar__wiki__texts {}
|
||||
|
||||
.pdk_diagnostic-page__right-bar__actions {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
|
||||
}
|
||||
|
||||
.pdk_diagnostic-page__right-bar__system-info {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-row-gap: 10px;
|
||||
@ -120,14 +107,10 @@ export const styles = `
|
||||
}
|
||||
|
||||
.pdk_diagnostic_alert {
|
||||
border: 2px var(--background-color-low, lightgray) solid;
|
||||
border-radius: 4px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
grid-column-gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pdk_diagnostic_alert--loading {
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './dashboard';
|
||||
export * from './diagnostic';
|
||||
export * from './manager';
|
||||
|
||||
264
fe-app-netshift/src/netshift/tabs/manager/cards.ts
Normal file
264
fe-app-netshift/src/netshift/tabs/manager/cards.ts
Normal file
@ -0,0 +1,264 @@
|
||||
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 (task-030), which now
|
||||
// calls the dedicated `component_action netshift check_update` action and writes
|
||||
// `managerChecks.netshift` — exactly like the sing-box cores. Keeping it a
|
||||
// DISTINCT kind guarantees a NetShift check can never be routed to the sing-box
|
||||
// check method (the dispatcher routes it to runNetshiftCheck).
|
||||
export type ManagerActionKind =
|
||||
| 'check'
|
||||
| 'check_netshift'
|
||||
| '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; for `check_netshift`:
|
||||
// the NetShift check action (routed to the dedicated NetShift check method).
|
||||
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 from the on-demand check result (task-030):
|
||||
// `managerChecks.netshift.status` is null until the user presses "Check update"
|
||||
// → neutral card (no badge, no update button). The backend already computes the
|
||||
// v-normalized status, so we TRUST it (no installed-vs-latest string compare).
|
||||
// The `dev`-build guard is kept locally: a dev/placeholder build never shows an
|
||||
// update prompt regardless of any check result.
|
||||
function netshiftStatus(
|
||||
systemInfo: ManagerSystemInfo,
|
||||
check: ManagerCheckState,
|
||||
): NetShift.ComponentUpdateStatus | null {
|
||||
const installed = normalizeCompiledVersion(systemInfo.netshift_version);
|
||||
|
||||
if (installed === 'dev') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return check.status;
|
||||
}
|
||||
|
||||
function netshiftCard(
|
||||
systemInfo: ManagerSystemInfo,
|
||||
check: ManagerCheckState,
|
||||
): ManagerCardDescriptor {
|
||||
const status = netshiftStatus(systemInfo, check);
|
||||
const latest = check.latest_version;
|
||||
const actions: ManagerActionDescriptor[] = [];
|
||||
|
||||
if (status === 'outdated') {
|
||||
actions.push({
|
||||
loadingKey: 'netshiftUpdate',
|
||||
kind: 'self_update',
|
||||
text: latest
|
||||
? _('Install %s').replace('%s', latest)
|
||||
: _('Update NetShift'),
|
||||
backendAction: 'self_update',
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
loadingKey: 'netshiftCheck',
|
||||
kind: 'check_netshift',
|
||||
text: _('Check update'),
|
||||
backendAction: '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, checks.netshift),
|
||||
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,
|
||||
};
|
||||
425
fe-app-netshift/src/netshift/tabs/manager/initController.ts
Normal file
425
fe-app-netshift/src/netshift/tabs/manager/initController.ts
Normal file
@ -0,0 +1,425 @@
|
||||
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 (task-030): on-demand call to the dedicated
|
||||
// `component_action netshift check_update` action, which returns the same
|
||||
// {success, current_version, latest_version, status} contract as the sing-box
|
||||
// cores (status already v-normalized server-side). We TRUST result.status and
|
||||
// write it into managerChecks.netshift — mirroring runSingBoxCheck precisely.
|
||||
async function runNetshiftCheck(button: ManagerActionDescriptor) {
|
||||
setActionLoading(button.loadingKey, true);
|
||||
|
||||
try {
|
||||
const parsed = await NetShiftShellMethods.netshiftCheckUpdate();
|
||||
|
||||
if (!parsed.success) {
|
||||
showToast(parsed.message || _('Failed to execute!'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = parsed.status ?? null;
|
||||
|
||||
setCheckResult('netshift', status, parsed.latest_version || '');
|
||||
showToast(getCheckToastMessage(status), 'success');
|
||||
} catch (error) {
|
||||
logger.error('[MANAGER]', 'runNetshiftCheck failed', error);
|
||||
showToast(_('Failed to execute!'), 'error');
|
||||
} 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…'), 'info');
|
||||
|
||||
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…'),
|
||||
'warning',
|
||||
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: 'card 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',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
105
fe-app-netshift/src/netshift/tabs/manager/styles.ts
Normal file
105
fe-app-netshift/src/netshift/tabs/manager/styles.ts
Normal file
@ -0,0 +1,105 @@
|
||||
// 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 {
|
||||
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;
|
||||
}
|
||||
`;
|
||||
215
fe-app-netshift/src/netshift/tabs/manager/tests/cards.test.js
Normal file
215
fe-app-netshift/src/netshift/tabs/manager/tests/cards.test.js
Normal file
@ -0,0 +1,215 @@
|
||||
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('is neutral until checked — null managerChecks.netshift status', () => {
|
||||
// task-030: mount does NO network check; managerChecks.netshift.status is
|
||||
// null → no badge, the "Check update" action (no outdated/update button).
|
||||
const cards = getComponentCards(makeSystemInfo(), emptyChecks);
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.tag).toBeUndefined();
|
||||
expect(netshift.actions[0].kind).toBe('check_netshift');
|
||||
expect(netshift.actions[0].backendAction).toBe('check_update');
|
||||
});
|
||||
|
||||
it('derives an outdated NetShift card from the on-demand check result', () => {
|
||||
// task-030: status comes from managerChecks.netshift (the check result), NOT
|
||||
// from a systemInfo installed-vs-latest string compare. The latest_version
|
||||
// for the "Install %s" text also comes from the check result.
|
||||
const cards = getComponentCards(
|
||||
makeSystemInfo({
|
||||
netshift_version: '1.0.0',
|
||||
netshift_latest_version: '1.0.0',
|
||||
}),
|
||||
{
|
||||
...emptyChecks,
|
||||
netshift: { status: 'outdated', latest_version: '1.1.0' },
|
||||
},
|
||||
);
|
||||
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('shows the Latest badge + Check update when the check says latest', () => {
|
||||
const cards = getComponentCards(makeSystemInfo(), {
|
||||
...emptyChecks,
|
||||
netshift: { status: 'latest', latest_version: '1.0.0' },
|
||||
});
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.tag).toEqual({ label: 'Latest', kind: 'success' });
|
||||
// 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 its own (non-sing-box) backendAction', () => {
|
||||
// The NetShift "Check update" routes to runNetshiftCheck (distinct kind) and
|
||||
// calls `component_action netshift check_update` — NOT a sing-box check
|
||||
// action. Guard against accidentally reusing a sing-box check action.
|
||||
const cards = getComponentCards(makeSystemInfo(), emptyChecks);
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.actions[0].kind).toBe('check_netshift');
|
||||
expect(netshift.actions[0].backendAction).toBe('check_update');
|
||||
expect(['check_update_stable']).not.toContain(
|
||||
netshift.actions[0].backendAction,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a dev build neutral even if a check result says outdated', () => {
|
||||
// The dev-build guard: a placeholder/dev install never shows an update
|
||||
// prompt regardless of any check result.
|
||||
const cards = getComponentCards(
|
||||
makeSystemInfo({ netshift_version: 'COMPILED_VERSION' }),
|
||||
{
|
||||
...emptyChecks,
|
||||
netshift: { status: 'outdated', latest_version: '9.9.9' },
|
||||
},
|
||||
);
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.version).toBe('dev');
|
||||
expect(netshift.tag).toBeUndefined();
|
||||
expect(netshift.actions[0].kind).toBe('check_netshift');
|
||||
});
|
||||
|
||||
it('ignores systemInfo netshift_latest_version for status (now on-demand)', () => {
|
||||
// task-030: a stale/unknown systemInfo latest must NOT drive the badge — only
|
||||
// the on-demand managerChecks.netshift result does.
|
||||
const cards = getComponentCards(
|
||||
makeSystemInfo({
|
||||
netshift_version: '1.0.0',
|
||||
netshift_latest_version: '9.9.9',
|
||||
}),
|
||||
emptyChecks,
|
||||
);
|
||||
const netshift = cards[0];
|
||||
|
||||
expect(netshift.tag).toBeUndefined();
|
||||
expect(netshift.actions[0].kind).toBe('check_netshift');
|
||||
});
|
||||
});
|
||||
@ -117,9 +117,13 @@ export namespace NetShift {
|
||||
export interface ConfigProxySubscriptionSection {
|
||||
connection_type: 'proxy';
|
||||
proxy_config_type: 'subscription';
|
||||
subscription_url: string;
|
||||
subscription_url: string[];
|
||||
subscription_format_preference?: 'auto' | 'xray' | 'singbox';
|
||||
subscription_update_interval?: string;
|
||||
subscription_group_by_countries?: '0' | '1';
|
||||
subscription_group_mode?: 'off' | 'country' | 'prefix';
|
||||
subscription_group_prefix_len?: string;
|
||||
subscription_filter_include_keywords?: string[];
|
||||
subscription_filter_exclude_keywords?: string[];
|
||||
}
|
||||
|
||||
export interface ConfigVpnSection {
|
||||
@ -173,6 +177,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 {
|
||||
@ -180,7 +185,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;
|
||||
@ -227,4 +231,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,43 @@
|
||||
// language=CSS
|
||||
import { DashboardTab, DiagnosticTab } from './netshift';
|
||||
import { DashboardTab, DiagnosticTab, ManagerTab } from './netshift';
|
||||
import { PartialStyles } from './partials';
|
||||
|
||||
export const GlobalStyles = `
|
||||
/*
|
||||
* NetShift design tokens (Stage 1 foundation — task-024).
|
||||
* Each token layers over the LuCI theme var (with a hardcoded fallback) so
|
||||
* themes still win. Reused by the custom tabs and the form redesigns
|
||||
* (task-025/026). Keep these names stable.
|
||||
*/
|
||||
:root,
|
||||
.cbi-map {
|
||||
--ns-card-border: var(--background-color-low, lightgray);
|
||||
--ns-card-border-width: 2px;
|
||||
--ns-card-radius: 4px;
|
||||
--ns-gap: 10px;
|
||||
--ns-card-padding: var(--ns-gap);
|
||||
--ns-success: var(--success-color-medium, #28a745);
|
||||
--ns-warning: var(--warn-color-medium, #f0ad4e);
|
||||
--ns-error: var(--error-color-medium, #dc3545);
|
||||
--ns-info: var(--primary-color-high, #2196f3);
|
||||
}
|
||||
|
||||
/*
|
||||
* Shared card primitive. Mirrors the Manager component card look
|
||||
* (2px solid border, 4px radius, 10px padding, overflow-safe min-width:0).
|
||||
* Defined BEFORE the per-tab styles so colored-border modifiers
|
||||
* (e.g. .pdk_diagnostic_alert--warning) still win via source order.
|
||||
*/
|
||||
.card {
|
||||
border: var(--ns-card-border-width) solid var(--ns-card-border);
|
||||
border-radius: var(--ns-card-radius);
|
||||
padding: var(--ns-card-padding);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
${DashboardTab.styles}
|
||||
${DiagnosticTab.styles}
|
||||
${ManagerTab.styles}
|
||||
${PartialStyles}
|
||||
|
||||
|
||||
@ -23,6 +56,42 @@ ${PartialStyles}
|
||||
margin-bottom: -32px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sections (connection) form — native CBI option-group tabs styled as a
|
||||
* card (task-025). Reuses task-024's --ns-* tokens. The tab strip
|
||||
* (ul.cbi-tabmenu) sits on top; each tab pane (.cbi-section-node-tabbed)
|
||||
* reads as the card body. depends()-driven auto-hide of tabs is unaffected.
|
||||
*/
|
||||
#cbi-netshift-section .cbi-section-node-tabbed {
|
||||
border: var(--ns-card-border-width) solid var(--ns-card-border);
|
||||
border-radius: var(--ns-card-radius);
|
||||
padding: var(--ns-card-padding);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#cbi-netshift-section ul.cbi-tabmenu {
|
||||
margin-bottom: var(--ns-gap);
|
||||
}
|
||||
|
||||
/*
|
||||
* Settings form — native CBI option-group tabs styled as a card (task-026).
|
||||
* Reuses task-024's --ns-* tokens and mirrors the #cbi-netshift-section
|
||||
* pattern above. The tab strip (ul.cbi-tabmenu) sits on top; each tab pane
|
||||
* (.cbi-section-node-tabbed) reads as the card body. depends()-driven
|
||||
* auto-hide of tabs is unaffected. The existing
|
||||
* #cbi-netshift-settings > h3 hide rule above stays valid.
|
||||
*/
|
||||
#cbi-netshift-settings .cbi-section-node-tabbed {
|
||||
border: var(--ns-card-border-width) solid var(--ns-card-border);
|
||||
border-radius: var(--ns-card-radius);
|
||||
padding: var(--ns-card-padding);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#cbi-netshift-settings ul.cbi-tabmenu {
|
||||
margin-bottom: var(--ns-gap);
|
||||
}
|
||||
|
||||
/* Centered class helper */
|
||||
.centered {
|
||||
display: flex;
|
||||
@ -98,11 +167,19 @@ ${PartialStyles}
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: #28a745;
|
||||
background-color: var(--ns-success, #28a745);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: #dc3545;
|
||||
background-color: var(--ns-error, #dc3545);
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background-color: var(--ns-warning, #f0ad4e);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background-color: var(--ns-info, #2196f3);
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
|
||||
@ -10,4 +10,5 @@ export * from './validateVlessUrl';
|
||||
export * from './validateOutboundJson';
|
||||
export * from './validateTrojanUrl';
|
||||
export * from './validateProxyUrl';
|
||||
export * from './validateProxyUrlList';
|
||||
export * from './validateSocksUrl';
|
||||
|
||||
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateProxyUrlList } from '../validateProxyUrlList';
|
||||
|
||||
// Synthetic placeholder links only — never real proxy/subscription data.
|
||||
const VLESS =
|
||||
'vless://94792286-7bbe-4f33-8b36-18d1bbf70723@127.0.0.1:34520?type=tcp&encryption=none&security=none#node-a';
|
||||
const SS =
|
||||
'ss://2022-blake3-aes-256-gcm:dmCly/Zh15Ww9+s+GFXiFTIkpw7c/qCISaBrai7WhhY=@127.0.0.1:27214?type=tcp#node-b';
|
||||
|
||||
const validBlobs = [
|
||||
['single vless line', VLESS],
|
||||
['single ss line', SS],
|
||||
['two links', `${VLESS}\n${SS}`],
|
||||
['blank lines ignored', `\n${VLESS}\n\n${SS}\n`],
|
||||
['leading/trailing whitespace trimmed', ` ${VLESS} \n\t${SS}\t`],
|
||||
['CRLF tolerated', `${VLESS}\r\n${SS}\r`],
|
||||
];
|
||||
|
||||
const invalidBlobs = [
|
||||
['empty string', ''],
|
||||
['whitespace/blank only', ' \n\t\n '],
|
||||
['unsupported scheme', 'tuic://127.0.0.1:443#node'],
|
||||
['second line invalid', `${VLESS}\ntuic://127.0.0.1:443`],
|
||||
['garbage line', 'not-a-link'],
|
||||
];
|
||||
|
||||
describe('validateProxyUrlList', () => {
|
||||
describe.each(validBlobs)('Valid blob: %s', (_desc, blob) => {
|
||||
it('returns valid=true', () => {
|
||||
const res = validateProxyUrlList(blob);
|
||||
expect(res.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(invalidBlobs)('Invalid blob: %s', (_desc, blob) => {
|
||||
it('returns valid=false', () => {
|
||||
const res = validateProxyUrlList(blob);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(typeof res.message).toBe('string');
|
||||
expect(res.message.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the 1-based line number of the first failing line', () => {
|
||||
const res = validateProxyUrlList(`${VLESS}\n${SS}\ntuic://127.0.0.1:443`);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toContain('Line 3');
|
||||
});
|
||||
|
||||
it('counts blank lines toward the reported line number', () => {
|
||||
const res = validateProxyUrlList(`${VLESS}\n\ntuic://127.0.0.1:443`);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toContain('Line 3');
|
||||
});
|
||||
});
|
||||
@ -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', () => {
|
||||
|
||||
150
fe-app-netshift/src/validators/tests/validateVmessUrl.test.js
Normal file
150
fe-app-netshift/src/validators/tests/validateVmessUrl.test.js
Normal file
@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateVmessUrl } from '../validateVmessUrl';
|
||||
|
||||
// Node global (vitest runs in the node environment); aliased so ESLint's
|
||||
// no-undef does not flag the bare `Buffer` identifier.
|
||||
const NodeBuffer = globalThis.Buffer;
|
||||
|
||||
// Build a vmess:// link from a config object the V2RayN way:
|
||||
// vmess:// + base64(JSON).
|
||||
const b64 = (obj) => NodeBuffer.from(JSON.stringify(obj)).toString('base64');
|
||||
const vmess = (obj) => `vmess://${b64(obj)}`;
|
||||
|
||||
const baseConfig = {
|
||||
v: '2',
|
||||
ps: 'node',
|
||||
add: '1.2.3.4',
|
||||
port: 443,
|
||||
id: 'b831381d-6324-4d53-ad4f-8cda48b30811',
|
||||
net: 'ws',
|
||||
type: 'none',
|
||||
host: '',
|
||||
path: '/',
|
||||
tls: 'tls',
|
||||
aid: 0,
|
||||
};
|
||||
|
||||
// A config whose JSON base64 contains a '+' char (ps:'>>>' forces it).
|
||||
// Regression parity with the backend S1 fix.
|
||||
const plusB64 = b64({ ...baseConfig, ps: '>>>' });
|
||||
|
||||
// An unpadded base64 variant of a valid config (strip trailing '=' padding).
|
||||
const unpaddedBody = b64(baseConfig).replace(/=+$/, '');
|
||||
|
||||
// The user's REAL key: V2RayN base64(JSON) followed by a "#🇳🇱Ne" display-name
|
||||
// fragment. The fragment (emoji/Cyrillic/etc.) used to corrupt the base64 and
|
||||
// throw "malformed base64"; stripping it before decode must validate it.
|
||||
const realUserKey =
|
||||
'vmess://eyJhZGQiOiJyZW5kZXJlci1zdHJlYW0tMS00MTEubWlycmEubm93IiwiYWlkIjoiMCIsImFsbG93SW5zZWN1cmUiOiIwIiwiYWxsb3dfaW5zZWN1cmUiOiIwIiwiaG9zdCI6InJlbmRlcmVyLXN0cmVhbS0xLTQxMS5taXJyYS5ub3ciLCJpZCI6ImRmOWM5MzU1LWIwMmMtNGMxMi05MDlkLTBkYmViNzI1ZDUyYiIsImluc2VjdXJlIjoiMCIsIm5ldCI6IndzIiwicGF0aCI6Ii9hcGkvdjEvZ3B1LXN0cmVhbS9zb2NrZXQiLCJwb3J0IjoiNDQzIiwicHMiOiLwn4ez8J+HsSBUaGUgTmV0aGVybGFuZHMgfCBbKkNJRFJdIiwic2VjdXJpdHkiOiJhdXRvIiwic25pIjoicmVuZGVyZXItc3RyZWFtLTEtNDExLm1pcnJhLm5vdyIsInRscyI6InRscyIsInYiOiIyIiwidHlwZSI6Im5vbmUiLCJzY3kiOiJhdXRvIiwiYWxwbiI6IiIsImZwIjoiIn0=#🇳🇱Ne';
|
||||
|
||||
const validUrls = [
|
||||
['basic add/port/id', vmess({ add: '1.2.3.4', port: 443, id: 'uuid-1' })],
|
||||
['full config with net:ws tls:tls', vmess(baseConfig)],
|
||||
['port as numeric string', vmess({ ...baseConfig, port: '8443' })],
|
||||
['base64 body containing "+"', `vmess://${plusB64}`],
|
||||
['unpadded base64 body', `vmess://${unpaddedBody}`],
|
||||
["user's real key with #🇳🇱Ne fragment", realUserKey],
|
||||
['fragment label after #', `${vmess(baseConfig)}#label`],
|
||||
['fragment with spaces after #', `${vmess(baseConfig)}#name with spaces`],
|
||||
['no fragment (no regression)', vmess(baseConfig)],
|
||||
];
|
||||
|
||||
const invalidUrls = [
|
||||
['wrong prefix', `vless://${b64(baseConfig)}`],
|
||||
['contains space', `vmess://${b64(baseConfig)} `],
|
||||
['non-base64 body', 'vmess://!!!not base64!!!'],
|
||||
[
|
||||
'base64 of non-JSON',
|
||||
`vmess://${NodeBuffer.from('not json at all').toString('base64')}`,
|
||||
],
|
||||
[
|
||||
'base64 of JSON array',
|
||||
`vmess://${NodeBuffer.from('[1,2,3]').toString('base64')}`,
|
||||
],
|
||||
['missing add', vmess({ port: 443, id: 'uuid-1' })],
|
||||
['empty add', vmess({ add: '', port: 443, id: 'uuid-1' })],
|
||||
['missing id', vmess({ add: '1.2.3.4', port: 443 })],
|
||||
['empty id', vmess({ add: '1.2.3.4', port: 443, id: '' })],
|
||||
['port 0', vmess({ add: '1.2.3.4', port: 0, id: 'uuid-1' })],
|
||||
['port 99999', vmess({ add: '1.2.3.4', port: 99999, id: 'uuid-1' })],
|
||||
['non-numeric port', vmess({ add: '1.2.3.4', port: 'abc', id: 'uuid-1' })],
|
||||
];
|
||||
|
||||
describe('validateVmessUrl', () => {
|
||||
describe.each(validUrls)('Valid URL: %s', (_desc, url) => {
|
||||
it(`returns valid=true for "${url}"`, () => {
|
||||
const res = validateVmessUrl(url);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.message).toBe('Valid');
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => {
|
||||
it(`returns valid=false for "${url}"`, () => {
|
||||
const res = validateVmessUrl(url);
|
||||
expect(res.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the wrong-prefix message', () => {
|
||||
const res = validateVmessUrl('http://example.com');
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toBe('Invalid VMess URL: must start with vmess://');
|
||||
});
|
||||
|
||||
it('reports malformed base64', () => {
|
||||
const res = validateVmessUrl('vmess://@@@@');
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toBe('Invalid VMess URL: malformed base64');
|
||||
});
|
||||
|
||||
it('reports malformed JSON', () => {
|
||||
const res = validateVmessUrl(
|
||||
`vmess://${NodeBuffer.from('definitely not json').toString('base64')}`,
|
||||
);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toBe('Invalid VMess URL: malformed JSON');
|
||||
});
|
||||
|
||||
it('reports missing address', () => {
|
||||
const res = validateVmessUrl(vmess({ port: 443, id: 'uuid-1' }));
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toBe('Invalid VMess URL: missing address');
|
||||
});
|
||||
|
||||
it('reports missing id', () => {
|
||||
const res = validateVmessUrl(vmess({ add: '1.2.3.4', port: 443 }));
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toBe('Invalid VMess URL: missing id');
|
||||
});
|
||||
|
||||
it('reports invalid port', () => {
|
||||
const res = validateVmessUrl(
|
||||
vmess({ add: '1.2.3.4', port: 99999, id: 'uuid-1' }),
|
||||
);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toBe('Invalid VMess URL: invalid port');
|
||||
});
|
||||
|
||||
it('confirms the "+"-containing base64 fixture really contains "+"', () => {
|
||||
expect(plusB64).toContain('+');
|
||||
});
|
||||
|
||||
it("validates the user's real #🇳🇱Ne-fragment key", () => {
|
||||
const res = validateVmessUrl(realUserKey);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.message).toBe('Valid');
|
||||
});
|
||||
|
||||
it('strips a fragment with spaces (whitespace check runs on base64 only)', () => {
|
||||
const res = validateVmessUrl(`${vmess(baseConfig)}#name with spaces`);
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.message).toBe('Valid');
|
||||
});
|
||||
|
||||
it('still rejects whitespace inside the base64 body (no fragment)', () => {
|
||||
const res = validateVmessUrl(`vmess://${b64(baseConfig)} `);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.message).toBe('Invalid VMess URL: must not contain spaces');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { validateVlessUrl } from './validateVlessUrl';
|
||||
import { validateTrojanUrl } from './validateTrojanUrl';
|
||||
import { validateSocksUrl } from './validateSocksUrl';
|
||||
import { validateHysteria2Url } from './validateHysteriaUrl';
|
||||
import { validateVmessUrl } from './validateVmessUrl';
|
||||
|
||||
// TODO refactor current validation and add tests
|
||||
export function validateProxyUrl(url: string): ValidationResult {
|
||||
@ -21,6 +22,10 @@ export function validateProxyUrl(url: string): ValidationResult {
|
||||
return validateTrojanUrl(trimmedUrl);
|
||||
}
|
||||
|
||||
if (trimmedUrl.startsWith('vmess://')) {
|
||||
return validateVmessUrl(trimmedUrl);
|
||||
}
|
||||
|
||||
if (/^socks(4|4a|5):\/\//.test(trimmedUrl)) {
|
||||
return validateSocksUrl(trimmedUrl);
|
||||
}
|
||||
@ -35,7 +40,7 @@ export function validateProxyUrl(url: string): ValidationResult {
|
||||
return {
|
||||
valid: false,
|
||||
message: _(
|
||||
'URL must start with vless://, ss://, trojan://, socks4/5://, or hysteria2://hy2://',
|
||||
'URL must start with vless://, vmess://, ss://, trojan://, socks4/5://, or hysteria2://hy2://',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
44
fe-app-netshift/src/validators/validateProxyUrlList.ts
Normal file
44
fe-app-netshift/src/validators/validateProxyUrlList.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { ValidationResult } from './types';
|
||||
import { validateProxyUrl } from './validateProxyUrl';
|
||||
|
||||
/**
|
||||
* Validate a textarea blob of proxy links (one per line).
|
||||
*
|
||||
* Splits on newlines, trims each line, ignores blank lines, then runs the
|
||||
* single-link `validateProxyUrl` on every remaining line. Returns the first
|
||||
* error encountered (annotated with the 1-based line number) or
|
||||
* `{ valid: true }` when every non-blank line is a valid proxy link.
|
||||
*/
|
||||
export function validateProxyUrlList(value: string): ValidationResult {
|
||||
const lines = value.split('\n');
|
||||
|
||||
let hasLink = false;
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index].trim();
|
||||
|
||||
if (line.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
hasLink = true;
|
||||
|
||||
const validation = validateProxyUrl(line);
|
||||
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `${_('Line')} ${index + 1}: ${validation.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasLink) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('At least one proxy link must be specified.'),
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
@ -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') };
|
||||
}
|
||||
|
||||
|
||||
87
fe-app-netshift/src/validators/validateVmessUrl.ts
Normal file
87
fe-app-netshift/src/validators/validateVmessUrl.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { ValidationResult } from './types';
|
||||
|
||||
export function validateVmessUrl(url: string): ValidationResult {
|
||||
if (!url.startsWith('vmess://')) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid VMess URL: must start with vmess://'),
|
||||
};
|
||||
}
|
||||
|
||||
const body = url.slice('vmess://'.length);
|
||||
|
||||
// The optional `#fragment` is the server display name (like #name in
|
||||
// vless/ss/trojan); the canonical name also lives in the JSON `ps` field.
|
||||
// Strip it BEFORE base64 decode (cut at the first '#'); the base64 body
|
||||
// never contains '#', so this is safe and matches the other schemes.
|
||||
const b64 = body.split('#')[0];
|
||||
|
||||
// Whitespace ordering: validate AFTER stripping the fragment, and only on
|
||||
// the base64 part. The base64 itself must contain no whitespace, but a
|
||||
// display-name fragment (e.g. "#The Netherlands") legitimately may.
|
||||
if (/\s/.test(b64)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid VMess URL: must not contain spaces'),
|
||||
};
|
||||
}
|
||||
|
||||
// VMess (V2RayN) is vmess:// + base64(JSON), not a user@host URL.
|
||||
// Tolerate unpadded base64 by right-padding to a multiple of 4, matching
|
||||
// the backend fix.
|
||||
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
||||
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = atob(padded);
|
||||
} catch (_e) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid VMess URL: malformed base64'),
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(decoded);
|
||||
} catch (_e) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid VMess URL: malformed JSON'),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid VMess URL: malformed JSON'),
|
||||
};
|
||||
}
|
||||
|
||||
const config = parsed as Record<string, unknown>;
|
||||
|
||||
if (typeof config.add !== 'string' || config.add.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid VMess URL: missing address'),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.id !== 'string' || config.id.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid VMess URL: missing id'),
|
||||
};
|
||||
}
|
||||
|
||||
const portNum = Number(config.port);
|
||||
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
return {
|
||||
valid: false,
|
||||
message: _('Invalid VMess URL: invalid port'),
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, message: _('Valid') };
|
||||
}
|
||||
134
install.sh
134
install.sh
@ -1,7 +1,13 @@
|
||||
#!/bin/sh
|
||||
# shellcheck shell=dash
|
||||
|
||||
REPO="https://api.github.com/repos/yandexru45/netshift/releases/latest"
|
||||
REPO="https://uralgit.ru/api/v1/repos/ural/netshift/releases/latest"
|
||||
# uralgit.ru FRONTEND redirect path (NOT the rate-limited api.github.com).
|
||||
# /releases/latest 303s to /releases/tag/<tag>; /releases/download/<tag>/<asset>
|
||||
# direct download. Primary install path so CGNAT / shared-IP routers avoid the
|
||||
# 60/hour/IP API limit; REPO stays as the fallback.
|
||||
RELEASES_LATEST_REDIRECT="https://uralgit.ru/ural/netshift/releases/latest"
|
||||
RELEASES_DOWNLOAD_BASE="https://uralgit.ru/ural/netshift/releases/download"
|
||||
DOWNLOAD_DIR="/tmp/netshift"
|
||||
COUNT=3
|
||||
|
||||
@ -57,7 +63,7 @@ pkg_install() {
|
||||
# If you're installing a non-standard (self-built) package, use the --allow-untrusted option:
|
||||
apk add --allow-untrusted "$pkg_file"
|
||||
else
|
||||
opkg install "$pkg_file"
|
||||
opkg install --force-downgrade --force-reinstall "$pkg_file"
|
||||
fi
|
||||
}
|
||||
|
||||
@ -66,7 +72,7 @@ update_config() {
|
||||
printf "\033[48;5;196m\033[1m║ ! Обнаружена старая версия NetShift. ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Если продолжите обновление, вам потребуется настроить NetShift заново.║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Старая конфигурация будет сохранена в /etc/config/netshift-070 ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Подробности: https://github.com/yandexru45/netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Подробности: https://uralgit.ru/ural/netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Точно хотите продолжить? ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
|
||||
|
||||
@ -76,7 +82,7 @@ update_config() {
|
||||
printf "\033[48;5;196m\033[1m║ ! Detected old NetShift version. ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ If you continue the update, you will need to RECONFIGURE NetShift. ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Your old configuration will be saved to /etc/config/netshift-070 ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Details: https://github.com/yandexru45/netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Details: https://uralgit.ru/ural/netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Are you sure you want to continue? ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
|
||||
|
||||
@ -88,7 +94,7 @@ update_config() {
|
||||
|
||||
yes|y|Y)
|
||||
mv /etc/config/netshift /etc/config/netshift-070
|
||||
wget -O /etc/config/netshift https://raw.githubusercontent.com/yandexru45/netshift/refs/heads/main/netshift/files/etc/config/netshift
|
||||
wget -O /etc/config/netshift https://uralgit.ru/ural/netshift/raw/branch/main/netshift/files/etc/config/netshift
|
||||
msg "NetShift config has been reset to default. Your old config saved in /etc/config/netshift-070"
|
||||
break
|
||||
;;
|
||||
@ -129,7 +135,7 @@ migrate_from_podkop() {
|
||||
printf "\033[48;5;196m\033[1m║ Ваша конфигурация будет перенесена автоматически. ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Старая конфигурация сохранится в /etc/config/podkop.bak.pre-netshift║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Старый пакет podkop будет удалён, NetShift будет установлен. ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Подробности: https://github.com/yandexru45/netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Подробности: https://uralgit.ru/ural/netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Точно хотите продолжить? ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
|
||||
|
||||
@ -140,7 +146,7 @@ migrate_from_podkop() {
|
||||
printf "\033[48;5;196m\033[1m║ Your configuration will be carried over automatically. ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Old config will be backed up to /etc/config/podkop.bak.pre-netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ The old podkop package will be removed, NetShift installed. ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Details: https://github.com/yandexru45/netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Details: https://uralgit.ru/ural/netshift ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m║ Are you sure you want to continue? ║\033[0m\n"
|
||||
printf "\033[48;5;196m\033[1m╚══════════════════════════════════════════════════════════════════════╝\033[0m\n"
|
||||
|
||||
@ -175,7 +181,8 @@ migrate_from_podkop() {
|
||||
/etc/init.d/podkop disable 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 4. Migrate config (copy, not move — keep a backup). Schema is compatible.
|
||||
# 4. Migrate config (copy first, then remove the original — we keep a
|
||||
# backup). Schema is compatible.
|
||||
if [ -f "/etc/config/podkop" ]; then
|
||||
if [ ! -f "/etc/config/netshift" ]; then
|
||||
msg "Migrating config /etc/config/podkop -> /etc/config/netshift..."
|
||||
@ -186,6 +193,14 @@ migrate_from_podkop() {
|
||||
if [ ! -f "/etc/config/podkop.bak.pre-netshift" ]; then
|
||||
cp /etc/config/podkop /etc/config/podkop.bak.pre-netshift 2>/dev/null || true
|
||||
fi
|
||||
# Remove the original /etc/config/podkop so a re-run does not keep
|
||||
# detecting an "old podkop install" (podkop_is_installed checks this
|
||||
# path). opkg/apk never delete user config, so we must do it here.
|
||||
# Only remove once the backup is confirmed present, to avoid data loss.
|
||||
if [ -f "/etc/config/podkop.bak.pre-netshift" ]; then
|
||||
msg "Removing migrated /etc/config/podkop (backup kept at podkop.bak.pre-netshift)..."
|
||||
rm -f /etc/config/podkop 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Migrate state dir (preserves subscription cache). Best-effort.
|
||||
@ -232,6 +247,30 @@ migrate_from_podkop() {
|
||||
msg "Your old config is preserved at /etc/config/podkop.bak.pre-netshift"
|
||||
}
|
||||
|
||||
# Download one release asset URL into $DOWNLOAD_DIR with retry. POSIX sh.
|
||||
download_release_asset() {
|
||||
url="$1"
|
||||
filename="$2"
|
||||
filepath="$DOWNLOAD_DIR/$filename"
|
||||
|
||||
attempt=0
|
||||
while [ $attempt -lt $COUNT ]; do
|
||||
msg "Download $filename (count $((attempt + 1)))..."
|
||||
if wget -q -O "$filepath" "$url"; then
|
||||
if [ -s "$filepath" ]; then
|
||||
msg "$filename successfully downloaded"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
msg "Download error for $filename. Retrying..."
|
||||
rm -f "$filepath"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
msg "Failed to download $filename after $COUNT attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
main() {
|
||||
check_system
|
||||
sing_box
|
||||
@ -246,44 +285,63 @@ main() {
|
||||
msg "Installing NetShift..."
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
check_response=$(curl -s "https://api.github.com/repos/yandexru45/netshift/releases/latest")
|
||||
|
||||
if echo "$check_response" | grep -q 'API rate limit '; then
|
||||
msg "You've reached the GitHub rate limit. Repeat in five minutes."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local grep_url_pattern
|
||||
local ext release_tag redirect_url
|
||||
if [ "$PKG_IS_APK" -eq 1 ]; then
|
||||
grep_url_pattern='https://[^"[:space:]]*\.apk'
|
||||
ext="apk"
|
||||
else
|
||||
grep_url_pattern='https://[^"[:space:]]*\.ipk'
|
||||
ext="ipk"
|
||||
fi
|
||||
|
||||
wget -qO- "$REPO" | grep -o "$grep_url_pattern" | while read -r url; do
|
||||
filename=$(basename "$url")
|
||||
filepath="$DOWNLOAD_DIR/$filename"
|
||||
# PRIMARY: resolve the latest tag via the uralgit.ru frontend redirect (no
|
||||
# api.github.com hit → not subject to the 60/hour/IP rate limit), then build
|
||||
# the deterministic releases/download/<tag>/<asset> URLs and download them.
|
||||
release_tag=""
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
redirect_url=$(curl -sI -o /dev/null -w '%{redirect_url}' \
|
||||
--connect-timeout 5 -m 15 -A 'netshift-installer' \
|
||||
"$RELEASES_LATEST_REDIRECT" 2>/dev/null)
|
||||
case "$redirect_url" in
|
||||
*/releases/tag/*)
|
||||
release_tag="${redirect_url##*/releases/tag/}"
|
||||
case "$release_tag" in '' | */*) release_tag="" ;; esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
attempt=0
|
||||
while [ $attempt -lt $COUNT ]; do
|
||||
msg "Download $filename (count $((attempt+1)))..."
|
||||
if wget -q -O "$filepath" "$url"; then
|
||||
if [ -s "$filepath" ]; then
|
||||
msg "$filename successfully downloaded"
|
||||
break
|
||||
fi
|
||||
if [ -n "$release_tag" ]; then
|
||||
msg "Latest NetShift release: $release_tag (direct download, no GitHub API)"
|
||||
for pkg in netshift luci-app-netshift; do
|
||||
if [ "$ext" = "ipk" ]; then
|
||||
filename="${pkg}-${release_tag}-r1-all.${ext}"
|
||||
else
|
||||
filename="${pkg}-${release_tag}-r1.${ext}"
|
||||
fi
|
||||
msg "Download error for $filename. Retrying..."
|
||||
rm -f "$filepath"
|
||||
attempt=$((attempt+1))
|
||||
download_release_asset "$RELEASES_DOWNLOAD_BASE/$release_tag/$filename" "$filename"
|
||||
done
|
||||
|
||||
if [ $attempt -eq $COUNT ]; then
|
||||
msg "Failed to download $filename after $COUNT attempts"
|
||||
# RU i18n only if already installed (mirrors the install flow below).
|
||||
if pkg_is_installed luci-i18n-netshift-ru; then
|
||||
filename="luci-i18n-netshift-ru-${release_tag}.${ext}"
|
||||
download_release_asset "$RELEASES_DOWNLOAD_BASE/$release_tag/$filename" "$filename"
|
||||
fi
|
||||
done
|
||||
else
|
||||
# FALLBACK: scrape the api.github.com release JSON for .ipk/.apk URLs.
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
check_response=$(curl -s "$REPO")
|
||||
|
||||
if echo "$check_response" | grep -q 'API rate limit '; then
|
||||
msg "You've reached the GitHub rate limit. Repeat in five minutes."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local grep_url_pattern
|
||||
grep_url_pattern="https://[^\"[:space:]]*\.${ext}"
|
||||
|
||||
wget -qO- "$REPO" | grep -o "$grep_url_pattern" | while read -r url; do
|
||||
filename=$(basename "$url")
|
||||
download_release_asset "$url" "$filename"
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if any files were downloaded
|
||||
if ! ls "$DOWNLOAD_DIR"/*netshift* >/dev/null 2>&1; then
|
||||
|
||||
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();
|
||||
@ -29,6 +32,21 @@ const EntryPoint = {
|
||||
// Enable tab views
|
||||
netshiftMap.tabbed = true;
|
||||
|
||||
// Dashboard tab (first / landing tab)
|
||||
const dashboardSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
"dashboard",
|
||||
_("Dashboard"),
|
||||
);
|
||||
dashboardSection.anonymous = true;
|
||||
dashboardSection.addremove = false;
|
||||
dashboardSection.cfgsections = function () {
|
||||
return ["dashboard"];
|
||||
};
|
||||
|
||||
// Render dashboard content
|
||||
dashboard.createDashboardContent(dashboardSection);
|
||||
|
||||
// Sections tab
|
||||
const sectionsSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
@ -58,6 +76,21 @@ const EntryPoint = {
|
||||
// Render settings content
|
||||
settings.createSettingsContent(settingsSection);
|
||||
|
||||
// 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);
|
||||
|
||||
// Diagnostic tab
|
||||
const diagnosticSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
@ -73,21 +106,6 @@ const EntryPoint = {
|
||||
// Render diagnostic content
|
||||
diagnostic.createDiagnosticContent(diagnosticSection);
|
||||
|
||||
// Dashboard tab
|
||||
const dashboardSection = netshiftMap.section(
|
||||
form.TypedSection,
|
||||
"dashboard",
|
||||
_("Dashboard"),
|
||||
);
|
||||
dashboardSection.anonymous = true;
|
||||
dashboardSection.addremove = false;
|
||||
dashboardSection.cfgsections = function () {
|
||||
return ["dashboard"];
|
||||
};
|
||||
|
||||
// Render dashboard content
|
||||
dashboard.createDashboardContent(dashboardSection);
|
||||
|
||||
// Inject core service
|
||||
main.coreService();
|
||||
|
||||
|
||||
@ -6,7 +6,34 @@
|
||||
"require view.netshift.main as main";
|
||||
|
||||
function createSectionContent(section) {
|
||||
let o = section.option(
|
||||
// Group the 36 connection options into 4 native CBI option-group tabs.
|
||||
// HARD RULE: once a section has tab(), every option MUST be added via
|
||||
// taboption() — any leftover section.option(...) renders nothing.
|
||||
// depends() works across tabs; a tab whose options are all depends-hidden
|
||||
// auto-hides from the strip (desired, e.g. Subscription for proxy/url).
|
||||
section.tab(
|
||||
"connection",
|
||||
_("Connection"),
|
||||
_("Connection type, transport and DNS resolver for this section"),
|
||||
);
|
||||
section.tab(
|
||||
"subscription",
|
||||
_("Subscription"),
|
||||
_("Subscription feeds, server filters and URLTest tuning"),
|
||||
);
|
||||
section.tab(
|
||||
"routing",
|
||||
_("Routing"),
|
||||
_("Domain and subnet lists that decide which traffic uses this section"),
|
||||
);
|
||||
section.tab(
|
||||
"advanced",
|
||||
_("Advanced"),
|
||||
_("Mixed proxy and DNS resolution tuning"),
|
||||
);
|
||||
|
||||
let o = section.taboption(
|
||||
"connection",
|
||||
form.ListValue,
|
||||
"connection_type",
|
||||
_("Connection Type"),
|
||||
@ -17,7 +44,8 @@ function createSectionContent(section) {
|
||||
o.value("block", "Block");
|
||||
o.value("exclusion", "Exclusion");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.ListValue,
|
||||
"proxy_config_type",
|
||||
_("Configuration Type"),
|
||||
@ -26,16 +54,21 @@ function createSectionContent(section) {
|
||||
o.value("url", _("Connection URL"));
|
||||
o.value("selector", _("Selector"));
|
||||
o.value("urltest", _("URLTest"));
|
||||
o.value("selector_text", _("Selector (text list)"));
|
||||
o.value("urltest_text", _("URLTest (text list)"));
|
||||
o.value("subscription", _("Subscription"));
|
||||
o.value("outbound", _("Outbound Config"));
|
||||
o.default = "url";
|
||||
o.depends("connection_type", "proxy");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.TextValue,
|
||||
"proxy_string",
|
||||
_("Proxy Configuration URL"),
|
||||
_("vless://, 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;
|
||||
@ -60,7 +93,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.TextValue,
|
||||
"outbound_json",
|
||||
_("Outbound Configuration"),
|
||||
@ -83,15 +117,18 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
form.Value,
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.DynamicList,
|
||||
"subscription_url",
|
||||
_("Subscription URL"),
|
||||
_("Enter the subscription URL to fetch proxy configurations from your provider"),
|
||||
_("Subscription URLs"),
|
||||
_(
|
||||
"Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged.",
|
||||
),
|
||||
);
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
o.placeholder = "https://example.com/api/sub";
|
||||
o.rmempty = false;
|
||||
o.rmempty = true;
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
@ -106,7 +143,38 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.ListValue,
|
||||
"subscription_format_preference",
|
||||
_("Subscription format"),
|
||||
_(
|
||||
"Which subscription format (client) to fetch first. Auto uses the default order. Choose Xray JSON (Happ) when your panel only exposes some nodes (e.g. xhttp) under a Happ-like client, or Sing-box to prefer the sing-box format.",
|
||||
),
|
||||
);
|
||||
o.value("auto", _("Auto"));
|
||||
o.value("xray", _("Xray JSON (Happ)"));
|
||||
o.value("singbox", _("Sing-box"));
|
||||
o.default = "auto";
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.Flag,
|
||||
"subscription_insecure",
|
||||
_("Allow insecure TLS for subscription fetch"),
|
||||
_("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.taboption(
|
||||
"subscription",
|
||||
form.ListValue,
|
||||
"subscription_update_interval",
|
||||
_("Subscription Update Interval"),
|
||||
@ -121,21 +189,70 @@ function createSectionContent(section) {
|
||||
o.default = "1h";
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"subscription_group_by_countries",
|
||||
_("Группировать по странам"),
|
||||
_("Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"),
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.ListValue,
|
||||
"subscription_group_mode",
|
||||
_("Subscription grouping"),
|
||||
_(
|
||||
"Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters.",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.value("off", _("Off"));
|
||||
o.value("country", _("By country flag"));
|
||||
o.value("prefix", _("By name prefix"));
|
||||
o.default = "off";
|
||||
o.rmempty = false;
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.Value,
|
||||
"subscription_group_prefix_len",
|
||||
_("Prefix length"),
|
||||
_("Number of leading characters of each proxy name to group by."),
|
||||
);
|
||||
o.default = "2";
|
||||
o.datatype = "and(uinteger,min(1))";
|
||||
o.rmempty = false;
|
||||
o.depends({
|
||||
connection_type: "proxy",
|
||||
proxy_config_type: "subscription",
|
||||
subscription_group_mode: "prefix",
|
||||
});
|
||||
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.DynamicList,
|
||||
"subscription_filter_include_keywords",
|
||||
_("Include servers by keyword"),
|
||||
_(
|
||||
"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;
|
||||
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.DynamicList,
|
||||
"subscription_filter_exclude_keywords",
|
||||
_("Exclude servers by keyword"),
|
||||
_(
|
||||
"Drop subscription servers whose name contains any of these keywords (case-insensitive).",
|
||||
),
|
||||
);
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
o.rmempty = true;
|
||||
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.DynamicList,
|
||||
"selector_proxy_links",
|
||||
_("Selector Proxy Links"),
|
||||
_("vless://, 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;
|
||||
@ -154,11 +271,43 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.TextValue,
|
||||
"selector_proxy_links_text",
|
||||
_("Selector Proxy Links (one per line)"),
|
||||
_(
|
||||
"vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line",
|
||||
),
|
||||
);
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "selector_text" });
|
||||
o.rows = 5;
|
||||
o.wrap = "soft";
|
||||
o.textarea = true;
|
||||
o.rmempty = false;
|
||||
o.validate = function (section_id, value) {
|
||||
// Optional
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const validation = main.validateProxyUrlList(value);
|
||||
|
||||
if (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.DynamicList,
|
||||
"urltest_proxy_links",
|
||||
_("URLTest Proxy Links"),
|
||||
_("vless://, 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;
|
||||
@ -177,11 +326,41 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.TextValue,
|
||||
"urltest_proxy_links_text",
|
||||
_("URLTest Proxy Links (one per line)"),
|
||||
_(
|
||||
"vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line",
|
||||
),
|
||||
);
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "urltest_text" });
|
||||
o.rows = 5;
|
||||
o.wrap = "soft";
|
||||
o.textarea = true;
|
||||
o.rmempty = false;
|
||||
o.validate = function (section_id, value) {
|
||||
// Optional
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const validation = main.validateProxyUrlList(value);
|
||||
|
||||
if (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.ListValue,
|
||||
"urltest_check_interval",
|
||||
_("URLTest Check Interval"),
|
||||
_("The interval between connectivity tests")
|
||||
_("The interval between connectivity tests"),
|
||||
);
|
||||
o.value("30s", _("Every 30 seconds"));
|
||||
o.value("1m", _("Every 1 minute"));
|
||||
@ -189,17 +368,22 @@ function createSectionContent(section) {
|
||||
o.value("5m", _("Every 5 minutes"));
|
||||
o.default = "3m";
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "urltest" });
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "urltest_text" });
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
form.Value,
|
||||
"urltest_tolerance",
|
||||
_("URLTest Tolerance"),
|
||||
_("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;
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "urltest" });
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "urltest_text" });
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value || value.length === 0) {
|
||||
@ -208,26 +392,43 @@ 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(
|
||||
o = section.taboption(
|
||||
"subscription",
|
||||
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" });
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "urltest_text" });
|
||||
o.depends({ connection_type: "proxy", proxy_config_type: "subscription" });
|
||||
|
||||
o.validate = function (section_id, value) {
|
||||
@ -244,7 +445,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.Flag,
|
||||
"enable_udp_over_tcp",
|
||||
_("UDP over TCP"),
|
||||
@ -254,7 +456,26 @@ function createSectionContent(section) {
|
||||
o.depends("connection_type", "proxy");
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
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.taboption(
|
||||
"connection",
|
||||
widgets.DeviceSelect,
|
||||
"interface",
|
||||
_("Network Interface"),
|
||||
@ -300,7 +521,8 @@ function createSectionContent(section) {
|
||||
return !isWireless;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.Flag,
|
||||
"domain_resolver_enabled",
|
||||
_("Domain Resolver"),
|
||||
@ -310,7 +532,8 @@ function createSectionContent(section) {
|
||||
o.rmempty = false;
|
||||
o.depends("connection_type", "vpn");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.ListValue,
|
||||
"domain_resolver_dns_type",
|
||||
_("DNS Protocol Type"),
|
||||
@ -323,7 +546,8 @@ function createSectionContent(section) {
|
||||
o.rmempty = false;
|
||||
o.depends("domain_resolver_enabled", "1");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"connection",
|
||||
form.Value,
|
||||
"domain_resolver_dns_server",
|
||||
_("DNS Server"),
|
||||
@ -345,12 +569,13 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"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]) => {
|
||||
@ -433,11 +658,18 @@ function createSectionContent(section) {
|
||||
}
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
// --- Custom domains group (mode selector + the matching input below) ---
|
||||
// Three UCI keys kept (user_domain_list_type, user_domains,
|
||||
// user_domains_text); depends() shows only the input for the chosen mode,
|
||||
// so the trio reads as one "list-or-text" control.
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.ListValue,
|
||||
"user_domain_list_type",
|
||||
_("User Domain List Type"),
|
||||
_("Select the list type for adding custom domains"),
|
||||
_("Custom domains"),
|
||||
_(
|
||||
"Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip",
|
||||
),
|
||||
);
|
||||
o.value("disabled", _("Disabled"));
|
||||
o.value("dynamic", _("Dynamic List"));
|
||||
@ -445,7 +677,8 @@ function createSectionContent(section) {
|
||||
o.default = "disabled";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"user_domains",
|
||||
_("User Domains"),
|
||||
@ -471,7 +704,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.TextValue,
|
||||
"user_domains_text",
|
||||
_("User Domains List"),
|
||||
@ -513,11 +747,17 @@ function createSectionContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
// --- Custom subnets group (mode selector + the matching input below) ---
|
||||
// Same pattern as the domains group; keeps user_subnet_list_type,
|
||||
// user_subnets and user_subnets_text as separate UCI keys.
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.ListValue,
|
||||
"user_subnet_list_type",
|
||||
_("User Subnet List Type"),
|
||||
_("Select the list type for adding custom subnets"),
|
||||
_("Custom subnets"),
|
||||
_(
|
||||
"Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip",
|
||||
),
|
||||
);
|
||||
o.value("disabled", _("Disabled"));
|
||||
o.value("dynamic", _("Dynamic List"));
|
||||
@ -525,7 +765,8 @@ function createSectionContent(section) {
|
||||
o.default = "disabled";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"user_subnets",
|
||||
_("User Subnets"),
|
||||
@ -551,13 +792,14 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.TextValue,
|
||||
"user_subnets_text",
|
||||
_("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 =
|
||||
@ -592,7 +834,8 @@ function createSectionContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"local_domain_lists",
|
||||
_("Local Domain Lists"),
|
||||
@ -615,7 +858,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"local_subnet_lists",
|
||||
_("Local Subnet Lists"),
|
||||
@ -638,7 +882,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"remote_domain_lists",
|
||||
_("Remote Domain Lists"),
|
||||
@ -661,7 +906,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"remote_subnet_lists",
|
||||
_("Remote Subnet Lists"),
|
||||
@ -684,7 +930,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"routing",
|
||||
form.DynamicList,
|
||||
"fully_routed_ips",
|
||||
_("Fully Routed IPs"),
|
||||
@ -711,7 +958,8 @@ function createSectionContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"mixed_proxy_enabled",
|
||||
_("Enable Mixed Proxy"),
|
||||
@ -724,13 +972,14 @@ function createSectionContent(section) {
|
||||
o.depends("connection_type", "proxy");
|
||||
o.depends("connection_type", "vpn");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Value,
|
||||
"mixed_proxy_port",
|
||||
_("Mixed Proxy Port"),
|
||||
_(
|
||||
"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";
|
||||
@ -739,7 +988,8 @@ function createSectionContent(section) {
|
||||
o.rmempty = true;
|
||||
o.depends("mixed_proxy_enabled", "1");
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"resolve_real_ip_for_routing",
|
||||
_("Resolve real IP for routing"),
|
||||
|
||||
@ -6,7 +6,40 @@
|
||||
"require view.netshift.main as main";
|
||||
|
||||
function createSettingsContent(section) {
|
||||
let o = section.option(
|
||||
// Group the 27 settings options into 5 native CBI option-group tabs.
|
||||
// HARD RULE: once a section has tab(), every option MUST be added via
|
||||
// taboption() — any leftover section.option(...) renders nothing.
|
||||
// depends() works across tabs; a tab whose options are all depends-hidden
|
||||
// auto-hides from the strip.
|
||||
section.tab(
|
||||
"dns",
|
||||
_("DNS"),
|
||||
_("Upstream and bootstrap DNS resolvers, and optional DNS-over-proxy"),
|
||||
);
|
||||
section.tab(
|
||||
"network",
|
||||
_("Network"),
|
||||
_("Source and output interfaces, and Bad WAN interface monitoring"),
|
||||
);
|
||||
section.tab(
|
||||
"lists",
|
||||
_("Lists & Updates"),
|
||||
_("List update schedule, download routing, and routing exclusions"),
|
||||
);
|
||||
section.tab(
|
||||
"yacd",
|
||||
_("Dashboard"),
|
||||
_("YACD web dashboard access and remote-access protection"),
|
||||
);
|
||||
section.tab(
|
||||
"advanced",
|
||||
_("Advanced"),
|
||||
_("Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."),
|
||||
);
|
||||
|
||||
// --- DNS tab ---
|
||||
let o = section.taboption(
|
||||
"dns",
|
||||
form.ListValue,
|
||||
"dns_type",
|
||||
_("DNS Protocol Type"),
|
||||
@ -18,7 +51,8 @@ function createSettingsContent(section) {
|
||||
o.default = "udp";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"dns",
|
||||
form.Value,
|
||||
"dns_server",
|
||||
_("DNS Server"),
|
||||
@ -39,7 +73,8 @@ function createSettingsContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"dns",
|
||||
form.Value,
|
||||
"bootstrap_dns_server",
|
||||
_("Bootstrap DNS server"),
|
||||
@ -62,7 +97,55 @@ function createSettingsContent(section) {
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"dns",
|
||||
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.taboption(
|
||||
"dns",
|
||||
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.taboption(
|
||||
"dns",
|
||||
form.Value,
|
||||
"dns_rewrite_ttl",
|
||||
_("DNS Rewrite TTL"),
|
||||
@ -83,7 +166,9 @@ function createSettingsContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
// --- Network tab ---
|
||||
o = section.taboption(
|
||||
"network",
|
||||
widgets.DeviceSelect,
|
||||
"source_network_interfaces",
|
||||
_("Source Network Interface"),
|
||||
@ -120,7 +205,8 @@ function createSettingsContent(section) {
|
||||
return !isWireless;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
form.Flag,
|
||||
"enable_output_network_interface",
|
||||
_("Enable Output Network Interface"),
|
||||
@ -129,7 +215,8 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
widgets.DeviceSelect,
|
||||
"output_network_interface",
|
||||
_("Output Network Interface"),
|
||||
@ -148,9 +235,7 @@ function createSettingsContent(section) {
|
||||
}
|
||||
|
||||
// Reject lan*
|
||||
if (
|
||||
value.startsWith("lan")
|
||||
) {
|
||||
if (value.startsWith("lan")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -183,7 +268,8 @@ function createSettingsContent(section) {
|
||||
return !isWireless;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
form.Flag,
|
||||
"enable_badwan_interface_monitoring",
|
||||
_("Interface Monitoring"),
|
||||
@ -192,7 +278,8 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
widgets.NetworkSelect,
|
||||
"badwan_monitored_interfaces",
|
||||
_("Monitored Interfaces"),
|
||||
@ -215,7 +302,8 @@ function createSettingsContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"network",
|
||||
form.Value,
|
||||
"badwan_reload_delay",
|
||||
_("Interface Monitoring Delay"),
|
||||
@ -231,46 +319,9 @@ function createSettingsContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"enable_yacd",
|
||||
_("Enable YACD"),
|
||||
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"enable_yacd_wan_access",
|
||||
_("Enable YACD WAN Access"),
|
||||
_("Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall."),
|
||||
);
|
||||
o.depends("enable_yacd", "1");
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Value,
|
||||
"yacd_secret_key",
|
||||
_("YACD Secret Key"),
|
||||
_("Secret key for authenticating remote access to YACD when WAN access is enabled."),
|
||||
);
|
||||
o.depends("enable_yacd_wan_access", "1");
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"disable_quic",
|
||||
_("Disable QUIC"),
|
||||
_(
|
||||
"Disable the QUIC protocol to improve compatibility or fix issues with video streaming",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
// --- Lists & Updates tab ---
|
||||
o = section.taboption(
|
||||
"lists",
|
||||
form.ListValue,
|
||||
"update_interval",
|
||||
_("List Update Frequency"),
|
||||
@ -282,7 +333,8 @@ function createSettingsContent(section) {
|
||||
o.default = "1d";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"lists",
|
||||
form.Flag,
|
||||
"download_lists_via_proxy",
|
||||
_("Download Lists via Proxy/VPN"),
|
||||
@ -291,7 +343,8 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"lists",
|
||||
form.ListValue,
|
||||
"download_lists_via_proxy_section",
|
||||
_("Download Lists via specific proxy section"),
|
||||
@ -311,7 +364,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);
|
||||
}
|
||||
@ -320,7 +377,81 @@ function createSettingsContent(section) {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"lists",
|
||||
form.DynamicList,
|
||||
"routing_excluded_ips",
|
||||
_("Routing Excluded IPs"),
|
||||
_("Specify a local IP address to be excluded from routing"),
|
||||
);
|
||||
o.placeholder = "IP";
|
||||
o.rmempty = true;
|
||||
o.validate = function (section_id, value) {
|
||||
// Optional
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const validation = main.validateIP(value);
|
||||
|
||||
if (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
// --- Dashboard / YACD tab ---
|
||||
o = section.taboption(
|
||||
"yacd",
|
||||
form.Flag,
|
||||
"enable_yacd",
|
||||
_("Enable YACD"),
|
||||
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"yacd",
|
||||
form.Flag,
|
||||
"enable_yacd_wan_access",
|
||||
_("Enable YACD WAN Access"),
|
||||
_(
|
||||
"Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall.",
|
||||
),
|
||||
);
|
||||
o.depends("enable_yacd", "1");
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"yacd",
|
||||
form.Value,
|
||||
"yacd_secret_key",
|
||||
_("YACD Secret Key"),
|
||||
_(
|
||||
"Secret key for authenticating remote access to YACD when WAN access is enabled.",
|
||||
),
|
||||
);
|
||||
o.depends("enable_yacd_wan_access", "1");
|
||||
o.rmempty = false;
|
||||
|
||||
// --- Advanced tab ---
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"disable_quic",
|
||||
_("Disable QUIC"),
|
||||
_(
|
||||
"Disable the QUIC protocol to improve compatibility or fix issues with video streaming",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"dont_touch_dhcp",
|
||||
_("Dont Touch My DHCP!"),
|
||||
@ -329,7 +460,44 @@ function createSettingsContent(section) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"exclude_ntp",
|
||||
_("Exclude NTP"),
|
||||
_(
|
||||
"Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"block_doh",
|
||||
_("Block DoH Servers"),
|
||||
_(
|
||||
"Block direct connections to known public DoH servers (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex) so apps cannot bypass router DNS filtering.",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"enable_ipv6",
|
||||
_("Enable IPv6 Support"),
|
||||
_("Enable IPv6 TProxy routing, IPv6 DNS inbound, and IPv6 FakeIP support.") +
|
||||
" " +
|
||||
_("Use this only when the router has working IPv6 connectivity."),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.ListValue,
|
||||
"config_path",
|
||||
_("Config File Path"),
|
||||
@ -342,7 +510,8 @@ function createSettingsContent(section) {
|
||||
o.default = "/etc/sing-box/config.json";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Value,
|
||||
"cache_path",
|
||||
_("Cache File Path"),
|
||||
@ -378,13 +547,12 @@ function createSettingsContent(section) {
|
||||
return true;
|
||||
};
|
||||
|
||||
o = section.option(
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
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");
|
||||
@ -395,40 +563,6 @@ function createSettingsContent(section) {
|
||||
o.value("panic", "Panic");
|
||||
o.default = "warn";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.Flag,
|
||||
"exclude_ntp",
|
||||
_("Exclude NTP"),
|
||||
_(
|
||||
"Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.option(
|
||||
form.DynamicList,
|
||||
"routing_excluded_ips",
|
||||
_("Routing Excluded IPs"),
|
||||
_("Specify a local IP address to be excluded from routing"),
|
||||
);
|
||||
o.placeholder = "IP";
|
||||
o.rmempty = true;
|
||||
o.validate = function (section_id, value) {
|
||||
// Optional
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const validation = main.validateIPV4(value);
|
||||
|
||||
if (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validation.message;
|
||||
};
|
||||
}
|
||||
|
||||
const EntryPoint = {
|
||||
|
||||
@ -7,8 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: NETSHIFT\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-06-02 17:15+0300\n"
|
||||
"PO-Revision-Date: 2026-06-02 17:15+0300\n"
|
||||
"POT-Creation-Date: 2026-06-13 14:14+0300\n"
|
||||
"PO-Revision-Date: 2026-06-13 14:14+0300\n"
|
||||
"Last-Translator: yandexru45\n"
|
||||
"Language-Team: none\n"
|
||||
"Language: ru\n"
|
||||
@ -29,33 +29,54 @@ msgstr "✘ Отключено"
|
||||
msgid "✘ Stopped"
|
||||
msgstr "✘ Остановлен"
|
||||
|
||||
msgid "Группировать по странам"
|
||||
msgstr ""
|
||||
|
||||
msgid "Группирует прокси подписки по флагу страны в начале тега в отдельные URLTest-группы"
|
||||
msgstr ""
|
||||
|
||||
msgid "Active Connections"
|
||||
msgstr "Активные соединения"
|
||||
|
||||
msgid "Add one or more subscription URLs to fetch proxy configurations from. All feeds are downloaded and merged."
|
||||
msgstr "Добавьте один или несколько URL подписок для получения конфигураций прокси. Все источники загружаются и объединяются."
|
||||
|
||||
msgid "Add your own domains: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
|
||||
msgstr "Добавьте свои домены: выберите Динамический список (по одному в строке) или Текстовый список (свободный ввод), либо Отключено, чтобы пропустить"
|
||||
|
||||
msgid "Add your own subnets or IPs: choose Dynamic List (one per row) or Text List (free-form), or Disabled to skip"
|
||||
msgstr "Добавьте свои подсети или IP: выберите Динамический список (по одному в строке) или Текстовый список (свободный ввод), либо Отключено, чтобы пропустить"
|
||||
|
||||
msgid "Additional marking rules found"
|
||||
msgstr "Найдены дополнительные правила маркировки"
|
||||
|
||||
msgid "Advanced"
|
||||
msgstr "Дополнительно"
|
||||
|
||||
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. Убедитесь, что в брандмауэре открыт соответствующий порт."
|
||||
|
||||
msgid "Applicable for SOCKS and Shadowsocks proxy"
|
||||
msgstr "Применимо для SOCKS и Shadowsocks прокси"
|
||||
|
||||
msgid "At least one proxy link must be specified."
|
||||
msgstr "Необходимо указать хотя бы одну прокси-ссылку."
|
||||
|
||||
msgid "At least one valid domain must be specified. Comments-only content is not allowed."
|
||||
msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
|
||||
|
||||
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
|
||||
msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Available actions"
|
||||
msgstr "Доступные действия"
|
||||
|
||||
msgid "Block direct connections to known public DoH servers (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex) so apps cannot bypass router DNS filtering."
|
||||
msgstr "Блокировать прямые подключения к известным публичным DoH-серверам (Cloudflare, Google, Quad9, OpenDNS, AdGuard, Yandex), чтобы приложения не могли обойти DNS-фильтрацию роутера."
|
||||
|
||||
msgid "Block DoH Servers"
|
||||
msgstr "Блокировать DoH-серверы"
|
||||
|
||||
msgid "Bootsrap DNS"
|
||||
msgstr "Bootstrap DNS"
|
||||
|
||||
@ -68,6 +89,12 @@ msgstr "Браузер не использует FakeIP"
|
||||
msgid "Browser is using FakeIP correctly"
|
||||
msgstr "Браузер использует FakeIP"
|
||||
|
||||
msgid "By country flag"
|
||||
msgstr "По флагу страны"
|
||||
|
||||
msgid "By name prefix"
|
||||
msgstr "По префиксу имени"
|
||||
|
||||
msgid "Cache File Path"
|
||||
msgstr "Путь к файлу кэша"
|
||||
|
||||
@ -77,6 +104,9 @@ msgstr "Путь к файлу кэша не может быть пустым"
|
||||
msgid "Cannot receive checks result"
|
||||
msgstr "Не удалось получить результаты проверки"
|
||||
|
||||
msgid "Check update"
|
||||
msgstr "Проверить обновление"
|
||||
|
||||
msgid "Checking, please wait"
|
||||
msgstr "Проверяем, пожалуйста подождите"
|
||||
|
||||
@ -92,33 +122,60 @@ msgstr "Проверки пройдены"
|
||||
msgid "CIDR must be between 0 and 32"
|
||||
msgstr "CIDR должен быть между 0 и 32"
|
||||
|
||||
msgid "Clear subscription cache"
|
||||
msgstr "Очистить кеш подписок"
|
||||
|
||||
msgid "Clearing subscription cache and re-downloading… this may take a minute"
|
||||
msgstr "Очистка кеша подписок и повторная загрузка… это может занять минуту"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
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 "Тип конфигурации"
|
||||
|
||||
msgid "Connection"
|
||||
msgstr "Подключение"
|
||||
|
||||
msgid "Connection Type"
|
||||
msgstr "Тип подключения"
|
||||
|
||||
msgid "Connection type, transport and DNS resolver for this section"
|
||||
msgstr "Тип подключения, транспорт и DNS-резолвер для этой секции"
|
||||
|
||||
msgid "Connection URL"
|
||||
msgstr "URL подключения"
|
||||
|
||||
msgid "Copy"
|
||||
msgstr "Копировать"
|
||||
|
||||
msgid "Core switch failed"
|
||||
msgstr "Не удалось переключить ядро"
|
||||
|
||||
msgid "Core switch timed out"
|
||||
msgstr "Истекло время ожидания переключения ядра"
|
||||
|
||||
msgid "Currently unavailable"
|
||||
msgstr "Временно недоступно"
|
||||
|
||||
msgid "Custom domains"
|
||||
msgstr "Свои домены"
|
||||
|
||||
msgid "Custom subnets"
|
||||
msgstr "Свои подсети"
|
||||
|
||||
msgid "Dashboard"
|
||||
msgstr "Дашборд"
|
||||
|
||||
@ -126,11 +183,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 сервер"
|
||||
|
||||
@ -149,9 +209,18 @@ msgstr "Отключить QUIC протокол для улучшения со
|
||||
msgid "Disabled"
|
||||
msgstr "Отключено"
|
||||
|
||||
msgid "Disables TLS certificate verification when downloading the subscription."
|
||||
msgstr "Отключает проверку TLS-сертификата при загрузке подписки."
|
||||
|
||||
msgid "DNS"
|
||||
msgstr "DNS"
|
||||
|
||||
msgid "DNS on router"
|
||||
msgstr "DNS на роутере"
|
||||
|
||||
msgid "DNS outbound section"
|
||||
msgstr "Секция outbound для DNS"
|
||||
|
||||
msgid "DNS over HTTPS (DoH)"
|
||||
msgstr "DNS через HTTPS (DoH)"
|
||||
|
||||
@ -173,6 +242,9 @@ msgstr "Адрес DNS-сервера не может быть пустым"
|
||||
msgid "Do not panic, everything can be fixed, just..."
|
||||
msgstr "Не паникуйте, всё можно исправить, просто..."
|
||||
|
||||
msgid "Domain and subnet lists that decide which traffic uses this section"
|
||||
msgstr "Списки доменов и подсетей, определяющие, какой трафик идёт через эту секцию"
|
||||
|
||||
msgid "Domain Resolver"
|
||||
msgstr "Резолвер доменов"
|
||||
|
||||
@ -194,6 +266,9 @@ msgstr "Скачивать списки через выбранную секци
|
||||
msgid "Downloading all lists via specific Proxy/VPN"
|
||||
msgstr "Загрузка всех списков через указанный прокси/VPN"
|
||||
|
||||
msgid "Drop subscription servers whose name contains any of these keywords (case-insensitive)."
|
||||
msgstr "Исключать серверы подписки, имя которых содержит любое из этих ключевых слов (без учёта регистра)."
|
||||
|
||||
msgid "Dynamic List"
|
||||
msgstr "Динамический список"
|
||||
|
||||
@ -206,6 +281,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 "Включить смешанный прокси"
|
||||
|
||||
@ -233,23 +314,20 @@ msgstr "Введите доменные имена без протоколов,
|
||||
msgid "Enter subnets in CIDR notation (e.g. 103.21.244.0/22) or single IP addresses"
|
||||
msgstr "Введите подсети в нотации CIDR (например, 103.21.244.0/22) или отдельные IP-адреса"
|
||||
|
||||
msgid "Enter the subscription URL to fetch proxy configurations from your provider"
|
||||
msgstr ""
|
||||
|
||||
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 секунд"
|
||||
@ -258,13 +336,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"
|
||||
@ -272,6 +350,12 @@ msgstr "Исключить NTP"
|
||||
msgid "Exclude NTP protocol traffic from the tunnel to prevent it from being routed through the proxy or VPN"
|
||||
msgstr "Исключите трафик протокола NTP из туннеля, чтобы предотвратить его маршрутизацию через прокси-сервер или VPN."
|
||||
|
||||
msgid "Exclude servers by keyword"
|
||||
msgstr "Исключать серверы по ключевому слову"
|
||||
|
||||
msgid "Failed to clear subscription cache"
|
||||
msgstr "Не удалось очистить кеш подписок"
|
||||
|
||||
msgid "Failed to copy!"
|
||||
msgstr "Не удалось скопировать!"
|
||||
|
||||
@ -290,17 +374,26 @@ msgstr "Получить глобальную проверку"
|
||||
msgid "Global check"
|
||||
msgstr "Глобальная проверка"
|
||||
|
||||
msgid "Global Proxy"
|
||||
msgstr "Глобальный прокси"
|
||||
|
||||
msgid "Group subscription proxies into URLTest groups. 'By country flag' uses the flag emoji at the start of each name; 'By name prefix' groups by the first N characters."
|
||||
msgstr "Группировать прокси из подписки в группы URLTest. «По флагу страны» использует эмодзи флага в начале каждого имени; «По префиксу имени» группирует по первым N символам."
|
||||
|
||||
msgid "How often to automatically update the subscription"
|
||||
msgstr ""
|
||||
msgstr "Как часто автоматически обновлять подписку"
|
||||
|
||||
msgid "HTTP error"
|
||||
msgstr "Ошибка HTTP"
|
||||
|
||||
msgid "Install extended"
|
||||
msgstr "Установить extended"
|
||||
msgid "Include servers by keyword"
|
||||
msgstr "Включать серверы по ключевому слову"
|
||||
|
||||
msgid "Install stable"
|
||||
msgstr "Установить stable"
|
||||
msgid "Install %s"
|
||||
msgstr "Установить %s"
|
||||
|
||||
msgid "Installed version is newer than release"
|
||||
msgstr "Установленная версия новее релиза"
|
||||
|
||||
msgid "Interface Monitoring"
|
||||
msgstr "Мониторинг интерфейса"
|
||||
@ -311,14 +404,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"
|
||||
@ -362,6 +455,9 @@ msgstr "Неверный URL Hysteria2: неподдерживаемый тип
|
||||
msgid "Invalid IP address"
|
||||
msgstr "Неверный IP-адрес"
|
||||
|
||||
msgid "Invalid IPv6 address"
|
||||
msgstr "Неверный IPv6-адрес"
|
||||
|
||||
msgid "Invalid JSON format"
|
||||
msgstr "Неверный формат JSON"
|
||||
|
||||
@ -440,18 +536,57 @@ msgstr "Неверный формат URL"
|
||||
msgid "Invalid VLESS URL: parsing failed"
|
||||
msgstr "Неверный URL VLESS: ошибка разбора"
|
||||
|
||||
msgid "Invalid VMess URL: invalid port"
|
||||
msgstr "Неверный URL VMess: недопустимый порт"
|
||||
|
||||
msgid "Invalid VMess URL: malformed base64"
|
||||
msgstr "Неверный URL VMess: некорректный base64"
|
||||
|
||||
msgid "Invalid VMess URL: malformed JSON"
|
||||
msgstr "Неверный URL VMess: некорректный JSON"
|
||||
|
||||
msgid "Invalid VMess URL: missing address"
|
||||
msgstr "Неверный URL VMess: отсутствует адрес"
|
||||
|
||||
msgid "Invalid VMess URL: missing id"
|
||||
msgstr "Неверный URL VMess: отсутствует id"
|
||||
|
||||
msgid "Invalid VMess URL: must not contain spaces"
|
||||
msgstr "Неверный URL VMess: не должен содержать пробелы"
|
||||
|
||||
msgid "Invalid VMess URL: must start with vmess://"
|
||||
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 "Обнаружены проблемы"
|
||||
|
||||
msgid "Keep only subscription servers whose name contains at least one of these keywords (case-insensitive). Leave empty to keep all."
|
||||
msgstr "Оставлять только серверы подписки, имя которых содержит хотя бы одно из этих ключевых слов (без учёта регистра). Оставьте пустым, чтобы оставить все."
|
||||
|
||||
msgid "Latest"
|
||||
msgstr "Последняя"
|
||||
|
||||
msgid "Latest version is installed"
|
||||
msgstr "Установлена последняя версия"
|
||||
|
||||
msgid "Line"
|
||||
msgstr "Строка"
|
||||
|
||||
msgid "List Update Frequency"
|
||||
msgstr "Частота обновления списков"
|
||||
|
||||
msgid "List update schedule, download routing, and routing exclusions"
|
||||
msgstr "Расписание обновления списков, маршрутизация загрузок и исключения из маршрутизации"
|
||||
|
||||
msgid "Lists & Updates"
|
||||
msgstr "Списки и обновления"
|
||||
|
||||
msgid "Local Domain Lists"
|
||||
msgstr "Локальные списки доменов"
|
||||
|
||||
@ -464,9 +599,15 @@ msgstr "Уровень логов"
|
||||
msgid "Main DNS"
|
||||
msgstr "Основной DNS"
|
||||
|
||||
msgid "Main DNS via outbound"
|
||||
msgstr "Основной DNS через outbound"
|
||||
|
||||
msgid "Memory Usage"
|
||||
msgstr "Использование памяти"
|
||||
|
||||
msgid "Mixed proxy and DNS resolution tuning"
|
||||
msgstr "Настройка смешанного прокси и разрешения DNS"
|
||||
|
||||
msgid "Mixed Proxy Port"
|
||||
msgstr "Порт смешанного прокси"
|
||||
|
||||
@ -477,13 +618,19 @@ 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"
|
||||
msgstr "Сеть"
|
||||
|
||||
msgid "Network Interface"
|
||||
msgstr "Сетевой интерфейс"
|
||||
@ -494,12 +641,24 @@ msgstr "Другие правила маркировки не найдены"
|
||||
msgid "Not implement yet"
|
||||
msgstr "Ещё не реализовано"
|
||||
|
||||
msgid "Not installed"
|
||||
msgstr "Не установлено"
|
||||
|
||||
msgid "Not responding"
|
||||
msgstr "Не отвечает"
|
||||
|
||||
msgid "Not running"
|
||||
msgstr "Не запущено"
|
||||
|
||||
msgid "Number of leading characters of each proxy name to group by."
|
||||
msgstr "Количество начальных символов имени каждого прокси для группировки."
|
||||
|
||||
msgid "Off"
|
||||
msgstr "Выключено"
|
||||
|
||||
msgid "Only one section can be global at a time."
|
||||
msgstr "Только одна секция может быть глобальной одновременно."
|
||||
|
||||
msgid "Operation timed out"
|
||||
msgstr "Время ожидания истекло"
|
||||
|
||||
@ -530,6 +689,12 @@ msgstr "Путь должен заканчиваться на cache.db"
|
||||
msgid "Pending"
|
||||
msgstr "Ожидает запуска"
|
||||
|
||||
msgid "Prefix length"
|
||||
msgstr "Длина префикса"
|
||||
|
||||
msgid "Protocol toggles, file paths and logging. Block DoH only after switching upstream DNS to UDP or DoT."
|
||||
msgstr "Переключатели протоколов, пути к файлам и журналирование. Включайте блокировку DoH только после переключения вышестоящего DNS на UDP или DoT."
|
||||
|
||||
msgid "Proxy Configuration URL"
|
||||
msgstr "URL конфигурации прокси"
|
||||
|
||||
@ -552,7 +717,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"
|
||||
@ -560,6 +731,9 @@ msgstr "DNS роутера не проходит через sing-box"
|
||||
msgid "Router DNS is routed through sing-box"
|
||||
msgstr "DNS роутера проходит через sing-box"
|
||||
|
||||
msgid "Routing"
|
||||
msgstr "Маршрутизация"
|
||||
|
||||
msgid "Routing Excluded IPs"
|
||||
msgstr "Исключённые из маршрутизации IP-адреса"
|
||||
|
||||
@ -569,9 +743,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 существуют"
|
||||
|
||||
@ -623,12 +794,6 @@ msgstr "Выберите путь к файлу конфигурации sing-bo
|
||||
msgid "Select the DNS protocol type for the domain resolver"
|
||||
msgstr "Выберите тип протокола DNS для резолвера доменов"
|
||||
|
||||
msgid "Select the list type for adding custom domains"
|
||||
msgstr "Выберите тип списка для добавления пользовательских доменов"
|
||||
|
||||
msgid "Select the list type for adding custom subnets"
|
||||
msgstr "Выберите тип списка для добавления пользовательских подсетей"
|
||||
|
||||
msgid "Select the log level for sing-box"
|
||||
msgstr "Выберите уровень логов для sing-box"
|
||||
|
||||
@ -644,9 +809,21 @@ msgstr "Выберите WAN интерфейсы для мониторинга"
|
||||
msgid "Selector"
|
||||
msgstr "Selector"
|
||||
|
||||
msgid "Selector (text list)"
|
||||
msgstr "Selector (текстовый список)"
|
||||
|
||||
msgid "Selector Proxy Links"
|
||||
msgstr "Ссылки прокси для Selector"
|
||||
|
||||
msgid "Selector Proxy Links (one per line)"
|
||||
msgstr "Прокси-ссылки Selector (по одной в строке)"
|
||||
|
||||
msgid "Self-update failed"
|
||||
msgstr "Не удалось обновить"
|
||||
|
||||
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 "Информация о сервисах"
|
||||
|
||||
@ -680,6 +857,9 @@ msgstr "Сервис sing-box существует"
|
||||
msgid "Sing-box version is compatible (newer than 1.12.4)"
|
||||
msgstr "Версия Sing-box совместима (новее 1.12.4)"
|
||||
|
||||
msgid "Source and output interfaces, and Bad WAN interface monitoring"
|
||||
msgstr "Входящий и исходящий интерфейсы, а также мониторинг интерфейсов Bad WAN"
|
||||
|
||||
msgid "Source Network Interface"
|
||||
msgstr "Сетевой интерфейс источника"
|
||||
|
||||
@ -699,23 +879,44 @@ 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 cache cleared and re-downloaded"
|
||||
msgstr "Кеш подписок очищен и загружен заново"
|
||||
|
||||
msgid "Subscription feeds, server filters and URLTest tuning"
|
||||
msgstr "Источники подписок, фильтры серверов и настройка URLTest"
|
||||
|
||||
msgid "Subscription format"
|
||||
msgstr "Формат подписки"
|
||||
|
||||
msgid "Subscription grouping"
|
||||
msgstr "Группировка подписки"
|
||||
|
||||
msgid "Subscription Update Interval"
|
||||
msgstr ""
|
||||
msgstr "Интервал обновления подписки"
|
||||
|
||||
msgid "Subscription URL"
|
||||
msgstr ""
|
||||
msgid "Subscription URLs"
|
||||
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, это может занять несколько минут…"
|
||||
|
||||
msgid "System info"
|
||||
msgstr "Системная информация"
|
||||
|
||||
@ -743,6 +944,9 @@ 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 "Time in seconds for DNS record caching (default: 60)"
|
||||
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
|
||||
|
||||
@ -773,11 +977,26 @@ 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 "Исходящий"
|
||||
|
||||
msgid "URL must start with vless://, ss://, trojan://, socks4/5://, or hysteria2://hy2://"
|
||||
msgstr "URL должен начинаться с vless://, ss://, trojan://, socks4/5:// или hysteria2:// hy2://"
|
||||
msgid "Upstream and bootstrap DNS resolvers, and optional DNS-over-proxy"
|
||||
msgstr "Вышестоящий и начальный (bootstrap) DNS-резолверы и опциональный DNS через прокси"
|
||||
|
||||
msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4/5://, or hysteria2://hy2://"
|
||||
msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4/5:// или hysteria2:// hy2://"
|
||||
|
||||
msgid "URL must use one of the following protocols:"
|
||||
msgstr "URL должен использовать один из следующих протоколов:"
|
||||
@ -785,20 +1004,32 @@ msgstr "URL должен использовать один из следующи
|
||||
msgid "URLTest"
|
||||
msgstr "URLTest"
|
||||
|
||||
msgid "URLTest (text list)"
|
||||
msgstr "URLTest (текстовый список)"
|
||||
|
||||
msgid "URLTest Check Interval"
|
||||
msgstr "Интервал проверки URLTest"
|
||||
|
||||
msgid "URLTest Proxy Links"
|
||||
msgstr "Ссылки прокси для URLTest"
|
||||
|
||||
msgid "URLTest Proxy Links (one per line)"
|
||||
msgstr "Прокси-ссылки URLTest (по одной в строке)"
|
||||
|
||||
msgid "URLTest Testing URL"
|
||||
msgstr "URLTest ссылка для проверки"
|
||||
|
||||
msgid "URLTest Tolerance"
|
||||
msgstr "URLTest допустимое отклонение"
|
||||
|
||||
msgid "User Domain List Type"
|
||||
msgstr "Тип пользовательского списка доменов"
|
||||
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 Domains"
|
||||
msgstr "Пользовательские домены"
|
||||
@ -806,9 +1037,6 @@ msgstr "Пользовательские домены"
|
||||
msgid "User Domains List"
|
||||
msgstr "Список пользовательских доменов"
|
||||
|
||||
msgid "User Subnet List Type"
|
||||
msgstr "Тип пользовательского списка подсетей"
|
||||
|
||||
msgid "User Subnets"
|
||||
msgstr "Пользовательские подсети"
|
||||
|
||||
@ -821,14 +1049,20 @@ msgstr "Валидно"
|
||||
msgid "Validation errors:"
|
||||
msgstr "Ошибки валидации:"
|
||||
|
||||
msgid "Version"
|
||||
msgstr "Версия"
|
||||
|
||||
msgid "View logs"
|
||||
msgstr "Посмотреть логи"
|
||||
|
||||
msgid "Visit Wiki"
|
||||
msgstr "Перейти в wiki"
|
||||
|
||||
msgid "vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
|
||||
msgstr ""
|
||||
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"
|
||||
msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2://"
|
||||
|
||||
msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links — one per line"
|
||||
msgstr "Ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// — по одной в строке"
|
||||
|
||||
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
|
||||
msgstr "Предупреждение: %s нельзя использовать вместе с %s. Предыдущие варианты были удалены."
|
||||
@ -836,8 +1070,23 @@ 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 "Which subscription format (client) to fetch first. Auto uses the default order. Choose Xray JSON (Happ) when your panel only exposes some nodes (e.g. xhttp) under a Happ-like client, or Sing-box to prefer the sing-box format."
|
||||
msgstr "Какой формат подписки (клиент) запрашивать первым. «Авто» использует порядок по умолчанию. Выберите «Xray JSON (Happ)», если ваша панель отдаёт некоторые узлы (например, xhttp) только под клиентом вроде Happ, или «Sing-box», чтобы предпочесть формат sing-box."
|
||||
|
||||
msgid "Xray JSON (Happ)"
|
||||
msgstr "Xray JSON (Happ)"
|
||||
|
||||
msgid "YACD Secret Key"
|
||||
msgstr "Секретный ключ YACD"
|
||||
|
||||
msgid "YACD web dashboard access and remote-access protection"
|
||||
msgstr "Доступ к веб-панели YACD и защита удалённого доступа"
|
||||
|
||||
msgid "You can select Output Network Interface, by default autodetect"
|
||||
msgstr "Вы можете выбрать выходной сетевой интерфейс, по умолчанию он определяется автоматически."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=netshift
|
||||
|
||||
PKG_VERSION := $(if $(NETSHIFT_VERSION),$(NETSHIFT_VERSION),0.$(shell date +%d%m%Y))
|
||||
PKG_VERSION := $(if $(NETSHIFT_VERSION),$(NETSHIFT_VERSION),0.9.6)
|
||||
|
||||
PKG_RELEASE:=1
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user