chore: reset main for v0.2 (sing-box fork) — full context docs
Foundation pivot. The complete, working, VM-verified xray-based project is preserved on the `v0.1` branch; `main` is reset to a docs-first scaffold for v0.2, which will be built as a FORK of sing-box-lx with our control-plane, DNS filter, stats and admin panel embedded in the one binary. - Preserve everything on branch v0.1 (pushed). - Remove the v0.1 implementation + old design docs from main (recoverable from v0.1); keep LICENSE, .gitignore, .gitattributes, dist/shater-feed.pub (feed signing key 5ac4b177689cb8e0 carries over). - License -> GPL-3.0 (sing-box is GPL-3.0). - Add full project context so it survives compaction: docs/CONTEXT.md (start here), DECISIONS.md, ARCHITECTURE.md, ROADMAP.md, FEATURES.md, and a new README. Engine/UI decisions (see docs/DECISIONS.md): fork sing-box-lx (AmneziaWG 2.0 + broad protocols, GPL-3.0, library-first) and embed the whole product for tight integration; keep the fork maintainable via an additive overlay (shater/, panel/, openwrt/) rebased on upstream tags. UI = thin LuCI launcher + a separate admin panel served by the daemon, entered via a short-lived token minted in the authenticated LuCI session. Do NOT write a proxy engine from scratch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
This commit is contained in:
@@ -1,190 +0,0 @@
|
||||
# Build the three shater packages into a per-arch opkg feed, and publish
|
||||
# releases automatically.
|
||||
#
|
||||
# Why not openwrt/gh-action-sdk directly:
|
||||
# * it injects the WHOLE repo as `src-link shater /feed/`; the OpenWrt feed
|
||||
# indexer scanned the repo root and indexed 0 packages ("No feed for package
|
||||
# 'xrayctl' found").
|
||||
# * shater-core/luci-app-shater are pure-data (PKGARCH=all) but declare heavy
|
||||
# RUNTIME deps (xray-core=Go, luci-base=C) that a full `make compile` would
|
||||
# needlessly rebuild (and which broke on some SDK images).
|
||||
# So we run the OpenWrt SDK image directly with a CLEAN single-package feed and
|
||||
# build exactly what needs compiling:
|
||||
# - xrayctl -> compiled by the SDK (Go) [per arch]
|
||||
# - shater-core -> in-tree package, files only, no deps [arch=all]
|
||||
# - luci-app-shater -> data .ipk via tar, no SDK [arch=all]
|
||||
# Then an opkg Packages index (SHA256; signed if the KEY_BUILD secret is set).
|
||||
#
|
||||
# TARGET HARDWARE
|
||||
# x86_64 -> the QEMU testbed VM (generic x86-64).
|
||||
# aarch64_cortex-a53 -> BOTH production routers:
|
||||
# * mini_router = Banana Pi BPI-R3 (MediaTek MT7986 / Filogic 830)
|
||||
# * main_router = Banana Pi BPI-R4 (MediaTek MT7988 / Filogic 880)
|
||||
# Both are the OpenWrt/ImmortalWrt `mediatek/filogic` target, whose package
|
||||
# architecture is `aarch64_cortex-a53` — so one build covers both boards.
|
||||
# Only xrayctl is arch-specific; shater-core + luci-app-shater are PKGARCH=all.
|
||||
#
|
||||
# AUTO-RELEASE (see the `release` job)
|
||||
# * push to main -> refreshes a rolling `latest` pre-release (always-fresh feed).
|
||||
# * push a tag `vX.Y.Z`-> publishes a versioned release with the same assets.
|
||||
# Assets: a per-arch feed tarball (`shater-feed-<arch>.tar.gz` = Packages + all
|
||||
# .ipk, ready to serve to opkg) plus the loose .ipk files for direct install.
|
||||
# Uses the Gitea API via curl (ci/gitea-release.sh) — no external action needed.
|
||||
# Auth: repo secret RELEASE_TOKEN if set, else the auto GITHUB_TOKEN.
|
||||
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.arch }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, sdk: x86_64-24.10.4 } # testbed VM (generic x86-64)
|
||||
- { arch: aarch64_cortex-a53, sdk: mediatek-filogic-24.10.4 } # BPI-R3 (mini) + BPI-R4 (main)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build xrayctl (OpenWrt SDK ${{ matrix.sdk }})
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p "out/${{ matrix.arch }}" && chmod -R 777 out
|
||||
chmod +x ci/*.sh
|
||||
docker pull "openwrt/sdk:${{ matrix.sdk }}"
|
||||
# --volumes-from shares the job container's workspace volume into the
|
||||
# nested SDK container (a bare -v $PWD points at a host path that does
|
||||
# not exist under the act_runner DinD setup).
|
||||
docker run --rm --volumes-from "$(hostname)" \
|
||||
-e ARCH='${{ matrix.arch }}' \
|
||||
-e REPO="$GITHUB_WORKSPACE" \
|
||||
"openwrt/sdk:${{ matrix.sdk }}" \
|
||||
bash "$GITHUB_WORKSPACE/ci/build-sdk.sh"
|
||||
|
||||
- name: Pack shater-core (data .ipk)
|
||||
run: bash ci/pack-core.sh "out/${{ matrix.arch }}"
|
||||
|
||||
- name: Pack luci-app-shater (data .ipk)
|
||||
run: bash ci/pack-luci.sh "out/${{ matrix.arch }}"
|
||||
|
||||
- name: Install usign (feed signer)
|
||||
run: bash ci/install-usign.sh
|
||||
|
||||
- name: Build feed index (+ sign if KEY_BUILD set)
|
||||
env:
|
||||
KEY_BUILD: ${{ secrets.KEY_BUILD }}
|
||||
run: bash ci/make-index.sh "out/${{ matrix.arch }}"
|
||||
|
||||
- name: Show feed
|
||||
run: ls -l "out/${{ matrix.arch }}" && cat "out/${{ matrix.arch }}/Packages"
|
||||
|
||||
- name: Upload feed artifact
|
||||
# v4 uses an artifact backend Gitea Actions does not implement
|
||||
# (GHESNotSupportedError); v3 works on Gitea's act_runner.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: shater-${{ matrix.arch }}
|
||||
path: out/${{ matrix.arch }}/*
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publish a Gitea release once both arches are built. Rolling `latest` on main,
|
||||
# a versioned release on a `vX.Y.Z` tag. Self-contained (curl -> Gitea API).
|
||||
release:
|
||||
name: release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all arch feeds
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Assemble release assets
|
||||
id: assets
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p release
|
||||
# For each downloaded arch feed: one ready-to-serve tarball + loose ipks.
|
||||
for d in artifacts/shater-*; do
|
||||
[ -d "$d" ] || continue
|
||||
arch="${d#artifacts/shater-}"
|
||||
tar -C "$d" -czf "release/shater-feed-${arch}.tar.gz" .
|
||||
# loose .ipk for direct `opkg install <url>` (dedupe shared _all ipks by name)
|
||||
for ipk in "$d"/*.ipk; do
|
||||
[ -e "$ipk" ] || continue
|
||||
cp -n "$ipk" "release/$(basename "$ipk")"
|
||||
done
|
||||
done
|
||||
# ship the feed's public key so routers can verify (see docs/FEED.md)
|
||||
cp -f dist/shater-feed.pub release/shater-feed.pub
|
||||
ls -l release
|
||||
echo "count=$(ls release | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install usign (feed signer)
|
||||
run: bash ci/install-usign.sh
|
||||
|
||||
- name: Build & sign combined opkg feed index
|
||||
# One Packages/Packages.gz over ALL loose .ipk (every arch + arch=all),
|
||||
# with basename Filenames. opkg filters by architecture, so a single
|
||||
# release URL serves every device: BPI routers pick aarch64_cortex-a53
|
||||
# + all, the x86 testbed picks x86_64 + all. Signed with the feed key so
|
||||
# routers can keep check_signature on. This is what makes the release
|
||||
# directly consumable as an `src/gz` feed (see docs/FEED.md).
|
||||
env:
|
||||
KEY_BUILD: ${{ secrets.KEY_BUILD }}
|
||||
run: bash ci/make-index.sh release
|
||||
|
||||
- name: Determine release identity
|
||||
id: rel
|
||||
run: |
|
||||
set -eu
|
||||
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=shater ${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "rolling=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=shater latest (main)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
echo "rolling=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish Gitea release
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
NAME: ${{ steps.rel.outputs.name }}
|
||||
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
|
||||
ROLLING: ${{ steps.rel.outputs.rolling }}
|
||||
BODY: |
|
||||
Automated build. Packages: xrayctl (per-arch), shater-core + luci-app-shater (arch=all).
|
||||
Targets: x86_64 (testbed) and aarch64_cortex-a53 (BPI-R3 mini + BPI-R4 main, mediatek/filogic).
|
||||
|
||||
── Add as an opkg feed (recommended — then `opkg upgrade` just works) ──
|
||||
This release is itself a SIGNED package feed; opkg filters by
|
||||
architecture, so the same lines work on every device:
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
|
||||
opkg update
|
||||
opkg install luci-app-shater # pulls xrayctl + shater-core too
|
||||
The public key install is one-time; after it, `opkg update/upgrade`
|
||||
verify the signature with check_signature left on. Full guide: docs/FEED.md.
|
||||
|
||||
── Or install the loose .ipk directly / from the tarball feed ──
|
||||
wget -O /tmp/f.tgz <this release>/shater-feed-aarch64_cortex-a53.tar.gz
|
||||
mkdir -p /tmp/shater && tar -C /tmp/shater -xzf /tmp/f.tgz
|
||||
opkg install /tmp/shater/luci-app-shater_*_all.ipk
|
||||
run: bash ci/gitea-release.sh release/*
|
||||
@@ -1,281 +1,622 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
<https://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
0. Definitions.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
1. Source Code.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
2. Basic Permissions.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
14. Revised Versions of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
NO WARRANTY
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
@@ -287,15 +628,15 @@ free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
@@ -303,36 +644,31 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
@@ -1,135 +1,70 @@
|
||||
# shater
|
||||
|
||||
**A transparent xray proxy manager for OpenWrt** — subscriptions, policy routing,
|
||||
a fail-closed kill-switch, and a modern LuCI UI. Think passwall2, but cleaner,
|
||||
faster, and honest about its failure modes.
|
||||
**A self-hosted internet-control appliance for OpenWrt.** One box turns your
|
||||
network into a transparent VPN gateway, a network-wide ad/tracker/malware blocker,
|
||||
per-device parental control, and a live traffic dashboard — configured from a rich
|
||||
web admin panel, all local.
|
||||
|
||||
[](LICENSE)
|
||||

|
||||
[](LICENSE)
|
||||

|
||||

|
||||
|
||||
> 🇷🇺 [Русская версия — README.ru.md](README.ru.md)
|
||||
> ⚠️ **v0.2 is under active development on a new foundation.** The previous,
|
||||
> complete and VM-verified xray-based version lives on the **[`v0.1`](../../src/branch/v0.1)**
|
||||
> branch and still installs from the signed feed.
|
||||
|
||||
shater turns an OpenWrt router into a whole-network proxy gateway: your LAN
|
||||
traffic (TCP **and** UDP) is transparently routed through xray via TPROXY, split
|
||||
by domain / geo / client, with no DNS leaks — configured entirely from a LuCI web
|
||||
UI, and applied atomically with automatic rollback so a bad config can never
|
||||
strand the router.
|
||||
## What v0.2 is
|
||||
|
||||
The engine is **xray-core** (unmodified — a runtime dependency). All the logic
|
||||
lives in `xrayctl`, a small Go control-plane that reads the UCI desired-state and
|
||||
renders the live xray JSON, an nftables table, and policy routing.
|
||||
shater v0.2 is built as a **fork of [sing-box](https://github.com/SagerNet/sing-box)
|
||||
(via [sing-box-lx](https://github.com/Leadaxe/sing-box-lx))** with our whole
|
||||
product embedded in the one binary: the proxy engine, a control plane, a DNS
|
||||
filter, and a full admin panel. Riding sing-box gives a broad, up-to-date protocol
|
||||
set — VLESS/VMess/Trojan/Shadowsocks, Reality, **AmneziaWG 2.0**, Hysteria2, TUIC —
|
||||
without reinventing the anti-DPI arms race.
|
||||
|
||||
---
|
||||
The UI is split for both integration and a great experience: a **thin LuCI app**
|
||||
(a small dashboard + an "Open panel" button) hands a short-lived token to a
|
||||
**standalone admin panel** the daemon serves on its own port — so panel auth is
|
||||
bootstrapped from LuCI's existing login, and the real UX is a modern SPA we fully
|
||||
own.
|
||||
|
||||
## Features
|
||||
## Highlights (planned)
|
||||
|
||||
**Proxying & routing**
|
||||
- TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP).
|
||||
- Subscriptions (VLESS / VMess / Trojan / Shadowsocks / WireGuard·AmneziaWG) with
|
||||
Clash / sing-box / Xray-JSON formats, HAPP-style fetch, and stable per-node
|
||||
identity across refreshes.
|
||||
- Node groups with a load-balancer + observatory (least-ping / failover / …),
|
||||
multi-hop chains (L1→Ln), and per-rule egress selection.
|
||||
- First-match routing rules by source (IP/CIDR/MAC/interface/zone), destination
|
||||
(domain / suffix / keyword / geosite), reusable domain/IP lists, port and proto.
|
||||
- Transparent TPROXY proxy (TCP+UDP), split by domain/geo/client, no DNS leaks.
|
||||
- Broad protocols incl. **AmneziaWG 2.0**, Reality, Hysteria2, TUIC.
|
||||
- Network-wide **DNS blocklists** with flexible sources (inline / file / url /
|
||||
geosite) and an efficient matcher for million-entry lists.
|
||||
- **Per-domain, per-client, per-device statistics** — fed by the engine's DNS
|
||||
events in-process (no log scraping).
|
||||
- **Per-device control**: block a site for one device or everyone; per-device
|
||||
exit/proxy toggles; schedules; alerts.
|
||||
- Fail-closed kill-switch, atomic apply with commit-confirm rollback, signed opkg
|
||||
feed.
|
||||
|
||||
**Reliability (the "железно" part)**
|
||||
- Fail-closed kill-switch: a dead or unparsed node group resolves to `block`,
|
||||
never a silent direct leak; IPv6 is dropped when disabled.
|
||||
- Atomic apply with `xray -test` + `nft -c` validation and commit-confirm
|
||||
auto-rollback to the last-good config.
|
||||
- Idempotent reconcile from hotplug/boot, serialized by a flock, that only
|
||||
restarts the engine when the rendered config actually changed.
|
||||
- Management bypass (SSH/LuCI/LAN) is always exempt — you can't lock yourself out.
|
||||
|
||||
**DNS**
|
||||
- :53 hijack through dnsmasq, per-domain resolver selection, DoH/DoT resolvers,
|
||||
FakeIP mode, nftset population for routing, and client DoT/DoH blocking to stop
|
||||
filter bypass.
|
||||
|
||||
**UI & ops**
|
||||
- A custom "instrument panel" LuCI app: a live Signal Path on the Overview, a
|
||||
Simple/Advanced toggle, and a one-click quick-start wizard (paste a link → done).
|
||||
- Live per-client / per-node / per-rule traffic stats.
|
||||
- Config backup/restore, named profiles, and WAN-mode profiles (conditional
|
||||
overrides, e.g. SIM uplink → different egress).
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
Every push publishes a **signed opkg feed** on the release, so a router adds it
|
||||
once and then upgrades with plain `opkg`. opkg filters by architecture, so the
|
||||
same lines work on every device (BPI-R3/R4 → `aarch64_cortex-a53`, x86-64 → `x86_64`):
|
||||
|
||||
```sh
|
||||
# 1. trust the feed's public key (one time; filename = key fingerprint)
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
|
||||
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
# 2. add the feed and install
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
|
||||
opkg update
|
||||
opkg install luci-app-shater # pulls xrayctl + shater-core
|
||||
```
|
||||
|
||||
`xray-core`, `dnsmasq-full` and the `kmod-nft-*` bits come from the router's own
|
||||
package feed (they must match its kernel). Full guide — key install, signing,
|
||||
pinning to a version, apk (OpenWrt 25.x) notes, troubleshooting — in
|
||||
[`docs/FEED.md`](docs/FEED.md).
|
||||
|
||||
## First run
|
||||
|
||||
Open **LuCI → Services → Shater (xray)** and run the quick-start wizard: paste
|
||||
your subscription link, pick "everything" or "everything except local/RU", click
|
||||
once. It fetches nodes, picks the fastest server, routes your LAN through it, and
|
||||
starts the engine — then probes the exit IP to confirm the tunnel is live.
|
||||
|
||||
---
|
||||
|
||||
## The model in one line
|
||||
|
||||
`Node → Group (+balancer) → Chain (L1..Ln) → Egress` ·
|
||||
`Rule (src / dst / list / geo → target + egress)` ·
|
||||
`Inbound (multi-LAN tproxy)` · `Profile (WAN-mode)` · `List (domain/ip, auto-update)`.
|
||||
|
||||
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the diagrams (data flow,
|
||||
traffic path, DNS, node lifecycle, the reliability state machine).
|
||||
See **[`docs/FEATURES.md`](docs/FEATURES.md)** for the full list.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | What |
|
||||
|-----|------|
|
||||
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Control-plane vs data-plane, data model, traffic/DNS flow, reliability state machine, roadmap |
|
||||
| [`docs/CONFIG.md`](docs/CONFIG.md) | Full UCI schema of `/etc/config/shater` + the `xrayctl` CLI and ubus interface |
|
||||
| [`docs/BUILD.md`](docs/BUILD.md) | Build the `.ipk`s (SDK / CI) and install on a router |
|
||||
| [`docs/FEED.md`](docs/FEED.md) | Add the signed package feed; install / update / sign / pin |
|
||||
| [`docs/CONTEXT.md`](docs/CONTEXT.md) | **Start here** — project context, v0.1→v0.2 history, decisions in brief, testbed/infra |
|
||||
| [`docs/ROADMAP.md`](docs/ROADMAP.md) | Phased plan (Phase 1 = fork + embedding prototype) |
|
||||
| [`docs/FEATURES.md`](docs/FEATURES.md) | Full feature list with MVP/T1/T2 tags |
|
||||
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | One-binary design, auth handoff, data/DNS/apply flow (diagrams) |
|
||||
| [`docs/DECISIONS.md`](docs/DECISIONS.md) | Why sing-box, why fork, why the panel split, license, etc. |
|
||||
|
||||
## Repository layout
|
||||
## Status
|
||||
|
||||
```
|
||||
xrayctl/ Go control-plane: parse subscriptions, render xray JSON + nft + routing, apply
|
||||
shater-core/ system package: procd init, hotplug, sysctl, fw4/routing glue, default config
|
||||
luci-app-shater/ LuCI web app: client-side JS views + ucode/rpcd ubus backend
|
||||
ci/ feed build + signing + Gitea release scripts
|
||||
dist/ feed public key (shater-feed.pub)
|
||||
examples/ sample /etc/config/shater + share-link fixtures
|
||||
docs/ documentation
|
||||
```
|
||||
|
||||
## Building from source
|
||||
|
||||
Cross-compiled through the OpenWrt SDK; CI builds a signed multi-arch feed on
|
||||
every push. See [`docs/BUILD.md`](docs/BUILD.md). To ship a new version, bump
|
||||
`PKG_RELEASE` in the relevant `Makefile` (or push a `vX.Y.Z` tag) and CI republishes.
|
||||
Foundation reset complete: v0.1 preserved on its branch, `main` reset for v0.2.
|
||||
Next is **Phase 1** — fork sing-box-lx into `main` and stand up the embedding
|
||||
prototype (prove AmneziaWG 2.0, measure binary size). Follow `docs/ROADMAP.md`.
|
||||
|
||||
## Hardware
|
||||
|
||||
`aarch64_cortex-a53` covers both target routers — Banana Pi **BPI-R3**
|
||||
(MT7986 / Filogic 830) and **BPI-R4** (MT7988 / Filogic 880), both the OpenWrt
|
||||
`mediatek/filogic` target. `x86_64` is the QEMU test VM.
|
||||
`aarch64_cortex-a53` covers Banana Pi **BPI-R3** (MT7986/Filogic 830) and **BPI-R4**
|
||||
(MT7988/Filogic 880), both the OpenWrt `mediatek/filogic` target. `x86_64` is the
|
||||
QEMU test VM.
|
||||
|
||||
## License
|
||||
|
||||
[GPL-2.0-or-later](LICENSE). The xray-core engine is a separate, unmodified
|
||||
runtime dependency with its own license.
|
||||
[GPL-3.0](LICENSE) (sing-box is GPL-3.0). See `docs/DECISIONS.md` D6.
|
||||
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
# shater
|
||||
|
||||
**Менеджер прозрачного xray-прокси для OpenWrt** — подписки, policy-routing,
|
||||
fail-closed kill-switch и современный LuCI-интерфейс. Как passwall2, только чище,
|
||||
быстрее и честнее в отказах.
|
||||
|
||||
[](LICENSE)
|
||||

|
||||

|
||||
|
||||
> 🇬🇧 [English version — README.md](README.md)
|
||||
|
||||
shater превращает роутер на OpenWrt в сетевой прокси-шлюз: трафик LAN (TCP **и**
|
||||
UDP) прозрачно заворачивается в xray через TPROXY, разделяется по домену / гео /
|
||||
клиенту, без DNS-утечек — всё настраивается из веб-интерфейса LuCI и применяется
|
||||
атомарно с авто-откатом, так что кривой конфиг не оставит роутер без связи.
|
||||
|
||||
Движок — **xray-core** (немодифицированный, внешняя зависимость). Вся логика в
|
||||
`xrayctl` — небольшом control-plane на Go, который читает desired-state в UCI и
|
||||
рендерит боевой xray JSON, таблицу nftables и policy-routing.
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
**Проксирование и маршрутизация**
|
||||
- Прозрачный TPROXY-прокси для нескольких LAN-интерфейсов (TCP + UDP).
|
||||
- Подписки (VLESS / VMess / Trojan / Shadowsocks / WireGuard·AmneziaWG), форматы
|
||||
Clash / sing-box / Xray-JSON, HAPP-эмуляция при загрузке, стабильная
|
||||
идентичность нод между обновлениями.
|
||||
- Группы нод с балансировщиком и observatory (least-ping / failover / …),
|
||||
multi-hop цепочки (L1→Ln), выбор egress на уровне правила.
|
||||
- Правила first-match по источнику (IP/CIDR/MAC/интерфейс/зона), назначению
|
||||
(домен / суффикс / keyword / geosite), переиспользуемым спискам доменов/IP,
|
||||
порту и протоколу.
|
||||
|
||||
**Надёжность («железно»)**
|
||||
- Fail-closed kill-switch: мёртвая или нераспарсенная группа резолвится в `block`,
|
||||
а не в тихую прямую утечку; IPv6 дропается, когда выключен.
|
||||
- Атомарный apply с валидацией `xray -test` + `nft -c` и commit-confirm
|
||||
авто-откатом к последнему рабочему конфигу.
|
||||
- Идемпотентный reconcile из hotplug/boot под flock, который перезапускает движок
|
||||
только при реальном изменении сгенерированного конфига.
|
||||
- Management-bypass (SSH/LuCI/LAN) всегда в обход — заблокировать себе доступ нельзя.
|
||||
|
||||
**DNS**
|
||||
- Перехват :53 через dnsmasq, выбор резолвера по домену, DoH/DoT-резолверы,
|
||||
режим FakeIP, populate nftset для маршрутизации и блокировка клиентского
|
||||
DoT/DoH, чтобы не обходили фильтрацию.
|
||||
|
||||
**Интерфейс и эксплуатация**
|
||||
- Кастомный LuCI-апп в стиле «приборной панели»: живой Signal Path на Overview,
|
||||
переключатель Simple/Advanced и мастер быстрой настройки (вставил ссылку → готово).
|
||||
- Живая статистика трафика по клиентам / нодам / правилам.
|
||||
- Бэкап/восстановление конфига, именованные профили и WAN-mode профили
|
||||
(условные оверрайды, напр. SIM-аплинк → другой egress).
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
Каждый push публикует **подписанный opkg-фид** в релизе, поэтому роутер добавляет
|
||||
его один раз и дальше обновляется обычным `opkg`. opkg фильтрует по архитектуре,
|
||||
так что одни и те же команды работают на любом устройстве (BPI-R3/R4 →
|
||||
`aarch64_cortex-a53`, x86-64 → `x86_64`):
|
||||
|
||||
```sh
|
||||
# 1. один раз — доверяем публичному ключу фида (имя файла = отпечаток ключа)
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
|
||||
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
# 2. добавляем фид и ставим
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
|
||||
opkg update
|
||||
opkg install luci-app-shater # тянет xrayctl + shater-core
|
||||
```
|
||||
|
||||
`xray-core`, `dnsmasq-full` и `kmod-nft-*` берутся из собственного фида роутера
|
||||
(должны совпадать с его ядром). Полный гайд — установка ключа, подпись, пиннинг
|
||||
версии, заметки про apk (OpenWrt 25.x), разбор проблем — в [`docs/FEED.md`](docs/FEED.md).
|
||||
|
||||
## Первый запуск
|
||||
|
||||
Открой **LuCI → Services → Shater (xray)** и запусти мастер: вставь ссылку
|
||||
подписки, выбери «всё» или «всё кроме локального/RU», нажми один раз. Он загрузит
|
||||
ноды, выберет самый быстрый сервер, завернёт LAN через него и запустит движок,
|
||||
после чего проверит exit-IP, что туннель жив.
|
||||
|
||||
---
|
||||
|
||||
## Модель в одну строку
|
||||
|
||||
`Node → Group (+balancer) → Chain (L1..Ln) → Egress` ·
|
||||
`Rule (src / dst / list / geo → target + egress)` ·
|
||||
`Inbound (multi-LAN tproxy)` · `Profile (WAN-mode)` · `List (домены/ip, автообновление)`.
|
||||
|
||||
Схемы (потоки данных, путь трафика, DNS, жизненный цикл ноды, state machine
|
||||
надёжности) — в [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||
|
||||
## Документация
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Control-plane vs data-plane, модель данных, потоки трафика/DNS, state machine надёжности, роадмап |
|
||||
| [`docs/CONFIG.md`](docs/CONFIG.md) | Полная UCI-схема `/etc/config/shater` + CLI и ubus-интерфейс `xrayctl` |
|
||||
| [`docs/BUILD.md`](docs/BUILD.md) | Сборка `.ipk` (SDK / CI) и установка на роутер |
|
||||
| [`docs/FEED.md`](docs/FEED.md) | Подключение подписанного фида; install / update / подпись / пиннинг |
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
```
|
||||
xrayctl/ control-plane на Go: парсинг подписок, рендер xray JSON + nft + routing, apply
|
||||
shater-core/ системный пакет: procd-init, hotplug, sysctl, glue fw4/routing, дефолт-конфиг
|
||||
luci-app-shater/ LuCI-апп: клиентские JS-views + ucode/rpcd ubus-бэкенд
|
||||
ci/ сборка фида + подпись + скрипты релиза Gitea
|
||||
dist/ публичный ключ фида (shater-feed.pub)
|
||||
examples/ примеры /etc/config/shater + фикстуры share-link
|
||||
docs/ документация
|
||||
```
|
||||
|
||||
## Сборка из исходников
|
||||
|
||||
Кросс-компиляция через OpenWrt SDK; CI собирает подписанный мультиарк-фид на
|
||||
каждый push. См. [`docs/BUILD.md`](docs/BUILD.md). Чтобы выпустить новую версию —
|
||||
бампни `PKG_RELEASE` в нужном `Makefile` (или запушь тег `vX.Y.Z`), CI
|
||||
перевыпустит.
|
||||
|
||||
## Железо
|
||||
|
||||
`aarch64_cortex-a53` покрывает оба целевых роутера — Banana Pi **BPI-R3**
|
||||
(MT7986 / Filogic 830) и **BPI-R4** (MT7988 / Filogic 880), оба target OpenWrt
|
||||
`mediatek/filogic`. `x86_64` — тестовая QEMU-VM.
|
||||
|
||||
## Лицензия
|
||||
|
||||
[GPL-2.0-or-later](LICENSE). Движок xray-core — отдельная немодифицированная
|
||||
зависимость со своей лицензией.
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Runs INSIDE an openwrt/sdk:<target>-<ver> container (CWD = SDK root /builder).
|
||||
# Workspace shared via `docker run --volumes-from`; repo at $REPO, output to
|
||||
# $REPO/out/<arch>. Builds ONLY xrayctl (Go) — shater-core & luci-app-shater are
|
||||
# pure data and are packaged as .ipk by ci/pack-*.sh on the runner (no SDK,
|
||||
# no kernel/dep rebuild).
|
||||
set -e
|
||||
ARCH="${ARCH:?ARCH env required}"
|
||||
REPO="${REPO:?REPO env required}"
|
||||
OUT="${REPO}/out/${ARCH}"; mkdir -p "$OUT"
|
||||
|
||||
echo "[ci] SDK build: arch=$ARCH repo=$REPO"
|
||||
test -f "$REPO/xrayctl/Makefile" || { echo "[ci] ERROR: repo not mounted ($REPO/xrayctl/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
|
||||
|
||||
mkdir -p /tmp/shfeed
|
||||
ln -sf "$REPO/xrayctl" /tmp/shfeed/xrayctl
|
||||
cp -f feeds.conf.default feeds.conf
|
||||
grep -q '^src-link shater ' feeds.conf || echo 'src-link shater /tmp/shfeed' >> feeds.conf
|
||||
|
||||
echo "[ci] feeds update (packages shater) + install"
|
||||
./scripts/feeds update packages shater
|
||||
./scripts/feeds install -p shater xrayctl
|
||||
./scripts/feeds install golang
|
||||
|
||||
echo "[ci] defconfig"
|
||||
make defconfig >/dev/null
|
||||
|
||||
echo "[ci] === build xrayctl ==="
|
||||
make package/xrayctl/compile V=s -j"$(nproc)"
|
||||
|
||||
find bin -type f -name 'xrayctl_*.ipk' -exec cp {} "$OUT/" \; -print | sed 's#.*/##'
|
||||
chmod -R a+rwX "$OUT" 2>/dev/null || true
|
||||
echo "[ci] OK xrayctl arch=$ARCH"
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Create (or refresh) a Gitea release and upload assets via the Gitea API.
|
||||
#
|
||||
# Self-contained: uses only curl (present in the act_runner image), so it needs
|
||||
# NO external marketplace action — the safest option on a self-hosted Gitea
|
||||
# act_runner where github.com/gitea.com action fetches may be unavailable.
|
||||
#
|
||||
# Idempotent: safe to re-run for the same tag. A pre-existing release (rolling
|
||||
# `latest`, or a re-run of a tag build) is reused — its clashing assets are
|
||||
# deleted and replaced — instead of aborting on a 409.
|
||||
#
|
||||
# Env:
|
||||
# SERVER Gitea base URL, e.g. https://git.qomar.pw (default: $GITHUB_SERVER_URL)
|
||||
# REPO owner/repo (default: $GITHUB_REPOSITORY)
|
||||
# TOKEN API token with contents:write (default: $GITHUB_TOKEN)
|
||||
# TAG release tag, e.g. v0.2.0 or "latest"
|
||||
# NAME release title (default: TAG)
|
||||
# BODY release notes markdown (default: "")
|
||||
# PRERELEASE true|false (default: false)
|
||||
# TARGET commit sha the tag should point at (default: $GITHUB_SHA)
|
||||
# ROLLING true => move the tag to $TARGET (delete+recreate); for `latest`
|
||||
# Args: asset files to upload.
|
||||
set -eu
|
||||
|
||||
SERVER="${SERVER:-${GITHUB_SERVER_URL:?}}"
|
||||
REPO="${REPO:-${GITHUB_REPOSITORY:?}}"
|
||||
TOKEN="${TOKEN:-${GITHUB_TOKEN:?token required (GITHUB_TOKEN or RELEASE_TOKEN)}}"
|
||||
TAG="${TAG:?tag required}"
|
||||
NAME="${NAME:-$TAG}"
|
||||
BODY="${BODY:-}"
|
||||
PRERELEASE="${PRERELEASE:-false}"
|
||||
TARGET="${TARGET:-${GITHUB_SHA:-}}"
|
||||
ROLLING="${ROLLING:-false}"
|
||||
|
||||
API="$SERVER/api/v1/repos/$REPO"
|
||||
AUTH=(-H "Authorization: token $TOKEN")
|
||||
BODYF="$(mktemp)" # last response body
|
||||
# FIRST "<key>": <number> in the body. grep -o preserves order, so for a release
|
||||
# object the top-level "id" (the release id) comes before nested author/asset ids
|
||||
# — a greedy `.*"id":` would wrongly grab the LAST id on a one-line JSON response.
|
||||
int() { grep -o "\"$1\"[[:space:]]*:[[:space:]]*[0-9]\{1,\}" "$BODYF" | head -n1 | grep -o '[0-9]\{1,\}'; }
|
||||
|
||||
# api METHOD PATH [curl-args...] -> echoes HTTP code, body in $BODYF
|
||||
api() {
|
||||
local m="$1" p="$2"; shift 2
|
||||
curl -sS -o "$BODYF" -w '%{http_code}' -X "$m" "${AUTH[@]}" "$@" "$API$p"
|
||||
}
|
||||
|
||||
echo "[release] repo=$REPO tag=$TAG prerelease=$PRERELEASE rolling=$ROLLING target=${TARGET:0:8}"
|
||||
|
||||
# --- find any existing release for this tag (by tag lookup, then by listing) --
|
||||
find_release() {
|
||||
local code
|
||||
code=$(api GET "/releases/tags/$TAG")
|
||||
if [ "$code" = 200 ]; then int id; return; fi
|
||||
# fall back to scanning the releases list (tag lookup 404s on some versions).
|
||||
# Split the array into per-release chunks and read the id of the chunk whose
|
||||
# tag_name matches — avoids a greedy match spanning objects.
|
||||
api GET "/releases?limit=50" >/dev/null || true
|
||||
tr '{' '\n' < "$BODYF" | grep -F "\"tag_name\":\"$TAG\"" \
|
||||
| grep -o '"id"[[:space:]]*:[[:space:]]*[0-9]\{1,\}' | head -n1 | grep -o '[0-9]\{1,\}'
|
||||
}
|
||||
|
||||
rid="$(find_release || true)"
|
||||
if [ -n "${rid:-}" ]; then
|
||||
echo "[release] deleting existing release id=$rid"
|
||||
api DELETE "/releases/$rid" >/dev/null || true
|
||||
fi
|
||||
|
||||
# For a rolling tag, drop the git tag so it re-points at $TARGET on recreate.
|
||||
if [ "$ROLLING" = true ]; then
|
||||
api DELETE "/tags/$TAG" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# --- create the release (idempotent: reuse on 409) ---------------------------
|
||||
esc_body=$(printf '%s' "$BODY" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS="\\n"}{print}')
|
||||
payload=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
|
||||
"$TAG" "$TARGET" "$NAME" "$esc_body" "$PRERELEASE")
|
||||
|
||||
code=$(api POST "/releases" -H 'Content-Type: application/json' -d "$payload")
|
||||
if [ "$code" = 201 ] || [ "$code" = 200 ]; then
|
||||
rid=$(int id)
|
||||
elif [ "$code" = 409 ]; then
|
||||
echo "[release] 409 on create — reusing existing release for tag $TAG"
|
||||
rid="$(find_release || true)"
|
||||
else
|
||||
echo "[release] ERROR: create returned HTTP $code: $(cat "$BODYF")" >&2; exit 1
|
||||
fi
|
||||
[ -n "${rid:-}" ] || { echo "[release] ERROR: no release id after create (HTTP $code)" >&2; cat "$BODYF" >&2; exit 1; }
|
||||
echo "[release] release id=$rid"
|
||||
|
||||
# --- upload assets, replacing any of the same name ---------------------------
|
||||
api GET "/releases/$rid/assets" >/dev/null || true
|
||||
assets_body="$(cat "$BODYF")"
|
||||
for f in "$@"; do
|
||||
[ -f "$f" ] || { echo "[release] skip missing $f"; continue; }
|
||||
base=$(basename "$f")
|
||||
# delete a pre-existing asset with this name (idempotent re-run)
|
||||
aid=$(printf '%s' "$assets_body" | sed -n 's/.*"id":[[:space:]]*\([0-9]*\)[^}]*"name":[[:space:]]*"'"$base"'".*/\1/p' | head -n1)
|
||||
[ -n "$aid" ] && api DELETE "/releases/$rid/assets/$aid" >/dev/null 2>&1 || true
|
||||
echo "[release] uploading $base"
|
||||
code=$(api POST "/releases/$rid/assets?name=$base" -F "attachment=@$f;filename=$base")
|
||||
case "$code" in
|
||||
201|200) ;;
|
||||
*) echo "[release] ERROR uploading $base: HTTP $code: $(cat "$BODYF")" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
rm -f "$BODYF"
|
||||
echo "[release] done: $SERVER/$REPO/releases/tag/$TAG"
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Make `usign` available on the CI runner so make-index.sh can sign the opkg
|
||||
# feed index. The OpenWrt SDK ships usign, but the index/signing steps run on the
|
||||
# bare runner, so we build the tiny standalone tool from source (no libubox — it
|
||||
# is intentionally dependency-free so it can bootstrap a build system). No-op if
|
||||
# usign is already on PATH.
|
||||
set -eu
|
||||
|
||||
if command -v usign >/dev/null 2>&1; then
|
||||
echo "[usign] already present: $(command -v usign)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
|
||||
if ! command -v cmake >/dev/null 2>&1 || ! command -v cc >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq cmake gcc git
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
# Canonical source; fall back to the GitHub mirror if git.openwrt.org is flaky.
|
||||
git clone --depth 1 https://git.openwrt.org/project/usign.git "$tmp/usign" \
|
||||
|| git clone --depth 1 https://github.com/openwrt/usign.git "$tmp/usign"
|
||||
( cd "$tmp/usign" && cmake -DCMAKE_BUILD_TYPE=Release . >/dev/null && make >/dev/null )
|
||||
|
||||
if $SUDO install -m0755 "$tmp/usign/usign" /usr/local/bin/usign 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
mkdir -p "$HOME/bin"
|
||||
install -m0755 "$tmp/usign/usign" "$HOME/bin/usign"
|
||||
echo "$HOME/bin" >> "${GITHUB_PATH:-/dev/null}"
|
||||
export PATH="$HOME/bin:$PATH"
|
||||
fi
|
||||
echo "[usign] built: $(command -v usign || echo "$HOME/bin/usign")"
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build the opkg feed index (Packages + Packages.gz) with SHA256 for a dir of
|
||||
# .ipk files. Optionally sign with usign if $KEY_BUILD (secret) + usign present.
|
||||
# Arg $1 = feed dir.
|
||||
set -e
|
||||
OUT="${1:?feed dir required}"; cd "$OUT"
|
||||
: > Packages
|
||||
for ipk in *.ipk; do
|
||||
[ -e "$ipk" ] || continue
|
||||
ctrl=$(tar -xzOf "$ipk" ./control.tar.gz | tar -xzO ./control)
|
||||
sz=$(wc -c < "$ipk"); sha=$(sha256sum "$ipk" | cut -d' ' -f1)
|
||||
printf '%s\n' "$ctrl" | sed '/^[[:space:]]*$/d' >> Packages
|
||||
printf 'Filename: %s\nSize: %s\nSHA256sum: %s\n\n' "$ipk" "$sz" "$sha" >> Packages
|
||||
done
|
||||
gzip -kf Packages
|
||||
|
||||
if [ -n "${KEY_BUILD:-}" ]; then
|
||||
# Signing was requested — a missing/broken signer must FAIL the build, not
|
||||
# silently ship an unsigned feed that routers with check_signature on reject.
|
||||
command -v usign >/dev/null 2>&1 || { echo "[index] ERROR: KEY_BUILD set but usign not found" >&2; exit 1; }
|
||||
umask 077; printf '%s\n' "$KEY_BUILD" > /tmp/usign.sec
|
||||
usign -S -m Packages -s /tmp/usign.sec || { rm -f /tmp/usign.sec; echo "[index] ERROR: usign signing failed" >&2; exit 1; }
|
||||
rm -f /tmp/usign.sec
|
||||
echo "[index] signed -> Packages.sig ($(head -1 Packages.sig))"
|
||||
else
|
||||
echo "[index] no KEY_BUILD -> UNSIGNED feed (opkg needs check_signature off, or set the secret)"
|
||||
fi
|
||||
echo "[index] contents:"; ls -l
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build shater-core as a pure-data .ipk (PKGARCH=all) with tar — no SDK, no
|
||||
# kernel/kmod dep build. Mirrors shater-core/Makefile (Depends, conffiles,
|
||||
# uci-defaults). Arg $1 = output dir.
|
||||
set -e
|
||||
OUT="$(mkdir -p "${1:?output dir required}" && cd "$1" && pwd)"
|
||||
# Derive the version from the package Makefile so `opkg upgrade` actually sees a
|
||||
# new build when PKG_RELEASE is bumped (the SDK does this for xrayctl; these
|
||||
# data packages must match, or bumps never ship).
|
||||
MK=shater-core/Makefile
|
||||
VER=$(sed -n 's/^PKG_VERSION:=[[:space:]]*//p' "$MK" | head -1)
|
||||
RN=$(sed -n 's/^PKG_RELEASE:=[[:space:]]*//p' "$MK" | head -1)
|
||||
REL="${VER:-0.1.0}-r${RN:-1}"
|
||||
W="$(mktemp -d)"; mkdir -p "$W/data" "$W/control"
|
||||
|
||||
cp -a shater-core/files/. "$W/data/"
|
||||
find "$W/data" -name '.gitkeep' -delete 2>/dev/null || true
|
||||
isize=$(du -sk "$W/data" | cut -f1)
|
||||
|
||||
cat > "$W/control/control" <<EOF
|
||||
Package: shater-core
|
||||
Version: ${REL}
|
||||
Depends: xrayctl, xray-core, dnsmasq-full, kmod-nft-tproxy, kmod-nft-socket, ip-full
|
||||
Section: net
|
||||
Architecture: all
|
||||
Installed-Size: ${isize}
|
||||
Maintainer: shater
|
||||
Description: shater data-plane glue (procd init, nft tproxy via xrayctl, policy routing, DNS, sysctl).
|
||||
EOF
|
||||
printf '/etc/config/shater\n' > "$W/control/conffiles"
|
||||
cat > "$W/control/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
[ -n "${IPKG_INSTROOT}" ] && exit 0
|
||||
[ -f /etc/uci-defaults/30_shater-core ] && { sh /etc/uci-defaults/30_shater-core && rm -f /etc/uci-defaults/30_shater-core; }
|
||||
exit 0
|
||||
EOF
|
||||
chmod 0755 "$W/control/postinst"
|
||||
echo "2.0" > "$W/debian-binary"
|
||||
|
||||
( cd "$W/control" && tar --numeric-owner --owner=0 --group=0 -czf ../control.tar.gz ./control ./conffiles ./postinst )
|
||||
( cd "$W/data" && tar --numeric-owner --owner=0 --group=0 -czf ../data.tar.gz . )
|
||||
( cd "$W" && tar --numeric-owner --owner=0 --group=0 -czf "$OUT/shater-core_${REL}_all.ipk" ./debian-binary ./data.tar.gz ./control.tar.gz )
|
||||
echo "built $OUT/shater-core_${REL}_all.ipk"
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build luci-app-shater as a pure-data .ipk (PKGARCH=all) with plain tar — no SDK,
|
||||
# no luci-base compilation. Runs on the CI runner. Arg $1 = output dir.
|
||||
set -e
|
||||
OUT="$(mkdir -p "${1:?output dir required}" && cd "$1" && pwd)"
|
||||
# Version from the package Makefile so bumps ship via `opkg upgrade`.
|
||||
MK=luci-app-shater/Makefile
|
||||
VER=$(sed -n 's/^PKG_VERSION:=[[:space:]]*//p' "$MK" | head -1)
|
||||
RN=$(sed -n 's/^PKG_RELEASE:=[[:space:]]*//p' "$MK" | head -1)
|
||||
REL="${VER:-0.1.0}-r${RN:-1}"
|
||||
W="$(mktemp -d)"; mkdir -p "$W/data" "$W/control"
|
||||
|
||||
cp -a luci-app-shater/root/. "$W/data/"
|
||||
mkdir -p "$W/data/www"; cp -a luci-app-shater/htdocs/. "$W/data/www/"
|
||||
find "$W/data" -name '.gitkeep' -delete 2>/dev/null || true
|
||||
isize=$(du -sk "$W/data" | cut -f1)
|
||||
|
||||
cat > "$W/control/control" <<EOF
|
||||
Package: luci-app-shater
|
||||
Version: ${REL}
|
||||
Depends: luci-base, xrayctl, shater-core
|
||||
Section: luci
|
||||
Architecture: all
|
||||
Installed-Size: ${isize}
|
||||
Maintainer: shater
|
||||
Description: LuCI web UI for shater (xray transparent-proxy control-plane).
|
||||
EOF
|
||||
cat > "$W/control/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
|
||||
[ -n "${IPKG_INSTROOT}" ] && exit 0
|
||||
[ -f /etc/uci-defaults/40_luci-shater ] && { sh /etc/uci-defaults/40_luci-shater && rm -f /etc/uci-defaults/40_luci-shater; }
|
||||
rm -f /tmp/luci-indexcache* 2>/dev/null; rm -rf /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/rpcd reload 2>/dev/null
|
||||
exit 0
|
||||
EOF
|
||||
chmod 0755 "$W/control/postinst"
|
||||
echo "2.0" > "$W/debian-binary"
|
||||
|
||||
( cd "$W/control" && tar --numeric-owner --owner=0 --group=0 -czf ../control.tar.gz ./control ./postinst )
|
||||
( cd "$W/data" && tar --numeric-owner --owner=0 --group=0 -czf ../data.tar.gz . )
|
||||
( cd "$W" && tar --numeric-owner --owner=0 --group=0 -czf "$OUT/luci-app-shater_${REL}_all.ipk" ./debian-binary ./data.tar.gz ./control.tar.gz )
|
||||
echo "built $OUT/luci-app-shater_${REL}_all.ipk"
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Pack a prebuilt xrayctl binary into an .ipk (no SDK). Mirrors xrayctl/Makefile
|
||||
# metadata. This is used for fast local testbed iteration; CI proper builds it
|
||||
# through the OpenWrt SDK (ci/build-sdk.sh). Args: $1 = binary path, $2 = arch,
|
||||
# $3 = output dir.
|
||||
set -e
|
||||
BIN="${1:?binary path required}"
|
||||
ARCH="${2:?arch required (e.g. x86_64)}"
|
||||
OUT="$(mkdir -p "${3:?output dir required}" && cd "$3" && pwd)"
|
||||
REL="0.1.0-r7"
|
||||
W="$(mktemp -d)"; mkdir -p "$W/data/usr/bin" "$W/control"
|
||||
|
||||
cp "$BIN" "$W/data/usr/bin/xrayctl"
|
||||
chmod 0755 "$W/data/usr/bin/xrayctl"
|
||||
isize=$(du -sk "$W/data" | cut -f1)
|
||||
|
||||
cat > "$W/control/control" <<EOF
|
||||
Package: xrayctl
|
||||
Version: ${REL}
|
||||
Depends: ca-bundle
|
||||
Section: net
|
||||
Architecture: ${ARCH}
|
||||
Installed-Size: ${isize}
|
||||
Maintainer: shater
|
||||
Description: xray control-plane (UCI -> xray JSON + nft + policy-routing).
|
||||
EOF
|
||||
# Guarantee the exec bit regardless of the host tar's mode handling (msys/Windows
|
||||
# tar does not always preserve 0755 in the archive), so opkg installs a runnable
|
||||
# binary on the device.
|
||||
cat > "$W/control/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
[ -n "${IPKG_INSTROOT}" ] || chmod 0755 /usr/bin/xrayctl
|
||||
exit 0
|
||||
EOF
|
||||
chmod 0755 "$W/control/postinst"
|
||||
echo "2.0" > "$W/debian-binary"
|
||||
|
||||
( cd "$W/control" && tar --numeric-owner --owner=0 --group=0 -czf ../control.tar.gz ./control ./postinst )
|
||||
( cd "$W/data" && tar --numeric-owner --owner=0 --group=0 -czf ../data.tar.gz . )
|
||||
( cd "$W" && tar --numeric-owner --owner=0 --group=0 -czf "$OUT/xrayctl_${REL}_${ARCH}.ipk" ./debian-binary ./data.tar.gz ./control.tar.gz )
|
||||
echo "built $OUT/xrayctl_${REL}_${ARCH}.ipk"
|
||||
+77
-139
@@ -1,172 +1,110 @@
|
||||
# Architecture
|
||||
# Architecture (v0.2)
|
||||
|
||||
The control plane is `xrayctl`, a Go daemon that renders the UCI desired-state
|
||||
(`/etc/config/shater`) into xray JSON, an nftables ruleset, and policy-routing
|
||||
rules, then applies them atomically. The data plane is just the kernel plus a
|
||||
single supervised xray process — nothing heavy sits in the packet path: config
|
||||
flows down, telemetry flows up.
|
||||
One Go binary — a **fork of sing-box-lx** with our product embedded — runs the
|
||||
proxy engine, the control plane, the DNS filter, and the admin-panel web server
|
||||
in a single process. OpenWrt integration (a thin LuCI launcher + procd/system
|
||||
glue) wraps it. Config is UCI desired-state; the daemon renders and applies it;
|
||||
telemetry flows back to the panel.
|
||||
|
||||
Diagrams render directly on Gitea/GitHub.
|
||||
Diagrams render on Gitea/GitHub.
|
||||
|
||||
## 1. System architecture (control plane vs data plane)
|
||||
|
||||
Config is pushed down; telemetry is collected back up.
|
||||
## 1. Components & repository layout
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph PRES["Presentation"]
|
||||
LUCI["LuCI app (JS)"]
|
||||
UBUS["ubus / rpcd"]
|
||||
subgraph BIN["shaterd — one binary (fork of sing-box-lx)"]
|
||||
ENG["sing-box engine (upstream tree)\nprotocols · Reality · AmneziaWG 2.0 · DNS · routing · stats"]
|
||||
CTRL["shater/ control-plane\nUCI model · config gen · apply/rollback · nft/routing · reconcile"]
|
||||
FILT["shater/ DNS filter + blocklists + per-device policy"]
|
||||
STAT["shater/ stats aggregator (per-domain/client/device)"]
|
||||
PANEL["panel/ admin web server + embedded SPA (own port, token auth)"]
|
||||
end
|
||||
subgraph CTRL["Control plane — xrayctl (Go)"]
|
||||
UCIM["UCI model — /etc/config/shater"]
|
||||
FETCH["sub fetch + parse (libXray)"]
|
||||
GEN["config generator — xray JSON, nft, ip-rules"]
|
||||
REC["reconciler — atomic apply, rollback"]
|
||||
TEL["telemetry — xray API, nft counters, logs"]
|
||||
subgraph WRT["OpenWrt glue (openwrt/)"]
|
||||
LUCI["thin LuCI app — mini dashboard + Open-panel button"]
|
||||
PROCD["procd init · hotplug · uci-defaults · fw4/routing"]
|
||||
end
|
||||
subgraph DATA["Data plane — kernel + one xray"]
|
||||
XRAY["xray-core — tproxy in, balancers, observatory"]
|
||||
NFT["nftables / fw4 — own table, tproxy, marks, counters"]
|
||||
DNSM["dnsmasq + DoH, FakeIP"]
|
||||
ROUTE["ip rule / route — policy routing"]
|
||||
PROCD["procd — supervise, respawn"]
|
||||
end
|
||||
LUCI <--> UBUS
|
||||
UBUS <--> CTRL
|
||||
CTRL -->|"render + atomic apply"| DATA
|
||||
DATA -->|"telemetry"| TEL
|
||||
LUCI -->|"ubus: mint token"| PANEL
|
||||
PROCD --> BIN
|
||||
CTRL --> ENG
|
||||
FILT --> ENG
|
||||
ENG --> STAT
|
||||
STAT --> PANEL
|
||||
```
|
||||
|
||||
## 2. Data model — 8 objects
|
||||
Overlay dirs (added on top of the upstream sing-box-lx tree, conflict-free):
|
||||
`shater/` (Go: control-plane, DNS filter, stats, engine host), `panel/` (admin
|
||||
SPA + its Go server), `openwrt/` (LuCI thin app, procd/shater-core, Makefiles,
|
||||
feed/CI), `docs/`.
|
||||
|
||||
The eight configuration objects and how they reference one another.
|
||||
## 2. Auth handoff — LuCI → admin panel
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Browser (LuCI, authed)
|
||||
participant L as LuCI (thin app)
|
||||
participant D as shaterd (panel server, own port)
|
||||
U->>L: click "Open panel"
|
||||
L->>D: ubus mint_token (LuCI session ACL-checked)
|
||||
D-->>L: short-lived one-time token
|
||||
L-->>U: redirect https://router:PORT/?t=TOKEN
|
||||
U->>D: GET /?t=TOKEN
|
||||
D-->>U: validate + set session cookie, drop token
|
||||
U->>D: SPA ⇄ panel API (session)
|
||||
```
|
||||
|
||||
The panel never runs its own login; it trusts a token that only an authenticated,
|
||||
ACL-permitted LuCI session could have minted. Tokens are single-use and short-TTL.
|
||||
|
||||
## 3. Data plane — traffic path
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SUB["Subscription — URL + HAPP headers"] --> NODE["1 — Node"]
|
||||
MAN["manual — paste, file, link"] --> NODE
|
||||
NODE --> GRP["2 — Group + balancer"]
|
||||
GRP --> CH["3 — Chain L1..Ln"]
|
||||
NODE --> CH
|
||||
INB["5 — Inbound (multi-LAN tproxy)"] --> RULE["6 — Rule"]
|
||||
RULE --> CH
|
||||
RULE --> GRP
|
||||
RULE --> NODE
|
||||
RULE --> EG["4 — Egress"]
|
||||
LIST["8 — List / Ruleset"] --> RULE
|
||||
LIST --> DNS["DNS"]
|
||||
PROF["7 — Profile (WAN-mode)"] -.->|"override"| RULE
|
||||
EG --> OUT["iface, tunnel, chain, direct, block"]
|
||||
C["LAN client"] -->|"nft tproxy, mark → tproxy port"| IN["sing-box tproxy inbound (sniff SNI/Host/QUIC)"]
|
||||
IN --> R{"route: rule match — src / dst / list / geo / client"}
|
||||
R -->|"proxied"| OUT["outbound / selector (balancer, chain)"]
|
||||
R -->|"direct"| DIR["direct (flow-offload on)"]
|
||||
R -->|"blocked"| BLK["block"]
|
||||
OUT --> NET["exit — VLESS/Reality/AmneziaWG2/Hysteria2/…"]
|
||||
```
|
||||
|
||||
## 3. Traffic path
|
||||
Reliability (ported from v0.1): own nft table `inet shater` + own marks/tables
|
||||
(never touch fw4); atomic validate→stage→swap; commit-confirm rollback;
|
||||
idempotent reconcile under flock; management-bypass always; fail-closed
|
||||
kill-switch (dead group → block, not a silent direct leak).
|
||||
|
||||
A LAN packet is TPROXY-marked into xray, matched by a rule, and sent out the
|
||||
selected egress.
|
||||
## 4. DNS + filtering + stats
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["LAN client"] -->|"nft tproxy, mark to :12345"| IN["xray inbound — dokodemo :12345, sniff SNI+Host"]
|
||||
IN --> R{"rule match — src, dst, list, geo"}
|
||||
R --> T["target — Chain L1-L2-L3, Group, Node"]
|
||||
T -->|"mark 0x11 to table"| E["egress — awgOut, eth, wifi"]
|
||||
E --> NET["Internet — exit IP"]
|
||||
C["client :53"] -->|"hijack"| DNS["sing-box DNS (in-process)"]
|
||||
DNS --> FILT{"shater filter: blocklists + allowlist + per-device policy"}
|
||||
FILT -->|"blocked"| NX["NXDOMAIN / 0.0.0.0"]
|
||||
FILT -->|"allowed"| RES["resolvers (DoH/DoT/plain/FakeIP) + nftset for routing"]
|
||||
DNS -->|"query events (engine observability)"| AGG["shater stats aggregator"]
|
||||
AGG --> PANEL["panel: top domains · per-device · allowed/blocked · timeline"]
|
||||
```
|
||||
|
||||
Loop guard: `sockopt.mark` on the xray egress plus RFC1918 and server-address
|
||||
bypass; flow offload stays ON for direct traffic.
|
||||
Because the engine's DNS runs **in our process**, every query (domain, client,
|
||||
verdict, latency) is available to the stats aggregator without log-scraping —
|
||||
this is the payoff of embedding. Blocklist matching uses an efficient compiled
|
||||
matcher, not dnsmasq megalists (see `DECISIONS.md` D5). Per-device blocking =
|
||||
engine route/DNS rule keyed by client, or nftset(device) × nftset(blocked-domain)
|
||||
→ drop.
|
||||
|
||||
## 4. DNS (configurable, leak-free)
|
||||
|
||||
Client DNS is hijacked into dnsmasq, then routed per domain/list/geo, with
|
||||
resolved answers feeding an nftset for routing.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["client :53"] -->|"hijack DNAT"| DM["dnsmasq — noresolv, cache"]
|
||||
DM --> DR{"DNS router — domain, list, geo, client"}
|
||||
DR --> R1["ISP / local"]
|
||||
DR --> R2["DoH — detour via outbound"]
|
||||
DR --> R3["FakeIP 198.18.x"]
|
||||
DR --> SET[("domain to nftset — for routing")]
|
||||
```
|
||||
|
||||
## 5. Node lifecycle and identity
|
||||
|
||||
Each node is fingerprinted and reconciled against the previous set; missing
|
||||
nodes go stale before removal, and pinned-but-missing targets fall back.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S["subscription — HAPP HWID fixed or auto"] --> P["parse (libXray)"]
|
||||
P --> FP["fingerprint — hash addr, port, id, net, sec, sni"]
|
||||
FP --> RC{"reconcile"}
|
||||
RC -->|"new"| ADD["add"]
|
||||
RC -->|"present"| KEEP["keep"]
|
||||
RC -->|"missing"| ST["stale — N refreshes then remove"]
|
||||
ADD --> SEL["selection — Group dynamic or pinned Node"]
|
||||
KEEP --> SEL
|
||||
SEL -->|"pinned and missing"| FB["fallback — group, direct, block"]
|
||||
```
|
||||
|
||||
Subscription refresh runs on a per-subscription interval, manually, and at boot;
|
||||
every refresh triggers this reconcile.
|
||||
|
||||
## 6. Reliability — apply state machine
|
||||
|
||||
Every change is built, validated, staged, applied atomically, and auto-rolled
|
||||
back if not confirmed within the window.
|
||||
## 5. Config & apply flow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Edit
|
||||
Edit --> Build
|
||||
Build --> Validate: xray-test, nft-c
|
||||
Edit --> Render: UCI → sing-box config (our generator, engine types)
|
||||
Render --> Validate: engine config check + nft -c
|
||||
Validate --> KeepOld: fail
|
||||
Validate --> Stage: ok
|
||||
Stage --> Apply: atomic nft-f + swap
|
||||
Validate --> Apply: ok (atomic swap: engine reload + nft/route reconcile)
|
||||
Apply --> ConfirmWindow
|
||||
ConfirmWindow --> Committed: confirmed
|
||||
ConfirmWindow --> Rollback: timeout N s
|
||||
ConfirmWindow --> Rollback: timeout
|
||||
Rollback --> LastGood
|
||||
KeepOld --> [*]
|
||||
Committed --> [*]
|
||||
LastGood --> [*]
|
||||
```
|
||||
|
||||
Own marks and tables (fw4 is left untouched) · idempotent reconcile ·
|
||||
hotplug persistence · management bypass always on.
|
||||
|
||||
## 7. Per-consumer statistics (kernel counts, daemon aggregates)
|
||||
|
||||
The kernel keeps in-path counters for free; userspace adds xray Stats and
|
||||
access-log detail; `xrayctl` merges everything for the dashboard.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph K["Kernel — in-path, free"]
|
||||
C1["nft dynamic counter set — per saddr"]
|
||||
C2["saddr x mark counters — client x proxy"]
|
||||
end
|
||||
subgraph U["Userspace — aggregate"]
|
||||
X["xray Stats API — per-outbound"]
|
||||
L["access-log parse — T2 per-domain"]
|
||||
RRD["RRD / nlbwmon — history"]
|
||||
end
|
||||
C1 --> AGG["xrayctl aggregator"]
|
||||
C2 --> AGG
|
||||
X --> AGG
|
||||
L --> AGG
|
||||
RRD --> AGG
|
||||
AGG -->|"ubus"| DASH["Dashboard — leaderboard, per-proxy, Sankey, conn-inspector"]
|
||||
```
|
||||
|
||||
## 8. Roadmap
|
||||
|
||||
| Tier | Contents |
|
||||
|------|----------|
|
||||
| **MVP** | subscriptions + manual import · TPROXY multi-LAN · balancer + observatory · rules (src, dst, domain-list, ip-list) · DNS (DoH + hijack + split, optional FakeIP) · atomic apply + rollback + kill-switch · LuCI (nodes, subs, status) · builds for OpenWrt + ImmortalWrt + feed |
|
||||
| **T1** | multi-hop chains · per-client policy + egress select · explain / trace · consumer leaderboard + SLA · profiles, scenes · config history + rollback · one-click test-all · Telegram alerts |
|
||||
| **T2** | per-domain stats · connection inspector · device quotas · schedules · FakeIP · geo-map · WAN-mode profiles · awg-wrap · auto route optimization · QR |
|
||||
| **T3** | fleet (many routers) · per-destination latency routing · config converters · sing-box adapter · REST / gRPC API · Telegram bot |
|
||||
## 6. Roadmap tiers
|
||||
See `ROADMAP.md` for the phased plan and `FEATURES.md` for the full feature list.
|
||||
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
# BUILD & INSTALL — shater packages
|
||||
|
||||
Three OpenWrt packages live under `package/`:
|
||||
|
||||
| package | kind | PKGARCH | build input |
|
||||
|--------------------|------------------------------|---------|--------------------|
|
||||
| `xrayctl` | Go control-plane daemon | per-arch | `golang-package.mk` |
|
||||
| `luci-app-shater` | LuCI web app (client-side JS)| `all` | `luci.mk` |
|
||||
| `shater-core` | system/init + fw4/routing | `all` | plain `package.mk` |
|
||||
|
||||
Engine dependency `xray-core` is **not** ours — it comes from the router's own
|
||||
package feed (ImmortalWrt/BananaWRT/OpenWrt packages feed).
|
||||
|
||||
Two ways to build: **CI** (multi-arch, signed feed — `.github/workflows/build.yml`)
|
||||
or **locally with the SDK in Docker** (below). Feed publishing and install are
|
||||
covered in [`FEED.md`](FEED.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Local build with the SDK (Docker)
|
||||
|
||||
The testbed already runs SDK builds via `testbed/scripts/sdk-build.ps1`, but that
|
||||
script builds a single, dependency-free `hello` package: it bind-mounts **one**
|
||||
package dir into `/builder/package/<pkg>` and runs `make package/<pkg>/compile`.
|
||||
Our three packages need (a) each other, (b) the `golang` host/target packages
|
||||
(for `xrayctl`) and (c) `luci-base` (for `luci-app-shater`), so they must be
|
||||
registered as a **feed** inside the SDK and built with the package feeds updated.
|
||||
Below is that generalization — run it as-is; it does **not** modify `testbed/`.
|
||||
|
||||
SDK images (match the CI pin — OpenWrt **24.10.4**, opkg/`.ipk`):
|
||||
|
||||
| matrix arch | SDK Docker image (target) | notes |
|
||||
|----------------------|------------------------------------------|--------------------------|
|
||||
| `x86_64` | `openwrt/sdk:x86_64-24.10.4` | VM / testbed |
|
||||
| `aarch64_cortex-a53` | `openwrt/sdk:mediatek-filogic-24.10.4` | **the router** (BananaWRT)|
|
||||
|
||||
> The testbed script currently hardcodes `-24.10.3`; bump it to `-24.10.4` (or
|
||||
> just use the `docker run` below) so local builds match CI. Build as an
|
||||
> unprivileged user, from a path without spaces.
|
||||
|
||||
### x86_64
|
||||
|
||||
```powershell
|
||||
# from repo root: C:\Users\Admin\Desktop\shater
|
||||
New-Item -ItemType Directory -Force .\dist\out\x86_64 | Out-Null
|
||||
docker run --rm `
|
||||
-v "${PWD}\package:/builder/shater-pkgs:ro" `
|
||||
-v "${PWD}\dist\out\x86_64:/builder/artifacts" `
|
||||
openwrt/sdk:x86_64-24.10.4 bash -eus -c @'
|
||||
set -e
|
||||
# register our repo package dir as an SDK feed (src-link needs an ABS path)
|
||||
echo "src-link shater /builder/shater-pkgs" >> feeds.conf.default
|
||||
./scripts/feeds update -a
|
||||
./scripts/feeds install -a -p shater # -p = prefer our feed
|
||||
make defconfig
|
||||
for p in xrayctl shater-core luci-app-shater; do
|
||||
make package/$p/compile V=s -j"$(nproc)"
|
||||
done
|
||||
make package/index
|
||||
find bin -name "*.ipk" -exec cp -v {} /builder/artifacts/ \;
|
||||
'@
|
||||
```
|
||||
|
||||
### aarch64_cortex-a53 (the router)
|
||||
|
||||
Identical, only the image and output dir change:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force .\dist\out\aarch64_cortex-a53 | Out-Null
|
||||
docker run --rm `
|
||||
-v "${PWD}\package:/builder/shater-pkgs:ro" `
|
||||
-v "${PWD}\dist\out\aarch64_cortex-a53:/builder/artifacts" `
|
||||
openwrt/sdk:mediatek-filogic-24.10.4 bash -eus -c @'
|
||||
set -e
|
||||
echo "src-link shater /builder/shater-pkgs" >> feeds.conf.default
|
||||
./scripts/feeds update -a
|
||||
./scripts/feeds install -a -p shater
|
||||
make defconfig
|
||||
for p in xrayctl shater-core luci-app-shater; do
|
||||
make package/$p/compile V=s -j"$(nproc)"
|
||||
done
|
||||
make package/index
|
||||
find bin -name "*.ipk" -exec cp -v {} /builder/artifacts/ \;
|
||||
'@
|
||||
```
|
||||
|
||||
(Bash/Git-Bash equivalent: same body, replace the PowerShell `docker run ... @'
|
||||
...'@` wrapper with `docker run --rm -v "$PWD/package:/builder/shater-pkgs:ro"
|
||||
-v "$PWD/dist/out/<arch>:/builder/artifacts" <image> bash -eu -c '<body>'`.)
|
||||
|
||||
Artifacts land in `dist/out/<arch>/*.ipk` (plus the `Packages*` index). To
|
||||
build just one package, keep only its line in the `for` loop — but the feed
|
||||
setup (`feeds update`/`install`) is required for `xrayctl` (golang) and
|
||||
`luci-app-shater` (luci-base) to resolve.
|
||||
|
||||
### Signing the local feed
|
||||
|
||||
```sh
|
||||
dist/make-feed.sh dist/out/aarch64_cortex-a53 /path/to/secret.key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Installing on the router
|
||||
|
||||
Two options. `xray-core` (the engine) must come from the router's OWN feed, not
|
||||
ours — see the kmod caveat below (same rule applies to the two kmods).
|
||||
|
||||
### Option A — signed feed (recommended, gets you upgrades)
|
||||
|
||||
Add the signed release feed once, then install/upgrade with plain opkg. Full
|
||||
guide (key install, signing, pinning, troubleshooting): [`FEED.md`](FEED.md).
|
||||
|
||||
```sh
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
|
||||
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
|
||||
opkg update
|
||||
opkg install luci-app-shater # pulls xrayctl + shater-core
|
||||
```
|
||||
|
||||
### Option B — manual `.ipk`, in dependency order
|
||||
|
||||
Copy the built `.ipk` files to the router (e.g. `/tmp`) and install bottom-up so
|
||||
each package's depends are already present:
|
||||
|
||||
```sh
|
||||
opkg update
|
||||
# 1) engine + kernel bits from the ROUTER's feed (not ours):
|
||||
opkg install xray-core kmod-nft-tproxy kmod-nft-socket dnsmasq-full
|
||||
# 2) our packages, dependency order:
|
||||
opkg install /tmp/xrayctl_*.ipk # Go daemon (control plane)
|
||||
opkg install /tmp/shater-core_*.ipk # init + fw4/routing glue
|
||||
opkg install /tmp/luci-app-shater_*.ipk # LuCI UI (depends on the above)
|
||||
```
|
||||
|
||||
Order rationale: `luci-app-shater` depends on `xrayctl` + `shater-core` +
|
||||
`xray-core`; `shater-core` wires fw4/routing and pulls the kmods; `xrayctl` is
|
||||
the leaf binary. Installing a `.ipk` whose deps are missing fails, hence
|
||||
bottom-up.
|
||||
|
||||
---
|
||||
|
||||
## 3. kmod caveat (READ THIS for the real router)
|
||||
|
||||
**Kernel modules pin to an exact kernel version+hash.** `kmod-nft-tproxy` and
|
||||
`kmod-nft-socket` (needed for the TPROXY/mark data path) built by *our* SDK will
|
||||
**refuse to load** on the router unless its kernel is byte-identical to the SDK's.
|
||||
|
||||
The router is ImmortalWrt/BananaWRT, not stock OpenWrt 24.10.4 — its kernel hash
|
||||
differs. Therefore:
|
||||
|
||||
- **Do NOT install our SDK's kmods on the router.** Pull `kmod-nft-tproxy` and
|
||||
`kmod-nft-socket` from the **router's own feed** (`opkg update && opkg install
|
||||
kmod-nft-tproxy kmod-nft-socket`), which matches its running kernel.
|
||||
- Our packages only *depend on* those kmods; they don't (and must not) ship them.
|
||||
- Same principle for `xray-core` and `dnsmasq-full`: prefer the router's feed.
|
||||
- Our built `.ipk` set (`xrayctl`, `luci-app-shater`, `shater-core`) contains no
|
||||
kmods — `xrayctl` is a userspace Go binary and the other two are `PKGARCH=all`
|
||||
— so they install cleanly across the 24.10.x line regardless of kernel hash.
|
||||
|
||||
---
|
||||
|
||||
## 4. Releasing a new version (Gitea auto-build)
|
||||
|
||||
CI (`.github/workflows/build.yml`) runs on every push and publishes releases
|
||||
automatically via the Gitea API — no manual upload:
|
||||
|
||||
- **Push to `main`** → rebuilds both arches and refreshes a rolling **`latest`**
|
||||
pre-release (always the newest feed).
|
||||
- **Push a tag `vX.Y.Z`** (`git tag v0.2.0 && git push origin v0.2.0`) →
|
||||
publishes a **versioned** release with the same assets.
|
||||
|
||||
Each release carries, per architecture:
|
||||
- `shater-feed-<arch>.tar.gz` — a ready-to-serve opkg feed (`Packages` + all
|
||||
`.ipk`); and the loose `.ipk` files for `opkg install <url>`.
|
||||
- `x86_64` = testbed VM; `aarch64_cortex-a53` = **both** routers
|
||||
(BPI-R3 `mini_router` + BPI-R4 `main_router`, mediatek/filogic).
|
||||
|
||||
**To ship an upgrade** that `opkg upgrade` will pick up, bump the package
|
||||
release number, then push:
|
||||
- `xrayctl` → `xrayctl/Makefile` `PKG_RELEASE`
|
||||
- `shater-core` → `shater-core/Makefile` `PKG_RELEASE`
|
||||
- `luci-app-shater` → `luci-app-shater/Makefile` `PKG_RELEASE`
|
||||
The data-`.ipk` packers (`ci/pack-core.sh`, `ci/pack-luci.sh`) read these, so a
|
||||
bump propagates to the built version string.
|
||||
|
||||
**Install on a router** (aarch64_cortex-a53):
|
||||
```sh
|
||||
wget -O /tmp/f.tgz https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed-aarch64_cortex-a53.tar.gz
|
||||
mkdir -p /tmp/shater && tar -C /tmp/shater -xzf /tmp/f.tgz
|
||||
opkg update
|
||||
opkg install /tmp/shater/xrayctl_*_aarch64_cortex-a53.ipk \
|
||||
/tmp/shater/shater-core_*_all.ipk \
|
||||
/tmp/shater/luci-app-shater_*_all.ipk
|
||||
```
|
||||
(Feed is unsigned unless the `KEY_BUILD` secret is set — install the `.ipk`
|
||||
directly as above, or add `option check_signature '0'`. Pull `kmod-nft-tproxy`,
|
||||
`kmod-nft-socket`, `xray-core`, `dnsmasq-full` from the router's own feed — see §3.)
|
||||
|
||||
**Optional:** set a repo secret `RELEASE_TOKEN` (a token with `write:repository`)
|
||||
if the auto `GITHUB_TOKEN` lacks release permission on your Gitea instance.
|
||||
-303
@@ -1,303 +0,0 @@
|
||||
# Configuration reference — `/etc/config/shater` + `xrayctl`
|
||||
|
||||
The contract that the entire codebase depends on. UCI = desired state; `xrayctl` renders
|
||||
the xray JSON + nft + policy-routing from it and applies everything atomically.
|
||||
|
||||
## Files on device
|
||||
```
|
||||
/etc/config/shater # UCI desired state (edited by LuCI/user)
|
||||
/etc/xray/run.json # generated live xray config
|
||||
/etc/xray/last-good.json # last valid config (for rollback)
|
||||
/etc/xray/subs/<name>.json # cached subscription nodes (after parse+reconcile)
|
||||
/etc/xray/nft/shater.nft # generated nft table (ours, separate)
|
||||
/var/run/xray/ # runtime: pid, pending-apply, stats
|
||||
/usr/bin/xrayctl # Go control-plane
|
||||
/usr/bin/xray # engine (not ours, a dependency)
|
||||
```
|
||||
|
||||
## UCI schema (`/etc/config/shater`)
|
||||
|
||||
### globals (single section)
|
||||
```
|
||||
config globals 'globals'
|
||||
option enabled '1'
|
||||
option loglevel 'warning' # xray loglevel
|
||||
option kill_switch 'closed' # closed|open — global default
|
||||
option dns_mode 'nftset' # nftset|fakeip
|
||||
option ipv6 '1'
|
||||
option fwmark_base '0x2000' # our reserved range of marks
|
||||
option table_base '0x2000' # base id for routing tables
|
||||
option confirm_timeout '0' # seconds; 0 = no commit-confirm
|
||||
```
|
||||
|
||||
### inbound (0..N — multi-LAN)
|
||||
```
|
||||
config inbound
|
||||
option name 'lan'
|
||||
option enabled '1'
|
||||
option type 'tproxy' # tproxy|socks|http|dokodemo (default tproxy)
|
||||
option network 'lan' # tproxy: UCI LAN interface(s) to intercept
|
||||
option tproxy_port '12345'
|
||||
option tcp '1'
|
||||
option udp '1'
|
||||
option sniff '1' # recover SNI/Host/QUIC
|
||||
# --- local socks/http/dokodemo (type != tproxy); do NOT take part in tproxy-nft ---
|
||||
option listen '127.0.0.1' # socks/http/dokodemo: listen address
|
||||
option port '1080' # socks/http/dokodemo: port
|
||||
option auth 'noauth' # socks/http: noauth|password
|
||||
option user '' # auth=password
|
||||
option pass ''
|
||||
option target_addr '' # dokodemo: fixed destination address (awg-wrap)
|
||||
option target_port '' # dokodemo: fixed port
|
||||
option target_network 'udp' # dokodemo: tcp|udp|tcp,udp
|
||||
```
|
||||
|
||||
### subscription (0..N)
|
||||
```
|
||||
config subscription
|
||||
option name 'qomar'
|
||||
option enabled '1'
|
||||
option url 'https://pro.qomar.pw/sub/...'
|
||||
option update_interval '6h' # 30m|6h|24h|<n>{m,h,d}
|
||||
option fetch_via 'direct' # direct|proxy
|
||||
# HAPP emulation:
|
||||
option ua 'Happ/3.13.0'
|
||||
option hwid 'auto' # auto (generate and remember) | <fixed>
|
||||
option device_os 'Android'
|
||||
option ver_os '14'
|
||||
option device_model 'SM-G998B'
|
||||
list header 'x-key: val' # arbitrary extra headers
|
||||
```
|
||||
|
||||
### node (0..N — manual; subscription nodes are NOT here, they live in the cache)
|
||||
```
|
||||
config node
|
||||
option name 'reality-nl'
|
||||
option enabled '1'
|
||||
option uri 'vless://...' # share-link, parsed by xrayctl
|
||||
# schemes: vless|vmess|trojan|ss|wireguard|wg. A WireGuard/AmneziaWG node —
|
||||
# uri of the form wireguard://<b64-secret>@host:port?publickey=..&address=..&mtu=..
|
||||
# (or paste a whole wg-quick .conf into the Import box — it is converted to a URI).
|
||||
# --- per-node multiplexing + sockopt (T1); all optional ---
|
||||
option mux '0' # multiplexing on/off (ignored for VLESS XTLS-Vision)
|
||||
option mux_concurrency '8' # streams per mux connection
|
||||
option xudp_concurrency '16' # vless/vmess only
|
||||
option xudp_udp443 'reject' # reject|allow|skip (vless/vmess)
|
||||
option sockopt_mark '0' # 0 = loop-guard 255 (any other value is ignored)
|
||||
option tcp_fast_open '' # ''|1|0
|
||||
option tcp_keepalive_idle '0' # seconds; 0 = off
|
||||
```
|
||||
|
||||
### group (0..N)
|
||||
```
|
||||
config group
|
||||
option name 'sub0'
|
||||
option source 'subscription' # subscription|manual
|
||||
option subscription 'qomar' # if source=subscription
|
||||
list node 'reality-nl' # if source=manual
|
||||
option strategy 'leastping' # leastping|random|roundrobin|failover|single
|
||||
list include '' # regex over node name (filter)
|
||||
list exclude ''
|
||||
option probe_url 'http://www.gstatic.com/generate_204'
|
||||
option probe_interval '60s'
|
||||
```
|
||||
|
||||
### chain (0..N — multi-hop)
|
||||
```
|
||||
config chain
|
||||
option name 'triple'
|
||||
list hop 'group:sub0' # order = layers L1..Ln; group:<n>|node:<n>
|
||||
list hop 'group:sub1'
|
||||
list hop 'group:sub2'
|
||||
```
|
||||
|
||||
### egress (0..N)
|
||||
```
|
||||
config egress
|
||||
option name 'via-awg'
|
||||
option type 'interface' # interface|proxy|direct|block
|
||||
option interface 'awgOut' # for type=interface (any iface/tunnel)
|
||||
option target 'chain:triple' # for type=proxy
|
||||
```
|
||||
|
||||
### ruleset (0..N — reusable domain/IP lists)
|
||||
```
|
||||
config ruleset
|
||||
option name 'ru-bypass'
|
||||
option type 'domain' # domain|ipcidr
|
||||
option source 'url' # inline|file|url
|
||||
option url 'https://...' # for url
|
||||
option path '/etc/xray/lists/ru.txt' # for file
|
||||
option format 'plain' # plain|clash|geosite
|
||||
option update_interval '24h'
|
||||
list entry 'example.com' # for inline
|
||||
```
|
||||
|
||||
### rule (0..N — ordered by order, first-match)
|
||||
```
|
||||
config rule
|
||||
option name 'pc-triple'
|
||||
option enabled '1'
|
||||
option order '10'
|
||||
list src '192.168.11.14/32' # cidr|host|mac|iface:<name>|zone:<name>
|
||||
list dst_domain 'geosite:telegram' # domain|suffix|keyword|geosite:x
|
||||
list dst_ruleset 'ru-bypass' # references to ruleset (domain or ip)
|
||||
list dst_ip '1.2.3.0/24' # cidr|geoip:x
|
||||
option dst_port '443' # port|range|list
|
||||
option proto 'tcp,udp'
|
||||
option target 'chain:triple' # chain:|group:|node:|direct|block
|
||||
option egress 'via-awg' # opt. — egress binding
|
||||
option kill 'default' # default|closed|open
|
||||
# --- schedule (T3): rule active only within the window (local time) ---
|
||||
option sched_enabled '0'
|
||||
list sched_day 'mon' # mon..sun; empty = every day
|
||||
option sched_start '22:00' # HH:MM; empty = 00:00
|
||||
option sched_end '06:00' # HH:MM; empty/equal to start = all day; end<start = crosses midnight
|
||||
option sched_tz '' # opt. IANA (Europe/Moscow); empty = router TZ
|
||||
```
|
||||
|
||||
### preset (0..3 — built-in preset packs of rules, T1)
|
||||
```
|
||||
config preset 'block_ads'
|
||||
option name 'block-ads' # block-ads|ru-bypass|private
|
||||
option enabled '0'
|
||||
option order '' # opt. order override (otherwise pack default: 5/8/9)
|
||||
option target '' # opt. target override
|
||||
```
|
||||
Presets are injected as synthetic rules BEFORE user rules (by order).
|
||||
Without geodata, geo-only packs (block-ads/ru-bypass) are dropped (fail-open); private
|
||||
always works (literal RFC1918/ULA).
|
||||
|
||||
### profile (0..N — WAN-mode / conditional overrides, T2)
|
||||
```
|
||||
config profile 'sim_mode'
|
||||
option name 'sim-mode'
|
||||
option enabled '1'
|
||||
option priority '10' # higher = higher precedence among active ones
|
||||
# conditions (all specified are AND-combined; a profile with no conditions is NOT activated):
|
||||
list match_iface 'wwan0' # active when default-route dev ∈ list
|
||||
option probe_url '' # opt. connectivity-probe (HTTP)
|
||||
option probe_mode 'up' # up|down (down = failover: active when probe is down)
|
||||
list sched_day 'mon' # window by day/time (as in rule)
|
||||
option sched_start '09:00'
|
||||
option sched_end '18:00'
|
||||
option sched_tz ''
|
||||
# overrides when active:
|
||||
list enable_rule 'sim-vless-wrap' # force-enable rules by name
|
||||
list disable_rule 'direct-ru' # disable rules
|
||||
option default_target 'group:ru-reality' # override catch-all target
|
||||
option default_egress ''
|
||||
```
|
||||
The active profile (highest priority with satisfied conditions) is computed at
|
||||
gen/reconcile; `xrayctl wanmode` shows the active one. shater-cron re-applies on
|
||||
change of the active profile (part of scheduleSignature). Use cases: SIM uplink→VLESS-egress,
|
||||
whitelist hours→RU-reality.
|
||||
|
||||
### resolver (0..N) + dns_rule (0..N) — DNS
|
||||
```
|
||||
config resolver
|
||||
option name 'proxy-doh'
|
||||
option type 'doh' # doh|dot|plain|local|fakeip
|
||||
option address 'https://dns.quad9.net/dns-query'
|
||||
option detour 'chain:triple' # ACCEPTED BUT NOT RENDERED: xray dns
|
||||
# does not support per-server detour
|
||||
# (skip is logged + a note in the dns object)
|
||||
|
||||
config dns_rule
|
||||
option order '10'
|
||||
list match_domain 'geosite:category-ads'
|
||||
list match_src '192.168.11.0/24' # per-client DNS
|
||||
option resolver 'block' # <resolver name>|block
|
||||
```
|
||||
`globals.resolver_default` / `globals.resolver_fallback` — names of resolver sections:
|
||||
default is rendered FIRST in xray `dns.servers`, fallback LAST (xray
|
||||
uses the order of the list); a value matching no section is
|
||||
treated as a literal server address.
|
||||
|
||||
### profile (0..N — WAN-mode/conditional overrides) [T2]
|
||||
```
|
||||
config profile
|
||||
option name 'sim'
|
||||
option when 'wan:eth2' # wan:<iface>|probe:<url>|time:<HH-HH>
|
||||
list set 'rule.pc-triple.egress=via-sim-vless' # k=v overrides
|
||||
```
|
||||
|
||||
## `xrayctl` interface (CLI)
|
||||
```
|
||||
xrayctl gen [--out FILE] # UCI -> xray JSON (stdout/FILE), no apply
|
||||
xrayctl test # gen + `xray -test`
|
||||
xrayctl apply [--confirm N] # gen -> test -> apply atomically (xray+nft+routing)
|
||||
# --confirm N: auto-rollback after N s without `confirm`
|
||||
xrayctl confirm # confirm the pending apply (cancel auto-rollback)
|
||||
xrayctl rollback # roll back to last-good
|
||||
xrayctl reconcile # idempotently re-apply (for hotplug/boot/watchdog)
|
||||
xrayctl sub update [NAME] # fetch+parse+reconcile subscription(s), refresh cache
|
||||
xrayctl node test [NAME] # probe node(s): alive/latency
|
||||
xrayctl status # JSON: enabled, xray up, active nodes, mode
|
||||
xrayctl nodes # JSON: [{name,group,proto,alive,latency,fingerprint,stale}]
|
||||
xrayctl stats # JSON: per-node bytes + per-client (saddr) bytes + per-rule
|
||||
xrayctl explain SRC DST [--proto] # JSON: which rule, target, egress, exit
|
||||
xrayctl selftest # smoke: parsers, generator on fixtures
|
||||
xrayctl geodata status|download|remove # opt. geoip/geosite (download on button press)
|
||||
xrayctl schedule due # (for cron) prints "1" at a schedule-window boundary
|
||||
xrayctl backup [--file PATH] # tar.gz config+subscription caches (stdout if no --file)
|
||||
xrayctl restore --file PATH [--confirm N] # validate(xray -test)->swap->apply(commit-confirm)
|
||||
xrayctl profile save|list|switch|delete [NAME] [--confirm N] # named config snapshots
|
||||
xrayctl migrate # run UCI schema migrations (idempotent; newer -> refusal)
|
||||
xrayctl compat # JSON: xray version vs features required by the config
|
||||
```
|
||||
Exit codes: 0 ok, !=0 error. All commands are quiet on stdout except JSON outputs and `gen`.
|
||||
|
||||
## ubus interface (object `xray`) — for LuCI
|
||||
Implementation: rpcd-ucode plugin, thin wrappers that call `xrayctl … ` and return JSON.
|
||||
```
|
||||
ubus call xray status
|
||||
ubus call xray nodes
|
||||
ubus call xray stats
|
||||
ubus call xray explain '{"src":"192.168.11.14","dst":"youtube.com"}'
|
||||
ubus call xray sub_update '{"name":"qomar"}'
|
||||
ubus call xray apply
|
||||
ubus call xray confirm
|
||||
ubus call xray reload
|
||||
```
|
||||
ACL `/usr/share/rpcd/acl.d/luci-app-shater.json`: read uci `xray` + ubus `xray` (status/nodes/
|
||||
stats/explain); write uci `xray` + ubus `xray` (sub_update/apply/confirm/reload).
|
||||
|
||||
## Reliability invariants (encoded in apply/reconcile)
|
||||
- Our resources: nft table `inet shater`, fwmark from `fwmark_base`, tables from `table_base`.
|
||||
We do NOT touch the fw4 table.
|
||||
- apply: build → `xray -test` + `nft -c` → atomic swap (nft -f as a single file; xray reload;
|
||||
ip rule/route reconcile) → opt. commit-confirm with auto-rollback.
|
||||
- mgmt-bypass: SSH/LuCI/LAN and router traffic to mgmt always bypass.
|
||||
- Persistence: `reconcile` is invoked from hotplug (ifup/ifdown) and at boot (procd) —
|
||||
but ONLY while the live flag `/var/run/shater.active` is raised (set by `start`,
|
||||
cleared by `stop`): an admin `stop` sticks, background actors do not resurrect
|
||||
interception. `reconcile` is serialized with a flock (`/var/lock/xrayctl.lock`),
|
||||
hash-compares run.json and restarts the engine (SIGTERM→respawn) only on a
|
||||
real change — idempotent calls are free and do not break the tunnel.
|
||||
- kill-switch: per-rule `kill` (default → globals.kill_switch). With
|
||||
`kill_switch=closed` this is a true fail-closed: an empty/unparsed group
|
||||
resolves to `block` (not `direct`); with `ipv6 '0'` LAN-ingress IPv6 is dropped
|
||||
in forward (except ICMPv6-ND/link-local/multicast) — there is no v6 bypass.
|
||||
- A rule whose `src` consists only of MAC/iface:/zone: is resolved to
|
||||
CIDRs at generation time (ip neigh + dhcp.leases + ubus network); if
|
||||
nothing resolves — the rule matches NOTHING (255.255.255.255/32),
|
||||
never "everything".
|
||||
|
||||
## Schema clarifications (v0.1 — from the review, resolving ambiguities)
|
||||
- **Section identity.** Sections are anonymous, but are addressed by the mandatory option `name`,
|
||||
which is **unique within its own type** (two `group`s cannot have the same `name`).
|
||||
References (`target`, `profile.set`, `group:x`, `chain` hop) are resolved by `name`.
|
||||
- **`globals.resolver_default` / `globals.resolver_fallback`** — names of resolver sections
|
||||
(default and fallback). Added to the `globals` block.
|
||||
- **`dst_domain` subforms** (as in xray): `example.com` (suffix/subdomains), `full:host`
|
||||
(exact), `keyword:kw` (substring), `regexp:re`, `geosite:cat`.
|
||||
- **`resolver type=fakeip`**: `option pool '198.18.0.0/15'` (FakeIP range), `address` is not needed.
|
||||
- **Chain (`chain`)**: `hop`s reference **different** `group`/`node`s — these are the layers L1..Ln
|
||||
(e.g. three subscriptions = three groups = three hops). One group = one source.
|
||||
- **`rule.src` formats**: `1.2.3.0/24` (CIDR), `1.2.3.4/32` (host), `iface:<name>`,
|
||||
`zone:<name>`, `AA:BB:CC:DD:EE:FF` (MAC).
|
||||
- **`rule.dst_port`**: single (`443`), range (`1000-2000`), list (`80,443,8443`).
|
||||
- **`group.strategy=single`**: the first alive node of the group is taken (pinning); no balancer is created.
|
||||
The same behavior is auto-applied if a group has exactly one alive node.
|
||||
- **`inbound.network`**: allows multiple values (multi-LAN) — like a repeatable `list network`.
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# Project context (read this first)
|
||||
|
||||
This is the durable, single-source-of-truth context for **shater v0.2** — kept in
|
||||
the repo so it survives conversation compaction and new sessions. If you are an
|
||||
agent or a new contributor picking this up: read this file, then `ROADMAP.md`,
|
||||
`FEATURES.md`, `ARCHITECTURE.md`, `DECISIONS.md`.
|
||||
|
||||
## What shater is
|
||||
|
||||
An **internet-control appliance for OpenWrt routers**: a whole-network transparent
|
||||
proxy + DNS filter + traffic-analytics box, configured from a rich web admin
|
||||
panel. One device turns a home/office network into: VPN-through-the-router (split
|
||||
by domain/geo/client, no DNS leaks), an ad/tracker/malware blocker, per-device
|
||||
parental control, and a detailed live dashboard — all local, all self-hosted.
|
||||
|
||||
Target hardware: Banana Pi **BPI-R3** (MT7986/Filogic 830) and **BPI-R4**
|
||||
(MT7988/Filogic 880), both the OpenWrt `mediatek/filogic` target (package arch
|
||||
`aarch64_cortex-a53`). `x86_64` is the QEMU test VM.
|
||||
|
||||
## Where we came from — v0.1 (branch `v0.1`)
|
||||
|
||||
The **`v0.1` git branch** holds a *complete, working, VM-verified* first version.
|
||||
Do not delete it — we port proven pieces from it. What v0.1 has:
|
||||
|
||||
- **`xrayctl`** — a Go control-plane: parses subscription share-links, renders
|
||||
**xray-core** JSON + an nftables `inet shater` table + policy routing, applies
|
||||
atomically with `xray -test`/`nft -c` validation and commit-confirm rollback.
|
||||
- **`shater-core`** — procd init, hotplug reconcile, sysctl, fw4/routing glue,
|
||||
default UCI config. Fail-closed kill-switch, live-flag `/var/run/shater.active`,
|
||||
watchdog cron.
|
||||
- **`luci-app-shater`** — a custom "instrument panel" LuCI app (client-side JS +
|
||||
ucode/rpcd ubus backend): Overview with a live Signal Path, Simple/Advanced
|
||||
toggle, quick-start wizard, Nodes/Subs/Rules/DNS/Live/Profiles/Settings pages.
|
||||
- **CI + signed opkg feed** on Gitea: builds per-arch, signs the feed index with
|
||||
usign, publishes a rolling `latest` Gitea release consumable as `src/gz`. **Feed
|
||||
signing key fingerprint `5ac4b177689cb8e0`**; public key `dist/shater-feed.pub`,
|
||||
secret in the Gitea repo secret `KEY_BUILD`.
|
||||
- Verified end-to-end on the VM: real LAN client proxied, DNS anti-leak, honest
|
||||
fail-closed, opkg install/upgrade from the signed feed.
|
||||
|
||||
v0.1 is engine-locked to **xray-core**; its generator, share-link parser and
|
||||
`run.json` are xray-shaped.
|
||||
|
||||
## The v0.2 pivot (decided in the session that created this file)
|
||||
|
||||
We are rebasing onto a new engine and a new UI architecture. Full rationale in
|
||||
`DECISIONS.md`. Short version:
|
||||
|
||||
1. **Engine → a FORK of `sing-box-lx`, with our whole product embedded inside it.**
|
||||
`github.com/Leadaxe/sing-box-lx` is a thin, rebaseable downstream fork of
|
||||
SagerNet/sing-box adding **AmneziaWG 2.0** (I1–I5 CPS decoy packets), XHTTP,
|
||||
MASQUE/WARP, and gRPC observability (DNS queries / rules / outbounds). Upstream
|
||||
sing-box brings VLESS/VMess/Trojan/Shadowsocks/WireGuard/Reality + Hysteria2/
|
||||
TUIC. It is library-first (`libbox`) and **GPL-3.0** (compatible with us).
|
||||
- We **fork it** (not just depend on it) so we can embed literally everything —
|
||||
control-plane, admin panel, DNS filter — and integrate tightly with the
|
||||
engine internals (DNS, routing, stats). This is a deliberate, decided
|
||||
trade-off: maximum integration over minimum maintenance.
|
||||
- **Maintainability discipline (mandatory):** our overlay lives in NEW
|
||||
top-level dirs (`shater/`, `panel/`, `openwrt/`) so it never conflicts with
|
||||
upstream files on rebase. Any unavoidable edit to an upstream file is minimal
|
||||
and marked `// shater`. We **rebase/merge onto sing-box-lx (and thus sing-box)
|
||||
tags** on a schedule — the same model sing-box-lx uses on sing-box. Fork ≠
|
||||
divergence; fork = additive overlay tracking upstream tags.
|
||||
- We do **not** write a proxy engine from scratch (byte-precise anti-DPI arms
|
||||
race — reuse, never reinvent).
|
||||
|
||||
2. **UI → thin LuCI launcher + separate full admin panel (embedded in the binary).**
|
||||
LuCI stays minimal: a small, pretty mini-dashboard plus an **"Open panel"**
|
||||
button. That button mints a **short-lived token** inside the already-
|
||||
authenticated LuCI session (via ubus) and redirects to our **standalone admin
|
||||
panel served on its own port** by the daemon. The panel validates the token
|
||||
with the daemon and opens a session. Panel auth is bootstrapped from LuCI's
|
||||
existing auth (no second login to secure); the real UX — detailed config, rich
|
||||
live stats, per-device control — is a modern SPA we fully own, served by and
|
||||
embedded in the forked binary.
|
||||
|
||||
3. **We own the value layers:** control-plane, DNS filter + blocklists (flexible
|
||||
sources: inline / file / url / geosite), per-domain + per-device statistics,
|
||||
per-device policy, schedules, alerts. Built inside the fork, hooking the
|
||||
engine's DNS/routing/stats directly.
|
||||
|
||||
4. **License → GPL-3.0** (sing-box is GPL-3.0; our former GPL-2.0-or-later files
|
||||
upgrade cleanly).
|
||||
|
||||
## Repository model
|
||||
|
||||
- **`shater` `main` = our fork of sing-box-lx.** After Phase 1 it contains the
|
||||
full sing-box-lx tree PLUS our additive overlay (`shater/`, `panel/`,
|
||||
`openwrt/`, `docs/`). Upstream is tracked via a git remote and merged by tag.
|
||||
- **`shater` branch `v0.1`** = the standalone xray-based version (frozen, ported
|
||||
from).
|
||||
- Until Phase 1 merges the engine in, `main` is the docs-first overlay seed you
|
||||
are reading now (LICENSE, README, `docs/`, `dist/shater-feed.pub`).
|
||||
|
||||
## What to port from v0.1 (don't rewrite these ideas)
|
||||
|
||||
Engine-agnostic and proven — port and adapt into the `shater/` + `openwrt/`
|
||||
overlay, don't redo:
|
||||
- The **reliability layer**: atomic apply, validate → stage → swap, commit-confirm
|
||||
rollback, idempotent hash-compared reconcile under flock, live-flag semantics,
|
||||
hotplug/boot persistence, management-bypass, fail-closed kill-switch.
|
||||
- **nftables `inet shater`** tproxy/mark/counters + policy routing (own marks/
|
||||
tables, never touch fw4).
|
||||
- **Subscription fetch** (HAPP emulation, fingerprint reconcile, per-sub cache)
|
||||
and the flexible **ruleset/list** model — though sing-box has its own share-link
|
||||
parser and config schema we now target.
|
||||
- **CI feed build + usign signing + Gitea release** (adapt to the single forked
|
||||
binary; keep key `5ac4b177689cb8e0`).
|
||||
- The LuCI **design system** (the "instrument panel" identity) — reused for the
|
||||
mini-dashboard and as the panel's visual language.
|
||||
|
||||
New in v0.2: sing-box config generation (replaces xray JSON) via the engine's own
|
||||
types, the embedded admin-panel web server + token-handoff auth, the DNS
|
||||
filter/stats engine wired into sing-box's DNS.
|
||||
|
||||
## Infra & testbed (for whoever continues)
|
||||
|
||||
- **Repo:** `https://git.qomar.pw/omar/shater` (Gitea). Default branch `main` =
|
||||
v0.2 fork; branch `v0.1` = the working xray-based version.
|
||||
- **Upstream to track:** `https://github.com/Leadaxe/sing-box-lx` (which tracks
|
||||
`https://github.com/SagerNet/sing-box`).
|
||||
- **CI:** Gitea Actions (act_runner + Docker). v0.1's workflow was removed from
|
||||
`main`; new CI is added when the v0.2 build exists.
|
||||
- **Feed signing:** usign key `5ac4b177689cb8e0`; secret in repo secret
|
||||
`KEY_BUILD`; public key `dist/shater-feed.pub` (kept so existing installs keep
|
||||
verifying).
|
||||
- **Test VM:** OpenWrt 24.10.3 x86_64 in Docker (`docker ps --filter
|
||||
name=openwrt-vm`). SSH via the ssh-manager MCP server `local_openwrt`
|
||||
(localhost:2222, root/openwrt). LuCI at `http://127.0.0.1:8080` (root/openwrt),
|
||||
drivable with the Playwright MCP.
|
||||
|
||||
## Current status
|
||||
|
||||
Repo reset done: v0.1 preserved on its branch; `main` cleaned to this docs-first
|
||||
scaffold. Next is Phase 1 in `ROADMAP.md` — fork sing-box-lx into `main`
|
||||
(add upstream remote, merge a pinned tag), stand up the embedding prototype
|
||||
(prove AmneziaWG 2.0, measure binary size with feature-trim + `-s -w` + UPX)
|
||||
before building the control plane and panel.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Decisions (ADR log)
|
||||
|
||||
Key architectural decisions and *why*, so nobody re-litigates them (we spent a
|
||||
whole discussion converging — see also `CONTEXT.md`).
|
||||
|
||||
## D1 — Do NOT write a proxy engine from scratch
|
||||
The proxy protocols (VLESS/VMess/Trojan/Shadowsocks, Reality/XTLS, AmneziaWG,
|
||||
Hysteria2/TUIC, transports, TLS fingerprinting) are a byte-precise, adversarial
|
||||
anti-DPI arms race maintained by large communities and changing monthly. A
|
||||
from-scratch engine would be slower, buggier, less secure, and *more* detectable —
|
||||
the opposite of "optimized." **Reuse a real engine.**
|
||||
|
||||
## D2 — Engine = sing-box (via `sing-box-lx`), not xray-core
|
||||
- **AmneziaWG 2.0** (I1–I5 CPS decoy packets) is a hard requirement; sing-box has
|
||||
first-class AmneziaWG, xray's is weaker. sing-box also has broader protocol
|
||||
coverage (Hysteria2, TUIC, ShadowTLS, MASQUE) and is **library-first**
|
||||
(`libbox`), which suits embedding.
|
||||
- `sing-box-lx` (`github.com/Leadaxe/sing-box-lx`) is a thin, actively-rebased
|
||||
downstream fork adding AmneziaWG 2.0, XHTTP, MASQUE/WARP and — usefully for us —
|
||||
**gRPC observability of DNS queries / rules / outbounds**, which feeds our stats.
|
||||
- License **GPL-3.0**, compatible with (and cleaner than) our prior
|
||||
GPL-2.0-or-later. See D6.
|
||||
- Cost accepted: our whole control plane / generator / share-link handling is
|
||||
re-based from xray-shaped (v0.1) to sing-box-shaped.
|
||||
|
||||
## D3 — FORK sing-box-lx and embed the whole product inside it (not "depend as a library")
|
||||
Considered: (a) consume as a pinned Go module + Gitea mirror; (b) fork.
|
||||
**Chosen: (b) fork**, so we can embed control-plane + admin panel + DNS filter
|
||||
directly and integrate tightly with engine internals (DNS, routing, stats). This
|
||||
was a deliberate call favouring maximum integration over minimum maintenance.
|
||||
|
||||
To keep the fork from rotting, a **mandatory discipline**:
|
||||
- Our code lives in **new top-level dirs** (`shater/`, `panel/`, `openwrt/`) that
|
||||
never collide with upstream files on rebase.
|
||||
- Edits to upstream files are minimal and marked `// shater`.
|
||||
- We **rebase/merge onto sing-box-lx tags** on a cadence (mirroring how
|
||||
sing-box-lx rebases onto sing-box). Fork = additive overlay tracking upstream,
|
||||
not a divergent rewrite.
|
||||
- `main` of the `shater` repo *is* the fork; `v0.1` branch keeps the old project.
|
||||
|
||||
## D4 — UI = thin LuCI launcher + a separate embedded admin panel
|
||||
LuCI stays a minimal, pretty mini-dashboard with an **"Open panel"** button. The
|
||||
button mints a **short-lived token** in the authenticated LuCI/ubus session and
|
||||
redirects to our **admin panel on its own port**, served by the daemon; the panel
|
||||
validates the token and opens a session.
|
||||
- **Why not do everything in LuCI:** LuCI's form/view model is limiting for the
|
||||
rich stats/config UX we want.
|
||||
- **Why not a standalone panel with its own login:** a router-facing panel with
|
||||
its own auth is a serious security surface to get right. Bootstrapping the
|
||||
token from LuCI's existing, hardened auth avoids a second login system.
|
||||
- The panel is a modern SPA, embedded in and served by the forked binary.
|
||||
|
||||
## D5 — Long blocklists: don't push megalists into dnsmasq; use an efficient matcher
|
||||
Naive `address=/domain/#` in dnsmasq holds every domain in RAM and reloads slowly
|
||||
(100k–1M+ entry lists are common). Instead the **engine's own domain matcher**
|
||||
(sing-box already compiles geosite-scale lists) or **our compact matcher**
|
||||
(suffix hash-set + optional bloom prefilter, compiled/cached blob, dedup +
|
||||
subdomain-collapse, streaming parse, refresh by content-hash) does the filtering.
|
||||
Blocklist **sources are flexible**: `inline` / `file` / `url` / `geosite`
|
||||
(geosite only when geodata is present, else that source is inert / fail-open).
|
||||
|
||||
## D6 — License = GPL-3.0
|
||||
sing-box is GPL-3.0; linking it makes the combined work GPL-3.0. Our own files may
|
||||
stay GPL-2.0-or-later (which permits the upgrade), but the project LICENSE is
|
||||
GPL-3.0 for clarity.
|
||||
|
||||
## D7 — Keep the v0.1 feed signing identity
|
||||
The usign feed key `5ac4b177689cb8e0` (public key in `dist/shater-feed.pub`,
|
||||
secret in Gitea secret `KEY_BUILD`) carries over, so routers that already trust it
|
||||
keep verifying v0.2 packages. Do not regenerate it without a documented rotation.
|
||||
|
||||
## D8 — Preserve, don't destroy: v0.1 lives on its branch
|
||||
The reset moved the full working xray-based project to the `v0.1` branch and
|
||||
cleaned `main`. Nothing is lost; reusable logic (reliability layer, nft/routing,
|
||||
sub-fetch, CI/signing, design system) is ported forward, not rewritten.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Feature list
|
||||
|
||||
The full intended feature set for shater v0.2. Tags: **[MVP]** target the first
|
||||
usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
|
||||
|
||||
## Proxy engine & protocols (from the sing-box fork)
|
||||
- **[MVP]** VLESS, VMess, Trojan, Shadowsocks, WireGuard, Reality/XTLS.
|
||||
- **[MVP]** **AmneziaWG 2.0** (I1–I5 CPS decoy packets) — a driving requirement.
|
||||
- **[T1]** Hysteria2, TUIC, ShadowTLS, XHTTP, MASQUE/CONNECT-IP (Cloudflare WARP).
|
||||
- **[MVP]** Transports: TCP/WS/gRPC/HTTPUpgrade/H2/QUIC as upstream provides.
|
||||
|
||||
## Transparent proxying & routing
|
||||
- **[MVP]** TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP), SNI/
|
||||
Host/QUIC sniffing.
|
||||
- **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone),
|
||||
destination (domain/suffix/keyword/geosite), reusable domain/IP lists, port,
|
||||
proto → target (outbound/selector/chain/direct/block) + egress.
|
||||
- **[MVP]** Node groups with balancer/observatory (least-ping/failover/round-robin).
|
||||
- **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any
|
||||
interface/tunnel (e.g. an AmneziaWG tunnel).
|
||||
- **[T2]** Per-destination latency-based routing; auto route-optimization.
|
||||
|
||||
## Subscriptions & nodes
|
||||
- **[MVP]** Subscriptions (VLESS/VMess/Trojan/SS/WG/AmneziaWG), Clash/sing-box/
|
||||
Xray-JSON formats, per-sub update interval + manual + on-boot; HAPP-style fetch
|
||||
(UA/HWID/headers); stable per-node identity (fingerprint reconcile) across
|
||||
refreshes; quota/expiry from `subscription-userinfo`.
|
||||
- **[MVP]** Manual nodes: paste share-link(s), file import, or a wg-quick/
|
||||
AmneziaWG `.conf`.
|
||||
- **[T1]** Node health test (TCP + real proxy-path HTTP probe, exit-IP), "test all",
|
||||
QR export.
|
||||
|
||||
## DNS, filtering & blocking (a core value layer)
|
||||
- **[MVP]** :53 hijack, in-process sing-box DNS; per-domain resolver selection;
|
||||
DoH/DoT/plain resolvers; FakeIP mode; nftset population for routing; no DNS leaks.
|
||||
- **[MVP]** Client DoT/DoH blocking (stop devices bypassing the filter).
|
||||
- **[MVP]** **Blocklists** with **flexible sources**: `inline` (type your own) /
|
||||
`file` / `url` (auto-update) / `geosite` category (only when geodata present).
|
||||
Response NXDOMAIN or 0.0.0.0; allowlist overrides. Seed StevenBlack/OISD/AdGuard.
|
||||
- **[MVP]** **Efficient matching for huge lists** (100k–1M+): compiled/cached
|
||||
matcher, dedup + subdomain-collapse, bloom prefilter, refresh by content-hash —
|
||||
not dnsmasq megalists (see `DECISIONS.md` D5).
|
||||
- **[T1]** Safe-search enforcement; category-based blocking bundles.
|
||||
|
||||
## Per-device control & parental
|
||||
- **[T1]** Devices page: auto-discover (dhcp.leases + ip neigh), name devices,
|
||||
live status/traffic.
|
||||
- **[T1]** Per-device toggles: proxy on/off, blocklists on/off, exit country/node.
|
||||
- **[T1]** Per-device domain block/allow (block a site for one device or everyone).
|
||||
- **[T2]** Schedules: time-windowed rules (bedtime, school hours) per device/group.
|
||||
- **[T2]** Per-device data quotas.
|
||||
|
||||
## Statistics & visibility (a core value layer)
|
||||
- **[T1]** Per-domain stats: top queried/blocked domains, allowed-vs-blocked,
|
||||
per-device breakdown, timelines — fed by the engine's in-process DNS events.
|
||||
- **[MVP]** Per-client / per-node / per-rule traffic (bytes), from nft counters +
|
||||
engine stats.
|
||||
- **[T1]** Live query log (streaming) with one-click block/allow.
|
||||
- **[T2]** Connection inspector; Sankey/leaderboard views; geo-map of exits.
|
||||
|
||||
## Reliability ("железно")
|
||||
- **[MVP]** Fail-closed kill-switch (dead group → block, never silent direct leak);
|
||||
IPv6 dropped when disabled.
|
||||
- **[MVP]** Atomic apply with engine + `nft -c` validation; commit-confirm
|
||||
auto-rollback to last-good.
|
||||
- **[MVP]** Idempotent reconcile from hotplug/boot under flock; restart engine only
|
||||
on real config change; management-bypass (SSH/LuCI/LAN) always exempt.
|
||||
- **[MVP]** Own nft table `inet shater` + own marks/tables; never touch fw4.
|
||||
|
||||
## UI — thin LuCI + full admin panel
|
||||
- **[MVP]** Thin LuCI app: pretty mini-dashboard (status + throughput) + "Open
|
||||
panel" button with short-lived token handoff (see `ARCHITECTURE.md` §2).
|
||||
- **[MVP]** Admin panel (SPA, own port, embedded in the binary): overview,
|
||||
node/subscription management, routing rules, apply/rollback, DNS/blocklists.
|
||||
- **[T1]** Rich stats dashboards, devices page, live query log, config diff/history.
|
||||
- **[T2]** Named profiles/scenes; WAN-mode profiles (conditional overrides, e.g.
|
||||
SIM uplink → different egress); backup/restore; i18n (EN + RU).
|
||||
|
||||
## Ops & distribution
|
||||
- **[MVP]** Single signed binary; signed opkg feed on Gitea (reuse key
|
||||
`5ac4b177689cb8e0`); one-line install; `opkg upgrade`.
|
||||
- **[T1]** Upstream-rebase cadence (track sing-box-lx tags) with a smoke suite.
|
||||
- **[T2]** apk (OpenWrt 25.x) packaging; multi-router fleet management; REST/gRPC
|
||||
external API; Telegram bot.
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
# Installing & updating shater from the package feed
|
||||
|
||||
You don't have to copy `.ipk` files around. Every push to `main` publishes an
|
||||
**opkg package feed** on Gitea, so a router can install shater once and then pull
|
||||
upgrades with a plain `opkg upgrade` — exactly like the official OpenWrt feeds.
|
||||
|
||||
- Feed URL (rolling, always the newest build): `https://git.qomar.pw/omar/shater/releases/download/latest`
|
||||
- Pinned to a version: `https://git.qomar.pw/omar/shater/releases/download/vX.Y.Z`
|
||||
|
||||
The feed is a single Gitea release that carries a **usign-signed** `Packages.gz`
|
||||
index plus every `.ipk`. opkg filters a feed by CPU architecture, so **one feed
|
||||
line works on every device**: the BPI‑R3 / BPI‑R4 routers pick the
|
||||
`aarch64_cortex-a53` build, the x86‑64 testbed picks `x86_64`, and both pick the
|
||||
arch‑independent (`all`) packages.
|
||||
|
||||
Signing key fingerprint: **`5ac4b177689cb8e0`** (public key: `shater-feed.pub`,
|
||||
shipped both in this release and in the repo at `dist/shater-feed.pub`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Add the feed (one time)
|
||||
|
||||
The feed is **signed**, so — unlike an unsigned feed — you keep opkg's signature
|
||||
checking **on** and just install the public key once:
|
||||
|
||||
```sh
|
||||
# 1. trust the feed's public key (filename MUST be the key fingerprint)
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
|
||||
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
|
||||
# 2. add the feed
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
|
||||
```
|
||||
|
||||
That's it — `opkg update` now verifies `Packages.sig` against the key, exactly
|
||||
like the official OpenWrt feeds. Confirm the key landed:
|
||||
|
||||
```sh
|
||||
opkg-key list 2>/dev/null | grep 5ac4b177689cb8e0 || ls -l /etc/opkg/keys/5ac4b177689cb8e0
|
||||
```
|
||||
|
||||
<details><summary>Older / unsigned builds, or if you'd rather not install the key</summary>
|
||||
|
||||
Pass `--no-check-signature` to the shater opkg commands, e.g.
|
||||
`opkg --no-check-signature update`. Or disable checking globally by
|
||||
**removing/commenting** the option — note `option check_signature 0` does NOT
|
||||
disable it (opkg treats the option's mere *presence* as on and will reject an
|
||||
unsigned feed): `sed -i 's/^option check_signature.*/# option check_signature/' /etc/opkg.conf`.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 2. Install
|
||||
|
||||
```sh
|
||||
opkg update
|
||||
opkg install luci-app-shater
|
||||
```
|
||||
|
||||
`luci-app-shater` depends on `xrayctl` and `shater-core`, so opkg pulls all three.
|
||||
Its **runtime** dependencies that live in the standard OpenWrt feed —
|
||||
`xray-core`, `dnsmasq-full`, the `kmod-nft-tproxy` family — are resolved from that
|
||||
feed automatically as long as it is reachable. If your router still has the stock
|
||||
`dnsmasq` (not `-full`), opkg will swap it as part of the install.
|
||||
|
||||
Then open **LuCI → Services → Shater (xray)** and run the Quick‑start wizard.
|
||||
|
||||
---
|
||||
|
||||
## 3. Update / upgrade
|
||||
|
||||
```sh
|
||||
opkg update # refresh the index (signature verified)
|
||||
opkg list-upgradable # see what's newer
|
||||
opkg upgrade xrayctl shater-core luci-app-shater
|
||||
```
|
||||
|
||||
Version comparison is on the package release (`0.1.0-r19` > `0.1.0-r7`), so a new
|
||||
build always wins. Nothing restarts your tunnel unexpectedly: `shater-core`'s init
|
||||
regenerates its config on start, and `xrayctl` only reloads the engine when the
|
||||
rendered config actually changed.
|
||||
|
||||
To upgrade *everything* installed from every feed: `opkg upgrade` with no args is
|
||||
**not** supported by opkg; upgrade the shater packages by name as above.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pin to a specific version (optional)
|
||||
|
||||
The rolling `latest` feed tracks `main`. To hold a box on a known-good release,
|
||||
point it at a version tag instead:
|
||||
|
||||
```sh
|
||||
sed -i 's#/releases/download/latest#/releases/download/v0.2.0#' /etc/opkg/customfeeds.conf
|
||||
opkg update
|
||||
```
|
||||
|
||||
Version tags are produced by pushing a `vX.Y.Z` git tag (see `BUILD.md` §4).
|
||||
|
||||
---
|
||||
|
||||
## 5. How the signing works (and rotating the key)
|
||||
|
||||
The feed **is** signed — you don't need to do anything to enable it. CI builds
|
||||
`usign` (`ci/install-usign.sh`), and `ci/make-index.sh` signs `Packages` →
|
||||
`Packages.sig` with the secret half of the feed key, held in the Gitea repo
|
||||
secret **`KEY_BUILD`**. The public half lives in the repo at `dist/shater-feed.pub`
|
||||
and is published with every release. Routers verify against it (§1).
|
||||
|
||||
If `KEY_BUILD` is set but `usign` can't sign, the build **fails** on purpose —
|
||||
better than silently shipping an unsigned feed that routers then reject.
|
||||
|
||||
To **rotate** the key (e.g. it leaked):
|
||||
|
||||
```sh
|
||||
usign -G -s shater-feed.sec -p shater-feed.pub -c "shater feed signing key"
|
||||
usign -F -p shater-feed.pub # new fingerprint
|
||||
```
|
||||
|
||||
1. Replace `dist/shater-feed.pub` in the repo and commit.
|
||||
2. Update the Gitea repo secret `KEY_BUILD` with the new `shater-feed.sec` contents:
|
||||
`PUT /api/v1/repos/omar/shater/actions/secrets/KEY_BUILD` `{"data":"<sec>"}`.
|
||||
3. Re-install the new public key on each router (§1) under its **new** fingerprint
|
||||
filename, and delete the old `/etc/opkg/keys/<old-fp>`.
|
||||
|
||||
The current key fingerprint is **`5ac4b177689cb8e0`**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Remove
|
||||
|
||||
```sh
|
||||
opkg remove luci-app-shater xrayctl shater-core
|
||||
# and, if you want the feed gone too:
|
||||
sed -i '/releases\/download\/.*shater/d;/ shater /d' /etc/opkg/customfeeds.conf
|
||||
```
|
||||
|
||||
`shater-core`'s uninstall tears down the nftables table, policy routing and the
|
||||
kill‑switch, so removing it returns the router to plain routing.
|
||||
|
||||
---
|
||||
|
||||
## 7. A note on `apk` (OpenWrt 25.x and snapshots)
|
||||
|
||||
OpenWrt is migrating from `opkg` to **`apk`** (the Alpine package manager). The VM
|
||||
and both BPI routers here run 24.10.x, which is **opkg** — so this guide uses opkg.
|
||||
|
||||
When you move to an apk-based build, the same release assets can be served as an
|
||||
apk repository (`apk add --repository <url> luci-app-shater`), but the index format
|
||||
differs (`APKINDEX.tar.gz`, signed with an apk key). The CI `make-index.sh` step
|
||||
would need an apk-index variant; that's not wired yet because no target here uses
|
||||
apk. Open an issue when a device moves to 25.x and it's a small addition.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| `opkg update` → *Signature check failed* / shater list vanishes | The feed's public key isn't installed. Do step 1 of §1 (fetch `shater-feed.pub` into `/etc/opkg/keys/5ac4b177689cb8e0`). As a stopgap use `opkg --no-check-signature update`. |
|
||||
| Set `option check_signature 0` and it still rejects the feed | opkg treats the option's mere *presence* as “on”, whatever the value. Install the key (§1), or **comment/remove** the line — don't set it to `0`. |
|
||||
| `Package xrayctl … has no valid architecture, ignoring` | Harmless. The combined index carries every arch; opkg ignores the builds that don't match this device and installs the right one. |
|
||||
| `opkg update` → *wget returned 4 / SSL* | The router lacks CA certs: `opkg install ca-bundle` (from the stock feed) or, offline, the tarball method in the release notes. |
|
||||
| `install` → *cannot satisfy dependency xray-core* | The standard OpenWrt package feed isn't reachable. Fix connectivity, or install `xray-core` from your usual source first. |
|
||||
| Installed but the wrong arch | opkg only offers packages matching your `arch` list (`opkg print-architecture`). If your target isn't `x86_64` or `aarch64_cortex-a53`, build for it — see `BUILD.md`. |
|
||||
@@ -0,0 +1,72 @@
|
||||
# Roadmap
|
||||
|
||||
Phased plan for shater v0.2. Each phase ends with something verifiable on the test
|
||||
VM (see `CONTEXT.md` for the testbed). Port proven logic from the `v0.1` branch;
|
||||
build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
|
||||
|
||||
## Phase 0 — Repo reset & context ✅
|
||||
- Preserve the working xray-based project on branch `v0.1`.
|
||||
- Clean `main` to a docs-first scaffold; write CONTEXT / DECISIONS / ARCHITECTURE
|
||||
/ ROADMAP / FEATURES / README. License → GPL-3.0. Keep the feed key.
|
||||
|
||||
## Phase 1 — Fork & embedding prototype ← NEXT
|
||||
- Turn `main` into the fork: add `upstream` remote (`Leadaxe/sing-box-lx`), merge
|
||||
a pinned tag with `--allow-unrelated-histories`, keep our overlay dirs.
|
||||
- Minimal embed: build a tiny Go main in `shater/` that starts a sing-box instance
|
||||
from a config we produce, one outbound from a share-link + a tproxy inbound.
|
||||
- **Prove AmneziaWG 2.0** connects end-to-end.
|
||||
- **Measure binary size**: feature-trim (import only needed protocols/transports),
|
||||
`-ldflags "-s -w"`, `upx --lzma`. Target ≤ ~18 MB on flash. Record numbers.
|
||||
- Cross-compile for `aarch64_cortex-a53` + `x86_64`; run on the VM.
|
||||
- **Gate:** engine embeds, awg2 works, size acceptable → proceed. Else reconsider.
|
||||
|
||||
## Phase 2 — Control-plane port
|
||||
- Port from v0.1 (adapt to sing-box types): UCI model, config generator (→ sing-box
|
||||
config, not xray JSON), share-link handling (reuse sing-box's parser where
|
||||
possible), atomic apply/validate/rollback, nft `inet shater` + policy routing,
|
||||
idempotent reconcile under flock, kill-switch, management-bypass, live-flag,
|
||||
hotplug/boot persistence, subscription fetch (+ HAPP), rulesets/lists.
|
||||
- procd init + uci-defaults in `openwrt/` (from v0.1 `shater-core`).
|
||||
- **Gate:** a real LAN client on the VM is proxied end-to-end through a sing-box
|
||||
outbound, DNS anti-leak holds, kill-switch is honest.
|
||||
|
||||
## Phase 3 — Admin panel MVP + thin LuCI launcher
|
||||
- `panel/`: embedded web server on its own port + session store; token-mint ubus
|
||||
method in the thin LuCI app; token-handoff auth (see ARCHITECTURE §2).
|
||||
- Thin LuCI app: mini dashboard (status + throughput) + "Open panel" button;
|
||||
reuse the v0.1 "instrument panel" design language.
|
||||
- Panel SPA MVP: status/overview, node/subscription management, basic routing
|
||||
rules, apply/rollback. Frontend build embedded into the binary.
|
||||
- **Gate:** log into the panel from LuCI via token, configure a proxy, apply.
|
||||
|
||||
## Phase 4 — DNS filter & blocklists
|
||||
- In-process DNS filter hooking sing-box DNS: blocklists with flexible sources
|
||||
(`inline`/`file`/`url`/`geosite`), allowlist overrides, NXDOMAIN/0.0.0.0.
|
||||
- Efficient matcher for megalists (compiled/cached, dedup, bloom prefilter); seed
|
||||
well-known lists (StevenBlack/OISD/AdGuard).
|
||||
- **Gate:** ad/tracker domains blocked network-wide; big list loads fast; RAM sane.
|
||||
|
||||
## Phase 5 — Statistics (per-domain / client / device)
|
||||
- Stats aggregator consuming the engine's DNS/routing/stats observability + nft
|
||||
counters: top domains, allowed vs blocked, per-device breakdown, timelines,
|
||||
per-node/per-rule traffic, live query log with one-click block.
|
||||
- Panel dashboards for all of the above.
|
||||
- **Gate:** live, accurate per-domain and per-device stats in the panel.
|
||||
|
||||
## Phase 6 — Per-device control & parental
|
||||
- Devices page: discover (dhcp.leases + ip neigh), name, live status; per-device
|
||||
toggles (proxy on/off, blocklists, exit country), per-device domain block/allow.
|
||||
- **Gate:** block a domain for one device only; route one device via a chosen exit.
|
||||
|
||||
## Phase 7 — Schedules & alerts
|
||||
- Time-based rules (bedtime/school hours) via the ported scheduler; Telegram/
|
||||
webhook alerts (sub expiry, node down, kill-switch trip, new device).
|
||||
|
||||
## Phase 8 — Ship it
|
||||
- Adapt CI to build/sign the single forked binary for both arches; publish the
|
||||
signed opkg feed (reuse key `5ac4b177689cb8e0`); install/upgrade docs.
|
||||
- Set an upstream-rebase cadence (merge new sing-box-lx tags, run the smoke suite).
|
||||
|
||||
## Cross-cutting (every phase)
|
||||
Tests + live VM verification before commit; keep the fork overlay conflict-free;
|
||||
document as we go.
|
||||
@@ -1,109 +0,0 @@
|
||||
# examples/ — sample configs & parser fixtures
|
||||
|
||||
Realistic, heavily-annotated fixtures for the **shater** xray control-plane
|
||||
(`xrayctl`). They double as (a) manual test fixtures and (b) onboarding docs.
|
||||
Every option name is cross-checked against [`../docs/CONFIG.md`](../docs/CONFIG.md);
|
||||
any field the contract left ambiguous is flagged in
|
||||
[Contract gaps / interpretations](#contract-gaps--interpretations) below.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|------|-----------|
|
||||
| `etc-config-shater.full` | Complete `/etc/config/shater` exercising **every** section type in the contract: `globals`, multi-LAN `inbound`, two `subscription` (HAPP auto-hwid + fixed-hwid with extra headers), manual `node`, subscription- and manual-sourced `group`, a 3-hop `chain`, `egress` of all four types (interface/proxy/direct/block), `ruleset` (domain-from-url + inline ipcidr), `rule` (per-client /32, MAC, domain-list, geosite, egress binding, kill modes), `resolver` (doh+detour / local / fakeip / default) with `dns_rule`, and a WAN-mode `profile`. Generalizes a real BPi-R3 Mini setup. |
|
||||
| `etc-config-shater.minimal` | Smallest useful config: one subscription, one group, one tproxy inbound, one default rule → group. For quick starts. |
|
||||
| `sublinks.txt` | Mixed share-link list (one per line) covering vless+reality+vision(tcp), vless+xhttp+tls, vless+ws+tls, vmess+ws, trojan+grpc, and shadowsocks (aes-256-gcm + 2022-blake3). Structurally valid, fake values. Parser fixture. |
|
||||
| `README.md` | This file. |
|
||||
|
||||
## How to use
|
||||
|
||||
Render a config to xray JSON (no apply), validate, then apply:
|
||||
|
||||
```sh
|
||||
# On the router, install one of the sample configs:
|
||||
cp examples/etc-config-shater.minimal /etc/config/shater
|
||||
xrayctl test # gen + `xray -test`
|
||||
xrayctl apply # gen -> test -> atomic swap (xray + nft + routing)
|
||||
# or, with auto-rollback if you don't confirm within globals.confirm_timeout:
|
||||
xrayctl apply --confirm 120 && xrayctl confirm
|
||||
|
||||
# Full-feature reference config:
|
||||
cp examples/etc-config-shater.full /etc/config/shater && xrayctl test
|
||||
```
|
||||
|
||||
Parse the share-link fixture (does not touch device state):
|
||||
|
||||
```sh
|
||||
xrayctl gen --links examples/sublinks.txt # parse -> node objects / JSON
|
||||
xrayctl selftest # smoke-tests parsers on fixtures
|
||||
```
|
||||
|
||||
## Consistency notes
|
||||
|
||||
- All option names and section types match `docs/CONFIG.md` exactly.
|
||||
- Cross-object references use the contract's target grammar
|
||||
(`chain:<name>` / `group:<name>` / `node:<name>` / `direct` / `block`) and
|
||||
refer to other sections by their **`name` option** (see gap #1).
|
||||
- `sublinks.txt` values are deliberately fake (fake UUIDs/keys/hosts) but
|
||||
syntactically valid so parsing succeeds; the reality `pbk`, the vmess base64
|
||||
payload, and both shadowsocks userinfo encodings are all well-formed.
|
||||
|
||||
## Contract gaps / interpretations
|
||||
|
||||
Points where `docs/CONFIG.md` is silent or ambiguous and I had to make a call.
|
||||
Listed so the lead can tighten the spec.
|
||||
|
||||
1. **Section addressing vs UCI anonymous sections.** All sections except
|
||||
`globals`/`default` are written anonymous (no explicit section id), yet the
|
||||
contract references them by name (`group:sub0`, `egress=via-awg`, and
|
||||
`profile.set` = `rule.pc-triple.egress=...`). I assumed **the `name` option
|
||||
is the identity** and must be unique per section type. The spec should state
|
||||
this (and how `profile.set` addresses a rule: by `name`, not UCI index).
|
||||
|
||||
2. **`globals.resolver_fallback`.** Mentioned only in prose (CONTRACT line ~146:
|
||||
"`config resolver 'default'` + `option resolver_fallback` в globals"), but
|
||||
NOT in the `globals` option block. I added it with a resolver **name** as its
|
||||
value (`system-local`). Confirm placement + that the value is a resolver name.
|
||||
|
||||
3. **`config resolver 'default'`.** The default resolver is a section literally
|
||||
named `default`. I gave it no `name` option (the section id is the name).
|
||||
Ambiguous whether `default` should also carry `option name 'default'`.
|
||||
|
||||
4. **`dst_domain` sub-forms.** Contract lists "домен|suffix|keyword|geosite:x"
|
||||
but only defines the `geosite:` prefix. There is no stated syntax for
|
||||
*suffix* vs *keyword* vs *exact* matches. I used plain domains and
|
||||
`geosite:` only, and avoided inventing prefixes. Spec should define these
|
||||
(e.g. xray-style `domain:` / `full:` / `keyword:` / `regexp:`).
|
||||
|
||||
5. **`rule.src` MAC and `iface:`/`zone:` forms.** MAC format unspecified — I used
|
||||
lowercase colon form `aa:bb:cc:dd:ee:ff`. `iface:<name>` — I mapped it to an
|
||||
inbound's `network`/interface name (`iface:guest`); unclear if it means a UCI
|
||||
network interface, a device, or an inbound name. Clarify the namespace.
|
||||
|
||||
6. **`dst_port` range syntax.** "port|range|список" — comma-list is obvious
|
||||
(`80,443`), but the *range* delimiter is undefined (dash `1000-2000` vs colon
|
||||
`1000:2000`). I used comma-lists only to stay unambiguous.
|
||||
|
||||
7. **`resolver` type=fakeip has no pool field.** The `resolver` schema only has
|
||||
`address` + `detour`. For `type=fakeip` I put the pool CIDR in `address`
|
||||
(`198.18.0.0/15`, per feature-catalog §8). A dedicated `pool` option (and
|
||||
fakeip-domain/real-ip lists) may be warranted.
|
||||
|
||||
8. **`resolver` type=local address.** No format given. I used dnsmasq notation
|
||||
`127.0.0.1#53`. Could equally be empty (implied local) — clarify.
|
||||
|
||||
9. **`group.strategy=single` selection.** For a subscription-sourced group,
|
||||
`single` needs to say *which* node is fixed, but no `pin`/`node` option is
|
||||
defined for that case. Not used in the examples; flag for the spec.
|
||||
|
||||
10. **Chain layers from one subscription.** The contract's `chain` example uses
|
||||
`group:sub0/sub1/sub2`, but each `group` binds to exactly one
|
||||
`subscription`. Producing three distinct layers from a single sub (the real
|
||||
"inverted chain") presumably needs three groups over the same sub with
|
||||
different `include`/`exclude` filters — the mechanism isn't stated. My
|
||||
`chain 'triple'` hops across a sub group, a manual group, and a fixed node
|
||||
to sidestep this; document the intended multi-layer-from-one-sub pattern.
|
||||
|
||||
11. **`inbound.network` multiplicity.** "UCI-интерфейс(ы)" implies multiple, but
|
||||
it's an `option` (single value), not a `list`. I assumed space-separated
|
||||
values in one option; a `list network` might be cleaner. Clarify.
|
||||
@@ -1,339 +0,0 @@
|
||||
#
|
||||
# =============================================================================
|
||||
# /etc/config/shater -- FULL annotated example (shater / xrayctl control-plane)
|
||||
# =============================================================================
|
||||
#
|
||||
# This file is the DESIRED STATE. It is edited by LuCI or by hand. `xrayctl`
|
||||
# renders it into xray run.json + nftables + policy-routing and applies it
|
||||
# atomically: uci edit -> `xrayctl test` -> `xrayctl apply [--confirm N]`.
|
||||
#
|
||||
# This sample exercises EVERY section type in docs/CONFIG.md and is deliberately
|
||||
# over-populated so it can double as a manual test fixture. It generalizes a
|
||||
# real BPi-R3 Mini setup: a qomar subscription
|
||||
# feeding a 3-hop xray chain, an AmneziaWG tunnel egress (awgOut), split-DNS
|
||||
# with no leaks, home-network access, and a SIM WAN-mode profile.
|
||||
#
|
||||
# Object model (see docs/ARCHITECTURE.md):
|
||||
# Node -> Group -> Chain -> Egress ; Inbound (entry) ; Rule (match->target
|
||||
# +egress) ; Ruleset (reusable lists) ; Resolver/dns_rule (DNS) ; Profile.
|
||||
#
|
||||
# Referencing convention used throughout: chain:<name> | group:<name> |
|
||||
# node:<name> | direct | block (targets), and egress by its own name.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# globals (exactly one section) -- box-wide defaults & our reserved resources
|
||||
# -----------------------------------------------------------------------------
|
||||
config globals 'globals'
|
||||
option enabled '1' # master on/off for the whole plugin
|
||||
option loglevel 'warning' # xray loglevel: debug|info|warning|error|none
|
||||
option kill_switch 'closed' # global default: closed = fail-closed (block)
|
||||
# open = fail-open (direct)
|
||||
option dns_mode 'nftset' # nftset|fakeip -- how DNS split is implemented
|
||||
option ipv6 '1' # enable IPv6 handling / dual-stack
|
||||
option fwmark_base '0x2000' # base of OUR reserved fwmark range (we never
|
||||
# touch fw4 marks); egress marks derive from it
|
||||
option table_base '0x2000' # base id for OUR routing tables (per-egress)
|
||||
option confirm_timeout '120' # sec; commit-confirm window for `apply --confirm`.
|
||||
# 0 = disabled. Auto-rollback if no `confirm`.
|
||||
option resolver_fallback 'system-local' # name of resolver used when a dns_rule
|
||||
# resolver fails (see NOTE in README about this).
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# inbound (0..N) -- transparent TPROXY entry points; MULTI-LAN here
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main trusted LAN (br-lan). TCP+UDP intercepted, sniffing on to recover SNI.
|
||||
config inbound
|
||||
option name 'lan'
|
||||
option enabled '1'
|
||||
option network 'lan' # UCI interface(s) to intercept; space-sep for many
|
||||
option tproxy_port '12345' # nft tproxy -> this port (mirrors real router)
|
||||
option tcp '1'
|
||||
option udp '1'
|
||||
option sniff '1' # recover SNI/Host/QUIC for domain rules (routeOnly)
|
||||
|
||||
# Second LAN: an isolated guest SSID / VLAN, its own tproxy port so rules can
|
||||
# match by inbound. Demonstrates the multi-LAN capability.
|
||||
config inbound
|
||||
option name 'guest'
|
||||
option enabled '1'
|
||||
option network 'guest'
|
||||
option tproxy_port '12346'
|
||||
option tcp '1'
|
||||
option udp '1'
|
||||
option sniff '1'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# subscription (0..N) -- remote node lists (nodes live in cache, not UCI)
|
||||
# -----------------------------------------------------------------------------
|
||||
# (1) qomar: HAPP emulation with AUTO hwid (generated once and remembered per-sub).
|
||||
config subscription
|
||||
option name 'qomar'
|
||||
option enabled '1'
|
||||
option url 'https://pro.qomar.pw/sub/EXAMPLEtoken0000'
|
||||
option update_interval '6h' # 30m|6h|24h|<n>{m,h,d}
|
||||
option fetch_via 'direct' # direct|proxy (proxy = fetch through the tunnel
|
||||
# when the sub host itself is blocked)
|
||||
# --- HAPP client emulation headers ---
|
||||
option ua 'Happ/3.13.0'
|
||||
option hwid 'auto' # auto = generate & persist a stable HWID per sub
|
||||
option device_os 'Android'
|
||||
option ver_os '14'
|
||||
option device_model 'SM-G998B'
|
||||
|
||||
# (2) renawave: FIXED hwid + extra custom headers (mirrors the real SIM sub).
|
||||
config subscription
|
||||
option name 'renawave'
|
||||
option enabled '1'
|
||||
option url 'https://sub.renawave.space/EXAMPLEtoken1111'
|
||||
option update_interval '24h'
|
||||
option fetch_via 'proxy' # host sometimes blocked -> fetch via tunnel
|
||||
option ua 'Happ/3.13.0'
|
||||
option hwid '4378f94b-0000-4000-8000-EXAMPLEhwid00' # fixed, pinned HWID
|
||||
option device_os 'Android'
|
||||
option ver_os '13'
|
||||
option device_model 'Pixel 7'
|
||||
list header 'x-panel-token: EXAMPLEpaneltoken' # arbitrary extra headers
|
||||
list header 'x-region: eu'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# node (0..N) -- MANUAL static nodes (never auto-removed, never go stale)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Added as a raw share-link; xrayctl parses it (see examples/sublinks.txt).
|
||||
config node
|
||||
option name 'reality-nl'
|
||||
option enabled '1'
|
||||
option uri 'vless://11111111-2222-3333-4444-555555555555@nl.example.net:443?type=tcp&security=reality&pbk=EXAMPLEpublickeyBASE64URL0000000000000000000&fp=chrome&sni=www.microsoft.com&sid=0123abcd&flow=xtls-rprx-vision#reality-nl'
|
||||
|
||||
config node
|
||||
option name 'ss-backup-de'
|
||||
option enabled '1'
|
||||
option uri 'ss://YWVzLTI1Ni1nY206RVhBTVBMRXBhc3N3b3Jk@de.example.net:8388#ss-backup-de'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# group (0..N) -- a set of nodes + a selection strategy (balancer)
|
||||
# -----------------------------------------------------------------------------
|
||||
# (a) Subscription-sourced group with a strategy + include/exclude regex filters.
|
||||
config group
|
||||
option name 'sub0'
|
||||
option source 'subscription'
|
||||
option subscription 'qomar' # which subscription feeds this group
|
||||
option strategy 'leastping' # leastping|random|roundrobin|failover|single
|
||||
list include '(?i)(nl|de|fi)' # regex over node NAME: keep only these
|
||||
list exclude '(?i)(trial|test)'# regex over node NAME: drop these
|
||||
option probe_url 'http://www.gstatic.com/generate_204'
|
||||
option probe_interval '60s' # observatory health probe cadence
|
||||
|
||||
# (b) Manual-sourced group: an explicit set of manual nodes, fixed selection.
|
||||
config group
|
||||
option name 'manual-fallback'
|
||||
option source 'manual'
|
||||
list node 'reality-nl' # references config node names above
|
||||
list node 'ss-backup-de'
|
||||
option strategy 'failover' # try in listed order, first alive wins
|
||||
option probe_url 'http://www.gstatic.com/generate_204'
|
||||
option probe_interval '120s'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# chain (0..N) -- multi-hop; hop order = layers L1 -> L2 -> L3 (inverted chain)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mirrors the real xray_chain: three hops from the qomar subscription groups.
|
||||
# Here we build the groups sub0/sub1/sub2 implicitly by strategy; L1 is entry.
|
||||
config chain
|
||||
option name 'triple'
|
||||
list hop 'group:sub0' # L1 (first hop the client reaches)
|
||||
list hop 'group:manual-fallback' # L2
|
||||
list hop 'node:reality-nl' # L3 (final hop -> exits here)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# egress (0..N) -- where traffic ultimately leaves the box
|
||||
# -----------------------------------------------------------------------------
|
||||
# interface: bind out to a tunnel/iface (AmneziaWG tunnel, like real awgOut).
|
||||
config egress
|
||||
option name 'via-awg'
|
||||
option type 'interface'
|
||||
option interface 'awgOut' # any iface/tunnel name from /etc/config/network
|
||||
|
||||
# interface: the home AmneziaWG tunnel for reaching the home LAN.
|
||||
config egress
|
||||
option name 'via-home'
|
||||
option type 'interface'
|
||||
option interface 'awgHome'
|
||||
|
||||
# proxy: send out through a proxy chain (target = chain:/group:/node:).
|
||||
config egress
|
||||
option name 'via-chain'
|
||||
option type 'proxy'
|
||||
option target 'chain:triple'
|
||||
|
||||
# direct: leave via the box's normal WAN routing (no tunnel).
|
||||
config egress
|
||||
option name 'direct-wan'
|
||||
option type 'direct'
|
||||
|
||||
# block: drop.
|
||||
config egress
|
||||
option name 'blackhole'
|
||||
option type 'block'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ruleset (0..N) -- reusable named domain / ip-cidr lists
|
||||
# -----------------------------------------------------------------------------
|
||||
# domain list pulled from a URL (auto-refreshed), plain format.
|
||||
config ruleset
|
||||
option name 'ru-bypass'
|
||||
option type 'domain'
|
||||
option source 'url'
|
||||
option url 'https://raw.githubusercontent.com/example/ru-bypass/main/domains.lst'
|
||||
option format 'plain' # plain|clash|geosite
|
||||
option update_interval '24h'
|
||||
|
||||
# ip-cidr list defined INLINE (no fetch). Useful for a few exit/allow CIDRs.
|
||||
config ruleset
|
||||
option name 'home-nets'
|
||||
option type 'ipcidr'
|
||||
option source 'inline'
|
||||
list entry '10.10.10.0/24' # home blockchain/teamspeak/cameras LAN
|
||||
list entry '10.11.0.0/24'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# rule (0..N) -- ordered, FIRST-MATCH wins (by `order`)
|
||||
# -----------------------------------------------------------------------------
|
||||
# NOTE: lower `order` is evaluated first. Keep a numeric gap between rules.
|
||||
|
||||
# (1) Per-client by /32: one specific PC always through the triple chain via awg.
|
||||
config rule
|
||||
option name 'pc-triple'
|
||||
option enabled '1'
|
||||
option order '10'
|
||||
list src '192.168.11.14/32' # cidr|host|mac|iface:<n>|zone:<n>
|
||||
option target 'chain:triple' # chain:|group:|node:|direct|block
|
||||
option egress 'via-awg' # pin the exit to the AmneziaWG tunnel
|
||||
option kill 'closed' # if chain dies -> block (no leak)
|
||||
|
||||
# (2) Per-device by MAC: a phone that keeps its policy across DHCP IP changes.
|
||||
config rule
|
||||
option name 'phone-by-mac'
|
||||
option enabled '1'
|
||||
option order '20'
|
||||
list src 'aa:bb:cc:dd:ee:ff' # MAC form of src
|
||||
option target 'group:sub0'
|
||||
option egress 'direct-wan' # proxy first hop, then plain WAN
|
||||
option kill 'open' # if group dies -> direct (fail-open)
|
||||
|
||||
# (3) By domain-list (ruleset) + geosite: bypass RU + private straight out.
|
||||
config rule
|
||||
option name 'ru-bypass-direct'
|
||||
option enabled '1'
|
||||
option order '30'
|
||||
list dst_ruleset 'ru-bypass' # references ruleset (domain type)
|
||||
list dst_domain 'geosite:category-ru'
|
||||
option target 'direct'
|
||||
option kill 'default' # inherit globals.kill_switch
|
||||
|
||||
# (4) By geosite (ads) -> block. Demonstrates dst_domain geosite + block target.
|
||||
config rule
|
||||
option name 'block-ads'
|
||||
option enabled '1'
|
||||
option order '40'
|
||||
list dst_domain 'geosite:category-ads-all'
|
||||
option target 'block'
|
||||
|
||||
# (5) Home networks reachable via the home tunnel (uses inline ipcidr ruleset).
|
||||
config rule
|
||||
option name 'home-access'
|
||||
option enabled '1'
|
||||
option order '50'
|
||||
list dst_ruleset 'home-nets' # ip-cidr ruleset
|
||||
list dst_ip '10.10.10.0/24' # also inline cidr form; geoip:x allowed
|
||||
option target 'direct'
|
||||
option egress 'via-home' # exit through awgHome
|
||||
|
||||
# (6) Guest LAN + a port/proto match -> chain (shows inbound-scoped + L4 match).
|
||||
config rule
|
||||
option name 'guest-web'
|
||||
option enabled '1'
|
||||
option order '60'
|
||||
list src 'iface:guest' # match by inbound interface/zone
|
||||
option dst_port '80,443' # port|range|comma-list
|
||||
option proto 'tcp,udp'
|
||||
option target 'chain:triple'
|
||||
option egress 'via-awg'
|
||||
|
||||
# (7) Default catch-all -> everything else through the chain. Highest order.
|
||||
config rule
|
||||
option name 'default'
|
||||
option enabled '1'
|
||||
option order '1000'
|
||||
option target 'chain:triple'
|
||||
option egress 'via-awg'
|
||||
option kill 'closed'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# resolver (0..N) + dns_rule (0..N) -- fully configurable DNS
|
||||
# -----------------------------------------------------------------------------
|
||||
# DoH resolver resolved THROUGH the proxy chain (no leak; resolved==routed).
|
||||
config resolver
|
||||
option name 'proxy-doh'
|
||||
option type 'doh' # doh|dot|plain|local|fakeip
|
||||
option address 'https://dns.quad9.net/dns-query'
|
||||
option detour 'chain:triple' # resolve via this outbound
|
||||
|
||||
# Local resolver = the router's own dnsmasq (for direct/RU domains, ISP view).
|
||||
config resolver
|
||||
option name 'system-local'
|
||||
option type 'local'
|
||||
option address '127.0.0.1#53' # local dnsmasq
|
||||
|
||||
# FakeIP resolver: hand out 198.18.x placeholders, resolve real IP proxy-side.
|
||||
config resolver
|
||||
option name 'fake'
|
||||
option type 'fakeip'
|
||||
option address '198.18.0.0/15' # fakeip pool (see README NOTE)
|
||||
|
||||
# The DEFAULT resolver is a resolver section literally named 'default'.
|
||||
config resolver 'default'
|
||||
option type 'doh'
|
||||
option address 'https://dns.quad9.net/dns-query'
|
||||
option detour 'chain:triple'
|
||||
|
||||
# --- DNS routing rules (ordered) ---
|
||||
# Ads domains -> blocked at DNS level.
|
||||
config dns_rule
|
||||
option order '10'
|
||||
list match_domain 'geosite:category-ads-all'
|
||||
option resolver 'block' # <resolver name>|block
|
||||
|
||||
# RU domains -> resolved locally (ISP/local view) to keep them direct.
|
||||
config dns_rule
|
||||
option order '20'
|
||||
list match_domain 'geosite:category-ru'
|
||||
option resolver 'system-local'
|
||||
|
||||
# A specific client subnet -> forced through the proxy DoH resolver.
|
||||
config dns_rule
|
||||
option order '30'
|
||||
list match_src '192.168.11.0/24' # per-client DNS
|
||||
option resolver 'proxy-doh'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# profile (0..N) -- conditional overrides by active WAN / probe / time [T2]
|
||||
# -----------------------------------------------------------------------------
|
||||
# SIM mode: when the SIM interface (eth2) becomes the default WAN, re-pin the
|
||||
# per-client rule's egress to a VLESS wrap (generalizes the real sim-setup.sh).
|
||||
config profile
|
||||
option name 'sim'
|
||||
option when 'wan:eth2' # wan:<iface>|probe:<url>|time:<HH-HH>
|
||||
list set 'rule.pc-triple.egress=via-chain' # k=v override
|
||||
list set 'globals.dns_mode=fakeip'
|
||||
@@ -1,63 +0,0 @@
|
||||
#
|
||||
# =============================================================================
|
||||
# /etc/config/shater -- MINIMAL quick-start example (shater / xrayctl)
|
||||
# =============================================================================
|
||||
#
|
||||
# The smallest useful config: ONE subscription, ONE group, ONE tproxy inbound,
|
||||
# ONE default rule sending everything to the group. Copy to the device and
|
||||
# apply:
|
||||
# cp examples/etc-config-shater.minimal /etc/config/shater
|
||||
# xrayctl test && xrayctl apply
|
||||
#
|
||||
# From here, add rules/chains/egresses incrementally (see etc-config-shater.full).
|
||||
# =============================================================================
|
||||
|
||||
config globals 'globals'
|
||||
option enabled '1'
|
||||
option loglevel 'warning'
|
||||
option kill_switch 'closed' # fail-closed: block if the proxy is down
|
||||
option dns_mode 'nftset'
|
||||
option ipv6 '1'
|
||||
option fwmark_base '0x2000'
|
||||
option table_base '0x2000'
|
||||
option confirm_timeout '0' # no commit-confirm for the simple case
|
||||
|
||||
# Transparent intercept of the main LAN (TCP+UDP), with sniffing for domain rules.
|
||||
config inbound
|
||||
option name 'lan'
|
||||
option enabled '1'
|
||||
option network 'lan'
|
||||
option tproxy_port '12345'
|
||||
option tcp '1'
|
||||
option udp '1'
|
||||
option sniff '1'
|
||||
|
||||
# One subscription (HAPP emulation, auto HWID).
|
||||
config subscription
|
||||
option name 'qomar'
|
||||
option enabled '1'
|
||||
option url 'https://pro.qomar.pw/sub/EXAMPLEtoken0000'
|
||||
option update_interval '6h'
|
||||
option fetch_via 'direct'
|
||||
option ua 'Happ/3.13.0'
|
||||
option hwid 'auto'
|
||||
option device_os 'Android'
|
||||
option ver_os '14'
|
||||
option device_model 'SM-G998B'
|
||||
|
||||
# One group over that subscription, pick the lowest-latency node.
|
||||
config group
|
||||
option name 'sub0'
|
||||
option source 'subscription'
|
||||
option subscription 'qomar'
|
||||
option strategy 'leastping'
|
||||
option probe_url 'http://www.gstatic.com/generate_204'
|
||||
option probe_interval '60s'
|
||||
|
||||
# One default rule: everything -> the group. Fail-closed if it dies.
|
||||
config rule
|
||||
option name 'default'
|
||||
option enabled '1'
|
||||
option order '1000'
|
||||
option target 'group:sub0'
|
||||
option kill 'closed'
|
||||
@@ -1,31 +0,0 @@
|
||||
# examples/sublinks.txt
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mixed share-link fixture for the xrayctl parser (`xrayctl gen --links FILE`).
|
||||
# One share-link per line. Blank lines and lines starting with '#' are ignored.
|
||||
#
|
||||
# All values below are STRUCTURALLY VALID but FAKE (fake UUIDs, keys, hosts) --
|
||||
# they will parse but will not connect. Use them to exercise the parser for
|
||||
# every protocol/transport/security combo.
|
||||
# The '#...' fragment at the end of each link is the node display name.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# vless + reality + vision, TCP (raw)
|
||||
vless://11111111-2222-3333-4444-555555555555@nl.example.net:443?type=tcp&security=reality&pbk=EXAMPLEpublickeyBASE64URL0000000000000000000&fp=chrome&sni=www.microsoft.com&sid=0123abcd&flow=xtls-rprx-vision#reality-nl
|
||||
|
||||
# vless + xhttp + tls
|
||||
vless://22222222-3333-4444-5555-666666666666@xh.example.net:443?type=xhttp&security=tls&sni=xh.example.net&host=xh.example.net&path=%2Fxhttp&mode=auto&alpn=h2%2Ch3#xhttp-tls
|
||||
|
||||
# vless + ws + tls
|
||||
vless://33333333-4444-5555-6666-777777777777@ws.example.net:443?type=ws&security=tls&sni=ws.example.net&host=ws.example.net&path=%2Fwspath#ws-tls
|
||||
|
||||
# vmess + ws (base64-encoded JSON payload)
|
||||
vmess://eyJ2IjoiMiIsInBzIjoidm1lc3Mtd3MtanAiLCJhZGQiOiJqcC5leGFtcGxlLm5ldCIsInBvcnQiOiI0NDMiLCJpZCI6IjY2NjY2NjY2LTc3NzctODg4OC05OTk5LWFhYWFhYWFhYWFhYSIsImFpZCI6IjAiLCJzY3kiOiJhdXRvIiwibmV0Ijoid3MiLCJ0eXBlIjoibm9uZSIsImhvc3QiOiJqcC5leGFtcGxlLm5ldCIsInBhdGgiOiIvdm1lc3N3cyIsInRscyI6InRscyIsInNuaSI6ImpwLmV4YW1wbGUubmV0In0=
|
||||
|
||||
# trojan + grpc + tls
|
||||
trojan://EXAMPLEtrojanPassword@gr.example.net:443?type=grpc&security=tls&sni=gr.example.net&serviceName=grpcsvc&mode=gun#trojan-grpc
|
||||
|
||||
# shadowsocks, aes-256-gcm (classic SIP002, base64 userinfo = method:password)
|
||||
ss://YWVzLTI1Ni1nY206RVhBTVBMRXBhc3N3b3Jk@de.example.net:8388#ss-aes-256-gcm
|
||||
|
||||
# shadowsocks 2022-blake3-aes-256-gcm (userinfo = method:base64key, not wrapped)
|
||||
ss://2022-blake3-aes-256-gcm:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=@ss2022.example.net:8443#ss-2022-blake3
|
||||
@@ -1,21 +0,0 @@
|
||||
# luci-app-shater — modern client-side LuCI app for the shater/xray control-plane.
|
||||
# Backend logic lives in xrayctl (Go) + shater-core; this package is pure UI + ubus glue.
|
||||
# This is free software, licensed under the GNU General Public License v2.
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=LuCI support for shater (xray transparent-proxy control plane)
|
||||
LUCI_DEPENDS:=+luci-base +xrayctl +shater-core
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
# Explicit version so the data-.ipk packer (ci/pack-luci.sh) and any SDK build
|
||||
# agree; bump PKG_RELEASE to ship a UI upgrade via `opkg upgrade`.
|
||||
PKG_VERSION:=0.1.0
|
||||
PKG_RELEASE:=9
|
||||
|
||||
PKG_LICENSE:=GPL-2.0-or-later
|
||||
PKG_MAINTAINER:=shater <maqrota@icloud.com>
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -1,625 +0,0 @@
|
||||
/* luci-app-shater — "instrument panel" design system.
|
||||
*
|
||||
* Identity: a piece of network instrumentation for a transparent-proxy gateway.
|
||||
* Everything quantitative (latency, throughput, IPs, counts) is set in a
|
||||
* monospace face with tabular figures; labels are a tracked uppercase grotesk.
|
||||
* One signature element carries the personality — the Signal Path (you ->
|
||||
* gateway -> foreign exit) with a live throughput ribbon. Everything else stays
|
||||
* quiet: hairline tiles, one state dot, a single confident accent (signal teal).
|
||||
*
|
||||
* Surfaces/text ride the host LuCI theme variables (so light & dark both look
|
||||
* native); only the brand accents are fixed — teal, coral and amber all read
|
||||
* cleanly on either background. Self-contained, no external assets.
|
||||
*/
|
||||
|
||||
.sh-wrap {
|
||||
max-width: 1120px;
|
||||
|
||||
/* --- brand tokens (fixed; legible on light and dark) --- */
|
||||
--sh-signal: #00b3a4; /* connected / protected */
|
||||
--sh-signal-2: #23d5c4; /* brighter tip for gradients */
|
||||
--sh-signal-soft: rgba(0, 179, 164, .12);
|
||||
--sh-hot: #ff5a5f; /* down / kill / leak */
|
||||
--sh-hot-soft: rgba(255, 90, 95, .12);
|
||||
--sh-amber: #f5a623; /* warnings / mid latency */
|
||||
--sh-amber-soft: rgba(245, 166, 35, .13);
|
||||
--sh-green: #35c46b; /* alive */
|
||||
|
||||
/* surface/line/text: theme vars with fallbacks tuned for both schemes */
|
||||
--sh-surface: var(--background-color-high, #ffffff);
|
||||
--sh-line: rgba(128, 128, 128, .22);
|
||||
--sh-line-2: rgba(128, 128, 128, .34);
|
||||
--sh-muted: rgba(128, 128, 128, .95);
|
||||
|
||||
--sh-mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", "Segoe UI Mono", Menlo, Consolas, monospace;
|
||||
--sh-label: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Deepen the tile surface a touch when the OS/theme is dark, so hairline
|
||||
* instruments still read against the darker chrome. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sh-wrap {
|
||||
--sh-surface: rgba(255, 255, 255, .035);
|
||||
--sh-line: rgba(255, 255, 255, .12);
|
||||
--sh-line-2: rgba(255, 255, 255, .22);
|
||||
--sh-signal: #24d3c3;
|
||||
--sh-signal-soft: rgba(36, 211, 195, .14);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ page header / eyebrow */
|
||||
.sh-header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: .8em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sh-header-row h2 {
|
||||
margin: .1em 0;
|
||||
font-family: var(--sh-label);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.01em;
|
||||
position: relative;
|
||||
padding-left: .62em;
|
||||
}
|
||||
.sh-header-row h2::before { /* signal tick before the title */
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: .18em; bottom: .18em;
|
||||
width: .2em;
|
||||
border-radius: 2px;
|
||||
background: var(--sh-signal);
|
||||
}
|
||||
.sh-pagedesc {
|
||||
opacity: .62;
|
||||
margin: .1em 0 1em;
|
||||
max-width: 72ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Simple | Advanced segmented switch */
|
||||
.sh-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--sh-line-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
font-family: var(--sh-label);
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
}
|
||||
.sh-toggle button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: .5em 1em;
|
||||
cursor: pointer;
|
||||
opacity: .55;
|
||||
transition: background .15s ease, opacity .15s ease, color .15s ease;
|
||||
}
|
||||
.sh-toggle button:hover { opacity: .85; }
|
||||
.sh-toggle button.active {
|
||||
background: var(--sh-signal);
|
||||
color: #04201d;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================================ SIGNAL PATH ===
|
||||
* The signature: LAN client -> gateway -> foreign exit, with a live flow.
|
||||
*/
|
||||
.sh-path {
|
||||
position: relative;
|
||||
border: 1px solid var(--sh-line);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, var(--sh-signal-soft), transparent 45%),
|
||||
var(--sh-surface);
|
||||
padding: 1.15em 1.3em 1.05em;
|
||||
margin: .2em 0 1.1em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sh-path.down {
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, var(--sh-hot-soft), transparent 45%),
|
||||
var(--sh-surface);
|
||||
}
|
||||
.sh-path .caption {
|
||||
font-family: var(--sh-label);
|
||||
font-size: .68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
opacity: .5;
|
||||
margin-bottom: .85em;
|
||||
}
|
||||
|
||||
/* the three stations + the rails between them */
|
||||
.sh-route {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
.sh-stn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .35em;
|
||||
padding: 0 .2em;
|
||||
}
|
||||
.sh-stn.exit { text-align: right; }
|
||||
.sh-node {
|
||||
width: 2.9em;
|
||||
height: 2.9em;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1.5px solid var(--sh-line-2);
|
||||
background: var(--sh-surface);
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
}
|
||||
.sh-node.you { border-radius: 50%; }
|
||||
.sh-node.gw { border-color: var(--sh-signal); box-shadow: 0 0 0 4px var(--sh-signal-soft); transform: rotate(45deg); }
|
||||
.sh-node.gw > span { transform: rotate(-45deg); }
|
||||
.sh-node.exit { border-radius: 50%; }
|
||||
.sh-path.down .sh-node.gw { border-color: var(--sh-hot); box-shadow: 0 0 0 4px var(--sh-hot-soft); }
|
||||
|
||||
.sh-stn .lab {
|
||||
font-family: var(--sh-label);
|
||||
font-size: .64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
opacity: .55;
|
||||
}
|
||||
.sh-stn .meta {
|
||||
font-family: var(--sh-mono);
|
||||
font-size: .82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 15ch;
|
||||
}
|
||||
.sh-stn .meta.big { font-size: .95rem; font-weight: 600; }
|
||||
|
||||
/* the connecting rail carries the animated flow when the tunnel is up */
|
||||
.sh-rail {
|
||||
flex: 1 1 auto;
|
||||
align-self: flex-start;
|
||||
margin-top: 1.45em; /* align with the node centers */
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--sh-line-2);
|
||||
position: relative;
|
||||
min-width: 26px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sh-rail.live {
|
||||
background: linear-gradient(90deg, var(--sh-signal), var(--sh-signal-2));
|
||||
}
|
||||
.sh-rail.live::after { /* travelling packet pulse */
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px; bottom: -2px;
|
||||
width: 34%;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .85), transparent);
|
||||
animation: sh-flow 1.5s linear infinite;
|
||||
}
|
||||
.sh-path.down .sh-rail { background: repeating-linear-gradient(90deg, var(--sh-hot) 0 7px, transparent 7px 14px); opacity: .7; }
|
||||
@keyframes sh-flow { from { left: -34%; } to { left: 100%; } }
|
||||
|
||||
/* live throughput readout under the path (folds the old sparkline in) */
|
||||
.sh-flowrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
margin-top: 1em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sh-rates {
|
||||
font-family: var(--sh-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: .92rem;
|
||||
display: flex;
|
||||
gap: 1.3em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sh-rates .dn { color: var(--sh-signal); }
|
||||
.sh-rates .up { color: var(--sh-amber); }
|
||||
.sh-rates b { font-weight: 600; }
|
||||
.sh-rates .k {
|
||||
font-family: var(--sh-label);
|
||||
font-size: .6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
opacity: .5;
|
||||
margin-right: .35em;
|
||||
}
|
||||
.sh-path canvas.sh-ribbon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
margin-top: .7em;
|
||||
}
|
||||
.sh-state {
|
||||
font-family: var(--sh-label);
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .45em;
|
||||
}
|
||||
.sh-state.ok { color: var(--sh-signal); }
|
||||
.sh-state.bad { color: var(--sh-hot); }
|
||||
|
||||
/* ============================================================= instrument tiles */
|
||||
.sh-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
|
||||
gap: .55em;
|
||||
margin: .2em 0 1.1em;
|
||||
}
|
||||
.sh-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--sh-line);
|
||||
border-radius: 13px;
|
||||
padding: .72em .9em .78em;
|
||||
background: var(--sh-surface);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
.sh-card::before { /* left state tick (neutral by default) */
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: .8em; bottom: .8em;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--sh-line-2);
|
||||
}
|
||||
.sh-card:hover {
|
||||
border-color: var(--sh-line-2);
|
||||
box-shadow: 0 3px 14px rgba(0, 0, 0, .08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.sh-card.is-ok::before { background: var(--sh-signal); }
|
||||
.sh-card.is-bad::before { background: var(--sh-hot); }
|
||||
.sh-card.is-warn::before { background: var(--sh-amber); }
|
||||
@media (max-width: 700px) { .sh-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 430px) { .sh-cards { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
.sh-card .lab {
|
||||
font-family: var(--sh-label);
|
||||
font-size: .62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
opacity: .55;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sh-card .val {
|
||||
font-family: var(--sh-mono);
|
||||
font-size: 1.32rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
margin-top: .22em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sh-card .val.small { font-size: 1rem; }
|
||||
.sh-card .sub {
|
||||
font-family: var(--sh-mono);
|
||||
font-size: .72rem;
|
||||
opacity: .58;
|
||||
margin-top: .22em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ semantic colors */
|
||||
.sh-ok { color: var(--sh-signal); }
|
||||
.sh-bad { color: var(--sh-hot); }
|
||||
.sh-warn { color: var(--sh-amber); }
|
||||
.sh-muted { opacity: .55; }
|
||||
|
||||
.sh-dot {
|
||||
display: inline-block;
|
||||
width: .58em;
|
||||
height: .58em;
|
||||
border-radius: 50%;
|
||||
margin-right: .45em;
|
||||
vertical-align: .04em;
|
||||
box-shadow: 0 0 0 3px transparent;
|
||||
}
|
||||
.sh-dot.ok { background: var(--sh-green); box-shadow: 0 0 0 3px rgba(53, 196, 107, .18); }
|
||||
.sh-dot.bad { background: var(--sh-hot); box-shadow: 0 0 0 3px var(--sh-hot-soft); }
|
||||
.sh-dot.warn { background: var(--sh-amber); box-shadow: 0 0 0 3px var(--sh-amber-soft); }
|
||||
|
||||
/* ----------------------------------------------------------------- chips */
|
||||
.sh-chip, .sh-badge {
|
||||
display: inline-block;
|
||||
padding: .12em .6em;
|
||||
border-radius: 999px;
|
||||
font-family: var(--sh-mono);
|
||||
font-size: .72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border: 1px solid var(--sh-line-2);
|
||||
background: rgba(128, 128, 128, .06);
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.sh-chip.ok, .sh-badge.ok {
|
||||
border-color: rgba(0, 179, 164, .5);
|
||||
color: var(--sh-signal);
|
||||
background: var(--sh-signal-soft);
|
||||
}
|
||||
.sh-chip.warn, .sh-badge.warn {
|
||||
border-color: rgba(245, 166, 35, .5);
|
||||
color: var(--sh-amber);
|
||||
background: var(--sh-amber-soft);
|
||||
}
|
||||
.sh-chip.bad, .sh-badge.bad {
|
||||
border-color: rgba(255, 90, 95, .5);
|
||||
color: var(--sh-hot);
|
||||
background: var(--sh-hot-soft);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- tables */
|
||||
.sh-sec { margin: 1.15em 0; }
|
||||
.sh-sec h3 {
|
||||
font-family: var(--sh-label);
|
||||
font-size: .74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 .5em;
|
||||
opacity: .62;
|
||||
}
|
||||
.sh-tblwrap { overflow-x: auto; max-width: 100%; }
|
||||
.sh-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sh-tbl th {
|
||||
text-align: left;
|
||||
font-family: var(--sh-label);
|
||||
font-size: .64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--sh-muted);
|
||||
padding: .35em .7em;
|
||||
border-bottom: 1px solid var(--sh-line);
|
||||
}
|
||||
.sh-tblwrap.sh-scroll { max-height: 65vh; overflow-y: auto; }
|
||||
.sh-tblwrap .sh-tbl thead th,
|
||||
.sh-tblwrap .table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--background-color-high, Canvas);
|
||||
}
|
||||
.sh-tbl td {
|
||||
padding: .42em .7em;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, .12);
|
||||
font-family: var(--sh-mono);
|
||||
font-size: .84rem;
|
||||
}
|
||||
.sh-tbl tbody tr { transition: background .12s ease; }
|
||||
.sh-tbl tbody tr:hover { background: var(--sh-signal-soft); }
|
||||
.sh-tbl td.r, .sh-tbl th.r { text-align: right; }
|
||||
|
||||
/* subtle polish for LuCI-native tables inside shater pages */
|
||||
.sh-wrap .table .tr:nth-child(even):not(.table-titles) { background: rgba(128, 128, 128, .04); }
|
||||
.sh-wrap .table .tr:hover:not(.table-titles) { background: var(--sh-signal-soft); }
|
||||
.sh-wrap .cbi-page-actions,
|
||||
.sh-actions { display: flex; flex-wrap: wrap; gap: .45em; align-items: center; }
|
||||
|
||||
/* friendly empty-state row */
|
||||
.sh-empty {
|
||||
opacity: .6;
|
||||
padding: 1.1em .6em 1.2em;
|
||||
text-align: center;
|
||||
font-family: var(--sh-label);
|
||||
font-size: .84rem;
|
||||
}
|
||||
.sh-empty::before {
|
||||
content: '\2205';
|
||||
display: block;
|
||||
font-size: 1.5em;
|
||||
opacity: .4;
|
||||
margin-bottom: .2em;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- horizontal share/usage bars */
|
||||
.sh-bar {
|
||||
position: relative;
|
||||
height: .7em;
|
||||
border-radius: 999px;
|
||||
background: rgba(128, 128, 128, .16);
|
||||
overflow: hidden;
|
||||
min-width: 64px;
|
||||
}
|
||||
.sh-bar > i {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--sh-signal), var(--sh-signal-2));
|
||||
}
|
||||
.sh-bar.warn > i { background: var(--sh-amber); }
|
||||
.sh-bar.bad > i { background: var(--sh-hot); }
|
||||
|
||||
.sh-quota { min-width: 120px; }
|
||||
.sh-quota .sh-bar { height: .5em; margin-top: .28em; }
|
||||
.sh-quota .txt { font-family: var(--sh-mono); font-size: .74rem; opacity: .78; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ------------------------------------------------------------ quick-start wizard */
|
||||
.sh-hero {
|
||||
position: relative;
|
||||
border: 1px solid var(--sh-line);
|
||||
border-radius: 16px;
|
||||
padding: 1.15em 1.35em 1.25em;
|
||||
margin: .3em 0 1.2em;
|
||||
background:
|
||||
radial-gradient(130% 120% at 100% 0%, var(--sh-signal-soft), transparent 50%),
|
||||
var(--sh-surface);
|
||||
box-shadow: 0 3px 18px rgba(0, 0, 0, .06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sh-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(var(--sh-signal), var(--sh-signal-2));
|
||||
}
|
||||
.sh-hero h3 {
|
||||
margin: 0 0 .3em;
|
||||
font-family: var(--sh-label);
|
||||
font-weight: 700;
|
||||
font-size: 1.16rem;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.sh-hero .sh-hint { opacity: .7; margin: 0 0 .9em; line-height: 1.5; max-width: 62ch; }
|
||||
.sh-hero .row {
|
||||
display: flex;
|
||||
gap: .55em;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin: .4em 0;
|
||||
}
|
||||
.sh-hero .row > span:first-child {
|
||||
font-family: var(--sh-label);
|
||||
font-size: .74rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
opacity: .8;
|
||||
}
|
||||
.sh-hero label.radio { display: block; margin: .18em 0; cursor: pointer; }
|
||||
.sh-hero label.radio input { margin-right: .5em; }
|
||||
.sh-hero input[type=text] {
|
||||
min-width: min(30em, 90%);
|
||||
font-family: var(--sh-mono);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.sh-hero .row { align-items: stretch; }
|
||||
.sh-hero .row > span { min-width: 0 !important; }
|
||||
.sh-hero input[type=text] { min-width: 0; width: 100%; max-width: none !important; flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
.sh-steps { margin: .9em 0 0; padding: 0; list-style: none; font-family: var(--sh-mono); }
|
||||
.sh-steps li { padding: .28em 0; font-variant-numeric: tabular-nums; font-size: .86rem; }
|
||||
.sh-steps li::before {
|
||||
content: '\25CB';
|
||||
display: inline-block;
|
||||
width: 1.6em;
|
||||
opacity: .45;
|
||||
text-align: left;
|
||||
}
|
||||
.sh-steps li.run::before {
|
||||
content: '';
|
||||
width: .78em;
|
||||
height: .78em;
|
||||
margin-right: .8em;
|
||||
vertical-align: -.06em;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--sh-signal);
|
||||
border-top-color: transparent;
|
||||
opacity: 1;
|
||||
animation: sh-spin .7s linear infinite;
|
||||
}
|
||||
.sh-steps li.done::before { content: '\2714'; color: var(--sh-signal); opacity: 1; }
|
||||
.sh-steps li.fail::before { content: '\2718'; color: var(--sh-hot); opacity: 1; }
|
||||
.sh-steps li.fail { color: var(--sh-hot); }
|
||||
.sh-steps li.skip { opacity: .45; }
|
||||
@keyframes sh-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.sh-bigcheck {
|
||||
display: inline-block;
|
||||
margin-right: .5em;
|
||||
font-size: 1.2em;
|
||||
font-weight: 700;
|
||||
color: var(--sh-signal);
|
||||
animation: sh-pop .45s cubic-bezier(.3, 1.6, .5, 1);
|
||||
}
|
||||
@keyframes sh-pop {
|
||||
0% { transform: scale(.3); opacity: 0; }
|
||||
70% { transform: scale(1.15); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- toast */
|
||||
.sh-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 2.2em;
|
||||
transform: translateX(-50%);
|
||||
background: #0e1726;
|
||||
color: #fff;
|
||||
padding: .55em 1.15em;
|
||||
border-radius: 999px;
|
||||
font-family: var(--sh-label);
|
||||
font-size: .85rem;
|
||||
z-index: 20000;
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, .35);
|
||||
transition: opacity .35s ease, transform .35s ease;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sh-toast.bad { background: var(--sh-hot); }
|
||||
.sh-toast.out { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
|
||||
.sh-copybtn { font-size: .8rem; padding: .18em .65em; }
|
||||
|
||||
.sh-updated {
|
||||
font-family: var(--sh-mono);
|
||||
font-size: .72rem;
|
||||
opacity: .5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
display: block;
|
||||
margin: -.3em 0 .5em;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- misc */
|
||||
.sh-note { font-size: .82rem; opacity: .68; margin: .35em 0; line-height: 1.5; }
|
||||
.sh-advhint {
|
||||
font-family: var(--sh-label);
|
||||
font-size: .8rem;
|
||||
opacity: .6;
|
||||
font-style: italic;
|
||||
margin: .7em 0;
|
||||
}
|
||||
.sh-subline { margin: -.3em 0 1em; font-size: .85rem; }
|
||||
.sh-subline .sh-chip, .sh-subline .sh-badge { margin-right: .5em; margin-bottom: .3em; }
|
||||
|
||||
/* respect reduced-motion for all decorative animation */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sh-rail.live::after,
|
||||
.sh-steps li.run::before,
|
||||
.sh-bigcheck { animation: none; }
|
||||
.sh-card { transition: none; }
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/*
|
||||
* Shared UI helpers for all shater views:
|
||||
* - global Simple/Advanced mode, persisted in localStorage (no uci option);
|
||||
* - the page header (title + plain-language description + mode switch);
|
||||
* - the shared stylesheet loader (shater/shater.css);
|
||||
* - small common widgets: badges, status dots, latency color-coding.
|
||||
*
|
||||
* Usage in a view:
|
||||
* 'require shater.uimode as uimode';
|
||||
* var adv = uimode.isAdvanced();
|
||||
* node = uimode.header(_('Nodes'), _('What this page does…'));
|
||||
*/
|
||||
|
||||
var KEY = 'shater-ui-mode';
|
||||
|
||||
return baseclass.extend({
|
||||
/* Simple (default) vs Advanced. Advanced shows every expert option. */
|
||||
isAdvanced: function() {
|
||||
try { return window.localStorage.getItem(KEY) === 'advanced'; }
|
||||
catch (e) { return true; /* no storage — never hide anything */ }
|
||||
},
|
||||
|
||||
setAdvanced: function(adv) {
|
||||
try { window.localStorage.setItem(KEY, adv ? 'advanced' : 'simple'); }
|
||||
catch (e) {}
|
||||
},
|
||||
|
||||
/* Inject the shared stylesheet once per page. */
|
||||
loadCSS: function() {
|
||||
if (document.getElementById('shater-css'))
|
||||
return;
|
||||
document.head.appendChild(E('link', {
|
||||
'id': 'shater-css',
|
||||
'rel': 'stylesheet',
|
||||
'href': L.resource('shater/shater.css')
|
||||
}));
|
||||
},
|
||||
|
||||
/* Segmented Simple | Advanced switch. Flipping re-renders the view. */
|
||||
renderToggle: function() {
|
||||
var self = this;
|
||||
var adv = this.isAdvanced();
|
||||
var mk = function(label, isAdv) {
|
||||
return E('button', {
|
||||
'type': 'button',
|
||||
'class': (isAdv === adv) ? 'active' : '',
|
||||
'title': isAdv
|
||||
? _('Show every expert option')
|
||||
: _('Show only what a typical setup needs'),
|
||||
'click': function() {
|
||||
if (isAdv === self.isAdvanced())
|
||||
return;
|
||||
self.setAdvanced(isAdv);
|
||||
window.location.reload();
|
||||
}
|
||||
}, label);
|
||||
};
|
||||
return E('div', { 'class': 'sh-toggle' }, [
|
||||
mk(_('Simple'), false),
|
||||
mk(_('Advanced'), true)
|
||||
]);
|
||||
},
|
||||
|
||||
/* Standard page header: h2 + one-line description + the mode switch. */
|
||||
header: function(title, desc) {
|
||||
this.loadCSS();
|
||||
return E('div', {}, [
|
||||
E('div', { 'class': 'sh-header-row' }, [
|
||||
E('h2', {}, [ title ]),
|
||||
this.renderToggle()
|
||||
]),
|
||||
desc ? E('p', { 'class': 'sh-pagedesc' }, [ desc ]) : ''
|
||||
]);
|
||||
},
|
||||
|
||||
/* Muted hint shown in Simple mode where expert content is hidden. */
|
||||
advHint: function(text) {
|
||||
return E('div', { 'class': 'sh-advhint' },
|
||||
[ text || _('More expert options are available in Advanced mode (switch at the top right).') ]);
|
||||
},
|
||||
|
||||
/* Colored pill chip. kind: ok | warn | bad | '' (neutral).
|
||||
* (.sh-badge is kept in the CSS as a legacy alias of .sh-chip.) */
|
||||
badge: function(text, kind) {
|
||||
return E('span', { 'class': 'sh-chip' + (kind ? ' ' + kind : '') }, [ text ]);
|
||||
},
|
||||
|
||||
/* Status dot + label. kind: ok | warn | bad. */
|
||||
dot: function(kind, text) {
|
||||
return E('span', {}, [ E('span', { 'class': 'sh-dot ' + kind }), text ]);
|
||||
},
|
||||
|
||||
/* Unified latency color-coding, used by Nodes / Overview / targets. */
|
||||
latencyKind: function(ms) {
|
||||
if (ms === undefined || ms === null || ms === '' || !isFinite(ms))
|
||||
return '';
|
||||
if (ms <= 200) return 'ok';
|
||||
if (ms <= 500) return 'warn';
|
||||
return 'bad';
|
||||
},
|
||||
|
||||
latencyBadge: function(ms, text) {
|
||||
var kind = this.latencyKind(ms);
|
||||
return this.badge(text || (ms + ' ms'), kind);
|
||||
},
|
||||
|
||||
/* Horizontal fraction bar (0..1). kind colors the fill. */
|
||||
bar: function(frac, kind) {
|
||||
var pct = Math.max(0, Math.min(100, (frac || 0) * 100));
|
||||
return E('div', { 'class': 'sh-bar' + (kind ? ' ' + kind : '') },
|
||||
[ E('i', { 'style': 'width:' + pct.toFixed(1) + '%' }) ]);
|
||||
},
|
||||
|
||||
/* ---- country / flag helpers -------------------------------------- */
|
||||
|
||||
/* ISO-3166 alpha-2 -> regional-indicator flag emoji (NL -> 🇳🇱).
|
||||
* Pure codepoint math, no table. '' when the input is not two letters. */
|
||||
countryFlag: function(cc) {
|
||||
if (!cc || !/^[A-Za-z]{2}$/.test(cc))
|
||||
return '';
|
||||
var u = String(cc).toUpperCase();
|
||||
return String.fromCodePoint(
|
||||
0x1F1E6 + u.charCodeAt(0) - 65,
|
||||
0x1F1E6 + u.charCodeAt(1) - 65);
|
||||
},
|
||||
|
||||
/* Country code from a node name. Mirrors xrayctl's nodeCountry():
|
||||
* a regional-indicator emoji pair wins; otherwise a standalone
|
||||
* two-letter uppercase token ("NL-01" -> NL). emojiOnly limits the
|
||||
* scan to the flag emoji (used by the nodes country filter). */
|
||||
countryFromName: function(name, emojiOnly) {
|
||||
var rs = Array.from(String(name || ''));
|
||||
for (var i = 0; i + 1 < rs.length; i++) {
|
||||
var a = rs[i].codePointAt(0) - 0x1F1E6,
|
||||
b = rs[i + 1].codePointAt(0) - 0x1F1E6;
|
||||
if (a >= 0 && a < 26 && b >= 0 && b < 26)
|
||||
return String.fromCharCode(65 + a) + String.fromCharCode(65 + b);
|
||||
}
|
||||
if (emojiOnly)
|
||||
return '';
|
||||
var toks = String(name || '').toUpperCase().split(/[^A-Z]+/);
|
||||
for (var j = 0; j < toks.length; j++)
|
||||
if (toks[j].length === 2)
|
||||
return toks[j];
|
||||
return '';
|
||||
},
|
||||
|
||||
/* Display name with a flag prefix when the country is derivable and
|
||||
* the name does not already carry a flag emoji. Fallback: name as-is. */
|
||||
flagged: function(name) {
|
||||
var s = (name === undefined || name === null) ? '' : String(name);
|
||||
if (!s || this.countryFromName(s, true))
|
||||
return s;
|
||||
var f = this.countryFlag(this.countryFromName(s));
|
||||
return f ? (f + ' ' + s) : s;
|
||||
},
|
||||
|
||||
/* ---- number / time formatting ------------------------------------ */
|
||||
|
||||
/* Integer with thousands separators (narrow no-break space, locale-neutral). */
|
||||
fmtNum: function(n) {
|
||||
n = Number(n);
|
||||
if (!isFinite(n)) n = 0;
|
||||
return String(Math.round(n)).replace(/\B(?=(\d{3})+$)/g, ' ');
|
||||
},
|
||||
|
||||
/* Relative "Xs ago" label for a Date.now()-style timestamp. */
|
||||
timeAgo: function(ts) {
|
||||
var s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
||||
if (s < 2) return _('just now');
|
||||
if (s < 90) return _('%ds ago').format(s);
|
||||
return _('%dm ago').format(Math.round(s / 60));
|
||||
},
|
||||
|
||||
/* ---- toast + clipboard ------------------------------------------- */
|
||||
|
||||
/* Tiny transient toast at the bottom of the viewport. */
|
||||
toast: function(text, kind) {
|
||||
var t = E('div', { 'class': 'sh-toast' + (kind ? ' ' + kind : '') }, [ text ]);
|
||||
document.body.appendChild(t);
|
||||
window.setTimeout(function() { t.className += ' out'; }, 1500);
|
||||
window.setTimeout(function() {
|
||||
if (t.parentNode) t.parentNode.removeChild(t);
|
||||
}, 1950);
|
||||
},
|
||||
|
||||
/* Copy text to the clipboard: navigator.clipboard when available
|
||||
* (secure contexts), else the execCommand('copy') fallback.
|
||||
* Always resolves; result is true on success. */
|
||||
copyText: function(text) {
|
||||
var fallback = function() {
|
||||
var ta = E('textarea', {
|
||||
'style': 'position:fixed;left:-9999px;top:0',
|
||||
'readonly': 'readonly'
|
||||
});
|
||||
ta.value = String(text);
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
var ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText)
|
||||
return navigator.clipboard.writeText(String(text))
|
||||
.then(function() { return true; }, function() { return fallback(); });
|
||||
return Promise.resolve(fallback());
|
||||
},
|
||||
|
||||
/* Small "copy" button; shows a "Copied!" toast on success. */
|
||||
copyBtn: function(getText, label) {
|
||||
var self = this;
|
||||
return E('button', {
|
||||
'type': 'button',
|
||||
'class': 'btn cbi-button sh-copybtn',
|
||||
'title': _('Copy to clipboard'),
|
||||
'click': function() {
|
||||
var text = (typeof getText == 'function') ? getText() : getText;
|
||||
self.copyText(text).then(function(ok) {
|
||||
self.toast(ok ? _('Copied!') : _('Copy failed'), ok ? '' : 'bad');
|
||||
});
|
||||
}
|
||||
}, [ label || _('Copy') ]);
|
||||
}
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require uci';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// DNS: named resolvers (config resolver) + DNS routing rules (config dns_rule)
|
||||
// + the DNS-related globals (mode, default & fallback resolver).
|
||||
//
|
||||
// Note: the per-resolver "detour" option was removed from this UI — xray cannot
|
||||
// route an individual DNS server through a specific outbound, so the backend
|
||||
// cannot honor it. Default/Fallback below are real: the default resolver is
|
||||
// emitted first and the fallback last in xray's DNS server order.
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return uci.load('shater');
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var m, s, o;
|
||||
|
||||
var resolverNames = uci.sections('shater', 'resolver')
|
||||
.map(function(sec) { return sec.name; })
|
||||
.filter(function(n) { return n != null && n !== ''; });
|
||||
|
||||
m = new form.Map('shater', null);
|
||||
|
||||
// ---------------- DNS globals ----------------
|
||||
s = m.section(form.NamedSection, 'globals', 'globals', _('DNS defaults'));
|
||||
s.addremove = false;
|
||||
|
||||
o = s.option(form.ListValue, 'dns_mode', _('DNS mode'),
|
||||
_('"nftset split" resolves names and routes the answers consistently (recommended); ' +
|
||||
'"FakeIP" answers instantly with placeholder IPs and maps them on the way out.'));
|
||||
o.value('nftset', _('nftset split'));
|
||||
o.value('fakeip', _('FakeIP'));
|
||||
o.default = 'nftset';
|
||||
|
||||
o = s.option(form.ListValue, 'resolver_default', _('Default resolver'),
|
||||
_('Used when no DNS rule matches — tried first.'));
|
||||
o.optional = true;
|
||||
o.value('', _('-- unset --'));
|
||||
resolverNames.forEach(function(n) { o.value(n, n); });
|
||||
|
||||
o = s.option(form.ListValue, 'resolver_fallback', _('Fallback resolver'),
|
||||
_('Tried last, if the default (and any rule-matched resolver) fails.'));
|
||||
o.optional = true;
|
||||
o.value('', _('-- unset --'));
|
||||
resolverNames.forEach(function(n) { o.value(n, n); });
|
||||
|
||||
// ---------------- resolvers ----------------
|
||||
s = m.section(form.GridSection, 'resolver', _('Resolvers'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add resolver');
|
||||
|
||||
o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'proxy-doh';
|
||||
|
||||
o = s.option(form.ListValue, 'type', _('Type'));
|
||||
o.value('doh', 'DoH');
|
||||
o.value('dot', 'DoT');
|
||||
o.value('plain', _('Plain'));
|
||||
o.value('local', _('Local (dnsmasq)'));
|
||||
o.value('fakeip', 'FakeIP');
|
||||
o.default = 'doh';
|
||||
|
||||
o = s.option(form.Value, 'address', _('Address'));
|
||||
o.depends('type', 'doh');
|
||||
o.depends('type', 'dot');
|
||||
o.depends('type', 'plain');
|
||||
o.placeholder = 'https://dns.quad9.net/dns-query';
|
||||
|
||||
o = s.option(form.Value, 'pool', _('FakeIP pool'),
|
||||
_('CIDR range for FakeIP answers.'));
|
||||
o.depends('type', 'fakeip');
|
||||
o.modalonly = true;
|
||||
o.placeholder = '198.18.0.0/15';
|
||||
|
||||
// ---------------- DNS rules ----------------
|
||||
s = m.section(form.GridSection, 'dns_rule', _('DNS rules'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.sortable = true;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add DNS rule');
|
||||
|
||||
o = s.option(form.Value, 'order', _('Order'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '10';
|
||||
|
||||
o = s.option(form.DynamicList, 'match_domain', _('Match domain'),
|
||||
_('Domain / suffix / keyword / geosite:x'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'geosite:category-ads';
|
||||
|
||||
o = s.option(form.DynamicList, 'match_src', _('Match source'),
|
||||
_('Per-client DNS (CIDR).'));
|
||||
o.datatype = 'cidr';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'resolver', _('Resolver'),
|
||||
_('Resolver name, or "block".'));
|
||||
o.placeholder = 'proxy-doh';
|
||||
o.value('block', _('block'));
|
||||
resolverNames.forEach(function(n) { o.value(n, n); });
|
||||
|
||||
return m.render().then(function(mapEl) {
|
||||
return E('div', {}, [
|
||||
uimode.header(_('DNS'),
|
||||
_('How names are looked up. The defaults work for most people; add resolvers and ' +
|
||||
'rules only if you want specific domains or clients resolved differently ' +
|
||||
'(split-DNS keeps "resolved" and "routed" consistent to avoid leaks).')),
|
||||
mapEl
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// Rulesets / Lists (config ruleset) — reusable named domain / ip-cidr lists.
|
||||
// Source can be inline entries, a local file, or a remote URL (auto-updated).
|
||||
//
|
||||
// Also hosts the optional geoip.dat/geosite.dat manager: the plugin never
|
||||
// hard-depends on the ~25 MB data files; this panel shows presence + free space
|
||||
// and lets the user download them on demand (only when there is room) or remove
|
||||
// them to reclaim flash.
|
||||
|
||||
var callGeoStatus = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'geodata_status',
|
||||
expect: { '': {} }
|
||||
});
|
||||
var callGeoDownload = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'geodata_download',
|
||||
expect: { '': {} }
|
||||
});
|
||||
var callGeoRemove = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'geodata_remove',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
function fmtKB(kb) {
|
||||
if (kb == null || kb < 0)
|
||||
return '?';
|
||||
if (kb >= 1024)
|
||||
return (kb / 1024).toFixed(1) + ' MB';
|
||||
return kb + ' KB';
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (b == null || b <= 0)
|
||||
return '—';
|
||||
return (b / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return L.resolveDefault(callGeoStatus(), {});
|
||||
},
|
||||
|
||||
renderGeo: function(st) {
|
||||
var self = this;
|
||||
st = st || {};
|
||||
var present = !!st.present;
|
||||
var canDl = st.can_download !== false;
|
||||
|
||||
var badge = present
|
||||
? E('span', { 'class': 'label', 'style': 'background:#5cb85c;color:#fff' }, _('Installed'))
|
||||
: E('span', { 'class': 'label', 'style': 'background:#999;color:#fff' }, _('Not installed'));
|
||||
|
||||
var info = present
|
||||
? E('span', {}, [
|
||||
' geoip.dat ', E('strong', {}, fmtBytes(st.geoip_size)),
|
||||
' · geosite.dat ', E('strong', {}, fmtBytes(st.geosite_size))
|
||||
])
|
||||
: E('span', {}, [
|
||||
_('Free space:') + ' ', E('strong', {}, fmtKB(st.free_kb)),
|
||||
' / ', _('needs ~'), E('strong', {}, fmtKB(st.need_kb))
|
||||
]);
|
||||
|
||||
var msg = E('span', { 'style': 'margin-left:1em;color:#c00' });
|
||||
if (st.error)
|
||||
dom.content(msg, String(st.error));
|
||||
|
||||
var busy = function(on, label) {
|
||||
if (on)
|
||||
ui.showModal(_('Geodata'), [
|
||||
E('p', { 'class': 'spinning' }, label)
|
||||
]);
|
||||
else
|
||||
ui.hideModal();
|
||||
};
|
||||
|
||||
var refresh = function(newSt) {
|
||||
var box = document.getElementById('shater-geodata');
|
||||
if (box)
|
||||
dom.content(box, self.renderGeo(newSt).childNodes);
|
||||
};
|
||||
|
||||
var dlBtn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'disabled': present || !canDl ? 'disabled' : null,
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
busy(true, _('Downloading geoip.dat / geosite.dat … this can take a minute.'));
|
||||
return callGeoDownload().then(function(r) {
|
||||
busy(false);
|
||||
if (r && r.error)
|
||||
ui.addNotification(null, E('p', {}, _('Geodata download failed:') + ' ' + r.error), 'danger');
|
||||
else
|
||||
ui.addNotification(null, E('p', {}, _('Geodata installed.')), 'info');
|
||||
refresh(r || {});
|
||||
}).catch(function(e) {
|
||||
busy(false);
|
||||
ui.addNotification(null, E('p', {}, _('Geodata download error:') + ' ' + e), 'danger');
|
||||
});
|
||||
})
|
||||
}, _('Download'));
|
||||
|
||||
var rmBtn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-remove',
|
||||
'disabled': present ? null : 'disabled',
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
busy(true, _('Removing geodata …'));
|
||||
return callGeoRemove().then(function(r) {
|
||||
busy(false);
|
||||
ui.addNotification(null, E('p', {}, _('Geodata removed.')), 'info');
|
||||
refresh(r || {});
|
||||
}).catch(function(e) {
|
||||
busy(false);
|
||||
ui.addNotification(null, E('p', {}, _('Geodata remove error:') + ' ' + e), 'danger');
|
||||
});
|
||||
})
|
||||
}, _('Remove'));
|
||||
|
||||
var hint = !present && !canDl
|
||||
? E('div', { 'class': 'cbi-value-description', 'style': 'color:#c00' },
|
||||
_('Not enough free space to download the data files. Free some flash first.'))
|
||||
: E('div', { 'class': 'cbi-value-description' },
|
||||
_('Without these files, geosite:/geoip: matchers in rules and DNS are ' +
|
||||
'automatically skipped so the config stays valid — the router keeps ' +
|
||||
'working, just without geo-based routing.'));
|
||||
|
||||
return E('div', {}, [
|
||||
E('div', { 'style': 'display:flex;align-items:center;gap:.75em;flex-wrap:wrap' }, [
|
||||
badge, info, dlBtn, rmBtn, msg
|
||||
]),
|
||||
hint
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(st) {
|
||||
var m, s, o;
|
||||
|
||||
var geoPanel = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Geodata (geoip.dat / geosite.dat)')),
|
||||
E('div', { 'id': 'shater-geodata' }, this.renderGeo(st).childNodes)
|
||||
]);
|
||||
|
||||
m = new form.Map('shater', null,
|
||||
_('Reusable, typed lists of domains or IP-CIDRs. Reference them from ' +
|
||||
'rules (Dest ruleset) or DNS rules. Sourced inline, from a file, or a URL.'));
|
||||
|
||||
s = m.section(form.GridSection, 'ruleset', _('Rulesets'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.sortable = false;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add ruleset');
|
||||
|
||||
o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'ru-bypass';
|
||||
|
||||
o = s.option(form.ListValue, 'type', _('Type'));
|
||||
o.value('domain', _('Domain list'));
|
||||
o.value('ipcidr', _('IP-CIDR list'));
|
||||
o.default = 'domain';
|
||||
|
||||
o = s.option(form.ListValue, 'source', _('Source'));
|
||||
o.value('inline', _('Inline entries'));
|
||||
o.value('file', _('Local file'));
|
||||
o.value('url', _('Remote URL'));
|
||||
o.default = 'inline';
|
||||
|
||||
o = s.option(form.Value, 'url', _('URL'),
|
||||
_('Remote list URL (fetched + cached).'));
|
||||
o.depends('source', 'url');
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'https://example.com/list.txt';
|
||||
|
||||
o = s.option(form.Value, 'path', _('File path'),
|
||||
_('Local path to the list file.'));
|
||||
o.depends('source', 'file');
|
||||
o.modalonly = true;
|
||||
o.placeholder = '/etc/xray/lists/ru.txt';
|
||||
|
||||
o = s.option(form.ListValue, 'format', _('Format'));
|
||||
o.value('plain', _('Plain (one entry per line)'));
|
||||
o.value('clash', _('Clash rule-provider'));
|
||||
o.value('geosite', _('geosite category'));
|
||||
o.default = 'plain';
|
||||
o.depends('source', 'url');
|
||||
o.depends('source', 'file');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'update_interval', _('Update interval'),
|
||||
_('For url source, e.g. 24h.'));
|
||||
o.depends('source', 'url');
|
||||
o.modalonly = true;
|
||||
o.placeholder = '24h';
|
||||
|
||||
o = s.option(form.DynamicList, 'entry', _('Inline entries'),
|
||||
_('Domains or IP-CIDRs, one per row.'));
|
||||
o.depends('source', 'inline');
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'example.com';
|
||||
|
||||
return Promise.resolve(m.render()).then(function(formNode) {
|
||||
return E('div', {}, [
|
||||
uimode.header(_('Lists / Rulesets'),
|
||||
_('Named lists of sites or IP ranges you can reference from Rules and DNS rules — ' +
|
||||
'plus the optional geo data files that power geosite:/geoip: matchers and the ' +
|
||||
'"ru-bypass" / "block-ads" preset packs.')),
|
||||
geoPanel,
|
||||
formNode
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// Live connections from conntrack. The outbound column is best-effort (xray
|
||||
// exposes no per-flow binding) — proxied flows show the current selected node,
|
||||
// private/local destinations show "direct".
|
||||
|
||||
var callConns = rpc.declare({ object: 'xray', method: 'conns', expect: { '': {} } });
|
||||
|
||||
function fmtBytes(v) {
|
||||
v = v || 0;
|
||||
if (v >= 1073741824) return (v / 1073741824).toFixed(2) + ' GB';
|
||||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB';
|
||||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB';
|
||||
return v + ' B';
|
||||
}
|
||||
|
||||
function renderTable(d) {
|
||||
if (d && d.available === false)
|
||||
return E('div', { 'class': 'alert-message warning' }, [d.note || _('Live connections unavailable.')]);
|
||||
var conns = (d && d.conns) || [];
|
||||
var rows = conns.map(function (c) {
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, c.src),
|
||||
E('td', { 'class': 'td' }, c.dst + ':' + c.dst_port),
|
||||
E('td', { 'class': 'td' }, c.proto),
|
||||
E('td', { 'class': 'td' }, c.outbound ? uimode.flagged(c.outbound) : '-'),
|
||||
E('td', { 'class': 'td', 'style': 'text-align:right;font-variant-numeric:tabular-nums' }, fmtBytes(c.bytes))
|
||||
]);
|
||||
});
|
||||
if (!rows.length)
|
||||
rows = [E('tr', { 'class': 'tr' }, [E('td', { 'class': 'td', 'colspan': 5 }, [
|
||||
E('div', { 'class': 'sh-empty' }, [ _('No active flows right now — traffic will appear here as devices talk.') ])
|
||||
])])];
|
||||
return E('div', { 'class': 'sh-tblwrap sh-scroll' }, [
|
||||
E('table', { 'class': 'table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, _('Source')),
|
||||
E('th', { 'class': 'th' }, _('Destination')),
|
||||
E('th', { 'class': 'th' }, _('Proto')),
|
||||
E('th', { 'class': 'th' }, _('Outbound')),
|
||||
E('th', { 'class': 'th', 'style': 'text-align:right' }, _('Bytes'))
|
||||
])
|
||||
].concat(rows))
|
||||
]);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null,
|
||||
|
||||
load: function () {
|
||||
return L.resolveDefault(callConns(), { conns: [] });
|
||||
},
|
||||
|
||||
render: function (data) {
|
||||
var box = E('div', {}, [renderTable(data)]);
|
||||
poll.add(function () {
|
||||
return L.resolveDefault(callConns(), { conns: [] }).then(function (r) {
|
||||
dom.content(box, renderTable(r));
|
||||
});
|
||||
}, 3);
|
||||
return E('div', { 'class': 'sh-wrap' }, [
|
||||
uimode.header(_('Live connections'),
|
||||
_('What is flowing through the router right now. The Outbound column is best-effort — ' +
|
||||
'xray exposes no per-flow binding, so proxied flows show the currently selected ' +
|
||||
'node. Requires conntrack-tools on the router.')),
|
||||
box
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -1,503 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require dom';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// Live node list (subscription + manual) over ubus.
|
||||
var callNodes = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'nodes',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
// Active probe of one node (or all when name omitted). method: tcp|http.
|
||||
var callNodeTest = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'node_test',
|
||||
params: [ 'name', 'method', 'url', 'http_method' ],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
// Bulk import of pasted share-links (base64-encoded blob) -> config node / cache.
|
||||
var callNodeImport = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'node_import',
|
||||
params: [ 'b64' ],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
// Bulk-action + QR methods.
|
||||
var callNodeEnable = rpc.declare({ object: 'xray', method: 'node_enable', params: [ 'name', 'enabled' ], expect: { '': {} } });
|
||||
var callNodeDelete = rpc.declare({ object: 'xray', method: 'node_delete', params: [ 'name' ], expect: { '': {} } });
|
||||
var callNodeAssign = rpc.declare({ object: 'xray', method: 'node_assign_group', params: [ 'name', 'group' ], expect: { '': {} } });
|
||||
var callNodeQR = rpc.declare({ object: 'xray', method: 'node_qr', params: [ 'name' ], expect: { '': {} } });
|
||||
|
||||
// country code from the node name: flag emoji first, else a standalone
|
||||
// 2-letter uppercase token — same detection xrayctl's country filter uses
|
||||
// (shared helper in uimode).
|
||||
function flagFromName(name) {
|
||||
return uimode.countryFromName(name);
|
||||
}
|
||||
|
||||
function disp(v) { return (v === undefined || v === null || v === '') ? '-' : v; }
|
||||
|
||||
// Latency in ms. Prefer an on-demand probe reading (n._probe_ms) over the
|
||||
// observatory's latency_ms; unknown -> Infinity (sorts last).
|
||||
function latRaw(n) {
|
||||
if (n._probe_ms !== undefined && n._probe_ms !== null) return n._probe_ms;
|
||||
var v = n.latency_ms;
|
||||
// 0 from the observatory means "not probed / no data", not a 0ms link.
|
||||
if (v === undefined || v === null || v === '' || v === 0) return undefined;
|
||||
return v;
|
||||
}
|
||||
function latMs(n) {
|
||||
var v = latRaw(n);
|
||||
if (v === undefined || v === null || v === '') return Infinity;
|
||||
if (typeof v == 'number') return v;
|
||||
var m = String(v).match(/[\d.]+/);
|
||||
return m ? parseFloat(m[0]) : Infinity;
|
||||
}
|
||||
// Latency cell: shared color-coded badge; ✓ marks a fresh on-demand probe.
|
||||
function latCell(n) {
|
||||
var v = latRaw(n);
|
||||
if (v === undefined || v === null || v === '')
|
||||
return E('span', { 'class': 'sh-muted' }, [ '-' ]);
|
||||
var ms = latMs(n);
|
||||
var t = (typeof v == 'number') ? (v + ' ms') : ('' + v);
|
||||
if (n._probe_ms !== undefined && n._probe_ms !== null) t += ' ✓';
|
||||
return uimode.latencyBadge(ms, t);
|
||||
}
|
||||
|
||||
// UTF-8 safe base64 for the import blob.
|
||||
function b64encode(text) {
|
||||
try { return btoa(unescape(encodeURIComponent(text))); }
|
||||
catch (e) { return btoa(text); }
|
||||
}
|
||||
|
||||
// Parse an SVG string safely: DOMParser, root must be <svg>, and it must not
|
||||
// contain scriptable content. Returns an imported DOM node or null.
|
||||
function safeSvgNode(text) {
|
||||
var doc;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(String(text || ''), 'image/svg+xml');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
var root = doc ? doc.documentElement : null;
|
||||
if (!root || String(root.nodeName).toLowerCase() !== 'svg')
|
||||
return null;
|
||||
if (root.querySelector('script,foreignObject,use,iframe,embed,object') ||
|
||||
doc.querySelector('parsererror'))
|
||||
return null;
|
||||
return document.importNode(root, true);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
render: function() {
|
||||
var self = this;
|
||||
var adv = uimode.isAdvanced();
|
||||
var state = { nodes: [], sortAsc: true, probed: {}, sel: {},
|
||||
filter: { q: '', proto: '', alive: '', country: '' },
|
||||
probe: { method: 'tcp', url: 'http://www.gstatic.com/generate_204', http: 'GET' } };
|
||||
var tableBox = E('div', { 'class': 'sh-tblwrap sh-scroll' });
|
||||
var bulkBar = E('div', {});
|
||||
var filterBox = E('div', {});
|
||||
var mapWrap = E('div', {});
|
||||
var m; // manual-nodes form.Map (re-rendered after server-side changes)
|
||||
|
||||
function isManual(n) { return !n.group; } // subscription nodes carry a group
|
||||
function selectedNames() {
|
||||
return Object.keys(state.sel).filter(function (k) { return state.sel[k]; });
|
||||
}
|
||||
function nodeMatches(n) {
|
||||
var f = state.filter;
|
||||
if (f.q && (String(n.name || '') + ' ' + String(n.group || '')).toLowerCase().indexOf(f.q.toLowerCase()) < 0) return false;
|
||||
if (f.proto && n.proto !== f.proto) return false;
|
||||
if (f.alive === 'alive' && !n.alive) return false;
|
||||
if (f.alive === 'dead' && n.alive) return false;
|
||||
if (f.country && flagFromName(n.name) !== f.country) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Targeted refresh instead of a full page reload: re-fetch the live node
|
||||
// list AND re-render the manual-nodes form from fresh uci data (xrayctl
|
||||
// mutates /etc/config/shater server-side).
|
||||
function refreshAll() {
|
||||
uci.unload('shater');
|
||||
return Promise.all([
|
||||
L.resolveDefault(callNodes(), { nodes: [] }).then(setNodes),
|
||||
m.render().then(function(node) { dom.content(mapWrap, node); })
|
||||
]);
|
||||
}
|
||||
|
||||
function runBulk(names, fn, verb) {
|
||||
if (!names.length) { ui.addNotification(null, E('p', {}, _('Select one or more manual nodes first.')), 'warning'); return; }
|
||||
return Promise.all(names.map(fn)).then(function (results) {
|
||||
var ok = results.filter(function (r) { return !r || r.ok !== false; }).length;
|
||||
ui.addNotification(null, E('p', {}, _('%s: %d/%d done').format(verb, ok, names.length)),
|
||||
ok === names.length ? 'info' : 'warning');
|
||||
state.sel = {};
|
||||
return refreshAll();
|
||||
});
|
||||
}
|
||||
function showShare(n) {
|
||||
var linkBox = E('textarea', { 'class': 'cbi-input-textarea', 'rows': 3, 'readonly': 'readonly',
|
||||
'style': 'width:100%;font-family:monospace' }, n.uri || '');
|
||||
var qrHolder = E('div', { 'style': 'text-align:center;min-height:180px;padding:.5em' }, [_('Loading QR…')]);
|
||||
ui.showModal(_('Share node') + ' — ' + disp(n.name), [
|
||||
qrHolder,
|
||||
E('p', {}, _('Share-link:')), linkBox,
|
||||
E('div', { 'class': 'right' }, [
|
||||
uimode.copyBtn(function () { return n.uri || ''; }, _('Copy link')),
|
||||
E('button', { 'class': 'btn cbi-button-neutral', 'style': 'margin-left:.4em', 'click': ui.hideModal }, _('Close'))
|
||||
])
|
||||
]);
|
||||
callNodeQR(n.name).then(function (r) {
|
||||
var svg = (r && r.svg) ? safeSvgNode(r.svg) : null;
|
||||
if (svg) {
|
||||
var wrap = E('div', { 'style': 'display:inline-block;max-width:220px' }, [ svg ]);
|
||||
dom.content(qrHolder, wrap);
|
||||
} else {
|
||||
dom.content(qrHolder, E('em', {}, _('QR unavailable — install qrencode on the router.')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Arguments for callNodeTest given the current probe-method selection.
|
||||
function probeArgs(name) {
|
||||
if (state.probe.method === 'tcp') return [ name, 'tcp', '', '' ];
|
||||
return [ name, 'http', state.probe.url, state.probe.http ];
|
||||
}
|
||||
|
||||
function setNodes(res) {
|
||||
state.nodes = (res && res.nodes) || [];
|
||||
draw();
|
||||
}
|
||||
|
||||
// Merge on-demand probe results ({name -> {alive, latency_ms}}) and redraw.
|
||||
function mergeProbes(res) {
|
||||
var arr = (res && res.probe) || [];
|
||||
arr.forEach(function(p) { if (p && p.name) state.probed[p.name] = p; });
|
||||
draw();
|
||||
return arr.length;
|
||||
}
|
||||
|
||||
// Bulk-action bar (rebuilt on selection/redraw so the count stays live).
|
||||
function drawBulkBar(list) {
|
||||
var names = selectedNames();
|
||||
var groupNames = (uci.sections('shater', 'group') || []).map(function (s) { return s.name || s['.name']; });
|
||||
var grpSel = E('select', { 'class': 'cbi-input-select' },
|
||||
[E('option', { 'value': '' }, _('— group —'))].concat(groupNames.map(function (g) { return E('option', { 'value': g }, g); })));
|
||||
var mk = function (label, cls, fn) {
|
||||
return E('button', { 'class': 'btn cbi-button ' + cls, 'style': 'margin-right:.3em',
|
||||
'click': ui.createHandlerFn(self, fn) }, label);
|
||||
};
|
||||
dom.content(bulkBar, E('div', { 'style': 'display:flex;gap:.3em;align-items:center;flex-wrap:wrap;margin:.4em 0' }, [
|
||||
E('span', { 'style': 'opacity:.7' }, _('%d selected').format(names.length)),
|
||||
mk(_('Enable'), 'cbi-button-positive', function () { return runBulk(selectedNames(), function (n) { return callNodeEnable(n, '1'); }, _('Enable')); }),
|
||||
mk(_('Disable'), 'cbi-button-neutral', function () { return runBulk(selectedNames(), function (n) { return callNodeEnable(n, '0'); }, _('Disable')); }),
|
||||
mk(_('Delete'), 'cbi-button-remove', function () { if (!confirm(_('Delete the selected manual nodes?'))) return; return runBulk(selectedNames(), function (n) { return callNodeDelete(n); }, _('Delete')); }),
|
||||
mk(_('Test'), 'cbi-button-action', function () { var ns = selectedNames(); if (!ns.length) { ui.addNotification(null, E('p', {}, _('Select nodes first.')), 'warning'); return; } return Promise.all(ns.map(function (n) { return callNodeTest.apply(null, probeArgs(n)); })).then(function (rs) { rs.forEach(mergeProbes); }); }),
|
||||
grpSel,
|
||||
mk(_('Assign to group'), 'cbi-button-action', function () { var g = grpSel.value; if (!g) { ui.addNotification(null, E('p', {}, _('Pick a group.')), 'warning'); return; } return runBulk(selectedNames(), function (n) { return callNodeAssign(n, g); }, _('Assign')); })
|
||||
]));
|
||||
}
|
||||
|
||||
// Client-side filter bar (name / proto / alive / country).
|
||||
function buildFilterBar() {
|
||||
var protos = {}, countries = {};
|
||||
state.nodes.forEach(function (n) { if (n.proto) protos[n.proto] = 1; var c = flagFromName(n.name); if (c) countries[c] = 1; });
|
||||
var qi = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': _('filter name…'), 'value': state.filter.q,
|
||||
'input': function (ev) { state.filter.q = ev.target.value; draw(); } });
|
||||
var mkSel = function (opts, cur, onch, allLabel, labelFn) {
|
||||
return E('select', { 'class': 'cbi-input-select', 'change': onch },
|
||||
[E('option', { 'value': '' }, allLabel)].concat(opts.map(function (o) { return E('option', { 'value': o, 'selected': o === cur ? 'selected' : null }, labelFn ? labelFn(o) : o); })));
|
||||
};
|
||||
var protoSel = mkSel(Object.keys(protos).sort(), state.filter.proto, function (ev) { state.filter.proto = ev.target.value; draw(); }, _('any proto'));
|
||||
var aliveSel = E('select', { 'class': 'cbi-input-select', 'change': function (ev) { state.filter.alive = ev.target.value; draw(); } }, [
|
||||
E('option', { 'value': '' }, _('any')), E('option', { 'value': 'alive' }, _('alive')), E('option', { 'value': 'dead' }, _('dead'))
|
||||
]);
|
||||
var cc = Object.keys(countries).sort();
|
||||
var kids = [E('span', { 'style': 'opacity:.7' }, _('Filter:')), qi, protoSel, aliveSel];
|
||||
if (cc.length) kids.push(mkSel(cc, state.filter.country, function (ev) { state.filter.country = ev.target.value; draw(); }, _('any country'),
|
||||
function (c) { var f = uimode.countryFlag(c); return f ? (f + ' ' + c) : c; }));
|
||||
return E('div', { 'style': 'display:flex;gap:.3em;align-items:center;flex-wrap:wrap;margin:.3em 0' }, kids);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
var list = state.nodes.slice().map(function(n) {
|
||||
var p = state.probed[n.name];
|
||||
if (!p) return n;
|
||||
n = Object.assign({}, n);
|
||||
if (p.alive !== undefined) n.alive = p.alive;
|
||||
// Only a live probe with a real RTT counts as a latency reading.
|
||||
n._probe_ms = (p.alive && p.latency_ms) ? p.latency_ms : undefined;
|
||||
return n;
|
||||
}).filter(nodeMatches);
|
||||
list.sort(function(a, b) {
|
||||
var d = latMs(a) - latMs(b);
|
||||
return state.sortAsc ? d : -d;
|
||||
});
|
||||
drawBulkBar(list);
|
||||
|
||||
var selAll = E('input', { 'type': 'checkbox', 'title': _('Select all manual'),
|
||||
'change': function (ev) {
|
||||
list.forEach(function (n) { if (isManual(n)) state.sel[n.name] = ev.target.checked; });
|
||||
draw();
|
||||
} });
|
||||
|
||||
var latHeader = E('th', {
|
||||
'class': 'th',
|
||||
'style': 'cursor:pointer',
|
||||
'click': function() { state.sortAsc = !state.sortAsc; draw(); }
|
||||
}, [ _('Latency') + (state.sortAsc ? ' ▲' : ' ▼') ]);
|
||||
|
||||
var head = E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th', 'style': 'width:1.5em' }, [ selAll ]),
|
||||
E('th', { 'class': 'th' }, [ _('Name') ]),
|
||||
E('th', { 'class': 'th' }, [ _('Group') ]),
|
||||
E('th', { 'class': 'th' }, [ _('Proto') ]),
|
||||
E('th', { 'class': 'th' }, [ _('Alive') ]),
|
||||
latHeader,
|
||||
E('th', { 'class': 'th' }, [ _('Fingerprint') ]),
|
||||
E('th', { 'class': 'th' }, [ _('Stale') ]),
|
||||
E('th', { 'class': 'th cbi-section-actions' }, [ _('Actions') ])
|
||||
]);
|
||||
|
||||
var rows = list.length ? list.map(function(n) {
|
||||
var name = n.name ? uimode.flagged(n.name) : '-';
|
||||
var fp = n.fingerprint ? String(n.fingerprint).substr(0, 12) : '-';
|
||||
|
||||
var testBtn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'style': 'margin-right:.3em',
|
||||
'click': ui.createHandlerFn(self, function() {
|
||||
return callNodeTest.apply(null, probeArgs(n.name)).then(function(res) {
|
||||
mergeProbes(res);
|
||||
var p = (res && res.probe && res.probe[0]) || {};
|
||||
var extra = (p.latency_ms ? (' ' + p.latency_ms + ' ms') : '') +
|
||||
(p.exit_ip ? (' → ' + p.exit_ip) : '');
|
||||
var msg = p.error ? (name + ': ' + p.error)
|
||||
: (name + ': ' + (p.alive ? _('alive') : _('dead')) + extra);
|
||||
ui.addNotification(null, E('p', {}, msg), p.alive ? 'info' : 'warning');
|
||||
});
|
||||
})
|
||||
}, [ _('Test') ]);
|
||||
|
||||
var qrBtn = E('button', {
|
||||
'class': 'btn cbi-button',
|
||||
'click': ui.createHandlerFn(self, function () { showShare(n); })
|
||||
}, [ _('QR / Link') ]);
|
||||
|
||||
var cb = E('input', { 'type': 'checkbox',
|
||||
'checked': state.sel[n.name] ? 'checked' : null,
|
||||
'disabled': isManual(n) ? null : 'disabled',
|
||||
'title': isManual(n) ? '' : _('subscription node — manage via its subscription'),
|
||||
'change': function (ev) { state.sel[n.name] = ev.target.checked; drawBulkBar(list); } });
|
||||
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, [ cb ]),
|
||||
E('td', { 'class': 'td' }, [ name ]),
|
||||
E('td', { 'class': 'td' }, [ disp(n.group) ]),
|
||||
E('td', { 'class': 'td' }, [ disp(n.proto) ]),
|
||||
E('td', { 'class': 'td' }, [
|
||||
n.alive ? E('span', { 'class': 'sh-ok' }, [ _('yes') ])
|
||||
: E('span', { 'class': 'sh-bad' }, [ _('no') ])
|
||||
]),
|
||||
E('td', { 'class': 'td' }, [ latCell(n) ]),
|
||||
E('td', { 'class': 'td', 'title': disp(n.fingerprint) }, [ fp ]),
|
||||
E('td', { 'class': 'td' }, [ n.stale ? _('stale') : '-' ]),
|
||||
E('td', { 'class': 'td cbi-section-actions' }, [ testBtn, qrBtn ])
|
||||
]);
|
||||
}) : [ E('tr', { 'class': 'tr placeholder' }, [
|
||||
E('td', { 'class': 'td', 'colspan': '9' }, [
|
||||
E('div', { 'class': 'sh-empty' }, [ _('No nodes match. Import links or add a manual node below.') ])
|
||||
])
|
||||
]) ];
|
||||
|
||||
// Build the filter bar lazily once nodes are present (preserves input focus).
|
||||
if (!filterBox.firstChild && state.nodes.length)
|
||||
dom.content(filterBox, buildFilterBar());
|
||||
|
||||
dom.content(tableBox, E('table', { 'class': 'table' }, [ head ].concat(rows)));
|
||||
}
|
||||
|
||||
var testAllBtn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action important',
|
||||
'click': ui.createHandlerFn(self, function() {
|
||||
return callNodeTest.apply(null, probeArgs('')).then(function(res) {
|
||||
var n = mergeProbes(res);
|
||||
var alive = ((res && res.probe) || []).filter(function(p) { return p.alive; }).length;
|
||||
ui.addNotification(null, E('p', {}, _('Probed %d nodes — %d reachable').format(n, alive)), 'info');
|
||||
});
|
||||
})
|
||||
}, [ _('Test all nodes') ]);
|
||||
|
||||
// --- probe-method controls (TCP fast connect | HTTP GET/HEAD through proxy) ---
|
||||
var urlInput = E('input', {
|
||||
'type': 'text', 'class': 'cbi-input-text',
|
||||
'style': 'width:22em;margin-left:.4em',
|
||||
'value': state.probe.url,
|
||||
'placeholder': 'http://www.gstatic.com/generate_204',
|
||||
'change': function(ev) { state.probe.url = ev.target.value; }
|
||||
});
|
||||
urlInput.style.display = 'none';
|
||||
var methodSel = E('select', { 'class': 'cbi-input-select', 'change': function(ev) {
|
||||
var v = ev.target.value;
|
||||
state.probe.method = (v === 'tcp') ? 'tcp' : 'http';
|
||||
state.probe.http = (v === 'head') ? 'HEAD' : 'GET';
|
||||
urlInput.style.display = (v === 'tcp') ? 'none' : '';
|
||||
} }, [
|
||||
E('option', { 'value': 'tcp' }, [ _('TCP connect (fast)') ]),
|
||||
E('option', { 'value': 'get' }, [ _('HTTP GET (through proxy)') ]),
|
||||
E('option', { 'value': 'head' }, [ _('HTTP HEAD (through proxy)') ])
|
||||
]);
|
||||
var probeControls = E('div', { 'style': 'margin:.3em 0' }, [
|
||||
E('span', { 'style': 'opacity:.7;margin-right:.3em' }, [ _('Probe method:') ]),
|
||||
methodSel, urlInput
|
||||
]);
|
||||
|
||||
// --- Import box: paste many share-links, one per line ---
|
||||
var importTa = E('textarea', {
|
||||
'class': 'cbi-input-textarea',
|
||||
'rows': 6,
|
||||
'style': 'width:100%;font-family:monospace',
|
||||
'placeholder': 'vless://...\nvmess://...\ntrojan://...'
|
||||
});
|
||||
|
||||
var importBtn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action important',
|
||||
'click': ui.createHandlerFn(self, function() {
|
||||
var text = importTa.value || '';
|
||||
if (!text.replace(/\s+/g, '')) {
|
||||
ui.addNotification(null, E('p', {}, _('Paste one or more share-links first.')), 'warning');
|
||||
return;
|
||||
}
|
||||
return callNodeImport(b64encode(text)).then(function(r) {
|
||||
var ok = !r || r.ok !== false;
|
||||
if (ok) {
|
||||
importTa.value = '';
|
||||
ui.addNotification(null, E('p', {}, _('Import succeeded.')), 'info');
|
||||
// xrayctl wrote config node/cache on disk; re-fetch instead of reloading.
|
||||
return refreshAll();
|
||||
}
|
||||
ui.addNotification(null, E('p', {}, _('Import failed')), 'warning');
|
||||
});
|
||||
})
|
||||
}, [ _('Import nodes') ]);
|
||||
|
||||
var importSection = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, [ _('Import share-links') ]),
|
||||
E('p', {}, [ _('Paste subscription share-links (base64 list or one link per line). ' +
|
||||
'They are parsed into manual nodes and the cache.') ]),
|
||||
importTa,
|
||||
E('div', { 'style': 'margin-top:.4em' }, [ importBtn ])
|
||||
]);
|
||||
|
||||
// --- Manual nodes CRUD (config node) ---
|
||||
m = new form.Map('shater', null,
|
||||
_('Manually added nodes. Each is a single share-link URI; xrayctl parses it.'));
|
||||
|
||||
var s = m.section(form.GridSection, 'node', _('Manual nodes'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.sortable = false;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add manual node');
|
||||
|
||||
var o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'reality-nl';
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.default = '1';
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.TextValue, 'uri', _('Share-link URI'));
|
||||
o.rows = 3;
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'vless://… | wireguard://…';
|
||||
o.monospace = true;
|
||||
o.description = _('A single share-link (vless/vmess/trojan/ss/wireguard). ' +
|
||||
'To add a WireGuard/AmneziaWG node from a wg-quick .conf, paste the whole ' +
|
||||
'file into the Import box instead (multi-line INI cannot live in one URI).');
|
||||
|
||||
// --- per-node multiplexing + sockopt: expert-only, modal-only ---
|
||||
if (adv) {
|
||||
o = s.option(form.Flag, 'mux', _('Multiplexing (mux)'));
|
||||
o.modalonly = true;
|
||||
o.default = '0';
|
||||
o.description = _('Enable connection multiplexing for this node. Ignored for VLESS XTLS-Vision.');
|
||||
|
||||
o = s.option(form.Value, 'mux_concurrency', _('Mux concurrency'));
|
||||
o.modalonly = true;
|
||||
o.datatype = 'range(1,1024)';
|
||||
o.default = '8';
|
||||
o.depends('mux', '1');
|
||||
|
||||
o = s.option(form.Value, 'xudp_concurrency', _('XUDP concurrency'));
|
||||
o.modalonly = true;
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '16';
|
||||
o.depends('mux', '1');
|
||||
o.description = _('VLESS/VMess only — ignored for other protocols.');
|
||||
|
||||
o = s.option(form.ListValue, 'xudp_udp443', _('XUDP UDP/443 policy'));
|
||||
o.modalonly = true;
|
||||
o.value('reject', _('reject (recommended)'));
|
||||
o.value('allow', _('allow'));
|
||||
o.value('skip', _('skip (direct)'));
|
||||
o.default = 'reject';
|
||||
o.depends('mux', '1');
|
||||
|
||||
o = s.option(form.ListValue, 'tcp_fast_open', _('TCP Fast Open'));
|
||||
o.modalonly = true;
|
||||
o.value('', _('inherit'));
|
||||
o.value('1', _('on'));
|
||||
o.value('0', _('off'));
|
||||
o.default = '';
|
||||
|
||||
o = s.option(form.Value, 'tcp_keepalive_idle', _('TCP keepalive idle (s)'));
|
||||
o.modalonly = true;
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '0';
|
||||
|
||||
o = s.option(form.Value, 'sockopt_mark', _('Socket mark (advanced)'));
|
||||
o.modalonly = true;
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '0';
|
||||
o.description = _('Leave 0. The loop-guard escape mark 255 is enforced regardless; ' +
|
||||
'a different value is ignored to prevent routing loops.');
|
||||
}
|
||||
|
||||
return m.render().then(function(mapEl) {
|
||||
dom.content(mapWrap, mapEl);
|
||||
draw();
|
||||
poll.add(function() {
|
||||
return L.resolveDefault(callNodes(), { nodes: [] }).then(setNodes);
|
||||
}, 10);
|
||||
|
||||
return E('div', { 'class': 'sh-wrap' }, [
|
||||
uimode.header(_('Nodes'),
|
||||
_('Every server you can route through — from your subscriptions plus any you added ' +
|
||||
'by hand. Use "Test" to check a server is reachable; the Latency column comes from ' +
|
||||
'the periodic node health checks.')),
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
probeControls,
|
||||
filterBox,
|
||||
bulkBar,
|
||||
tableBox,
|
||||
E('div', { 'class': 'cbi-page-actions', 'style': 'margin-top:.4em' }, [ testAllBtn ])
|
||||
]),
|
||||
importSection,
|
||||
mapWrap,
|
||||
adv ? '' : uimode.advHint(_('Per-node multiplexing (mux/XUDP) and socket tuning are available in Advanced mode.'))
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,742 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// Backend ubus object "xray" (see shater.uc). All methods granted in acl.d.
|
||||
var callStatus = rpc.declare({ object: 'xray', method: 'status', expect: { '': {} } });
|
||||
var callStats = rpc.declare({ object: 'xray', method: 'stats', expect: { '': {} } });
|
||||
var callNodes = rpc.declare({ object: 'xray', method: 'nodes', expect: { '': {} } });
|
||||
var callSubInfo = rpc.declare({ object: 'xray', method: 'sub_info', expect: { '': {} } });
|
||||
var callGeoStatus = rpc.declare({ object: 'xray', method: 'geodata_status', expect: { '': {} } });
|
||||
var callApply = rpc.declare({ object: 'xray', method: 'apply', expect: { '': {} } });
|
||||
var callConfirm = rpc.declare({ object: 'xray', method: 'confirm', expect: { '': {} } });
|
||||
var callReload = rpc.declare({ object: 'xray', method: 'reload', expect: { '': {} } });
|
||||
var callSubUpdate = rpc.declare({ object: 'xray', method: 'sub_update', params: [ 'name' ], expect: { '': {} } });
|
||||
var callNodeTest = rpc.declare({ object: 'xray', method: 'node_test', params: [ 'name', 'method', 'url', 'http_method' ], expect: { '': {} } });
|
||||
|
||||
// Direct (unchecked) uci apply for the wizard: commits the staged shater
|
||||
// changes and triggers reload_config. Same call LuCI's own Save & Apply uses
|
||||
// (rollback=false path), so it is covered by the standard luci-base ubus ACL;
|
||||
// the per-config write access comes from write.uci "shater" in our acl.d.
|
||||
var callUciApply = rpc.declare({ object: 'uci', method: 'apply', params: [ 'rollback', 'timeout' ] });
|
||||
|
||||
var POLL = 5; // seconds
|
||||
|
||||
function num(v) { var n = Number(v); return isNaN(n) ? 0 : n; }
|
||||
function fmtBytes(v) {
|
||||
var n = num(v), u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'], i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return (i === 0 ? n : n.toFixed(2)) + ' ' + u[i];
|
||||
}
|
||||
function fmtRate(v) { return fmtBytes(v) + '/s'; }
|
||||
function pick(obj) {
|
||||
if (!obj) return undefined;
|
||||
for (var i = 1; i < arguments.length; i++) {
|
||||
var v = obj[arguments[i]];
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function toArray(v) {
|
||||
if (Array.isArray(v)) return v;
|
||||
if (v && typeof v == 'object') return Object.keys(v).map(function(k) {
|
||||
var r = v[k];
|
||||
return (r && typeof r == 'object' && r.name === undefined) ? Object.assign({ name: k }, r) : r;
|
||||
});
|
||||
return [];
|
||||
}
|
||||
function sleep(ms) {
|
||||
return new Promise(function(resolve) { window.setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
// tileState colors the left instrument tick: '' | is-ok | is-bad | is-warn
|
||||
function kpi(label, valNode, sub, cls, tileState) {
|
||||
return E('div', { 'class': 'sh-card' + (tileState ? ' ' + tileState : '') }, [
|
||||
E('div', { 'class': 'lab' }, [ label ]),
|
||||
E('div', { 'class': 'val' + (cls ? ' ' + cls : '') }, [ valNode ]),
|
||||
E('div', { 'class': 'sub' }, [ sub || ' ' ])
|
||||
]);
|
||||
}
|
||||
function tileState(cls) {
|
||||
return cls === 'sh-ok' ? 'is-ok' : cls === 'sh-bad' ? 'is-bad' : cls === 'sh-warn' ? 'is-warn' : '';
|
||||
}
|
||||
|
||||
// rate state: previous totals + timestamp, to derive B/s across polls.
|
||||
var prev = { t: 0, up: 0, down: 0 };
|
||||
function rates(upTotal, downTotal) {
|
||||
var now = Date.now() / 1000, r = { up: 0, down: 0 };
|
||||
if (prev.t && now > prev.t) {
|
||||
var dt = now - prev.t;
|
||||
r.up = Math.max(0, (upTotal - prev.up) / dt);
|
||||
r.down = Math.max(0, (downTotal - prev.down) / dt);
|
||||
}
|
||||
prev = { t: now, up: upTotal, down: downTotal };
|
||||
spark.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- live sparkline
|
||||
// Ring buffer of the last SPARK_MAX rate samples, drawn as two polylines
|
||||
// (download = theme primary, upload = theme success) with a subtle area fill.
|
||||
var SPARK_MAX = 60;
|
||||
var spark = {
|
||||
hist: [], // [{ up, down }] — newest last
|
||||
last: { up: 0, down: 0 },
|
||||
canvas: null,
|
||||
push: function(r) {
|
||||
this.last = r;
|
||||
this.hist.push({ up: r.up, down: r.down });
|
||||
if (this.hist.length > SPARK_MAX)
|
||||
this.hist.shift();
|
||||
}
|
||||
};
|
||||
|
||||
function themeColor(name, fallback) {
|
||||
var v = '';
|
||||
try { v = window.getComputedStyle(document.documentElement).getPropertyValue(name); }
|
||||
catch (e) {}
|
||||
return (v && v.replace(/\s+/g, '')) ? v.trim() : fallback;
|
||||
}
|
||||
|
||||
function drawSpark() {
|
||||
var cv = spark.canvas;
|
||||
if (!cv || !cv.getContext || !cv.clientWidth)
|
||||
return;
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var w = cv.clientWidth, h = cv.clientHeight || 46;
|
||||
cv.width = Math.round(w * dpr);
|
||||
cv.height = Math.round(h * dpr);
|
||||
var ctx = cv.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
var hist = spark.hist;
|
||||
if (hist.length < 2)
|
||||
return;
|
||||
|
||||
var max = 1;
|
||||
hist.forEach(function(p) {
|
||||
if (p.down > max) max = p.down;
|
||||
if (p.up > max) max = p.up;
|
||||
});
|
||||
|
||||
// samples fill in from the right, so a fresh page grows leftwards
|
||||
var step = w / (SPARK_MAX - 1);
|
||||
function x(i) { return w - (hist.length - 1 - i) * step; }
|
||||
function y(v) { return h - 2 - (v / max) * (h - 6); }
|
||||
|
||||
function series(key, col) {
|
||||
ctx.beginPath();
|
||||
for (var i = 0; i < hist.length; i++)
|
||||
(i ? ctx.lineTo : ctx.moveTo).call(ctx, x(i), y(hist[i][key]));
|
||||
ctx.strokeStyle = col;
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
ctx.lineTo(x(hist.length - 1), h);
|
||||
ctx.lineTo(x(0), h);
|
||||
ctx.closePath();
|
||||
ctx.globalAlpha = 0.14;
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
// match the .sh-rates legend colors (download = signal teal, upload = amber)
|
||||
series('down', themeColor('--sh-signal', '#00b3a4'));
|
||||
series('up', themeColor('--sh-amber', '#f5a623'));
|
||||
}
|
||||
function drawRibbon() { window.requestAnimationFrame(drawSpark); }
|
||||
|
||||
// ---------------------------------------------------------------- dashboard model
|
||||
// Compute the dashboard model ONCE per poll (rates() has side effects — it must
|
||||
// be called exactly once per tick), shared by the signal path and the tiles.
|
||||
function computeModel(status, stats, nodes) {
|
||||
var m = {};
|
||||
m.up = !!(status && (status.xray_up || status.running));
|
||||
m.enabled = !!(status && status.enabled);
|
||||
m.nft = !!(status && status.nft_loaded);
|
||||
m.pending = !!(status && status.pending);
|
||||
m.kill = (status && status.kill) || '-';
|
||||
m.dnsMode = (status && status.dns_mode) || '-';
|
||||
m.version = status && status.xray_version;
|
||||
m.protected = m.up && m.nft;
|
||||
|
||||
var nlist = toArray(nodes && nodes.nodes ? nodes.nodes : nodes);
|
||||
m.alive = nlist.filter(function(n) { return n && n.alive; }).length;
|
||||
m.total = nlist.length;
|
||||
|
||||
var upTot = 0, downTot = 0, topNode = null;
|
||||
toArray(stats && stats.nodes).forEach(function(n) {
|
||||
upTot += num(pick(n, 'uplink', 'up', 'tx'));
|
||||
downTot += num(pick(n, 'downlink', 'down', 'rx'));
|
||||
var tot = num(pick(n, 'total')) || (num(pick(n, 'uplink', 'up')) + num(pick(n, 'downlink', 'down')));
|
||||
var nm = pick(n, 'name', 'tag');
|
||||
if (tot > 0 && nm && nm !== 'block' && nm !== 'direct' && (!topNode || tot > topNode.tot))
|
||||
topNode = { name: nm, tot: tot };
|
||||
});
|
||||
m.upTot = upTot; m.downTot = downTot;
|
||||
m.rt = rates(upTot, downTot); // <-- the one and only rates() call per tick
|
||||
|
||||
var selected = nlist.filter(function(n) { return n && n.selected; });
|
||||
var active = selected.length ? selected[0] : null;
|
||||
m.activeName = active ? active.name : (topNode ? topNode.name : null);
|
||||
m.activeLat = active && active.latency_ms ? active.latency_ms : undefined;
|
||||
|
||||
m.clients = toArray(stats && stats.clients)
|
||||
.filter(function(c) { return num(pick(c, 'bytes', 'down', 'up')) > 0; }).length;
|
||||
m.aliveCls = m.total === 0 ? '' : (m.alive === 0 ? 'sh-bad' : (m.alive < m.total / 2 ? 'sh-warn' : 'sh-ok'));
|
||||
return m;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- signature: the Signal Path
|
||||
// you (LAN) -> gateway (shater engine) -> foreign exit, with the live throughput
|
||||
// ribbon flowing beneath the rails. This is the one bold element of the page.
|
||||
function renderSignalPath(m) {
|
||||
var proto = m.protected;
|
||||
var country = m.activeName ? uimode.countryFromName(m.activeName) : '';
|
||||
var flag = country ? uimode.countryFlag(country) : '';
|
||||
|
||||
function station(kind, glyph, lab, meta, big) {
|
||||
return E('div', { 'class': 'sh-stn ' + kind }, [
|
||||
E('div', { 'class': 'sh-node ' + kind }, [ E('span', {}, [ glyph ]) ]),
|
||||
E('div', { 'class': 'lab' }, [ lab ]),
|
||||
E('div', { 'class': 'meta' + (big ? ' big' : '') }, [ meta ])
|
||||
]);
|
||||
}
|
||||
function rail() {
|
||||
return E('div', { 'class': 'sh-rail' + (proto ? ' live' : '') });
|
||||
}
|
||||
|
||||
var youStn = station('you', '⌘', _('Your LAN'),
|
||||
m.clients ? _('%d active').format(m.clients) : _('idle'));
|
||||
var gwStn = station('gw', '◇', _('Gateway'),
|
||||
m.up ? (m.version ? ('xray ' + m.version) : _('engine up')) : _('engine down'));
|
||||
|
||||
var exitMeta;
|
||||
if (proto && m.activeName)
|
||||
exitMeta = (flag ? flag + ' ' : '') + m.activeName;
|
||||
else if (proto)
|
||||
exitMeta = _('selecting…');
|
||||
else
|
||||
exitMeta = _('no route');
|
||||
var exitStn = station('exit', proto ? '↗' : '∅', _('Exit'), exitMeta, true);
|
||||
|
||||
// live throughput ribbon (reuses the sparkline ring buffer)
|
||||
spark.canvas = E('canvas', { 'class': 'sh-ribbon', 'height': '46' });
|
||||
|
||||
var stateLabel = proto
|
||||
? E('span', { 'class': 'sh-state ok' }, [ uimode.countryFlag(country) || '✓', ' ' + _('Protected') ])
|
||||
: E('span', { 'class': 'sh-state bad' }, [ '⚠ ' + (m.up ? _('No route — no live node') : _('Tunnel down')) ]);
|
||||
|
||||
return E('div', { 'class': 'sh-path' + (proto ? '' : ' down') }, [
|
||||
E('div', { 'class': 'caption' }, [ _('Signal path') ]),
|
||||
E('div', { 'class': 'sh-route' }, [ youStn, rail(), gwStn, rail(), exitStn ]),
|
||||
E('div', { 'class': 'sh-flowrow' }, [
|
||||
E('div', { 'class': 'sh-rates' }, [
|
||||
E('span', { 'class': 'dn' }, [ E('span', { 'class': 'k' }, [ 'DL' ]), E('b', {}, [ fmtRate(m.rt.down) ]) ]),
|
||||
E('span', { 'class': 'up' }, [ E('span', { 'class': 'k' }, [ 'UL' ]), E('b', {}, [ fmtRate(m.rt.up) ]) ])
|
||||
]),
|
||||
stateLabel
|
||||
]),
|
||||
spark.canvas
|
||||
]);
|
||||
}
|
||||
|
||||
function renderKPIs(m) {
|
||||
var engineSub = m.enabled ? _('enabled') : _('disabled');
|
||||
if (m.version) engineSub += ' · xray ' + m.version;
|
||||
|
||||
var killCls = m.kill === 'closed' ? 'sh-ok' : (m.kill === 'open' ? 'sh-warn' : '');
|
||||
|
||||
return E('div', { 'class': 'sh-cards' }, [
|
||||
kpi(_('Engine'),
|
||||
E('span', {}, [
|
||||
m.up ? uimode.dot('ok', _('running')) : uimode.dot('bad', _('stopped')),
|
||||
m.pending ? E('span', { 'style': 'margin-left:.4em' }, [ uimode.badge(_('pending confirm'), 'warn') ]) : ''
|
||||
]),
|
||||
engineSub, m.up ? 'sh-ok' : 'sh-bad', m.up ? 'is-ok' : 'is-bad'),
|
||||
kpi(_('Data plane'),
|
||||
m.nft ? uimode.dot('ok', _('active')) : uimode.dot('bad', _('not loaded')),
|
||||
_('nftables interception'), m.nft ? 'sh-ok' : 'sh-bad', m.nft ? 'is-ok' : 'is-bad'),
|
||||
kpi(_('Kill switch'),
|
||||
E('span', { 'class': 'val small' }, [ m.kill ]),
|
||||
_('DNS mode: ') + m.dnsMode, killCls, tileState(killCls)),
|
||||
kpi(_('Active route'),
|
||||
E('span', { 'class': 'small' }, [
|
||||
uimode.flagged(m.activeName || '-'),
|
||||
m.activeLat ? E('span', { 'style': 'margin-left:.45em' }, [ uimode.latencyBadge(m.activeLat) ]) : ''
|
||||
]),
|
||||
_('nodes alive %d / %d').format(m.alive, m.total), m.aliveCls, tileState(m.aliveCls)),
|
||||
kpi(_('Download'), E('span', {}, [ fmtRate(m.rt.down) ]), _('total ') + fmtBytes(m.downTot)),
|
||||
kpi(_('Upload'), E('span', {}, [ fmtRate(m.rt.up) ]), _('total ') + fmtBytes(m.upTot)),
|
||||
kpi(_('LAN clients'), E('span', {}, [ '' + m.clients ]),
|
||||
_('devices proxied'))
|
||||
]);
|
||||
}
|
||||
|
||||
// Compact subscription-health line (quota / expiry from sub_info).
|
||||
function renderSubHealth(subinfo) {
|
||||
var subs = (subinfo && subinfo.subs) || [];
|
||||
if (!subs.length)
|
||||
return E('div', {});
|
||||
var parts = [];
|
||||
subs.forEach(function(s) {
|
||||
var bits = [ s.name ];
|
||||
if (s.total > 0)
|
||||
bits.push(_('%s%% used').format(Math.round(s.pct_used)));
|
||||
if (s.days_left >= 0)
|
||||
bits.push(_('%d days left').format(s.days_left));
|
||||
var kind = (s.exhausted || (s.days_left >= 0 && s.days_left <= 1)) ? 'bad'
|
||||
: ((s.expiring || s.pct_used >= 90) ? 'warn' : 'ok');
|
||||
parts.push(uimode.badge(bits.join(' · '), kind));
|
||||
});
|
||||
return E('div', { 'class': 'sh-subline' },
|
||||
[ E('span', { 'class': 'sh-muted', 'style': 'margin-right:.5em' }, [ _('Subscriptions:') ]) ].concat(parts));
|
||||
}
|
||||
|
||||
// A styled table with optional bar column. cols: [{title, r?, get, bar?}]
|
||||
function table(caption, cols, rows, empty) {
|
||||
var head = E('tr', {}, cols.map(function(c) {
|
||||
return E('th', { 'class': c.r ? 'r' : '' }, [ c.title ]);
|
||||
}));
|
||||
var body = rows.length ? rows.map(function(row) {
|
||||
return E('tr', {}, cols.map(function(c) {
|
||||
if (c.bar) return E('td', {}, [ c.get(row) ]);
|
||||
return E('td', { 'class': c.r ? 'r' : '' }, [ '' + c.get(row) ]);
|
||||
}));
|
||||
}) : [ E('tr', {}, [ E('td', { 'colspan': String(cols.length) }, [
|
||||
E('div', { 'class': 'sh-empty' }, [ empty || _('No data yet.') ]) ]) ]) ];
|
||||
return E('div', { 'class': 'sh-sec' }, [
|
||||
caption ? E('h3', {}, [ caption ]) : '',
|
||||
E('div', { 'class': 'sh-tblwrap' }, [
|
||||
E('table', { 'class': 'sh-tbl' }, [ E('thead', {}, head), E('tbody', {}, body) ])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function renderTraffic(stats) {
|
||||
if (!stats || stats.error)
|
||||
return E('div', { 'class': 'alert-message warning' },
|
||||
[ _('Failed to read stats: '), (stats && (stats.error || stats.raw)) || _('no data') ]);
|
||||
|
||||
// Only nodes that actually carried traffic — the 250-row all-zero dump is useless.
|
||||
var nodes = toArray(stats.nodes).map(function(n) {
|
||||
return { name: pick(n, 'name', 'tag'), up: num(pick(n, 'uplink', 'up')), down: num(pick(n, 'downlink', 'down')),
|
||||
total: num(pick(n, 'total')) || (num(pick(n, 'uplink', 'up')) + num(pick(n, 'downlink', 'down'))) };
|
||||
}).filter(function(n) { return n.total > 0 && n.name !== 'block' && n.name !== 'direct'; })
|
||||
.sort(function(a, b) { return b.total - a.total; });
|
||||
var maxNode = nodes.length ? nodes[0].total : 1;
|
||||
var topNodes = nodes.slice(0, 10);
|
||||
|
||||
var clients = toArray(stats.clients).concat(toArray(stats.clients6)).map(function(c) {
|
||||
return { ip: pick(c, 'ip', 'addr', 'saddr'), bytes: num(pick(c, 'bytes')), packets: num(pick(c, 'packets')) };
|
||||
}).filter(function(c) { return c.bytes > 0; }).sort(function(a, b) { return b.bytes - a.bytes; });
|
||||
var maxCli = clients.length ? clients[0].bytes : 1;
|
||||
|
||||
var rules = toArray(stats.rules).concat(toArray(stats.inbounds)).map(function(r) {
|
||||
return { name: pick(r, 'name', 'rule'), packets: num(pick(r, 'packets', 'hits')), bytes: num(pick(r, 'bytes')) };
|
||||
}).filter(function(r) { return r.name; }).sort(function(a, b) { return b.bytes - a.bytes; });
|
||||
|
||||
var nodeSec = table(_('Top nodes by traffic'), [
|
||||
{ title: _('Node'), get: function(r) { return r.name ? uimode.flagged(r.name) : '-'; } },
|
||||
{ title: _('Down'), r: true, get: function(r) { return fmtBytes(r.down); } },
|
||||
{ title: _('Up'), r: true, get: function(r) { return fmtBytes(r.up); } },
|
||||
{ title: _('Share'), bar: true, get: function(r) { return uimode.bar(r.total / maxNode); } }
|
||||
], topNodes, _('No node has carried traffic yet — pass some through a proxied client.'));
|
||||
|
||||
var cliSec = table(_('Top clients'), [
|
||||
{ title: _('Client'), get: function(r) { return r.ip || '-'; } },
|
||||
{ title: _('Traffic'), r: true, get: function(r) { return fmtBytes(r.bytes); } },
|
||||
{ title: _('Packets'), r: true, get: function(r) { return uimode.fmtNum(r.packets); } },
|
||||
{ title: _('Share'), bar: true, get: function(r) { return uimode.bar(r.bytes / maxCli); } }
|
||||
], clients, _('No per-client counters yet (kernel sets warming up).'));
|
||||
|
||||
var ruleSec = table(_('Per-rule / per-inbound diverted'), [
|
||||
{ title: _('Rule / inbound'), get: function(r) { return r.name; } },
|
||||
{ title: _('Packets'), r: true, get: function(r) { return uimode.fmtNum(r.packets); } },
|
||||
{ title: _('Bytes'), r: true, get: function(r) { return fmtBytes(r.bytes); } }
|
||||
], rules, _('No per-rule counters yet.'));
|
||||
|
||||
return E('div', {}, [ nodeSec, cliSec, ruleSec ]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- quick-start wizard
|
||||
|
||||
function sanitizeName(v) {
|
||||
var s = String(v || '').trim().replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return s || 'main';
|
||||
}
|
||||
|
||||
// Find a uci section of a type whose "name" option (or section id) matches.
|
||||
function findSection(type, name) {
|
||||
var secs = uci.sections('shater', type) || [];
|
||||
for (var i = 0; i < secs.length; i++)
|
||||
if ((secs[i].name || secs[i]['.name']) === name)
|
||||
return secs[i]['.name'];
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureSection(type, name, values) {
|
||||
var sid = findSection(type, name);
|
||||
if (!sid) {
|
||||
sid = uci.add('shater', type);
|
||||
uci.set('shater', sid, 'name', name);
|
||||
}
|
||||
Object.keys(values || {}).forEach(function(k) {
|
||||
uci.set('shater', sid, k, values[k]);
|
||||
});
|
||||
return sid;
|
||||
}
|
||||
|
||||
// Stage the whole quick-start configuration in the uci client state.
|
||||
// mode: 'all' (route everything) | 'ru' (everything except RU + private).
|
||||
function stageWizardConfig(name, url, mode) {
|
||||
// 1. subscription
|
||||
ensureSection('subscription', name, {
|
||||
enabled: '1',
|
||||
url: url,
|
||||
update_interval: '6h'
|
||||
});
|
||||
|
||||
// 2. group over that subscription (least-ping balancer)
|
||||
ensureSection('group', name, {
|
||||
source: 'subscription',
|
||||
subscription: name,
|
||||
strategy: 'leastping'
|
||||
});
|
||||
|
||||
// 3. catch-all rule -> group (without a rule NOTHING is proxied)
|
||||
ensureSection('rule', 'quick-start', {
|
||||
enabled: '1',
|
||||
order: '100',
|
||||
target: 'group:' + name
|
||||
});
|
||||
|
||||
// 4. preset packs: private always stays direct (keeps LAN/router reachable);
|
||||
// ru-bypass follows the chosen mode; block-ads is seeded but left as-is.
|
||||
ensureSection('preset', 'private', { enabled: '1' });
|
||||
ensureSection('preset', 'ru-bypass', { enabled: (mode === 'ru') ? '1' : '0' });
|
||||
if (!findSection('preset', 'block-ads'))
|
||||
ensureSection('preset', 'block-ads', { enabled: '0' });
|
||||
|
||||
// 5. engine on + LAN interception on
|
||||
uci.set('shater', 'globals', 'enabled', '1');
|
||||
var lan = findSection('inbound', 'lan');
|
||||
if (!lan) {
|
||||
// first tproxy-ish inbound, else create the standard one
|
||||
var inb = (uci.sections('shater', 'inbound') || []).filter(function(s) {
|
||||
return !s.type || s.type === 'tproxy';
|
||||
});
|
||||
if (inb.length) {
|
||||
lan = inb[0]['.name'];
|
||||
} else {
|
||||
lan = uci.add('shater', 'inbound');
|
||||
uci.set('shater', lan, 'name', 'lan');
|
||||
uci.set('shater', lan, 'network', 'lan');
|
||||
uci.set('shater', lan, 'tproxy_port', '12345');
|
||||
uci.set('shater', lan, 'tcp', '1');
|
||||
uci.set('shater', lan, 'udp', '1');
|
||||
uci.set('shater', lan, 'sniff', '1');
|
||||
}
|
||||
}
|
||||
uci.set('shater', lan, 'enabled', '1');
|
||||
}
|
||||
|
||||
function renderWizard(geodata, onDone) {
|
||||
var nameIn = E('input', { 'type': 'text', 'class': 'cbi-input-text',
|
||||
'placeholder': _('name (optional, default: main)'), 'style': 'max-width:14em' });
|
||||
var urlIn = E('input', { 'type': 'text', 'class': 'cbi-input-text',
|
||||
'placeholder': 'https://example.com/sub/…' });
|
||||
var mode = { value: 'ru' };
|
||||
var geoWarn = E('div', {
|
||||
'class': 'sh-note sh-warn',
|
||||
'style': (geodata && geodata.present) ? 'display:none' : ''
|
||||
}, [ _('Note: geo data files (geoip/geosite) are not installed, so the "except Russian sites" ' +
|
||||
'part stays inactive until you download them on the Lists page. Everything else works.') ]);
|
||||
|
||||
function radio(value, label, checked) {
|
||||
return E('label', { 'class': 'radio' }, [
|
||||
E('input', { 'type': 'radio', 'name': 'sh-wiz-mode', 'value': value,
|
||||
'checked': checked ? 'checked' : null,
|
||||
'change': function() {
|
||||
mode.value = value;
|
||||
geoWarn.style.display = (value === 'ru' && !(geodata && geodata.present)) ? '' : 'none';
|
||||
} }),
|
||||
label
|
||||
]);
|
||||
}
|
||||
|
||||
var stepDefs = [
|
||||
_('Write configuration (subscription, node group, routing rule, LAN interception)'),
|
||||
_('Fetch the node list from your subscription'),
|
||||
_('Start the engine'),
|
||||
_('Verify the tunnel')
|
||||
];
|
||||
var stepEls = stepDefs.map(function(t) { return E('li', {}, [ t ]); });
|
||||
var stepsList = E('ul', { 'class': 'sh-steps', 'style': 'display:none' }, stepEls);
|
||||
var resultBox = E('div', {});
|
||||
|
||||
function setStep(i, cls, extra) {
|
||||
stepEls[i].className = cls;
|
||||
if (extra)
|
||||
dom.content(stepEls[i], [ stepDefs[i], E('span', { 'class': 'sh-muted' }, [ ' — ' + extra ]) ]);
|
||||
}
|
||||
|
||||
var startBtn = E('button', { 'class': 'btn cbi-button cbi-button-apply important' }, [ _('Set up now') ]);
|
||||
|
||||
startBtn.addEventListener('click', ui.createHandlerFn({}, function() {
|
||||
var url = (urlIn.value || '').trim();
|
||||
if (!/^https?:\/\/.+/.test(url)) {
|
||||
ui.addNotification(null, E('p', {}, _('Enter your subscription link (an http(s):// URL).')), 'warning');
|
||||
return;
|
||||
}
|
||||
var name = sanitizeName(nameIn.value);
|
||||
stepsList.style.display = '';
|
||||
dom.content(resultBox, '');
|
||||
startBtn.disabled = true;
|
||||
|
||||
var failed = false;
|
||||
setStep(0, 'run');
|
||||
|
||||
// Step 1: stage + save + commit (unchecked apply).
|
||||
return uci.load('shater').then(function() {
|
||||
stageWizardConfig(name, url, mode.value);
|
||||
return uci.save();
|
||||
}).then(function() {
|
||||
return callUciApply(false, 0);
|
||||
}).then(function() {
|
||||
setStep(0, 'done');
|
||||
setStep(1, 'run');
|
||||
// Step 2: fetch the subscription now.
|
||||
return callSubUpdate(name);
|
||||
}).then(function(r) {
|
||||
if (r && r.ok === false) {
|
||||
failed = true;
|
||||
setStep(1, 'fail', _('fetch failed — check the URL; you can retry from the Subscriptions page'));
|
||||
} else {
|
||||
setStep(1, 'done');
|
||||
}
|
||||
setStep(2, 'run');
|
||||
// Step 3: apply (xrayctl renders + starts everything).
|
||||
return callApply();
|
||||
}).then(function(r) {
|
||||
if (r && r.ok === false) {
|
||||
failed = true;
|
||||
setStep(2, 'fail', _('apply failed — see the system log'));
|
||||
throw 'apply';
|
||||
}
|
||||
setStep(2, 'done');
|
||||
setStep(3, 'run');
|
||||
// Step 4: wait for the engine, then try an exit-IP probe.
|
||||
var tries = 0;
|
||||
function waitUp() {
|
||||
return L.resolveDefault(callStatus(), {}).then(function(st) {
|
||||
if (st && st.xray_up && st.nft_loaded)
|
||||
return st;
|
||||
if (++tries >= 8)
|
||||
return st;
|
||||
return sleep(1500).then(waitUp);
|
||||
});
|
||||
}
|
||||
return waitUp();
|
||||
}).then(function(st) {
|
||||
var engineUp = !!(st && st.xray_up);
|
||||
return L.resolveDefault(callNodes(), {}).then(function(res) {
|
||||
var nlist = (res && res.nodes) || [];
|
||||
if (!nlist.length) {
|
||||
setStep(3, engineUp ? 'done' : 'fail',
|
||||
_('engine %s, but no nodes yet — check the subscription').format(engineUp ? _('running') : _('down')));
|
||||
return null;
|
||||
}
|
||||
var probeName = null;
|
||||
for (var i = 0; i < nlist.length; i++)
|
||||
if (nlist[i].selected) { probeName = nlist[i].name; break; }
|
||||
if (!probeName)
|
||||
probeName = nlist[0].name;
|
||||
// Exit-IP check through the proxy path (best effort).
|
||||
return L.resolveDefault(callNodeTest(probeName, 'http', '', 'GET'), {}).then(function(pr) {
|
||||
var p = (pr && pr.probe && pr.probe[0]) || {};
|
||||
if (p.alive) {
|
||||
setStep(3, 'done', p.exit_ip
|
||||
? _('%d nodes loaded · exit IP %s (%d ms)').format(nlist.length, p.exit_ip, p.latency_ms || 0)
|
||||
: _('%d nodes loaded · node reachable').format(nlist.length));
|
||||
} else {
|
||||
setStep(3, engineUp ? 'done' : 'fail',
|
||||
_('%d nodes loaded, probe of "%s" failed: %s').format(nlist.length, probeName, p.error || _('unreachable')));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}).then(function() {
|
||||
dom.content(resultBox, E('div', { 'class': failed ? 'alert-message warning' : 'alert-message success',
|
||||
'style': 'margin-top:.8em' },
|
||||
failed
|
||||
? [ _('Setup finished with warnings — see the steps above. You can adjust everything on the other pages.') ]
|
||||
: [ E('span', { 'class': 'sh-bigcheck' }, [ '✓' ]),
|
||||
_('Done! Devices on your LAN now go through the VPN. Fine-tune anything later on the other pages.') ]));
|
||||
startBtn.disabled = false;
|
||||
if (onDone) onDone();
|
||||
}).catch(function(e) {
|
||||
if (e !== 'apply')
|
||||
dom.content(resultBox, E('div', { 'class': 'alert-message error', 'style': 'margin-top:.8em' },
|
||||
[ _('Setup failed: ') + e ]));
|
||||
startBtn.disabled = false;
|
||||
});
|
||||
}));
|
||||
|
||||
return E('div', { 'class': 'sh-hero' }, [
|
||||
E('h3', {}, [ _('Quick start — paste your link, click once, done') ]),
|
||||
E('p', { 'class': 'sh-hint' },
|
||||
[ _('This sets up everything a typical VPN-through-the-router install needs: it adds your ' +
|
||||
'subscription, picks the fastest server automatically, routes your LAN through it and ' +
|
||||
'switches the engine on.') ]),
|
||||
E('div', { 'class': 'row' }, [
|
||||
E('span', { 'style': 'min-width:9em' }, [ _('Subscription link') ]),
|
||||
urlIn
|
||||
]),
|
||||
E('div', { 'class': 'row' }, [
|
||||
E('span', { 'style': 'min-width:9em' }, [ _('Name') ]),
|
||||
nameIn
|
||||
]),
|
||||
E('div', { 'style': 'margin:.5em 0' }, [
|
||||
E('div', { 'class': 'sh-muted', 'style': 'margin-bottom:.2em' }, [ _('What should go through the VPN?') ]),
|
||||
radio('ru', _('Everything except Russian sites and local addresses (recommended)'), true),
|
||||
radio('all', _('Route everything through the VPN')),
|
||||
geoWarn
|
||||
]),
|
||||
E('div', { 'class': 'row' }, [ startBtn ]),
|
||||
stepsList,
|
||||
resultBox
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
return view.extend({
|
||||
handleSaveApply: null, handleSave: null, handleReset: null,
|
||||
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
L.resolveDefault(callStatus(), {}),
|
||||
L.resolveDefault(callStats(), {}),
|
||||
L.resolveDefault(callNodes(), {}),
|
||||
L.resolveDefault(callSubInfo(), {}),
|
||||
L.resolveDefault(callGeoStatus(), {}),
|
||||
L.resolveDefault(uci.load('shater'), null)
|
||||
]);
|
||||
},
|
||||
|
||||
// "unconfigured" = nothing that could proxy traffic yet: no enabled
|
||||
// subscription, or no group, or no enabled rule.
|
||||
isUnconfigured: function() {
|
||||
var subs = (uci.sections('shater', 'subscription') || []).filter(function(s) { return s.enabled !== '0'; });
|
||||
var groups = uci.sections('shater', 'group') || [];
|
||||
var rules = (uci.sections('shater', 'rule') || []).filter(function(r) { return r.enabled !== '0'; });
|
||||
return !subs.length || !groups.length || !rules.length;
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
uimode.loadCSS();
|
||||
var self = this;
|
||||
var geodata = data[4] || {};
|
||||
|
||||
var model0 = computeModel(data[0], data[1], data[2]);
|
||||
var pathBox = E('div', {}, [ renderSignalPath(model0) ]);
|
||||
var kpiBox = E('div', {}, [ renderKPIs(model0) ]);
|
||||
var subBox = E('div', {}, [ renderSubHealth(data[3]) ]);
|
||||
var trafBox = E('div', {}, [ renderTraffic(data[1]) ]);
|
||||
var wizardBox = E('div', {});
|
||||
drawRibbon();
|
||||
window.addEventListener('resize', drawRibbon);
|
||||
|
||||
// small muted "updated Xs ago" tick, refreshed every second
|
||||
var lastPoll = Date.now();
|
||||
var updatedEl = E('span', { 'class': 'sh-updated' }, [ _('updated %s').format(uimode.timeAgo(lastPoll)) ]);
|
||||
window.setInterval(function() {
|
||||
if (updatedEl.isConnected !== false)
|
||||
updatedEl.firstChild.data = _('updated %s').format(uimode.timeAgo(lastPoll));
|
||||
}, 1000);
|
||||
|
||||
var unconfigured = this.isUnconfigured();
|
||||
|
||||
function hideWizardAfterSetup() {
|
||||
// keep it visible (with its success message) until the next page view
|
||||
}
|
||||
|
||||
if (unconfigured) {
|
||||
dom.content(wizardBox, renderWizard(geodata, hideWizardAfterSetup));
|
||||
}
|
||||
|
||||
poll.add(function() {
|
||||
return Promise.all([
|
||||
L.resolveDefault(callStatus(), {}),
|
||||
L.resolveDefault(callStats(), {}),
|
||||
L.resolveDefault(callNodes(), {}),
|
||||
L.resolveDefault(callSubInfo(), {})
|
||||
]).then(function(res) {
|
||||
// A failed/timed-out status RPC resolves to {} (StatusJSON always
|
||||
// returns a populated object otherwise). Don't flash "everything
|
||||
// down" on a transient hiccup — keep the last good view and let the
|
||||
// "updated Xs ago" tick reveal the staleness.
|
||||
if (!res[0] || typeof res[0] != 'object' || !Object.keys(res[0]).length)
|
||||
return;
|
||||
var m = computeModel(res[0], res[1], res[2]);
|
||||
dom.content(pathBox, renderSignalPath(m));
|
||||
dom.content(kpiBox, renderKPIs(m));
|
||||
dom.content(subBox, renderSubHealth(res[3]));
|
||||
dom.content(trafBox, renderTraffic(res[1]));
|
||||
drawRibbon();
|
||||
lastPoll = Date.now();
|
||||
});
|
||||
}, POLL);
|
||||
|
||||
function actionBtn(label, style, fn, okMsg) {
|
||||
return E('button', {
|
||||
'class': 'btn cbi-button cbi-button-' + style, 'style': 'margin-right:.4em',
|
||||
'click': ui.createHandlerFn(self, function() {
|
||||
return fn().then(function(r) {
|
||||
var ok = !r || r.ok !== false;
|
||||
ui.addNotification(null, E('p', {}, ok ? okMsg : _('Command failed')), ok ? 'info' : 'warning');
|
||||
});
|
||||
})
|
||||
}, label);
|
||||
}
|
||||
|
||||
var actions = [
|
||||
actionBtn(_('Apply configuration'), 'action important', callApply, _('Configuration applied')),
|
||||
actionBtn(_('Confirm pending'), 'save', callConfirm, _('Pending apply confirmed')),
|
||||
actionBtn(_('Reload / reconcile'), 'reset', callReload, _('Reconcile triggered'))
|
||||
];
|
||||
|
||||
// Configured systems still get access to the wizard, just not prominently.
|
||||
if (!unconfigured) {
|
||||
actions.push(E('button', {
|
||||
'class': 'btn cbi-button cbi-button-neutral',
|
||||
'click': function() {
|
||||
if (wizardBox.firstChild)
|
||||
dom.content(wizardBox, '');
|
||||
else
|
||||
dom.content(wizardBox, renderWizard(geodata, hideWizardAfterSetup));
|
||||
}
|
||||
}, [ _('Quick setup…') ]));
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'sh-wrap' }, [
|
||||
uimode.header(_('Overview'),
|
||||
_('Health and traffic of your proxy at a glance. "Apply configuration" pushes saved ' +
|
||||
'settings to the running engine.')),
|
||||
wizardBox,
|
||||
updatedEl,
|
||||
pathBox,
|
||||
kpiBox,
|
||||
subBox,
|
||||
trafBox,
|
||||
E('div', { 'class': 'cbi-page-actions', 'style': 'margin-top:1em' }, actions)
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require dom';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// This page hosts:
|
||||
// 1. Config snapshots — save/restore/switch named backups of the whole config.
|
||||
// 2. Backup/Restore (download/upload).
|
||||
// 3. WAN-mode Profiles (config profile) — conditional overrides that auto-switch
|
||||
// routing by active uplink / connectivity probe / time window. Advanced only.
|
||||
// 4. xray compatibility report. Advanced only.
|
||||
|
||||
var callBackup = rpc.declare({ object: 'xray', method: 'backup', expect: { '': {} } });
|
||||
var callRestore = rpc.declare({ object: 'xray', method: 'restore', params: ['b64', 'confirm'], expect: { '': {} } });
|
||||
var callSnapshots = rpc.declare({ object: 'xray', method: 'profile_list', expect: { '': {} } });
|
||||
var callSnapSave = rpc.declare({ object: 'xray', method: 'profile_save', params: ['name'], expect: { '': {} } });
|
||||
var callSnapSwitch = rpc.declare({ object: 'xray', method: 'profile_switch', params: ['name', 'confirm'], expect: { '': {} } });
|
||||
var callSnapDelete = rpc.declare({ object: 'xray', method: 'profile_delete', params: ['name'], expect: { '': {} } });
|
||||
var callCompat = rpc.declare({ object: 'xray', method: 'compat', expect: { '': {} } });
|
||||
var callWanmode = rpc.declare({ object: 'xray', method: 'wanmode', expect: { '': {} } });
|
||||
|
||||
function fmtBytes(v) {
|
||||
v = v || 0;
|
||||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB';
|
||||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB';
|
||||
return v + ' B';
|
||||
}
|
||||
function confirmTimeout() { return uci.get('shater', 'globals', 'confirm_timeout') || '0'; }
|
||||
var HHMM = /^([01]?\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
return view.extend({
|
||||
load: function () {
|
||||
return Promise.all([
|
||||
uci.load('shater'),
|
||||
L.resolveDefault(callSnapshots(), { profiles: [] }),
|
||||
L.resolveDefault(callCompat(), {}),
|
||||
L.resolveDefault(callWanmode(), {})
|
||||
]);
|
||||
},
|
||||
|
||||
render: function (data) {
|
||||
var self = this;
|
||||
var adv = uimode.isAdvanced();
|
||||
var snapshots = (data[1] && data[1].profiles) || [];
|
||||
var compat = data[2] || {};
|
||||
var wan = data[3] || {};
|
||||
|
||||
// Containers that get re-rendered in place after mutations (no full reload).
|
||||
var snapBox = E('div', {});
|
||||
var wanFormBox = E('div', {});
|
||||
var bannerBox = E('div', {});
|
||||
var m = null; // WAN-mode form.Map, built below when Advanced
|
||||
|
||||
// Targeted refresh: re-fetch snapshots (and, after a config switch/restore,
|
||||
// the whole uci state + WAN banner) and re-render in place.
|
||||
function refreshSnapshots() {
|
||||
return L.resolveDefault(callSnapshots(), { profiles: [] }).then(function (r) {
|
||||
dom.content(snapBox, renderSnapTable((r && r.profiles) || []));
|
||||
});
|
||||
}
|
||||
function refreshAll() {
|
||||
uci.unload('shater');
|
||||
var tasks = [
|
||||
refreshSnapshots(),
|
||||
uci.load('shater'),
|
||||
L.resolveDefault(callWanmode(), {}).then(function (w) {
|
||||
dom.content(bannerBox, renderBanner(w || {}));
|
||||
})
|
||||
];
|
||||
return Promise.all(tasks).then(function () {
|
||||
if (m)
|
||||
return m.render().then(function (node) { dom.content(wanFormBox, node); });
|
||||
});
|
||||
}
|
||||
|
||||
function renderBanner(w) {
|
||||
var activeTxt = w.active
|
||||
? E('span', {}, [_('Active WAN-mode profile: '), E('strong', { 'class': 'sh-ok' }, w.active)])
|
||||
: E('span', { 'class': 'sh-muted' }, _('No WAN-mode profile is currently active.'));
|
||||
return E('div', { 'class': 'cbi-section', 'style': 'padding:.4em .6em' }, [
|
||||
activeTxt,
|
||||
w.default_iface ? E('span', { 'class': 'sh-muted', 'style': 'margin-left:1em' }, [_('default uplink: '), E('code', {}, w.default_iface)]) : ''
|
||||
]);
|
||||
}
|
||||
|
||||
function renderSnapTable(snaps) {
|
||||
var rows = snaps.map(function (p) {
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, [p.name, p.active ? E('span', { 'style': 'margin-left:.5em' }, [uimode.badge(_('active'), 'ok')]) : '']),
|
||||
E('td', { 'class': 'td' }, fmtBytes(p.size)),
|
||||
E('td', { 'class': 'td' }, 'v' + (p.schema_version || 0)),
|
||||
E('td', { 'class': 'td' }, [
|
||||
E('button', { 'class': 'btn cbi-button cbi-button-action', 'style': 'margin-right:.4em',
|
||||
'click': ui.createHandlerFn(self, function () {
|
||||
if (!confirm(_('Switch to snapshot "%s"?').format(p.name))) return;
|
||||
ui.showModal(_('Switching…'), [E('p', { 'class': 'spinning' }, _('Applying snapshot.'))]);
|
||||
return callSnapSwitch(p.name, confirmTimeout()).then(function () {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', {}, _('Switched to "%s". Other pages now reflect the restored config.').format(p.name)), 'info');
|
||||
return refreshAll();
|
||||
}).catch(function (e) { ui.hideModal(); ui.addNotification(null, E('p', {}, '' + e), 'danger'); });
|
||||
}) }, _('Switch')),
|
||||
E('button', { 'class': 'btn cbi-button cbi-button-remove',
|
||||
'click': ui.createHandlerFn(self, function () {
|
||||
if (!confirm(_('Delete snapshot "%s"?').format(p.name))) return;
|
||||
return callSnapDelete(p.name).then(function () { return refreshSnapshots(); });
|
||||
}) }, _('Delete'))
|
||||
])
|
||||
]);
|
||||
});
|
||||
return E('div', { 'class': 'sh-tblwrap' }, [E('table', { 'class': 'table cbi-section-table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, _('Snapshot')), E('th', { 'class': 'th' }, _('Size')),
|
||||
E('th', { 'class': 'th' }, _('Schema')), E('th', { 'class': 'th' }, _('Actions'))
|
||||
])
|
||||
].concat(rows.length ? rows : [E('tr', { 'class': 'tr' }, [E('td', { 'class': 'td', 'colspan': 4 }, [
|
||||
E('div', { 'class': 'sh-empty' }, [ _('No snapshots yet — save the current config below to create one.') ])
|
||||
])])]))]);
|
||||
}
|
||||
|
||||
// ---------- 1. Config snapshots ----------
|
||||
var nameInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': _('snapshot name') });
|
||||
var saveBtn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-add',
|
||||
'click': ui.createHandlerFn(this, function () {
|
||||
var n = (nameInput.value || '').trim();
|
||||
if (!n) { ui.addNotification(null, E('p', {}, _('Enter a name.')), 'warning'); return; }
|
||||
return callSnapSave(n).then(function () {
|
||||
nameInput.value = '';
|
||||
ui.addNotification(null, E('p', {}, _('Snapshot saved.')), 'info');
|
||||
return refreshSnapshots();
|
||||
});
|
||||
})
|
||||
}, _('Save current as…'));
|
||||
|
||||
dom.content(snapBox, renderSnapTable(snapshots));
|
||||
var snapCard = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Config snapshots')),
|
||||
E('p', {}, _('Named snapshots of the whole config you can switch between atomically (commit-confirm protected).')),
|
||||
snapBox,
|
||||
E('div', { 'style': 'margin-top:.6em;display:flex;gap:.5em;align-items:center;flex-wrap:wrap' }, [nameInput, saveBtn])
|
||||
]);
|
||||
|
||||
// ---------- 2. Backup / Restore ----------
|
||||
var downloadBtn = E('button', { 'class': 'btn cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, function () {
|
||||
return callBackup().then(function (r) {
|
||||
if (!r || !r.data) throw _('Backup failed');
|
||||
var bin = atob(r.data), buf = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
var url = URL.createObjectURL(new Blob([buf], { type: 'application/gzip' }));
|
||||
var a = E('a', { 'href': url, 'download': r.filename || 'shater-backup.tar.gz' });
|
||||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
ui.addNotification(null, E('p', {}, _('Backup downloaded.')), 'info');
|
||||
}).catch(function (e) { ui.addNotification(null, E('p', {}, _('Backup error: ') + e), 'danger'); });
|
||||
}) }, _('Download backup'));
|
||||
var fileInput = E('input', { 'type': 'file', 'accept': '.tar.gz,application/gzip' });
|
||||
var restoreBtn = E('button', { 'class': 'btn cbi-button cbi-button-negative',
|
||||
'click': ui.createHandlerFn(this, function () {
|
||||
var f = fileInput.files && fileInput.files[0];
|
||||
if (!f) { ui.addNotification(null, E('p', {}, _('Choose a backup file first.')), 'warning'); return; }
|
||||
if (!confirm(_('Restore this backup? The current config is snapshotted as ".pre-restore" first.'))) return;
|
||||
return new Promise(function (resolve) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
var b64 = String(reader.result).replace(/^data:[^,]*,/, '');
|
||||
ui.showModal(_('Restoring…'), [E('p', { 'class': 'spinning' }, _('Validating and applying.'))]);
|
||||
callRestore(b64, confirmTimeout()).then(function (r) {
|
||||
ui.hideModal();
|
||||
if (r && r.ok === false) {
|
||||
ui.addNotification(null, E('p', {}, _('Restore failed (config kept).')), 'danger');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
ui.addNotification(null, E('p', {}, _('Restored. Other pages now reflect the restored config.')), 'info');
|
||||
refreshAll().then(resolve, resolve);
|
||||
}).catch(function (e) { ui.hideModal(); ui.addNotification(null, E('p', {}, _('Restore error: ') + e), 'danger'); resolve(); });
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
});
|
||||
}) }, _('Restore'));
|
||||
var backupCard = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Backup / Restore')),
|
||||
E('p', {}, _('A backup bundles /etc/config/shater and the subscription caches. It contains credentials — keep it private.')),
|
||||
E('div', { 'style': 'display:flex;gap:.75em;align-items:center;flex-wrap:wrap' }, [downloadBtn, E('span', { 'style': 'width:1em' }), fileInput, restoreBtn])
|
||||
]);
|
||||
|
||||
// ---------- 3. WAN-mode Profiles (Advanced) ----------
|
||||
if (adv) {
|
||||
m = new form.Map('shater', null,
|
||||
_('Conditional overrides that auto-switch routing by the active uplink, a ' +
|
||||
'connectivity probe, or a time window. The highest-priority profile whose ' +
|
||||
'conditions all hold becomes active and applies its overrides.'));
|
||||
var s = m.section(form.GridSection, 'profile', _('WAN-mode profiles'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add profile');
|
||||
|
||||
var o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'sim-mode';
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.default = '0';
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Value, 'priority', _('Priority'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '0';
|
||||
|
||||
// conditions
|
||||
o = s.option(form.DynamicList, 'match_iface', _('Active uplink is'),
|
||||
_('Profile active when the default route egresses via one of these interfaces (e.g. wwan0, usb0).'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'wwan0';
|
||||
|
||||
o = s.option(form.Value, 'probe_url', _('Connectivity probe URL'),
|
||||
_('Optional. Profile gated on whether this URL is reachable.'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'http://www.gstatic.com/generate_204';
|
||||
|
||||
o = s.option(form.ListValue, 'probe_mode', _('Probe condition'));
|
||||
o.value('up', _('active when probe is UP'));
|
||||
o.value('down', _('active when probe is DOWN (failover)'));
|
||||
o.default = 'up';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.MultiValue, 'sched_day', _('Days'));
|
||||
[['mon', 'Mon'], ['tue', 'Tue'], ['wed', 'Wed'], ['thu', 'Thu'],
|
||||
['fri', 'Fri'], ['sat', 'Sat'], ['sun', 'Sun']].forEach(function (d) { o.value(d[0], _(d[1])); });
|
||||
o.widget = 'checkbox';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'sched_start', _('Window start (HH:MM)'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '09:00';
|
||||
o.validate = function (sid, v) { return (!v || HHMM.test(v)) ? true : _('Use HH:MM'); };
|
||||
|
||||
o = s.option(form.Value, 'sched_end', _('Window end (HH:MM)'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '18:00';
|
||||
o.validate = function (sid, v) { return (!v || HHMM.test(v)) ? true : _('Use HH:MM'); };
|
||||
|
||||
o = s.option(form.Value, 'sched_tz', _('Timezone'));
|
||||
o.modalonly = true;
|
||||
o.optional = true;
|
||||
|
||||
// overrides
|
||||
o = s.option(form.DynamicList, 'enable_rule', _('Force-enable rules'),
|
||||
_('Rule names to enable while this profile is active.'));
|
||||
o.modalonly = true;
|
||||
o = s.option(form.DynamicList, 'disable_rule', _('Disable rules'));
|
||||
o.modalonly = true;
|
||||
o = s.option(form.Value, 'default_target', _('Override default target'),
|
||||
_('group:/node:/chain:/direct/block — becomes the catch-all while active.'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'group:ru-reality';
|
||||
o = s.option(form.Value, 'default_egress', _('Override default egress'));
|
||||
o.modalonly = true;
|
||||
}
|
||||
|
||||
dom.content(bannerBox, renderBanner(wan));
|
||||
|
||||
// ---------- 4. Compatibility (Advanced) ----------
|
||||
var compatCard = '';
|
||||
if (adv) {
|
||||
var feats = (compat.features || []).map(function (f) {
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, f.name), E('td', { 'class': 'td' }, f.required || '—'),
|
||||
E('td', { 'class': 'td' }, f.detected || '?'),
|
||||
E('td', { 'class': 'td' }, f.ok ? E('span', { 'class': 'sh-ok' }, '✓') : E('span', { 'class': 'sh-bad' }, '✗'))
|
||||
]);
|
||||
});
|
||||
compatCard = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('xray compatibility')),
|
||||
E('p', {}, [_('Detected xray: '), E('strong', {}, compat.xray_version || _('unknown')), ' — ',
|
||||
compat.ok ? E('span', { 'class': 'sh-ok' }, _('all used features supported')) : E('span', { 'class': 'sh-bad' }, _('some features need a newer xray'))]),
|
||||
feats.length ? E('table', { 'class': 'table cbi-section-table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [E('th', { 'class': 'th' }, _('Feature')), E('th', { 'class': 'th' }, _('Requires')), E('th', { 'class': 'th' }, _('Detected')), E('th', { 'class': 'th' }, _('OK'))])
|
||||
].concat(feats)) : E('p', {}, E('em', {}, _('No version-sensitive features in use.')))
|
||||
]);
|
||||
}
|
||||
|
||||
var header = uimode.header(_('Backup / Profiles'),
|
||||
_('Save and restore your whole setup: quick named snapshots you can switch between, ' +
|
||||
'and a downloadable backup file for reinstalls or a second router.'));
|
||||
|
||||
var assemble = function (wanFormNode) {
|
||||
if (wanFormNode)
|
||||
dom.content(wanFormBox, wanFormNode);
|
||||
return E('div', {}, [
|
||||
header,
|
||||
adv ? bannerBox : '',
|
||||
snapCard,
|
||||
backupCard,
|
||||
adv ? wanFormBox : '',
|
||||
compatCard,
|
||||
adv ? '' : uimode.advHint(_('WAN-mode profiles (auto-switching by uplink/schedule) and the xray compatibility report are available in Advanced mode.'))
|
||||
]);
|
||||
};
|
||||
|
||||
if (m)
|
||||
return m.render().then(assemble);
|
||||
return assemble(null);
|
||||
}
|
||||
});
|
||||
@@ -1,342 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require dom';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// The three built-in packs (must match xrayctl preset.go). We seed missing
|
||||
// `config preset` sections into the staged uci state so the toggles are always
|
||||
// visible; they only persist if the user saves.
|
||||
var PRESET_PACKS = [ 'block-ads', 'ru-bypass', 'private' ];
|
||||
|
||||
function ensurePresetSections() {
|
||||
var have = {};
|
||||
(uci.sections('shater', 'preset') || []).forEach(function(s) {
|
||||
if (s.name) have[String(s.name).replace(/_/g, '-')] = true;
|
||||
});
|
||||
PRESET_PACKS.forEach(function(p) {
|
||||
if (!have[p]) {
|
||||
var sid = uci.add('shater', 'preset');
|
||||
uci.set('shater', sid, 'name', p);
|
||||
uci.set('shater', sid, 'enabled', '0');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// renderPresets: a toggle panel for the built-in curated rule packs (config
|
||||
// preset). They layer above the user's rules (first-match by order) and degrade
|
||||
// gracefully without geodata (geo-only packs are simply skipped).
|
||||
function renderPresets() {
|
||||
var pm = new form.Map('shater', null);
|
||||
var ps = pm.section(form.TableSection, 'preset', _('Preset packs'),
|
||||
_('One-tap building blocks, checked before your own rules: "block-ads" blocks ad ' +
|
||||
'domains, "ru-bypass" sends Russian sites directly (not through the VPN), "private" ' +
|
||||
'keeps local/LAN addresses direct. block-ads / ru-bypass need the geo data files ' +
|
||||
'(Lists page) and are skipped while those are absent.'));
|
||||
ps.anonymous = false;
|
||||
ps.addremove = false;
|
||||
ps.sortable = false;
|
||||
|
||||
var f = ps.option(form.Flag, 'enabled', _('On'));
|
||||
f.editable = true;
|
||||
ps.option(form.DummyValue, 'name', _('Pack'));
|
||||
|
||||
var ord = ps.option(form.Value, 'order', _('Order'));
|
||||
ord.modalonly = true;
|
||||
ord.datatype = 'uinteger';
|
||||
var tgt = ps.option(form.Value, 'target', _('Target override'));
|
||||
tgt.modalonly = true;
|
||||
tgt.placeholder = 'direct | block | group:x';
|
||||
return pm.render();
|
||||
}
|
||||
|
||||
// explain: which rule/target/egress/exit a given src->dst would take.
|
||||
var callExplain = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'explain',
|
||||
params: [ 'src', 'dst', 'proto' ],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
// Reference names of a UCI type as "prefix:name".
|
||||
function prefixed(type, prefix) {
|
||||
return uci.sections('shater', type)
|
||||
.map(function(sec) { return sec.name; })
|
||||
.filter(function(n) { return n != null && n !== ''; })
|
||||
.map(function(n) { return prefix + ':' + n; });
|
||||
}
|
||||
function nameList(type) {
|
||||
return uci.sections('shater', type)
|
||||
.map(function(sec) { return sec.name; })
|
||||
.filter(function(n) { return n != null && n !== ''; });
|
||||
}
|
||||
|
||||
function renderExplain() {
|
||||
var srcIn = E('input', {
|
||||
'type': 'text', 'class': 'cbi-input-text',
|
||||
'placeholder': '192.168.11.14', 'style': 'margin-right:.4em'
|
||||
});
|
||||
var dstIn = E('input', {
|
||||
'type': 'text', 'class': 'cbi-input-text',
|
||||
'placeholder': 'youtube.com', 'style': 'margin-right:.4em'
|
||||
});
|
||||
var protoIn = E('select', { 'class': 'cbi-input-select', 'style': 'margin-right:.4em' }, [
|
||||
E('option', { 'value': '' }, [ _('any proto') ]),
|
||||
E('option', { 'value': 'tcp' }, [ 'tcp' ]),
|
||||
E('option', { 'value': 'udp' }, [ 'udp' ])
|
||||
]);
|
||||
var out = E('pre', {
|
||||
'style': 'margin-top:.6em;padding:.6em;min-height:2em;' +
|
||||
'border:1px solid rgba(128,128,128,.35);border-radius:4px;white-space:pre-wrap'
|
||||
}, [ _('Enter a source and destination, then press Explain.') ]);
|
||||
|
||||
var btn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
var src = srcIn.value || '';
|
||||
var dst = dstIn.value || '';
|
||||
if (!src || !dst) {
|
||||
out.textContent = _('Both source and destination are required.');
|
||||
return;
|
||||
}
|
||||
out.textContent = _('Explaining…');
|
||||
return callExplain(src, dst, protoIn.value || '').then(function(res) {
|
||||
if (res && res.error)
|
||||
out.textContent = _('Error: ') + (res.error || res.raw);
|
||||
else
|
||||
out.textContent = JSON.stringify(res, null, 2);
|
||||
});
|
||||
})
|
||||
}, [ _('Explain') ]);
|
||||
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, [ _('Explain / trace') ]),
|
||||
E('p', {}, [ _('Simulate routing for a source and destination without applying anything.') ]),
|
||||
E('div', { 'style': 'display:flex;flex-wrap:wrap;align-items:center' }, [
|
||||
srcIn, dstIn, protoIn, btn
|
||||
]),
|
||||
out
|
||||
]);
|
||||
}
|
||||
|
||||
// Convenience: add a device->target rule from a couple of inputs.
|
||||
function renderPolicyHelper(rerender) {
|
||||
var targets = prefixed('chain', 'chain')
|
||||
.concat(prefixed('group', 'group'))
|
||||
.concat(prefixed('node', 'node'))
|
||||
.concat([ 'direct', 'block' ]);
|
||||
|
||||
var devIn = E('input', {
|
||||
'type': 'text', 'class': 'cbi-input-text',
|
||||
'placeholder': '192.168.11.14/32 or AA:BB:CC:DD:EE:FF',
|
||||
'style': 'margin-right:.4em;min-width:16em'
|
||||
});
|
||||
var tgtSel = E('select', { 'class': 'cbi-input-select', 'style': 'margin-right:.4em' },
|
||||
targets.map(function(t) { return E('option', { 'value': t }, [ t ]); }));
|
||||
|
||||
var addBtn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-apply',
|
||||
'click': function() {
|
||||
var dev = (devIn.value || '').trim();
|
||||
var tgt = tgtSel.value || '';
|
||||
if (!dev) {
|
||||
ui.addNotification(null, E('p', {}, _('Enter a device IP/CIDR or MAC.')), 'warning');
|
||||
return;
|
||||
}
|
||||
if (!tgt) {
|
||||
ui.addNotification(null, E('p', {}, _('Pick a target (define groups/chains first).')), 'warning');
|
||||
return;
|
||||
}
|
||||
var sid = uci.add('shater', 'rule');
|
||||
uci.set('shater', sid, 'name', 'policy-' + dev.replace(/[^a-zA-Z0-9]+/g, '-'));
|
||||
uci.set('shater', sid, 'enabled', '1');
|
||||
uci.set('shater', sid, 'src', [ dev ]);
|
||||
uci.set('shater', sid, 'target', tgt);
|
||||
devIn.value = '';
|
||||
ui.addNotification(null, E('p', {},
|
||||
_('Rule added below — review, then Save & Apply.')), 'info');
|
||||
rerender();
|
||||
}
|
||||
}, [ _('Add policy') ]);
|
||||
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, [ _('Per-client policy') ]),
|
||||
E('p', {}, [ _('Quickly send one device (by IP/CIDR or MAC) to a target. ' +
|
||||
'Creates a rule below; Save & Apply to activate.') ]),
|
||||
E('div', { 'style': 'display:flex;flex-wrap:wrap;align-items:center' }, [
|
||||
E('span', { 'style': 'margin-right:.4em' }, [ _('Device') ]),
|
||||
devIn,
|
||||
E('span', { 'style': 'margin-right:.4em' }, [ '→' ]),
|
||||
tgtSel,
|
||||
addBtn
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return uci.load('shater');
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var m, s, o;
|
||||
var adv = uimode.isAdvanced();
|
||||
|
||||
ensurePresetSections();
|
||||
|
||||
var targets = prefixed('chain', 'chain')
|
||||
.concat(prefixed('group', 'group'))
|
||||
.concat(prefixed('node', 'node'));
|
||||
var egresses = nameList('egress');
|
||||
var rulesets = nameList('ruleset');
|
||||
|
||||
m = new form.Map('shater', null,
|
||||
_('Ordered routing rules (first match wins). Complex match lists are edited in the row dialog.'));
|
||||
|
||||
s = m.section(form.GridSection, 'rule', _('Rules'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.sortable = true;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add rule');
|
||||
|
||||
o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'pc-triple';
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.default = '1';
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Value, 'order', _('Order'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '10';
|
||||
|
||||
o = s.option(form.DynamicList, 'src', _('Source'),
|
||||
_('cidr | host/32 | mac | iface:<name> | zone:<name>'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '192.168.11.14/32';
|
||||
|
||||
o = s.option(form.DynamicList, 'dst_domain', _('Dest domain'),
|
||||
_('domain | full:host | keyword:kw | regexp:re | geosite:x'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'geosite:telegram';
|
||||
|
||||
o = s.option(form.DynamicList, 'dst_ruleset', _('Dest ruleset'),
|
||||
_('References to ruleset sections (domain or ip).'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'ru-bypass';
|
||||
rulesets.forEach(function(n) { o.value(n, n); });
|
||||
|
||||
o = s.option(form.DynamicList, 'dst_ip', _('Dest IP'),
|
||||
_('cidr | geoip:x'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '1.2.3.0/24';
|
||||
|
||||
o = s.option(form.Value, 'dst_port', _('Dest port'),
|
||||
_('port | range (1000-2000) | comma list (80,443)'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '443';
|
||||
|
||||
o = s.option(form.ListValue, 'proto', _('Proto'));
|
||||
o.value('', _('any'));
|
||||
o.value('tcp', 'tcp');
|
||||
o.value('udp', 'udp');
|
||||
o.value('tcp,udp', 'tcp,udp');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'target', _('Target'),
|
||||
_('chain:<n> | group:<n> | node:<n> | direct | block'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'chain:triple';
|
||||
targets.forEach(function(t) { o.value(t, t); });
|
||||
o.value('direct', 'direct');
|
||||
o.value('block', 'block');
|
||||
|
||||
if (adv) {
|
||||
o = s.option(form.ListValue, 'egress', _('Egress'),
|
||||
_('Optional exit binding by name (see Routing targets → Exits).'));
|
||||
o.optional = true;
|
||||
o.modalonly = true;
|
||||
o.value('', _('-- none --'));
|
||||
egresses.forEach(function(n) { o.value(n, n); });
|
||||
|
||||
o = s.option(form.ListValue, 'kill', _('Kill switch'));
|
||||
o.value('default', _('Default (global)'));
|
||||
o.value('closed', _('Fail closed (block)'));
|
||||
o.value('open', _('Fail open (direct)'));
|
||||
o.default = 'default';
|
||||
o.modalonly = true;
|
||||
|
||||
// --- schedule (T3): only apply this rule during the window ---
|
||||
o = s.option(form.Flag, 'sched_enabled', _('Schedule'),
|
||||
_('Only apply this rule during the window below.'));
|
||||
o.default = '0';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.MultiValue, 'sched_day', _('Days'));
|
||||
[['mon', 'Mon'], ['tue', 'Tue'], ['wed', 'Wed'], ['thu', 'Thu'],
|
||||
['fri', 'Fri'], ['sat', 'Sat'], ['sun', 'Sun']].forEach(function (d) {
|
||||
o.value(d[0], _(d[1]));
|
||||
});
|
||||
o.widget = 'checkbox';
|
||||
o.modalonly = true;
|
||||
o.depends('sched_enabled', '1');
|
||||
o.description = _('Empty = every day.');
|
||||
|
||||
o = s.option(form.Value, 'sched_start', _('Start (HH:MM)'));
|
||||
o.placeholder = '22:00';
|
||||
o.modalonly = true;
|
||||
o.depends('sched_enabled', '1');
|
||||
o.validate = function (sid, v) {
|
||||
return (!v || /^([01]?\d|2[0-3]):[0-5]\d$/.test(v)) ? true : _('Use HH:MM');
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'sched_end', _('End (HH:MM)'),
|
||||
_('Wraps past midnight if earlier than Start (e.g. 22:00–06:00). ' +
|
||||
'Empty/equal = all day on the selected days.'));
|
||||
o.placeholder = '06:00';
|
||||
o.modalonly = true;
|
||||
o.depends('sched_enabled', '1');
|
||||
o.validate = function (sid, v) {
|
||||
return (!v || /^([01]?\d|2[0-3]):[0-5]\d$/.test(v)) ? true : _('Use HH:MM');
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'sched_tz', _('Timezone'),
|
||||
_('Optional IANA name (e.g. Europe/Moscow). Default: router timezone.'));
|
||||
o.optional = true;
|
||||
o.modalonly = true;
|
||||
o.depends('sched_enabled', '1');
|
||||
}
|
||||
|
||||
// Wrap the map so the policy helper can re-render it after adding a rule.
|
||||
var mapContainer = E('div', {});
|
||||
|
||||
function rerender() {
|
||||
return m.render().then(function(node) {
|
||||
dom.content(mapContainer, node);
|
||||
}).catch(function() {
|
||||
// Re-render is best-effort; the uci change is already staged and
|
||||
// will show after Save/refresh regardless.
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.all([m.render(), renderPresets()]).then(function(parts) {
|
||||
dom.content(mapContainer, parts[0]);
|
||||
return E('div', {}, [
|
||||
uimode.header(_('Rules'),
|
||||
_('Rules decide which traffic goes where: through the VPN, directly, or blocked. ' +
|
||||
'The preset packs at the top cover the common cases with one tap; add your own ' +
|
||||
'rules below for anything else. Without at least one rule nothing is proxied.')),
|
||||
parts[1],
|
||||
renderPolicyHelper(rerender),
|
||||
mapContainer,
|
||||
renderExplain(),
|
||||
adv ? '' : uimode.advHint(_('Per-rule exits, kill-switch overrides and time schedules are available in Advanced mode.'))
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// Global settings + inbounds (multi-LAN tproxy), backed by /etc/config/shater.
|
||||
// DNS resolvers / rules moved to the dedicated DNS page.
|
||||
// Save & Apply commits UCI; use Overview -> Apply to render+apply via xrayctl.
|
||||
return view.extend({
|
||||
render: function() {
|
||||
var m, s, o;
|
||||
var adv = uimode.isAdvanced();
|
||||
|
||||
m = new form.Map('shater', null,
|
||||
_('Saving commits /etc/config/shater; push it to the running engine with ' +
|
||||
'"Apply configuration" on the Overview page.'));
|
||||
|
||||
// ---------------- globals ----------------
|
||||
s = m.section(form.NamedSection, 'globals', 'globals', _('Global'));
|
||||
s.addremove = false;
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.default = '1';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.option(form.ListValue, 'loglevel', _('Log level'));
|
||||
o.value('none', _('None'));
|
||||
o.value('error', _('Error'));
|
||||
o.value('warning', _('Warning'));
|
||||
o.value('info', _('Info'));
|
||||
o.value('debug', _('Debug'));
|
||||
o.default = 'warning';
|
||||
|
||||
o = s.option(form.ListValue, 'kill_switch', _('Kill switch (default)'),
|
||||
_('Global fail policy when no route is available.'));
|
||||
o.value('closed', _('Fail closed (block)'));
|
||||
o.value('open', _('Fail open (direct)'));
|
||||
o.default = 'closed';
|
||||
|
||||
o = s.option(form.Flag, 'ipv6', _('IPv6'));
|
||||
o.default = '1';
|
||||
|
||||
if (adv) {
|
||||
o = s.option(form.Value, 'fwmark_base', _('fwmark base'),
|
||||
_('Reserved fwmark range base (hex). Only change on collision with another app.'));
|
||||
o.default = '0x2000';
|
||||
o.placeholder = '0x2000';
|
||||
|
||||
o = s.option(form.Value, 'table_base', _('Routing table base'),
|
||||
_('Base id for our routing tables. Only change on collision.'));
|
||||
o.default = '0x2000';
|
||||
o.placeholder = '0x2000';
|
||||
|
||||
o = s.option(form.Value, 'confirm_timeout', _('Confirm timeout (s)'),
|
||||
_('commit-confirm auto-rollback window; 0 disables.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '0';
|
||||
|
||||
// ---- node health checks / Observatory (node "alive"/latency in Overview & Nodes) ----
|
||||
o = s.option(form.Value, 'probe_url', _('Health-check URL'),
|
||||
_('Node health checks (Observatory) fetch this URL THROUGH each node every ' +
|
||||
'interval; a success marks the node alive and measures its latency. Use a ' +
|
||||
'tiny 204 endpoint. A group can override this. (On-demand Node "Test" is separate.)'));
|
||||
o.default = 'http://www.gstatic.com/generate_204';
|
||||
o.placeholder = 'http://www.gstatic.com/generate_204';
|
||||
|
||||
o = s.option(form.Value, 'probe_interval', _('Health-check interval'),
|
||||
_('How often each node is re-probed (e.g. 30s, 60s, 5m).'));
|
||||
o.default = '60s';
|
||||
o.placeholder = '60s';
|
||||
}
|
||||
|
||||
// ---------------- inbounds (tproxy + local socks/http/dokodemo) ----------------
|
||||
s = m.section(form.GridSection, 'inbound', _('Inbounds'),
|
||||
_('Transparent TPROXY LAN interception, plus optional local SOCKS/HTTP ' +
|
||||
'proxies and a dokodemo wrap listener.'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add inbound');
|
||||
|
||||
o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'lan';
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.default = '1';
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.ListValue, 'type', _('Type'));
|
||||
o.value('tproxy', _('Transparent (TPROXY)'));
|
||||
o.value('socks', _('Local SOCKS'));
|
||||
o.value('http', _('Local HTTP'));
|
||||
o.value('dokodemo', _('Dokodemo (wrap)'));
|
||||
o.default = 'tproxy';
|
||||
|
||||
// tproxy fields
|
||||
o = s.option(form.Value, 'network', _('LAN network(s)'),
|
||||
_('UCI interface name(s) of the LAN(s) to intercept.'));
|
||||
o.placeholder = 'lan';
|
||||
o.depends('type', 'tproxy');
|
||||
o.depends('type', '');
|
||||
|
||||
o = s.option(form.Value, 'tproxy_port', _('TPROXY port'));
|
||||
o.datatype = 'port';
|
||||
o.default = '12345';
|
||||
o.depends('type', 'tproxy');
|
||||
o.depends('type', '');
|
||||
|
||||
// local socks/http/dokodemo listener
|
||||
o = s.option(form.Value, 'listen', _('Listen address'),
|
||||
_('127.0.0.1 = router/apps only (recommended). A LAN IP / 0.0.0.0 exposes it to LAN.'));
|
||||
o.default = '127.0.0.1';
|
||||
o.depends('type', 'socks');
|
||||
o.depends('type', 'http');
|
||||
o.depends('type', 'dokodemo');
|
||||
|
||||
o = s.option(form.Value, 'port', _('Listen port'));
|
||||
o.datatype = 'port';
|
||||
o.depends('type', 'socks');
|
||||
o.depends('type', 'http');
|
||||
o.depends('type', 'dokodemo');
|
||||
|
||||
// socks/http auth
|
||||
o = s.option(form.ListValue, 'auth', _('Auth'));
|
||||
o.value('noauth', _('None'));
|
||||
o.value('password', _('Username/password'));
|
||||
o.default = 'noauth';
|
||||
o.depends('type', 'socks');
|
||||
o.depends('type', 'http');
|
||||
|
||||
o = s.option(form.Value, 'user', _('Username'));
|
||||
o.depends('auth', 'password');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'pass', _('Password'));
|
||||
o.depends('auth', 'password');
|
||||
o.password = true;
|
||||
o.modalonly = true;
|
||||
|
||||
// dokodemo target
|
||||
o = s.option(form.Value, 'target_addr', _('Wrap target address'));
|
||||
o.depends('type', 'dokodemo');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'target_port', _('Wrap target port'));
|
||||
o.datatype = 'port';
|
||||
o.depends('type', 'dokodemo');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'target_network', _('Wrap network'));
|
||||
o.value('udp');
|
||||
o.value('tcp');
|
||||
o.value('tcp,udp');
|
||||
o.default = 'udp';
|
||||
o.depends('type', 'dokodemo');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tcp', _('TCP'));
|
||||
o.default = '1';
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Flag, 'udp', _('UDP'));
|
||||
o.default = '1';
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Flag, 'sniff', _('Sniff'),
|
||||
_('Recover SNI/Host/QUIC for domain rules.'));
|
||||
o.default = '1';
|
||||
o.editable = true;
|
||||
|
||||
// DNS resolvers and DNS rules now live on the dedicated DNS page.
|
||||
|
||||
return m.render().then(function(mapEl) {
|
||||
return E('div', {}, [
|
||||
uimode.header(_('Settings'),
|
||||
_('The engine master switch and which LAN(s) get intercepted. A typical setup ' +
|
||||
'needs "Enabled" on and one enabled LAN inbound — the quick-start wizard on ' +
|
||||
'the Overview page sets both for you.')),
|
||||
mapEl,
|
||||
adv ? '' : uimode.advHint(_('fwmark/routing-table bases, commit-confirm and node health-check tuning are available in Advanced mode.'))
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require dom';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// sub_update accepts an optional subscription name (empty => update all).
|
||||
var callSubUpdate = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'sub_update',
|
||||
params: [ 'name' ],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
// Per-subscription quota/expiry (parsed subscription-userinfo header).
|
||||
var callSubInfo = rpc.declare({
|
||||
object: 'xray',
|
||||
method: 'sub_info',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
function updateNotify(r) {
|
||||
var ok = !r || r.ok !== false;
|
||||
ui.addNotification(null, E('p', {}, ok ? _('Subscription update triggered') : _('Update failed')),
|
||||
ok ? 'info' : 'warning');
|
||||
}
|
||||
|
||||
function fmtGB(v) {
|
||||
v = v || 0;
|
||||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB';
|
||||
if (v >= 1048576) return (v / 1048576).toFixed(0) + ' MB';
|
||||
return Math.round(v / 1024) + ' KB';
|
||||
}
|
||||
|
||||
// Quota/expiry cell for one subscription (from sub_info).
|
||||
function renderInfoCell(inf) {
|
||||
if (!inf)
|
||||
return E('span', { 'class': 'sh-muted' }, [ _('no data yet') ]);
|
||||
var kids = [];
|
||||
if (inf.total > 0) {
|
||||
var frac = Math.min(1, (inf.upload + inf.download) / inf.total);
|
||||
var kind = inf.exhausted ? 'bad' : (inf.pct_used >= 90 ? 'warn' : '');
|
||||
kids.push(E('div', { 'class': 'txt' },
|
||||
[ fmtGB(inf.upload + inf.download) + ' / ' + fmtGB(inf.total) +
|
||||
' (' + Math.round(inf.pct_used) + '%)' ]));
|
||||
kids.push(uimode.bar(frac, kind));
|
||||
}
|
||||
if (inf.expire > 0) {
|
||||
var d = new Date(inf.expire * 1000);
|
||||
var badgeKind = inf.days_left <= 1 ? 'bad' : (inf.expiring ? 'warn' : '');
|
||||
var label = inf.days_left >= 0
|
||||
? _('expires %s (%d d)').format(d.toLocaleDateString(), inf.days_left)
|
||||
: _('expired');
|
||||
kids.push(E('div', { 'style': 'margin-top:.2em' }, [
|
||||
uimode.badge(label, badgeKind || 'ok'),
|
||||
inf.expiring ? E('span', { 'style': 'margin-left:.3em' }, [ uimode.badge(_('expiring soon'), 'warn') ]) : ''
|
||||
]));
|
||||
}
|
||||
if (inf.exhausted)
|
||||
kids.push(E('div', { 'style': 'margin-top:.2em' }, [ uimode.badge(_('quota exhausted'), 'bad') ]));
|
||||
if (!kids.length)
|
||||
return E('span', { 'class': 'sh-muted' }, [ '-' ]);
|
||||
return E('div', { 'class': 'sh-quota' }, kids);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
uci.load('shater'),
|
||||
L.resolveDefault(callSubInfo(), {})
|
||||
]);
|
||||
},
|
||||
|
||||
// Snapshot of name -> url at load time, to detect new/changed subscriptions
|
||||
// on save and auto-trigger their first fetch (no separate "Update now" needed).
|
||||
snapshotSubs: function() {
|
||||
var snap = {};
|
||||
(uci.sections('shater', 'subscription') || []).forEach(function(s) {
|
||||
if (s.name)
|
||||
snap[s.name] = s.url || '';
|
||||
});
|
||||
return snap;
|
||||
},
|
||||
|
||||
// Hook the save flow (covers both Save and Save & Apply): after a successful
|
||||
// save, immediately fetch any subscription that is new or whose URL changed —
|
||||
// no separate "Update now" click needed. Runs in the background so a slow
|
||||
// provider endpoint never stalls the save/apply pipeline.
|
||||
handleSave: function(ev) {
|
||||
var self = this;
|
||||
return this.super('handleSave', [ev]).then(function(res) {
|
||||
var cur = self.snapshotSubs();
|
||||
var changed = Object.keys(cur).filter(function(n) {
|
||||
return !(n in self._subSnap) || self._subSnap[n] !== cur[n];
|
||||
});
|
||||
self._subSnap = cur;
|
||||
if (changed.length) {
|
||||
Promise.all(changed.map(function(n) {
|
||||
return L.resolveDefault(callSubUpdate(n), { ok: false });
|
||||
})).then(function(results) {
|
||||
var ok = results.filter(function(r) { return !r || r.ok !== false; }).length;
|
||||
ui.addNotification(null, E('p', {},
|
||||
_('Fetched %d/%d new/changed subscription(s). Nodes appear on the Nodes page.')
|
||||
.format(ok, changed.length)),
|
||||
ok === changed.length ? 'info' : 'warning');
|
||||
});
|
||||
}
|
||||
return res;
|
||||
});
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var adv = uimode.isAdvanced();
|
||||
var subinfoArr = (data[1] && data[1].subs) || [];
|
||||
var subinfo = {};
|
||||
subinfoArr.forEach(function(s) { subinfo[s.name] = s; });
|
||||
|
||||
this._subSnap = this.snapshotSubs();
|
||||
|
||||
var m, s, o;
|
||||
|
||||
m = new form.Map('shater', null);
|
||||
|
||||
s = m.section(form.GridSection, 'subscription', _('Subscriptions'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.sortable = false;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add subscription');
|
||||
|
||||
o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'main';
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.default = '1';
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Value, 'url', _('URL'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'https://example.com/sub/...';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'update_interval', _('Update interval'),
|
||||
_('How often the node list is refreshed automatically, e.g. 30m, 6h, 24h.'));
|
||||
o.default = '6h';
|
||||
|
||||
o = s.option(form.ListValue, 'fetch_via', _('Fetch via'),
|
||||
_('Fetch directly or route the fetch through the proxy (for blocked hosts).'));
|
||||
o.value('direct', _('Direct'));
|
||||
o.value('proxy', _('Proxy'));
|
||||
o.default = 'direct';
|
||||
|
||||
// Quota / expiry from the subscription-userinfo header (read-only).
|
||||
o = s.option(form.DummyValue, '_info', _('Quota / expiry'));
|
||||
o.modalonly = false;
|
||||
o.textvalue = function(section_id) {
|
||||
var name = uci.get('shater', section_id, 'name');
|
||||
return renderInfoCell(subinfo[name]);
|
||||
};
|
||||
o.cfgvalue = function(section_id) {
|
||||
var name = uci.get('shater', section_id, 'name');
|
||||
return renderInfoCell(subinfo[name]);
|
||||
};
|
||||
|
||||
// --- format + filters (modal only; expert) ---
|
||||
if (adv) {
|
||||
o = s.option(form.ListValue, 'format', _('Format'));
|
||||
o.value('auto', _('Auto-detect'));
|
||||
o.value('links', _('Share-links / base64'));
|
||||
o.value('clash', _('Clash / Mihomo YAML'));
|
||||
o.value('xray', _('Xray JSON'));
|
||||
o.value('singbox', _('sing-box JSON'));
|
||||
o.default = 'auto';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'include', _('Include (name regex)'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '(?i)premium';
|
||||
|
||||
o = s.option(form.DynamicList, 'exclude', _('Exclude (name regex)'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '(?i)expire|traffic';
|
||||
|
||||
o = s.option(form.DynamicList, 'filter_proto', _('Protocols'),
|
||||
_('Keep only these protocols (empty = all).'));
|
||||
o.modalonly = true;
|
||||
o.value('vless'); o.value('vmess'); o.value('trojan'); o.value('ss');
|
||||
|
||||
o = s.option(form.DynamicList, 'filter_country', _('Countries'),
|
||||
_('ISO code (from a flag emoji in the node name); prefix ! to exclude.'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'NL or !RU';
|
||||
|
||||
o = s.option(form.Flag, 'dedup', _('De-duplicate'));
|
||||
o.modalonly = true;
|
||||
o.default = '1';
|
||||
|
||||
o = s.option(form.Value, 'expire_alert_days', _('Expiry alert (days)'),
|
||||
_('Warn when the subscription expires within N days (0 = off).'));
|
||||
o.modalonly = true;
|
||||
o.datatype = 'uinteger';
|
||||
o.default = '3';
|
||||
|
||||
// --- HAPP emulation (modal only; expert) ---
|
||||
o = s.option(form.Value, 'ua', _('User-Agent'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'Happ/3.13.0';
|
||||
|
||||
o = s.option(form.Value, 'hwid', _('Hardware ID (x-hwid)'),
|
||||
_('"auto" auto-generates and remembers a HWID; or set a fixed value.'));
|
||||
o.modalonly = true;
|
||||
o.default = 'auto';
|
||||
o.placeholder = 'auto';
|
||||
|
||||
o = s.option(form.Value, 'device_os', _('Device OS (x-device-os)'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'Android';
|
||||
|
||||
o = s.option(form.Value, 'ver_os', _('OS version (x-ver-os)'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '14';
|
||||
|
||||
o = s.option(form.Value, 'device_model', _('Device model (x-device-model)'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'SM-G998B';
|
||||
|
||||
o = s.option(form.DynamicList, 'header', _('Extra headers'),
|
||||
_('Custom request headers as "Name: value".'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'x-key: value';
|
||||
}
|
||||
|
||||
// Per-row "Update now" button.
|
||||
o = s.option(form.Button, '_update', _('Refresh'));
|
||||
o.inputtitle = _('Update now');
|
||||
o.inputstyle = 'apply';
|
||||
o.modalonly = false;
|
||||
o.onclick = function(ev, section_id) {
|
||||
var name = uci.get('shater', section_id, 'name') || '';
|
||||
return callSubUpdate(name).then(updateNotify);
|
||||
};
|
||||
|
||||
return m.render().then(function(mapEl) {
|
||||
var updateAll = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action important',
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
return callSubUpdate('').then(updateNotify);
|
||||
})
|
||||
}, [ _('Update all subscriptions now') ]);
|
||||
|
||||
return E('div', { 'class': 'sh-wrap' }, [
|
||||
uimode.header(_('Subscriptions'),
|
||||
_('A subscription is the link your VPN provider gave you — it delivers the server ' +
|
||||
'list. New or changed subscriptions are fetched automatically when you save.')),
|
||||
E('div', { 'class': 'cbi-section', 'style': 'margin-bottom:.5em' }, [ updateAll ]),
|
||||
mapEl,
|
||||
adv ? '' : uimode.advHint(_('Format overrides, node filters and HAPP header emulation are available in Advanced mode.'))
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require uci';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require network';
|
||||
'require shater.uimode as uimode';
|
||||
|
||||
// Routing targets — everything a rule can send traffic to, in one place:
|
||||
// 1. Groups (primary): balancers over subscription/manual node sets.
|
||||
// 2. Multi-hop (Chains) — Advanced only: ordered L1..Ln hops.
|
||||
// 3. Exits (Egress) — Advanced only: bind flows to an interface/tunnel/proxy.
|
||||
// This view replaces the former separate Groups / Chains / Egress pages.
|
||||
|
||||
// End-to-end HTTP probe of a whole chain (all hops) -> reachable/latency/exit-IP.
|
||||
var callChainTest = rpc.declare({
|
||||
object: 'xray', method: 'chain_test',
|
||||
params: [ 'name', 'url', 'http_method' ], expect: { '': {} }
|
||||
});
|
||||
|
||||
function sectionNames(type) {
|
||||
return uci.sections('shater', type)
|
||||
.map(function(sec) { return sec.name; })
|
||||
.filter(function(n) { return n != null && n !== ''; });
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
uci.load('shater'),
|
||||
L.resolveDefault(network.getNetworks(), [])
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var adv = uimode.isAdvanced();
|
||||
var m, s, o;
|
||||
var nets = (data && data[1]) || [];
|
||||
|
||||
var subNames = sectionNames('subscription');
|
||||
var groupNames = sectionNames('group');
|
||||
var nodeNames = sectionNames('node');
|
||||
var chainNames = sectionNames('chain');
|
||||
|
||||
m = new form.Map('shater', null);
|
||||
|
||||
// ---------------- 1. Groups (always visible) ----------------
|
||||
s = m.section(form.GridSection, 'group', _('Groups'),
|
||||
_('A group bundles the servers of a subscription (or a hand-picked list) and ' +
|
||||
'automatically picks one to use — e.g. the fastest. Point your rules at ' +
|
||||
'"group:<name>". This is all a typical setup needs.'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.sortable = false;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add group');
|
||||
|
||||
o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'main';
|
||||
|
||||
o = s.option(form.ListValue, 'source', _('Source'));
|
||||
o.value('subscription', _('Subscription'));
|
||||
o.value('manual', _('Manual node list'));
|
||||
o.default = 'subscription';
|
||||
|
||||
o = s.option(form.ListValue, 'subscription', _('Subscription'),
|
||||
_('Which subscription supplies the nodes.'));
|
||||
o.depends('source', 'subscription');
|
||||
o.modalonly = true;
|
||||
if (subNames.length) {
|
||||
subNames.forEach(function(n) { o.value(n, n); });
|
||||
} else {
|
||||
o.value('', _('-- no subscriptions defined --'));
|
||||
}
|
||||
|
||||
o = s.option(form.DynamicList, 'node', _('Nodes (manual)'),
|
||||
_('Manual node names (references config node entries).'));
|
||||
o.depends('source', 'manual');
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'reality-nl';
|
||||
nodeNames.forEach(function(n) { o.value(n, uimode.flagged(n)); });
|
||||
|
||||
o = s.option(form.ListValue, 'strategy', _('Strategy'),
|
||||
_('How the active server is chosen. "Least ping" (fastest) is right for most people.'));
|
||||
o.value('leastping', _('Least ping (fastest)'));
|
||||
o.value('random', _('Random'));
|
||||
o.value('roundrobin', _('Round robin'));
|
||||
o.value('failover', _('Failover (priority order)'));
|
||||
o.value('single', _('Single (first alive, pinned)'));
|
||||
o.default = 'leastping';
|
||||
|
||||
if (adv) {
|
||||
o = s.option(form.DynamicList, 'include', _('Include (regex)'),
|
||||
_('Keep only nodes whose name matches one of these regexes.'));
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'exclude', _('Exclude (regex)'),
|
||||
_('Drop nodes whose name matches one of these regexes.'));
|
||||
o.modalonly = true;
|
||||
|
||||
// Observatory (node health check) tuning — expert territory.
|
||||
o = s.option(form.Value, 'probe_url', _('Health-check URL'),
|
||||
_('Node health checks (Observatory) probe URL override for this group.'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'http://www.gstatic.com/generate_204';
|
||||
|
||||
o = s.option(form.Value, 'probe_interval', _('Health-check interval'));
|
||||
o.modalonly = true;
|
||||
o.placeholder = '60s';
|
||||
}
|
||||
|
||||
// ---------------- 2. Multi-hop (Chains) — Advanced ----------------
|
||||
if (adv) {
|
||||
s = m.section(form.GridSection, 'chain', _('Multi-hop (Chains)'),
|
||||
_('Route traffic through several servers in a row (L1 → L2 → …) for extra ' +
|
||||
'privacy or reachability. Each hop is a group or a fixed node. Most users ' +
|
||||
'never need this.'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.sortable = false;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add chain');
|
||||
|
||||
o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'triple';
|
||||
|
||||
// Ordered hop builder. Combobox lets you pick a known group/node or type freely.
|
||||
o = s.option(form.DynamicList, 'hop', _('Hops (ordered)'),
|
||||
_('One entry per layer, in order: group:<name> | node:<name>'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'group:main';
|
||||
groupNames.forEach(function(n) { o.value('group:' + n, 'group:' + n); });
|
||||
nodeNames.forEach(function(n) { o.value('node:' + n, 'node:' + n); });
|
||||
}
|
||||
|
||||
// ---------------- 3. Exits (Egress) — Advanced ----------------
|
||||
if (adv) {
|
||||
var ifaces = nets.map(function(n) { return n.getName(); })
|
||||
.filter(function(n) { return n && n !== 'loopback'; });
|
||||
|
||||
var proxyTargets = chainNames.map(function(n) { return 'chain:' + n; })
|
||||
.concat(groupNames.map(function(n) { return 'group:' + n; }))
|
||||
.concat(nodeNames.map(function(n) { return 'node:' + n; }));
|
||||
|
||||
s = m.section(form.GridSection, 'egress', _('Exits (Egress)'),
|
||||
_('Where a matched flow finally leaves the router: a network interface / VPN ' +
|
||||
'tunnel (e.g. WireGuard), a proxy, direct, or block. Reference an exit from ' +
|
||||
'a rule\'s "Egress" field. Only needed for multi-uplink / tunnel setups.'));
|
||||
s.addremove = true;
|
||||
s.anonymous = true;
|
||||
s.sortable = false;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add exit');
|
||||
|
||||
o = s.option(form.Value, 'name', _('Name'));
|
||||
o.rmempty = false;
|
||||
o.placeholder = 'via-awg';
|
||||
|
||||
o = s.option(form.ListValue, 'type', _('Type'));
|
||||
o.value('interface', _('Interface / tunnel'));
|
||||
o.value('proxy', _('Proxy (chain/group/node)'));
|
||||
o.value('direct', _('Direct'));
|
||||
o.value('block', _('Block'));
|
||||
o.default = 'interface';
|
||||
|
||||
o = s.option(form.ListValue, 'interface', _('Interface'),
|
||||
_('Egress network interface / tunnel (any iface, incl. wg/awg).'));
|
||||
o.depends('type', 'interface');
|
||||
o.modalonly = true;
|
||||
if (ifaces.length) {
|
||||
ifaces.forEach(function(n) { o.value(n, n); });
|
||||
} else {
|
||||
o.value('', _('-- no interfaces detected --'));
|
||||
}
|
||||
|
||||
o = s.option(form.Value, 'target', _('Proxy target'),
|
||||
_('chain:<n> | group:<n> | node:<n>'));
|
||||
o.depends('type', 'proxy');
|
||||
o.modalonly = true;
|
||||
o.placeholder = 'chain:triple';
|
||||
proxyTargets.forEach(function(t) { o.value(t, t); });
|
||||
}
|
||||
|
||||
// ---------------- chain reachability probe (Advanced) ----------------
|
||||
var probeSection = '';
|
||||
if (adv) {
|
||||
var resultBox = E('span', { 'style': 'margin-left:.6em;font-variant-numeric:tabular-nums' }, [ '' ]);
|
||||
var chainSel = E('select', { 'class': 'cbi-input-select' },
|
||||
chainNames.length ? chainNames.map(function(n) { return E('option', { 'value': n }, [ n ]); })
|
||||
: [ E('option', { 'value': '' }, [ _('-- no chains --') ]) ]);
|
||||
var testBtn = E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, function() {
|
||||
var name = chainSel.value;
|
||||
if (!name) return;
|
||||
dom.content(resultBox, E('span', { 'class': 'sh-muted' }, [ _('probing …') ]));
|
||||
return callChainTest(name, 'http://www.gstatic.com/generate_204', 'GET').then(function(res) {
|
||||
var p = (res && res.probe) || {};
|
||||
var ok = !!p.alive;
|
||||
var txt = p.error ? p.error
|
||||
: ((ok ? _('reachable') : _('unreachable')) +
|
||||
(p.latency_ms ? (' · ' + p.latency_ms + ' ms') : '') +
|
||||
(p.exit_ip ? (' · exit ' + p.exit_ip) : '') +
|
||||
(p.status ? (' · HTTP ' + p.status) : ''));
|
||||
dom.content(resultBox, E('span', { 'class': ok ? 'sh-ok' : 'sh-bad' }, [ txt ]));
|
||||
});
|
||||
})
|
||||
}, [ _('Test chain') ]);
|
||||
|
||||
probeSection = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, [ _('Chain reachability') ]),
|
||||
E('p', {}, [ _('Probe a saved chain end-to-end (HTTP through every hop) and see the exit IP. ' +
|
||||
'Save & Apply new chains first.') ]),
|
||||
E('div', { 'style': 'display:flex;align-items:center;gap:.4em;flex-wrap:wrap' },
|
||||
[ chainSel, testBtn, resultBox ])
|
||||
]);
|
||||
}
|
||||
|
||||
return m.render().then(function(mapEl) {
|
||||
return E('div', { 'class': 'sh-wrap' }, [
|
||||
uimode.header(_('Routing targets'),
|
||||
_('The destinations your rules can send traffic to. Groups pick a working server ' +
|
||||
'for you automatically; that is usually all you need.')),
|
||||
mapEl,
|
||||
probeSection,
|
||||
adv ? '' : uimode.advHint(_('Multi-hop chains and custom exits (egress) are available in Advanced mode.'))
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
# Translation catalogs stub. luci.mk builds luci-i18n-shater-* subpackages from *.po here.
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
# luci-app-shater first-boot hook.
|
||||
# The default /etc/config/shater is shipped/seeded by shater-core, NOT here.
|
||||
# We only clear the LuCI caches so the new menu/views appear.
|
||||
rm -f /tmp/luci-indexcache*
|
||||
rm -rf /tmp/luci-modulecache/
|
||||
exit 0
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"admin/services/shater": {
|
||||
"title": "Shater (xray)",
|
||||
"order": 40,
|
||||
"action": { "type": "firstchild" },
|
||||
"depends": { "acl": [ "luci-app-shater" ] }
|
||||
},
|
||||
|
||||
"admin/services/shater/overview": {
|
||||
"title": "Overview",
|
||||
"order": 1,
|
||||
"action": { "type": "view", "path": "shater/overview" }
|
||||
},
|
||||
|
||||
"admin/services/shater/nodes": {
|
||||
"title": "Nodes",
|
||||
"order": 2,
|
||||
"action": { "type": "view", "path": "shater/nodes" }
|
||||
},
|
||||
|
||||
"admin/services/shater/subscriptions": {
|
||||
"title": "Subscriptions",
|
||||
"order": 3,
|
||||
"action": { "type": "view", "path": "shater/subscriptions" }
|
||||
},
|
||||
|
||||
"admin/services/shater/targets": {
|
||||
"title": "Routing targets",
|
||||
"order": 4,
|
||||
"action": { "type": "view", "path": "shater/targets" }
|
||||
},
|
||||
|
||||
"admin/services/shater/rules": {
|
||||
"title": "Rules",
|
||||
"order": 5,
|
||||
"action": { "type": "view", "path": "shater/rules" }
|
||||
},
|
||||
|
||||
"admin/services/shater/lists": {
|
||||
"title": "Lists / Rulesets",
|
||||
"order": 6,
|
||||
"action": { "type": "view", "path": "shater/lists" }
|
||||
},
|
||||
|
||||
"admin/services/shater/dns": {
|
||||
"title": "DNS",
|
||||
"order": 7,
|
||||
"action": { "type": "view", "path": "shater/dns" }
|
||||
},
|
||||
|
||||
"admin/services/shater/live": {
|
||||
"title": "Live connections",
|
||||
"order": 8,
|
||||
"action": { "type": "view", "path": "shater/live" }
|
||||
},
|
||||
|
||||
"admin/services/shater/profiles": {
|
||||
"title": "Backup / Profiles",
|
||||
"order": 9,
|
||||
"action": { "type": "view", "path": "shater/profiles" }
|
||||
},
|
||||
|
||||
"admin/services/shater/settings": {
|
||||
"title": "Settings",
|
||||
"order": 10,
|
||||
"action": { "type": "view", "path": "shater/settings" }
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"luci-app-shater": {
|
||||
"description": "Grant UCI and ubus access to luci-app-shater (xray control plane)",
|
||||
"read": {
|
||||
"uci": [ "shater" ],
|
||||
"ubus": {
|
||||
"xray": [ "status", "nodes", "stats", "explain", "geodata_status", "compat", "profile_list", "backup", "sub_info", "conns", "node_qr", "wanmode" ]
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"uci": [ "shater" ],
|
||||
"ubus": {
|
||||
"xray": [ "sub_update", "apply", "confirm", "reload", "node_test", "node_import", "chain_test", "geodata_download", "geodata_remove", "restore", "profile_save", "profile_switch", "profile_delete", "node_enable", "node_delete", "node_assign_group" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
#!/usr/bin/env ucode
|
||||
// rpcd ucode backend for luci-app-shater. Registers ubus object "xray".
|
||||
// Install at /usr/share/rpcd/ucode/shater.uc. After changes: /etc/init.d/rpcd restart
|
||||
// Verify: ubus list ; ubus call xray status
|
||||
// Thin wrappers around /usr/bin/xrayctl; each returns parsed JSON (read methods) or
|
||||
// an { ok, code } result (action methods). Methods must be granted in acl.d.
|
||||
'use strict';
|
||||
|
||||
import { popen } from 'fs';
|
||||
|
||||
const XRAYCTL = '/usr/bin/xrayctl';
|
||||
|
||||
// Run a command, return its stdout as a string (empty on failure).
|
||||
function run(cmd) {
|
||||
let out = '';
|
||||
let fh = popen(cmd);
|
||||
if (fh) {
|
||||
let r = fh.read('all');
|
||||
if (r != null)
|
||||
out = r;
|
||||
fh.close();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Run a command expected to emit JSON on stdout; parse and return it.
|
||||
// On parse failure return an { error, raw } object so the UI can show something.
|
||||
function run_json(cmd) {
|
||||
let out = run(cmd);
|
||||
let data;
|
||||
try {
|
||||
data = json(out);
|
||||
} catch (e) {
|
||||
return { error: 'invalid JSON from xrayctl', raw: trim(out) };
|
||||
}
|
||||
if (data == null)
|
||||
return {};
|
||||
return data;
|
||||
}
|
||||
|
||||
// Minimal shell-argument quoting: strip single quotes, wrap in single quotes.
|
||||
function shq(v) {
|
||||
let s = replace('' + v, "'", '');
|
||||
return "'" + s + "'";
|
||||
}
|
||||
|
||||
// Normalize xrayctl `nodes` output (a JSON array) into { nodes: [...] }.
|
||||
// probe=true forces a fresh liveness sweep; the default serves xrayctl's cached
|
||||
// sweep, which keeps the dashboard's frequent polling cheap.
|
||||
function nodes_result(probe) {
|
||||
let d = run_json(XRAYCTL + ' nodes' + (probe ? ' probe' : ''));
|
||||
if (type(d) == 'array')
|
||||
return { nodes: d };
|
||||
return d;
|
||||
}
|
||||
|
||||
// Run an action command and report its exit code.
|
||||
function action(cmd) {
|
||||
let rc = system(cmd + ' >/dev/null 2>&1');
|
||||
return { ok: rc == 0, code: rc };
|
||||
}
|
||||
|
||||
return {
|
||||
xray: {
|
||||
// --- read methods (acl read.ubus) ---
|
||||
status: {
|
||||
call: function() {
|
||||
return run_json(XRAYCTL + ' status');
|
||||
}
|
||||
},
|
||||
|
||||
nodes: {
|
||||
args: { probe: false },
|
||||
call: function(req) {
|
||||
return nodes_result(req.args?.probe);
|
||||
}
|
||||
},
|
||||
|
||||
stats: {
|
||||
call: function() {
|
||||
return run_json(XRAYCTL + ' stats');
|
||||
}
|
||||
},
|
||||
|
||||
// Optional geoip.dat/geosite.dat status: presence, sizes, free space, and
|
||||
// whether a download would fit. { present, geoip_size, geosite_size,
|
||||
// asset_dir, free_kb, need_kb, can_download }.
|
||||
geodata_status: {
|
||||
call: function() {
|
||||
return run_json(XRAYCTL + ' geodata status');
|
||||
}
|
||||
},
|
||||
|
||||
explain: {
|
||||
args: { src: '', dst: '', proto: '' },
|
||||
call: function(req) {
|
||||
let a = req.args;
|
||||
if (!a) a = {};
|
||||
let cmd = XRAYCTL + ' explain ' + shq(a.src) + ' ' + shq(a.dst);
|
||||
if (a.proto)
|
||||
cmd += ' --proto ' + shq(a.proto);
|
||||
return run_json(cmd);
|
||||
}
|
||||
},
|
||||
|
||||
// --- action methods (acl write.ubus) ---
|
||||
// On-demand node probe. method: "tcp" (default, fast endpoint connect) or
|
||||
// "http" (real proxy-path GET/HEAD through the node, with exit IP). Returns
|
||||
// { probe: [{name,alive,latency_ms,status?,exit_ip?,error?}] }.
|
||||
node_test: {
|
||||
args: { name: '', method: '', url: '', http_method: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
let cmd = XRAYCTL + ' node test';
|
||||
if (a.name) cmd += ' ' + shq(a.name);
|
||||
if (a.method == 'http') {
|
||||
cmd += ' --method http';
|
||||
if (a.url) cmd += ' --url ' + shq(a.url);
|
||||
if (a.http_method) cmd += ' --http-method ' + shq(a.http_method);
|
||||
}
|
||||
let d = run_json(cmd);
|
||||
if (type(d) == 'array') return { probe: d };
|
||||
// A single HTTP probe returns one object.
|
||||
if (d.name != null) return { probe: [ d ] };
|
||||
return { probe: d.probe || [] };
|
||||
}
|
||||
},
|
||||
|
||||
// End-to-end HTTP probe of a whole chain (all hops) — reports reachable /
|
||||
// latency / exit-IP. Returns { probe: {name,alive,latency_ms,status?,exit_ip?,error?} }.
|
||||
chain_test: {
|
||||
args: { name: '', url: '', http_method: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.name) return { error: 'chain name required' };
|
||||
let cmd = XRAYCTL + ' chain test ' + shq(a.name);
|
||||
if (a.url) cmd += ' --url ' + shq(a.url);
|
||||
if (a.http_method) cmd += ' --http-method ' + shq(a.http_method);
|
||||
return { probe: run_json(cmd) };
|
||||
}
|
||||
},
|
||||
|
||||
// Bulk-import pasted share-links. The frontend sends a base64-encoded blob
|
||||
// (UTF-8 safe) in `b64`; we decode and pipe it to `xrayctl node import`,
|
||||
// which parses the links into `config node` / the node cache.
|
||||
node_import: {
|
||||
args: { b64: '', text: '' },
|
||||
call: function(req) {
|
||||
let a = req.args;
|
||||
if (!a) a = {};
|
||||
let cmd;
|
||||
if (a.b64 != null && a.b64 != '')
|
||||
cmd = 'echo ' + shq(a.b64) + ' | base64 -d | ' + XRAYCTL + ' node import';
|
||||
else
|
||||
cmd = 'printf %s ' + shq(a.text) + ' | ' + XRAYCTL + ' node import';
|
||||
return action(cmd);
|
||||
}
|
||||
},
|
||||
|
||||
// Download geoip.dat/geosite.dat on demand (opkg/apk install after a free-space
|
||||
// check). Returns the post-install status (with an "error" field on failure).
|
||||
// Slow (fetches ~25 MB) — the frontend calls this with a long rpc timeout.
|
||||
geodata_download: {
|
||||
call: function() {
|
||||
return run_json(XRAYCTL + ' geodata download');
|
||||
}
|
||||
},
|
||||
|
||||
// Remove geoip.dat/geosite.dat to reclaim flash. Returns the post-removal status.
|
||||
geodata_remove: {
|
||||
call: function() {
|
||||
return run_json(XRAYCTL + ' geodata remove');
|
||||
}
|
||||
},
|
||||
|
||||
// Live connections from conntrack (best-effort outbound label).
|
||||
conns: {
|
||||
call: function() {
|
||||
return run_json(XRAYCTL + ' conns');
|
||||
}
|
||||
},
|
||||
|
||||
// QR / share-link export: SVG of the node's share-link (needs qrencode).
|
||||
node_qr: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.name) return { error: 'name required' };
|
||||
let svg = run(XRAYCTL + ' node qr ' + shq(a.name));
|
||||
if (index(svg, '<svg') < 0)
|
||||
return { error: 'qrencode unavailable — opkg install qrencode' };
|
||||
return { svg: svg };
|
||||
}
|
||||
},
|
||||
|
||||
// --- bulk-action write methods (manual nodes) ---
|
||||
node_enable: {
|
||||
args: { name: '', enabled: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.name) return { ok: false, code: 2 };
|
||||
let verb = (a.enabled == '0' || a.enabled == 0) ? ' node disable ' : ' node enable ';
|
||||
return action(XRAYCTL + verb + shq(a.name));
|
||||
}
|
||||
},
|
||||
node_delete: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.name) return { ok: false, code: 2 };
|
||||
return action(XRAYCTL + ' node delete ' + shq(a.name));
|
||||
}
|
||||
},
|
||||
node_assign_group: {
|
||||
args: { name: '', group: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.name || !a.group) return { ok: false, code: 2 };
|
||||
return action(XRAYCTL + ' node group ' + shq(a.name) + ' ' + shq(a.group));
|
||||
}
|
||||
},
|
||||
|
||||
// Per-subscription quota/expiry + alerts -> array of userinfo views.
|
||||
sub_info: {
|
||||
call: function() {
|
||||
let d = run_json(XRAYCTL + ' sub info');
|
||||
return (type(d) == 'array') ? { subs: d } : d;
|
||||
}
|
||||
},
|
||||
|
||||
// xray version-compat report { xray_version, ok, features:[...] }.
|
||||
compat: {
|
||||
call: function() {
|
||||
return run_json(XRAYCTL + ' compat');
|
||||
}
|
||||
},
|
||||
|
||||
// Active WAN-mode profile + evaluation { default_iface, active, profiles:[...] }.
|
||||
wanmode: {
|
||||
call: function() {
|
||||
return run_json(XRAYCTL + ' wanmode');
|
||||
}
|
||||
},
|
||||
|
||||
// List saved config profiles -> { profiles: [...] }.
|
||||
profile_list: {
|
||||
call: function() {
|
||||
let d = run_json(XRAYCTL + ' profile list');
|
||||
return (type(d) == 'array') ? { profiles: d } : d;
|
||||
}
|
||||
},
|
||||
|
||||
// Export the whole config as a base64 tar.gz for client-side download.
|
||||
backup: {
|
||||
call: function() {
|
||||
let b64 = trim(run(XRAYCTL + ' backup | base64 | tr -d "\\n"'));
|
||||
return { data: b64, filename: 'shater-backup.tar.gz' };
|
||||
}
|
||||
},
|
||||
|
||||
// Restore a config from an uploaded base64 tar.gz (validate -> swap ->
|
||||
// apply with commit-confirm). confirm = seconds to arm auto-rollback.
|
||||
restore: {
|
||||
args: { b64: '', confirm: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.b64) return { ok: false, error: 'no data' };
|
||||
let c = a.confirm ? (' --confirm ' + shq(a.confirm)) : '';
|
||||
let cmd = 'echo ' + shq(a.b64) + ' | base64 -d > /tmp/shater-restore.tar.gz && ' +
|
||||
XRAYCTL + ' restore --file /tmp/shater-restore.tar.gz' + c +
|
||||
'; rc=$?; rm -f /tmp/shater-restore.tar.gz; exit $rc';
|
||||
return action(cmd);
|
||||
}
|
||||
},
|
||||
|
||||
profile_save: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.name) return { ok: false, error: 'name required' };
|
||||
return action(XRAYCTL + ' profile save ' + shq(a.name));
|
||||
}
|
||||
},
|
||||
|
||||
profile_switch: {
|
||||
args: { name: '', confirm: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.name) return { ok: false, error: 'name required' };
|
||||
let c = a.confirm ? (' --confirm ' + shq(a.confirm)) : '';
|
||||
return action(XRAYCTL + ' profile switch ' + shq(a.name) + c);
|
||||
}
|
||||
},
|
||||
|
||||
profile_delete: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let a = req.args || {};
|
||||
if (!a.name) return { ok: false, error: 'name required' };
|
||||
return action(XRAYCTL + ' profile delete ' + shq(a.name));
|
||||
}
|
||||
},
|
||||
|
||||
sub_update: {
|
||||
args: { name: '' },
|
||||
call: function(req) {
|
||||
let a = req.args;
|
||||
if (!a) a = {};
|
||||
let cmd = XRAYCTL + ' sub update';
|
||||
if (a.name)
|
||||
cmd += ' ' + shq(a.name);
|
||||
return action(cmd);
|
||||
}
|
||||
},
|
||||
|
||||
apply: {
|
||||
call: function() {
|
||||
return action(XRAYCTL + ' apply');
|
||||
}
|
||||
},
|
||||
|
||||
confirm: {
|
||||
call: function() {
|
||||
return action(XRAYCTL + ' confirm');
|
||||
}
|
||||
},
|
||||
|
||||
// Idempotent re-apply (hotplug/watchdog path).
|
||||
reload: {
|
||||
call: function() {
|
||||
return action(XRAYCTL + ' reconcile');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
#
|
||||
# shater-core — data-plane glue for the Shater transparent-proxy stack.
|
||||
#
|
||||
# Ships the "железно" (rock-solid) static layer that the Go control-plane
|
||||
# (xrayctl) and the LuCI app sit on: the procd init that supervises the stock
|
||||
# xray binary against xrayctl's generated /etc/xray/run.json, the hotplug hook
|
||||
# that re-persists policy routing, the sysctl knobs TPROXY needs, a minimal
|
||||
# inert UCI default, and one-time rt_tables seeding.
|
||||
#
|
||||
# Pure scripts + config => PKGARCH:=all. Nothing is compiled here.
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=shater-core
|
||||
PKG_VERSION:=0.1.0
|
||||
PKG_RELEASE:=19
|
||||
|
||||
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||
PKG_LICENSE:=GPL-2.0-or-later
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/shater-core
|
||||
SECTION:=net
|
||||
CATEGORY:=Network
|
||||
TITLE:=Shater transparent-proxy data-plane glue
|
||||
URL:=https://github.com/shater
|
||||
# xrayctl : control-plane (gen/test/apply/reconcile) invoked by our init/hotplug
|
||||
# xray-core: the /usr/bin/xray engine our init supervises
|
||||
# dnsmasq-full: nftset= support for domain-based routing sets
|
||||
# kmod-nft-tproxy + kmod-nft-socket: kernel TPROXY (xrayctl emits the rules)
|
||||
# ip-full : `ip rule`/`ip route`/rt_tables for policy routing
|
||||
DEPENDS:=+xrayctl +xray-core +dnsmasq-full +kmod-nft-tproxy +kmod-nft-socket +ip-full
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
define Package/shater-core/description
|
||||
Static data-plane glue for the Shater xray-based transparent proxy: procd
|
||||
init (supervises xray on xrayctl-generated /etc/xray/run.json and reconciles
|
||||
policy routing / our nft table `inet shater`), an ifup/ifdown hotplug hook
|
||||
that re-persists ip rules & routes, TPROXY sysctl settings, a minimal inert
|
||||
UCI default (globals disabled until configured), and idempotent first-boot
|
||||
setup. Designed to never break connectivity: fully inert until explicitly
|
||||
enabled and a generated runtime config exists.
|
||||
endef
|
||||
|
||||
# /etc/config/shater is user-editable desired state -> preserve on upgrade.
|
||||
define Package/shater-core/conffiles
|
||||
/etc/config/shater
|
||||
endef
|
||||
|
||||
# Nothing to fetch or build.
|
||||
define Build/Prepare
|
||||
mkdir -p $(PKG_BUILD_DIR)
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
endef
|
||||
|
||||
define Package/shater-core/install
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) ./files/etc/init.d/shater $(1)/etc/init.d/shater
|
||||
$(INSTALL_BIN) ./files/etc/init.d/shater-cron $(1)/etc/init.d/shater-cron
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface
|
||||
$(INSTALL_BIN) ./files/etc/hotplug.d/iface/99-shater $(1)/etc/hotplug.d/iface/99-shater
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/sysctl.d
|
||||
$(INSTALL_DATA) ./files/etc/sysctl.d/99-shater.conf $(1)/etc/sysctl.d/99-shater.conf
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_CONF) ./files/etc/config/shater $(1)/etc/config/shater
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/uci-defaults
|
||||
$(INSTALL_BIN) ./files/etc/uci-defaults/30_shater-core $(1)/etc/uci-defaults/30_shater-core
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/share/shater
|
||||
$(INSTALL_DATA) ./files/usr/share/shater/README $(1)/usr/share/shater/README
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,shater-core))
|
||||
@@ -1,36 +0,0 @@
|
||||
#
|
||||
# Shater desired-state config (/etc/config/shater).
|
||||
#
|
||||
# This shipped default is intentionally INERT: globals.enabled='0' means the
|
||||
# init script opens no instance and touches nothing, so a fresh install cannot
|
||||
# affect connectivity. Configure via the LuCI app (or uci), set enabled='1',
|
||||
# then apply (xrayctl apply / ubus call xray apply).
|
||||
#
|
||||
# Full schema: see docs/CONFIG.md. xrayctl renders xray JSON + nft + policy-routing
|
||||
# from this file; do not hand-edit /etc/xray/run.json (it is generated).
|
||||
#
|
||||
# This file is installed as a conffile — your edits survive package upgrades.
|
||||
#
|
||||
|
||||
config globals 'globals'
|
||||
option enabled '0'
|
||||
option loglevel 'warning'
|
||||
option kill_switch 'closed'
|
||||
option dns_mode 'nftset'
|
||||
option ipv6 '1'
|
||||
# Reserved fwmark base and routing-table base (do not overlap fw4/other apps).
|
||||
option fwmark_base '0x2000'
|
||||
option table_base '0x2000'
|
||||
option confirm_timeout '0'
|
||||
option schema_version '1'
|
||||
|
||||
# Example LAN interception inbound (disabled). Enable and adjust `network` to the
|
||||
# UCI interface(s) to transparently proxy, then set globals.enabled='1'.
|
||||
config inbound
|
||||
option name 'lan'
|
||||
option enabled '0'
|
||||
option network 'lan'
|
||||
option tproxy_port '12345'
|
||||
option tcp '1'
|
||||
option udp '1'
|
||||
option sniff '1'
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/sh
|
||||
# /etc/hotplug.d/iface/99-shater
|
||||
#
|
||||
# netifd wipes `ip rule` / `ip route` on `network reload` and on interface
|
||||
# churn, so our policy routing must be re-persisted on every ifup/ifdown.
|
||||
# xrayctl reconcile is idempotent (flock-serialized, hash-compared), so
|
||||
# re-running it here is safe and cheap.
|
||||
#
|
||||
# Fully inert unless the Shater stack is enabled AND the main service is live
|
||||
# (ACTIVE_FLAG raised by /etc/init.d/shater start, cleared by stop). Guarding
|
||||
# on the flag — not just on UCI — means an admin `stop` sticks: a WAN flap can
|
||||
# never resurrect interception behind a deliberately stopped engine.
|
||||
|
||||
[ -x /usr/bin/xrayctl ] || exit 0
|
||||
|
||||
case "$ACTION" in
|
||||
ifup|ifdown) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Only act when explicitly enabled AND the service is meant to be running.
|
||||
en=$(uci -q get shater.globals.enabled) || exit 0
|
||||
[ "$en" = "1" ] || exit 0
|
||||
[ -f /var/run/shater.active ] || exit 0
|
||||
|
||||
# Coalesce interface-flap storms: each reconcile runs a full `xray -test`,
|
||||
# which is real CPU on small routers. The FIRST event in a burst schedules one
|
||||
# reconcile 2s out (absorbing the burst's rule-wipes); followers in that window
|
||||
# exit — their wipes are covered by the pending pass. The marker is released
|
||||
# BEFORE reconciling so an event landing mid-reconcile schedules a fresh pass
|
||||
# and no wipe is ever left unrepaired.
|
||||
pending=/var/run/shater/hotplug.pending
|
||||
mkdir -p /var/run/shater
|
||||
mkdir "$pending" 2>/dev/null || exit 0
|
||||
sleep 2
|
||||
rmdir "$pending" 2>/dev/null
|
||||
|
||||
/usr/bin/xrayctl reconcile >/dev/null 2>&1
|
||||
|
||||
exit 0
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# /etc/init.d/shater — procd supervisor for the Shater data plane.
|
||||
#
|
||||
# Runs the stock xray engine (/usr/bin/xray) against xrayctl's generated
|
||||
# /etc/xray/run.json, and drives xrayctl's idempotent `reconcile` to (re)persist
|
||||
# policy routing (ip rules/routes in our reserved table base) and our nft table.
|
||||
#
|
||||
# RELIABILITY CONTRACT (the "железно" layer):
|
||||
# * Completely INERT unless globals.enabled=1. On a fresh/misconfigured box
|
||||
# start_service exits cleanly with no instance and no data-plane changes,
|
||||
# so boot connectivity is NEVER affected.
|
||||
# * The interception data plane may only exist while this service is meant to
|
||||
# be running. `start` raises ACTIVE_FLAG, `stop` clears it AND tears the
|
||||
# data plane down; hotplug/cron reconcile ONLY while the flag is up, so an
|
||||
# admin `stop` STICKS — no background actor may resurrect interception.
|
||||
# * The engine must never be permanently abandoned while interception stands:
|
||||
# respawn retries are infinite (procd never gives up); a sustained-dead
|
||||
# engine is additionally escalated by the shater-cron watchdog.
|
||||
# * Teardown only ever touches OUR resources: nft table `inet shater` and the
|
||||
# routing tables in the reserved base (default 0x2000). fw4's table and the
|
||||
# management/LAN paths are never touched.
|
||||
# * busybox ash only — no bashisms.
|
||||
|
||||
USE_PROCD=1
|
||||
START=99 # after network + firewall + dnsmasq
|
||||
STOP=10
|
||||
|
||||
PROG=/usr/bin/xray
|
||||
XRAYCTL=/usr/bin/xrayctl
|
||||
RUN_JSON=/etc/xray/run.json
|
||||
RUN_DIR=/var/run/xray
|
||||
PID_FILE=/var/run/xray/xray.pid
|
||||
ASSET_DIR=/usr/share/xray
|
||||
# Raised while the service is meant to be running; the ONLY token that lets
|
||||
# hotplug/shater-cron touch the data plane. tmpfs => cleared by reboot, so
|
||||
# nothing reconciles before this init has run at boot.
|
||||
ACTIVE_FLAG=/var/run/shater.active
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
# True only when the stack is explicitly enabled in UCI.
|
||||
shater_enabled() {
|
||||
local en
|
||||
en=$(uci -q get shater.globals.enabled) || return 1
|
||||
[ "$en" = "1" ]
|
||||
}
|
||||
|
||||
# Normalize a UCI numeric option (hex "0x2000" or decimal "8192") to decimal.
|
||||
# Rejects anything that is not a clean hex/decimal literal — a malformed value
|
||||
# must NOT silently disable teardown (the caller falls back to the default).
|
||||
shater_to_dec() {
|
||||
local v="$1" rest
|
||||
case "$v" in
|
||||
0x*|0X*)
|
||||
rest="${v#0[xX]}"
|
||||
[ -n "$rest" ] || return 1
|
||||
case "$rest" in *[!0-9a-fA-F]*) return 1 ;; esac
|
||||
;;
|
||||
*)
|
||||
[ -n "$v" ] || return 1
|
||||
case "$v" in *[!0-9]*) return 1 ;; esac
|
||||
;;
|
||||
esac
|
||||
echo $(( v ))
|
||||
}
|
||||
|
||||
# Best-effort removal of OUR data-plane state. Safe to run repeatedly and when
|
||||
# nothing is set up. Restores plain routing/forwarding immediately.
|
||||
shater_teardown() {
|
||||
# Interception table (owned by us; generated by xrayctl at apply time).
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
nft delete table inet shater 2>/dev/null
|
||||
fi
|
||||
|
||||
# Policy routing in our reserved table block. We only clear tables at/above
|
||||
# table_base, never the main/default/local tables or fw4 state. A malformed
|
||||
# table_base falls back to the shipped default — teardown must ALWAYS run.
|
||||
local tbase base t
|
||||
tbase=$(uci -q get shater.globals.table_base)
|
||||
[ -n "$tbase" ] || tbase="0x2000"
|
||||
base=$(shater_to_dec "$tbase") || base=8192
|
||||
|
||||
t="$base"
|
||||
while [ "$t" -lt $(( base + 64 )) ]; do
|
||||
# Delete every ip rule that points at this table (there may be more
|
||||
# than one), for both address families, then flush the table.
|
||||
while ip rule del table "$t" 2>/dev/null; do :; done
|
||||
while ip -6 rule del table "$t" 2>/dev/null; do :; done
|
||||
ip route flush table "$t" 2>/dev/null
|
||||
ip -6 route flush table "$t" 2>/dev/null
|
||||
t=$(( t + 1 ))
|
||||
done
|
||||
}
|
||||
|
||||
# Idempotent kernel reconcile via the control-plane (routing + nft). Brings the
|
||||
# UCI schema forward first (idempotent; refuses a newer schema) so an upgraded
|
||||
# xrayctl never generates from a stale config. xrayctl serializes itself with
|
||||
# an flock and hash-compares run.json, so re-running this is safe and cheap.
|
||||
shater_reconcile() {
|
||||
[ -x "$XRAYCTL" ] && "$XRAYCTL" migrate >/dev/null 2>&1
|
||||
[ -x "$XRAYCTL" ] && "$XRAYCTL" reconcile >/dev/null 2>&1
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- procd lifecycle -------------------------------------------------------
|
||||
|
||||
start_service() {
|
||||
# Guard: stay inert unless explicitly enabled.
|
||||
shater_enabled || return 0
|
||||
|
||||
# Guard: never install interception without a working engine binary. A
|
||||
# half-removed/failed xray upgrade must degrade to "plugin off", not to a
|
||||
# LAN whose traffic is diverted into nothing.
|
||||
if [ ! -x "$PROG" ]; then
|
||||
logger -t shater -p daemon.err \
|
||||
"xray binary missing/not executable at $PROG — refusing to start (LAN stays on plain routing)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$RUN_DIR" "$(dirname "$RUN_JSON")"
|
||||
|
||||
# run.json is generated state (lost on sysupgrade). If it is missing but the
|
||||
# stack is enabled, regenerate it from UCI before opening the instance —
|
||||
# otherwise the box comes up enabled-but-engineless and the first background
|
||||
# reconcile would install interception with nothing listening behind it.
|
||||
if [ ! -f "$RUN_JSON" ]; then
|
||||
[ -x "$XRAYCTL" ] && "$XRAYCTL" migrate >/dev/null 2>&1
|
||||
[ -x "$XRAYCTL" ] && "$XRAYCTL" reconcile >/dev/null 2>&1
|
||||
if [ ! -f "$RUN_JSON" ]; then
|
||||
logger -t shater -p daemon.err \
|
||||
"enabled but could not generate $RUN_JSON — staying inert (check 'xrayctl reconcile' output)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
procd_open_instance shater
|
||||
# xray runs in the FOREGROUND under procd (must never daemonize).
|
||||
procd_set_param command "$PROG" run -c "$RUN_JSON"
|
||||
procd_set_param env XRAY_LOCATION_ASSET="$ASSET_DIR" # geoip.dat/geosite.dat
|
||||
# threshold(3600) timeout(5) retries(0=INFINITE): procd must NEVER give up on
|
||||
# the engine while our interception rules stand — an abandoned engine with
|
||||
# live TPROXY+DNS-hijack rules is a permanent LAN blackout. A genuine crash
|
||||
# loop retries every 5s (cheap); sustained death is escalated by the
|
||||
# shater-cron watchdog (which fails open / alerts per kill_switch policy).
|
||||
procd_set_param respawn 3600 5 0
|
||||
# NOTE: deliberately NO `procd_set_param file` watches. run.json changes are
|
||||
# propagated by xrayctl itself (hash-compare + engine SIGTERM on real change),
|
||||
# and UCI edits flow through the reload trigger below. A file-watch here
|
||||
# would bounce the tunnel (dropping every proxied connection) on every UCI
|
||||
# commit even when the rendered config is byte-identical.
|
||||
procd_set_param stdout 1 # -> logread
|
||||
procd_set_param stderr 1
|
||||
procd_set_param term_timeout 10
|
||||
procd_set_param pidfile "$PID_FILE"
|
||||
procd_close_instance
|
||||
|
||||
# Mark the stack live for hotplug/cron, then persist policy routing / nft
|
||||
# for the freshly (re)started engine.
|
||||
mkdir -p "$(dirname "$ACTIVE_FLAG")"
|
||||
: > "$ACTIVE_FLAG"
|
||||
shater_reconcile
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
# Drop the live-flag FIRST so a concurrent hotplug/cron tick cannot rebuild
|
||||
# what we are about to tear down; procd stops the xray instance itself.
|
||||
rm -f "$ACTIVE_FLAG"
|
||||
shater_teardown
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
# Fired by the `shater` config.change reload-trigger. `start` re-runs
|
||||
# start_service, which reconciles nft/routing; xrayctl's reconcile compares
|
||||
# the rendered run.json hash and restarts the engine ONLY on real change.
|
||||
# When the stack is disabled we tear our resources down. We must NOT call
|
||||
# `shater_reconcile` in addition to `start` — that would run reconcile twice
|
||||
# per reload, and any path that makes `xrayctl reconcile` itself invoke
|
||||
# `/etc/init.d/shater reload` would turn this into an unbounded fork storm.
|
||||
if shater_enabled; then
|
||||
start
|
||||
else
|
||||
stop
|
||||
fi
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "shater"
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# /etc/init.d/shater-cron — periodic auto-updater for subscriptions & rulesets,
|
||||
# plus the data-plane watchdog.
|
||||
#
|
||||
# A tiny procd-supervised loop that, once per tick, checks every enabled
|
||||
# subscription and url-ruleset against its per-item `update_interval` and runs
|
||||
# xrayctl sub update <name> (subscriptions)
|
||||
# xrayctl ruleset update <name> (url rulesets)
|
||||
# when the item is due, then a single `xrayctl reconcile` if anything changed
|
||||
# (xrayctl hash-compares run.json and restarts the engine only on real change).
|
||||
#
|
||||
# RELIABILITY CONTRACT (same "железно" posture as /etc/init.d/shater):
|
||||
# * The loop body is fully INERT unless globals.enabled=1 AND the main shater
|
||||
# service is live (ACTIVE_FLAG raised by its start, cleared by its stop).
|
||||
# An admin `stop` of the main service therefore STICKS — this loop idles.
|
||||
# * Only ever invokes xrayctl verbs / the shater init — never touches the
|
||||
# data plane directly.
|
||||
# * Due-ness is tracked with epoch stamp FILES under a tmpfs run dir, so no
|
||||
# dependence on `date -r`. Stamps are lost on reboot => every item is due at
|
||||
# boot, giving a fetch-at-boot exactly as the feature catalog requires.
|
||||
# Fetches are skipped (not stamped) while the box has no default route, so
|
||||
# the boot-time fetch actually happens once WAN is up instead of silently
|
||||
# burning the attempt.
|
||||
# * A stamp is written ONLY on success; a failed fetch is retried after a
|
||||
# short backoff (RETRY_SECS) instead of waiting out the full interval.
|
||||
# * WATCHDOG: if interception is meant to be live but the engine has been
|
||||
# dead for WATCHDOG_TICKS consecutive ticks, we escalate: with
|
||||
# kill_switch=open the main service is STOPPED (tears interception down —
|
||||
# fail-open, LAN returns to plain routing); with kill_switch=closed the
|
||||
# rules stay (blocked-by-design) and we log loudly.
|
||||
# * The loop never self-exits (procd would respawn-churn an exiting body);
|
||||
# it idles on its guards instead. busybox ash only — no bashisms.
|
||||
|
||||
USE_PROCD=1
|
||||
START=96 # order vs the main init (99) is irrelevant; loop self-guards
|
||||
STOP=11
|
||||
|
||||
# `loop` is an internal action procd re-execs to run the periodic body.
|
||||
EXTRA_COMMANDS="loop"
|
||||
EXTRA_HELP=" loop internal: run the periodic update loop (invoked by procd)"
|
||||
|
||||
INIT_SCRIPT=/etc/init.d/shater-cron
|
||||
SHATER_INIT=/etc/init.d/shater
|
||||
XRAYCTL=/usr/bin/xrayctl
|
||||
RUN_JSON=/etc/xray/run.json
|
||||
ACTIVE_FLAG=/var/run/shater.active
|
||||
STAMP_DIR=/var/run/shater/cron
|
||||
TICK=60 # seconds between due-checks
|
||||
RETRY_SECS=300 # backoff before retrying a FAILED fetch
|
||||
WATCHDOG_TICKS=5 # consecutive dead-engine ticks before escalating
|
||||
DEFAULT_SUB_INTERVAL=6h
|
||||
DEFAULT_RS_INTERVAL=24h
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
shater_enabled() {
|
||||
local en
|
||||
en=$(uci -q get shater.globals.enabled) || return 1
|
||||
[ "$en" = "1" ]
|
||||
}
|
||||
|
||||
# The main service raised its live-flag (start) and has not stopped since.
|
||||
shater_active() {
|
||||
[ -f "$ACTIVE_FLAG" ]
|
||||
}
|
||||
|
||||
# Best-effort "do we have an uplink" check; fetching before WAN is up at boot
|
||||
# would waste each item's one boot attempt.
|
||||
shater_has_uplink() {
|
||||
ip route show default 2>/dev/null | grep -q '^default' && return 0
|
||||
ip -6 route show default 2>/dev/null | grep -q '^default'
|
||||
}
|
||||
|
||||
# Stamp names embed the UCI item name; keep them to one safe path component.
|
||||
shater_safe_name() {
|
||||
echo "$1" | sed 's/[^A-Za-z0-9._-]/_/g'
|
||||
}
|
||||
|
||||
# Convert 30m|6h|24h|2d|3600 -> seconds on stdout. $2 is a fallback token used
|
||||
# when the input is empty or malformed (the FALLBACK is honored, not a
|
||||
# hardcoded constant).
|
||||
shater_ivl_secs() {
|
||||
local v="$1" def="$2" n u
|
||||
[ -n "$v" ] || v="$def"
|
||||
n=$(echo "$v" | sed 's/[^0-9].*$//')
|
||||
u=$(echo "$v" | sed 's/^[0-9]*//')
|
||||
if [ -z "$n" ]; then
|
||||
# Malformed (no leading digits): fall back to the caller's default; if
|
||||
# that is somehow malformed too, 6h.
|
||||
v="$def"
|
||||
n=$(echo "$v" | sed 's/[^0-9].*$//')
|
||||
u=$(echo "$v" | sed 's/^[0-9]*//')
|
||||
[ -n "$n" ] || { echo 21600; return; }
|
||||
fi
|
||||
case "$u" in
|
||||
s|"") echo "$n" ;;
|
||||
m|M) echo $(( n * 60 )) ;;
|
||||
h|H) echo $(( n * 3600 )) ;;
|
||||
d|D) echo $(( n * 86400 )) ;;
|
||||
*) echo $(( n )) ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Due if now - stamp >= interval. $1 stamp file, $2 interval seconds.
|
||||
shater_due() {
|
||||
local stamp="$1" ivl="$2" now last
|
||||
now=$(date +%s)
|
||||
last=$(cat "$stamp" 2>/dev/null)
|
||||
[ -n "$last" ] || last=0
|
||||
[ $(( now - last )) -ge "$ivl" ]
|
||||
}
|
||||
|
||||
shater_stamp() { mkdir -p "$STAMP_DIR"; date +%s > "$1"; }
|
||||
|
||||
# Failed fetch: pretend the last success was (interval - RETRY_SECS) ago so the
|
||||
# item comes due again after a short backoff instead of a full interval — but
|
||||
# never sooner than RETRY_SECS (guards tiny intervals).
|
||||
shater_stamp_retry() {
|
||||
local stamp="$1" ivl="$2" back
|
||||
back=$(( ivl - RETRY_SECS ))
|
||||
[ "$back" -gt 0 ] || back=0
|
||||
mkdir -p "$STAMP_DIR"
|
||||
echo $(( $(date +%s) - back )) > "$stamp"
|
||||
}
|
||||
|
||||
# Walk anonymous `config subscription` / `config ruleset` sections by index and
|
||||
# run any that are due. Echoes non-empty on stdout if at least one item updated.
|
||||
shater_run_due() {
|
||||
local i name en ivl secs stamp changed=""
|
||||
|
||||
# Subscriptions.
|
||||
i=0
|
||||
while uci -q get "shater.@subscription[$i]" >/dev/null 2>&1; do
|
||||
name=$(uci -q get "shater.@subscription[$i].name")
|
||||
en=$(uci -q get "shater.@subscription[$i].enabled")
|
||||
[ -z "$en" ] && en=1
|
||||
if [ -n "$name" ] && [ "$en" = "1" ]; then
|
||||
ivl=$(uci -q get "shater.@subscription[$i].update_interval")
|
||||
secs=$(shater_ivl_secs "$ivl" "$DEFAULT_SUB_INTERVAL")
|
||||
stamp="$STAMP_DIR/sub.$(shater_safe_name "$name")"
|
||||
if shater_due "$stamp" "$secs"; then
|
||||
if "$XRAYCTL" sub update "$name" >/dev/null 2>&1; then
|
||||
shater_stamp "$stamp"
|
||||
changed=1
|
||||
else
|
||||
logger -t shater-cron -p daemon.warn \
|
||||
"sub update '$name' failed; retrying in ${RETRY_SECS}s"
|
||||
shater_stamp_retry "$stamp" "$secs"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
i=$(( i + 1 ))
|
||||
done
|
||||
|
||||
# Rulesets (only url sources auto-update; others have nothing to fetch).
|
||||
i=0
|
||||
while uci -q get "shater.@ruleset[$i]" >/dev/null 2>&1; do
|
||||
name=$(uci -q get "shater.@ruleset[$i].name")
|
||||
src=$(uci -q get "shater.@ruleset[$i].source")
|
||||
if [ -n "$name" ] && [ "$src" = "url" ]; then
|
||||
ivl=$(uci -q get "shater.@ruleset[$i].update_interval")
|
||||
secs=$(shater_ivl_secs "$ivl" "$DEFAULT_RS_INTERVAL")
|
||||
stamp="$STAMP_DIR/rs.$(shater_safe_name "$name")"
|
||||
if shater_due "$stamp" "$secs"; then
|
||||
if "$XRAYCTL" ruleset update "$name" >/dev/null 2>&1; then
|
||||
shater_stamp "$stamp"
|
||||
changed=1
|
||||
else
|
||||
logger -t shater-cron -p daemon.warn \
|
||||
"ruleset update '$name' failed; retrying in ${RETRY_SECS}s"
|
||||
shater_stamp_retry "$stamp" "$secs"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
i=$(( i + 1 ))
|
||||
done
|
||||
|
||||
[ -n "$changed" ] && echo 1
|
||||
}
|
||||
|
||||
# Watchdog body for one tick: detect "interception live, engine dead". $1 is
|
||||
# the running dead-tick count; echoes the updated count.
|
||||
shater_watchdog() {
|
||||
local dead="$1" ks
|
||||
if [ ! -f "$RUN_JSON" ] || pidof xray >/dev/null 2>&1; then
|
||||
echo 0
|
||||
return
|
||||
fi
|
||||
dead=$(( dead + 1 ))
|
||||
if [ "$dead" -eq "$WATCHDOG_TICKS" ]; then
|
||||
ks=$(uci -q get shater.globals.kill_switch)
|
||||
if [ "$ks" = "closed" ]; then
|
||||
# Fail-closed is a POLICY: dead engine + standing rules == traffic
|
||||
# blocked, which is what the admin asked for. Keep it, but say so.
|
||||
logger -t shater-cron -p daemon.crit \
|
||||
"xray dead for $(( dead * TICK ))s with interception live; kill_switch=closed keeps LAN blocked — fix the engine or /etc/init.d/shater stop"
|
||||
else
|
||||
# Fail-open: durably stop the stack (clears the live-flag + tears
|
||||
# interception down) so the LAN returns to plain routing.
|
||||
logger -t shater-cron -p daemon.crit \
|
||||
"xray dead for $(( dead * TICK ))s with interception live; kill_switch=open — stopping shater (fail-open, LAN back to plain routing)"
|
||||
"$SHATER_INIT" stop
|
||||
dead=0
|
||||
fi
|
||||
fi
|
||||
echo "$dead"
|
||||
}
|
||||
|
||||
# loop: the foreground body supervised by procd. Never exits on its own — it
|
||||
# idles while disabled/inactive so procd is not respawn-churned by a
|
||||
# self-exiting body when the stack is off.
|
||||
loop() {
|
||||
local changed sched dead=0
|
||||
mkdir -p "$STAMP_DIR"
|
||||
while :; do
|
||||
if shater_enabled && shater_active; then
|
||||
if shater_has_uplink; then
|
||||
changed=$(shater_run_due)
|
||||
else
|
||||
changed=""
|
||||
fi
|
||||
# Schedule boundary: `schedule due` prints "1" only when the set of
|
||||
# active time-scheduled rules changed since the last tick, so we
|
||||
# reconcile a few times a day at window edges — not every minute.
|
||||
sched=$("$XRAYCTL" schedule due 2>/dev/null)
|
||||
if [ -n "$changed" ] || [ -n "$sched" ]; then
|
||||
"$XRAYCTL" reconcile >/dev/null 2>&1
|
||||
fi
|
||||
dead=$(shater_watchdog "$dead")
|
||||
else
|
||||
dead=0
|
||||
fi
|
||||
sleep "$TICK"
|
||||
done
|
||||
}
|
||||
|
||||
# --- procd lifecycle -------------------------------------------------------
|
||||
|
||||
start_service() {
|
||||
[ -x "$XRAYCTL" ] || return 0
|
||||
shater_enabled || return 0
|
||||
|
||||
procd_open_instance shater-cron
|
||||
# Re-exec ourselves as the loop body so procd supervises a single process.
|
||||
# Invoke through the rc.common shebang (NOT `/bin/sh $INIT_SCRIPT`) so the
|
||||
# `loop` action is dispatched by rc.common and procd tracks the resulting
|
||||
# foreground PID — running `/bin/sh <script> loop` detaches the loop from
|
||||
# procd (instance shows not-running, and procd respawn-churns it).
|
||||
procd_set_param command "$INIT_SCRIPT" loop
|
||||
procd_set_param respawn 3600 10 0 # threshold timeout always-retry
|
||||
procd_set_param file /etc/config/shater # restart the loop when UCI changes
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
# UCI changed: bounce the loop so new intervals/items take effect. If the
|
||||
# stack was disabled, start re-guards to a no-op.
|
||||
stop
|
||||
start
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "shater"
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
# /etc/sysctl.d/99-shater.conf
|
||||
# Kernel knobs required by the TPROXY + policy-routing data plane.
|
||||
# Applied at boot by procd's sysctl service; 30_shater-core also applies it
|
||||
# once at install time so a fresh install works without a reboot.
|
||||
|
||||
# Route between LAN and WAN.
|
||||
net.ipv4.ip_forward=1
|
||||
|
||||
# rp_filter MUST be loose/off: strict reverse-path filtering silently DROPS the
|
||||
# tproxied packets (LAN source arriving via the `local ... dev lo` trick).
|
||||
# Classic "rules match but nothing works".
|
||||
net.ipv4.conf.all.rp_filter=0
|
||||
net.ipv4.conf.default.rp_filter=0
|
||||
|
||||
# Allow routing to 127/8 so `ip route add local 0.0.0.0/0 dev lo table <N>`
|
||||
# delivers foreign-destination packets to the transparent socket. Scoped to
|
||||
# `lo` only — that is the only path the tproxy local-delivery trick uses.
|
||||
# Setting it on `all` would make WAN-originated packets destined to 127/8
|
||||
# routable (a real anti-spoofing weakening we do not need).
|
||||
net.ipv4.conf.lo.route_localnet=1
|
||||
|
||||
# Accept packets with a local source address arriving on the loopback path.
|
||||
# Same scoping rationale: needed on `lo` for the looped tproxy packets, and
|
||||
# on `all` it would accept local-source spoofs from any interface incl. WAN.
|
||||
net.ipv4.conf.lo.accept_local=1
|
||||
|
||||
# Keep the source-address validity check happy for marked/looped packets.
|
||||
net.ipv4.conf.all.src_valid_mark=1
|
||||
|
||||
# IPv6 forwarding (globals.ipv6 defaults on; mirror the data plane for v6).
|
||||
net.ipv6.conf.all.forwarding=1
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/bin/sh
|
||||
# /etc/uci-defaults/30_shater-core
|
||||
#
|
||||
# One-time, idempotent setup for shater-core. Runs on first boot (and via the
|
||||
# default postinst on a live opkg/apk install). Must exit 0 so it is cleared and
|
||||
# not retried. Everything here is safe to run more than once.
|
||||
|
||||
RT_TABLES=/etc/iproute2/rt_tables
|
||||
|
||||
# Append "<id> <name>" to rt_tables only if neither the id nor the name is
|
||||
# already present. Purely cosmetic (readable `ip rule`/`ip route` output);
|
||||
# xrayctl allocates the rest of the reserved block numerically.
|
||||
seed_rt_table() {
|
||||
local id="$1" name="$2"
|
||||
[ -f "$RT_TABLES" ] || return 0
|
||||
grep -qE "^[[:space:]]*${id}[[:space:]]" "$RT_TABLES" && return 0
|
||||
grep -qE "[[:space:]]${name}[[:space:]]*\$" "$RT_TABLES" && return 0
|
||||
printf '%s\t%s\n' "$id" "$name" >> "$RT_TABLES"
|
||||
}
|
||||
|
||||
# Reserved routing-table base 0x2000 == 8192.
|
||||
seed_rt_table 8192 shater
|
||||
|
||||
# Enable the service at boot. The init guard keeps everything inert until
|
||||
# globals.enabled='1' and /etc/xray/run.json exist, so enabling here can never
|
||||
# break connectivity on a fresh box.
|
||||
[ -x /etc/init.d/shater ] && /etc/init.d/shater enable
|
||||
|
||||
# Enable the per-item auto-update loop (subscriptions / url rulesets). It is
|
||||
# equally inert until globals.enabled='1', so enabling on a fresh box is safe.
|
||||
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron enable
|
||||
|
||||
# Seed the built-in preset packs (disabled) so the LuCI Rules page renders their
|
||||
# toggles. Idempotent: only creates a section that does not yet exist.
|
||||
seed_preset() {
|
||||
local sid="$1" name="$2" s n
|
||||
uci -q get "shater.$sid" >/dev/null 2>&1 && return 0
|
||||
# A pack section may already exist under a DIFFERENT section id (created by
|
||||
# the LuCI seeding or an older release) — match by pack name, not just id,
|
||||
# or we would duplicate the toggle.
|
||||
for s in $(uci -q show shater 2>/dev/null | sed -n "s/^shater\.\([^.=]*\)=preset$/\1/p"); do
|
||||
n=$(uci -q get "shater.$s.name")
|
||||
[ "$n" = "$name" ] && return 0
|
||||
done
|
||||
uci set "shater.$sid=preset"
|
||||
uci set "shater.$sid.name=$name"
|
||||
uci set "shater.$sid.enabled=0"
|
||||
}
|
||||
if uci -q get shater.globals >/dev/null 2>&1 || [ -f /etc/config/shater ]; then
|
||||
seed_preset block_ads block-ads
|
||||
seed_preset ru_bypass ru-bypass
|
||||
seed_preset private private
|
||||
uci -q commit shater
|
||||
fi
|
||||
|
||||
# Bring the UCI schema forward on upgrade (idempotent; refuses a newer schema).
|
||||
[ -x /usr/bin/xrayctl ] && /usr/bin/xrayctl migrate >/dev/null 2>&1
|
||||
|
||||
# Apply our sysctl knobs NOW (boot applies them via procd's sysctl service, but
|
||||
# on a live opkg/apk install nothing else re-reads sysctl.d — without this, an
|
||||
# install→enable→apply flow hits the rp_filter "rules match but nothing works"
|
||||
# failure until the first reboot). Idempotent; unknown keys are ignored.
|
||||
[ -f /etc/sysctl.d/99-shater.conf ] && sysctl -p /etc/sysctl.d/99-shater.conf >/dev/null 2>&1
|
||||
|
||||
exit 0
|
||||
@@ -1,61 +0,0 @@
|
||||
shater-core — data-plane glue for the Shater transparent proxy
|
||||
==============================================================
|
||||
|
||||
This package is the static, rock-solid ("железно") layer beneath the Go
|
||||
control-plane (xrayctl) and the LuCI app. It ships no business logic and
|
||||
compiles nothing; it only wires the kernel + procd so xrayctl's generated
|
||||
artifacts come to life reliably.
|
||||
|
||||
Files installed
|
||||
---------------
|
||||
/etc/init.d/shater
|
||||
procd supervisor (START=99). Runs the stock xray engine
|
||||
(/usr/bin/xray run -c /etc/xray/run.json) as a supervised, respawning
|
||||
instance and drives `xrayctl reconcile` on start to (re)persist policy
|
||||
routing and our nft table. Fully inert unless globals.enabled=1 AND
|
||||
/etc/xray/run.json exists. stop tears down only our own resources.
|
||||
|
||||
/etc/hotplug.d/iface/99-shater
|
||||
On ifup/ifdown, re-runs `xrayctl reconcile` (ip rules/routes are volatile
|
||||
and wiped on `network reload`). Inert unless enabled.
|
||||
|
||||
/etc/sysctl.d/99-shater.conf
|
||||
ip_forward, rp_filter=0, route_localnet=1, accept_local=1,
|
||||
src_valid_mark=1 (+ IPv6 forwarding) — required for TPROXY + the
|
||||
`local ... dev lo` policy-routing trick.
|
||||
|
||||
/etc/config/shater
|
||||
Minimal INERT UCI default (globals.enabled='0'). Installed as a conffile:
|
||||
your edits survive upgrades. Full schema in docs/CONFIG.md.
|
||||
|
||||
/etc/uci-defaults/30_shater-core
|
||||
First-boot idempotent setup: seeds an rt_tables name for the reserved
|
||||
table base (0x2000) and enables the service.
|
||||
|
||||
Ownership & reliability invariants
|
||||
----------------------------------
|
||||
* We own the nft table `inet shater` (generated by xrayctl at apply time,
|
||||
not shipped here) — fw4's table is NEVER touched.
|
||||
* Reserved fwmark base 0x2000, routing-table base 0x2000.
|
||||
* Management/LAN/SSH/LuCI paths are always bypassed (enforced by xrayctl's
|
||||
generated ruleset); this package's teardown only removes `inet shater` and
|
||||
routing tables in the reserved block.
|
||||
* Safe on boot even if xray or run.json is absent: the init exits cleanly and
|
||||
never breaks connectivity.
|
||||
|
||||
Runtime layout (created/managed by xrayctl, for reference)
|
||||
----------------------------------------------------------
|
||||
/etc/xray/run.json generated live xray config (do not hand-edit)
|
||||
/etc/xray/last-good.json last valid config (rollback)
|
||||
/etc/xray/nft/shater.nft generated nft table `inet shater`
|
||||
/var/run/xray/ runtime: pid, pending-apply, stats
|
||||
|
||||
Operating
|
||||
---------
|
||||
Configure via LuCI or `uci set shater.globals.enabled=1` then apply:
|
||||
xrayctl apply # gen -> test -> atomic apply (+ optional confirm)
|
||||
/etc/init.d/shater start # or restart; reconcile runs automatically
|
||||
Inspect:
|
||||
/etc/init.d/shater status ; logread -e xray
|
||||
ip rule show ; ip route show table 8192
|
||||
nft list table inet shater
|
||||
@@ -1,57 +0,0 @@
|
||||
#
|
||||
# OpenWrt package Makefile for xrayctl — the xray control-plane daemon.
|
||||
# Cross-compiled through the OpenWrt SDK against musl (golang-package.mk).
|
||||
#
|
||||
# Absolute path to THIS package's directory (robust for src-link feeds), captured
|
||||
# before any include changes $(MAKEFILE_LIST).
|
||||
XRAYCTL_DIR:=$(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=xrayctl
|
||||
PKG_VERSION:=0.1.0
|
||||
PKG_RELEASE:=19
|
||||
|
||||
PKG_MAINTAINER:=shater
|
||||
PKG_LICENSE:=GPL-2.0-or-later
|
||||
|
||||
# Build from the source tree shipped alongside this Makefile (no remote tarball).
|
||||
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
|
||||
|
||||
# Go import path == module name (see go.mod: `module xrayctl`).
|
||||
GO_PKG:=xrayctl
|
||||
PKG_BUILD_DEPENDS:=golang/host
|
||||
PKG_BUILD_FLAGS:=no-mips16
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
|
||||
|
||||
define Package/xrayctl
|
||||
SECTION:=net
|
||||
CATEGORY:=Network
|
||||
TITLE:=xray control-plane (UCI -> xray JSON + nft + policy-routing)
|
||||
URL:=https://github.com/shater/xrayctl
|
||||
DEPENDS:=$(GO_ARCH_DEPENDS) +ca-bundle
|
||||
endef
|
||||
|
||||
define Package/xrayctl/description
|
||||
xrayctl reads the UCI desired-state (/etc/config/xray), renders an xray JSON
|
||||
config, an nftables tproxy table (inet shater) and policy-routing, then applies
|
||||
them atomically with commit-confirm/rollback. Subscriptions, multi-hop chains,
|
||||
balancer+observatory, per-client/domain/geo routing and DNS split.
|
||||
endef
|
||||
|
||||
# Copy the local Go sources into the build dir before compiling.
|
||||
define Build/Prepare
|
||||
mkdir -p $(PKG_BUILD_DIR)
|
||||
$(CP) $(XRAYCTL_DIR)*.go $(XRAYCTL_DIR)go.mod $(PKG_BUILD_DIR)/
|
||||
endef
|
||||
|
||||
define Package/xrayctl/install
|
||||
$(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
|
||||
$(INSTALL_DIR) $(1)/usr/bin
|
||||
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/xrayctl $(1)/usr/bin/xrayctl
|
||||
endef
|
||||
|
||||
$(eval $(call GoBinPackage,xrayctl))
|
||||
$(eval $(call BuildPackage,xrayctl))
|
||||
@@ -1,840 +0,0 @@
|
||||
package main
|
||||
|
||||
// Atomic apply pipeline: gen -> `xray -test` -> write run.json -> reload xray ->
|
||||
// load our own nft table (inet shater) -> reconcile policy routing -> optional
|
||||
// commit-confirm with auto-rollback. Our resources only: nft table `inet shater`,
|
||||
// fwmark from fwmark_base, route tables from table_base. fw4 is never touched.
|
||||
//
|
||||
// Reliability invariants encoded here:
|
||||
// * mgmt-bypass: router-originated egress + SSH/LuCI/LAN/DNS-to-router are NEVER
|
||||
// intercepted. Interception only ever happens on prerouting for LAN-ingress
|
||||
// (iifname) traffic; `fib daddr type local accept` bypasses anything destined
|
||||
// to a router-owned address on any interface.
|
||||
// * snapshot/rollback: apply snapshots the full prior state (run.json + our nft
|
||||
// table + our ip rule/route). rollback restores all of it atomically.
|
||||
// * commit-confirm: `apply --confirm N` arms a fully-detached auto-rollback that
|
||||
// survives the CLI exiting; `confirm` cancels it.
|
||||
// * reconcile: idempotent; detects drift (our table/rule vanished while enabled)
|
||||
// and re-applies; tears everything down when disabled.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runJSON is a var (not const) so tests can redirect it.
|
||||
var runJSON = "/etc/xray/run.json"
|
||||
|
||||
const (
|
||||
lastGood = "/etc/xray/last-good.json"
|
||||
pendingFlag = "/var/run/xray/pending-apply"
|
||||
nftFile = "/etc/xray/nft/shater.nft"
|
||||
routeSnap = "/etc/xray/snapshot/route.json"
|
||||
)
|
||||
|
||||
// GenerateConfig loads UCI (+ sub caches) and renders the xray JSON.
|
||||
func GenerateConfig() (map[string]any, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
return BuildConfig(m)
|
||||
}
|
||||
|
||||
// writeJSON marshals cfg to path (creating parent dirs), atomically.
|
||||
func writeJSON(path string, cfg any) error {
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeBytesAtomic(path, b)
|
||||
}
|
||||
|
||||
// writeBytesAtomic writes b to path (creating parent dirs) via tmp + rename so
|
||||
// a reader never sees a partial file. xray is (re)started to pick up a new
|
||||
// run.json by syncRunJSON/reloadXray; procd's own file-watch is not relied
|
||||
// upon (it does not fire passively on this build).
|
||||
func writeBytesAtomic(path string, b []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// TestConfig writes the config to a temp file and validates it with `xray -test`.
|
||||
func TestConfig(cfg map[string]any) error {
|
||||
// Unique per-invocation temp file (os.CreateTemp, 0600): a fixed predictable
|
||||
// path is both a symlink/pre-creation target in a shared tmp and a race when
|
||||
// two validations run concurrently (LuCI apply + cron reconcile).
|
||||
f, err := os.CreateTemp("", "xrayctl-test-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
_ = f.Close()
|
||||
defer os.Remove(tmp)
|
||||
if err := writeJSON(tmp, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := exec.Command("xray", "-test", "-c", tmp).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("xray -test failed: %v\n%s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncRunJSON is the single write+reload path shared by Apply and Reconcile:
|
||||
// it writes the rendered config to run.json and bounces the engine ONLY when
|
||||
// the rendered bytes differ (sha256) from what is already on disk. An
|
||||
// unchanged config skips both the write and the restart, so a UCI commit that
|
||||
// does not change the effective engine config no longer bounces the tunnel.
|
||||
//
|
||||
// allowColdStart governs the engine-not-running case. Apply (user-driven) may
|
||||
// cold-start via the init script (see reloadXray). Reconcile must NOT: it is
|
||||
// invoked FROM the service lifecycle, and calling back into /etc/init.d/shater
|
||||
// re-enters reload_service -> shater_reconcile -> Reconcile — the unbounded
|
||||
// fork storm that OOMs the box (see reloadXray's comment). During the
|
||||
// lifecycle procd itself starts the engine, so Reconcile only ever needs the
|
||||
// SIGTERM -> respawn restart of an already-running engine.
|
||||
func syncRunJSON(cfg map[string]any, allowColdStart bool) (changed bool, err error) {
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if prev, rerr := os.ReadFile(runJSON); rerr == nil && sha256.Sum256(prev) == sha256.Sum256(b) {
|
||||
if allowColdStart && len(xrayPIDs()) == 0 {
|
||||
return false, reloadXray() // config current but engine down -> cold start
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if err := writeBytesAtomic(runJSON, b); err != nil {
|
||||
return true, err
|
||||
}
|
||||
if restartXrayIfRunning() {
|
||||
return true, nil
|
||||
}
|
||||
if allowColdStart {
|
||||
return true, reloadXray()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Apply runs the full pipeline. confirmTimeout>0 arms commit-confirm.
|
||||
func Apply(confirmTimeout int) error {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
|
||||
// Snapshot the current live state (run.json + our nft table + our routing)
|
||||
// so a rollback — manual or commit-confirm — can restore all of it.
|
||||
apSnapshot(m)
|
||||
|
||||
// Disabled globally => tear our resources down (fail-safe: no interception),
|
||||
// leaving xray/procd to manage the engine. Never breaks mgmt.
|
||||
if !m.Globals.Enabled {
|
||||
return apTeardown(m)
|
||||
}
|
||||
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := TestConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
// Preserve current run.json as last-good only after a successful test.
|
||||
if b, err := os.ReadFile(runJSON); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(lastGood), 0o755)
|
||||
_ = os.WriteFile(lastGood, b, 0o644)
|
||||
}
|
||||
if _, err := syncRunJSON(cfg, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyNft(m); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyRouting(m); err != nil {
|
||||
return err
|
||||
}
|
||||
if confirmTimeout > 0 {
|
||||
_ = os.MkdirAll(filepath.Dir(pendingFlag), 0o755)
|
||||
deadline := time.Now().Add(time.Duration(confirmTimeout) * time.Second).Unix()
|
||||
// The nonce ties THIS apply to ITS watcher: a later apply re-arms the
|
||||
// flag with a fresh nonce, so an earlier (still-sleeping) watcher finds
|
||||
// a mismatch and does nothing instead of rolling back the newer apply.
|
||||
nonce := apNonce()
|
||||
_ = os.WriteFile(pendingFlag, []byte(fmt.Sprintf("%d\n%d\n%s\n", confirmTimeout, deadline, nonce)), 0o644)
|
||||
// Arm a fully-detached rollback; `confirm` cancels it (removes the flag).
|
||||
apArmAutoRollback(confirmTimeout, nonce)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apNonce returns a random hex token for the commit-confirm flag; falls back
|
||||
// to a timestamp when the entropy source is unavailable (still unique enough
|
||||
// to disambiguate overlapping confirm windows).
|
||||
func apNonce() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// Confirm cancels a pending auto-rollback.
|
||||
func Confirm() error {
|
||||
if _, err := os.Stat(pendingFlag); err != nil {
|
||||
return fmt.Errorf("no pending apply")
|
||||
}
|
||||
return os.Remove(pendingFlag)
|
||||
}
|
||||
|
||||
// Rollback restores the full snapshot (nft + routing + run.json) atomically and
|
||||
// reloads xray. Connectivity is restored first (nft/routing), then the engine.
|
||||
// If there is no last-good snapshot (e.g. a broken first apply) we tear our
|
||||
// resources down so the router is never left in an intercepting/broken state.
|
||||
func Rollback() error {
|
||||
snap := apReadRouteSnap()
|
||||
m, mErr := ReadUCI()
|
||||
if mErr != nil {
|
||||
m = &Model{Globals: defaultGlobals()}
|
||||
}
|
||||
mark, table := snap.Mark, snap.Table
|
||||
if mark == 0 {
|
||||
mark = m.Globals.FwmarkBase
|
||||
}
|
||||
if table == 0 {
|
||||
table = m.Globals.TableBase
|
||||
}
|
||||
|
||||
// 1) Restore our nft table first (connectivity-critical).
|
||||
if snap.NftPresent {
|
||||
if b, err := os.ReadFile(nftLastGood); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(nftFile), 0o755)
|
||||
_ = os.WriteFile(nftFile, b, 0o644)
|
||||
if out, err := exec.Command("nft", "-f", nftLastGood).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("rollback nft -f: %v\n%s", err, out)
|
||||
}
|
||||
} else {
|
||||
apDeleteTable()
|
||||
}
|
||||
} else {
|
||||
apDeleteTable()
|
||||
}
|
||||
|
||||
// 2) Restore our policy routing.
|
||||
if snap.RoutePresent {
|
||||
apAddRouting(mark, table)
|
||||
} else {
|
||||
apRemoveRouting(mark, table)
|
||||
}
|
||||
|
||||
// 3) Restore xray run.json + reload (if we have a last-good).
|
||||
_ = os.Remove(pendingFlag)
|
||||
if b, err := os.ReadFile(lastGood); err == nil {
|
||||
if err := os.WriteFile(runJSON, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return reloadXray()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconcile is an idempotent re-apply (hotplug/boot/watchdog). It tears down when
|
||||
// disabled, and when enabled it re-applies only if it detects drift (our nft
|
||||
// table or ip rule vanished) — otherwise it still ensures xray's run.json is
|
||||
// current. Never arms commit-confirm and never rotates last-good.
|
||||
func Reconcile() error {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
|
||||
if !m.Globals.Enabled {
|
||||
return apTeardown(m)
|
||||
}
|
||||
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := TestConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
// Write run.json + restart the engine ONLY when the rendered config actually
|
||||
// changed (syncRunJSON hash-compares). procd's passive file-watch on run.json
|
||||
// does NOT fire on this build, so a mere write never reaches the running
|
||||
// engine — the SIGTERM -> respawn restart inside syncRunJSON is what does.
|
||||
// allowColdStart=false: Reconcile is invoked FROM the shater service
|
||||
// lifecycle (start_service / reload_service / service_started / hotplug /
|
||||
// watchdog); calling `/etc/init.d/shater` back would re-enter reload_service
|
||||
// -> shater_reconcile -> Reconcile -> ... an unbounded fork storm that OOMs
|
||||
// the box. When the engine is down, procd's own lifecycle starts it.
|
||||
if _, err := syncRunJSON(cfg, false); err != nil {
|
||||
return err
|
||||
}
|
||||
// Drift detection: re-apply nft/routing when our resources are missing, and
|
||||
// always ensure they are present (idempotent — delete-then-add semantics).
|
||||
if err := applyNft(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyRouting(m)
|
||||
}
|
||||
|
||||
// apDrifted reports whether our enabled resources are missing from the kernel.
|
||||
func apDrifted(m *Model) bool {
|
||||
if !m.Globals.Enabled {
|
||||
return false
|
||||
}
|
||||
if !nftTableExists() {
|
||||
return true
|
||||
}
|
||||
return !apRoutingPresent(m.Globals.FwmarkBase, m.Globals.TableBase)
|
||||
}
|
||||
|
||||
func reloadXray() error {
|
||||
// Our procd service /etc/init.d/shater runs `xray run -c /etc/xray/run.json`
|
||||
// with a file-watch on run.json (`procd_set_param file $RUN_JSON`). Callers
|
||||
// (Apply/Rollback) have ALREADY written the fresh run.json.
|
||||
//
|
||||
// * If xray is already supervised, procd's file-watch on run.json restarts it
|
||||
// with the new config on its own — we must do NOTHING here. In particular we
|
||||
// must NOT run `/etc/init.d/shater start`: that re-runs start_service ->
|
||||
// shater_reconcile -> `xrayctl reconcile`, which regenerates run.json from
|
||||
// UCI and would immediately clobber a Rollback's just-restored run.json (and
|
||||
// needlessly double-work every Apply).
|
||||
// * Only when xray is NOT running (first apply / it was stopped) do we `start`
|
||||
// it. That path also reconciles, which is correct for a cold start.
|
||||
//
|
||||
// We never use `reload` (it re-enters reload_service -> reconcile) and never poke
|
||||
// the stock `service xray` (different schema, not ours).
|
||||
if _, err := os.Stat("/etc/init.d/shater"); err != nil {
|
||||
return nil // no service (dev/test) — nothing to (re)start
|
||||
}
|
||||
// If xray is already supervised, restart it by killing the process: procd's
|
||||
// respawn brings it straight back up against the run.json now on disk, WITHOUT
|
||||
// re-running start_service -> shater_reconcile (which regenerates run.json from
|
||||
// UCI and would clobber a Rollback's restored file). procd's passive file-watch
|
||||
// does not fire on this build, and calling the init `start`/`reload` reconciles,
|
||||
// so a direct SIGTERM + respawn is the one path that reloads the engine without
|
||||
// touching run.json. The init uses a low respawn threshold so these operator-
|
||||
// driven restarts are never mistaken for a crash loop.
|
||||
if restartXrayIfRunning() {
|
||||
return nil
|
||||
}
|
||||
// Not running — cold start (start_service reconciles, which is correct here).
|
||||
if out, err := exec.Command("/etc/init.d/shater", "start").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("shater start: %v\n%s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartXrayIfRunning SIGTERMs a running engine so procd's respawn brings it
|
||||
// straight back up against the run.json now on disk, without re-entering the
|
||||
// init script (see reloadXray). Reports whether an engine was running.
|
||||
func restartXrayIfRunning() bool {
|
||||
pids := xrayPIDs()
|
||||
for _, pid := range pids {
|
||||
_ = exec.Command("kill", pid).Run()
|
||||
}
|
||||
return len(pids) > 0
|
||||
}
|
||||
|
||||
// xrayPIDs returns the pids of the running xray engine (exact program name).
|
||||
func xrayPIDs() []string {
|
||||
out, err := exec.Command("pidof", "xray").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// applyNft renders our own `inet shater` table and loads it atomically (nft -f).
|
||||
func applyNft(m *Model) error {
|
||||
rules := RenderNft(m)
|
||||
if err := os.MkdirAll(filepath.Dir(nftFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(nftFile, []byte(rules), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if out, err := exec.Command("nft", "-c", "-f", nftFile).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("nft -c failed: %v\n%s", err, out)
|
||||
}
|
||||
if out, err := exec.Command("nft", "-f", nftFile).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("nft -f failed: %v\n%s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ifaceDevice resolves a UCI interface name (e.g. "lan") to its actual L3 device
|
||||
// (e.g. "br-lan") for nft iifname matching — bridges/VLANs have device != name,
|
||||
// so matching the raw UCI name would silently intercept nothing.
|
||||
func ifaceDevice(name string) string {
|
||||
if name == "" {
|
||||
return "br-lan"
|
||||
}
|
||||
if out, err := exec.Command("ubus", "call", "network.interface."+name, "status").Output(); err == nil {
|
||||
var st map[string]any
|
||||
if json.Unmarshal(out, &st) == nil {
|
||||
if d, ok := st["l3_device"].(string); ok && d != "" {
|
||||
return d
|
||||
}
|
||||
if d, ok := st["device"].(string); ok && d != "" {
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
if out, err := exec.Command("uci", "-q", "get", "network."+name+".device").Output(); err == nil {
|
||||
if d := strings.TrimSpace(string(out)); d != "" {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// RenderNft builds the `inet shater` tproxy table text.
|
||||
//
|
||||
// mgmt-bypass (unconditional, in order): loop-guard mark, router-local dsts
|
||||
// (fib daddr type local — SSH/LuCI/DNS to any router IP), RFC1918 + loopback +
|
||||
// link-local + multicast/broadcast, and the IPv6 mirror. Only LAN-ingress
|
||||
// (iifname) traffic past those bypasses is diverted, so router-originated egress
|
||||
// is structurally never touched.
|
||||
//
|
||||
// Chains: prerouting (mangle) holds the tproxy diverts and only accept verbs;
|
||||
// a separate forward (filter) chain holds everything needing reject/drop —
|
||||
// the DoT/DoQ :853 reject and the IPv6 fail-closed drop when ipv6 is disabled
|
||||
// with a closed kill-switch (reject is illegal in prerouting-hook chains: the
|
||||
// kernel refuses to load the ruleset). dnsnat (nat prerouting) hijacks LAN :53.
|
||||
//
|
||||
// Accounting: a dynamic `clients` set gives per-LAN-source byte counters; named
|
||||
// `c_rule_<name>` counters attribute diverted bytes to the rule whose src
|
||||
// (CIDR/host/MAC/iface/zone) matched; `c_in_<name>` counts each inbound's
|
||||
// catch-all divert.
|
||||
func RenderNft(m *Model) string {
|
||||
tproxyMark := int(m.Globals.FwmarkBase)
|
||||
inboundDevs := nftEnabledInboundDevs(m)
|
||||
primary, hasPrimary := nftPrimaryInbound(m)
|
||||
|
||||
// Build the classification/divert lines into a buffer, collecting referenced
|
||||
// counters so we can declare exactly those.
|
||||
var body strings.Builder
|
||||
used := map[string]bool{}
|
||||
var order []string
|
||||
useCounter := func(name string) string {
|
||||
if name != "" && !used[name] {
|
||||
used[name] = true
|
||||
order = append(order, name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
emit := func(iif, l4, match string, fam int, port, mark int, counter string) {
|
||||
var b strings.Builder
|
||||
b.WriteString("\t\t")
|
||||
if iif != "" {
|
||||
b.WriteString(iif + " ")
|
||||
}
|
||||
b.WriteString("meta l4proto " + l4 + " ")
|
||||
if match != "" {
|
||||
b.WriteString(match + " ")
|
||||
}
|
||||
if fam == 6 {
|
||||
b.WriteString("update @" + nftClient6 + " { ip6 saddr } ")
|
||||
} else {
|
||||
b.WriteString("update @" + nftClient4 + " { ip saddr } ")
|
||||
}
|
||||
if counter != "" {
|
||||
b.WriteString("counter name \"" + counter + "\" ")
|
||||
}
|
||||
if fam == 6 {
|
||||
b.WriteString(fmt.Sprintf("tproxy ip6 to :%d ", port))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("tproxy ip to :%d ", port))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("meta mark set 0x%x accept\n", mark))
|
||||
body.WriteString(b.String())
|
||||
}
|
||||
|
||||
// emitFrag expands one src fragment into the l4 x family lines it needs.
|
||||
emitFrag := func(f nftFrag, l4 string, port, mark int, counter string) {
|
||||
iif := nftIifExpr(f.iif)
|
||||
switch f.fam {
|
||||
case 4:
|
||||
emit(iif, l4, f.match, 4, port, mark, counter)
|
||||
case 6:
|
||||
if m.Globals.IPv6 {
|
||||
emit(iif, l4, f.match, 6, port, mark, counter)
|
||||
}
|
||||
default: // both
|
||||
emit(iif, l4, f.match, 4, port, mark, counter)
|
||||
if m.Globals.IPv6 {
|
||||
emit(iif, l4, f.match, 6, port, mark, counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Per-rule src classification (first-match order). Only rules with a src
|
||||
// selector produce nft lines; dst/target decisions stay inside xray.
|
||||
if hasPrimary && len(inboundDevs) > 0 {
|
||||
for _, r := range sortedRules(m) {
|
||||
if !r.Enabled || len(r.Src) == 0 {
|
||||
continue
|
||||
}
|
||||
frags := nftSrcFrags(r, inboundDevs)
|
||||
if len(frags) == 0 {
|
||||
continue
|
||||
}
|
||||
cnt := useCounter(nftRuleCounter(r))
|
||||
for _, f := range frags {
|
||||
if primary.TCP {
|
||||
emitFrag(f, "tcp", primary.TproxyPort, tproxyMark, cnt)
|
||||
}
|
||||
if primary.UDP {
|
||||
emitFrag(f, "udp", primary.TproxyPort, tproxyMark, cnt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Per-inbound catch-all divert (all remaining LAN-ingress traffic).
|
||||
// Only tproxy inbounds divert; local socks/http/dokodemo listeners must not
|
||||
// inject a bogus LAN tproxy rule pointing at their listener port.
|
||||
for _, in := range m.Inbounds {
|
||||
if !isTproxyInbound(in) {
|
||||
continue
|
||||
}
|
||||
dev := ifaceDevice(in.Network)
|
||||
iif := nftIifExpr([]string{dev})
|
||||
cnt := useCounter(nftInCounter(in))
|
||||
if in.TCP {
|
||||
emit(iif, "tcp", "", 4, in.TproxyPort, tproxyMark, cnt)
|
||||
if m.Globals.IPv6 {
|
||||
emit(iif, "tcp", "", 6, in.TproxyPort, tproxyMark, cnt)
|
||||
}
|
||||
}
|
||||
if in.UDP {
|
||||
emit(iif, "udp", "", 4, in.TproxyPort, tproxyMark, cnt)
|
||||
if m.Globals.IPv6 {
|
||||
emit(iif, "udp", "", 6, in.TproxyPort, tproxyMark, cnt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble the table: sets + counter declarations + chain.
|
||||
var sb strings.Builder
|
||||
sb.WriteString("#!/usr/sbin/nft -f\n")
|
||||
sb.WriteString("table inet shater\n")
|
||||
sb.WriteString("delete table inet shater\n")
|
||||
sb.WriteString("table inet shater {\n")
|
||||
sb.WriteString("\tset " + nftClient4 + " { type ipv4_addr; flags dynamic; counter; }\n")
|
||||
if m.Globals.IPv6 {
|
||||
sb.WriteString("\tset " + nftClient6 + " { type ipv6_addr; flags dynamic; counter; }\n")
|
||||
}
|
||||
for _, c := range order {
|
||||
sb.WriteString(fmt.Sprintf("\tcounter %s { }\n", c))
|
||||
}
|
||||
sb.WriteString("\tchain prerouting {\n")
|
||||
sb.WriteString("\t\ttype filter hook prerouting priority mangle; policy accept;\n")
|
||||
// --- mgmt-bypass (must precede any divert) ---
|
||||
sb.WriteString(fmt.Sprintf("\t\tmeta mark 0x%x accept\n", loopMark))
|
||||
// Egress-marked traffic (interface/tunnel egresses) must never be re-diverted.
|
||||
for i, eg := range m.Egresses {
|
||||
if t := strings.ToLower(eg.Type); t == "interface" || t == "tunnel" {
|
||||
sb.WriteString(fmt.Sprintf("\t\tmeta mark 0x%x accept\n", egEgressMark(m.Globals, i)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\t\tfib daddr type local accept\n")
|
||||
sb.WriteString("\t\tip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 224.0.0.0/4, 255.255.255.255 } accept\n")
|
||||
sb.WriteString("\t\tip6 daddr { ::1, fc00::/7, fe80::/10, ff00::/8 } accept\n")
|
||||
// --- DNS anti-leak: keep client :53 OUT of tproxy so the dnsnat chain can
|
||||
// redirect it to the router's resolver (below). Without this the catch-all
|
||||
// diverts :53 into xray, which then has no listener to answer it. ---
|
||||
dnsIif := nftIifExpr(inboundDevs)
|
||||
if dnsIif != "" {
|
||||
// Keep encrypted-DNS bypass ports (DoT :853 tcp, DoQ :853 udp) OUT of
|
||||
// tproxy here so those packets actually traverse the forward chain, where
|
||||
// they are REJECTED. The reject itself cannot live in this chain: the
|
||||
// kernel refuses `reject` outside input/forward/output hooks, so a reject
|
||||
// here makes the whole ruleset fail to load. DoH (:443) is not
|
||||
// port-blockable without an IP list; it is still SNI-routed by the proxy.
|
||||
sb.WriteString("\t\t" + dnsIif + " meta l4proto { tcp, udp } th dport 853 accept\n")
|
||||
// Keep client :53 OUT of tproxy so the dnsnat chain can redirect it to the
|
||||
// router's resolver; the catch-all would otherwise divert :53 into xray,
|
||||
// which has no listener to answer it.
|
||||
sb.WriteString("\t\t" + dnsIif + " meta l4proto { tcp, udp } th dport 53 accept\n")
|
||||
}
|
||||
sb.WriteString(body.String())
|
||||
sb.WriteString("\t}\n")
|
||||
// --- forward: policy enforcement that needs a reject/drop verb. `reject` is
|
||||
// only legal in input/forward/output-hook chains; tproxy'd traffic never
|
||||
// reaches forward (it is delivered locally), so everything the prerouting
|
||||
// chain explicitly accepts past the tproxy divert lands here. ---
|
||||
ipv6Off := !m.Globals.IPv6 && dnsIif != ""
|
||||
if dnsIif != "" {
|
||||
sb.WriteString("\tchain forward {\n")
|
||||
sb.WriteString("\t\ttype filter hook forward priority filter; policy accept;\n")
|
||||
// DoT/DoQ hard-block (accepted past tproxy in prerouting above): clients
|
||||
// fall back to plain :53, which the dnsnat chain hijacks to the router.
|
||||
sb.WriteString("\t\t" + dnsIif + " meta l4proto { tcp, udp } th dport 853 reject\n")
|
||||
switch {
|
||||
case ipv6Off && genGlobalClosed(m.Globals):
|
||||
// ipv6 disabled + closed kill-switch: no v6 divert is emitted above, so
|
||||
// dual-stack LAN clients would silently BYPASS the proxy over IPv6.
|
||||
// Fail closed: drop LAN-ingress IPv6, keeping the link-local plane
|
||||
// (ND/RA ICMPv6, fe80::/10, multicast) alive so the LAN itself works.
|
||||
sb.WriteString("\t\t" + dnsIif + " icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept\n")
|
||||
sb.WriteString("\t\t" + dnsIif + " ip6 daddr fe80::/10 accept\n")
|
||||
sb.WriteString("\t\t" + dnsIif + " ip6 daddr ff00::/8 accept\n")
|
||||
sb.WriteString("\t\t" + dnsIif + " meta nfproto ipv6 drop\n")
|
||||
case ipv6Off:
|
||||
// ipv6 disabled but kill_switch=open: IPv6 from the LAN bypasses the
|
||||
// proxy undiverted and undropped (documented fail-open behavior).
|
||||
sb.WriteString("\t\t# ipv6=0, kill_switch=open: LAN IPv6 bypasses the proxy (not dropped)\n")
|
||||
}
|
||||
sb.WriteString("\t}\n")
|
||||
}
|
||||
// --- dnsnat: hijack LAN :53 to the router's own resolver (dnsmasq), so no LAN
|
||||
// client can bypass it with a hard-coded upstream. Router-destined DNS is left
|
||||
// alone (fib local); everything else on :53 is redirected to the router. ---
|
||||
if dnsIif != "" {
|
||||
sb.WriteString("\tchain dnsnat {\n")
|
||||
sb.WriteString("\t\ttype nat hook prerouting priority dstnat; policy accept;\n")
|
||||
sb.WriteString("\t\tfib daddr type local accept\n")
|
||||
sb.WriteString("\t\t" + dnsIif + " meta l4proto { tcp, udp } th dport 53 redirect to :53\n")
|
||||
sb.WriteString("\t}\n")
|
||||
}
|
||||
sb.WriteString("}\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// applyRouting reconciles ip rule/route: fwmark(fwmark_base) -> table(table_base)
|
||||
// with `local default dev lo`, so tproxy-marked packets are delivered locally.
|
||||
func applyRouting(m *Model) error {
|
||||
if err := apAddRouting(m.Globals.FwmarkBase, m.Globals.TableBase); err != nil {
|
||||
return err
|
||||
}
|
||||
return apAddEgressRouting(m)
|
||||
}
|
||||
|
||||
func apAddRouting(mark, table uint32) error {
|
||||
run := func(args ...string) { _ = exec.Command("ip", args...).Run() }
|
||||
for _, fam := range []string{"-4", "-6"} {
|
||||
run(fam, "rule", "del", "fwmark", fmt.Sprintf("0x%x", mark), "lookup", fmt.Sprintf("%d", table))
|
||||
if out, err := exec.Command("ip", fam, "rule", "add", "fwmark",
|
||||
fmt.Sprintf("0x%x", mark), "lookup", fmt.Sprintf("%d", table)).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ip %s rule add: %v\n%s", fam, err, out)
|
||||
}
|
||||
run(fam, "route", "flush", "table", fmt.Sprintf("%d", table))
|
||||
if out, err := exec.Command("ip", fam, "route", "add", "local", "default",
|
||||
"dev", "lo", "table", fmt.Sprintf("%d", table)).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ip %s route add: %v\n%s", fam, err, out)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apRemoveRouting(mark, table uint32) {
|
||||
run := func(args ...string) { _ = exec.Command("ip", args...).Run() }
|
||||
for _, fam := range []string{"-4", "-6"} {
|
||||
run(fam, "rule", "del", "fwmark", fmt.Sprintf("0x%x", mark), "lookup", fmt.Sprintf("%d", table))
|
||||
run(fam, "route", "flush", "table", fmt.Sprintf("%d", table))
|
||||
}
|
||||
}
|
||||
|
||||
// egEgressTable is the dedicated routing table for the idx-th egress. It sits in
|
||||
// the reserved table block (table_base .. table_base+64, cleared by teardown),
|
||||
// offset well clear of the main tproxy table (== table_base).
|
||||
func egEgressTable(g Globals, idx int) uint32 {
|
||||
base := g.TableBase
|
||||
if base == 0 {
|
||||
base = 0x2000
|
||||
}
|
||||
return base + 0x10 + uint32(idx)
|
||||
}
|
||||
|
||||
// apAddEgressRouting realises the policy-routing half of an interface/tunnel egress
|
||||
// (egress.go emits the SO_BINDTODEVICE+SO_MARK freedom outbound; this binds the mark
|
||||
// to a table whose default route leaves via the egress device). Idempotent.
|
||||
func apAddEgressRouting(m *Model) error {
|
||||
run := func(args ...string) { _ = exec.Command("ip", args...).Run() }
|
||||
for i, eg := range m.Egresses {
|
||||
t := strings.ToLower(eg.Type)
|
||||
if (t != "interface" && t != "tunnel") || eg.Interface == "" {
|
||||
continue
|
||||
}
|
||||
dev := ifaceDevice(eg.Interface)
|
||||
mark := egEgressMark(m.Globals, i)
|
||||
table := egEgressTable(m.Globals, i)
|
||||
fams := []string{"-4"}
|
||||
if m.Globals.IPv6 {
|
||||
fams = append(fams, "-6")
|
||||
}
|
||||
for _, fam := range fams {
|
||||
run(fam, "rule", "del", "fwmark", fmt.Sprintf("0x%x", mark), "lookup", fmt.Sprintf("%d", table))
|
||||
run(fam, "rule", "add", "fwmark", fmt.Sprintf("0x%x", mark), "lookup", fmt.Sprintf("%d", table))
|
||||
run(fam, "route", "flush", "table", fmt.Sprintf("%d", table))
|
||||
// Default route via the egress device. SO_BINDTODEVICE on the outbound is
|
||||
// the primary bind; this table makes the mark a self-sufficient exit path
|
||||
// (needed for tunnels / when the main table has no route via the device).
|
||||
// A gateway'd interface (WAN) needs `via <gw>`; a point-to-point tunnel
|
||||
// (wg/awg) has no gateway and routes straight out the device.
|
||||
if gw := egDefaultGateway(fam, dev); gw != "" {
|
||||
run(fam, "route", "add", "default", "via", gw, "dev", dev, "table", fmt.Sprintf("%d", table))
|
||||
} else {
|
||||
run(fam, "route", "add", "default", "dev", dev, "table", fmt.Sprintf("%d", table))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// egDefaultGateway returns the main-table default-route nexthop for a device
|
||||
// (e.g. "10.0.2.2" for a WAN), or "" for a point-to-point device with no gateway.
|
||||
func egDefaultGateway(fam, dev string) string {
|
||||
out, err := exec.Command("ip", fam, "route", "show", "default", "dev", dev).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
f := strings.Fields(string(out))
|
||||
for i := 0; i < len(f)-1; i++ {
|
||||
if f[i] == "via" {
|
||||
return f[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// apRemoveEgressRouting tears down every possible egress table/rule in the reserved
|
||||
// block (safe when nothing is set up).
|
||||
func apRemoveEgressRouting(m *Model) {
|
||||
run := func(args ...string) { _ = exec.Command("ip", args...).Run() }
|
||||
for i := range m.Egresses {
|
||||
mark := egEgressMark(m.Globals, i)
|
||||
table := egEgressTable(m.Globals, i)
|
||||
for _, fam := range []string{"-4", "-6"} {
|
||||
run(fam, "rule", "del", "fwmark", fmt.Sprintf("0x%x", mark), "lookup", fmt.Sprintf("%d", table))
|
||||
run(fam, "route", "flush", "table", fmt.Sprintf("%d", table))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apRoutingPresent reports whether our fwmark ip rule is currently installed.
|
||||
func apRoutingPresent(mark, table uint32) bool {
|
||||
out, err := exec.Command("ip", "-4", "rule", "show").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), fmt.Sprintf("fwmark 0x%x", mark))
|
||||
}
|
||||
|
||||
// apDeleteTable removes our nft table (ignore-absent).
|
||||
func apDeleteTable() {
|
||||
_ = exec.Command("nft", "delete", "table", "inet", "shater").Run()
|
||||
}
|
||||
|
||||
// apTeardown removes every resource we own (nft table + routing) and the pending
|
||||
// flag. Used when globally disabled — leaves the router with clean egress.
|
||||
func apTeardown(m *Model) error {
|
||||
apDeleteTable()
|
||||
apRemoveRouting(m.Globals.FwmarkBase, m.Globals.TableBase)
|
||||
apRemoveEgressRouting(m)
|
||||
_ = os.Remove(nftFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- snapshot / rollback plumbing ---
|
||||
|
||||
type apRouteSnapshot struct {
|
||||
Mark uint32 `json:"mark"`
|
||||
Table uint32 `json:"table"`
|
||||
NftPresent bool `json:"nft_present"`
|
||||
RoutePresent bool `json:"route_present"`
|
||||
}
|
||||
|
||||
// apSnapshot captures the current live state so rollback can restore it: the
|
||||
// generated nft file (copied to last-good.nft) and the routing marks/presence.
|
||||
// run.json is snapshotted separately by Apply (as last-good.json).
|
||||
func apSnapshot(m *Model) {
|
||||
_ = os.MkdirAll(filepath.Dir(routeSnap), 0o755)
|
||||
snap := apRouteSnapshot{
|
||||
Mark: m.Globals.FwmarkBase,
|
||||
Table: m.Globals.TableBase,
|
||||
NftPresent: nftTableExists(),
|
||||
RoutePresent: apRoutingPresent(m.Globals.FwmarkBase, m.Globals.TableBase),
|
||||
}
|
||||
if snap.NftPresent {
|
||||
// Persist the exact table that is currently running so rollback reloads it.
|
||||
if b, err := os.ReadFile(nftFile); err == nil {
|
||||
_ = os.MkdirAll(filepath.Dir(nftLastGood), 0o755)
|
||||
_ = os.WriteFile(nftLastGood, b, 0o644)
|
||||
} else if out, err := exec.Command("nft", "list", "table", "inet", "shater").Output(); err == nil {
|
||||
// Fall back to a live dump, wrapped so `nft -f` reloads it cleanly.
|
||||
var wrap strings.Builder
|
||||
wrap.WriteString("#!/usr/sbin/nft -f\ntable inet shater\ndelete table inet shater\n")
|
||||
wrap.Write(out)
|
||||
_ = os.MkdirAll(filepath.Dir(nftLastGood), 0o755)
|
||||
_ = os.WriteFile(nftLastGood, []byte(wrap.String()), 0o644)
|
||||
} else {
|
||||
snap.NftPresent = false
|
||||
}
|
||||
} else {
|
||||
_ = os.Remove(nftLastGood)
|
||||
}
|
||||
if b, err := json.Marshal(snap); err == nil {
|
||||
_ = os.WriteFile(routeSnap, b, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
func apReadRouteSnap() apRouteSnapshot {
|
||||
var snap apRouteSnapshot
|
||||
if b, err := os.ReadFile(routeSnap); err == nil {
|
||||
_ = json.Unmarshal(b, &snap)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// apArmAutoRollback schedules a fully-detached auto-rollback after `seconds`,
|
||||
// unless `confirm` has removed the pending flag by then. Uses setsid so the
|
||||
// watcher survives this process (and its session) exiting; falls back to a plain
|
||||
// detached shell where setsid is unavailable.
|
||||
//
|
||||
// The watcher checks that the flag still contains ITS nonce, not merely that
|
||||
// the flag exists: with overlapping confirm windows (apply #2 while #1's
|
||||
// watcher is still sleeping), a bare existence check would let watcher #1
|
||||
// roll back apply #2. A re-armed flag carries a fresh nonce, so the stale
|
||||
// watcher sees a mismatch and exits without rolling back.
|
||||
func apArmAutoRollback(seconds int, nonce string) {
|
||||
self, _ := os.Executable()
|
||||
if self == "" {
|
||||
self = "xrayctl"
|
||||
}
|
||||
script := fmt.Sprintf("sleep %d; grep -qF %q %q 2>/dev/null && %q rollback >/dev/null 2>&1",
|
||||
seconds, nonce, pendingFlag, self)
|
||||
// setsid runs the watcher in a new session (no controlling terminal), so it
|
||||
// survives this CLI process — and its shell/SSH session — exiting. busybox
|
||||
// setsid takes no -f flag; Start() (not Wait) already returns immediately.
|
||||
if _, err := exec.LookPath("setsid"); err == nil {
|
||||
cmd := exec.Command("setsid", "sh", "-c", script) // nil stdio => /dev/null
|
||||
_ = cmd.Start()
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("sh", "-c", script) // nil stdio => /dev/null
|
||||
_ = cmd.Start()
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
package main
|
||||
|
||||
// Config backup / restore (catalog 05 §11, MVP). A backup is a gzip(tar) bundle
|
||||
// of /etc/config/shater + the subscription node caches, plus a manifest. Restore
|
||||
// validates the staged config with `xray -test` BEFORE swapping, snapshots the
|
||||
// current config as a recovery profile, swaps atomically, migrates the schema
|
||||
// forward, and applies with commit-confirm so a bad restore can never brick the
|
||||
// management path.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const uciConfPath = "/etc/config/shater"
|
||||
|
||||
// backupManifest is the first archive member.
|
||||
type backupManifest struct {
|
||||
Format string `json:"format"`
|
||||
Manifest int `json:"manifest"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Xrayctl string `json:"xrayctl"`
|
||||
Created int64 `json:"created"`
|
||||
Host string `json:"host,omitempty"`
|
||||
}
|
||||
|
||||
func tarBytes(tw *tar.Writer, name string, data []byte) error {
|
||||
h := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(h); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tw.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func tarJSON(tw *tar.Writer, name string, v any) error {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tarBytes(tw, name, b)
|
||||
}
|
||||
|
||||
func tarFile(tw *tar.Writer, name, path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tarBytes(tw, name, b)
|
||||
}
|
||||
|
||||
// BackupTo streams a gzip(tar) bundle to w.
|
||||
func BackupTo(w io.Writer) error {
|
||||
gz := gzip.NewWriter(w)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
sv := 0
|
||||
if m, err := ReadUCI(); err == nil {
|
||||
sv = m.Globals.SchemaVersion
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
man := backupManifest{
|
||||
Format: "shater-backup", Manifest: 1, SchemaVersion: sv,
|
||||
Xrayctl: version, Created: time.Now().Unix(), Host: host,
|
||||
}
|
||||
if err := tarJSON(tw, "manifest.json", man); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tarFile(tw, "config/shater", uciConfPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if entries, err := os.ReadDir(subCacheDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
_ = tarFile(tw, "subs/"+e.Name(), filepath.Join(subCacheDir, e.Name()))
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return gz.Close()
|
||||
}
|
||||
|
||||
// Backup writes to path (mode 0600), or to stdout when path=="".
|
||||
func Backup(path string) error {
|
||||
if path == "" {
|
||||
return BackupTo(os.Stdout)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := BackupTo(f); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// Decompression caps for untarInto: backups are a small UCI config plus sub
|
||||
// caches (a few hundred KB), but the upload is untrusted and gzip can expand
|
||||
// >1000x — an unbounded io.ReadAll would let a tiny gzip-bomb OOM a 128MB
|
||||
// router. Anything near these limits is not a real backup.
|
||||
const (
|
||||
untarMemberCap = 8 << 20 // max decompressed bytes per archive member
|
||||
untarTotalCap = 64 << 20 // max decompressed bytes for the whole archive
|
||||
)
|
||||
|
||||
// untarInto extracts a gzip(tar) stream into dir (rejecting path traversal and
|
||||
// capping decompressed size) and returns the parsed manifest. On error the
|
||||
// caller's staging dir may hold partial files; every caller stages into a
|
||||
// temp dir it removes (defer os.RemoveAll), so no cleanup is done here.
|
||||
func untarInto(r io.Reader, dir string) (backupManifest, error) {
|
||||
var man backupManifest
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return man, err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var total int64
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return man, err
|
||||
}
|
||||
clean := filepath.Clean(h.Name)
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) || strings.Contains(clean, ".."+string(filepath.Separator)) {
|
||||
return man, fmt.Errorf("unsafe archive member %q", h.Name)
|
||||
}
|
||||
dst := filepath.Join(dir, clean)
|
||||
if !strings.HasPrefix(dst, filepath.Clean(dir)+string(filepath.Separator)) {
|
||||
return man, fmt.Errorf("archive member escapes staging: %q", h.Name)
|
||||
}
|
||||
if h.Typeflag == tar.TypeDir {
|
||||
_ = os.MkdirAll(dst, 0o755)
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return man, err
|
||||
}
|
||||
// LimitReader (cap+1 so overflow is detectable) rather than trusting
|
||||
// h.Size: the tar header is attacker-controlled too.
|
||||
data, err := io.ReadAll(io.LimitReader(tr, untarMemberCap+1))
|
||||
if err != nil {
|
||||
return man, err
|
||||
}
|
||||
if int64(len(data)) > untarMemberCap {
|
||||
return man, fmt.Errorf("archive member %q exceeds %d MB", h.Name, untarMemberCap>>20)
|
||||
}
|
||||
total += int64(len(data))
|
||||
if total > untarTotalCap {
|
||||
return man, fmt.Errorf("archive exceeds %d MB decompressed", untarTotalCap>>20)
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0o600); err != nil {
|
||||
return man, err
|
||||
}
|
||||
if clean == "manifest.json" {
|
||||
_ = json.Unmarshal(data, &man)
|
||||
}
|
||||
}
|
||||
return man, nil
|
||||
}
|
||||
|
||||
// loadCacheNodesFromDir loads subscription-cache nodes from an arbitrary dir
|
||||
// (used to validate a staged restore without touching the live cache).
|
||||
func loadCacheNodesFromDir(m *Model, dir string) {
|
||||
for _, s := range m.Subscriptions {
|
||||
// sanitizeSubName: the sub name comes from the staged (untrusted) config;
|
||||
// it also keeps the lookup consistent with SaveSubCache's sanitized
|
||||
// on-disk filenames.
|
||||
b, err := os.ReadFile(filepath.Join(dir, sanitizeSubName(s.Name)+".json"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var c SubCache
|
||||
if json.Unmarshal(b, &c) != nil {
|
||||
continue
|
||||
}
|
||||
for _, n := range c.Nodes {
|
||||
m.Nodes = append(m.Nodes, Node{
|
||||
Name: n.Name, URI: n.URI, Enabled: !n.Stale,
|
||||
FromSub: s.Name, Fingerprint: n.Fingerprint, Stale: n.Stale,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swapFile atomically replaces dst with the contents of src.
|
||||
func swapFile(dst, src string) error {
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dst + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
// swapSubs replaces /etc/xray/subs/*.json with the staged set (best-effort).
|
||||
func swapSubs(stageSubs string) error {
|
||||
entries, err := os.ReadDir(stageSubs)
|
||||
if err != nil {
|
||||
return nil // no subs in backup: nothing to do
|
||||
}
|
||||
if err := os.MkdirAll(subCacheDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
_ = swapFile(filepath.Join(subCacheDir, e.Name()), filepath.Join(stageSubs, e.Name()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore reads the archive at path and restores it safely.
|
||||
func Restore(path string, confirm int) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return restoreReader(f, confirm)
|
||||
}
|
||||
|
||||
func restoreReader(r io.Reader, confirm int) error {
|
||||
stage, err := os.MkdirTemp("", "shater-restore-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
|
||||
man, err := untarInto(r, stage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if man.Format != "shater-backup" {
|
||||
return fmt.Errorf("not a shater backup")
|
||||
}
|
||||
if man.SchemaVersion > CurrentSchemaVersion {
|
||||
return fmt.Errorf("backup schema v%d newer than supported v%d", man.SchemaVersion, CurrentSchemaVersion)
|
||||
}
|
||||
stagedConf := filepath.Join(stage, "config", "shater")
|
||||
if !fileExists(stagedConf) {
|
||||
return fmt.Errorf("backup missing config/shater")
|
||||
}
|
||||
|
||||
// Validate the staged config before swapping anything.
|
||||
text, err := os.ReadFile(stagedConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := ParseUCIExport(string(text))
|
||||
if err != nil {
|
||||
return fmt.Errorf("staged config parse: %w", err)
|
||||
}
|
||||
loadCacheNodesFromDir(m, filepath.Join(stage, "subs"))
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("staged config invalid: %w", err)
|
||||
}
|
||||
if err := TestConfig(cfg); err != nil {
|
||||
return fmt.Errorf("staged config failed xray -test: %w", err)
|
||||
}
|
||||
|
||||
// Recovery snapshot, then atomic swap of config + subs.
|
||||
_ = ProfileSave(".pre-restore")
|
||||
if err := swapFile(uciConfPath, stagedConf); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = swapSubs(filepath.Join(stage, "subs"))
|
||||
|
||||
// Bring schema forward if the archive was older, then apply (armed rollback).
|
||||
if err := Migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return Apply(confirm)
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
package main
|
||||
|
||||
// Regression tests for the audited bug fixes: kill-switch honesty for empty
|
||||
// groups, nft-plane src resolution fail-safe, nft chain layout (reject
|
||||
// placement + IPv6 fail-closed), run.json hash-compare reload, balancer
|
||||
// selector collisions, chain exit-tag recomputation, probe-mode balancer
|
||||
// collapse, DNS resolver ordering, and the uci quote unescape.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const badLink = "not-a-share-link"
|
||||
|
||||
// --- 1: empty group must honour the kill-switch, never silently fall open ---
|
||||
|
||||
func TestEmptyGroupKillSwitch(t *testing.T) {
|
||||
mk := func(kill string) map[string]any {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Globals.KillSwitch = kill
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
// Group references only a nonexistent node -> zero usable members.
|
||||
m.Groups = []Group{{Name: "empty", Source: "manual", Nodes: []string{"ghost"}, Strategy: "leastping"}}
|
||||
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Target: "group:empty"}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
target := func(cfg map[string]any) string {
|
||||
for _, r := range routingRules(cfg) {
|
||||
rm := r.(map[string]any)
|
||||
if rm["network"] == "tcp,udp" && rm["outboundTag"] != "api" {
|
||||
return rm["outboundTag"].(string)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// closed (default): the rule's group target resolves to block.
|
||||
if got := target(mk("closed")); got != "block" {
|
||||
t.Fatalf("closed kill-switch: empty group resolved to %q, want block", got)
|
||||
}
|
||||
// open: keeps the legacy direct fallback (warned on stderr).
|
||||
got := ""
|
||||
for _, r := range routingRules(mk("open")) {
|
||||
rm := r.(map[string]any)
|
||||
if rm["outboundTag"] == "direct" && rm["network"] == "tcp,udp" {
|
||||
got = "direct"
|
||||
}
|
||||
}
|
||||
if got != "direct" {
|
||||
t.Fatal("open kill-switch: empty group did not resolve to direct")
|
||||
}
|
||||
}
|
||||
|
||||
// A group whose members ALL fail to parse is just as empty.
|
||||
func TestAllUnparseableGroupFailsClosed(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()} // kill_switch closed by default
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Nodes = []Node{{Name: "junk", Enabled: true, URI: badLink}}
|
||||
m.Groups = []Group{{Name: "g", Source: "manual", Nodes: []string{"junk"}, Strategy: "leastping"}}
|
||||
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Target: "group:g"}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range routingRules(cfg) {
|
||||
rm := r.(map[string]any)
|
||||
if rm["outboundTag"] == "block" && rm["network"] == "tcp,udp" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("all-unparseable group did not fail closed to block")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2: MAC/iface/zone-only src must never become a catch-all ---
|
||||
|
||||
func TestNftPlaneSrcFailSafe(t *testing.T) {
|
||||
saved := genResolveNftSrcs
|
||||
defer func() { genResolveNftSrcs = saved }()
|
||||
|
||||
build := func() map[string]any {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Rules = []Rule{{
|
||||
Name: "mac-only", Enabled: true, Order: 10,
|
||||
Src: []string{"AA:BB:CC:DD:EE:FF"},
|
||||
Target: "direct",
|
||||
}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
userRule := func(cfg map[string]any) map[string]any {
|
||||
for _, r := range routingRules(cfg) {
|
||||
rm := r.(map[string]any)
|
||||
if _, ok := rm["source"]; ok {
|
||||
return rm
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nothing resolves -> impossible source, matches NOTHING (fail-safe).
|
||||
genResolveNftSrcs = func(genSrcClasses) []string { return nil }
|
||||
rm := userRule(build())
|
||||
if rm == nil {
|
||||
t.Fatal("MAC-only rule lost its source matcher entirely (catch-all regression)")
|
||||
}
|
||||
src := rm["source"].([]any)
|
||||
if len(src) != 1 || src[0] != genImpossibleSource {
|
||||
t.Fatalf("unresolved MAC-only rule source = %v, want [%s]", src, genImpossibleSource)
|
||||
}
|
||||
|
||||
// MAC resolves -> the resolved host CIDR is the source.
|
||||
genResolveNftSrcs = func(sc genSrcClasses) []string {
|
||||
if len(sc.MACs) == 1 && sc.MACs[0] == "AA:BB:CC:DD:EE:FF" {
|
||||
return []string{"192.168.1.50/32"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rm = userRule(build())
|
||||
src = rm["source"].([]any)
|
||||
if len(src) != 1 || src[0] != "192.168.1.50/32" {
|
||||
t.Fatalf("resolved MAC rule source = %v, want [192.168.1.50/32]", src)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3 + 4: nft chain layout ---
|
||||
|
||||
// nftChainBody extracts the body of one chain from the rendered ruleset.
|
||||
func nftChainBody(t *testing.T, ruleset, chain string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(ruleset, "chain "+chain+" {")
|
||||
if start < 0 {
|
||||
t.Fatalf("chain %s missing in ruleset:\n%s", chain, ruleset)
|
||||
}
|
||||
rest := ruleset[start:]
|
||||
end := strings.Index(rest, "\t}\n")
|
||||
if end < 0 {
|
||||
t.Fatalf("chain %s not terminated", chain)
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
func nftModel(ipv6 bool, kill string) *Model {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Globals.IPv6 = ipv6
|
||||
m.Globals.KillSwitch = kill
|
||||
// Network "" resolves to br-lan without shelling out (see ifaceDevice).
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestRenderNftRejectOnlyInForwardChain(t *testing.T) {
|
||||
s := RenderNft(nftModel(true, "closed"))
|
||||
|
||||
pre := nftChainBody(t, s, "prerouting")
|
||||
if strings.Contains(pre, "reject") {
|
||||
t.Fatalf("reject in prerouting-hook chain (kernel refuses to load it):\n%s", pre)
|
||||
}
|
||||
if !strings.Contains(pre, "th dport 853 accept") {
|
||||
t.Fatalf("prerouting must accept :853 past the tproxy divert so forward sees it:\n%s", pre)
|
||||
}
|
||||
if !strings.Contains(pre, "th dport 53 accept") {
|
||||
t.Fatalf("prerouting :53 accept missing:\n%s", pre)
|
||||
}
|
||||
|
||||
fwd := nftChainBody(t, s, "forward")
|
||||
if !strings.Contains(fwd, "type filter hook forward priority filter; policy accept;") {
|
||||
t.Fatalf("forward chain hook line wrong:\n%s", fwd)
|
||||
}
|
||||
if !strings.Contains(fwd, "th dport 853 reject") {
|
||||
t.Fatalf("DoT/DoQ :853 reject missing from forward chain:\n%s", fwd)
|
||||
}
|
||||
// The :853 accept must come BEFORE the catch-all tproxy divert lines.
|
||||
divert := strings.Index(pre, "tproxy ip to")
|
||||
accept853 := strings.Index(pre, "th dport 853 accept")
|
||||
if divert >= 0 && accept853 > divert {
|
||||
t.Fatal(":853 accept placed after the tproxy divert (would be swallowed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNftIPv6FailClosed(t *testing.T) {
|
||||
// ipv6 disabled + closed kill-switch: LAN-ingress IPv6 must be dropped in
|
||||
// forward, with the ND/link-local/multicast plane kept alive.
|
||||
s := RenderNft(nftModel(false, "closed"))
|
||||
fwd := nftChainBody(t, s, "forward")
|
||||
for _, want := range []string{
|
||||
"icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept",
|
||||
"ip6 daddr fe80::/10 accept",
|
||||
"ip6 daddr ff00::/8 accept",
|
||||
"meta nfproto ipv6 drop",
|
||||
} {
|
||||
if !strings.Contains(fwd, want) {
|
||||
t.Fatalf("forward chain missing %q:\n%s", want, fwd)
|
||||
}
|
||||
}
|
||||
// Order: accepts before the drop.
|
||||
if strings.Index(fwd, "fe80::/10 accept") > strings.Index(fwd, "meta nfproto ipv6 drop") {
|
||||
t.Fatal("link-local accept placed after the ipv6 drop")
|
||||
}
|
||||
|
||||
// ipv6 disabled + open kill-switch: no drop, but the bypass is documented.
|
||||
sOpen := RenderNft(nftModel(false, "open"))
|
||||
fwdOpen := nftChainBody(t, sOpen, "forward")
|
||||
if strings.Contains(fwdOpen, "meta nfproto ipv6 drop") {
|
||||
t.Fatal("open kill-switch must not drop ipv6")
|
||||
}
|
||||
if !strings.Contains(fwdOpen, "# ipv6=0, kill_switch=open") {
|
||||
t.Fatalf("open kill-switch ipv6 bypass not documented:\n%s", fwdOpen)
|
||||
}
|
||||
|
||||
// ipv6 enabled: no drop and no bypass comment.
|
||||
sV6 := RenderNft(nftModel(true, "closed"))
|
||||
fwdV6 := nftChainBody(t, sV6, "forward")
|
||||
if strings.Contains(fwdV6, "nfproto ipv6 drop") || strings.Contains(fwdV6, "ipv6=0") {
|
||||
t.Fatalf("ipv6-enabled render must not carry the v6-off rules:\n%s", fwdV6)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5: run.json write+reload only on real change ---
|
||||
|
||||
func TestSyncRunJSONSkipsUnchanged(t *testing.T) {
|
||||
saved := runJSON
|
||||
runJSON = filepath.Join(t.TempDir(), "run.json")
|
||||
defer func() { runJSON = saved }()
|
||||
|
||||
cfg := map[string]any{"log": map[string]any{"loglevel": "warning"}}
|
||||
|
||||
changed, err := syncRunJSON(cfg, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("first sync (no file) must report changed")
|
||||
}
|
||||
if _, err := os.Stat(runJSON); err != nil {
|
||||
t.Fatal("run.json not written")
|
||||
}
|
||||
|
||||
changed, err = syncRunJSON(cfg, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed {
|
||||
t.Fatal("identical config must be a no-op (no write, no engine bounce)")
|
||||
}
|
||||
|
||||
cfg["log"].(map[string]any)["loglevel"] = "debug"
|
||||
changed, err = syncRunJSON(cfg, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("modified config must report changed")
|
||||
}
|
||||
b, _ := os.ReadFile(runJSON)
|
||||
if !strings.Contains(string(b), "debug") {
|
||||
t.Fatal("modified config not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6: commit-confirm nonce ---
|
||||
|
||||
func TestApNonceUnique(t *testing.T) {
|
||||
a, b := apNonce(), apNonce()
|
||||
if a == "" || a == b {
|
||||
t.Fatalf("nonces not unique: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 8: chain probe collapses group hops (no balancer/observatory deps) ---
|
||||
|
||||
func TestProbeBuilderCollapsesGroupHops(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Nodes = []Node{
|
||||
{Name: "h1", Enabled: true, URI: vlessReality},
|
||||
{Name: "h2", Enabled: true, URI: trojanTLS},
|
||||
}
|
||||
m.Groups = []Group{{Name: "g", Source: "manual", Nodes: []string{"h1", "h2"}, Strategy: "leastping"}}
|
||||
m.Chains = []Chain{{Name: "c", Hops: []string{"group:g"}}}
|
||||
|
||||
b := newProbeBuilder(m)
|
||||
exitTag, err := b.emitChain(m.Chains[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(b.balancers) != 0 {
|
||||
t.Fatalf("probe builder emitted balancers (ephemeral config cannot resolve them): %v", b.balancers)
|
||||
}
|
||||
if !strings.HasPrefix(exitTag, "c_c_L1_") {
|
||||
t.Fatalf("exit tag %q is not a member outbound", exitTag)
|
||||
}
|
||||
// The exit tag must be an actually-emitted outbound.
|
||||
found := false
|
||||
for _, o := range b.outbounds {
|
||||
if o.(map[string]any)["tag"] == exitTag {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("exit tag %q not among emitted outbounds", exitTag)
|
||||
}
|
||||
// And no rule may reference a balancerTag.
|
||||
for _, r := range b.rules {
|
||||
if _, ok := r.(map[string]any)["balancerTag"]; ok {
|
||||
t.Fatalf("probe rules reference a balancer: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 9: selector prefix collision (eu vs eu_fast) ---
|
||||
|
||||
func TestBalancerSelectorNoPrefixCollision(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Nodes = []Node{
|
||||
{Name: "a", Enabled: true, URI: vlessReality},
|
||||
{Name: "b", Enabled: true, URI: trojanTLS},
|
||||
{Name: "c", Enabled: true, URI: vlessReality},
|
||||
{Name: "d", Enabled: true, URI: trojanTLS},
|
||||
}
|
||||
m.Groups = []Group{
|
||||
{Name: "eu", Source: "manual", Nodes: []string{"a", "b"}, Strategy: "leastping"},
|
||||
{Name: "eu_fast", Source: "manual", Nodes: []string{"c", "d"}, Strategy: "leastping"},
|
||||
}
|
||||
m.Rules = []Rule{
|
||||
{Name: "r1", Enabled: true, Order: 10, DstPort: "443", Target: "group:eu"},
|
||||
{Name: "r2", Enabled: true, Order: 20, Target: "group:eu_fast"},
|
||||
}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Collect all outbound tags.
|
||||
var allTags []string
|
||||
for _, o := range cfg["outbounds"].([]any) {
|
||||
allTags = append(allTags, o.(map[string]any)["tag"].(string))
|
||||
}
|
||||
|
||||
bal := findBalancer(t, cfg, "g_eu")
|
||||
if bal == nil {
|
||||
t.Fatal("balancer g_eu not emitted")
|
||||
}
|
||||
sel := bal["selector"].([]any)
|
||||
if len(sel) != 2 {
|
||||
t.Fatalf("g_eu selector = %v, want exactly its 2 member tags", sel)
|
||||
}
|
||||
// xray selectors are PREFIX matched: no g_eu selector entry may be a prefix
|
||||
// of any tag that is not one of eu's own members (in particular none of
|
||||
// eu_fast's member tags).
|
||||
selSet := map[string]bool{}
|
||||
for _, s := range sel {
|
||||
selSet[s.(string)] = true
|
||||
}
|
||||
for _, s := range sel {
|
||||
for _, tag := range allTags {
|
||||
if strings.HasPrefix(tag, s.(string)) && !selSet[tag] {
|
||||
t.Fatalf("selector entry %q prefix-captures foreign outbound %q", s, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sanity: eu_fast members exist and carry the eu_fast prefix.
|
||||
fast := 0
|
||||
for _, tag := range allTags {
|
||||
if strings.HasPrefix(tag, groupPrefix("eu_fast")) {
|
||||
fast++
|
||||
}
|
||||
}
|
||||
if fast != 2 {
|
||||
t.Fatalf("expected 2 eu_fast members, got %d (tags %v)", fast, allTags)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 11: chain exit tag matches what was actually emitted ---
|
||||
|
||||
func TestChainExitTagSkipsUnparseableMembers(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Nodes = []Node{
|
||||
{Name: "bad", Enabled: true, URI: badLink}, // first member: unparseable
|
||||
{Name: "good", Enabled: true, URI: trojanTLS},
|
||||
}
|
||||
m.Groups = []Group{{Name: "gx", Source: "manual", Nodes: []string{"bad", "good"}, Strategy: "single"}}
|
||||
m.Chains = []Chain{{Name: "x", Hops: []string{"group:gx"}}}
|
||||
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Target: "chain:x"}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Every tag referenced by routing rules must exist as an outbound/balancer.
|
||||
known := map[string]bool{"api": true}
|
||||
for _, o := range cfg["outbounds"].([]any) {
|
||||
known[o.(map[string]any)["tag"].(string)] = true
|
||||
}
|
||||
for _, b := range cfg["routing"].(map[string]any)["balancers"].([]any) {
|
||||
known[b.(map[string]any)["tag"].(string)] = true
|
||||
}
|
||||
for _, r := range routingRules(cfg) {
|
||||
rm := r.(map[string]any)
|
||||
for _, k := range []string{"outboundTag", "balancerTag"} {
|
||||
if tag, ok := rm[k].(string); ok && !known[tag] {
|
||||
t.Fatalf("rule references never-emitted tag %q: %v", tag, rm)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And specifically: the chain rule points at the parseable member.
|
||||
b, _ := json.Marshal(cfg)
|
||||
if !strings.Contains(string(b), "c_x_L1_0001_good") {
|
||||
t.Fatalf("exit tag for the parseable member missing:\n%s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 10: DNS resolver ordering (default first, fallback last) + detour note ---
|
||||
|
||||
func TestDNSDefaultFirstFallbackLast(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Globals.ResolverDefault = "cf"
|
||||
m.Globals.ResolverFallback = "quad9"
|
||||
m.Resolvers = []Resolver{
|
||||
{Name: "quad9", Type: "plain", Address: "9.9.9.9"},
|
||||
{Name: "goog", Type: "plain", Address: "8.8.8.8", Detour: "node:n1"},
|
||||
{Name: "cf", Type: "plain", Address: "1.1.1.1"},
|
||||
}
|
||||
dns := dnsRender(m, true)
|
||||
var addrs []string
|
||||
for _, s := range dns["servers"].([]any) {
|
||||
switch v := s.(type) {
|
||||
case string:
|
||||
addrs = append(addrs, v)
|
||||
case map[string]any:
|
||||
addrs = append(addrs, v["address"].(string))
|
||||
}
|
||||
}
|
||||
want := []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"}
|
||||
if len(addrs) != 3 {
|
||||
t.Fatalf("servers = %v, want 3", addrs)
|
||||
}
|
||||
for i := range want {
|
||||
if addrs[i] != want[i] {
|
||||
t.Fatalf("server order = %v, want %v (default first, fallback last)", addrs, want)
|
||||
}
|
||||
}
|
||||
// Unsupported detour is skipped but documented.
|
||||
notes, _ := dns["_shater_notes"].([]any)
|
||||
if len(notes) != 1 || !strings.Contains(notes[0].(string), "detour") {
|
||||
t.Fatalf("detour skip not documented: %v", notes)
|
||||
}
|
||||
}
|
||||
|
||||
// A fallback/default naming no resolver section degrades to a plain address.
|
||||
func TestDNSFallbackLiteralAddress(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Globals.ResolverFallback = "8.8.4.4"
|
||||
m.Resolvers = []Resolver{{Name: "cf", Type: "plain", Address: "1.1.1.1"}}
|
||||
dns := dnsRender(m, true)
|
||||
servers := dns["servers"].([]any)
|
||||
if servers[len(servers)-1] != "8.8.4.4" {
|
||||
t.Fatalf("literal fallback not last: %v", servers)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 13: uci export escaped single quotes ---
|
||||
|
||||
func TestUnquoteEscapedQuote(t *testing.T) {
|
||||
if got := unquote(`'it'\''s'`); got != "it's" {
|
||||
t.Fatalf("unquote = %q, want %q", got, "it's")
|
||||
}
|
||||
if got := unquote(`'plain'`); got != "plain" {
|
||||
t.Fatalf("unquote = %q, want plain", got)
|
||||
}
|
||||
if got := unquote(`"dq"`); got != "dq" {
|
||||
t.Fatalf("unquote = %q, want dq", got)
|
||||
}
|
||||
// End-to-end through the section parser.
|
||||
m, err := ParseUCIExport("config node\n\toption name 'it'\\''s'\n\toption uri 'x'\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Nodes) != 1 || m.Nodes[0].Name != "it's" {
|
||||
t.Fatalf("nodes = %+v", m.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
// resolver_default is parsed from globals (10a).
|
||||
func TestGlobalsResolverDefaultParsed(t *testing.T) {
|
||||
m, err := ParseUCIExport("config globals 'globals'\n\toption resolver_default 'cf'\n\toption resolver_fallback 'q9'\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Globals.ResolverDefault != "cf" || m.Globals.ResolverFallback != "q9" {
|
||||
t.Fatalf("globals = %+v", m.Globals)
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package main
|
||||
|
||||
// xray version-compatibility check (catalog 05 §12, T1). Detects the installed
|
||||
// xray version and the feature set the desired-state config actually uses, and
|
||||
// flags any feature whose minimum version exceeds what is installed.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// featureMinVer is the minimum xray-core version per feature.
|
||||
var featureMinVer = map[string]string{
|
||||
"reality": "1.8.0",
|
||||
"vision": "1.8.0",
|
||||
"xhttp": "1.8.16",
|
||||
"observatory": "1.4.0",
|
||||
"fakeip": "1.8.0",
|
||||
}
|
||||
|
||||
// xrayVersion runs `xray -version` and returns the semver ("1.8.4"), or "".
|
||||
func xrayVersion() string {
|
||||
out, err := exec.Command("xray", "-version").Output()
|
||||
if err != nil {
|
||||
out, err = exec.Command("xray", "version").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 2 {
|
||||
return strings.TrimPrefix(f[1], "v")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// requiredFeatures scans the model for features actually in use.
|
||||
func requiredFeatures(m *Model) map[string]bool {
|
||||
req := map[string]bool{}
|
||||
if strings.EqualFold(m.Globals.DNSMode, "fakeip") {
|
||||
req["fakeip"] = true
|
||||
}
|
||||
for _, r := range m.Resolvers {
|
||||
if strings.EqualFold(r.Type, "fakeip") {
|
||||
req["fakeip"] = true
|
||||
}
|
||||
}
|
||||
if len(m.Groups) > 0 {
|
||||
req["observatory"] = true
|
||||
}
|
||||
for _, n := range m.Nodes {
|
||||
ob, err := ParseShareLink(n.URI, n.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ss, _ := ob["streamSettings"].(map[string]any)
|
||||
if ss != nil {
|
||||
if s, _ := ss["security"].(string); strings.EqualFold(s, "reality") {
|
||||
req["reality"] = true
|
||||
}
|
||||
if t, _ := ss["network"].(string); strings.EqualFold(t, "xhttp") {
|
||||
req["xhttp"] = true
|
||||
}
|
||||
}
|
||||
if st, _ := ob["settings"].(map[string]any); st != nil {
|
||||
if vnext, ok := st["vnext"].([]any); ok && len(vnext) > 0 {
|
||||
if v0, ok := vnext[0].(map[string]any); ok {
|
||||
if users, ok := v0["users"].([]any); ok && len(users) > 0 {
|
||||
if u0, ok := users[0].(map[string]any); ok {
|
||||
if fl, _ := u0["flow"].(string); strings.Contains(fl, "vision") {
|
||||
req["vision"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// semverGE reports a >= b for dotted numeric versions (padded, pre-release stripped).
|
||||
func semverGE(a, b string) bool {
|
||||
pa := splitSemver(a)
|
||||
pb := splitSemver(b)
|
||||
for i := 0; i < 3; i++ {
|
||||
if pa[i] != pb[i] {
|
||||
return pa[i] > pb[i]
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func splitSemver(v string) [3]int {
|
||||
var out [3]int
|
||||
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||
// strip pre-release / build suffix
|
||||
if i := strings.IndexAny(v, "-+ "); i >= 0 {
|
||||
v = v[:i]
|
||||
}
|
||||
for i, p := range strings.SplitN(v, ".", 3) {
|
||||
if i > 2 {
|
||||
break
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
out[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type compatFeature struct {
|
||||
Name string `json:"name"`
|
||||
Required string `json:"required"`
|
||||
Detected string `json:"detected"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// compatResult builds the compat report for a model.
|
||||
func compatResult(m *Model) (string, bool, []compatFeature) {
|
||||
ver := xrayVersion()
|
||||
overall := ver != ""
|
||||
var feats []compatFeature
|
||||
for name := range requiredFeatures(m) {
|
||||
min := featureMinVer[name]
|
||||
ok := ver != "" && (min == "" || semverGE(ver, min))
|
||||
if !ok {
|
||||
overall = false
|
||||
}
|
||||
feats = append(feats, compatFeature{Name: name, Required: min, Detected: ver, OK: ok})
|
||||
}
|
||||
return ver, overall, feats
|
||||
}
|
||||
|
||||
// CompatJSON reports xray version vs the required feature set.
|
||||
func CompatJSON() ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
m = &Model{Globals: defaultGlobals()}
|
||||
} else {
|
||||
loadCacheNodesInto(m)
|
||||
}
|
||||
ver, ok, feats := compatResult(m)
|
||||
res := map[string]any{"xray_version": ver, "ok": ok, "features": feats}
|
||||
if ver == "" {
|
||||
res["note"] = "xray binary not found or version unparseable"
|
||||
}
|
||||
return json.MarshalIndent(res, "", " ")
|
||||
}
|
||||
|
||||
// compatOK is a thin bool for status.go.
|
||||
func compatOK() bool {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
_, ok, _ := compatResult(m)
|
||||
return ok
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package main
|
||||
|
||||
// Live connections (catalog 05 §10, T1). xray's stock CLI exposes no per-flow
|
||||
// table, so this derives live flows from the kernel conntrack table and labels
|
||||
// each with a best-effort outbound (proxied vs direct + the balancer's currently
|
||||
// selected node). The `outbound` label is approximate — documented in the UI.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type connOut struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
DstPort int `json:"dst_port"`
|
||||
Proto string `json:"proto"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Outbound string `json:"outbound"`
|
||||
}
|
||||
|
||||
// readConntrack returns the raw conntrack dump, or "" if unavailable.
|
||||
func readConntrack() string {
|
||||
if out, err := exec.Command("conntrack", "-L").Output(); err == nil {
|
||||
return string(out)
|
||||
}
|
||||
if b, err := os.ReadFile("/proc/net/nf_conntrack"); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseConntrack extracts flows (src/dst/dport/proto/bytes) from a conntrack dump.
|
||||
// Handles both `conntrack -L` and /proc/net/nf_conntrack line formats.
|
||||
func parseConntrack(dump string) []connOut {
|
||||
var out []connOut
|
||||
for _, line := range strings.Split(dump, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
proto := ""
|
||||
if strings.Contains(line, "tcp") {
|
||||
proto = "tcp"
|
||||
} else if strings.Contains(line, "udp") {
|
||||
proto = "udp"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
f := kvFields(line)
|
||||
src := f["src"]
|
||||
dst := f["dst"]
|
||||
dport, _ := strconv.Atoi(f["dport"])
|
||||
if src == "" || dst == "" {
|
||||
continue
|
||||
}
|
||||
var bytes int64
|
||||
if b, ok := f["bytes"]; ok {
|
||||
bytes, _ = strconv.ParseInt(b, 10, 64)
|
||||
}
|
||||
out = append(out, connOut{Src: src, Dst: dst, DstPort: dport, Proto: proto, Bytes: bytes})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// kvFields parses "key=value" tokens from a conntrack line (first occurrence
|
||||
// wins, i.e. the original-direction tuple).
|
||||
func kvFields(line string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, tok := range strings.Fields(line) {
|
||||
k, v, ok := strings.Cut(tok, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := m[k]; !exists {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ConnsJSON reports live proxied flows, bounded and sorted by bytes.
|
||||
func ConnsJSON() ([]byte, error) {
|
||||
dump := readConntrack()
|
||||
if strings.TrimSpace(dump) == "" {
|
||||
return json.MarshalIndent(map[string]any{
|
||||
"available": false,
|
||||
"note": "install conntrack-tools for live connections",
|
||||
"conns": []connOut{},
|
||||
}, "", " ")
|
||||
}
|
||||
flows := parseConntrack(dump)
|
||||
|
||||
// Best-effort outbound label: the currently selected proxy node (if any).
|
||||
selected := "proxy"
|
||||
obs := obsGather()
|
||||
for tag, sel := range obs.Selected {
|
||||
if sel {
|
||||
selected = tag
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
m, _ := ReadUCI()
|
||||
for i := range flows {
|
||||
if m != nil && isPrivateOrLocal(flows[i].Dst) {
|
||||
flows[i].Outbound = "direct"
|
||||
} else {
|
||||
flows[i].Outbound = selected
|
||||
}
|
||||
}
|
||||
sort.Slice(flows, func(i, j int) bool { return flows[i].Bytes > flows[j].Bytes })
|
||||
if len(flows) > 200 {
|
||||
flows = flows[:200]
|
||||
}
|
||||
return json.MarshalIndent(map[string]any{
|
||||
"available": true,
|
||||
"conns": flows,
|
||||
}, "", " ")
|
||||
}
|
||||
|
||||
// isPrivateOrLocal reports whether an IP is RFC1918/loopback/link-local (a rough
|
||||
// "not proxied" heuristic for the outbound label).
|
||||
func isPrivateOrLocal(ip string) bool {
|
||||
for _, p := range []string{"10.", "127.", "192.168.", "169.254.", "::1", "fe80:", "fc", "fd"} {
|
||||
if strings.HasPrefix(ip, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(ip, "172.") {
|
||||
// 172.16.0.0/12
|
||||
parts := strings.SplitN(ip, ".", 3)
|
||||
if len(parts) >= 2 {
|
||||
if n, err := strconv.Atoi(parts[1]); err == nil && n >= 16 && n <= 31 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseConntrack(t *testing.T) {
|
||||
// `conntrack -L` style + /proc/net/nf_conntrack style lines.
|
||||
dump := `tcp 6 431999 ESTABLISHED src=192.168.1.60 dst=104.18.2.1 sport=51000 dport=443 packets=10 bytes=5000 src=104.18.2.1 dst=45.131.214.140 sport=443 dport=51000 packets=8 bytes=12000 [ASSURED] mark=0
|
||||
udp 17 29 src=192.168.1.61 dst=8.8.8.8 sport=5353 dport=53 packets=2 bytes=140 src=8.8.8.8 dst=45.131.214.140 sport=53 dport=5353 packets=2 bytes=200 mark=0
|
||||
ipv4 2 icmp 1 src=1.2.3.4 dst=5.6.7.8 type=8`
|
||||
flows := parseConntrack(dump)
|
||||
if len(flows) != 2 {
|
||||
t.Fatalf("want 2 flows (icmp skipped), got %d: %+v", len(flows), flows)
|
||||
}
|
||||
if flows[0].Src != "192.168.1.60" || flows[0].Dst != "104.18.2.1" || flows[0].DstPort != 443 {
|
||||
t.Fatalf("flow0 = %+v", flows[0])
|
||||
}
|
||||
if flows[0].Proto != "tcp" || flows[0].Bytes != 5000 {
|
||||
t.Fatalf("flow0 proto/bytes = %+v", flows[0])
|
||||
}
|
||||
if flows[1].Proto != "udp" || flows[1].DstPort != 53 {
|
||||
t.Fatalf("flow1 = %+v", flows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateOrLocal(t *testing.T) {
|
||||
priv := []string{"10.0.0.1", "192.168.1.1", "172.16.5.5", "127.0.0.1", "169.254.1.1", "::1", "fe80::1"}
|
||||
pub := []string{"8.8.8.8", "104.18.2.1", "172.15.0.1", "172.32.0.1"}
|
||||
for _, ip := range priv {
|
||||
if !isPrivateOrLocal(ip) {
|
||||
t.Errorf("%s should be private/local", ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range pub {
|
||||
if isPrivateOrLocal(ip) {
|
||||
t.Errorf("%s should be public", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
-232
@@ -1,232 +0,0 @@
|
||||
package main
|
||||
|
||||
// Full xray `dns` object rendering from Resolvers + DNSRules + Globals.
|
||||
//
|
||||
// Scope: this file emits ONLY the xray dns object (named typed servers, per-domain
|
||||
// and per-client scoping, queryStrategy, and a fakedns pool in fakeip mode). The
|
||||
// nft/dnsmasq plane — :53 hijack, blocking client DoT/DoH, nftset population for
|
||||
// resolved==routed — is apply.go / shater-core's job and is NOT emitted here.
|
||||
//
|
||||
// Server address schemes by resolver type:
|
||||
// doh -> "https://<addr>" (DNS-over-HTTPS)
|
||||
// dot -> "tls://<addr>" (DNS-over-TLS)
|
||||
// plain -> "<addr>" (UDP/TCP DNS to an IP/host)
|
||||
// local -> "localhost" (system resolver / local dnsmasq)
|
||||
// fakeip -> "fakedns" (backed by the dns.fakedns pool)
|
||||
//
|
||||
// Ordering: xray consults `servers` in list order (domain-scoped entries win
|
||||
// for their domains). globals.resolver_default names the resolver placed FIRST;
|
||||
// globals.resolver_fallback names the resolver placed LAST; everything else
|
||||
// keeps config order.
|
||||
//
|
||||
// detour (resolve a server through a specific outbound) is NOT expressible inside
|
||||
// the xray dns object (xray has no per-server outbound detour the way sing-box
|
||||
// does); it would be realised by a routing rule matching the resolver's address
|
||||
// to that outbound — left to the routing/apply plane. A resolver carrying a
|
||||
// detour is emitted WITHOUT it: the skip is recorded in the dns object's
|
||||
// "_shater_notes" field (ignored by xray's json decoder) and warned on stderr,
|
||||
// so the UI can surface/hide the field honestly.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dnsRender builds the xray `dns` object. geoOK reports whether geoip.dat/geosite.dat
|
||||
// are present; when false, geosite:/geoip: domain matchers are stripped so the emitted
|
||||
// dns object still validates against xray without the data files.
|
||||
func dnsRender(m *Model, geoOK bool) map[string]any {
|
||||
fakeip := strings.EqualFold(m.Globals.DNSMode, "fakeip")
|
||||
|
||||
// Group dns_rules by target resolver: per-domain lists and a per-client hint.
|
||||
domainsByResolver := map[string][]string{}
|
||||
clientIPByResolver := map[string]string{}
|
||||
var blockDomains []string
|
||||
for _, dr := range m.DNSRules {
|
||||
if dr.Resolver == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(dr.Resolver, "block") {
|
||||
blockDomains = append(blockDomains, dr.MatchDomain...)
|
||||
continue
|
||||
}
|
||||
domainsByResolver[dr.Resolver] = append(domainsByResolver[dr.Resolver], dr.MatchDomain...)
|
||||
if clientIPByResolver[dr.Resolver] == "" {
|
||||
if ip := dnsFirstClientIP(dr.MatchSrc); ip != "" {
|
||||
clientIPByResolver[dr.Resolver] = ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var servers []any
|
||||
|
||||
// A blocked-domain set resolves to a fixed non-routable address (fail-closed
|
||||
// DNS): xray has no "refuse" verb in the dns object, so map to a server that
|
||||
// hands back an unusable IP. We keep it valid by pointing those domains at a
|
||||
// localhost server — routing/nft then drops them (documented split).
|
||||
if len(blockDomains) > 0 {
|
||||
servers = append(servers, map[string]any{
|
||||
"address": "localhost",
|
||||
"domains": toAny(dnsCleanEntries(blockDomains, geoOK)),
|
||||
})
|
||||
}
|
||||
|
||||
// resolver_default first / resolver_fallback last (xray uses list order).
|
||||
def := strings.TrimSpace(m.Globals.ResolverDefault)
|
||||
fb := strings.TrimSpace(m.Globals.ResolverFallback)
|
||||
var first, mid, last []any
|
||||
var notes []string
|
||||
defSeen, fbSeen := false, false
|
||||
for _, r := range m.Resolvers {
|
||||
addr := dnsResolverAddress(r)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
obj := map[string]any{"address": addr}
|
||||
if ds := dnsCleanEntries(domainsByResolver[r.Name], geoOK); len(ds) > 0 {
|
||||
obj["domains"] = toAny(ds)
|
||||
}
|
||||
if ip := clientIPByResolver[r.Name]; ip != "" {
|
||||
obj["clientIP"] = ip
|
||||
}
|
||||
if strings.TrimSpace(r.Detour) != "" {
|
||||
// Not expressible in the xray dns object — see the header note.
|
||||
note := fmt.Sprintf("resolver %q: detour=%q not supported by xray dns servers; ignored", r.Name, r.Detour)
|
||||
notes = append(notes, note)
|
||||
fmt.Fprintln(os.Stderr, "note: "+note)
|
||||
}
|
||||
switch {
|
||||
case def != "" && r.Name == def:
|
||||
first = append(first, obj)
|
||||
defSeen = true
|
||||
case fb != "" && r.Name == fb:
|
||||
last = append(last, obj)
|
||||
fbSeen = true
|
||||
default:
|
||||
mid = append(mid, obj)
|
||||
}
|
||||
}
|
||||
// A default/fallback value naming no resolver section is treated as a plain
|
||||
// server address (best-effort; LuCI normally writes resolver names).
|
||||
if def != "" && !defSeen {
|
||||
first = append(first, def)
|
||||
}
|
||||
if fb != "" && !fbSeen {
|
||||
last = append(last, fb)
|
||||
}
|
||||
servers = append(append(append(first, servers...), mid...), last...)
|
||||
|
||||
if len(servers) == 0 {
|
||||
servers = []any{"1.1.1.1", "8.8.8.8", "localhost"}
|
||||
}
|
||||
|
||||
dns := map[string]any{}
|
||||
if fakeip {
|
||||
// FakeIP pool: all not-otherwise-matched lookups get a fake IP from the
|
||||
// pool; the fakedns server is consulted last so typed resolvers above win.
|
||||
dns["fakedns"] = []any{map[string]any{
|
||||
"ipPool": dnsFakePool(m),
|
||||
"poolSize": 65535,
|
||||
}}
|
||||
servers = append(servers, "fakedns")
|
||||
}
|
||||
dns["servers"] = servers
|
||||
dns["queryStrategy"] = dnsQueryStrategy(m, fakeip)
|
||||
if len(notes) > 0 {
|
||||
// Documentation channel: xray's json decoder ignores unknown fields, so
|
||||
// this records (in the generated config itself) what was skipped and why.
|
||||
dns["_shater_notes"] = toAny(notes)
|
||||
}
|
||||
return dns
|
||||
}
|
||||
|
||||
// dnsResolverAddress renders a resolver's address string per its type.
|
||||
func dnsResolverAddress(r Resolver) string {
|
||||
switch strings.ToLower(r.Type) {
|
||||
case "doh":
|
||||
return dnsEnsureScheme(r.Address, "https://")
|
||||
case "dot":
|
||||
return dnsEnsureScheme(r.Address, "tls://")
|
||||
case "local":
|
||||
return "localhost"
|
||||
case "fakeip":
|
||||
return "fakedns"
|
||||
case "plain", "":
|
||||
return strings.TrimSpace(r.Address)
|
||||
default:
|
||||
return strings.TrimSpace(r.Address)
|
||||
}
|
||||
}
|
||||
|
||||
// dnsEnsureScheme prepends scheme unless addr already carries one.
|
||||
func dnsEnsureScheme(addr, scheme string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(addr, "://") {
|
||||
return addr
|
||||
}
|
||||
return scheme + addr
|
||||
}
|
||||
|
||||
// dnsQueryStrategy picks UseIP / UseIPv4 honouring globals.ipv6 (fakeip needs IPs).
|
||||
func dnsQueryStrategy(m *Model, fakeip bool) string {
|
||||
if fakeip {
|
||||
return "UseIP"
|
||||
}
|
||||
if !m.Globals.IPv6 {
|
||||
return "UseIPv4"
|
||||
}
|
||||
return "UseIP"
|
||||
}
|
||||
|
||||
// dnsFakePool resolves the FakeIP CIDR: an explicit `option pool`, else a fakeip
|
||||
// resolver's Address when it looks like a CIDR, else the conventional default.
|
||||
func dnsFakePool(m *Model) string {
|
||||
for _, r := range m.Resolvers {
|
||||
if !strings.EqualFold(r.Type, "fakeip") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(r.Pool, "/") {
|
||||
return strings.TrimSpace(r.Pool)
|
||||
}
|
||||
if strings.Contains(r.Address, "/") {
|
||||
return strings.TrimSpace(r.Address)
|
||||
}
|
||||
}
|
||||
return "198.18.0.0/15"
|
||||
}
|
||||
|
||||
// dnsFirstClientIP returns the first real IP/CIDR from a dns_rule match_src list
|
||||
// (MAC/iface/zone are ignored — they are not valid EDNS clientIP values).
|
||||
func dnsFirstClientIP(src []string) string {
|
||||
for _, c := range genClassifySrc(src).CIDRs {
|
||||
if i := strings.IndexByte(c, '/'); i >= 0 {
|
||||
return c[:i]
|
||||
}
|
||||
return c
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dnsCleanEntries trims blanks from a domain list and — when geoip.dat/geosite.dat
|
||||
// are absent (geoOK==false) — drops geosite:/geoip: entries so the xray dns object
|
||||
// still validates.
|
||||
func dnsCleanEntries(in []string, geoOK bool) []string {
|
||||
var out []string
|
||||
for _, v := range in {
|
||||
if v = strings.TrimSpace(v); v == "" {
|
||||
continue
|
||||
}
|
||||
if !geoOK {
|
||||
l := strings.ToLower(v)
|
||||
if strings.HasPrefix(l, "geosite:") || strings.HasPrefix(l, "geoip:") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package main
|
||||
|
||||
// Egress binding + src classification — the parts of routing that must be
|
||||
// coordinated with apply.go's nft/policy-routing plane.
|
||||
//
|
||||
// ============================ COORDINATION CONTRACT ============================
|
||||
// egress name -> outbound tag "egress-<name>" (see egEgressOutboundTag).
|
||||
//
|
||||
// type=interface | type=tunnel
|
||||
// A dedicated `freedom` outbound tagged "egress-<name>" is emitted with
|
||||
// streamSettings.sockopt:
|
||||
// "interface": "<egress.Interface>" // SO_BINDTODEVICE — direct bind
|
||||
// "mark": egEgressMark(globals,i) // SO_MARK — for policy routing
|
||||
// Primary binding is sockopt.interface (xray binds the socket straight to the
|
||||
// device). The mark is the fallback/coordination channel: apply.go SHOULD, for
|
||||
// the i-th egress in Model.Egresses whose type is interface/tunnel, add
|
||||
// ip rule add fwmark <egEgressMark(g,i)> lookup <table>
|
||||
// ip route add default dev <egress.Interface> table <table>
|
||||
// and add `meta mark <egEgressMark(g,i)> accept` to the nft prerouting bypass
|
||||
// so egress traffic is never re-tproxied. i = index in Model.Egresses.
|
||||
//
|
||||
// type=proxy -> resolves egress.Target ("chain:|group:|node:|direct|block")
|
||||
// to the corresponding outbound/balancer tag.
|
||||
// type=direct -> "direct"
|
||||
// type=block -> "block"
|
||||
//
|
||||
// A rule's `egress` (when set and resolvable) selects the outbound INSTEAD of the
|
||||
// rule's own `target`.
|
||||
//
|
||||
// src classification (genClassifySrc): only real IP/CIDR/host go into xray routing
|
||||
// `source`. MAC / iface:<name> / zone:<name> are nft-plane matches — apply.go
|
||||
// consumes genClassifySrc(rule.Src).{MACs,Ifaces,Zones} to build meta/ether match
|
||||
// lines; they are filtered OUT of the xray config here (xray `source` accepts only
|
||||
// IP/CIDR and would reject the rest).
|
||||
// ==============================================================================
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// egEgressMarkOffset keeps egress marks clear of the tproxy divert mark
|
||||
// (fwmark_base) and the loop-guard mark (loopMark).
|
||||
const egEgressMarkOffset = 0x100
|
||||
|
||||
// egEgressMark returns the deterministic SO_MARK for the idx-th egress (its
|
||||
// position in Model.Egresses). apply.go recomputes the same value to bind the
|
||||
// mark to a routing table / device.
|
||||
func egEgressMark(g Globals, idx int) int {
|
||||
base := g.FwmarkBase
|
||||
if base == 0 {
|
||||
base = 0x2000
|
||||
}
|
||||
return int(base) + egEgressMarkOffset + idx
|
||||
}
|
||||
|
||||
// egEgressOutboundTag is the outbound tag for an interface/tunnel egress.
|
||||
func egEgressOutboundTag(name string) string { return "egress-" + name }
|
||||
|
||||
// findEgress looks up an egress by name.
|
||||
func (b *builder) findEgress(name string) *Egress {
|
||||
for i := range b.m.Egresses {
|
||||
if b.m.Egresses[i].Name == name {
|
||||
return &b.m.Egresses[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildEgresses emits a distinct `freedom` outbound for every interface/tunnel
|
||||
// egress so its traffic is separable (device-bound + uniquely marked). proxy/
|
||||
// direct/block egresses need no dedicated outbound (they reuse existing tags).
|
||||
func (b *builder) buildEgresses() {
|
||||
for i, eg := range b.m.Egresses {
|
||||
switch strings.ToLower(eg.Type) {
|
||||
case "interface", "tunnel":
|
||||
tag := egEgressOutboundTag(eg.Name)
|
||||
if b.emittedTag[tag] {
|
||||
b.egressTag[eg.Name] = tag
|
||||
continue
|
||||
}
|
||||
sock := map[string]any{"mark": egEgressMark(b.m.Globals, i)}
|
||||
if eg.Interface != "" {
|
||||
// SO_BINDTODEVICE needs the L3 DEVICE (eth1, wg0, …), not the UCI
|
||||
// interface name (wan, awg0). Resolve it; ifaceDevice returns the
|
||||
// input unchanged when it is already a device.
|
||||
sock["interface"] = ifaceDevice(eg.Interface)
|
||||
}
|
||||
b.outbounds = append(b.outbounds, Outbound{
|
||||
"tag": tag,
|
||||
"protocol": "freedom",
|
||||
"settings": map[string]any{"domainStrategy": "UseIP"},
|
||||
"streamSettings": map[string]any{"sockopt": sock},
|
||||
})
|
||||
b.emittedTag[tag] = true
|
||||
b.egressTag[eg.Name] = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEgressTag maps a rule's `egress` name to the outbound/balancer tag it
|
||||
// should route to. ok is false when the name is unknown (caller then falls back
|
||||
// to the rule's own target).
|
||||
func (b *builder) resolveEgressTag(name string) (tag string, ok bool) {
|
||||
eg := b.findEgress(name)
|
||||
if eg == nil {
|
||||
return "", false
|
||||
}
|
||||
switch strings.ToLower(eg.Type) {
|
||||
case "interface", "tunnel":
|
||||
if t := b.egressTag[name]; t != "" {
|
||||
return t, true
|
||||
}
|
||||
return egEgressOutboundTag(name), true
|
||||
case "proxy":
|
||||
return b.resolveTarget(eg.Target), true
|
||||
case "block":
|
||||
return "block", true
|
||||
case "direct":
|
||||
return "direct", true
|
||||
}
|
||||
return "direct", true
|
||||
}
|
||||
|
||||
// --- src classification (shared with apply.go's nft plane) ---
|
||||
|
||||
// genSrcClasses is rule.Src split by kind.
|
||||
type genSrcClasses struct {
|
||||
CIDRs []string // IP / CIDR / host — xray `source`
|
||||
MACs []string // AA:BB:CC:DD:EE:FF — nft `ether saddr`
|
||||
Ifaces []string // iface:<name> — nft `iifname`
|
||||
Zones []string // zone:<name> — fw4 zone -> devices
|
||||
}
|
||||
|
||||
var genMACRe = regexp.MustCompile(`^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$`)
|
||||
|
||||
// genClassifySrc classifies each rule.Src entry. Only CIDRs belong in xray
|
||||
// routing `source`; MAC/iface/zone are for apply.go's nft rules.
|
||||
func genClassifySrc(src []string) genSrcClasses {
|
||||
var c genSrcClasses
|
||||
for _, raw := range src {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(v, "iface:"):
|
||||
c.Ifaces = append(c.Ifaces, strings.TrimPrefix(v, "iface:"))
|
||||
case strings.HasPrefix(v, "zone:"):
|
||||
c.Zones = append(c.Zones, strings.TrimPrefix(v, "zone:"))
|
||||
case genMACRe.MatchString(v):
|
||||
c.MACs = append(c.MACs, v)
|
||||
default:
|
||||
c.CIDRs = append(c.CIDRs, v)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// genLoadListFile reads a ruleset file (one token per line, `#` comments) into a
|
||||
// slice of entries. Missing/unreadable files yield no entries (best-effort).
|
||||
func genLoadListFile(path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
// Real cross-process lock for the target OS (OpenWrt/Linux). syscall.Flock
|
||||
// does not exist on non-unix dev hosts, so this lives behind a build tag and
|
||||
// installs itself over main.go's no-op default. The lock is BLOCKING: a second
|
||||
// mutating xrayctl invocation waits for the first instead of interleaving its
|
||||
// nft/route/run.json writes with it. The kernel releases a flock automatically
|
||||
// when the process dies, so a crashed holder can never wedge the box.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const lockPath = "/var/lock/xrayctl.lock"
|
||||
|
||||
func init() { lockExclusive = flockExclusive; lockTry = flockTryPath }
|
||||
|
||||
// flockTryPath is the non-blocking counterpart used to single-flight the node
|
||||
// probe sweep. ok=false (EWOULDBLOCK) means another process already holds it —
|
||||
// that is expected contention, not an error, so err stays nil.
|
||||
func flockTryPath(path string) (release func(), ok bool, err error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, false, nil
|
||||
}
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
_ = f.Close()
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func flockExclusive() (release func(), err error) {
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
-1374
File diff suppressed because it is too large
Load Diff
@@ -1,315 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helper: find a balancer by tag in a built config.
|
||||
func findBalancer(t *testing.T, cfg map[string]any, tag string) map[string]any {
|
||||
t.Helper()
|
||||
for _, b := range cfg["routing"].(map[string]any)["balancers"].([]any) {
|
||||
bm := b.(map[string]any)
|
||||
if bm["tag"] == tag {
|
||||
return bm
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func routingRules(cfg map[string]any) []any {
|
||||
return cfg["routing"].(map[string]any)["rules"].([]any)
|
||||
}
|
||||
|
||||
// 1. Egress binding: an interface egress yields a distinct device-bound outbound
|
||||
// and the rule routes to it (instead of its own target).
|
||||
func TestEgressInterfaceProducesDistinctOutbound(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Egresses = []Egress{{Name: "via-wan", Type: "interface", Interface: "wan0"}}
|
||||
m.Rules = []Rule{{
|
||||
Name: "r", Enabled: true, Order: 10,
|
||||
Src: []string{"192.168.1.0/24"}, Egress: "via-wan", Target: "direct",
|
||||
}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Distinct outbound egress-via-wan bound to the device.
|
||||
var eg map[string]any
|
||||
for _, o := range cfg["outbounds"].([]any) {
|
||||
ob := o.(map[string]any)
|
||||
if ob["tag"] == "egress-via-wan" {
|
||||
eg = ob
|
||||
}
|
||||
}
|
||||
if eg == nil {
|
||||
t.Fatal("egress outbound egress-via-wan not emitted")
|
||||
}
|
||||
if eg["protocol"] != "freedom" {
|
||||
t.Fatalf("egress outbound protocol = %v, want freedom", eg["protocol"])
|
||||
}
|
||||
sock := eg["streamSettings"].(map[string]any)["sockopt"].(map[string]any)
|
||||
if sock["interface"] != "wan0" {
|
||||
t.Fatalf("egress sockopt.interface = %v, want wan0", sock["interface"])
|
||||
}
|
||||
if _, ok := sock["mark"]; !ok {
|
||||
t.Fatal("egress sockopt.mark missing (policy-routing coordination)")
|
||||
}
|
||||
|
||||
// A routing rule targets the egress outbound.
|
||||
found := false
|
||||
for _, r := range routingRules(cfg) {
|
||||
if r.(map[string]any)["outboundTag"] == "egress-via-wan" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no routing rule targets egress-via-wan")
|
||||
}
|
||||
}
|
||||
|
||||
// Egress type=proxy resolves target to the proxy tag (here a single node).
|
||||
func TestEgressProxyResolvesToTarget(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Nodes = []Node{{Name: "n1", Enabled: true, URI: vlessReality}}
|
||||
m.Egresses = []Egress{{Name: "via-proxy", Type: "proxy", Target: "node:n1"}}
|
||||
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Egress: "via-proxy", Target: "direct"}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range routingRules(cfg) {
|
||||
if r.(map[string]any)["outboundTag"] == "node_n1" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("proxy egress did not route to node_n1")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Kill-switch: closed (default) -> balancer fallbackTag block + trailing block
|
||||
// catch-all. open -> fallback direct.
|
||||
func TestKillSwitchClosedFallsBackToBlock(t *testing.T) {
|
||||
mk := func(kill string) map[string]any {
|
||||
m := &Model{Globals: defaultGlobals()} // KillSwitch defaults to "closed"
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Nodes = []Node{
|
||||
{Name: "h1", Enabled: true, URI: vlessReality},
|
||||
{Name: "h2", Enabled: true, URI: trojanTLS},
|
||||
}
|
||||
m.Groups = []Group{{Name: "g", Source: "manual", Nodes: []string{"h1", "h2"}, Strategy: "leastping"}}
|
||||
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Target: "group:g", Kill: kill}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// closed: balancer g_g falls back to block, NOT direct.
|
||||
cfg := mk("default")
|
||||
bal := findBalancer(t, cfg, "g_g")
|
||||
if bal == nil {
|
||||
t.Fatal("balancer g_g not emitted")
|
||||
}
|
||||
if bal["fallbackTag"] != "block" {
|
||||
t.Fatalf("closed kill-switch fallbackTag = %v, want block", bal["fallbackTag"])
|
||||
}
|
||||
// trailing fail-closed catch-all: last rule blocks unmatched tcp,udp.
|
||||
rules := routingRules(cfg)
|
||||
last := rules[len(rules)-1].(map[string]any)
|
||||
if last["outboundTag"] != "block" || last["network"] != "tcp,udp" {
|
||||
t.Fatalf("trailing catch-all = %v, want network tcp,udp -> block", last)
|
||||
}
|
||||
// direct must NOT be the implicit fallback anywhere for this closed config.
|
||||
for _, b := range cfg["routing"].(map[string]any)["balancers"].([]any) {
|
||||
if b.(map[string]any)["fallbackTag"] == "direct" {
|
||||
t.Fatal("closed policy leaked a direct fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// open: same group now falls back to direct.
|
||||
cfgOpen := mk("open")
|
||||
balOpen := findBalancer(t, cfgOpen, "g_g")
|
||||
if balOpen["fallbackTag"] != "direct" {
|
||||
t.Fatalf("open kill-switch fallbackTag = %v, want direct", balOpen["fallbackTag"])
|
||||
}
|
||||
}
|
||||
|
||||
// 3. DNS: typed servers by resolver type (doh/dot/local) + fakeip pool.
|
||||
func TestDNSTypedServers(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Resolvers = []Resolver{
|
||||
{Name: "doh1", Type: "doh", Address: "dns.quad9.net/dns-query"},
|
||||
{Name: "dot1", Type: "dot", Address: "1.1.1.1"},
|
||||
{Name: "loc", Type: "local"},
|
||||
}
|
||||
m.DNSRules = []DNSRule{{Order: 10, MatchDomain: []string{"geosite:ads"}, MatchSrc: []string{"192.168.5.0/24"}, Resolver: "doh1"}}
|
||||
|
||||
dns := dnsRender(m, true)
|
||||
addrs := map[string]map[string]any{}
|
||||
var flat []string
|
||||
for _, s := range dns["servers"].([]any) {
|
||||
switch v := s.(type) {
|
||||
case string:
|
||||
flat = append(flat, v)
|
||||
case map[string]any:
|
||||
addrs[v["address"].(string)] = v
|
||||
flat = append(flat, v["address"].(string))
|
||||
}
|
||||
}
|
||||
joined := strings.Join(flat, ",")
|
||||
for _, want := range []string{"https://dns.quad9.net/dns-query", "tls://1.1.1.1", "localhost"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("dns servers missing %q; got %v", want, flat)
|
||||
}
|
||||
}
|
||||
// per-domain + per-client scoping attached to the doh server.
|
||||
doh := addrs["https://dns.quad9.net/dns-query"]
|
||||
if doh == nil || doh["domains"] == nil {
|
||||
t.Fatalf("doh server missing per-domain scoping: %v", doh)
|
||||
}
|
||||
if doh["clientIP"] != "192.168.5.0" {
|
||||
t.Fatalf("doh server clientIP = %v, want 192.168.5.0", doh["clientIP"])
|
||||
}
|
||||
if dns["queryStrategy"] == nil {
|
||||
t.Fatal("queryStrategy missing")
|
||||
}
|
||||
|
||||
// fakeip mode adds a fakedns pool + server.
|
||||
m.Globals.DNSMode = "fakeip"
|
||||
fdns := dnsRender(m, true)
|
||||
if fdns["fakedns"] == nil {
|
||||
t.Fatal("fakeip mode missing fakedns block")
|
||||
}
|
||||
pool := fdns["fakedns"].([]any)[0].(map[string]any)
|
||||
if _, ok := pool["ipPool"]; !ok {
|
||||
t.Fatal("fakedns pool missing ipPool")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Stats/API plane present in the full config.
|
||||
func TestStatsAPIPresent(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Rules = []Rule{{Name: "all", Enabled: true, Order: 10, Target: "direct"}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"api", "stats", "policy"} {
|
||||
if _, ok := cfg[k]; !ok {
|
||||
t.Fatalf("config missing %q block", k)
|
||||
}
|
||||
}
|
||||
api := cfg["api"].(map[string]any)
|
||||
svcs := strings.Join(func() []string {
|
||||
var out []string
|
||||
for _, s := range api["services"].([]any) {
|
||||
out = append(out, s.(string))
|
||||
}
|
||||
return out
|
||||
}(), ",")
|
||||
if !strings.Contains(svcs, "StatsService") {
|
||||
t.Fatalf("api services missing StatsService: %v", svcs)
|
||||
}
|
||||
// ObservatoryService is only advertised when an observatory block exists
|
||||
// (a 2+-member health-probed group); this config has none, so it must be absent
|
||||
// (else xray -test fails "not all dependencies are resolved").
|
||||
if strings.Contains(svcs, "ObservatoryService") {
|
||||
t.Fatalf("ObservatoryService advertised without an observatory block: %v", svcs)
|
||||
}
|
||||
// api dokodemo inbound on the loopback port.
|
||||
foundIn := false
|
||||
for _, in := range cfg["inbounds"].([]any) {
|
||||
im := in.(map[string]any)
|
||||
if im["tag"] == "api" && im["protocol"] == "dokodemo-door" && im["port"] == apiInboundPort {
|
||||
foundIn = true
|
||||
}
|
||||
}
|
||||
if !foundIn {
|
||||
t.Fatal("api dokodemo inbound missing")
|
||||
}
|
||||
// routing rule inboundTag api -> outboundTag api.
|
||||
foundRule := false
|
||||
for _, r := range routingRules(cfg) {
|
||||
rm := r.(map[string]any)
|
||||
if rm["outboundTag"] == "api" {
|
||||
if tags, ok := rm["inboundTag"].([]any); ok && len(tags) == 1 && tags[0] == "api" {
|
||||
foundRule = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundRule {
|
||||
t.Fatal("api routing rule (inboundTag api -> outbound api) missing")
|
||||
}
|
||||
// policy.system stats toggles.
|
||||
sys := cfg["policy"].(map[string]any)["system"].(map[string]any)
|
||||
for _, k := range []string{"statsInboundUplink", "statsInboundDownlink", "statsOutboundUplink", "statsOutboundDownlink"} {
|
||||
if sys[k] != true {
|
||||
t.Fatalf("policy.system.%s not enabled", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. src classification: only CIDRs reach xray source; MAC/iface/zone split out.
|
||||
func TestSrcClassification(t *testing.T) {
|
||||
c := genClassifySrc([]string{
|
||||
"192.168.1.0/24", "AA:BB:CC:DD:EE:FF", "iface:lan", "zone:guest", "10.0.0.1/32", " ",
|
||||
})
|
||||
if len(c.CIDRs) != 2 {
|
||||
t.Fatalf("CIDRs = %v, want 2", c.CIDRs)
|
||||
}
|
||||
if len(c.MACs) != 1 || c.MACs[0] != "AA:BB:CC:DD:EE:FF" {
|
||||
t.Fatalf("MACs = %v", c.MACs)
|
||||
}
|
||||
if len(c.Ifaces) != 1 || c.Ifaces[0] != "lan" {
|
||||
t.Fatalf("Ifaces = %v", c.Ifaces)
|
||||
}
|
||||
if len(c.Zones) != 1 || c.Zones[0] != "guest" {
|
||||
t.Fatalf("Zones = %v", c.Zones)
|
||||
}
|
||||
|
||||
// End-to-end: a rule with mixed src only emits CIDRs into xray `source`.
|
||||
// Stub the live MAC/iface/zone resolution (host-dependent) to "no result"
|
||||
// so only the literal CIDR survives.
|
||||
saved := genResolveNftSrcs
|
||||
genResolveNftSrcs = func(genSrcClasses) []string { return nil }
|
||||
defer func() { genResolveNftSrcs = saved }()
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Rules = []Rule{{
|
||||
Name: "r", Enabled: true, Order: 10,
|
||||
Src: []string{"192.168.1.5/32", "AA:BB:CC:DD:EE:FF", "iface:lan", "zone:guest"},
|
||||
Target: "direct",
|
||||
}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(cfg)
|
||||
s := string(b)
|
||||
if strings.Contains(s, "AA:BB:CC:DD:EE:FF") || strings.Contains(s, "iface:lan") || strings.Contains(s, "zone:guest") {
|
||||
t.Fatalf("nft-plane src tokens leaked into xray config:\n%s", s)
|
||||
}
|
||||
// The user rule keeps exactly the one CIDR in source.
|
||||
var userRule map[string]any
|
||||
for _, r := range routingRules(cfg) {
|
||||
rm := r.(map[string]any)
|
||||
if src, ok := rm["source"].([]any); ok && len(src) > 0 && src[0] == "192.168.1.5/32" {
|
||||
userRule = rm
|
||||
}
|
||||
}
|
||||
if userRule == nil {
|
||||
t.Fatal("rule source did not retain the CIDR entry")
|
||||
}
|
||||
if len(userRule["source"].([]any)) != 1 {
|
||||
t.Fatalf("source = %v, want exactly the one CIDR", userRule["source"])
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const vlessReality = "vless://11111111-1111-1111-1111-111111111111@a.example.com:443?type=tcp&security=reality&pbk=PBK&sid=aa&sni=www.microsoft.com#n1"
|
||||
const trojanTLS = "trojan://pw@b.example.com:443?security=tls&sni=b.example.com#n2"
|
||||
|
||||
func TestBuildConfigFromLinks(t *testing.T) {
|
||||
cfg, err := BuildConfigFromLinks([]string{vlessReality, trojanTLS})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Must round-trip through JSON.
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back map[string]any
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, k := range []string{"log", "dns", "inbounds", "outbounds", "routing"} {
|
||||
if _, ok := back[k]; !ok {
|
||||
t.Fatalf("missing top-level key %q", k)
|
||||
}
|
||||
}
|
||||
// One tproxy dokodemo inbound with tproxy sockopt + loop mark.
|
||||
ins := cfg["inbounds"].([]any)
|
||||
if len(ins) != 1 {
|
||||
t.Fatalf("want 1 inbound, got %d", len(ins))
|
||||
}
|
||||
in0 := ins[0].(map[string]any)
|
||||
if in0["protocol"] != "dokodemo-door" {
|
||||
t.Fatalf("inbound protocol = %v", in0["protocol"])
|
||||
}
|
||||
sock := in0["streamSettings"].(map[string]any)["sockopt"].(map[string]any)
|
||||
if sock["tproxy"] != "tproxy" || sock["mark"] != loopMark {
|
||||
t.Fatalf("inbound sockopt = %v", sock)
|
||||
}
|
||||
// Balancer over the two members + observatory present (leastPing default).
|
||||
routing := cfg["routing"].(map[string]any)
|
||||
bals := routing["balancers"].([]any)
|
||||
if len(bals) != 1 {
|
||||
t.Fatalf("want 1 balancer, got %d", len(bals))
|
||||
}
|
||||
if _, ok := cfg["observatory"]; !ok {
|
||||
t.Fatal("observatory missing for leastPing group")
|
||||
}
|
||||
// Every real outbound carries the loop-guard mark.
|
||||
for _, o := range cfg["outbounds"].([]any) {
|
||||
ob := o.(map[string]any)
|
||||
if ob["protocol"] == "blackhole" {
|
||||
continue
|
||||
}
|
||||
ss, _ := ob["streamSettings"].(map[string]any)
|
||||
if ss == nil {
|
||||
t.Fatalf("outbound %v missing streamSettings", ob["tag"])
|
||||
}
|
||||
if ss["sockopt"].(map[string]any)["mark"] != loopMark {
|
||||
t.Fatalf("outbound %v missing loop mark", ob["tag"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfigChain(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, Network: "br-lan", TproxyPort: 12345, TCP: true, UDP: true, Sniff: true}}
|
||||
m.Nodes = []Node{
|
||||
{Name: "h1", Enabled: true, URI: vlessReality},
|
||||
{Name: "h2", Enabled: true, URI: trojanTLS},
|
||||
}
|
||||
m.Groups = []Group{
|
||||
{Name: "g1", Source: "manual", Nodes: []string{"h1"}, Strategy: "single"},
|
||||
{Name: "g2", Source: "manual", Nodes: []string{"h2"}, Strategy: "single"},
|
||||
}
|
||||
m.Chains = []Chain{{Name: "dbl", Hops: []string{"group:g1", "group:g2"}}}
|
||||
m.Rules = []Rule{{Name: "all", Enabled: true, Order: 10, Target: "chain:dbl"}}
|
||||
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
// Internal socks inbound for the first layer must exist.
|
||||
if !strings.Contains(s, "in_dbl_L1") {
|
||||
t.Fatalf("chain internal socks inbound missing:\n%s", s)
|
||||
}
|
||||
// The L2 (exit) node must dial through the L1 socks outbound (inverted proxySettings).
|
||||
if !strings.Contains(s, "proxySettings") || !strings.Contains(s, "socks_dbl_L1") {
|
||||
t.Fatalf("inverted proxySettings chaining missing:\n%s", s)
|
||||
}
|
||||
// A routing rule must send matched traffic to the exit layer (L2).
|
||||
if !strings.Contains(s, "c_dbl_L2_") {
|
||||
t.Fatalf("exit-layer outbound missing:\n%s", s)
|
||||
}
|
||||
var back map[string]any
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfigRuleTargets(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true}}
|
||||
m.Nodes = []Node{{Name: "n1", Enabled: true, URI: vlessReality}}
|
||||
m.Rules = []Rule{
|
||||
{Name: "ads", Enabled: true, Order: 5, DstDomain: []string{"geosite:category-ads"}, Target: "block"},
|
||||
{Name: "direct-ru", Enabled: true, Order: 10, DstIP: []string{"1.2.3.0/24"}, Target: "direct"},
|
||||
{Name: "pin", Enabled: true, Order: 20, Src: []string{"192.168.1.5/32"}, Target: "node:n1"},
|
||||
}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules := cfg["routing"].(map[string]any)["rules"].([]any)
|
||||
// Expect our 3 rules, ordered.
|
||||
if len(rules) < 3 {
|
||||
t.Fatalf("want >=3 routing rules, got %d", len(rules))
|
||||
}
|
||||
// node:n1 -> outbound node_n1
|
||||
found := false
|
||||
for _, r := range rules {
|
||||
rm := r.(map[string]any)
|
||||
if rm["outboundTag"] == "node_n1" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("rule targeting node_n1 not emitted")
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
package main
|
||||
|
||||
// Optional geoip.dat / geosite.dat management. The plugin never hard-depends on
|
||||
// them (they are ~25 MB — too much for small-flash routers). When they are absent
|
||||
// the generator strips geosite:/geoip: matchers so `xray -test` still passes and
|
||||
// the box keeps working; the user can download them on demand (Lists page button /
|
||||
// `xrayctl geodata download`) when there is disk to spare, and remove them to
|
||||
// reclaim it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// geoAssetDir is where xray looks for geoip.dat/geosite.dat (XRAY_LOCATION_ASSET).
|
||||
func geoAssetDir() string {
|
||||
if d := os.Getenv("XRAY_LOCATION_ASSET"); d != "" {
|
||||
return d
|
||||
}
|
||||
return "/usr/share/xray"
|
||||
}
|
||||
|
||||
// geoFileOK reports the resolved size of an asset (following symlinks); 0 if absent.
|
||||
func geoFileSize(name string) int64 {
|
||||
fi, err := os.Stat(filepath.Join(geoAssetDir(), name)) // Stat follows symlinks
|
||||
if err != nil || fi.IsDir() || fi.Size() == 0 {
|
||||
return 0
|
||||
}
|
||||
return fi.Size()
|
||||
}
|
||||
|
||||
// geoAssetPresent is true only when BOTH dat files are present and non-empty.
|
||||
func geoAssetPresent() bool {
|
||||
return geoFileSize("geoip.dat") > 0 && geoFileSize("geosite.dat") > 0
|
||||
}
|
||||
|
||||
// geoStrip drops geosite:/geoip: tokens from a matcher list (used when the data is
|
||||
// absent, so xray never sees an unresolvable geo reference).
|
||||
func geoStrip(in []string) []string {
|
||||
out := in[:0:0]
|
||||
for _, v := range in {
|
||||
t := strings.ToLower(strings.TrimSpace(v))
|
||||
if strings.HasPrefix(t, "geosite:") || strings.HasPrefix(t, "geoip:") {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// freeKB returns the free space (KiB) on the filesystem holding p, via `df -k`
|
||||
// (portable: compiles off-Linux for tests, runs anywhere the router has df). -1 on
|
||||
// failure so callers treat "unknown" as "allow".
|
||||
func freeKB(p string) int64 {
|
||||
out, err := exec.Command("df", "-k", p).Output()
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) < 2 {
|
||||
return -1
|
||||
}
|
||||
f := strings.Fields(lines[len(lines)-1]) // last line = the mount
|
||||
if len(f) < 4 {
|
||||
return -1
|
||||
}
|
||||
// df -k columns: Filesystem 1K-blocks Used Available ... -> index 3 = available
|
||||
if v, e := strconv.ParseInt(f[3], 10, 64); e == nil {
|
||||
return v
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// geodataStatus builds the status map (presence, sizes, free space, fit). An
|
||||
// optional error string is attached so action commands can report failure in-band
|
||||
// (the UI reads it) while still returning the current status.
|
||||
func geodataStatus(errMsg string) map[string]any {
|
||||
free := freeKB("/")
|
||||
// geoip.dat ~23 MB + geosite.dat ~3 MB; require a little headroom.
|
||||
const needKB = 30 * 1024
|
||||
m := map[string]any{
|
||||
"present": geoAssetPresent(),
|
||||
"geoip_size": geoFileSize("geoip.dat"),
|
||||
"geosite_size": geoFileSize("geosite.dat"),
|
||||
"asset_dir": geoAssetDir(),
|
||||
"free_kb": free,
|
||||
"need_kb": needKB,
|
||||
"can_download": free < 0 || free >= needKB,
|
||||
}
|
||||
if errMsg != "" {
|
||||
m["error"] = errMsg
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// GeodataStatusJSON reports presence, sizes, and whether a download would fit.
|
||||
func GeodataStatusJSON() ([]byte, error) {
|
||||
return json.MarshalIndent(geodataStatus(""), "", " ")
|
||||
}
|
||||
|
||||
// GeodataDownload installs v2ray-geoip/v2ray-geosite (opkg) after a free-space
|
||||
// check, then makes sure the dat files are visible in the xray asset dir. apk-based
|
||||
// systems (25.12+) fall through to the same call names.
|
||||
func GeodataDownload() error {
|
||||
if geoAssetPresent() {
|
||||
return nil // already there
|
||||
}
|
||||
const needKB = 30 * 1024
|
||||
if f := freeKB("/"); f >= 0 && f < needKB {
|
||||
return fmt.Errorf("not enough free space: %d KiB free, need ~%d KiB", f, needKB)
|
||||
}
|
||||
// Refresh lists then install (opkg on 24.10; apk shim uses the same pkg names).
|
||||
pm := "opkg"
|
||||
if _, err := exec.LookPath("apk"); err == nil {
|
||||
if _, e := exec.LookPath("opkg"); e != nil {
|
||||
pm = "apk"
|
||||
}
|
||||
}
|
||||
if pm == "opkg" {
|
||||
_ = exec.Command("opkg", "update").Run()
|
||||
if out, err := exec.Command("opkg", "install", "v2ray-geoip", "v2ray-geosite").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("opkg install geodata: %v\n%s", err, out)
|
||||
}
|
||||
} else {
|
||||
_ = exec.Command("apk", "update").Run()
|
||||
if out, err := exec.Command("apk", "add", "v2ray-geoip", "v2ray-geosite").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("apk add geodata: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
geoEnsureSymlinks()
|
||||
if !geoAssetPresent() {
|
||||
return fmt.Errorf("geodata still not visible in %s after install", geoAssetDir())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// geoEnsureSymlinks links the v2ray-shipped dat files into xray's asset dir when
|
||||
// the packages install to /usr/share/v2ray but xray reads XRAY_LOCATION_ASSET.
|
||||
func geoEnsureSymlinks() {
|
||||
dir := geoAssetDir()
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
for _, f := range []string{"geoip.dat", "geosite.dat"} {
|
||||
dst := filepath.Join(dir, f)
|
||||
if geoFileSize(f) > 0 {
|
||||
continue
|
||||
}
|
||||
for _, src := range []string{"/usr/share/v2ray/" + f, "/usr/share/xray/" + f} {
|
||||
if fi, err := os.Stat(src); err == nil && fi.Size() > 0 && src != dst {
|
||||
_ = os.Remove(dst)
|
||||
_ = os.Symlink(src, dst)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GeodataRemove uninstalls the geodata packages to reclaim flash.
|
||||
func GeodataRemove() error {
|
||||
if _, err := exec.LookPath("opkg"); err == nil {
|
||||
_ = exec.Command("opkg", "remove", "v2ray-geoip", "v2ray-geosite").Run()
|
||||
} else if _, err := exec.LookPath("apk"); err == nil {
|
||||
_ = exec.Command("apk", "del", "v2ray-geoip", "v2ray-geosite").Run()
|
||||
}
|
||||
for _, f := range []string{"geoip.dat", "geosite.dat"} {
|
||||
p := filepath.Join(geoAssetDir(), f)
|
||||
if fi, err := os.Lstat(p); err == nil && fi.Mode()&os.ModeSymlink != 0 {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdGeodata dispatches `xrayctl geodata status|download|remove`.
|
||||
func cmdGeodata(args []string) int {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
// Action commands always emit a status JSON (with an in-band "error" field on
|
||||
// failure) so the LuCI backend gets a parseable result either way; the process
|
||||
// still exits non-zero on failure for CLI/script callers.
|
||||
switch sub {
|
||||
case "status", "":
|
||||
return emit(GeodataStatusJSON())
|
||||
case "download":
|
||||
if err := GeodataDownload(); err != nil {
|
||||
emit(json.MarshalIndent(geodataStatus(err.Error()), "", " "))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "geodata installed")
|
||||
return emit(GeodataStatusJSON())
|
||||
case "remove":
|
||||
if err := GeodataRemove(); err != nil {
|
||||
emit(json.MarshalIndent(geodataStatus(err.Error()), "", " "))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "geodata removed")
|
||||
return emit(GeodataStatusJSON())
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl geodata status|download|remove")
|
||||
return 2
|
||||
}
|
||||
|
||||
// geoNoteAbsent logs (to stderr) which geo matchers were stripped, for `gen`/`test`
|
||||
// diagnostics. Best-effort, never fails.
|
||||
func geoNoteAbsent(stripped int) {
|
||||
if stripped > 0 {
|
||||
fmt.Fprintf(os.Stderr, "note: geodata absent — stripped %d geosite:/geoip: matcher(s); run `xrayctl geodata download`\n", stripped)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// geoStrip must drop geosite:/geoip: tokens (any case) and keep everything else.
|
||||
func TestGeoStrip(t *testing.T) {
|
||||
in := []string{"geosite:ads", "example.com", "GeoIP:cn", " geosite:private ", "1.2.3.0/24"}
|
||||
got := geoStrip(in)
|
||||
want := []string{"example.com", "1.2.3.0/24"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("geoStrip = %v, want %v", got, want)
|
||||
}
|
||||
// keeps original slice values (no mutation of survivors)
|
||||
if got[0] != "example.com" {
|
||||
t.Fatalf("geoStrip mangled survivor: %q", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// dnsRender must honour geoOK: keep geosite:/geoip: when true, strip when false.
|
||||
func TestDNSRenderGeoOptional(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Resolvers = []Resolver{{Name: "doh1", Type: "doh", Address: "dns.quad9.net/dns-query"}}
|
||||
m.DNSRules = []DNSRule{{Order: 10, MatchDomain: []string{"geosite:ads", "example.org"}, Resolver: "doh1"}}
|
||||
|
||||
kept, _ := json.Marshal(dnsRender(m, true))
|
||||
if !strings.Contains(string(kept), "geosite:ads") {
|
||||
t.Fatalf("geoOK=true dropped geosite entry: %s", kept)
|
||||
}
|
||||
stripped, _ := json.Marshal(dnsRender(m, false))
|
||||
if strings.Contains(string(stripped), "geosite:ads") {
|
||||
t.Fatalf("geoOK=false kept geosite entry: %s", stripped)
|
||||
}
|
||||
if !strings.Contains(string(stripped), "example.org") {
|
||||
t.Fatalf("geoOK=false dropped the plain domain too: %s", stripped)
|
||||
}
|
||||
}
|
||||
|
||||
// A full config built when geodata is absent (geoAssetPresent()==false on the test
|
||||
// host) must not leak any geosite:/geoip: matcher into routing, yet keep plain ones,
|
||||
// and still marshal to valid JSON.
|
||||
func TestBuildConfigStripsGeoWhenAbsent(t *testing.T) {
|
||||
if geoAssetPresent() {
|
||||
t.Skip("geodata present on this host; strip path not exercised")
|
||||
}
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Nodes = []Node{{Name: "n1", Enabled: true, URI: "trojan://password123@example.com:443?security=tls&sni=example.com&type=tcp#n1"}}
|
||||
m.Rules = []Rule{{
|
||||
Name: "r1", Enabled: true, Order: 10,
|
||||
DstDomain: []string{"geosite:cn", "example.net"},
|
||||
DstIP: []string{"geoip:cn", "1.2.3.0/24"},
|
||||
Target: "direct",
|
||||
}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig: %v", err)
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
s := string(b)
|
||||
if strings.Contains(s, "geosite:") || strings.Contains(s, "geoip:") {
|
||||
t.Fatalf("geo matcher leaked into config with geodata absent: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "example.net") || !strings.Contains(s, "1.2.3.0/24") {
|
||||
t.Fatalf("plain matchers were dropped: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module xrayctl
|
||||
|
||||
go 1.22
|
||||
@@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func inboundByTag(cfg map[string]any, sub string) map[string]any {
|
||||
for _, o := range cfg["inbounds"].([]any) {
|
||||
ib := o.(map[string]any)
|
||||
if tag, _ := ib["tag"].(string); strings.Contains(tag, sub) {
|
||||
return ib
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWithInbounds(t *testing.T, ins ...Inbound) map[string]any {
|
||||
t.Helper()
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = ins
|
||||
m.Nodes = []Node{{Name: "n1", Enabled: true, URI: vlessReality}}
|
||||
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Target: "node:n1"}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestSocksInboundEmitted(t *testing.T) {
|
||||
cfg := buildWithInbounds(t, Inbound{Type: "socks", Name: "local", Enabled: true, Listen: "127.0.0.1", Port: 1080, UDP: true, Sniff: true, Auth: "noauth"})
|
||||
ib := inboundByTag(cfg, "socks-in-local")
|
||||
if ib == nil || ib["protocol"] != "socks" {
|
||||
t.Fatalf("socks inbound missing: %v", ib)
|
||||
}
|
||||
if ib["listen"] != "127.0.0.1" || ib["port"] != 1080 {
|
||||
t.Fatalf("listen/port wrong: %v", ib)
|
||||
}
|
||||
st := ib["settings"].(map[string]any)
|
||||
if st["udp"] != true || st["auth"] != "noauth" {
|
||||
t.Fatalf("settings wrong: %v", st)
|
||||
}
|
||||
if _, ok := ib["streamSettings"]; ok {
|
||||
t.Fatalf("local socks must have no streamSettings/sockopt: %v", ib["streamSettings"])
|
||||
}
|
||||
if ib["sniffing"] == nil {
|
||||
t.Fatalf("sniffing expected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocksInboundPasswordAuth(t *testing.T) {
|
||||
cfg := buildWithInbounds(t, Inbound{Type: "socks", Name: "a", Enabled: true, Port: 1080, Auth: "password", User: "u", Pass: "p"})
|
||||
st := inboundByTag(cfg, "socks-in-a")["settings"].(map[string]any)
|
||||
if st["auth"] != "password" {
|
||||
t.Fatalf("auth = %v", st["auth"])
|
||||
}
|
||||
acc := st["accounts"].([]any)[0].(map[string]any)
|
||||
if acc["user"] != "u" || acc["pass"] != "p" {
|
||||
t.Fatalf("accounts = %v", acc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpInboundEmitted(t *testing.T) {
|
||||
cfg := buildWithInbounds(t, Inbound{Type: "http", Name: "h", Enabled: true, Port: 8123})
|
||||
ib := inboundByTag(cfg, "http-in-h")
|
||||
if ib == nil || ib["protocol"] != "http" || ib["port"] != 8123 {
|
||||
t.Fatalf("http inbound wrong: %v", ib)
|
||||
}
|
||||
if _, ok := ib["streamSettings"]; ok {
|
||||
t.Fatalf("http inbound must have no sockopt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDokodemoWrapInbound(t *testing.T) {
|
||||
cfg := buildWithInbounds(t, Inbound{Type: "dokodemo", Name: "wrap", Enabled: true, Port: 5353, TargetAddr: "10.13.13.1", TargetPort: 51820, TargetNetwork: "udp", Sniff: false})
|
||||
ib := inboundByTag(cfg, "dokodemo-wrap")
|
||||
if ib == nil || ib["protocol"] != "dokodemo-door" {
|
||||
t.Fatalf("dokodemo inbound wrong: %v", ib)
|
||||
}
|
||||
st := ib["settings"].(map[string]any)
|
||||
if st["address"] != "10.13.13.1" || st["port"] != 51820 || st["network"] != "udp" || st["followRedirect"] != false {
|
||||
t.Fatalf("dokodemo settings wrong: %v", st)
|
||||
}
|
||||
if ib["sniffing"] != nil {
|
||||
t.Fatalf("sniffing should be off for dokodemo-wrap")
|
||||
}
|
||||
if _, ok := ib["streamSettings"]; ok {
|
||||
t.Fatalf("dokodemo must have no sockopt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyTproxyInboundStillEmitted(t *testing.T) {
|
||||
// Type empty => tproxy; must keep the sockopt tproxy/mark.
|
||||
cfg := buildWithInbounds(t, Inbound{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true})
|
||||
ib := inboundByTag(cfg, "tproxy-lan")
|
||||
if ib == nil || ib["protocol"] != "dokodemo-door" {
|
||||
t.Fatalf("legacy tproxy inbound missing: %v", ib)
|
||||
}
|
||||
so := ib["streamSettings"].(map[string]any)["sockopt"].(map[string]any)
|
||||
if so["tproxy"] != "tproxy" || so["mark"] != loopMark {
|
||||
t.Fatalf("tproxy sockopt wrong: %v", so)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNftIgnoresNonTproxyInbounds(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Inbounds = []Inbound{
|
||||
{Name: "lan", Enabled: true, Network: "lan", TproxyPort: 12345, TCP: true, UDP: true},
|
||||
{Type: "socks", Name: "local", Enabled: true, Port: 1080, UDP: true},
|
||||
}
|
||||
nft := RenderNft(m)
|
||||
if !strings.Contains(nft, "12345") {
|
||||
t.Fatalf("tproxy port 12345 must appear in nft")
|
||||
}
|
||||
if strings.Contains(nft, "1080") {
|
||||
t.Fatalf("socks listener port 1080 must NOT appear as a tproxy divert:\n%s", nft)
|
||||
}
|
||||
}
|
||||
-538
@@ -1,538 +0,0 @@
|
||||
package main
|
||||
|
||||
// CLI entrypoint and dispatch. See docs/CONFIG.md for the command surface.
|
||||
// All commands are silent on stdout except JSON outputs and `gen`.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
// lockExclusive serialises mutating commands ACROSS PROCESSES (LuCI + cron +
|
||||
// hotplug can all invoke xrayctl concurrently) with a blocking exclusive flock
|
||||
// on /var/lock/xrayctl.lock. The default is a no-op for non-unix dev builds;
|
||||
// flock_unix.go's init installs the real implementation on the target OS.
|
||||
var lockExclusive = func() (release func(), err error) { return func() {}, nil }
|
||||
|
||||
// lockTry attempts a NON-BLOCKING cross-process lock on `path`: ok=false means
|
||||
// another process already holds it, so the caller should back off rather than
|
||||
// wait (used to single-flight the expensive node probe sweep). The no-op default
|
||||
// (non-unix dev/test hosts) always "succeeds".
|
||||
var lockTry = func(path string) (release func(), ok bool, err error) { return func() {}, true, nil }
|
||||
|
||||
// mutatingCmd reports whether a command mutates shared state (run.json, nft
|
||||
// table, policy routing, UCI) and therefore must hold the exclusive lock.
|
||||
func mutatingCmd(cmd string, rest []string) bool {
|
||||
switch cmd {
|
||||
case "apply", "reconcile", "rollback", "confirm", "restore":
|
||||
return true
|
||||
case "profile":
|
||||
return len(rest) > 0 && rest[0] == "switch"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
if len(args) == 0 {
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
cmd, rest := args[0], args[1:]
|
||||
if mutatingCmd(cmd, rest) {
|
||||
release, err := lockExclusive()
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("acquire /var/lock/xrayctl.lock: %w", err))
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
switch cmd {
|
||||
case "gen":
|
||||
return cmdGen(rest)
|
||||
case "test":
|
||||
return cmdTest()
|
||||
case "selftest":
|
||||
return cmdSelftest()
|
||||
case "apply":
|
||||
return cmdApply(rest)
|
||||
case "confirm":
|
||||
return errWrap(Confirm())
|
||||
case "rollback":
|
||||
return errWrap(Rollback())
|
||||
case "reconcile":
|
||||
return errWrap(Reconcile())
|
||||
case "sub":
|
||||
return cmdSub(rest)
|
||||
case "node":
|
||||
return cmdNode(rest)
|
||||
case "chain":
|
||||
return cmdChain(rest)
|
||||
case "geodata":
|
||||
return cmdGeodata(rest)
|
||||
case "schedule":
|
||||
if len(rest) > 0 && rest[0] == "due" {
|
||||
return ScheduleDue()
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl schedule due")
|
||||
return 2
|
||||
case "backup":
|
||||
return cmdBackup(rest)
|
||||
case "restore":
|
||||
return cmdRestore(rest)
|
||||
case "profile":
|
||||
return cmdProfile(rest)
|
||||
case "migrate":
|
||||
return errWrap(Migrate())
|
||||
case "compat":
|
||||
return emit(CompatJSON())
|
||||
case "wanmode":
|
||||
return emit(ProfileStatusJSON())
|
||||
case "ruleset":
|
||||
return cmdRuleset(rest)
|
||||
case "status":
|
||||
return emit(StatusJSON())
|
||||
case "conns":
|
||||
return emit(ConnsJSON())
|
||||
case "nodes":
|
||||
// `nodes probe` forces a fresh liveness sweep; bare `nodes` serves the
|
||||
// cached sweep (fast — the dashboard polls this every few seconds).
|
||||
return emit(NodesJSON(len(rest) > 0 && rest[0] == "probe"))
|
||||
case "stats":
|
||||
return emit(StatsJSON())
|
||||
case "explain":
|
||||
return cmdExplain(rest)
|
||||
case "-v", "--version", "version":
|
||||
fmt.Println("xrayctl", version)
|
||||
return 0
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "unknown command:", cmd)
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// gen [--links FILE] [--out FILE]
|
||||
func cmdGen(args []string) int {
|
||||
links, out := "", ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--links":
|
||||
i++
|
||||
if i < len(args) {
|
||||
links = args[i]
|
||||
}
|
||||
case "--out":
|
||||
i++
|
||||
if i < len(args) {
|
||||
out = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
var cfg map[string]any
|
||||
var err error
|
||||
if links != "" {
|
||||
data, rerr := os.ReadFile(links)
|
||||
if rerr != nil {
|
||||
return fail(rerr)
|
||||
}
|
||||
cfg, err = BuildConfigFromLinks(strings.Split(string(data), "\n"))
|
||||
} else {
|
||||
cfg, err = GenerateConfig()
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if out != "" {
|
||||
if err := writeJSON(out, cfg); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
return 0
|
||||
}
|
||||
|
||||
// test: gen + xray -test
|
||||
func cmdTest() int {
|
||||
cfg, err := GenerateConfig()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := TestConfig(cfg); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "OK")
|
||||
return 0
|
||||
}
|
||||
|
||||
// apply [--confirm N]
|
||||
func cmdApply(args []string) int {
|
||||
confirm := 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--confirm" && i+1 < len(args) {
|
||||
confirm, _ = strconv.Atoi(args[i+1])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return errWrap(Apply(confirm))
|
||||
}
|
||||
|
||||
// sub update [NAME] | sub info
|
||||
func cmdSub(args []string) int {
|
||||
if len(args) >= 1 && args[0] == "info" {
|
||||
return emit(SubInfoJSON())
|
||||
}
|
||||
if len(args) == 0 || args[0] != "update" {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl sub update [NAME] | sub info")
|
||||
return 2
|
||||
}
|
||||
name := ""
|
||||
if len(args) > 1 {
|
||||
name = args[1]
|
||||
}
|
||||
// SubUpdate fetches (honoring fetch_via), parses userinfo, reconciles by
|
||||
// fingerprint (new/keep/stale) and caches — for all subs or one by name.
|
||||
return errWrap(SubUpdate(name))
|
||||
}
|
||||
|
||||
// ruleset update [NAME] — fetch+cache remote (url) rulesets.
|
||||
func cmdRuleset(args []string) int {
|
||||
if len(args) == 0 || args[0] != "update" {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl ruleset update [NAME]")
|
||||
return 2
|
||||
}
|
||||
name := ""
|
||||
if len(args) > 1 {
|
||||
name = args[1]
|
||||
}
|
||||
return errWrap(RulesetUpdate(name))
|
||||
}
|
||||
|
||||
// node test [NAME] | node import [--file PATH] (blob from stdin if no --file)
|
||||
func cmdNode(args []string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl node test [NAME] | node import [--file PATH]")
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "test":
|
||||
name, method, urlArg, httpMethod := "", "tcp", "", "GET"
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--method":
|
||||
if i+1 < len(args) {
|
||||
method = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--url":
|
||||
if i+1 < len(args) {
|
||||
urlArg = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--http-method":
|
||||
if i+1 < len(args) {
|
||||
httpMethod = args[i+1]
|
||||
i++
|
||||
}
|
||||
default:
|
||||
if !strings.HasPrefix(args[i], "-") {
|
||||
name = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
// HTTP = real proxy-path probe (through the node); TCP (default) = fast
|
||||
// endpoint connect. Without a NAME both methods probe ALL usable nodes
|
||||
// (LuCI "Test all"); the HTTP all-nodes path is concurrency-bounded low
|
||||
// because each probe spawns an ephemeral xray.
|
||||
if method == "http" {
|
||||
if name == "" {
|
||||
return emit(NodeHTTPProbeAll(urlArg, httpMethod))
|
||||
}
|
||||
return emit(json.MarshalIndent(NodeHTTPProbe(name, urlArg, httpMethod), "", " "))
|
||||
}
|
||||
return emit(NodeTest(name))
|
||||
case "import":
|
||||
if len(args) >= 3 && args[1] == "--file" {
|
||||
n, err := NodeImportFile(args[2])
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "imported %d nodes\n", n)
|
||||
return 0
|
||||
}
|
||||
blob, _ := io.ReadAll(os.Stdin)
|
||||
n, err := NodeImport(string(blob))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "imported %d nodes\n", n)
|
||||
return 0
|
||||
case "enable":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl node enable NAME")
|
||||
return 2
|
||||
}
|
||||
return errWrap(NodeSetEnabled(args[1], true))
|
||||
case "disable":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl node disable NAME")
|
||||
return 2
|
||||
}
|
||||
return errWrap(NodeSetEnabled(args[1], false))
|
||||
case "delete":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl node delete NAME")
|
||||
return 2
|
||||
}
|
||||
return errWrap(NodeDelete(args[1]))
|
||||
case "group":
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl node group NAME GROUP")
|
||||
return 2
|
||||
}
|
||||
return errWrap(NodeAssignGroup(args[1], args[2]))
|
||||
case "qr":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl node qr NAME")
|
||||
return 2
|
||||
}
|
||||
return errWrap(NodeQR(args[1]))
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl node test|import|enable|disable|delete|group|qr ...")
|
||||
return 2
|
||||
}
|
||||
|
||||
// chain test NAME [--url URL] [--http-method GET|HEAD]
|
||||
// Probes the whole multi-hop chain end-to-end via an ephemeral xray (HTTP through
|
||||
// the full chain) and reports reachable / latency / exit-IP.
|
||||
func cmdChain(args []string) int {
|
||||
if len(args) < 2 || args[0] != "test" {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl chain test NAME [--url URL] [--http-method GET|HEAD]")
|
||||
return 2
|
||||
}
|
||||
name, urlArg, httpMethod := args[1], "", "GET"
|
||||
for i := 2; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--url":
|
||||
if i+1 < len(args) {
|
||||
urlArg = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--http-method":
|
||||
if i+1 < len(args) {
|
||||
httpMethod = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
return emit(json.MarshalIndent(ChainHTTPProbe(name, urlArg, httpMethod), "", " "))
|
||||
}
|
||||
|
||||
// backup [--file PATH] (stdout tar.gz if no --file)
|
||||
func cmdBackup(args []string) int {
|
||||
path := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--file" && i+1 < len(args) {
|
||||
path = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
return errWrap(Backup(path))
|
||||
}
|
||||
|
||||
// restore --file PATH [--confirm N]
|
||||
func cmdRestore(args []string) int {
|
||||
path, confirm := "", 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--file":
|
||||
if i+1 < len(args) {
|
||||
path = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--confirm":
|
||||
if i+1 < len(args) {
|
||||
confirm, _ = strconv.Atoi(args[i+1])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl restore --file PATH [--confirm N]")
|
||||
return 2
|
||||
}
|
||||
return errWrap(Restore(path, confirm))
|
||||
}
|
||||
|
||||
// profile save|list|switch|delete [NAME] [--confirm N]
|
||||
func cmdProfile(args []string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl profile save|list|switch|delete [NAME]")
|
||||
return 2
|
||||
}
|
||||
sub := args[0]
|
||||
name, confirm := "", 0
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--confirm":
|
||||
if i+1 < len(args) {
|
||||
confirm, _ = strconv.Atoi(args[i+1])
|
||||
i++
|
||||
}
|
||||
default:
|
||||
if !strings.HasPrefix(args[i], "-") {
|
||||
name = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
switch sub {
|
||||
case "list":
|
||||
return emit(ProfileListJSON())
|
||||
case "save":
|
||||
return errWrap(ProfileSave(name))
|
||||
case "switch":
|
||||
return errWrap(ProfileSwitch(name, confirm))
|
||||
case "delete":
|
||||
return errWrap(ProfileDelete(name))
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl profile save|list|switch|delete [NAME]")
|
||||
return 2
|
||||
}
|
||||
|
||||
// explain SRC DST [--proto tcp|udp]
|
||||
func cmdExplain(args []string) int {
|
||||
var pos []string
|
||||
proto := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--proto" && i+1 < len(args) {
|
||||
proto = args[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
pos = append(pos, args[i])
|
||||
}
|
||||
if len(pos) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: xrayctl explain SRC DST [--proto tcp|udp]")
|
||||
return 2
|
||||
}
|
||||
return emit(ExplainJSON(pos[0], pos[1], proto))
|
||||
}
|
||||
|
||||
// cmdSelftest exercises the parsers and generator on built-in fixtures without
|
||||
// touching the device. Prints a short JSON report; non-zero on any failure.
|
||||
func cmdSelftest() int {
|
||||
type check struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
var checks []check
|
||||
add := func(name string, err error) {
|
||||
c := check{Name: name, OK: err == nil}
|
||||
if err != nil {
|
||||
c.Err = err.Error()
|
||||
}
|
||||
checks = append(checks, c)
|
||||
}
|
||||
|
||||
for _, f := range selftestLinks {
|
||||
_, err := ParseShareLink(f.uri, f.name)
|
||||
add("parse:"+f.name, err)
|
||||
}
|
||||
|
||||
// Generator smoke: build from the fixture links and validate JSON round-trips
|
||||
// with the required top-level keys.
|
||||
var uris []string
|
||||
for _, f := range selftestLinks {
|
||||
uris = append(uris, f.uri)
|
||||
}
|
||||
cfg, err := BuildConfigFromLinks(uris)
|
||||
add("generate:build", err)
|
||||
if err == nil {
|
||||
b, merr := json.Marshal(cfg)
|
||||
add("generate:marshal", merr)
|
||||
if merr == nil {
|
||||
var back map[string]any
|
||||
add("generate:unmarshal", json.Unmarshal(b, &back))
|
||||
for _, k := range []string{"log", "dns", "inbounds", "outbounds", "routing"} {
|
||||
if _, ok := back[k]; !ok {
|
||||
add("generate:key:"+k, fmt.Errorf("missing key %q", k))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(checks, "", " ")
|
||||
fmt.Println(string(b))
|
||||
for _, c := range checks {
|
||||
if !c.OK {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var selftestLinks = []struct{ name, uri string }{
|
||||
{"vless-reality", "vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=abcdefg&sid=00&sni=www.microsoft.com&flow=xtls-rprx-vision&fp=chrome#reality-nl"},
|
||||
{"vless-xhttp", "vless://22222222-2222-2222-2222-222222222222@example.org:8443?type=xhttp&security=tls&path=/xh&host=cdn.example.org&sni=cdn.example.org#xhttp"},
|
||||
{"vmess-ws", "vmess://eyJ2IjoiMiIsInBzIjoidm1lc3MtdyIsImFkZCI6ImV4YW1wbGUubmV0IiwicG9ydCI6IjQ0MyIsImlkIjoiMzMzMzMzMzMtMzMzMy0zMzMzLTMzMzMtMzMzMzMzMzMzMzMzIiwiYWlkIjoiMCIsInNjeSI6ImF1dG8iLCJuZXQiOiJ3cyIsImhvc3QiOiJleGFtcGxlLm5ldCIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMifQ=="},
|
||||
{"trojan", "trojan://password123@example.com:443?security=tls&sni=example.com&type=tcp#trojan"},
|
||||
{"ss", "ss://YWVzLTI1Ni1nY206c2VjcmV0QGV4YW1wbGUuY29tOjg0NDM=#ss"},
|
||||
{"wireguard", "wireguard://qK5s3v1e8f0mXh2wYb9cD4nJ7pR6tU1oA3sE5gH8k0%3D@203.0.113.10:51820?publickey=aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefg%3D&address=10.0.0.2/32&mtu=1420&keepalive=25#wg"},
|
||||
}
|
||||
|
||||
// --- output helpers ---
|
||||
|
||||
func emit(b []byte, err error) int {
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
return 0
|
||||
}
|
||||
|
||||
func errWrap(err error) int {
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fail(err error) int {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `xrayctl `+version+` — xray control-plane
|
||||
|
||||
Commands:
|
||||
gen [--links FILE] [--out FILE] render xray JSON (from UCI, or from share-links)
|
||||
test gen + xray -test
|
||||
selftest smoke-test parsers + generator on fixtures
|
||||
apply [--confirm N] gen -> test -> atomic apply (xray+nft+routing)
|
||||
confirm confirm a pending apply (cancel auto-rollback)
|
||||
rollback restore last-good config
|
||||
reconcile idempotent re-apply (hotplug/boot/watchdog)
|
||||
sub update [NAME] fetch+parse+reconcile subscription cache
|
||||
node test [NAME] probe node(s): alive/latency
|
||||
status | nodes | stats JSON state
|
||||
explain SRC DST [--proto P] show which rule/target/egress applies
|
||||
`)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package main
|
||||
|
||||
// UCI schema migration between plugin versions (catalog 05 §12, T1). A schema
|
||||
// version is stored in globals.schema_version; on load/boot/restore, Migrate runs
|
||||
// ordered, idempotent steps up to CurrentSchemaVersion. It refuses a newer schema
|
||||
// (no lossy downgrade). Runs BEFORE config generation so the Model is always current.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion is the schema this build understands. Bump it when adding
|
||||
// a migration step below.
|
||||
const CurrentSchemaVersion = 1
|
||||
|
||||
// uciRunner abstracts uci get/set/delete/commit so migrations are unit-testable.
|
||||
type uciRunner interface {
|
||||
Get(key string) (string, bool)
|
||||
Set(key, val string) error
|
||||
Delete(key string) error
|
||||
Commit(pkg string) error
|
||||
}
|
||||
|
||||
type execUCI struct{}
|
||||
|
||||
func (execUCI) Get(k string) (string, bool) {
|
||||
out, err := exec.Command("uci", "-q", "get", k).Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(string(out)), true
|
||||
}
|
||||
func (execUCI) Set(k, v string) error { return exec.Command("uci", "set", k+"="+v).Run() }
|
||||
func (execUCI) Delete(k string) error { return exec.Command("uci", "-q", "delete", k).Run() }
|
||||
func (execUCI) Commit(p string) error { return exec.Command("uci", "commit", p).Run() }
|
||||
|
||||
// uci is the active runner (overridable in tests).
|
||||
var uci uciRunner = execUCI{}
|
||||
|
||||
type migration struct {
|
||||
from, to int
|
||||
apply func(uciRunner) error
|
||||
}
|
||||
|
||||
var migrations = []migration{
|
||||
{from: 0, to: 1, apply: migrate0to1},
|
||||
}
|
||||
|
||||
func readSchemaVersion(u uciRunner) int {
|
||||
v, _ := u.Get("shater.globals.schema_version")
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
return n
|
||||
}
|
||||
|
||||
// ensureGlobals makes sure a named `config globals 'globals'` section exists so
|
||||
// option writes don't fail on a config that lacks it (or has it anonymous).
|
||||
func ensureGlobals(u uciRunner) {
|
||||
if _, ok := u.Get("shater.globals"); !ok {
|
||||
_ = u.Set("shater.globals", "globals")
|
||||
}
|
||||
}
|
||||
|
||||
func setSchemaVersion(u uciRunner, v int) error {
|
||||
ensureGlobals(u)
|
||||
if err := u.Set("shater.globals.schema_version", strconv.Itoa(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
return u.Commit("shater")
|
||||
}
|
||||
|
||||
// Migrate runs pending migrations to CurrentSchemaVersion.
|
||||
func Migrate() error {
|
||||
return migrateWith(uci)
|
||||
}
|
||||
|
||||
func migrateWith(u uciRunner) error {
|
||||
cur := readSchemaVersion(u)
|
||||
if cur > CurrentSchemaVersion {
|
||||
return fmt.Errorf("config schema v%d newer than this xrayctl (v%d); upgrade the package", cur, CurrentSchemaVersion)
|
||||
}
|
||||
for cur < CurrentSchemaVersion {
|
||||
var step *migration
|
||||
for i := range migrations {
|
||||
if migrations[i].from == cur {
|
||||
step = &migrations[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if step == nil {
|
||||
return fmt.Errorf("no migration path from schema v%d", cur)
|
||||
}
|
||||
if err := step.apply(u); err != nil {
|
||||
return fmt.Errorf("migrate v%d->v%d: %w", step.from, step.to, err)
|
||||
}
|
||||
if err := setSchemaVersion(u, step.to); err != nil {
|
||||
return err
|
||||
}
|
||||
cur = step.to
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrate0to1: baseline. Legacy installs predate schema_version; fold a legacy
|
||||
// globals.kill spelling into kill_switch (idempotent).
|
||||
func migrate0to1(u uciRunner) error {
|
||||
if v, ok := u.Get("shater.globals.kill"); ok && v != "" {
|
||||
if _, exists := u.Get("shater.globals.kill_switch"); !exists {
|
||||
_ = u.Set("shater.globals.kill_switch", v)
|
||||
}
|
||||
_ = u.Delete("shater.globals.kill")
|
||||
}
|
||||
return u.Commit("shater")
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
package main
|
||||
|
||||
// Data model for the xray control-plane. This is the parsed, typed form of the
|
||||
// UCI desired-state (/etc/config/shater) plus subscription-cache nodes. Every
|
||||
// generator/apply step consumes *Model; nothing downstream touches raw UCI.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Model is the whole desired-state.
|
||||
type Model struct {
|
||||
Globals Globals
|
||||
Inbounds []Inbound
|
||||
Subscriptions []Subscription
|
||||
Nodes []Node // manual nodes + subscription-cache nodes (see Node.FromSub)
|
||||
Groups []Group
|
||||
Chains []Chain
|
||||
Egresses []Egress
|
||||
Rulesets []Ruleset
|
||||
Rules []Rule
|
||||
Presets []Preset
|
||||
Profiles []Profile
|
||||
Resolvers []Resolver
|
||||
DNSRules []DNSRule
|
||||
}
|
||||
|
||||
// Globals is the single `config globals` section.
|
||||
type Globals struct {
|
||||
Enabled bool
|
||||
LogLevel string
|
||||
KillSwitch string // closed|open
|
||||
DNSMode string // nftset|fakeip
|
||||
IPv6 bool
|
||||
FwmarkBase uint32
|
||||
TableBase uint32
|
||||
ConfirmTimeout int
|
||||
ResolverDefault string // resolver name consulted FIRST (xray servers list order)
|
||||
ResolverFallback string // resolver name consulted LAST (fallback)
|
||||
ProbeURL string // observatory health-probe URL (default gstatic/generate_204)
|
||||
ProbeInterval string // observatory probe interval (e.g. 60s)
|
||||
SchemaVersion int // UCI schema revision (0 = pre-versioned legacy)
|
||||
ActiveProfile string // last profile switched to (display bookkeeping)
|
||||
}
|
||||
|
||||
func defaultGlobals() Globals {
|
||||
return Globals{
|
||||
Enabled: true,
|
||||
LogLevel: "warning",
|
||||
KillSwitch: "closed",
|
||||
DNSMode: "nftset",
|
||||
IPv6: true,
|
||||
FwmarkBase: 0x2000,
|
||||
TableBase: 0x2000,
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound is a `config inbound`. Type selects the shape:
|
||||
//
|
||||
// tproxy (default) — transparent dokodemo-door + TPROXY sockopt (multi-LAN)
|
||||
// socks — local SOCKS5 listener (router/apps) [T1]
|
||||
// http — local HTTP proxy listener [T1]
|
||||
// dokodemo — plain redirect target for wrapping traffic (awg-wrap)[T2]
|
||||
//
|
||||
// Only tproxy inbounds are wired into the nft TPROXY plane (see isTproxyInbound);
|
||||
// socks/http/dokodemo are plain listeners with no sockopt/mark. Absent Type =>
|
||||
// tproxy, so existing configs are unchanged.
|
||||
type Inbound struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Type string // tproxy|socks|http|dokodemo (default tproxy)
|
||||
|
||||
// tproxy
|
||||
Network string
|
||||
TproxyPort int
|
||||
|
||||
// socks/http/dokodemo local listener
|
||||
Listen string // bind addr; default 127.0.0.1
|
||||
Port int
|
||||
|
||||
// socks/http auth
|
||||
Auth string // noauth|password (default noauth)
|
||||
User string
|
||||
Pass string
|
||||
|
||||
// dokodemo target (fixed destination for wrapped traffic)
|
||||
TargetAddr string
|
||||
TargetPort int
|
||||
TargetNetwork string // tcp|udp|tcp,udp (default udp)
|
||||
|
||||
// shared
|
||||
TCP bool
|
||||
UDP bool
|
||||
Sniff bool
|
||||
}
|
||||
|
||||
// inboundType returns the effective type, defaulting empty -> tproxy.
|
||||
func (in Inbound) inboundType() string {
|
||||
if in.Type == "" {
|
||||
return "tproxy"
|
||||
}
|
||||
return strings.ToLower(in.Type)
|
||||
}
|
||||
|
||||
// isTproxyInbound reports whether an inbound participates in the TPROXY nft plane
|
||||
// (only tproxy inbounds divert LAN traffic; local socks/http/dokodemo do not).
|
||||
func isTproxyInbound(in Inbound) bool {
|
||||
return in.Enabled && in.inboundType() == "tproxy"
|
||||
}
|
||||
|
||||
// Subscription is a `config subscription`.
|
||||
type Subscription struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
URL string
|
||||
UpdateInterval string
|
||||
FetchVia string // direct|proxy
|
||||
UA string
|
||||
HWID string // auto|<fixed>
|
||||
DeviceOS string
|
||||
VerOS string
|
||||
DeviceModel string
|
||||
Headers []string // raw "Key: val"
|
||||
|
||||
// Format + filters (T2/T1). Format selects the body parser; filters run after
|
||||
// parse, before reconcile, so the cache holds exactly the surviving nodes.
|
||||
Format string // auto|clash|xray|singbox|links (default auto)
|
||||
Include []string // name regex, keep
|
||||
Exclude []string // name regex, drop
|
||||
FilterProto []string // vless/vmess/trojan/ss (empty = all)
|
||||
FilterCountry []string // ISO codes; leading "!" excludes (empty = all)
|
||||
Dedup bool // drop duplicate nodes by fingerprint
|
||||
ExpireAlertDays int // warn when the sub expires within N days (0/unset = default 3; negative = off)
|
||||
}
|
||||
|
||||
// Node is a manual node (`config node`) or a subscription-cache node.
|
||||
type Node struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
URI string
|
||||
FromSub string // "" = manual; else subscription name
|
||||
Fingerprint string
|
||||
Stale bool
|
||||
|
||||
// Per-node multiplexing + sockopt (T1). All optional; zero values = off, so
|
||||
// existing nodes emit byte-identical outbounds. See applyNodeOpts in generate.go.
|
||||
Mux bool // enable outbound mux
|
||||
MuxConcurrency int // streams per mux connection (xray `concurrency`)
|
||||
XUDPConcurrency int // vless/vmess only (UDP-over-mux)
|
||||
XUDPProxyUDP443 string // reject|allow|skip (vless/vmess only)
|
||||
Mark uint32 // sockopt mark; 0 = use loop-guard 255 (a different value is refused)
|
||||
TCPFastOpen string // "" inherit | "1" on | "0" off
|
||||
TCPKeepAliveIdle int // seconds; 0 = omit
|
||||
}
|
||||
|
||||
// Group is a `config group`.
|
||||
type Group struct {
|
||||
Name string
|
||||
Source string // subscription|manual
|
||||
Subscription string
|
||||
Nodes []string // manual node names
|
||||
Strategy string // leastping|random|roundrobin|failover|single
|
||||
Include []string
|
||||
Exclude []string
|
||||
FilterProto []string // additional group-level protocol filter
|
||||
FilterCountry []string // additional group-level country filter
|
||||
Dedup bool // drop duplicate members by fingerprint
|
||||
ProbeURL string
|
||||
ProbeInterval string
|
||||
}
|
||||
|
||||
// Chain is a `config chain` (multi-hop L1..Ln).
|
||||
type Chain struct {
|
||||
Name string
|
||||
Hops []string // "group:<name>" | "node:<name>"
|
||||
}
|
||||
|
||||
// Egress is a `config egress`.
|
||||
type Egress struct {
|
||||
Name string
|
||||
Type string // interface|proxy|direct|block
|
||||
Interface string
|
||||
Target string
|
||||
}
|
||||
|
||||
// Profile is a `config profile` — a WAN-mode / failover conditional override
|
||||
// (catalog §7, T2). When its conditions hold (active default-route interface,
|
||||
// connectivity probe up/down, and/or a time window — all AND'd), the highest-
|
||||
// priority active profile applies its overrides: force-enable / disable named
|
||||
// rules and optionally override the default catch-all target/egress. Evaluated
|
||||
// at gen/reconcile time; the cron re-applies when the active profile changes.
|
||||
type Profile struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Priority int
|
||||
|
||||
// conditions (only the specified ones are checked; all must hold)
|
||||
MatchIface []string // active default-route dev in this set (e.g. wwan0, usb0)
|
||||
ProbeURL string // connectivity probe (HTTP); empty = no probe condition
|
||||
ProbeMode string // up|down — active when the probe succeeds/fails (default up)
|
||||
SchedDays []string
|
||||
SchedStart string
|
||||
SchedEnd string
|
||||
SchedTZ string
|
||||
|
||||
// overrides applied while active
|
||||
EnableRules []string // rule names to force-enable
|
||||
DisableRules []string // rule names to disable
|
||||
DefaultTarget string // override the default catch-all target (group:/node:/chain:/direct/block)
|
||||
DefaultEgress string // override the default egress binding
|
||||
}
|
||||
|
||||
// Ruleset is a `config ruleset` (reusable domain/ip list).
|
||||
type Ruleset struct {
|
||||
Name string
|
||||
Type string // domain|ipcidr
|
||||
Source string // inline|file|url
|
||||
URL string
|
||||
Path string
|
||||
Format string
|
||||
UpdateInterval string
|
||||
Entries []string
|
||||
}
|
||||
|
||||
// Rule is a `config rule` (ordered, first-match).
|
||||
type Rule struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Order int
|
||||
Src []string
|
||||
DstDomain []string
|
||||
DstRuleset []string
|
||||
DstIP []string
|
||||
DstPort string
|
||||
Proto string
|
||||
Target string // chain:|group:|node:|direct|block
|
||||
Egress string
|
||||
Kill string
|
||||
|
||||
// Schedule (T3): when SchedEnabled, the rule is only emitted while the current
|
||||
// local time falls inside the window. Evaluated at gen/reconcile time (xray has
|
||||
// no native time match); the shater-cron re-applies at boundaries.
|
||||
SchedEnabled bool
|
||||
SchedDays []string // mon..sun; empty => every day
|
||||
SchedStart string // "HH:MM" local; empty => 00:00
|
||||
SchedEnd string // "HH:MM" local; empty/equal => all-day
|
||||
SchedTZ string // optional IANA name; empty => router local
|
||||
}
|
||||
|
||||
// Preset is a `config preset` — a toggle for a built-in curated rule pack
|
||||
// (block-ads / ru-bypass / private). See preset.go for the pack definitions.
|
||||
type Preset struct {
|
||||
Name string // block-ads | ru-bypass | private
|
||||
Enabled bool
|
||||
Order int // sort key override; 0 => pack default
|
||||
Target string // target override; "" => pack default
|
||||
}
|
||||
|
||||
// Resolver is a `config resolver`.
|
||||
type Resolver struct {
|
||||
Name string
|
||||
Type string // doh|dot|plain|local|fakeip
|
||||
Address string
|
||||
Detour string
|
||||
Pool string // fakeip CIDR pool (e.g. 198.18.0.0/15)
|
||||
}
|
||||
|
||||
// DNSRule is a `config dns_rule`.
|
||||
type DNSRule struct {
|
||||
Order int
|
||||
MatchDomain []string
|
||||
MatchSrc []string
|
||||
Resolver string
|
||||
}
|
||||
|
||||
// --- small parse helpers shared across uci/sub ---
|
||||
|
||||
func parseBool(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "yes", "on", "enabled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseUint32(s string, def uint32) uint32 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
base := 10
|
||||
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
|
||||
base = 16
|
||||
s = s[2:]
|
||||
}
|
||||
v, err := strconv.ParseUint(s, base, 32)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return uint32(v)
|
||||
}
|
||||
|
||||
func parseInt(s string, def int) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// splitTarget splits "kind:name" into ("kind","name"); bare "direct"/"block"
|
||||
// return (value, "").
|
||||
func splitTarget(t string) (kind, name string) {
|
||||
t = strings.TrimSpace(t)
|
||||
if i := strings.IndexByte(t, ':'); i >= 0 {
|
||||
return t[:i], t[i+1:]
|
||||
}
|
||||
return t, ""
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
package main
|
||||
|
||||
// Helpers for the `inet shater` nft table: source classification (CIDR / host /
|
||||
// MAC / iface / zone), identifier sanitising, and parsing of the per-client
|
||||
// dynamic set + named per-rule counters back out of `nft -j`.
|
||||
//
|
||||
// Naming contract (consumed by status/stats and the LuCI dashboard):
|
||||
// * set `clients` : type ipv4_addr, flags dynamic, per-element counter — LAN client bytes.
|
||||
// * set `clients6` : type ipv6_addr, flags dynamic, per-element counter — IPv6 client bytes.
|
||||
// * counter `c_rule_<ident>` : bytes/packets attributed to rule <name|order-N>.
|
||||
// * counter `c_in_<ident>` : bytes/packets for an inbound's catch-all divert.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
nftTable = "inet shater"
|
||||
nftClient4 = "clients"
|
||||
nftClient6 = "clients6"
|
||||
nftLastGood = "/etc/xray/nft/last-good.nft"
|
||||
)
|
||||
|
||||
var nftMACRe = regexp.MustCompile(`^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$`)
|
||||
|
||||
// nftCount is a packets/bytes pair.
|
||||
type nftCount struct {
|
||||
Packets int64 `json:"packets"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// nftFrag is one nft match line derived from a single rule.src entry. iif, when
|
||||
// non-nil, overrides the inbound device set (iface:/zone: sources). match is an
|
||||
// optional saddr expression ("ip saddr X" / "ip6 saddr X" / "ether saddr X").
|
||||
// fam is 4, 6, or 0 (both).
|
||||
type nftFrag struct {
|
||||
iif []string
|
||||
match string
|
||||
fam int
|
||||
}
|
||||
|
||||
// nftIdent maps an arbitrary name to a safe nft identifier ([A-Za-z0-9_]).
|
||||
func nftIdent(s string) string {
|
||||
if s == "" {
|
||||
return "unnamed"
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, s)
|
||||
}
|
||||
|
||||
// nftRuleKey is the stable key used for a rule's counter (name, else order-N).
|
||||
func nftRuleKey(r Rule) string {
|
||||
if strings.TrimSpace(r.Name) != "" {
|
||||
return r.Name
|
||||
}
|
||||
return fmt.Sprintf("order-%d", r.Order)
|
||||
}
|
||||
|
||||
func nftRuleCounter(r Rule) string { return "c_rule_" + nftIdent(nftRuleKey(r)) }
|
||||
func nftInCounter(in Inbound) string {
|
||||
return "c_in_" + nftIdent(orDefault(in.Name, "lan"))
|
||||
}
|
||||
|
||||
// nftIifExpr renders an iifname match for one or more devices.
|
||||
func nftIifExpr(devs []string) string {
|
||||
devs = nftDedupStr(devs)
|
||||
if len(devs) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(devs) == 1 {
|
||||
return fmt.Sprintf("iifname \"%s\"", devs[0])
|
||||
}
|
||||
q := make([]string, len(devs))
|
||||
for i, d := range devs {
|
||||
q[i] = "\"" + d + "\""
|
||||
}
|
||||
return "iifname { " + strings.Join(q, ", ") + " }"
|
||||
}
|
||||
|
||||
// nftEnabledInboundDevs returns the L3 devices of all enabled inbounds.
|
||||
func nftEnabledInboundDevs(m *Model) []string {
|
||||
var out []string
|
||||
for _, in := range m.Inbounds {
|
||||
if isTproxyInbound(in) {
|
||||
out = append(out, ifaceDevice(in.Network))
|
||||
}
|
||||
}
|
||||
return nftDedupStr(out)
|
||||
}
|
||||
|
||||
// nftPrimaryInbound returns the first enabled tproxy inbound (port/mark source).
|
||||
func nftPrimaryInbound(m *Model) (Inbound, bool) {
|
||||
for _, in := range m.Inbounds {
|
||||
if isTproxyInbound(in) {
|
||||
return in, true
|
||||
}
|
||||
}
|
||||
return Inbound{}, false
|
||||
}
|
||||
|
||||
// nftSrcFrags converts a rule's src list into independent match fragments,
|
||||
// preserving first-match OR semantics (each src entry => its own line(s)).
|
||||
// inboundDevs is the default iif for ip/mac sources.
|
||||
func nftSrcFrags(r Rule, inboundDevs []string) []nftFrag {
|
||||
var frags []nftFrag
|
||||
for _, raw := range r.Src {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(s, "iface:"):
|
||||
dev := ifaceDevice(strings.TrimPrefix(s, "iface:"))
|
||||
frags = append(frags, nftFrag{iif: []string{dev}, fam: 0})
|
||||
case strings.HasPrefix(s, "zone:"):
|
||||
devs := nftZoneDevices(strings.TrimPrefix(s, "zone:"))
|
||||
if len(devs) > 0 {
|
||||
frags = append(frags, nftFrag{iif: devs, fam: 0})
|
||||
}
|
||||
case nftMACRe.MatchString(s):
|
||||
frags = append(frags, nftFrag{iif: inboundDevs, match: "ether saddr " + s, fam: 0})
|
||||
default:
|
||||
// CIDR or bare host -> normalise to a prefix and split by family.
|
||||
cidr, fam := nftNormalizeCIDR(s)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if fam == 6 {
|
||||
frags = append(frags, nftFrag{iif: inboundDevs, match: "ip6 saddr " + cidr, fam: 6})
|
||||
} else {
|
||||
frags = append(frags, nftFrag{iif: inboundDevs, match: "ip saddr " + cidr, fam: 4})
|
||||
}
|
||||
}
|
||||
}
|
||||
return frags
|
||||
}
|
||||
|
||||
// nftNormalizeCIDR returns a canonical CIDR string and family (4/6) for a
|
||||
// CIDR or bare host; "" if unparseable.
|
||||
func nftNormalizeCIDR(s string) (string, int) {
|
||||
if strings.Contains(s, "/") {
|
||||
if ip, _, err := net.ParseCIDR(s); err == nil {
|
||||
if ip.To4() != nil {
|
||||
return s, 4
|
||||
}
|
||||
return s, 6
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
return "", 0
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
return s + "/32", 4
|
||||
}
|
||||
return s + "/128", 6
|
||||
}
|
||||
|
||||
// nftZoneDevices resolves an fw4 zone name to its member L3 devices by reading
|
||||
// /etc/config/firewall (reusing the UCI export parser).
|
||||
func nftZoneDevices(zone string) []string {
|
||||
out, err := exec.Command("uci", "-q", "export", "firewall").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
secs, err := parseSections(string(out))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var devs []string
|
||||
for _, s := range secs {
|
||||
if s.Type != "zone" || s.Options["name"] != zone {
|
||||
continue
|
||||
}
|
||||
for _, netName := range s.Lists["network"] {
|
||||
devs = append(devs, ifaceDevice(netName))
|
||||
}
|
||||
devs = append(devs, s.Lists["device"]...)
|
||||
if d := s.Options["device"]; d != "" {
|
||||
devs = append(devs, d)
|
||||
}
|
||||
}
|
||||
return nftDedupStr(devs)
|
||||
}
|
||||
|
||||
func nftDedupStr(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, v := range in {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || seen[v] {
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- parsing nft -j output ---
|
||||
|
||||
// nftListClients parses `nft -j list set inet shater <set>` into ip -> counter.
|
||||
func nftListClients(set string) map[string]nftCount {
|
||||
res := map[string]nftCount{}
|
||||
out, err := exec.Command("nft", "-j", "list", "set", "inet", "shater", set).Output()
|
||||
if err != nil {
|
||||
return res
|
||||
}
|
||||
var doc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
}
|
||||
if json.Unmarshal(out, &doc) != nil {
|
||||
return res
|
||||
}
|
||||
for _, obj := range doc.Nftables {
|
||||
raw, ok := obj["set"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var s struct {
|
||||
Elem []struct {
|
||||
Elem struct {
|
||||
Val string `json:"val"`
|
||||
Counter nftCount `json:"counter"`
|
||||
} `json:"elem"`
|
||||
} `json:"elem"`
|
||||
}
|
||||
if json.Unmarshal(raw, &s) != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Elem {
|
||||
if e.Elem.Val != "" {
|
||||
res[e.Elem.Val] = e.Elem.Counter
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// nftListCounters parses `nft -j list counters table inet shater` into name -> counter.
|
||||
func nftListCounters() map[string]nftCount {
|
||||
res := map[string]nftCount{}
|
||||
out, err := exec.Command("nft", "-j", "list", "counters", "table", "inet", "shater").Output()
|
||||
if err != nil {
|
||||
return res
|
||||
}
|
||||
var doc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
}
|
||||
if json.Unmarshal(out, &doc) != nil {
|
||||
return res
|
||||
}
|
||||
for _, obj := range doc.Nftables {
|
||||
raw, ok := obj["counter"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var c struct {
|
||||
Name string `json:"name"`
|
||||
Packets int64 `json:"packets"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
if json.Unmarshal(raw, &c) != nil || c.Name == "" {
|
||||
continue
|
||||
}
|
||||
res[c.Name] = nftCount{Packets: c.Packets, Bytes: c.Bytes}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// nftTableExists reports whether our `inet shater` table is currently loaded.
|
||||
func nftTableExists() bool {
|
||||
return exec.Command("nft", "list", "table", "inet", "shater").Run() == nil
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// outboundByTagContains returns the first outbound whose tag contains sub.
|
||||
func outboundByTagContains(cfg map[string]any, sub string) map[string]any {
|
||||
for _, o := range cfg["outbounds"].([]any) {
|
||||
ob := o.(map[string]any)
|
||||
if tag, _ := ob["tag"].(string); tag != "" && contains(tag, sub) {
|
||||
return ob
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func muxOf(ob map[string]any) map[string]any {
|
||||
if ob == nil {
|
||||
return nil
|
||||
}
|
||||
m, _ := ob["mux"].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
func sockoptOf(ob map[string]any) map[string]any {
|
||||
if ob == nil {
|
||||
return nil
|
||||
}
|
||||
ss, _ := ob["streamSettings"].(map[string]any)
|
||||
if ss == nil {
|
||||
return nil
|
||||
}
|
||||
so, _ := ss["sockopt"].(map[string]any)
|
||||
return so
|
||||
}
|
||||
|
||||
func buildOneNode(t *testing.T, nd Node) map[string]any {
|
||||
t.Helper()
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
nd.Enabled = true
|
||||
m.Nodes = []Node{nd}
|
||||
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Target: "node:" + nd.Name}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestNodeMuxEmitted(t *testing.T) {
|
||||
cfg := buildOneNode(t, Node{Name: "n1", URI: vlessReality, Mux: true, MuxConcurrency: 4})
|
||||
ob := outboundByTagContains(cfg, "node_n1")
|
||||
mux := muxOf(ob)
|
||||
if mux == nil || mux["enabled"] != true {
|
||||
t.Fatalf("mux not enabled: %v", ob)
|
||||
}
|
||||
if mux["concurrency"] != 4 {
|
||||
t.Fatalf("concurrency = %v, want 4", mux["concurrency"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMuxDefaultOff(t *testing.T) {
|
||||
cfg := buildOneNode(t, Node{Name: "n1", URI: vlessReality})
|
||||
ob := outboundByTagContains(cfg, "node_n1")
|
||||
if _, ok := ob["mux"]; ok {
|
||||
t.Fatalf("mux key present on a mux-off node: %v", ob["mux"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeXUDPOnlyVlessVmess(t *testing.T) {
|
||||
// vless: XUDP keys present.
|
||||
cfg := buildOneNode(t, Node{Name: "nv", URI: "vless://11111111-1111-1111-1111-111111111111@a.example.com:443?type=tcp&security=reality&pbk=PBK&sid=aa&sni=www.microsoft.com#nv", Mux: true, XUDPConcurrency: 16, XUDPProxyUDP443: "reject"})
|
||||
mux := muxOf(outboundByTagContains(cfg, "node_nv"))
|
||||
if mux["xudpConcurrency"] != 16 || mux["xudpProxyUDP443"] != "reject" {
|
||||
t.Fatalf("vless XUDP keys missing: %v", mux)
|
||||
}
|
||||
// trojan: mux present but no XUDP keys.
|
||||
cfg = buildOneNode(t, Node{Name: "nt", URI: "trojan://pw@b.example.com:443?security=tls&sni=b.example.com#nt", Mux: true, XUDPConcurrency: 16, XUDPProxyUDP443: "reject"})
|
||||
mux = muxOf(outboundByTagContains(cfg, "node_nt"))
|
||||
if mux == nil || mux["enabled"] != true {
|
||||
t.Fatalf("trojan mux not enabled: %v", mux)
|
||||
}
|
||||
if _, ok := mux["xudpConcurrency"]; ok {
|
||||
t.Fatalf("trojan should not carry XUDP keys: %v", mux)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMuxSkippedForVision(t *testing.T) {
|
||||
uri := "vless://11111111-1111-1111-1111-111111111111@a.example.com:443?type=tcp&security=reality&pbk=PBK&sid=aa&sni=www.microsoft.com&flow=xtls-rprx-vision#nvis"
|
||||
cfg := buildOneNode(t, Node{Name: "nvis", URI: uri, Mux: true})
|
||||
ob := outboundByTagContains(cfg, "node_nvis")
|
||||
if _, ok := ob["mux"]; ok {
|
||||
t.Fatalf("mux must be skipped under XTLS Vision: %v", ob["mux"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSockoptMarkLoopGuardWins(t *testing.T) {
|
||||
cfg := buildOneNode(t, Node{Name: "n1", URI: vlessReality, Mark: 1234})
|
||||
so := sockoptOf(outboundByTagContains(cfg, "node_n1"))
|
||||
if so == nil || so["mark"] != loopMark {
|
||||
t.Fatalf("loop-guard mark not enforced, got %v", so)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSockoptTCPKnobs(t *testing.T) {
|
||||
cfg := buildOneNode(t, Node{Name: "n1", URI: vlessReality, TCPFastOpen: "1", TCPKeepAliveIdle: 30})
|
||||
so := sockoptOf(outboundByTagContains(cfg, "node_n1"))
|
||||
if so["tcpFastOpen"] != true || so["tcpKeepAliveIdle"] != 30 {
|
||||
t.Fatalf("tcp knobs missing: %v", so)
|
||||
}
|
||||
if so["mark"] != loopMark {
|
||||
t.Fatalf("loop mark lost alongside tcp knobs: %v", so)
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package main
|
||||
|
||||
// Node management verbs for LuCI bulk-actions (catalog 05 §11, T1) and QR export
|
||||
// (T2). enable/disable/delete/group mutate `config node` sections by name; qr
|
||||
// renders the node's share-link as an SVG via `qrencode` (soft dependency).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// nodeSectionByName returns the UCI section id of the `config node` whose name
|
||||
// matches (manual nodes only; subscription nodes live in the cache, not UCI).
|
||||
func nodeSectionByName(name string) (string, bool) {
|
||||
out, err := exec.Command("uci", "-q", "show", "shater").Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
nodeSids := map[string]bool{}
|
||||
sidName := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "shater.") {
|
||||
continue
|
||||
}
|
||||
body := strings.TrimPrefix(line, "shater.")
|
||||
k, v, ok := strings.Cut(body, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.Trim(v, "'")
|
||||
if !strings.Contains(k, ".") { // section decl: shater.<sid>=<type>
|
||||
if v == "node" {
|
||||
nodeSids[k] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
sid, opt, _ := strings.Cut(k, ".")
|
||||
if opt == "name" {
|
||||
sidName[sid] = v
|
||||
}
|
||||
}
|
||||
for sid := range nodeSids {
|
||||
if sidName[sid] == name {
|
||||
return sid, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// NodeSetEnabled toggles a manual node's enabled flag.
|
||||
func NodeSetEnabled(name string, enabled bool) error {
|
||||
sid, ok := nodeSectionByName(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("manual node %q not found", name)
|
||||
}
|
||||
val := "0"
|
||||
if enabled {
|
||||
val = "1"
|
||||
}
|
||||
if err := exec.Command("uci", "set", "shater."+sid+".enabled="+val).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("uci", "commit", "shater").Run()
|
||||
}
|
||||
|
||||
// NodeDelete removes a manual node section.
|
||||
func NodeDelete(name string) error {
|
||||
sid, ok := nodeSectionByName(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("manual node %q not found", name)
|
||||
}
|
||||
if err := exec.Command("uci", "delete", "shater."+sid).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("uci", "commit", "shater").Run()
|
||||
}
|
||||
|
||||
// NodeAssignGroup adds a node to a manual group's node list (dedup), creating the
|
||||
// group section reference if needed.
|
||||
func NodeAssignGroup(name, group string) error {
|
||||
gsid, ok := groupSectionByName(group)
|
||||
if !ok {
|
||||
return fmt.Errorf("group %q not found", group)
|
||||
}
|
||||
// ensure it's a manual group
|
||||
_ = exec.Command("uci", "set", "shater."+gsid+".source=manual").Run()
|
||||
// dedup: only add if not already listed
|
||||
out, _ := exec.Command("uci", "-q", "get", "shater."+gsid+".node").Output()
|
||||
for _, existing := range strings.Fields(string(out)) {
|
||||
if strings.Trim(existing, "'") == name {
|
||||
return exec.Command("uci", "commit", "shater").Run()
|
||||
}
|
||||
}
|
||||
if err := exec.Command("uci", "add_list", "shater."+gsid+".node="+name).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("uci", "commit", "shater").Run()
|
||||
}
|
||||
|
||||
func groupSectionByName(name string) (string, bool) {
|
||||
out, err := exec.Command("uci", "-q", "show", "shater").Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
groupSids := map[string]bool{}
|
||||
sidName := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
body := strings.TrimPrefix(line, "shater.")
|
||||
k, v, ok := strings.Cut(body, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.Trim(v, "'")
|
||||
if !strings.Contains(k, ".") {
|
||||
if v == "group" {
|
||||
groupSids[k] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
sid, opt, _ := strings.Cut(k, ".")
|
||||
if opt == "name" {
|
||||
sidName[sid] = v
|
||||
}
|
||||
}
|
||||
for sid := range groupSids {
|
||||
if sidName[sid] == name {
|
||||
return sid, true
|
||||
}
|
||||
}
|
||||
// a group may be a named section whose id IS its name
|
||||
if groupSids[name] {
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// nodeURIByName finds a node's share-link (manual UCI or subscription cache).
|
||||
func nodeURIByName(name string) (string, bool) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
for _, n := range m.Nodes {
|
||||
if n.Name == name && n.URI != "" {
|
||||
return n.URI, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// NodeQR prints an SVG QR of the node's share-link to stdout (needs qrencode).
|
||||
func NodeQR(name string) error {
|
||||
uri, ok := nodeURIByName(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("node %q not found", name)
|
||||
}
|
||||
if _, err := exec.LookPath("qrencode"); err != nil {
|
||||
return fmt.Errorf("qrencode not installed (opkg install qrencode)")
|
||||
}
|
||||
out, err := exec.Command("qrencode", "-t", "SVG", "-o", "-", uri).Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("qrencode: %v", err)
|
||||
}
|
||||
os.Stdout.Write(out)
|
||||
return nil
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package main
|
||||
|
||||
// Read xray's live state over the local API inbound (127.0.0.1:10853, added by
|
||||
// the generator with StatsService + ObservatoryService + RoutingService):
|
||||
//
|
||||
// * StatsService (`xray api statsquery`) -> per-outbound uplink/downlink bytes.
|
||||
// * RoutingService (`xray api bi <tag>`) -> each balancer's principleTarget,
|
||||
// i.e. the member the observatory currently considers best/alive (selected).
|
||||
//
|
||||
// The stock xray CLI exposes no direct ObservatoryService.GetOutboundStatus
|
||||
// command, so alive/selected is derived from the balancer principle + traffic
|
||||
// counters; per-node latency falls back to an endpoint probe (see status.go).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const obsDefaultAPIPort = 10853
|
||||
|
||||
type obsTraffic struct {
|
||||
Up int64 `json:"uplink"`
|
||||
Down int64 `json:"downlink"`
|
||||
}
|
||||
|
||||
// obsInfo is the merged, best-effort view of xray's live API state.
|
||||
type obsInfo struct {
|
||||
APIPort int `json:"api_port"`
|
||||
Reachable bool `json:"reachable"`
|
||||
Stats map[string]obsTraffic `json:"stats"` // outbound tag -> bytes
|
||||
Selected map[string]bool `json:"selected"` // outbound tag -> chosen by a balancer
|
||||
}
|
||||
|
||||
// obsGather queries the API inbound and returns a merged view. Always returns a
|
||||
// non-nil struct; Reachable=false when the API is absent/unreachable.
|
||||
func obsGather() obsInfo {
|
||||
info := obsInfo{
|
||||
APIPort: obsAPIPort(),
|
||||
Stats: map[string]obsTraffic{},
|
||||
Selected: map[string]bool{},
|
||||
}
|
||||
server := obsServer(info.APIPort)
|
||||
|
||||
stats, ok := obsStats(server)
|
||||
info.Reachable = ok
|
||||
info.Stats = stats
|
||||
|
||||
for _, tag := range obsSelectedTags(server, obsBalancerTags()) {
|
||||
info.Selected[tag] = true
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func obsServer(port int) string {
|
||||
if port == 0 {
|
||||
port = obsDefaultAPIPort
|
||||
}
|
||||
return "127.0.0.1:" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
// obsAPIPort finds the api inbound's listen port in run.json (tag "api"),
|
||||
// defaulting to 10853.
|
||||
func obsAPIPort() int {
|
||||
b, err := os.ReadFile(runJSON)
|
||||
if err != nil {
|
||||
return obsDefaultAPIPort
|
||||
}
|
||||
var cfg struct {
|
||||
Inbounds []struct {
|
||||
Tag string `json:"tag"`
|
||||
Port int `json:"port"`
|
||||
} `json:"inbounds"`
|
||||
}
|
||||
if json.Unmarshal(b, &cfg) != nil {
|
||||
return obsDefaultAPIPort
|
||||
}
|
||||
for _, in := range cfg.Inbounds {
|
||||
if in.Tag == "api" && in.Port != 0 {
|
||||
return in.Port
|
||||
}
|
||||
}
|
||||
return obsDefaultAPIPort
|
||||
}
|
||||
|
||||
// obsBalancerTags reads routing.balancers[].tag from run.json.
|
||||
func obsBalancerTags() []string {
|
||||
b, err := os.ReadFile(runJSON)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cfg struct {
|
||||
Routing struct {
|
||||
Balancers []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"balancers"`
|
||||
} `json:"routing"`
|
||||
}
|
||||
if json.Unmarshal(b, &cfg) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, bal := range cfg.Routing.Balancers {
|
||||
if bal.Tag != "" {
|
||||
out = append(out, bal.Tag)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// obsStats queries StatsService for per-outbound uplink/downlink counters.
|
||||
func obsStats(server string) (map[string]obsTraffic, bool) {
|
||||
res := map[string]obsTraffic{}
|
||||
out, err := exec.Command("xray", "api", "statsquery",
|
||||
"--server="+server, "-json", "-pattern", "outbound>>>").Output()
|
||||
if err != nil {
|
||||
return res, false
|
||||
}
|
||||
var doc struct {
|
||||
Stat []struct {
|
||||
Name string `json:"name"`
|
||||
Value int64 `json:"value"`
|
||||
} `json:"stat"`
|
||||
}
|
||||
if json.Unmarshal(out, &doc) != nil {
|
||||
return res, false
|
||||
}
|
||||
for _, s := range doc.Stat {
|
||||
// outbound>>>TAG>>>traffic>>>uplink|downlink
|
||||
parts := strings.Split(s.Name, ">>>")
|
||||
if len(parts) != 4 || parts[0] != "outbound" {
|
||||
continue
|
||||
}
|
||||
tag, dir := parts[1], parts[3]
|
||||
t := res[tag]
|
||||
switch dir {
|
||||
case "uplink":
|
||||
t.Up = s.Value
|
||||
case "downlink":
|
||||
t.Down = s.Value
|
||||
}
|
||||
res[tag] = t
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
|
||||
// obsSelectedTags returns the union of every balancer's principleTarget tags —
|
||||
// the members the observatory currently deems best/alive.
|
||||
func obsSelectedTags(server string, balancers []string) []string {
|
||||
var out []string
|
||||
for _, tag := range balancers {
|
||||
o, err := exec.Command("xray", "api", "bi", "--server="+server, "-json", tag).Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var doc struct {
|
||||
Balancer struct {
|
||||
PrincipleTarget struct {
|
||||
Tag []string `json:"tag"`
|
||||
} `json:"principleTarget"`
|
||||
} `json:"balancer"`
|
||||
}
|
||||
if json.Unmarshal(o, &doc) != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, doc.Balancer.PrincipleTarget.Tag...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- backup / restore ---
|
||||
|
||||
func TestBackupUntarRoundTrips(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
tarJSON(tw, "manifest.json", backupManifest{Format: "shater-backup", Manifest: 1, SchemaVersion: 1})
|
||||
tarBytes(tw, "config/shater", []byte("config globals 'globals'\n\toption enabled '1'\n"))
|
||||
tarBytes(tw, "subs/x.json", []byte(`{"name":"x","nodes":[]}`))
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
man, err := untarInto(&buf, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("untar: %v", err)
|
||||
}
|
||||
if man.Format != "shater-backup" || man.SchemaVersion != 1 {
|
||||
t.Fatalf("manifest = %+v", man)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "config", "shater"))
|
||||
if err != nil || !bytes.Contains(b, []byte("enabled")) {
|
||||
t.Fatalf("config not extracted: %v", err)
|
||||
}
|
||||
if !fileExists(filepath.Join(dir, "subs", "x.json")) {
|
||||
t.Fatal("sub cache not extracted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntarRejectsTraversal(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
tarBytes(tw, "../../etc/passwd", []byte("pwned"))
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
dir := t.TempDir()
|
||||
if _, err := untarInto(&buf, dir); err == nil {
|
||||
t.Fatal("expected traversal rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// Gzip-bomb defence: a member whose decompressed size exceeds the per-member
|
||||
// cap must be rejected instead of io.ReadAll'ing it into RAM.
|
||||
func TestUntarRejectsOversizedMember(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
tarBytes(tw, "config/shater", bytes.Repeat([]byte{0}, untarMemberCap+1))
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
if _, err := untarInto(&buf, t.TempDir()); err == nil {
|
||||
t.Fatal("expected per-member size cap rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntarRejectsOversizedTotal(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
chunk := bytes.Repeat([]byte{0}, untarMemberCap) // each member individually legal
|
||||
for i := int64(0); i <= untarTotalCap/untarMemberCap; i++ {
|
||||
tarBytes(tw, fmt.Sprintf("subs/%d.json", i), chunk)
|
||||
}
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
if _, err := untarInto(&buf, t.TempDir()); err == nil {
|
||||
t.Fatal("expected total size cap rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRejectsNonBackup(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
tarJSON(tw, "manifest.json", backupManifest{Format: "not-ours"})
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
if err := restoreReader(&buf, 0); err == nil {
|
||||
t.Fatal("expected rejection of non-shater backup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRejectsNewerSchema(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
tarJSON(tw, "manifest.json", backupManifest{Format: "shater-backup", SchemaVersion: CurrentSchemaVersion + 1})
|
||||
tarBytes(tw, "config/shater", []byte("config globals 'globals'\n"))
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
if err := restoreReader(&buf, 0); err == nil {
|
||||
t.Fatal("expected rejection of newer schema")
|
||||
}
|
||||
}
|
||||
|
||||
// --- profiles ---
|
||||
|
||||
func TestProfileSaveListDelete(t *testing.T) {
|
||||
saved := profileDir
|
||||
profileDir = t.TempDir()
|
||||
defer func() { profileDir = saved }()
|
||||
|
||||
// Backup reads /etc/config/shater which may not exist on the test host; write a
|
||||
// stub there is not possible, so test the list/delete plumbing with a hand-made
|
||||
// profile archive placed directly in profileDir.
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
tarJSON(tw, "manifest.json", backupManifest{Format: "shater-backup", SchemaVersion: 1, Created: 111})
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
os.WriteFile(profilePath("p1"), buf.Bytes(), 0o600)
|
||||
|
||||
b, err := ProfileListJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if !bytes.Contains(b, []byte(`"name": "p1"`)) {
|
||||
t.Fatalf("p1 not listed: %s", b)
|
||||
}
|
||||
if err := ProfileDelete("p1"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if fileExists(profilePath("p1")) {
|
||||
t.Fatal("profile not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeProfile(t *testing.T) {
|
||||
// path separators are stripped so the result is always a single, safe filename
|
||||
// component (dots are kept for .tar.gz and .pre-restore recovery snapshots).
|
||||
got := sanitizeProfile("../evil/name")
|
||||
if got != "..evilname" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if filepath.Base(profilePath("../evil/name")) != "..evilname.tar.gz" {
|
||||
t.Fatalf("unsafe profile path: %q", profilePath("../evil/name"))
|
||||
}
|
||||
if sanitizeProfile("") != "profile" {
|
||||
t.Fatal("empty name should default")
|
||||
}
|
||||
}
|
||||
|
||||
// --- migration (injectable runner) ---
|
||||
|
||||
type fakeUCI struct{ kv map[string]string }
|
||||
|
||||
func (f *fakeUCI) Get(k string) (string, bool) { v, ok := f.kv[k]; return v, ok }
|
||||
func (f *fakeUCI) Set(k, v string) error { f.kv[k] = v; return nil }
|
||||
func (f *fakeUCI) Delete(k string) error { delete(f.kv, k); return nil }
|
||||
func (f *fakeUCI) Commit(string) error { return nil }
|
||||
|
||||
func TestMigrate0to1TransformsFixture(t *testing.T) {
|
||||
f := &fakeUCI{kv: map[string]string{"shater.globals.kill": "open"}}
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if f.kv["shater.globals.kill_switch"] != "open" {
|
||||
t.Fatalf("kill_switch = %q", f.kv["shater.globals.kill_switch"])
|
||||
}
|
||||
if _, ok := f.kv["shater.globals.kill"]; ok {
|
||||
t.Fatal("legacy kill not removed")
|
||||
}
|
||||
if f.kv["shater.globals.schema_version"] != "1" {
|
||||
t.Fatalf("schema_version = %q", f.kv["shater.globals.schema_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIdempotent(t *testing.T) {
|
||||
f := &fakeUCI{kv: map[string]string{"shater.globals.schema_version": "1"}}
|
||||
before := len(f.kv)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if len(f.kv) != before {
|
||||
t.Fatal("idempotent migrate changed state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesNewer(t *testing.T) {
|
||||
f := &fakeUCI{kv: map[string]string{"shater.globals.schema_version": "99"}}
|
||||
if err := migrateWith(f); err == nil {
|
||||
t.Fatal("expected refusal of newer schema")
|
||||
}
|
||||
}
|
||||
|
||||
// --- compat ---
|
||||
|
||||
func TestSemverGE(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"1.8.16", "1.8.16", true},
|
||||
{"1.8.4", "1.8.16", false},
|
||||
{"25.1.0", "1.8.0", true},
|
||||
{"1.9", "1.8.16", true},
|
||||
{"v1.8.0", "1.8.0", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if semverGE(c.a, c.b) != c.want {
|
||||
t.Errorf("semverGE(%q,%q) = %v, want %v", c.a, c.b, !c.want, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredFeatures(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Globals.DNSMode = "fakeip"
|
||||
m.Groups = []Group{{Name: "g"}}
|
||||
m.Nodes = []Node{
|
||||
{Name: "r", URI: vlessReality},
|
||||
{Name: "x", URI: "vless://11111111-1111-1111-1111-111111111111@h:443?type=xhttp&security=tls&sni=h#x"},
|
||||
}
|
||||
req := requiredFeatures(m)
|
||||
for _, f := range []string{"fakeip", "observatory", "reality", "xhttp"} {
|
||||
if !req[f] {
|
||||
t.Errorf("expected feature %q required; got %v", f, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package main
|
||||
|
||||
// Preset packs (catalog 05 §5, T1): one-tap curated rule packs layered above the
|
||||
// user's rules. Each enabled `config preset` materialises into a synthetic Rule
|
||||
// prepended to the model rules, so the existing sort + emit pipeline (including the
|
||||
// geodata-optional geoStrip) handles them uniformly.
|
||||
//
|
||||
// Geodata-absent behaviour: block-ads and ru-bypass are geo-matcher based, so when
|
||||
// geoip.dat/geosite.dat are missing they strip to nothing and are DROPPED by the
|
||||
// empty-matcher guard in buildRoutingRules (fail-open — no block-everything). The
|
||||
// private pack carries literal RFC1918/ULA CIDRs and stays functional regardless.
|
||||
|
||||
import "strings"
|
||||
|
||||
// presetDef is a built-in pack definition (single source of truth).
|
||||
type presetDef struct {
|
||||
name string
|
||||
order int
|
||||
target string
|
||||
domains []string
|
||||
ips []string
|
||||
}
|
||||
|
||||
var builtinPresets = []presetDef{
|
||||
{
|
||||
name: "block-ads", order: 5, target: "block",
|
||||
domains: []string{"geosite:category-ads-all"},
|
||||
},
|
||||
{
|
||||
name: "ru-bypass", order: 8, target: "direct",
|
||||
domains: []string{"geosite:category-ru"},
|
||||
ips: []string{"geoip:ru"},
|
||||
},
|
||||
{
|
||||
name: "private", order: 9, target: "direct",
|
||||
ips: []string{
|
||||
"geoip:private",
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"127.0.0.0/8", "169.254.0.0/16", "fc00::/7", "fe80::/10",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// presetName normalises a preset section name (accepts "block_ads"/"block-ads").
|
||||
func presetName(s string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(s)), "_", "-")
|
||||
}
|
||||
|
||||
// presetRules materialises enabled `config preset` sections into synthetic Rules,
|
||||
// each given the pack's low Order so it sorts ahead of typical user rules (10) yet
|
||||
// stays overridable via the section's `order`.
|
||||
func (b *builder) presetRules() []Rule {
|
||||
on := map[string]Preset{}
|
||||
for _, p := range b.m.Presets {
|
||||
if p.Enabled {
|
||||
on[presetName(p.Name)] = p
|
||||
}
|
||||
}
|
||||
var out []Rule
|
||||
for _, def := range builtinPresets {
|
||||
p, ok := on[def.name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
order := def.order
|
||||
if p.Order != 0 {
|
||||
order = p.Order
|
||||
}
|
||||
target := def.target
|
||||
if p.Target != "" {
|
||||
target = p.Target
|
||||
}
|
||||
out = append(out, Rule{
|
||||
Name: "_preset-" + def.name,
|
||||
Enabled: true,
|
||||
Order: order,
|
||||
DstDomain: append([]string(nil), def.domains...),
|
||||
DstIP: append([]string(nil), def.ips...),
|
||||
Target: target,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func routingHasTarget(cfg map[string]any, tag string) map[string]any {
|
||||
for _, r := range cfg["routing"].(map[string]any)["rules"].([]any) {
|
||||
rm := r.(map[string]any)
|
||||
if rm["outboundTag"] == tag || rm["balancerTag"] == tag {
|
||||
return rm
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWithPresets(t *testing.T, presets []Preset, rules []Rule) map[string]any {
|
||||
t.Helper()
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Presets = presets
|
||||
m.Rules = rules
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestPresetPrivateDirectSurvivesNoGeodata(t *testing.T) {
|
||||
if geoAssetPresent() {
|
||||
t.Skip("geodata present; strip path not exercised")
|
||||
}
|
||||
cfg := buildWithPresets(t, []Preset{{Name: "private", Enabled: true}}, nil)
|
||||
var priv map[string]any
|
||||
for _, r := range cfg["routing"].(map[string]any)["rules"].([]any) {
|
||||
rm := r.(map[string]any)
|
||||
if ips, ok := rm["ip"].([]any); ok {
|
||||
for _, ip := range ips {
|
||||
if ip == "10.0.0.0/8" {
|
||||
priv = rm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if priv == nil {
|
||||
t.Fatal("private preset rule with literal RFC1918 not emitted")
|
||||
}
|
||||
if priv["outboundTag"] != "direct" {
|
||||
t.Fatalf("private target = %v", priv["outboundTag"])
|
||||
}
|
||||
// geoip:private must have been stripped when geodata is absent
|
||||
b, _ := marshalRule(priv)
|
||||
if strings.Contains(b, "geoip:") {
|
||||
t.Fatalf("geoip leaked: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetGeoOnlyRuleDroppedWhenGeodataAbsent(t *testing.T) {
|
||||
if geoAssetPresent() {
|
||||
t.Skip("geodata present; strip path not exercised")
|
||||
}
|
||||
// block-ads is geo-only; with geodata absent and kill-switch open (no trailing
|
||||
// block catch-all), NO block rule must exist (empty-matcher guard, fail-open).
|
||||
g := defaultGlobals()
|
||||
g.KillSwitch = "open"
|
||||
m := &Model{Globals: g, Presets: []Preset{{Name: "block-ads", Enabled: true}}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig: %v", err)
|
||||
}
|
||||
if routingHasTarget(cfg, "block") != nil {
|
||||
t.Fatal("geo-only block-ads must be dropped when geodata absent (no block-everything)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetDisabledNotInjected(t *testing.T) {
|
||||
cfg := buildWithPresets(t, []Preset{{Name: "private", Enabled: false}}, nil)
|
||||
for _, r := range cfg["routing"].(map[string]any)["rules"].([]any) {
|
||||
rm := r.(map[string]any)
|
||||
if ips, ok := rm["ip"].([]any); ok {
|
||||
for _, ip := range ips {
|
||||
if ip == "10.0.0.0/8" {
|
||||
t.Fatal("disabled preset must not inject rules")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetTargetOverride(t *testing.T) {
|
||||
cfg := buildWithPresets(t, []Preset{{Name: "private", Enabled: true, Target: "block"}}, nil)
|
||||
var found bool
|
||||
for _, r := range cfg["routing"].(map[string]any)["rules"].([]any) {
|
||||
rm := r.(map[string]any)
|
||||
if ips, ok := rm["ip"].([]any); ok {
|
||||
for _, ip := range ips {
|
||||
if ip == "10.0.0.0/8" && rm["outboundTag"] == "block" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("target override not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledRuleExcludedOutOfWindow(t *testing.T) {
|
||||
saved := timeNow
|
||||
defer func() { timeNow = saved }()
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Nodes = []Node{{Name: "n1", Enabled: true, URI: vlessReality}}
|
||||
m.Rules = []Rule{{Name: "night", Enabled: true, Order: 10, DstDomain: []string{"example.com"}, Target: "node:n1", SchedEnabled: true, SchedTZ: "UTC", SchedStart: "22:00", SchedEnd: "23:00"}}
|
||||
|
||||
timeNow = func() time.Time { t, _ := time.Parse(time.RFC3339, "2026-07-09T12:00:00Z"); return t }
|
||||
cfg, _ := BuildConfig(m)
|
||||
if ruleWithDomain(cfg, "example.com") != nil {
|
||||
t.Fatal("rule should be absent at 12:00 (outside window)")
|
||||
}
|
||||
timeNow = func() time.Time { t, _ := time.Parse(time.RFC3339, "2026-07-09T22:30:00Z"); return t }
|
||||
cfg, _ = BuildConfig(m)
|
||||
if ruleWithDomain(cfg, "example.com") == nil {
|
||||
t.Fatal("rule should be present at 22:30 (inside window)")
|
||||
}
|
||||
}
|
||||
|
||||
func ruleWithDomain(cfg map[string]any, domain string) map[string]any {
|
||||
for _, r := range cfg["routing"].(map[string]any)["rules"].([]any) {
|
||||
rm := r.(map[string]any)
|
||||
if ds, ok := rm["domain"].([]any); ok {
|
||||
for _, d := range ds {
|
||||
if d == domain {
|
||||
return rm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalRule(rm map[string]any) (string, error) {
|
||||
b, err := json.Marshal(rm)
|
||||
return string(b), err
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
package main
|
||||
|
||||
// On-demand reachability probes. Two flavours, user-selectable:
|
||||
// * TCP (default, fast): a raw TCP connect to the node endpoint — status.go's
|
||||
// tcpProbe. "Is the server port open", no proxy handshake. Cheapest.
|
||||
// * HTTP (opt-in): a real proxy-PATH check — spin an ephemeral xray with a local
|
||||
// SOCKS inbound routed through the node (or the whole chain), then do an HTTP
|
||||
// GET/HEAD to a URL over it. This exercises the actual protocol (vless/reality/
|
||||
// trojan/ss/xhttp/…) and, for chains, the full multi-hop path, and can report
|
||||
// the exit IP. Uses only the stdlib (a tiny SOCKS5 client) — no new deps.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProbeResult is the outcome of one HTTP proxy-path probe.
|
||||
type ProbeResult struct {
|
||||
Name string `json:"name"`
|
||||
Alive bool `json:"alive"`
|
||||
LatencyMS int `json:"latency_ms"`
|
||||
Status string `json:"status,omitempty"` // e.g. "204" / "200"
|
||||
ExitIP string `json:"exit_ip,omitempty"` // when the URL echoes the client IP
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// probeFreePort returns a free loopback TCP port.
|
||||
func probeFreePort() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
// socks5Dial opens a TCP tunnel to host:port through a SOCKS5 proxy (no auth).
|
||||
func socks5Dial(proxyAddr, host string, port int, timeout time.Duration) (net.Conn, error) {
|
||||
c, err := net.DialTimeout("tcp", proxyAddr, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = c.SetDeadline(time.Now().Add(timeout))
|
||||
// greeting: VER=5, 1 method, method 0 (no auth)
|
||||
if _, err := c.Write([]byte{5, 1, 0}); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
rep := make([]byte, 2)
|
||||
if _, err := io.ReadFull(c, rep); err != nil || rep[1] != 0 {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("socks greeting failed")
|
||||
}
|
||||
// CONNECT: VER=5, CMD=1, RSV=0, ATYP=3(domain), len, host, port
|
||||
if len(host) > 255 {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("host too long")
|
||||
}
|
||||
req := []byte{5, 1, 0, 3, byte(len(host))}
|
||||
req = append(req, host...)
|
||||
req = append(req, byte(port>>8), byte(port&0xff))
|
||||
if _, err := c.Write(req); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
// reply: VER REP RSV ATYP + addr + port. Read header, then drain bound addr.
|
||||
hdr := make([]byte, 4)
|
||||
if _, err := io.ReadFull(c, hdr); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
if hdr[1] != 0 {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("socks connect rejected (0x%02x)", hdr[1])
|
||||
}
|
||||
var n int
|
||||
switch hdr[3] {
|
||||
case 1:
|
||||
n = 4
|
||||
case 4:
|
||||
n = 16
|
||||
case 3:
|
||||
l := make([]byte, 1)
|
||||
if _, err := io.ReadFull(c, l); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
n = int(l[0])
|
||||
}
|
||||
if _, err := io.ReadFull(c, make([]byte, n+2)); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = c.SetDeadline(time.Time{})
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// httpProbeVia does an HTTP GET/HEAD to rawURL through a SOCKS5 proxy, returning
|
||||
// the status code, round-trip latency, and the (possibly IP-echo) body.
|
||||
func httpProbeVia(proxyAddr, rawURL, method string, timeout time.Duration) (status string, ms int, body string, err error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", 0, "", fmt.Errorf("bad url %q", rawURL)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
// TLS-over-SOCKS would need a full client; the health URL is plain http.
|
||||
return "", 0, "", fmt.Errorf("only http:// probe URLs are supported")
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := 80
|
||||
if p := u.Port(); p != "" {
|
||||
port, _ = strconv.Atoi(p)
|
||||
}
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
path := u.RequestURI()
|
||||
|
||||
start := time.Now()
|
||||
conn, err := socks5Dial(proxyAddr, host, port, timeout)
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
req := method + " " + path + " HTTP/1.1\r\nHost: " + host +
|
||||
"\r\nUser-Agent: shater-probe\r\nAccept: */*\r\nConnection: close\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(req)); err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
br := bufio.NewReader(conn)
|
||||
statusLine, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
ms = int(time.Since(start).Milliseconds())
|
||||
// "HTTP/1.1 204 No Content"
|
||||
f := strings.Fields(statusLine)
|
||||
if len(f) >= 2 {
|
||||
status = f[1]
|
||||
}
|
||||
// Read a little body (for ip-echo URLs). Skip headers first.
|
||||
for {
|
||||
line, e := br.ReadString('\n')
|
||||
if e != nil || strings.TrimRight(line, "\r\n") == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 256)
|
||||
n, _ := br.Read(buf)
|
||||
body = strings.TrimSpace(string(buf[:n]))
|
||||
return status, ms, body, nil
|
||||
}
|
||||
|
||||
// runEphemeralXray writes cfg to a temp file, starts `xray run -c`, and waits for
|
||||
// socksPort to accept. Returns a stop() and a pointer to xray's captured log.
|
||||
func runEphemeralXray(cfg map[string]any, socksPort int) (stop func(), logp *strings.Builder, err error) {
|
||||
tmp, err := os.CreateTemp("", "shater-probe-*.json")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if e := writeJSON(tmp.Name(), cfg); e != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return nil, nil, e
|
||||
}
|
||||
cmd := exec.Command("xray", "run", "-c", tmp.Name())
|
||||
cmd.Env = append(os.Environ(), "XRAY_LOCATION_ASSET="+assetDir())
|
||||
var errbuf strings.Builder
|
||||
cmd.Stderr = &errbuf
|
||||
cmd.Stdout = &errbuf
|
||||
if e := cmd.Start(); e != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return nil, &errbuf, e
|
||||
}
|
||||
stop = func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
os.Remove(tmp.Name())
|
||||
}
|
||||
// Wait (up to ~4s) for the SOCKS inbound to come up.
|
||||
addr := "127.0.0.1:" + strconv.Itoa(socksPort)
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c, e := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||
if e == nil {
|
||||
c.Close()
|
||||
return stop, &errbuf, nil
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
}
|
||||
stop()
|
||||
return nil, &errbuf, fmt.Errorf("ephemeral xray did not open socks :%d", socksPort)
|
||||
}
|
||||
|
||||
// lastLog returns the last ~2 lines of a captured log for diagnostics.
|
||||
func lastLog(b *strings.Builder) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n")
|
||||
if len(lines) > 2 {
|
||||
lines = lines[len(lines)-2:]
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, " | "))
|
||||
}
|
||||
|
||||
// assetDir is where geoip.dat/geosite.dat live (matches the init script env).
|
||||
func assetDir() string {
|
||||
if d := os.Getenv("XRAY_LOCATION_ASSET"); d != "" {
|
||||
return d
|
||||
}
|
||||
return "/usr/share/xray"
|
||||
}
|
||||
|
||||
// probeEphemeral runs an HTTP probe through an ephemeral xray built from the given
|
||||
// inbounds/outbounds/rules and a route from the probe socks inbound to routeTag.
|
||||
// extraRules carry a chain's internal socks-hop routing (must be preserved or the
|
||||
// multi-hop path has no route between layers).
|
||||
func probeEphemeral(inbounds, outbounds, extraRules []any, routeTag, name, rawURL, method string) ProbeResult {
|
||||
r := ProbeResult{Name: name}
|
||||
port, err := probeFreePort()
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
return r
|
||||
}
|
||||
socksIn := map[string]any{
|
||||
"tag": "probe-in", "protocol": "socks", "listen": "127.0.0.1", "port": port,
|
||||
"settings": map[string]any{"udp": false},
|
||||
}
|
||||
rules := append([]any{map[string]any{
|
||||
"type": "field", "inboundTag": []any{"probe-in"}, "outboundTag": routeTag,
|
||||
}}, extraRules...)
|
||||
cfg := map[string]any{
|
||||
"log": map[string]any{"loglevel": "warning"},
|
||||
"inbounds": append([]any{socksIn}, inbounds...),
|
||||
"outbounds": outbounds,
|
||||
"routing": map[string]any{"rules": rules},
|
||||
}
|
||||
stop, logp, err := runEphemeralXray(cfg, port)
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
if ll := lastLog(logp); ll != "" {
|
||||
r.Error += " [xray: " + ll + "]"
|
||||
}
|
||||
return r
|
||||
}
|
||||
defer stop()
|
||||
if rawURL == "" {
|
||||
rawURL = "http://www.gstatic.com/generate_204"
|
||||
}
|
||||
// Give the probe headroom: a reality/TLS handshake + upstream can take a few s.
|
||||
status, ms, body, err := httpProbeVia("127.0.0.1:"+strconv.Itoa(port), rawURL, method, 15*time.Second)
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
if ll := lastLog(logp); ll != "" {
|
||||
r.Error += " [xray: " + ll + "]"
|
||||
}
|
||||
return r
|
||||
}
|
||||
r.Status = status
|
||||
r.LatencyMS = ms
|
||||
// 2xx/3xx (generate_204 -> 204) = reachable.
|
||||
r.Alive = len(status) > 0 && (status[0] == '2' || status[0] == '3')
|
||||
if net.ParseIP(body) != nil {
|
||||
r.ExitIP = body
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// nodeHTTPProbeOne runs the ephemeral proxy-path HTTP probe for one node.
|
||||
func nodeHTTPProbeOne(n Node, rawURL, method string) ProbeResult {
|
||||
ob, err := ParseShareLink(n.URI, "proxy")
|
||||
if err != nil {
|
||||
return ProbeResult{Name: n.Name, Error: err.Error()}
|
||||
}
|
||||
return probeEphemeral(nil, []any{map[string]any(ob)}, nil, "proxy", n.Name, rawURL, method)
|
||||
}
|
||||
|
||||
// NodeHTTPProbe probes a single node's proxy path with an HTTP GET/HEAD.
|
||||
func NodeHTTPProbe(name, rawURL, method string) ProbeResult {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return ProbeResult{Name: name, Error: err.Error()}
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
for _, n := range m.Nodes {
|
||||
if n.Name != name {
|
||||
continue
|
||||
}
|
||||
return nodeHTTPProbeOne(n, rawURL, method)
|
||||
}
|
||||
return ProbeResult{Name: name, Error: "node not found"}
|
||||
}
|
||||
|
||||
// NodeHTTPProbeAll probes EVERY usable node's proxy path (enabled manual nodes
|
||||
// + non-stale cached subscription nodes) with the HTTP method — LuCI's
|
||||
// "Test all + HTTP". Output shape matches the TCP all-nodes path: a JSON array
|
||||
// of per-node results. Concurrency is bounded LOW, not 32 like the cheap TCP
|
||||
// path: each HTTP probe spawns an ephemeral xray process, and 32 engines at
|
||||
// once would exhaust a small router's RAM.
|
||||
func NodeHTTPProbeAll(rawURL, method string) ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
var todo []Node
|
||||
for _, n := range m.Nodes {
|
||||
if !n.Enabled || n.Stale || n.URI == "" {
|
||||
continue
|
||||
}
|
||||
todo = append(todo, n)
|
||||
}
|
||||
out := make([]ProbeResult, len(todo))
|
||||
sem := make(chan struct{}, 4)
|
||||
var wg sync.WaitGroup
|
||||
for i, n := range todo {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, n Node) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
out[i] = nodeHTTPProbeOne(n, rawURL, method)
|
||||
}(i, n)
|
||||
}
|
||||
wg.Wait()
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
// ChainHTTPProbe probes a whole chain's multi-hop path with an HTTP GET/HEAD.
|
||||
func ChainHTTPProbe(name, rawURL, method string) ProbeResult {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return ProbeResult{Name: name, Error: err.Error()}
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
b := newProbeBuilder(m)
|
||||
var chain *Chain
|
||||
for i := range m.Chains {
|
||||
if m.Chains[i].Name == name {
|
||||
chain = &m.Chains[i]
|
||||
}
|
||||
}
|
||||
if chain == nil {
|
||||
return ProbeResult{Name: name, Error: "chain not found"}
|
||||
}
|
||||
exitTag, err := b.emitChain(*chain)
|
||||
if err != nil {
|
||||
return ProbeResult{Name: name, Error: err.Error()}
|
||||
}
|
||||
outs := make([]any, 0, len(b.outbounds)+2)
|
||||
outs = append(outs, b.outbounds...)
|
||||
// direct + block so degraded layer tags (empty group -> block/direct, see
|
||||
// emptyGroupTag) always resolve in the ephemeral config.
|
||||
outs = append(outs,
|
||||
map[string]any{"tag": "direct", "protocol": "freedom"},
|
||||
map[string]any{"tag": "block", "protocol": "blackhole"})
|
||||
return probeEphemeral(b.inbounds, outs, b.rules, exitTag, name, rawURL, method)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package main
|
||||
|
||||
// Named config profiles (catalog 05 §11, T2): saved backup bundles under
|
||||
// /etc/xray/profiles/<name>.tar.gz that the user can switch between atomically
|
||||
// (reusing the restore safety path). These are config SNAPSHOTS — distinct from
|
||||
// the reserved `config profile` WAN-mode override feature.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// profileDir is a var (not const) so tests can redirect it.
|
||||
var profileDir = "/etc/xray/profiles"
|
||||
|
||||
func sanitizeProfile(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9',
|
||||
r == '.', r == '_', r == '-':
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
if s == "" {
|
||||
s = "profile"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func profilePath(name string) string {
|
||||
return filepath.Join(profileDir, sanitizeProfile(name)+".tar.gz")
|
||||
}
|
||||
|
||||
// ProfileSave snapshots the current config bundle to profiles/<name>.tar.gz.
|
||||
func ProfileSave(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("profile name required")
|
||||
}
|
||||
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return Backup(profilePath(name))
|
||||
}
|
||||
|
||||
// readManifest opens a profile archive and returns only its manifest.
|
||||
func readManifest(path string) (backupManifest, error) {
|
||||
var man backupManifest
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return man, err
|
||||
}
|
||||
defer f.Close()
|
||||
stage, err := os.MkdirTemp("", "shater-man-")
|
||||
if err != nil {
|
||||
return man, err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
return untarInto(f, stage)
|
||||
}
|
||||
|
||||
func activeProfile() string {
|
||||
out, err := exec.Command("uci", "-q", "get", "shater.globals.active_profile").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func setActiveProfile(name string) error {
|
||||
ensureGlobals(uci) // create the named globals section if missing
|
||||
if err := exec.Command("uci", "set", "shater.globals.active_profile="+name).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("uci", "commit", "shater").Run()
|
||||
}
|
||||
|
||||
type profileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Created int64 `json:"created"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Active bool `json:"active"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ProfileListJSON enumerates saved profiles.
|
||||
func ProfileListJSON() ([]byte, error) {
|
||||
active := activeProfile()
|
||||
entries, _ := os.ReadDir(profileDir)
|
||||
out := []profileInfo{}
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".tar.gz") || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), ".tar.gz")
|
||||
man, _ := readManifest(filepath.Join(profileDir, e.Name()))
|
||||
fi, _ := e.Info()
|
||||
var size int64
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
out = append(out, profileInfo{
|
||||
Name: name, Created: man.Created, SchemaVersion: man.SchemaVersion,
|
||||
Active: name == active, Size: size,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
// ProfileSwitch restores a saved profile and records it as active.
|
||||
func ProfileSwitch(name string, confirm int) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("profile name required")
|
||||
}
|
||||
path := profilePath(name)
|
||||
if !fileExists(path) {
|
||||
return fmt.Errorf("no such profile %q", name)
|
||||
}
|
||||
_ = ProfileSave(".pre-switch")
|
||||
if err := Restore(path, confirm); err != nil {
|
||||
return err
|
||||
}
|
||||
return setActiveProfile(name)
|
||||
}
|
||||
|
||||
// ProfileDelete removes a saved profile.
|
||||
func ProfileDelete(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("profile name required")
|
||||
}
|
||||
return os.Remove(profilePath(name))
|
||||
}
|
||||
|
||||
// gzipMagic verifies a byte slice starts with the gzip magic (used by tests).
|
||||
func gzipMagic(b []byte) bool {
|
||||
r, err := gzip.NewReader(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = r.Close()
|
||||
return true
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package main
|
||||
|
||||
// WAN-mode / failover profiles (catalog §7, T2). A `config profile` defines
|
||||
// conditions (active default-route interface, a connectivity probe up/down, a
|
||||
// time window — all AND'd) and overrides (force-enable/disable named rules,
|
||||
// override the default catch-all target/egress). The highest-priority profile
|
||||
// whose conditions all hold is "active"; its overrides are applied at gen time.
|
||||
// Evaluated on every reconcile; the cron re-applies when the active profile
|
||||
// changes (its name feeds scheduleSignature).
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// strSet builds a lookup set from a string slice.
|
||||
func strSet(ss []string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, s := range ss {
|
||||
m[s] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// defaultIface returns the interface the kernel would actually egress a public
|
||||
// destination through — i.e. which of possibly-several active uplinks is the
|
||||
// current default. It asks the kernel authoritatively via `ip route get` (which
|
||||
// honours metric AND policy/mwan3 rules), and falls back to the lowest-metric
|
||||
// default route. Our own fwmark policy rule is not matched (this query carries no
|
||||
// mark), so it reflects the real WAN egress, not the tproxy loopback table.
|
||||
func defaultIface() string {
|
||||
for _, dst := range []string{"8.8.8.8", "1.1.1.1"} {
|
||||
if out, err := exec.Command("ip", "route", "get", dst).Output(); err == nil {
|
||||
if dev := devFromRouteGet(string(out)); dev != "" {
|
||||
return dev
|
||||
}
|
||||
}
|
||||
}
|
||||
return lowestMetricDefaultIface()
|
||||
}
|
||||
|
||||
// devFromRouteGet extracts the `dev <x>` from `ip route get` output.
|
||||
func devFromRouteGet(out string) string {
|
||||
f := strings.Fields(out)
|
||||
for i := 0; i < len(f); i++ {
|
||||
if f[i] == "dev" && i+1 < len(f) {
|
||||
return f[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lowestMetricDefaultIface picks the dev of the lowest-metric default route.
|
||||
func lowestMetricDefaultIface() string {
|
||||
out, err := exec.Command("ip", "route", "show", "default").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
best, bestMetric := "", 1<<30
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
f := strings.Fields(line)
|
||||
dev, metric := "", 0
|
||||
for i := 0; i < len(f); i++ {
|
||||
if f[i] == "dev" && i+1 < len(f) {
|
||||
dev = f[i+1]
|
||||
}
|
||||
if f[i] == "metric" && i+1 < len(f) {
|
||||
metric = atoiOr(f[i+1], 0)
|
||||
}
|
||||
}
|
||||
if dev != "" && metric < bestMetric {
|
||||
best, bestMetric = dev, metric
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// profileProbeUp reports whether an HTTP(S) probe to url succeeds (2xx/3xx).
|
||||
func profileProbeUp(url string) bool {
|
||||
if !strings.Contains(url, "://") {
|
||||
url = "http://" + url
|
||||
}
|
||||
c := &http.Client{Timeout: 6 * time.Second}
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode < 400
|
||||
}
|
||||
|
||||
// profileConditionsHold checks a profile's specified conditions (all must hold).
|
||||
// iface and now are passed in so a full evaluation dials the probe at most once.
|
||||
func profileConditionsHold(p Profile, iface string, now time.Time) bool {
|
||||
if len(p.MatchIface) > 0 {
|
||||
hit := false
|
||||
for _, want := range p.MatchIface {
|
||||
if strings.EqualFold(strings.TrimSpace(want), iface) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if p.ProbeURL != "" {
|
||||
up := profileProbeUp(p.ProbeURL)
|
||||
if strings.EqualFold(p.ProbeMode, "down") {
|
||||
if up {
|
||||
return false // want probe down, but it's up
|
||||
}
|
||||
} else if !up {
|
||||
return false // want probe up, but it's down
|
||||
}
|
||||
}
|
||||
if p.SchedStart != "" || p.SchedEnd != "" || len(p.SchedDays) > 0 {
|
||||
r := Rule{SchedEnabled: true, SchedDays: p.SchedDays, SchedStart: p.SchedStart, SchedEnd: p.SchedEnd, SchedTZ: p.SchedTZ}
|
||||
if !ruleActiveNow(r, now) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// activeWanProfile returns the highest-priority enabled profile whose conditions
|
||||
// all hold, or nil. A profile with NO conditions never activates (avoids a
|
||||
// permanent override with an empty match set).
|
||||
func activeWanProfile(m *Model, now time.Time) *Profile {
|
||||
var candidates []Profile
|
||||
for _, p := range m.Profiles {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
if len(p.MatchIface) == 0 && p.ProbeURL == "" && p.SchedStart == "" && p.SchedEnd == "" && len(p.SchedDays) == 0 {
|
||||
continue // no conditions -> never auto-activates
|
||||
}
|
||||
candidates = append(candidates, p)
|
||||
}
|
||||
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].Priority > candidates[j].Priority })
|
||||
iface := ""
|
||||
needIface := false
|
||||
for _, p := range candidates {
|
||||
if len(p.MatchIface) > 0 {
|
||||
needIface = true
|
||||
}
|
||||
}
|
||||
if needIface {
|
||||
iface = defaultIface()
|
||||
}
|
||||
for i := range candidates {
|
||||
if profileConditionsHold(candidates[i], iface, now) {
|
||||
p := candidates[i]
|
||||
return &p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProfileStatusJSON reports the currently-active WAN-mode profile + evaluation.
|
||||
func ProfileStatusJSON() ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := timeNow()
|
||||
iface := defaultIface()
|
||||
active := activeWanProfile(m, now)
|
||||
type row struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
var rows []row
|
||||
for _, p := range m.Profiles {
|
||||
rows = append(rows, row{p.Name, p.Enabled, active != nil && active.Name == p.Name})
|
||||
}
|
||||
res := map[string]any{
|
||||
"default_iface": iface,
|
||||
"active": "",
|
||||
"profiles": rows,
|
||||
}
|
||||
if active != nil {
|
||||
res["active"] = active.Name
|
||||
}
|
||||
return jsonMarshalIndent(res)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDevFromRouteGet(t *testing.T) {
|
||||
// authoritative kernel answer for the active egress
|
||||
out := "8.8.8.8 via 10.0.2.2 dev eth1 src 10.0.2.15 uid 0 \n cache "
|
||||
if devFromRouteGet(out) != "eth1" {
|
||||
t.Fatalf("got %q", devFromRouteGet(out))
|
||||
}
|
||||
// a directly-connected dest: "1.2.3.4 dev wwan0 src ..."
|
||||
if devFromRouteGet("1.2.3.4 dev wwan0 src 1.2.3.5") != "wwan0" {
|
||||
t.Fatal("wwan0 not parsed")
|
||||
}
|
||||
if devFromRouteGet("unreachable 1.2.3.4") != "" {
|
||||
t.Fatal("expected empty for no dev")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileConditionsIface(t *testing.T) {
|
||||
p := Profile{Name: "sim", Enabled: true, MatchIface: []string{"wwan0", "usb0"}}
|
||||
now := at(t, "2026-07-09T12:00:00Z")
|
||||
if !profileConditionsHold(p, "wwan0", now) {
|
||||
t.Fatal("should hold on wwan0")
|
||||
}
|
||||
if profileConditionsHold(p, "eth0", now) {
|
||||
t.Fatal("should not hold on eth0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileConditionsSchedule(t *testing.T) {
|
||||
p := Profile{Name: "night", Enabled: true, SchedTZ: "UTC", SchedStart: "22:00", SchedEnd: "23:00"}
|
||||
if !profileConditionsHold(p, "", at(t, "2026-07-09T22:30:00Z")) {
|
||||
t.Fatal("should hold at 22:30")
|
||||
}
|
||||
if profileConditionsHold(p, "", at(t, "2026-07-09T12:00:00Z")) {
|
||||
t.Fatal("should not hold at 12:00")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileConditionsAND(t *testing.T) {
|
||||
// iface AND schedule: both must hold
|
||||
p := Profile{Name: "x", Enabled: true, MatchIface: []string{"wwan0"}, SchedTZ: "UTC", SchedStart: "22:00", SchedEnd: "23:00"}
|
||||
if profileConditionsHold(p, "wwan0", at(t, "2026-07-09T12:00:00Z")) {
|
||||
t.Fatal("iface ok but out of window -> must not hold")
|
||||
}
|
||||
if profileConditionsHold(p, "eth0", at(t, "2026-07-09T22:30:00Z")) {
|
||||
t.Fatal("in window but wrong iface -> must not hold")
|
||||
}
|
||||
if !profileConditionsHold(p, "wwan0", at(t, "2026-07-09T22:30:00Z")) {
|
||||
t.Fatal("both hold -> must hold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveWanProfilePriority(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Profiles = []Profile{
|
||||
{Name: "lo", Enabled: true, Priority: 1, SchedTZ: "UTC", SchedStart: "00:00", SchedEnd: "23:59"},
|
||||
{Name: "hi", Enabled: true, Priority: 9, SchedTZ: "UTC", SchedStart: "00:00", SchedEnd: "23:59"},
|
||||
}
|
||||
p := activeWanProfile(m, at(t, "2026-07-09T12:00:00Z"))
|
||||
if p == nil || p.Name != "hi" {
|
||||
t.Fatalf("expected 'hi' by priority, got %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveWanProfileNoConditionsNeverActive(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Profiles = []Profile{{Name: "empty", Enabled: true}}
|
||||
if activeWanProfile(m, at(t, "2026-07-09T12:00:00Z")) != nil {
|
||||
t.Fatal("a profile with no conditions must never auto-activate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileOverrideEnableDisable(t *testing.T) {
|
||||
saved := timeNow
|
||||
defer func() { timeNow = saved }()
|
||||
timeNow = func() time.Time { return at(t, "2026-07-09T22:30:00Z") }
|
||||
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Nodes = []Node{{Name: "n1", Enabled: true, URI: vlessReality}}
|
||||
m.Rules = []Rule{
|
||||
{Name: "off-by-default", Enabled: false, Order: 10, DstDomain: []string{"ru.example"}, Target: "node:n1"},
|
||||
{Name: "on-by-default", Enabled: true, Order: 11, DstDomain: []string{"keep.example"}, Target: "node:n1"},
|
||||
}
|
||||
m.Profiles = []Profile{{
|
||||
Name: "night", Enabled: true, SchedTZ: "UTC", SchedStart: "22:00", SchedEnd: "23:00",
|
||||
EnableRules: []string{"off-by-default"}, DisableRules: []string{"on-by-default"},
|
||||
}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig: %v", err)
|
||||
}
|
||||
if ruleWithDomain(cfg, "ru.example") == nil {
|
||||
t.Fatal("profile should have enabled 'off-by-default'")
|
||||
}
|
||||
if ruleWithDomain(cfg, "keep.example") != nil {
|
||||
t.Fatal("profile should have disabled 'on-by-default'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileDefaultTargetInjected(t *testing.T) {
|
||||
saved := timeNow
|
||||
defer func() { timeNow = saved }()
|
||||
timeNow = func() time.Time { return at(t, "2026-07-09T22:30:00Z") }
|
||||
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Nodes = []Node{{Name: "ru", Enabled: true, URI: vlessReality}}
|
||||
m.Profiles = []Profile{{
|
||||
Name: "whitelist", Enabled: true, SchedTZ: "UTC", SchedStart: "22:00", SchedEnd: "23:00",
|
||||
DefaultTarget: "node:ru",
|
||||
}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig: %v", err)
|
||||
}
|
||||
if routingHasTarget(cfg, "node_ru") == nil {
|
||||
t.Fatal("profile default target should inject a catch-all to node_ru")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileInactiveNoOverride(t *testing.T) {
|
||||
saved := timeNow
|
||||
defer func() { timeNow = saved }()
|
||||
timeNow = func() time.Time { return at(t, "2026-07-09T12:00:00Z") } // outside window
|
||||
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Nodes = []Node{{Name: "n1", Enabled: true, URI: vlessReality}}
|
||||
m.Rules = []Rule{{Name: "off-by-default", Enabled: false, Order: 10, DstDomain: []string{"ru.example"}, Target: "node:n1"}}
|
||||
m.Profiles = []Profile{{
|
||||
Name: "night", Enabled: true, SchedTZ: "UTC", SchedStart: "22:00", SchedEnd: "23:00",
|
||||
EnableRules: []string{"off-by-default"},
|
||||
}}
|
||||
cfg, _ := BuildConfig(m)
|
||||
if ruleWithDomain(cfg, "ru.example") != nil {
|
||||
t.Fatal("inactive profile must not enable the rule")
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Regression: xray rejects a routing rule with no matcher ("this rule has no
|
||||
// effective fields"). Every generated rule must carry at least one matcher —
|
||||
// a pure default/catch-all rule gets an explicit tcp,udp network matcher.
|
||||
// Verified against real `xray -test` (Configuration OK) on the OpenWrt testbed.
|
||||
func TestEveryRuleHasEffectiveMatcher(t *testing.T) {
|
||||
matchers := []string{"source", "domain", "ip", "port", "network", "inboundTag", "protocol", "user", "sourcePort", "attrs", "domainMatcher"}
|
||||
|
||||
check := func(name string, cfg map[string]any, err error) {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: build error: %v", name, err)
|
||||
}
|
||||
b, _ := json.Marshal(cfg)
|
||||
var c struct {
|
||||
Routing struct {
|
||||
Rules []map[string]any `json:"rules"`
|
||||
} `json:"routing"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
t.Fatalf("%s: unmarshal: %v", name, err)
|
||||
}
|
||||
if len(c.Routing.Rules) == 0 {
|
||||
t.Fatalf("%s: no routing rules generated", name)
|
||||
}
|
||||
for i, r := range c.Routing.Rules {
|
||||
has := false
|
||||
for _, k := range matchers {
|
||||
if _, ok := r[k]; ok {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
t.Fatalf("%s: rule %d has no effective matcher: %v", name, i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := BuildConfigFromLinks([]string{
|
||||
"trojan://pass@1.2.3.4:443?type=tcp&security=tls&sni=a.example.com#t",
|
||||
})
|
||||
check("links", cfg, err)
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package main
|
||||
|
||||
// Ruleset loading: resolve a `config ruleset` (reusable domain/ip list) from
|
||||
// inline entries, a local file (`path`), or a remote URL (`url`) with pluggable
|
||||
// `format` adapters (plain / clash / geosite). Remote lists are cached under
|
||||
// /etc/xray/lists/<name>.{domain,ip} and served from cache when a refresh fails
|
||||
// (use-cache-on-fail). Per-ruleset update is exposed for the scheduler/CLI.
|
||||
//
|
||||
// generate.go (owned by another agent) resolves rulesets into a rule's
|
||||
// domain/ip arrays; it can call rsResolve(rs) directly, or the exported
|
||||
// LoadRuleset(name) convenience. Update is driven by RulesetUpdate(name).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rsListsDir is where cached remote lists live: <name>.domain / <name>.ip.
|
||||
const rsListsDir = "/etc/xray/lists"
|
||||
|
||||
// LoadRuleset resolves the named ruleset into domain and ip entries. It is the
|
||||
// exported entry point for the generator (generate.go) and CLI. Best-effort:
|
||||
// returns empty slices if the ruleset is unknown or unreadable.
|
||||
func LoadRuleset(name string) (domains, ips []string) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
for _, rs := range m.Rulesets {
|
||||
if rs.Name == name {
|
||||
return rsResolve(rs)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// rsResolve loads a ruleset's raw text from its source, applies the format
|
||||
// adapter, and splits the parsed entries into domains/ips by the ruleset type.
|
||||
func rsResolve(rs Ruleset) (domains, ips []string) {
|
||||
text := rsRawText(rs)
|
||||
entries := rsParse(text, rs.Format, rs.Type)
|
||||
if strings.EqualFold(rs.Type, "ipcidr") {
|
||||
return nil, entries
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// rsRawText returns the unparsed list text for a ruleset per its source.
|
||||
// - inline: the UCI `entry` list joined by newlines.
|
||||
// - file: the contents of rs.Path.
|
||||
// - url: the cached copy; if absent, one best-effort fetch is attempted
|
||||
// (subsequent refreshes are the scheduler's job via RulesetUpdate).
|
||||
func rsRawText(rs Ruleset) string {
|
||||
switch strings.ToLower(rs.Source) {
|
||||
case "file":
|
||||
if b, err := os.ReadFile(rs.Path); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return ""
|
||||
case "url":
|
||||
path := rsCachePath(rs)
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
// No cache yet: try to populate once, then read it back.
|
||||
if err := rsFetchToCache(rs); err == nil {
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
default: // inline
|
||||
return strings.Join(rs.Entries, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// RulesetUpdate fetches and caches remote (source=url) rulesets. Empty name
|
||||
// updates all url rulesets; otherwise only the named one. Fetch failures keep
|
||||
// the existing cache (use-cache-on-fail) and are reported but not fatal unless
|
||||
// there is no cache to fall back on. Exposed for `xrayctl ruleset update`.
|
||||
func RulesetUpdate(name string) error {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matched := 0
|
||||
var firstErr error
|
||||
for _, rs := range m.Rulesets {
|
||||
if !strings.EqualFold(rs.Source, "url") {
|
||||
continue
|
||||
}
|
||||
if name != "" && rs.Name != name {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
if err := rsFetchToCache(rs); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if matched == 0 && name != "" {
|
||||
return fmt.Errorf("ruleset %q not found or not a url source", name)
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// rsCachePath is the on-disk cache location for a remote list.
|
||||
func rsCachePath(rs Ruleset) string {
|
||||
ext := "domain"
|
||||
if strings.EqualFold(rs.Type, "ipcidr") {
|
||||
ext = "ip"
|
||||
}
|
||||
return filepath.Join(rsListsDir, rs.Name+"."+ext)
|
||||
}
|
||||
|
||||
// rsFetchToCache downloads a url ruleset and atomically writes it to the cache.
|
||||
// On any network/HTTP error it returns nil if a cache already exists (serve
|
||||
// stale), otherwise the error.
|
||||
func rsFetchToCache(rs Ruleset) error {
|
||||
if rs.URL == "" {
|
||||
return fmt.Errorf("ruleset %q: empty url", rs.Name)
|
||||
}
|
||||
req, err := http.NewRequest("GET", rs.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return rsCacheFallback(rs, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return rsCacheFallback(rs, fmt.Errorf("ruleset %q: HTTP %d", rs.Name, resp.StatusCode))
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if err != nil {
|
||||
return rsCacheFallback(rs, err)
|
||||
}
|
||||
return rsWriteCache(rs, body)
|
||||
}
|
||||
|
||||
// rsCacheFallback swallows a fetch error when a cached copy is present.
|
||||
func rsCacheFallback(rs Ruleset, cause error) error {
|
||||
if _, err := os.Stat(rsCachePath(rs)); err == nil {
|
||||
return nil
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// rsWriteCache writes the raw list to its cache path atomically.
|
||||
func rsWriteCache(rs Ruleset, body []byte) error {
|
||||
if err := os.MkdirAll(rsListsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
path := rsCachePath(rs)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// --- format adapters -------------------------------------------------------
|
||||
|
||||
// rsParse dispatches to the format adapter and returns the list entries.
|
||||
func rsParse(text, format, typ string) []string {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "clash":
|
||||
return rsParseClash(text)
|
||||
case "geosite":
|
||||
return rsParseGeosite(text, typ)
|
||||
default: // plain (one entry per line)
|
||||
return rsParsePlain(text)
|
||||
}
|
||||
}
|
||||
|
||||
// rsParsePlain returns one entry per non-empty, non-comment line.
|
||||
func rsParsePlain(text string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, "//") ||
|
||||
strings.HasPrefix(t, ";") {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rsParseClash parses a Clash rule-provider list. It accepts the YAML-ish
|
||||
//
|
||||
// payload:
|
||||
// - '+.example.com'
|
||||
// - 'DOMAIN-SUFFIX,foo.com'
|
||||
// - IP-CIDR,1.2.3.0/24
|
||||
//
|
||||
// form. Both "behavior: domain/ipcidr" style bare tokens and "classical" style
|
||||
// TYPE,value rules are handled; the extracted value is returned (a leading
|
||||
// "+." wildcard is normalized to an xray suffix match).
|
||||
func rsParseClash(text string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(t, "-") {
|
||||
continue // skip "payload:" and any header/scalar lines
|
||||
}
|
||||
item := rsUnquote(strings.TrimSpace(strings.TrimPrefix(t, "-")))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(item, ",") { // classical: TYPE,value[,opts]
|
||||
parts := strings.Split(item, ",")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
item = strings.TrimSpace(parts[1])
|
||||
}
|
||||
item = strings.TrimPrefix(item, "+.") // clash suffix wildcard -> xray suffix
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rsParseGeosite passes each token through as geosite:<name> (or geoip:<name>
|
||||
// for ipcidr rulesets), so a geosite/geoip category list maps straight into
|
||||
// xray domain/ip matchers. Existing geosite:/geoip: prefixes are respected.
|
||||
func rsParseGeosite(text, typ string) []string {
|
||||
prefix := "geosite:"
|
||||
if strings.EqualFold(typ, "ipcidr") {
|
||||
prefix = "geoip:"
|
||||
}
|
||||
var out []string
|
||||
for _, tok := range rsParsePlain(text) {
|
||||
tok = strings.TrimPrefix(tok, "geosite:")
|
||||
tok = strings.TrimPrefix(tok, "geoip:")
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, prefix+tok)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rsUnquote strips one layer of matching single/double quotes.
|
||||
func rsUnquote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package main
|
||||
|
||||
// Schedule rules (catalog 05 §5, T3). xray routing has no time match, so a
|
||||
// scheduled rule is realised by the control plane: buildRoutingRules consults
|
||||
// ruleActiveNow(now) and skips out-of-window rules, so a plain `gen`/`reconcile`
|
||||
// at any instant emits the correct ruleset. The shater-cron calls `xrayctl
|
||||
// schedule due` each tick and reconciles when a window boundary was crossed.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// timeNow is the wall clock for schedule evaluation. Overridable via SHATER_FAKE_NOW
|
||||
// (RFC3339 or unix seconds) for tests and live verification.
|
||||
var timeNow = func() time.Time {
|
||||
if v := os.Getenv("SHATER_FAKE_NOW"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
return t
|
||||
}
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return time.Unix(n, 0)
|
||||
}
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
var weekdayByName = map[string]time.Weekday{
|
||||
"sun": time.Sunday, "mon": time.Monday, "tue": time.Tuesday,
|
||||
"wed": time.Wednesday, "thu": time.Thursday, "fri": time.Friday, "sat": time.Saturday,
|
||||
"0": time.Sunday, "1": time.Monday, "2": time.Tuesday, "3": time.Wednesday,
|
||||
"4": time.Thursday, "5": time.Friday, "6": time.Saturday,
|
||||
}
|
||||
|
||||
// parseDays maps day tokens to a set; empty/unparseable => empty (means all days).
|
||||
func parseDays(days []string) map[time.Weekday]bool {
|
||||
out := map[time.Weekday]bool{}
|
||||
for _, d := range days {
|
||||
key := strings.ToLower(strings.TrimSpace(d))
|
||||
if len(key) > 3 {
|
||||
key = key[:3]
|
||||
}
|
||||
if wd, ok := weekdayByName[key]; ok {
|
||||
out[wd] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseHHMM parses "HH:MM" into minutes-since-midnight; ok=false if empty/malformed.
|
||||
func parseHHMM(s string) (int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
h, m, ok := strings.Cut(s, ":")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
hh, e1 := strconv.Atoi(h)
|
||||
mm, e2 := strconv.Atoi(m)
|
||||
if e1 != nil || e2 != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return hh*60 + mm, true
|
||||
}
|
||||
|
||||
// resolveLoc returns the tz location; empty or load-failure => time.Local.
|
||||
func resolveLoc(tz string) *time.Location {
|
||||
if strings.TrimSpace(tz) == "" {
|
||||
return time.Local
|
||||
}
|
||||
if loc, err := time.LoadLocation(tz); err == nil {
|
||||
return loc
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
// ruleActiveNow reports whether a rule is active at `now`. A disabled schedule or
|
||||
// a malformed window fails open (rule active), so a broken schedule never silently
|
||||
// disables a rule.
|
||||
func ruleActiveNow(r Rule, now time.Time) bool {
|
||||
if !r.SchedEnabled {
|
||||
return true
|
||||
}
|
||||
now = now.In(resolveLoc(r.SchedTZ))
|
||||
days := parseDays(r.SchedDays)
|
||||
inDay := func(d time.Weekday) bool { return len(days) == 0 || days[d] }
|
||||
|
||||
startMin, okS := parseHHMM(r.SchedStart)
|
||||
endMin, okE := parseHHMM(r.SchedEnd)
|
||||
cur := now.Hour()*60 + now.Minute()
|
||||
|
||||
if !okS || !okE || startMin == endMin {
|
||||
return inDay(now.Weekday()) // all-day; gate only by day-of-week
|
||||
}
|
||||
if startMin < endMin { // same-day window ([start,end))
|
||||
return inDay(now.Weekday()) && cur >= startMin && cur < endMin
|
||||
}
|
||||
// wrap-around (e.g. 22:00-06:00): window belongs to the day it opened.
|
||||
if cur >= startMin {
|
||||
return inDay(now.Weekday())
|
||||
}
|
||||
if cur < endMin {
|
||||
return inDay((now.Weekday() + 6) % 7) // morning part -> yesterday's window
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scheduleSignature hashes the set of currently-active scheduled rules AND the
|
||||
// active WAN-mode profile; it changes exactly when a schedule boundary is crossed
|
||||
// or the active profile flips, so the cron reconciles at those transitions.
|
||||
func scheduleSignature(m *Model, now time.Time) string {
|
||||
var active []string
|
||||
for _, r := range m.Rules {
|
||||
if r.SchedEnabled && r.Enabled && ruleActiveNow(r, now) {
|
||||
active = append(active, r.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(active)
|
||||
h := fnv.New64a()
|
||||
for _, n := range active {
|
||||
h.Write([]byte(n))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
if p := activeWanProfile(m, now); p != nil {
|
||||
h.Write([]byte("profile:" + p.Name))
|
||||
}
|
||||
return strconv.FormatUint(h.Sum64(), 16)
|
||||
}
|
||||
|
||||
// ScheduleDue prints "1" (and updates the stamp) when the active-scheduled-rule set
|
||||
// changed since the last tick — the cron's signal to reconcile. Prints nothing when
|
||||
// no boundary was crossed.
|
||||
func ScheduleDue() int {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
sig := scheduleSignature(m, timeNow())
|
||||
const stamp = "/var/run/shater/cron/sched.sig"
|
||||
old, _ := os.ReadFile(stamp)
|
||||
if strings.TrimSpace(string(old)) != sig {
|
||||
_ = os.MkdirAll(filepath.Dir(stamp), 0o755)
|
||||
_ = os.WriteFile(stamp, []byte(sig+"\n"), 0o644)
|
||||
fmt.Println("1")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func at(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
tm, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tm
|
||||
}
|
||||
|
||||
func TestRuleActiveNowSameDayWindow(t *testing.T) {
|
||||
r := Rule{SchedEnabled: true, SchedTZ: "UTC", SchedStart: "09:00", SchedEnd: "17:00"}
|
||||
if !ruleActiveNow(r, at(t, "2026-07-09T12:00:00Z")) {
|
||||
t.Fatal("12:00 should be active")
|
||||
}
|
||||
if ruleActiveNow(r, at(t, "2026-07-09T08:00:00Z")) {
|
||||
t.Fatal("08:00 should be inactive")
|
||||
}
|
||||
if ruleActiveNow(r, at(t, "2026-07-09T17:00:00Z")) {
|
||||
t.Fatal("17:00 (end, exclusive) should be inactive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleActiveNowWrapAround(t *testing.T) {
|
||||
r := Rule{SchedEnabled: true, SchedTZ: "UTC", SchedStart: "22:00", SchedEnd: "06:00"}
|
||||
if !ruleActiveNow(r, at(t, "2026-07-09T23:00:00Z")) {
|
||||
t.Fatal("23:00 should be active")
|
||||
}
|
||||
if !ruleActiveNow(r, at(t, "2026-07-09T05:00:00Z")) {
|
||||
t.Fatal("05:00 should be active")
|
||||
}
|
||||
if ruleActiveNow(r, at(t, "2026-07-09T12:00:00Z")) {
|
||||
t.Fatal("12:00 should be inactive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleActiveNowWrapAroundDayOfWeek(t *testing.T) {
|
||||
// window Fri 22:00 - 06:00 (into Sat morning)
|
||||
r := Rule{SchedEnabled: true, SchedTZ: "UTC", SchedDays: []string{"fri"}, SchedStart: "22:00", SchedEnd: "06:00"}
|
||||
if !ruleActiveNow(r, at(t, "2026-07-10T23:00:00Z")) { // 2026-07-10 is Friday
|
||||
t.Fatal("Fri 23:00 should be active")
|
||||
}
|
||||
if !ruleActiveNow(r, at(t, "2026-07-11T05:00:00Z")) { // Sat morning belongs to Fri window
|
||||
t.Fatal("Sat 05:00 should be active (Fri window)")
|
||||
}
|
||||
if ruleActiveNow(r, at(t, "2026-07-11T23:00:00Z")) { // Sat evening not in Fri window
|
||||
t.Fatal("Sat 23:00 should be inactive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleActiveNowAllDayByDays(t *testing.T) {
|
||||
r := Rule{SchedEnabled: true, SchedTZ: "UTC", SchedDays: []string{"sat", "sun"}}
|
||||
if !ruleActiveNow(r, at(t, "2026-07-11T03:00:00Z")) { // Sat
|
||||
t.Fatal("Sat 03:00 should be active")
|
||||
}
|
||||
if ruleActiveNow(r, at(t, "2026-07-13T03:00:00Z")) { // Mon
|
||||
t.Fatal("Mon 03:00 should be inactive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleActiveNowMalformedFailOpen(t *testing.T) {
|
||||
r := Rule{SchedEnabled: true, SchedTZ: "UTC", SchedStart: "25:99", SchedEnd: "10:00"}
|
||||
if !ruleActiveNow(r, at(t, "2026-07-09T23:00:00Z")) {
|
||||
t.Fatal("malformed window must fail open (active)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleActiveNowDisabled(t *testing.T) {
|
||||
r := Rule{SchedEnabled: false, SchedStart: "09:00", SchedEnd: "10:00"}
|
||||
if !ruleActiveNow(r, at(t, "2026-07-09T23:00:00Z")) {
|
||||
t.Fatal("schedule disabled => always active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleSignatureFlipsAtBoundary(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Rules = []Rule{{Name: "night", Enabled: true, SchedEnabled: true, SchedTZ: "UTC", SchedStart: "22:30", SchedEnd: "23:00", Target: "block"}}
|
||||
a := scheduleSignature(m, at(t, "2026-07-09T22:29:00Z")) // before start -> inactive
|
||||
b := scheduleSignature(m, at(t, "2026-07-09T22:31:00Z")) // after start -> active
|
||||
if a == b {
|
||||
t.Fatalf("signature must change across the 22:30 boundary: %s == %s", a, b)
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
package main
|
||||
|
||||
// Self-contained parser for proxy share-links (vless/vmess/trojan/ss) into
|
||||
// xray outbound objects. No external deps. Handles modern transports:
|
||||
// tcp/raw, ws, grpc, xhttp, httpupgrade, http(h2) and security none/tls/reality.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Outbound is a JSON object (xray outbound). map keeps us schema-flexible.
|
||||
type Outbound = map[string]any
|
||||
|
||||
// b64decode tries standard, raw, and url-safe base64 (with/without padding).
|
||||
func b64decode(s string) ([]byte, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, enc := range []*base64.Encoding{
|
||||
base64.StdEncoding, base64.RawStdEncoding,
|
||||
base64.URLEncoding, base64.RawURLEncoding,
|
||||
} {
|
||||
if b, err := enc.DecodeString(s); err == nil {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// normalizeTransport maps share-link "type" values to xray network names.
|
||||
// "raw" is the modern rename of "tcp" -> emit "tcp" (identical engine transport).
|
||||
func normalizeTransport(t string) string {
|
||||
switch t {
|
||||
case "", "raw", "tcp":
|
||||
return "tcp"
|
||||
case "ws", "grpc", "xhttp", "httpupgrade", "http", "h2", "quic", "kcp":
|
||||
if t == "h2" {
|
||||
return "http"
|
||||
}
|
||||
return t
|
||||
default:
|
||||
return "tcp" // unknown -> safe default (caller may warn)
|
||||
}
|
||||
}
|
||||
|
||||
// buildStream assembles streamSettings from a network + security + query params.
|
||||
func buildStream(network, security string, q url.Values, fallbackSNI string) map[string]any {
|
||||
ss := map[string]any{"network": network}
|
||||
|
||||
sni := q.Get("sni")
|
||||
if sni == "" {
|
||||
sni = q.Get("host") // some links reuse host as SNI
|
||||
}
|
||||
if sni == "" {
|
||||
sni = fallbackSNI
|
||||
}
|
||||
fp := q.Get("fp")
|
||||
alpn := q.Get("alpn")
|
||||
|
||||
switch security {
|
||||
case "tls":
|
||||
tls := map[string]any{"serverName": sni}
|
||||
if fp != "" {
|
||||
tls["fingerprint"] = fp
|
||||
}
|
||||
if alpn != "" {
|
||||
tls["alpn"] = strings.Split(alpn, ",")
|
||||
}
|
||||
ss["security"] = "tls"
|
||||
ss["tlsSettings"] = tls
|
||||
case "reality":
|
||||
r := map[string]any{
|
||||
"serverName": sni,
|
||||
"publicKey": q.Get("pbk"),
|
||||
}
|
||||
if fp == "" {
|
||||
fp = "chrome"
|
||||
}
|
||||
r["fingerprint"] = fp
|
||||
if sid := q.Get("sid"); sid != "" {
|
||||
r["shortId"] = sid
|
||||
}
|
||||
if spx := q.Get("spx"); spx != "" {
|
||||
r["spiderX"] = spx
|
||||
}
|
||||
ss["security"] = "reality"
|
||||
ss["realitySettings"] = r
|
||||
}
|
||||
|
||||
path := q.Get("path")
|
||||
hostHdr := q.Get("host")
|
||||
switch network {
|
||||
case "ws":
|
||||
w := map[string]any{"path": orDefault(path, "/")}
|
||||
if hostHdr != "" {
|
||||
w["host"] = hostHdr
|
||||
}
|
||||
ss["wsSettings"] = w
|
||||
case "httpupgrade":
|
||||
h := map[string]any{"path": orDefault(path, "/")}
|
||||
if hostHdr != "" {
|
||||
h["host"] = hostHdr
|
||||
}
|
||||
ss["httpupgradeSettings"] = h
|
||||
case "xhttp":
|
||||
x := map[string]any{"path": orDefault(path, "/")}
|
||||
if hostHdr != "" {
|
||||
x["host"] = hostHdr
|
||||
}
|
||||
if mode := q.Get("mode"); mode != "" {
|
||||
x["mode"] = mode
|
||||
}
|
||||
ss["xhttpSettings"] = x
|
||||
case "grpc":
|
||||
ss["grpcSettings"] = map[string]any{"serviceName": q.Get("serviceName")}
|
||||
case "http":
|
||||
hh := map[string]any{}
|
||||
if hostHdr != "" {
|
||||
hh["host"] = strings.Split(hostHdr, ",")
|
||||
}
|
||||
if path != "" {
|
||||
hh["path"] = path
|
||||
}
|
||||
ss["httpSettings"] = hh
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
func orDefault(s, d string) string {
|
||||
if s == "" {
|
||||
return d
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ParseShareLink parses a vless/vmess/trojan/ss URI into an xray outbound with the given tag.
|
||||
func ParseShareLink(uri, tag string) (Outbound, error) {
|
||||
uri = strings.TrimSpace(uri)
|
||||
switch {
|
||||
case strings.HasPrefix(uri, "vless://"):
|
||||
return parseVLESS(uri, tag)
|
||||
case strings.HasPrefix(uri, "vmess://"):
|
||||
return parseVMess(uri, tag)
|
||||
case strings.HasPrefix(uri, "trojan://"):
|
||||
return parseTrojan(uri, tag)
|
||||
case strings.HasPrefix(uri, "ss://"):
|
||||
return parseSS(uri, tag)
|
||||
case strings.HasPrefix(uri, "wireguard://"), strings.HasPrefix(uri, "wg://"):
|
||||
return parseWireguard(uri, tag)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported scheme: %.12s", uri)
|
||||
}
|
||||
}
|
||||
|
||||
func parseVLESS(uri, tag string) (Outbound, error) {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host := u.Hostname()
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil || host == "" {
|
||||
return nil, fmt.Errorf("vless: bad host/port")
|
||||
}
|
||||
uuid := u.User.Username()
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("vless: missing uuid")
|
||||
}
|
||||
q := u.Query()
|
||||
network := normalizeTransport(q.Get("type"))
|
||||
security := q.Get("security")
|
||||
|
||||
user := map[string]any{"id": uuid, "encryption": "none"}
|
||||
if flow := q.Get("flow"); flow != "" {
|
||||
user["flow"] = flow
|
||||
}
|
||||
ob := Outbound{
|
||||
"tag": tag,
|
||||
"protocol": "vless",
|
||||
"settings": map[string]any{"vnext": []any{map[string]any{
|
||||
"address": host, "port": port, "users": []any{user},
|
||||
}}},
|
||||
"streamSettings": buildStream(network, security, q, host),
|
||||
}
|
||||
return ob, nil
|
||||
}
|
||||
|
||||
func parseTrojan(uri, tag string) (Outbound, error) {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host := u.Hostname()
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil || host == "" {
|
||||
return nil, fmt.Errorf("trojan: bad host/port")
|
||||
}
|
||||
pw := u.User.Username()
|
||||
// The WHOLE userinfo is the trojan password. A password with a raw ':'
|
||||
// (legal in userinfo, and url.User leaves it unescaped) parses as
|
||||
// user:pass — stitch the halves back together or the tail is lost.
|
||||
if p, ok := u.User.Password(); ok {
|
||||
pw = pw + ":" + p
|
||||
}
|
||||
q := u.Query()
|
||||
network := normalizeTransport(q.Get("type"))
|
||||
security := q.Get("security")
|
||||
if security == "" {
|
||||
security = "tls" // trojan is TLS by default
|
||||
}
|
||||
ob := Outbound{
|
||||
"tag": tag,
|
||||
"protocol": "trojan",
|
||||
"settings": map[string]any{"servers": []any{map[string]any{
|
||||
"address": host, "port": port, "password": pw,
|
||||
}}},
|
||||
"streamSettings": buildStream(network, security, q, host),
|
||||
}
|
||||
return ob, nil
|
||||
}
|
||||
|
||||
func parseVMess(uri, tag string) (Outbound, error) {
|
||||
raw := strings.TrimPrefix(uri, "vmess://")
|
||||
// strip fragment
|
||||
if i := strings.IndexByte(raw, '#'); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
b, ok := b64decode(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("vmess: base64 decode failed")
|
||||
}
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return nil, fmt.Errorf("vmess: json: %w", err)
|
||||
}
|
||||
gs := func(k string) string {
|
||||
switch x := v[k].(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return strconv.Itoa(int(x))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
host := gs("add")
|
||||
port, _ := strconv.Atoi(gs("port"))
|
||||
if host == "" || port == 0 {
|
||||
return nil, fmt.Errorf("vmess: bad add/port")
|
||||
}
|
||||
aid, _ := strconv.Atoi(gs("aid"))
|
||||
scy := gs("scy")
|
||||
if scy == "" {
|
||||
scy = "auto"
|
||||
}
|
||||
network := normalizeTransport(gs("net"))
|
||||
// Build query-like params from vmess json for buildStream reuse.
|
||||
q := url.Values{}
|
||||
q.Set("path", gs("path"))
|
||||
q.Set("host", gs("host"))
|
||||
q.Set("sni", gs("sni"))
|
||||
q.Set("serviceName", gs("path"))
|
||||
q.Set("alpn", gs("alpn"))
|
||||
security := ""
|
||||
if gs("tls") == "tls" {
|
||||
security = "tls"
|
||||
}
|
||||
ob := Outbound{
|
||||
"tag": tag,
|
||||
"protocol": "vmess",
|
||||
"settings": map[string]any{"vnext": []any{map[string]any{
|
||||
"address": host, "port": port,
|
||||
"users": []any{map[string]any{"id": gs("id"), "alterId": aid, "security": scy}},
|
||||
}}},
|
||||
"streamSettings": buildStream(network, security, q, host),
|
||||
}
|
||||
return ob, nil
|
||||
}
|
||||
|
||||
var ssCiphers = map[string]bool{
|
||||
"aes-128-gcm": true, "aes-256-gcm": true,
|
||||
"chacha20-ietf-poly1305": true, "xchacha20-ietf-poly1305": true,
|
||||
"2022-blake3-aes-128-gcm": true, "2022-blake3-aes-256-gcm": true,
|
||||
"2022-blake3-chacha20-poly1305": true,
|
||||
}
|
||||
|
||||
func parseSS(uri, tag string) (Outbound, error) {
|
||||
raw := strings.TrimPrefix(uri, "ss://")
|
||||
if i := strings.IndexByte(raw, '#'); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if i := strings.IndexByte(raw, '?'); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
var method, password, host string
|
||||
var port int
|
||||
if at := strings.LastIndexByte(raw, '@'); at >= 0 {
|
||||
userinfo := raw[:at]
|
||||
hostport := raw[at+1:]
|
||||
// userinfo may be base64(method:password) or plain method:password
|
||||
plain := true
|
||||
if dec, ok := b64decode(userinfo); ok && strings.Contains(string(dec), ":") {
|
||||
userinfo = string(dec)
|
||||
plain = false
|
||||
}
|
||||
mp := strings.SplitN(userinfo, ":", 2)
|
||||
if len(mp) != 2 {
|
||||
return nil, fmt.Errorf("ss: bad userinfo")
|
||||
}
|
||||
method, password = mp[0], mp[1]
|
||||
if plain {
|
||||
// SIP002: a non-base64 userinfo is percent-encoded (the ':' separator
|
||||
// stays literal, so decode AFTER splitting) — otherwise "p%40ss" would
|
||||
// be taken as the literal password.
|
||||
if d, err := url.PathUnescape(method); err == nil {
|
||||
method = d
|
||||
}
|
||||
if d, err := url.PathUnescape(password); err == nil {
|
||||
password = d
|
||||
}
|
||||
}
|
||||
host, port = splitHostPort(hostport)
|
||||
} else {
|
||||
dec, ok := b64decode(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ss: base64 decode failed")
|
||||
}
|
||||
s := string(dec)
|
||||
at := strings.LastIndexByte(s, '@')
|
||||
if at < 0 {
|
||||
return nil, fmt.Errorf("ss: no @ in decoded")
|
||||
}
|
||||
mp := strings.SplitN(s[:at], ":", 2)
|
||||
if len(mp) != 2 {
|
||||
return nil, fmt.Errorf("ss: bad userinfo")
|
||||
}
|
||||
method, password = mp[0], mp[1]
|
||||
host, port = splitHostPort(s[at+1:])
|
||||
}
|
||||
if host == "" || port == 0 {
|
||||
return nil, fmt.Errorf("ss: bad host/port")
|
||||
}
|
||||
if !ssCiphers[method] {
|
||||
return nil, fmt.Errorf("ss: unsupported cipher %q", method)
|
||||
}
|
||||
ob := Outbound{
|
||||
"tag": tag,
|
||||
"protocol": "shadowsocks",
|
||||
"settings": map[string]any{"servers": []any{map[string]any{
|
||||
"address": host, "port": port, "method": method, "password": password,
|
||||
}}},
|
||||
}
|
||||
return ob, nil
|
||||
}
|
||||
|
||||
func splitHostPort(hp string) (string, int) {
|
||||
// strip trailing path if any
|
||||
if i := strings.IndexByte(hp, '/'); i >= 0 {
|
||||
hp = hp[:i]
|
||||
}
|
||||
// Bracketed IPv6 ("[2001:db8::1]:8388"): return the BARE literal — the
|
||||
// brackets are URI syntax, and xray's "address" field must not carry them.
|
||||
if strings.HasPrefix(hp, "[") {
|
||||
if j := strings.IndexByte(hp, ']'); j >= 0 {
|
||||
port := 0
|
||||
if rest := hp[j+1:]; strings.HasPrefix(rest, ":") {
|
||||
port, _ = strconv.Atoi(rest[1:])
|
||||
}
|
||||
return hp[1:j], port
|
||||
}
|
||||
}
|
||||
c := strings.LastIndexByte(hp, ':')
|
||||
if c < 0 {
|
||||
return hp, 0
|
||||
}
|
||||
host := hp[:c]
|
||||
port, _ := strconv.Atoi(hp[c+1:])
|
||||
return host, port
|
||||
}
|
||||
|
||||
// joinHostPort is net.JoinHostPort (which brackets IPv6 literals) with
|
||||
// tolerance for a host that already arrives bracketed — JoinHostPort would
|
||||
// otherwise double-bracket it into "[[::1]]:443".
|
||||
func joinHostPort(host, port string) string {
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseVLESSReality(t *testing.T) {
|
||||
uri := "vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=PBK&sid=aa&sni=www.microsoft.com&flow=xtls-rprx-vision&fp=chrome#nl"
|
||||
ob, err := ParseShareLink(uri, "nl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ob["protocol"] != "vless" {
|
||||
t.Fatalf("protocol = %v", ob["protocol"])
|
||||
}
|
||||
ss := ob["streamSettings"].(map[string]any)
|
||||
if ss["security"] != "reality" {
|
||||
t.Fatalf("security = %v", ss["security"])
|
||||
}
|
||||
r := ss["realitySettings"].(map[string]any)
|
||||
if r["publicKey"] != "PBK" || r["serverName"] != "www.microsoft.com" {
|
||||
t.Fatalf("reality settings = %v", r)
|
||||
}
|
||||
vnext := ob["settings"].(map[string]any)["vnext"].([]any)
|
||||
u := vnext[0].(map[string]any)["users"].([]any)[0].(map[string]any)
|
||||
if u["flow"] != "xtls-rprx-vision" {
|
||||
t.Fatalf("flow = %v", u["flow"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVLESSXHTTP(t *testing.T) {
|
||||
uri := "vless://22222222-2222-2222-2222-222222222222@example.org:8443?type=xhttp&security=tls&path=/xh&host=cdn.example.org&sni=cdn.example.org#x"
|
||||
ob, err := ParseShareLink(uri, "x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ss := ob["streamSettings"].(map[string]any)
|
||||
if ss["network"] != "xhttp" {
|
||||
t.Fatalf("network = %v", ss["network"])
|
||||
}
|
||||
xh := ss["xhttpSettings"].(map[string]any)
|
||||
if xh["path"] != "/xh" {
|
||||
t.Fatalf("xhttp path = %v", xh["path"])
|
||||
}
|
||||
if ss["security"] != "tls" {
|
||||
t.Fatalf("security = %v", ss["security"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVMessWS(t *testing.T) {
|
||||
// base64 of a vmess ws+tls json
|
||||
uri := "vmess://eyJ2IjoiMiIsInBzIjoidm1lc3MtdyIsImFkZCI6ImV4YW1wbGUubmV0IiwicG9ydCI6IjQ0MyIsImlkIjoiMzMzMzMzMzMtMzMzMy0zMzMzLTMzMzMtMzMzMzMzMzMzMzMzIiwiYWlkIjoiMCIsInNjeSI6ImF1dG8iLCJuZXQiOiJ3cyIsImhvc3QiOiJleGFtcGxlLm5ldCIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMifQ=="
|
||||
ob, err := ParseShareLink(uri, "w")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ob["protocol"] != "vmess" {
|
||||
t.Fatalf("protocol = %v", ob["protocol"])
|
||||
}
|
||||
ss := ob["streamSettings"].(map[string]any)
|
||||
if ss["network"] != "ws" || ss["security"] != "tls" {
|
||||
t.Fatalf("stream = %v", ss)
|
||||
}
|
||||
ws := ss["wsSettings"].(map[string]any)
|
||||
if ws["path"] != "/ws" {
|
||||
t.Fatalf("ws path = %v", ws["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrojan(t *testing.T) {
|
||||
ob, err := ParseShareLink("trojan://pw@example.com:443?security=tls&sni=example.com&type=tcp#t", "t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ob["protocol"] != "trojan" {
|
||||
t.Fatalf("protocol = %v", ob["protocol"])
|
||||
}
|
||||
srv := ob["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||||
if srv["password"] != "pw" || srv["port"] != 443 {
|
||||
t.Fatalf("server = %v", srv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSS(t *testing.T) {
|
||||
// base64(method:password)@host:port
|
||||
ob, err := ParseShareLink("ss://YWVzLTI1Ni1nY206c2VjcmV0QGV4YW1wbGUuY29tOjg0NDM=#s", "s")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ob["protocol"] != "shadowsocks" {
|
||||
t.Fatalf("protocol = %v", ob["protocol"])
|
||||
}
|
||||
srv := ob["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||||
if srv["method"] != "aes-256-gcm" || srv["password"] != "secret" || srv["port"] != 8443 {
|
||||
t.Fatalf("server = %v", srv)
|
||||
}
|
||||
}
|
||||
|
||||
// SIP002: a plain (non-base64) userinfo is percent-encoded — "p%40ss" is the
|
||||
// password "p@ss", not the literal string.
|
||||
func TestParseSSPlainUserinfoPercentEncoded(t *testing.T) {
|
||||
ob, err := ParseShareLink("ss://aes-256-gcm:p%40ss%23w@host.example.com:8388#x", "s")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := ob["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||||
if srv["password"] != "p@ss#w" {
|
||||
t.Fatalf("password not percent-decoded: %v", srv["password"])
|
||||
}
|
||||
if srv["method"] != "aes-256-gcm" || srv["port"] != 8388 {
|
||||
t.Fatalf("server = %v", srv)
|
||||
}
|
||||
}
|
||||
|
||||
// A bracketed IPv6 host must lose its brackets: they are URI syntax, and
|
||||
// xray's "address" field wants the bare literal.
|
||||
func TestParseSSBracketedIPv6(t *testing.T) {
|
||||
ui := base64.RawURLEncoding.EncodeToString([]byte("aes-256-gcm:pw"))
|
||||
ob, err := ParseShareLink("ss://"+ui+"@[2001:db8::1]:8388#v6", "s")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := ob["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||||
if srv["address"] != "2001:db8::1" || srv["port"] != 8388 {
|
||||
t.Fatalf("IPv6 host/port mangled: %v/%v", srv["address"], srv["port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintStable(t *testing.T) {
|
||||
uri := "vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=PBK&sid=aa&sni=www.microsoft.com#a"
|
||||
a, _ := ParseShareLink(uri, "a")
|
||||
// same connection, different display name -> same fingerprint
|
||||
b, _ := ParseShareLink(uri, "b-renamed")
|
||||
if Fingerprint(a) != Fingerprint(b) {
|
||||
t.Fatalf("fingerprint not stable across rename")
|
||||
}
|
||||
if Fingerprint(a) == "" {
|
||||
t.Fatal("empty fingerprint")
|
||||
}
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
package main
|
||||
|
||||
// Read-side commands: status / nodes / stats / explain, all emitting JSON.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StatusJSON reports overall health.
|
||||
func StatusJSON() ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
m = &Model{Globals: defaultGlobals()}
|
||||
}
|
||||
obs := obsGather()
|
||||
st := map[string]any{
|
||||
"enabled": m.Globals.Enabled,
|
||||
"xray_up": xrayRunning(),
|
||||
"xray_version": xrayVersion(),
|
||||
"compat_ok": compatOK(),
|
||||
"api_up": obs.Reachable,
|
||||
"api_port": obs.APIPort,
|
||||
"dns_mode": m.Globals.DNSMode,
|
||||
"kill": m.Globals.KillSwitch,
|
||||
"inbounds": len(m.Inbounds),
|
||||
"groups": len(m.Groups),
|
||||
"chains": len(m.Chains),
|
||||
"rules": len(m.Rules),
|
||||
"run_json": fileExists(runJSON),
|
||||
"last_good": fileExists(lastGood),
|
||||
"pending": fileExists(pendingFlag),
|
||||
"nft_loaded": nftTableExists(),
|
||||
"drift": apDrifted(m),
|
||||
}
|
||||
return json.MarshalIndent(st, "", " ")
|
||||
}
|
||||
|
||||
// Probe-liveness cache. NodesJSON's per-endpoint TCP dial is the expensive part:
|
||||
// a full subscription is easily hundreds of nodes and each dial blocks up to the
|
||||
// stProbe timeout, so one sweep takes several seconds. The dashboard polls the
|
||||
// node list every few seconds, so dialing on every call would stack multi-second
|
||||
// invocations and starve the router (this was the "everything looks down" flash
|
||||
// after a page reload). Instead the last sweep is cached on disk and refreshed at
|
||||
// most once per nodesProbeTTL, single-flighted with a non-blocking lock so a slow
|
||||
// sweep is never run by two callers at once — late callers just serve the cache.
|
||||
var (
|
||||
nodesProbeCache = "/var/run/shater/nodes_probe.json"
|
||||
nodesProbeLock = "/var/run/shater/nodes_probe.lock"
|
||||
)
|
||||
|
||||
const nodesProbeTTL = 45 * time.Second
|
||||
|
||||
type probeRec struct {
|
||||
Alive bool `json:"alive"`
|
||||
MS int `json:"ms"`
|
||||
}
|
||||
type probeCacheFile struct {
|
||||
TS int64 `json:"ts"`
|
||||
Nodes map[string]probeRec `json:"nodes"` // keyed by node name
|
||||
}
|
||||
|
||||
func loadProbeCache() probeCacheFile {
|
||||
var c probeCacheFile
|
||||
if b, err := os.ReadFile(nodesProbeCache); err == nil {
|
||||
_ = json.Unmarshal(b, &c)
|
||||
}
|
||||
if c.Nodes == nil {
|
||||
c.Nodes = map[string]probeRec{}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func saveProbeCache(c probeCacheFile) {
|
||||
if os.MkdirAll("/var/run/shater", 0o755) != nil {
|
||||
return
|
||||
}
|
||||
if b, err := json.Marshal(c); err == nil {
|
||||
tmp := nodesProbeCache + ".tmp"
|
||||
if os.WriteFile(tmp, b, 0o644) == nil {
|
||||
_ = os.Rename(tmp, nodesProbeCache)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sweepProbes dials every node's endpoint (bounded fan-out) and returns a fresh
|
||||
// liveness cache. addrs[i]/ports[i] correspond to names[i].
|
||||
func sweepProbes(names, addrs []string, ports []int) probeCacheFile {
|
||||
c := probeCacheFile{Nodes: make(map[string]probeRec, len(names))}
|
||||
var mu sync.Mutex
|
||||
sem := make(chan struct{}, 32)
|
||||
var wg sync.WaitGroup
|
||||
for i := range names {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(name, a string, p int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
alive, ms, _ := stProbe(a, p)
|
||||
mu.Lock()
|
||||
c.Nodes[name] = probeRec{Alive: alive, MS: ms}
|
||||
mu.Unlock()
|
||||
}(names[i], addrs[i], ports[i])
|
||||
}
|
||||
wg.Wait()
|
||||
c.TS = time.Now().Unix()
|
||||
return c
|
||||
}
|
||||
|
||||
// NodesJSON lists all nodes (manual + subscription cache) with liveness fields.
|
||||
// Liveness is merged from xray's API (observatory-selected member + traffic
|
||||
// counters, always fresh) and, for latency and as a fallback, a cached endpoint
|
||||
// probe (see the probe cache above). force=true runs a fresh sweep regardless of
|
||||
// cache age (the `nodes probe` subcommand); the dashboard's plain `nodes` call
|
||||
// serves the cache and stays fast.
|
||||
func NodesJSON(force bool) ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
obs := obsGather()
|
||||
tags := stNodeTags(m)
|
||||
|
||||
type nodeOut struct {
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group"`
|
||||
Proto string `json:"proto"`
|
||||
Tag string `json:"tag"`
|
||||
Alive bool `json:"alive"`
|
||||
Selected bool `json:"selected"`
|
||||
LatencyMS int `json:"latency_ms"`
|
||||
Uplink int64 `json:"uplink"`
|
||||
Downlink int64 `json:"downlink"`
|
||||
Source string `json:"source"` // observatory|stats|probe
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Stale bool `json:"stale"`
|
||||
URI string `json:"uri"` // share-link (for QR / link export)
|
||||
}
|
||||
|
||||
// Cheap pass: identity + observatory/stats-derived liveness (no dialing).
|
||||
out := make([]nodeOut, len(m.Nodes))
|
||||
addrs := make([]string, len(m.Nodes))
|
||||
ports := make([]int, len(m.Nodes))
|
||||
names := make([]string, len(m.Nodes))
|
||||
for i, n := range m.Nodes {
|
||||
proto, fp := "", n.Fingerprint
|
||||
var addr string
|
||||
var port int
|
||||
if ob, perr := ParseShareLink(n.URI, n.Name); perr == nil {
|
||||
proto, _ = ob["protocol"].(string)
|
||||
if fp == "" {
|
||||
fp = Fingerprint(ob)
|
||||
}
|
||||
addr, port, _, _, _, _, _ = extractIdentity(ob)
|
||||
}
|
||||
names[i], addrs[i], ports[i] = n.Name, addr, port
|
||||
tag := tags[n.Name]
|
||||
traffic := obs.Stats[tag]
|
||||
selected := tag != "" && obs.Selected[tag]
|
||||
|
||||
rec := nodeOut{
|
||||
Name: n.Name, Group: n.FromSub, Proto: proto, Tag: tag,
|
||||
Selected: selected, Uplink: traffic.Up, Downlink: traffic.Down,
|
||||
Fingerprint: fp, Stale: n.Stale, URI: n.URI,
|
||||
}
|
||||
// Decide alive + source from the strongest available signal.
|
||||
switch {
|
||||
case selected:
|
||||
rec.Alive, rec.Source = true, "observatory"
|
||||
case traffic.Up+traffic.Down > 0:
|
||||
rec.Alive, rec.Source = true, "stats"
|
||||
}
|
||||
out[i] = rec
|
||||
}
|
||||
|
||||
// Probe layer: refresh the cached sweep at most once per TTL (or when forced),
|
||||
// single-flighted so a slow sweep never stacks; otherwise reuse the cache.
|
||||
cache := loadProbeCache()
|
||||
if force || cache.TS == 0 || time.Since(time.Unix(cache.TS, 0)) > nodesProbeTTL {
|
||||
if release, ok, _ := lockTry(nodesProbeLock); ok {
|
||||
cache = sweepProbes(names, addrs, ports)
|
||||
saveProbeCache(cache)
|
||||
release()
|
||||
}
|
||||
// !ok: another sweep is already in flight — fall through with the old cache.
|
||||
}
|
||||
for i := range out {
|
||||
if pr, ok := cache.Nodes[out[i].Name]; ok {
|
||||
out[i].LatencyMS = pr.MS
|
||||
if out[i].Source == "" {
|
||||
if pr.Alive {
|
||||
out[i].Alive = true
|
||||
}
|
||||
out[i].Source = "probe"
|
||||
}
|
||||
}
|
||||
}
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
// NodeTest probes nodes' reachability (TCP connect to addr:port) and latency.
|
||||
func NodeTest(name string) ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
type res struct {
|
||||
Name string `json:"name"`
|
||||
Alive bool `json:"alive"`
|
||||
LatencyMS int `json:"latency_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
// Collect the nodes to probe first (preserving order for a stable result).
|
||||
var todo []Node
|
||||
for _, n := range m.Nodes {
|
||||
if name != "" && n.Name != name {
|
||||
continue
|
||||
}
|
||||
todo = append(todo, n)
|
||||
}
|
||||
out := make([]res, len(todo))
|
||||
// Probe concurrently (bounded): a "Test all" over hundreds of nodes with a 5s
|
||||
// dial timeout would otherwise take many minutes done serially.
|
||||
sem := make(chan struct{}, 32)
|
||||
var wg sync.WaitGroup
|
||||
for i, n := range todo {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, n Node) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
r := res{Name: n.Name}
|
||||
ob, err := ParseShareLink(n.URI, n.Name)
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
out[i] = r
|
||||
return
|
||||
}
|
||||
addr, port, _, _, _, _, _ := extractIdentity(ob)
|
||||
alive, ms, e := tcpProbe(addr, port)
|
||||
r.Alive, r.LatencyMS = alive, ms
|
||||
if e != nil {
|
||||
r.Error = e.Error()
|
||||
}
|
||||
out[i] = r
|
||||
}(i, n)
|
||||
}
|
||||
wg.Wait()
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
// StatsJSON reports the live traffic breakdown the LuCI dashboard reads:
|
||||
// - nodes: per-outbound bytes from xray StatsService.
|
||||
// - clients: per-LAN-source bytes from the nft `clients`/`clients6` dynamic sets.
|
||||
// - rules: per-rule diverted bytes from the nft `c_rule_*` named counters.
|
||||
// - inbounds: per-inbound catch-all bytes from the nft `c_in_*` named counters.
|
||||
func StatsJSON() ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
m = &Model{Globals: defaultGlobals()}
|
||||
} else {
|
||||
loadCacheNodesInto(m)
|
||||
}
|
||||
obs := obsGather()
|
||||
tag2node, tag2group := stTagIndex(m)
|
||||
|
||||
type nodeStat struct {
|
||||
Tag string `json:"tag"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Uplink int64 `json:"uplink"`
|
||||
Downlink int64 `json:"downlink"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
nodes := []nodeStat{}
|
||||
for tag, t := range obs.Stats {
|
||||
nodes = append(nodes, nodeStat{
|
||||
Tag: tag, Name: tag2node[tag], Group: tag2group[tag],
|
||||
Uplink: t.Up, Downlink: t.Down, Total: t.Up + t.Down,
|
||||
})
|
||||
}
|
||||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].Tag < nodes[j].Tag })
|
||||
|
||||
clients := stClients(nftListClients(nftClient4))
|
||||
clients6 := stClients(nftListClients(nftClient6))
|
||||
|
||||
counters := nftListCounters()
|
||||
type ruleStat struct {
|
||||
Name string `json:"name"`
|
||||
Counter string `json:"counter"`
|
||||
Packets int64 `json:"packets"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
rules := []ruleStat{}
|
||||
inbounds := []ruleStat{}
|
||||
for name, c := range counters {
|
||||
rs := ruleStat{Counter: name, Packets: c.Packets, Bytes: c.Bytes}
|
||||
switch {
|
||||
case strings.HasPrefix(name, "c_rule_"):
|
||||
rs.Name = strings.TrimPrefix(name, "c_rule_")
|
||||
rules = append(rules, rs)
|
||||
case strings.HasPrefix(name, "c_in_"):
|
||||
rs.Name = strings.TrimPrefix(name, "c_in_")
|
||||
inbounds = append(inbounds, rs)
|
||||
}
|
||||
}
|
||||
sort.Slice(rules, func(i, j int) bool { return rules[i].Counter < rules[j].Counter })
|
||||
sort.Slice(inbounds, func(i, j int) bool { return inbounds[i].Counter < inbounds[j].Counter })
|
||||
|
||||
stats := map[string]any{
|
||||
"api": obs.Reachable,
|
||||
"api_port": obs.APIPort,
|
||||
"nodes": nodes,
|
||||
"clients": clients,
|
||||
"clients6": clients6,
|
||||
"rules": rules,
|
||||
"inbounds": inbounds,
|
||||
}
|
||||
return json.MarshalIndent(stats, "", " ")
|
||||
}
|
||||
|
||||
// stClients converts an nft set counter map into a sorted client list.
|
||||
func stClients(in map[string]nftCount) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(in))
|
||||
for ip, c := range in {
|
||||
out = append(out, map[string]any{"ip": ip, "packets": c.Packets, "bytes": c.Bytes})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["bytes"].(int64) > out[j]["bytes"].(int64)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// stNodeTags maps each node name to its resolved xray outbound tag
|
||||
// (groupMemberTag under the group's prefix for members, else node_<name> when
|
||||
// referenced). MUST mirror emitGroup's tagging (generate.go) or the stats/
|
||||
// observatory merge silently stops matching.
|
||||
func stNodeTags(m *Model) map[string]string {
|
||||
tags := map[string]string{}
|
||||
b := &builder{m: m}
|
||||
for _, g := range m.Groups {
|
||||
prefix := groupPrefix(g.Name)
|
||||
for i, nd := range b.resolveGroupMembers(g) {
|
||||
if _, seen := tags[nd.Name]; !seen {
|
||||
tags[nd.Name] = groupMemberTag(prefix, i, nd.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nodes referenced directly as node:<name>.
|
||||
referenced := map[string]bool{}
|
||||
for _, r := range m.Rules {
|
||||
if k, n := splitTarget(r.Target); k == "node" {
|
||||
referenced[n] = true
|
||||
}
|
||||
}
|
||||
for _, c := range m.Chains {
|
||||
for _, h := range c.Hops {
|
||||
if k, n := splitTarget(h); k == "node" {
|
||||
referenced[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range m.Nodes {
|
||||
if _, ok := tags[n.Name]; !ok && referenced[n.Name] {
|
||||
tags[n.Name] = "node_" + sanitize(n.Name)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// stTagIndex builds reverse maps tag->node-name and tag->group for annotation.
|
||||
func stTagIndex(m *Model) (name, group map[string]string) {
|
||||
name, group = map[string]string{}, map[string]string{}
|
||||
b := &builder{m: m}
|
||||
for _, g := range m.Groups {
|
||||
prefix := groupPrefix(g.Name)
|
||||
for i, nd := range b.resolveGroupMembers(g) {
|
||||
tag := groupMemberTag(prefix, i, nd.Name)
|
||||
name[tag] = nd.Name
|
||||
group[tag] = g.Name
|
||||
}
|
||||
}
|
||||
for _, n := range m.Nodes {
|
||||
tag := "node_" + sanitize(n.Name)
|
||||
if _, ok := name[tag]; !ok {
|
||||
name[tag] = n.Name
|
||||
}
|
||||
}
|
||||
return name, group
|
||||
}
|
||||
|
||||
// stProbe is a short-timeout endpoint reachability/latency probe.
|
||||
func stProbe(addr string, port int) (bool, int, error) {
|
||||
if addr == "" || port == 0 {
|
||||
return false, 0, fmt.Errorf("no address")
|
||||
}
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(addr, fmt.Sprintf("%d", port)), 2*time.Second)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true, int(time.Since(start).Milliseconds()), nil
|
||||
}
|
||||
|
||||
// ExplainJSON evaluates the rule set for a src/dst and reports the decision path.
|
||||
func ExplainJSON(src, dst, proto string) ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadCacheNodesInto(m)
|
||||
rules := sortedRules(m)
|
||||
dstIsIP := net.ParseIP(dst) != nil
|
||||
for _, r := range rules {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
if !matchSrc(r.Src, src) {
|
||||
continue
|
||||
}
|
||||
if !matchProto(r.Proto, proto) {
|
||||
continue
|
||||
}
|
||||
if !matchDst(m, r, dst, dstIsIP) {
|
||||
continue
|
||||
}
|
||||
return json.MarshalIndent(map[string]any{
|
||||
"src": src, "dst": dst, "proto": defProto(proto),
|
||||
"matched_rule": orDefault(r.Name, fmt.Sprintf("order-%d", r.Order)),
|
||||
"target": r.Target,
|
||||
"egress": r.Egress,
|
||||
"kill": r.Kill,
|
||||
}, "", " ")
|
||||
}
|
||||
return json.MarshalIndent(map[string]any{
|
||||
"src": src, "dst": dst, "proto": defProto(proto),
|
||||
"matched_rule": nil, "target": "direct",
|
||||
"note": "no rule matched; default outbound (direct)",
|
||||
}, "", " ")
|
||||
}
|
||||
|
||||
// --- matching helpers (mirror the generator's semantics, first-match) ---
|
||||
|
||||
func sortedRules(m *Model) []Rule {
|
||||
rules := append([]Rule(nil), m.Rules...)
|
||||
for i := 1; i < len(rules); i++ {
|
||||
for j := i; j > 0 && rules[j-1].Order > rules[j].Order; j-- {
|
||||
rules[j-1], rules[j] = rules[j], rules[j-1]
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func matchSrc(srcs []string, src string) bool {
|
||||
if len(srcs) == 0 {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(src)
|
||||
for _, s := range srcs {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == src {
|
||||
return true
|
||||
}
|
||||
if _, cidr, err := net.ParseCIDR(s); err == nil && ip != nil && cidr.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchProto(ruleProto, proto string) bool {
|
||||
if ruleProto == "" || proto == "" {
|
||||
return true
|
||||
}
|
||||
for _, p := range strings.Split(ruleProto, ",") {
|
||||
if strings.TrimSpace(p) == proto {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchDst(m *Model, r Rule, dst string, dstIsIP bool) bool {
|
||||
// A rule with no dst constraints matches everything.
|
||||
if len(r.DstDomain) == 0 && len(r.DstIP) == 0 && len(r.DstRuleset) == 0 {
|
||||
return true
|
||||
}
|
||||
if dstIsIP {
|
||||
ip := net.ParseIP(dst)
|
||||
for _, c := range r.DstIP {
|
||||
if _, cidr, err := net.ParseCIDR(strings.TrimSpace(c)); err == nil && cidr.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, rs := range r.DstRuleset {
|
||||
_, ips := (&builder{m: m}).expandRuleset(rs)
|
||||
for _, c := range ips {
|
||||
if _, cidr, err := net.ParseCIDR(strings.TrimSpace(c)); err == nil && cidr.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, d := range r.DstDomain {
|
||||
if domainMatch(d, dst) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, rs := range r.DstRuleset {
|
||||
doms, _ := (&builder{m: m}).expandRuleset(rs)
|
||||
for _, d := range doms {
|
||||
if domainMatch(d, dst) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func domainMatch(pattern, host string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case strings.HasPrefix(pattern, "geosite:"), strings.HasPrefix(pattern, "geoip:"):
|
||||
return false // requires geodata; not resolvable offline in explain
|
||||
case strings.HasPrefix(pattern, "full:"):
|
||||
return host == strings.TrimPrefix(pattern, "full:")
|
||||
case strings.HasPrefix(pattern, "domain:"):
|
||||
p := strings.TrimPrefix(pattern, "domain:")
|
||||
return host == p || strings.HasSuffix(host, "."+p)
|
||||
default:
|
||||
return host == pattern || strings.HasSuffix(host, "."+pattern) || strings.Contains(host, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
func defProto(p string) string {
|
||||
if p == "" {
|
||||
return "tcp"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// --- misc ---
|
||||
|
||||
func xrayRunning() bool {
|
||||
// `pidof xray` matches the exact program name (not the substring, so it never
|
||||
// counts `xrayctl`) and — unlike busybox `pgrep -x xray`, which matches nothing
|
||||
// here despite comm=="xray" — reliably detects our supervised engine.
|
||||
out, err := exec.Command("pidof", "xray").Output()
|
||||
return err == nil && len(strings.TrimSpace(string(out))) > 0
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// tcpProbe attempts a TCP connect and returns liveness + latency in ms.
|
||||
func tcpProbe(addr string, port int) (bool, int, error) {
|
||||
if addr == "" || port == 0 {
|
||||
return false, 0, fmt.Errorf("no address")
|
||||
}
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(addr, fmt.Sprintf("%d", port)), 5*time.Second)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true, int(time.Since(start).Milliseconds()), nil
|
||||
}
|
||||
-818
@@ -1,818 +0,0 @@
|
||||
package main
|
||||
|
||||
// Subscription handling: fetch (HAPP header emulation), parse links, compute a
|
||||
// stable per-node fingerprint, reconcile against the on-disk cache, and persist.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const subCacheDir = "/etc/xray/subs"
|
||||
const hwidStateDir = "/etc/xray/state"
|
||||
|
||||
// Local xray inbounds used when subscription.fetch_via=proxy, so a blocked
|
||||
// subscription host can be reached through the tunnel.
|
||||
const subProxySocks = "127.0.0.1:10808" // local socks5 inbound
|
||||
const subProxyHTTP = "127.0.0.1:10809" // local http inbound (reserved/fallback)
|
||||
|
||||
// CachedNode is one node persisted in a subscription cache file.
|
||||
type CachedNode struct {
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Stale bool `json:"stale"`
|
||||
StaleCount int `json:"stale_count"`
|
||||
FirstSeen int64 `json:"first_seen"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
// SubCache is the persisted state for one subscription.
|
||||
type SubCache struct {
|
||||
Name string `json:"name"`
|
||||
Updated int64 `json:"updated"`
|
||||
Nodes []CachedNode `json:"nodes"`
|
||||
Userinfo *SubUserinfo `json:"userinfo,omitempty"` // parsed subscription-userinfo header
|
||||
}
|
||||
|
||||
// SubUserinfo is the parsed `subscription-userinfo` response header: traffic
|
||||
// counters (bytes) and expiry, plus derived remaining traffic.
|
||||
type SubUserinfo struct {
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
Total int64 `json:"total"`
|
||||
Expire int64 `json:"expire"` // unix seconds; 0 = no expiry
|
||||
Remaining int64 `json:"remaining"` // total-(upload+download), clamped >=0
|
||||
Raw string `json:"raw,omitempty"` // original header value
|
||||
FetchedAt int64 `json:"fetched_at,omitempty"`
|
||||
}
|
||||
|
||||
const maxStaleRefreshes = 3 // keep a vanished node this many updates before removal
|
||||
|
||||
// Fingerprint computes a stable hash over connection-defining fields of an
|
||||
// outbound, so a node keeps its identity across subscription refreshes even if
|
||||
// its display name changes.
|
||||
func Fingerprint(ob Outbound) string {
|
||||
proto, _ := ob["protocol"].(string)
|
||||
addr, port, id, net, sec, sni, path := extractIdentity(ob)
|
||||
key := strings.Join([]string{proto, addr, strconv.Itoa(port), id, net, sec, sni, path}, "|")
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func extractIdentity(ob Outbound) (addr string, port int, id, net, sec, sni, path string) {
|
||||
settings, _ := ob["settings"].(map[string]any)
|
||||
if settings != nil {
|
||||
if vnext, ok := settings["vnext"].([]any); ok && len(vnext) > 0 {
|
||||
if s, ok := vnext[0].(map[string]any); ok {
|
||||
addr, _ = s["address"].(string)
|
||||
port = toInt(s["port"])
|
||||
if users, ok := s["users"].([]any); ok && len(users) > 0 {
|
||||
if u, ok := users[0].(map[string]any); ok {
|
||||
id, _ = u["id"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if servers, ok := settings["servers"].([]any); ok && len(servers) > 0 {
|
||||
if s, ok := servers[0].(map[string]any); ok {
|
||||
addr, _ = s["address"].(string)
|
||||
port = toInt(s["port"])
|
||||
if pw, ok := s["password"].(string); ok {
|
||||
id = pw
|
||||
}
|
||||
}
|
||||
}
|
||||
// WireGuard: identity = peer endpoint + public key (there is no vnext/servers).
|
||||
if peers, ok := settings["peers"].([]any); ok && len(peers) > 0 {
|
||||
if p, ok := peers[0].(map[string]any); ok {
|
||||
if ep, _ := p["endpoint"].(string); ep != "" {
|
||||
h, ps := splitEndpoint(ep)
|
||||
addr = h
|
||||
port = toInt(ps)
|
||||
}
|
||||
id, _ = p["publicKey"].(string)
|
||||
}
|
||||
net = "wireguard"
|
||||
}
|
||||
}
|
||||
ss, _ := ob["streamSettings"].(map[string]any)
|
||||
if ss != nil {
|
||||
net, _ = ss["network"].(string)
|
||||
sec, _ = ss["security"].(string)
|
||||
if tls, ok := ss["tlsSettings"].(map[string]any); ok {
|
||||
sni, _ = tls["serverName"].(string)
|
||||
}
|
||||
if r, ok := ss["realitySettings"].(map[string]any); ok {
|
||||
sni, _ = r["serverName"].(string)
|
||||
}
|
||||
for _, k := range []string{"wsSettings", "xhttpSettings", "httpupgradeSettings", "grpcSettings"} {
|
||||
if t, ok := ss[k].(map[string]any); ok {
|
||||
if p, ok := t["path"].(string); ok && p != "" {
|
||||
path = p
|
||||
}
|
||||
if p, ok := t["serviceName"].(string); ok && p != "" {
|
||||
path = p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case float64:
|
||||
return int(x)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(x)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FetchSubscription downloads and returns the list of share-links for a sub,
|
||||
// emulating HAPP client headers as configured. Retained for existing callers;
|
||||
// delegates to FetchSubscriptionFull and drops the userinfo.
|
||||
func FetchSubscription(s Subscription) ([]string, error) {
|
||||
links, _, err := FetchSubscriptionFull(s)
|
||||
return links, err
|
||||
}
|
||||
|
||||
// FetchSubscriptionFull downloads a subscription and returns its share-links
|
||||
// plus the parsed subscription-userinfo header (nil if absent). It honours
|
||||
// subscription.fetch_via: "proxy" routes the request through the local xray
|
||||
// socks inbound so a blocked subscription host still resolves; anything else
|
||||
// fetches directly.
|
||||
func FetchSubscriptionFull(s Subscription) ([]string, *SubUserinfo, error) {
|
||||
req, err := http.NewRequest("GET", s.URL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
applyHappHeaders(req, s)
|
||||
resp, err := subFetchClient(s).Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("subscription %q: HTTP %d", s.Name, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ui := subParseUserinfo(resp.Header.Get("Subscription-Userinfo"))
|
||||
return ParseSubBody(body, resp.Header.Get("Content-Type"), s.Format), ui, nil
|
||||
}
|
||||
|
||||
// subFetchClient builds the HTTP client for a subscription fetch. When
|
||||
// fetch_via=proxy every connection is dialed through the local xray socks5
|
||||
// inbound; otherwise a plain direct client is returned.
|
||||
func subFetchClient(s Subscription) *http.Client {
|
||||
if strings.EqualFold(s.FetchVia, "proxy") {
|
||||
tr := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return subSocks5Dial(ctx, subProxySocks, host, port)
|
||||
},
|
||||
}
|
||||
return &http.Client{Timeout: 30 * time.Second, Transport: tr}
|
||||
}
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
// subSocks5Dial opens a TCP connection to host:port through a no-auth SOCKS5
|
||||
// proxy (RFC 1928). Kept dependency-free so xrayctl needs no external modules;
|
||||
// the target is sent as a domain so DNS is resolved proxy-side (no leak).
|
||||
func subSocks5Dial(ctx context.Context, proxyAddr, host string, port int) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 15 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", proxyAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fail := func(e error) (net.Conn, error) { conn.Close(); return nil, e }
|
||||
|
||||
// The whole handshake runs under a deadline (the caller's ctx deadline if
|
||||
// sooner, else the dial timeout): ctx cancellation alone does not unblock
|
||||
// an in-flight conn read, so a proxy that accepts but never replies would
|
||||
// otherwise hang the fetch forever.
|
||||
dl := time.Now().Add(15 * time.Second)
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(dl) {
|
||||
dl = d
|
||||
}
|
||||
if err := conn.SetDeadline(dl); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
// Greeting: version 5, one method, no-auth (0x00).
|
||||
if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
sel := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, sel); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if sel[0] != 0x05 || sel[1] != 0x00 {
|
||||
return fail(fmt.Errorf("socks5: no acceptable auth method (0x%02x)", sel[1]))
|
||||
}
|
||||
|
||||
// CONNECT request with a domain-name address (ATYP 0x03).
|
||||
if len(host) > 255 {
|
||||
return fail(fmt.Errorf("socks5: host too long"))
|
||||
}
|
||||
req := []byte{0x05, 0x01, 0x00, 0x03, byte(len(host))}
|
||||
req = append(req, host...)
|
||||
req = append(req, byte(port>>8), byte(port))
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
// Reply: VER REP RSV ATYP BND.ADDR BND.PORT — REP 0x00 = success.
|
||||
head := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if head[1] != 0x00 {
|
||||
return fail(fmt.Errorf("socks5: connect rejected (0x%02x)", head[1]))
|
||||
}
|
||||
var alen int
|
||||
switch head[3] {
|
||||
case 0x01:
|
||||
alen = 4
|
||||
case 0x04:
|
||||
alen = 16
|
||||
case 0x03:
|
||||
l := make([]byte, 1)
|
||||
if _, err := io.ReadFull(conn, l); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
alen = int(l[0])
|
||||
default:
|
||||
return fail(fmt.Errorf("socks5: bad reply atyp 0x%02x", head[3]))
|
||||
}
|
||||
if _, err := io.ReadFull(conn, make([]byte, alen+2)); err != nil { // addr + port
|
||||
return fail(err)
|
||||
}
|
||||
// Handshake done — clear the deadline so it doesn't cut the HTTP exchange
|
||||
// short; the http.Client's own Timeout governs the request from here.
|
||||
if err := conn.SetDeadline(time.Time{}); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// subParseUserinfo parses a `subscription-userinfo` header value of the form
|
||||
// "upload=1; download=2; total=3; expire=1700000000" into a SubUserinfo. It
|
||||
// returns nil when the header is empty or carries none of the known keys.
|
||||
func subParseUserinfo(header string) *SubUserinfo {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return nil
|
||||
}
|
||||
ui := &SubUserinfo{Raw: header, FetchedAt: time.Now().Unix()}
|
||||
found := false
|
||||
for _, part := range strings.Split(header, ";") {
|
||||
k, v, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.ToLower(strings.TrimSpace(k))
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "upload":
|
||||
ui.Upload = n
|
||||
found = true
|
||||
case "download":
|
||||
ui.Download = n
|
||||
found = true
|
||||
case "total":
|
||||
ui.Total = n
|
||||
found = true
|
||||
case "expire":
|
||||
ui.Expire = n
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if ui.Total > 0 {
|
||||
if rem := ui.Total - ui.Upload - ui.Download; rem > 0 {
|
||||
ui.Remaining = rem
|
||||
}
|
||||
}
|
||||
return ui
|
||||
}
|
||||
|
||||
func applyHappHeaders(req *http.Request, s Subscription) {
|
||||
if s.UA != "" {
|
||||
req.Header.Set("User-Agent", s.UA)
|
||||
} else {
|
||||
req.Header.Set("User-Agent", "Happ/3.13.0")
|
||||
}
|
||||
if hw := resolveHWID(s); hw != "" {
|
||||
req.Header.Set("x-hwid", hw)
|
||||
}
|
||||
if s.DeviceOS != "" {
|
||||
req.Header.Set("x-device-os", s.DeviceOS)
|
||||
}
|
||||
if s.VerOS != "" {
|
||||
req.Header.Set("x-ver-os", s.VerOS)
|
||||
}
|
||||
if s.DeviceModel != "" {
|
||||
req.Header.Set("x-device-model", s.DeviceModel)
|
||||
}
|
||||
for _, h := range s.Headers {
|
||||
if k, v, ok := strings.Cut(h, ":"); ok {
|
||||
req.Header.Set(strings.TrimSpace(k), strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveHWID returns a fixed hwid or an auto-generated one that is persisted
|
||||
// per subscription so it stays stable across runs.
|
||||
func resolveHWID(s Subscription) string {
|
||||
if s.HWID != "" && s.HWID != "auto" {
|
||||
return s.HWID
|
||||
}
|
||||
if s.HWID != "auto" {
|
||||
return ""
|
||||
}
|
||||
path := filepath.Join(hwidStateDir, sanitizeSubName(s.Name)+".hwid")
|
||||
if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
hw := generateHWID()
|
||||
_ = os.MkdirAll(hwidStateDir, 0o755)
|
||||
_ = os.WriteFile(path, []byte(hw), 0o600)
|
||||
return hw
|
||||
}
|
||||
|
||||
func generateHWID() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "00000000-0000-4000-8000-000000000000"
|
||||
}
|
||||
buf[6] = (buf[6] & 0x0f) | 0x40
|
||||
buf[8] = (buf[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16])
|
||||
}
|
||||
|
||||
// ParseSubscriptionBody accepts a base64 blob or a plain newline list and
|
||||
// returns the individual share-link lines.
|
||||
func ParseSubscriptionBody(body []byte) []string {
|
||||
txt := strings.TrimSpace(string(body))
|
||||
// Try base64 (whole-body) first; if it decodes to something with schemes, use it.
|
||||
if dec, ok := b64decode(txt); ok && looksLikeLinks(string(dec)) {
|
||||
txt = string(dec)
|
||||
}
|
||||
var links []string
|
||||
for _, line := range strings.Split(txt, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if hasScheme(line) {
|
||||
links = append(links, line)
|
||||
}
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func looksLikeLinks(s string) bool { return hasSchemeAnywhere(s) }
|
||||
|
||||
// schemePrefixes is the set of share-link schemes ParseShareLink understands.
|
||||
var schemePrefixes = []string{"vless://", "vmess://", "trojan://", "ss://", "wireguard://", "wg://"}
|
||||
|
||||
func hasScheme(l string) bool {
|
||||
for _, p := range schemePrefixes {
|
||||
if strings.HasPrefix(l, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSchemeAnywhere(s string) bool {
|
||||
for _, p := range schemePrefixes {
|
||||
if strings.Contains(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReconcileSub merges freshly fetched links into an existing cache by fingerprint:
|
||||
// new -> add, present -> keep (refresh lastSeen), missing -> mark stale and drop
|
||||
// after maxStaleRefreshes.
|
||||
func ReconcileSub(prev SubCache, name string, links []string) SubCache {
|
||||
now := time.Now().Unix()
|
||||
byFP := map[string]CachedNode{}
|
||||
for _, n := range prev.Nodes {
|
||||
byFP[n.Fingerprint] = n
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
next := SubCache{Name: name, Updated: now}
|
||||
i := 0
|
||||
for _, link := range links {
|
||||
ob, err := ParseShareLink(link, "probe")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fp := Fingerprint(ob)
|
||||
if seen[fp] {
|
||||
continue
|
||||
}
|
||||
seen[fp] = true
|
||||
i++
|
||||
name := linkName(link, i)
|
||||
if old, ok := byFP[fp]; ok {
|
||||
old.URI = link
|
||||
old.Name = name
|
||||
old.Stale = false
|
||||
old.StaleCount = 0
|
||||
old.LastSeen = now
|
||||
next.Nodes = append(next.Nodes, old)
|
||||
} else {
|
||||
next.Nodes = append(next.Nodes, CachedNode{
|
||||
Name: name, URI: link, Fingerprint: fp,
|
||||
FirstSeen: now, LastSeen: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Carry over vanished nodes as stale until they age out.
|
||||
for _, n := range prev.Nodes {
|
||||
if seen[n.Fingerprint] {
|
||||
continue
|
||||
}
|
||||
n.Stale = true
|
||||
n.StaleCount++
|
||||
if n.StaleCount <= maxStaleRefreshes {
|
||||
next.Nodes = append(next.Nodes, n)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func linkName(link string, idx int) string {
|
||||
if i := strings.LastIndexByte(link, '#'); i >= 0 && i+1 < len(link) {
|
||||
if frag, err := url.QueryUnescape(link[i+1:]); err == nil && frag != "" {
|
||||
return frag
|
||||
}
|
||||
}
|
||||
// vmess carries its label in the base64-JSON "ps" field, not a #fragment, so
|
||||
// a bare `vmess://<base64>` (common in real subscriptions) would otherwise
|
||||
// fall through to the generic "node-N" and lose its name (and country flag).
|
||||
if raw, isVmess := strings.CutPrefix(link, "vmess://"); isVmess {
|
||||
if i := strings.IndexByte(raw, '#'); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if b, ok := b64decode(raw); ok {
|
||||
var v map[string]any
|
||||
if json.Unmarshal(b, &v) == nil {
|
||||
if ps, _ := v["ps"].(string); strings.TrimSpace(ps) != "" {
|
||||
return strings.TrimSpace(ps)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("node-%d", idx)
|
||||
}
|
||||
|
||||
// LoadSubCache reads a subscription cache file (empty cache if absent).
|
||||
func LoadSubCache(name string) SubCache {
|
||||
c := SubCache{Name: name}
|
||||
b, err := os.ReadFile(cachePath(name))
|
||||
if err != nil {
|
||||
return c
|
||||
}
|
||||
_ = json.Unmarshal(b, &c)
|
||||
return c
|
||||
}
|
||||
|
||||
// SaveSubCache writes a subscription cache file atomically.
|
||||
func SaveSubCache(c SubCache) error {
|
||||
if err := os.MkdirAll(subCacheDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := cachePath(c.Name) + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, cachePath(c.Name))
|
||||
}
|
||||
|
||||
// sanitizeSubName makes a subscription name safe to embed in a filesystem
|
||||
// path (cache and hwid files live in fixed dirs keyed by name): anything
|
||||
// outside [A-Za-z0-9._-] becomes '_', so a UCI name like "../../tmp/x" cannot
|
||||
// escape the directory. A result that is empty or all dots ("", ".", "..")
|
||||
// would still be a path hazard / hidden file, so it falls back to "sub".
|
||||
func sanitizeSubName(name string) string {
|
||||
s := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '.', r == '_', r == '-':
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, name)
|
||||
if strings.Trim(s, ".") == "" {
|
||||
return "sub"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func cachePath(name string) string {
|
||||
return filepath.Join(subCacheDir, sanitizeSubName(name)+".json")
|
||||
}
|
||||
|
||||
// SubUpdate fetches, parses, reconciles and caches subscription(s). An empty
|
||||
// name updates every enabled subscription; otherwise only the named one. Fetch
|
||||
// failures are non-fatal and keep the existing cache (use-cache-on-fail); the
|
||||
// parsed subscription-userinfo (quota/expiry) is folded into the cache. This is
|
||||
// the full flow behind `xrayctl sub update [NAME]` and the auto-update timer.
|
||||
func SubUpdate(name string) error {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matched := 0
|
||||
for _, s := range m.Subscriptions {
|
||||
if !s.Enabled || (name != "" && s.Name != name) {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
links, ui, err := FetchSubscriptionFull(s)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "sub %q: %v\n", s.Name, err)
|
||||
continue // keep prior cache
|
||||
}
|
||||
// Filter (name regex / proto / country / dedup) BEFORE reconcile so the
|
||||
// cache holds exactly the surviving nodes.
|
||||
if n0 := len(links); n0 > 0 {
|
||||
links = subApplyFilters(links, subFilterSpec(s))
|
||||
if d := n0 - len(links); d > 0 {
|
||||
fmt.Fprintf(os.Stderr, "sub %q: filtered out %d node(s)\n", s.Name, d)
|
||||
}
|
||||
}
|
||||
next := ReconcileSub(LoadSubCache(s.Name), s.Name, links)
|
||||
if ui != nil {
|
||||
next.Userinfo = ui
|
||||
}
|
||||
if err := SaveSubCache(next); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "sub %q: %d nodes\n", s.Name, len(next.Nodes))
|
||||
}
|
||||
if matched == 0 && name != "" {
|
||||
return fmt.Errorf("subscription %q not found or disabled", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubUserinfos returns the cached userinfo (quota/expiry) for every
|
||||
// subscription that has one, keyed by subscription name. Consumed by status
|
||||
// reporting so the dashboard can surface remaining traffic / expiry.
|
||||
func SubUserinfos() map[string]*SubUserinfo {
|
||||
out := map[string]*SubUserinfo{}
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, s := range m.Subscriptions {
|
||||
if c := LoadSubCache(s.Name); c.Userinfo != nil {
|
||||
out[s.Name] = c.Userinfo
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SubUserinfoView augments SubUserinfo with derived, UI-friendly fields.
|
||||
type SubUserinfoView struct {
|
||||
*SubUserinfo
|
||||
Name string `json:"name"`
|
||||
DaysLeft int64 `json:"days_left"` // -1 if no expiry
|
||||
PctUsed float64 `json:"pct_used"`
|
||||
Expiring bool `json:"expiring"`
|
||||
Exhausted bool `json:"exhausted"`
|
||||
}
|
||||
|
||||
// SubInfoJSON reports quota/expiry + alerts per subscription.
|
||||
func SubInfoJSON() ([]byte, error) {
|
||||
m, err := ReadUCI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := timeNow().Unix()
|
||||
views := []SubUserinfoView{}
|
||||
for _, s := range m.Subscriptions {
|
||||
c := LoadSubCache(s.Name)
|
||||
if c.Userinfo == nil {
|
||||
continue
|
||||
}
|
||||
u := c.Userinfo
|
||||
v := SubUserinfoView{SubUserinfo: u, Name: s.Name, DaysLeft: -1}
|
||||
if u.Expire > 0 {
|
||||
v.DaysLeft = (u.Expire - now) / 86400
|
||||
v.Expiring = subExpiring(s.ExpireAlertDays, v.DaysLeft)
|
||||
}
|
||||
if u.Total > 0 {
|
||||
v.PctUsed = float64(u.Upload+u.Download) / float64(u.Total) * 100
|
||||
v.Exhausted = u.Remaining == 0
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
return jsonMarshalIndent(views)
|
||||
}
|
||||
|
||||
// subExpiring reports whether daysLeft is inside the expiry-alert window.
|
||||
// 0 means "unset" and gets the 3-day default (previously the default was dead
|
||||
// code — the condition also required ExpireAlertDays != 0, so an explicit or
|
||||
// implicit 0 silently disabled alerts); a negative value disables alerts.
|
||||
func subExpiring(alertDays int, daysLeft int64) bool {
|
||||
if alertDays < 0 {
|
||||
return false
|
||||
}
|
||||
alert := int64(alertDays)
|
||||
if alert == 0 {
|
||||
alert = 3
|
||||
}
|
||||
return daysLeft <= alert
|
||||
}
|
||||
|
||||
func jsonMarshalIndent(v any) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
}
|
||||
|
||||
// ImportNodes parses a pasted blob — a base64 subscription body or a plain
|
||||
// newline-separated list of one-or-many share-links — into de-duplicated cache
|
||||
// nodes with stable fingerprints. Invalid/unsupported lines are skipped.
|
||||
func ImportNodes(blob string) []CachedNode {
|
||||
// A pasted wg-quick / AmneziaWG .conf is a multi-line INI that the line-based
|
||||
// body parser would shred — detect and convert it to a wireguard:// URI first.
|
||||
if looksLikeWGConf(blob) {
|
||||
if c, name, err := parseWGConf(blob); err == nil {
|
||||
uri := wgConfigToURI(c, orDefault(name, "wireguard"))
|
||||
if ob, err := ParseShareLink(uri, "probe"); err == nil {
|
||||
now := time.Now().Unix()
|
||||
return []CachedNode{{
|
||||
Name: linkName(uri, 1), URI: uri, Fingerprint: Fingerprint(ob),
|
||||
FirstSeen: now, LastSeen: now,
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Accept pasted Clash YAML / Xray-JSON / sing-box-JSON blobs too, not just
|
||||
// share-link lists (auto-detected).
|
||||
links := ParseSubBody([]byte(blob), "", "auto")
|
||||
var out []CachedNode
|
||||
seen := map[string]bool{}
|
||||
now := time.Now().Unix()
|
||||
i := 0
|
||||
for _, link := range links {
|
||||
ob, err := ParseShareLink(link, "probe")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fp := Fingerprint(ob)
|
||||
if seen[fp] {
|
||||
continue
|
||||
}
|
||||
seen[fp] = true
|
||||
i++
|
||||
out = append(out, CachedNode{
|
||||
Name: linkName(link, i), URI: link, Fingerprint: fp,
|
||||
FirstSeen: now, LastSeen: now,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ImportNodesFromFile reads a text file and parses it like ImportNodes.
|
||||
func ImportNodesFromFile(path string) ([]CachedNode, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ImportNodes(string(b)), nil
|
||||
}
|
||||
|
||||
// NodeImport parses a blob and persists each node as a `config node` section in
|
||||
// UCI (name/uri/enabled), then commits. Returns the number of nodes added.
|
||||
// Device-side: shells out to `uci`. Backs `xrayctl node import`.
|
||||
func NodeImport(blob string) (int, error) {
|
||||
return subAddNodesUCI(ImportNodes(blob))
|
||||
}
|
||||
|
||||
// NodeImportFile is NodeImport reading its blob from a file path.
|
||||
func NodeImportFile(path string) (int, error) {
|
||||
nodes, err := ImportNodesFromFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return subAddNodesUCI(nodes)
|
||||
}
|
||||
|
||||
// subAddNodesUCI appends nodes as anonymous `config node` sections via uci and
|
||||
// commits once. Names are made unique against existing node names.
|
||||
func subAddNodesUCI(nodes []CachedNode) (int, error) {
|
||||
if len(nodes) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
used := subExistingNodeNames()
|
||||
added := 0
|
||||
for _, nd := range nodes {
|
||||
out, err := exec.Command("uci", "add", "shater", "node").Output()
|
||||
if err != nil {
|
||||
return added, fmt.Errorf("uci add node: %w", err)
|
||||
}
|
||||
id := strings.TrimSpace(string(out))
|
||||
name := subUniqueName(sanitize(nd.Name), used)
|
||||
used[name] = true
|
||||
if err := exec.Command("uci", "set", "shater."+id+".name="+name).Run(); err != nil {
|
||||
return added, err
|
||||
}
|
||||
if err := exec.Command("uci", "set", "shater."+id+".uri="+nd.URI).Run(); err != nil {
|
||||
return added, err
|
||||
}
|
||||
if err := exec.Command("uci", "set", "shater."+id+".enabled=1").Run(); err != nil {
|
||||
return added, err
|
||||
}
|
||||
added++
|
||||
}
|
||||
if err := exec.Command("uci", "commit", "shater").Run(); err != nil {
|
||||
return added, fmt.Errorf("uci commit: %w", err)
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// subExistingNodeNames returns the set of node names already in UCI.
|
||||
func subExistingNodeNames() map[string]bool {
|
||||
used := map[string]bool{}
|
||||
if m, err := ReadUCI(); err == nil {
|
||||
for _, n := range m.Nodes {
|
||||
if n.Name != "" {
|
||||
used[n.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// subUniqueName returns base, or base-2/base-3/... until it is unused.
|
||||
func subUniqueName(base string, used map[string]bool) string {
|
||||
if base == "" {
|
||||
base = "node"
|
||||
}
|
||||
if !used[base] {
|
||||
return base
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
cand := fmt.Sprintf("%s-%d", base, i)
|
||||
if !used[cand] {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadCacheNodesInto pulls all cache nodes for the model's subscriptions into
|
||||
// m.Nodes (tagged with FromSub) so groups can resolve subscription members.
|
||||
func loadCacheNodesInto(m *Model) {
|
||||
for _, s := range m.Subscriptions {
|
||||
c := LoadSubCache(s.Name)
|
||||
for _, n := range c.Nodes {
|
||||
m.Nodes = append(m.Nodes, Node{
|
||||
Name: n.Name, URI: n.URI, Enabled: !n.Stale,
|
||||
FromSub: s.Name, Fingerprint: n.Fingerprint, Stale: n.Stale,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Import from a multi-line blob: several share-links separated by newlines,
|
||||
// mixed with comments/blank lines, are parsed into de-duplicated nodes.
|
||||
func TestImportNodesMultiLine(t *testing.T) {
|
||||
blob := `
|
||||
# my nodes
|
||||
vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=abcd&sni=www.microsoft.com&fp=chrome#nl
|
||||
|
||||
trojan://password123@example.org:443?security=tls&sni=example.org#tj
|
||||
not-a-link
|
||||
vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=abcd&sni=www.microsoft.com&fp=chrome#dup
|
||||
`
|
||||
nodes := ImportNodes(blob)
|
||||
// Two distinct links (the second vless is a fingerprint duplicate of the first).
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("want 2 nodes, got %d: %+v", len(nodes), nodes)
|
||||
}
|
||||
if nodes[0].Fingerprint == "" || nodes[1].Fingerprint == "" {
|
||||
t.Fatalf("nodes must carry fingerprints: %+v", nodes)
|
||||
}
|
||||
if nodes[0].Fingerprint == nodes[1].Fingerprint {
|
||||
t.Fatalf("distinct nodes share a fingerprint")
|
||||
}
|
||||
if nodes[0].Name != "nl" || nodes[1].Name != "tj" {
|
||||
t.Fatalf("unexpected names: %q %q", nodes[0].Name, nodes[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// A bare vmess://<base64> link (no #fragment) must take its name from the JSON
|
||||
// "ps" field, not fall through to the generic "node-N".
|
||||
func TestVmessLinkNameFromPS(t *testing.T) {
|
||||
// {"add":"1.2.3.4","id":"...","net":"tcp","port":"443","ps":"FI-vmess-1"}
|
||||
link := "vmess://eyJhZGQiOiIxLjIuMy40IiwiaWQiOiI3ZDE1NmUyOS0wNTA1LTQ4M2ItYjlkZi04YTJjMGE1YjM5MmYiLCJuZXQiOiJ0Y3AiLCJwb3J0IjoiNDQzIiwicHMiOiJGSS12bWVzcy0xIn0="
|
||||
if got := linkName(link, 7); got != "FI-vmess-1" {
|
||||
t.Fatalf("vmess name = %q, want FI-vmess-1", got)
|
||||
}
|
||||
// A #fragment still wins when present.
|
||||
if got := linkName("vless://x@h:443#NL-01", 3); got != "NL-01" {
|
||||
t.Fatalf("fragment name = %q, want NL-01", got)
|
||||
}
|
||||
// No name anywhere -> generic fallback.
|
||||
if got := linkName("vless://x@h:443", 5); got != "node-5" {
|
||||
t.Fatalf("fallback name = %q, want node-5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUserinfoHeader(t *testing.T) {
|
||||
ui := subParseUserinfo("upload=1000; download=2500; total=10000; expire=1700000000")
|
||||
if ui == nil {
|
||||
t.Fatal("expected non-nil userinfo")
|
||||
}
|
||||
if ui.Upload != 1000 || ui.Download != 2500 || ui.Total != 10000 || ui.Expire != 1700000000 {
|
||||
t.Fatalf("bad parse: %+v", ui)
|
||||
}
|
||||
if ui.Remaining != 10000-1000-2500 {
|
||||
t.Fatalf("remaining = %d, want %d", ui.Remaining, 10000-1000-2500)
|
||||
}
|
||||
// Header without any known key -> nil.
|
||||
if got := subParseUserinfo("foo=bar; nonsense"); got != nil {
|
||||
t.Fatalf("expected nil for header with no known keys, got %+v", got)
|
||||
}
|
||||
// Empty header -> nil.
|
||||
if got := subParseUserinfo(""); got != nil {
|
||||
t.Fatalf("expected nil for empty header")
|
||||
}
|
||||
// Over-quota (upload+download > total) clamps remaining to 0.
|
||||
if got := subParseUserinfo("upload=9000; download=9000; total=10000"); got == nil || got.Remaining != 0 {
|
||||
t.Fatalf("expected remaining clamped to 0, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesetParsePlain(t *testing.T) {
|
||||
text := "# comment\nexample.com\n\n foo.org \n// skip\n;skip2\nbar.net\n"
|
||||
got := rsParsePlain(text)
|
||||
want := []string{"example.com", "foo.org", "bar.net"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("plain: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("plain[%d] = %q want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesetParseClash(t *testing.T) {
|
||||
text := `payload:
|
||||
- '+.example.com'
|
||||
- "DOMAIN-SUFFIX,foo.org"
|
||||
- IP-CIDR,1.2.3.0/24
|
||||
- bar.net
|
||||
# trailing comment
|
||||
`
|
||||
got := rsParseClash(text)
|
||||
want := []string{"example.com", "foo.org", "1.2.3.0/24", "bar.net"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("clash: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("clash[%d] = %q want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesetResolveGeosite(t *testing.T) {
|
||||
doms, ips := rsResolve(Ruleset{
|
||||
Name: "ads", Type: "domain", Source: "inline", Format: "geosite",
|
||||
Entries: []string{"category-ads-all", "geosite:telegram"},
|
||||
})
|
||||
if ips != nil {
|
||||
t.Fatalf("domain ruleset returned ips: %v", ips)
|
||||
}
|
||||
want := []string{"geosite:category-ads-all", "geosite:telegram"}
|
||||
if len(doms) != len(want) || doms[0] != want[0] || doms[1] != want[1] {
|
||||
t.Fatalf("geosite resolve = %v want %v", doms, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesetResolveInlineIPCIDR(t *testing.T) {
|
||||
doms, ips := rsResolve(Ruleset{
|
||||
Name: "ad-ips", Type: "ipcidr", Source: "inline", Format: "plain",
|
||||
Entries: []string{"10.0.0.0/8", "192.168.0.0/16"},
|
||||
})
|
||||
if doms != nil {
|
||||
t.Fatalf("ipcidr ruleset returned domains: %v", doms)
|
||||
}
|
||||
if len(ips) != 2 || ips[0] != "10.0.0.0/8" || ips[1] != "192.168.0.0/16" {
|
||||
t.Fatalf("ipcidr resolve = %v", ips)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile: a node that vanishes from the feed is marked stale and carried for
|
||||
// maxStaleRefreshes updates, then aged out.
|
||||
func TestReconcileStaleAging(t *testing.T) {
|
||||
linkA := "vless://11111111-1111-1111-1111-111111111111@a.example:443?type=tcp&security=reality&pbk=abcd&sni=www.microsoft.com&fp=chrome#a"
|
||||
linkB := "trojan://pw@b.example:443?security=tls&sni=b.example#b"
|
||||
|
||||
// Initial: both nodes present.
|
||||
c := ReconcileSub(SubCache{Name: "s"}, "s", []string{linkA, linkB})
|
||||
if len(c.Nodes) != 2 {
|
||||
t.Fatalf("initial: want 2 nodes, got %d", len(c.Nodes))
|
||||
}
|
||||
|
||||
fpB := ""
|
||||
for _, n := range c.Nodes {
|
||||
if n.Name == "b" {
|
||||
fpB = n.Fingerprint
|
||||
}
|
||||
}
|
||||
if fpB == "" {
|
||||
t.Fatal("node b missing after initial reconcile")
|
||||
}
|
||||
|
||||
// Drop B from the feed. It must survive as stale for maxStaleRefreshes
|
||||
// updates (stale_count 1..maxStaleRefreshes), then disappear.
|
||||
for round := 1; round <= maxStaleRefreshes; round++ {
|
||||
c = ReconcileSub(c, "s", []string{linkA})
|
||||
nb := findNodeByFP(c.Nodes, fpB)
|
||||
if nb == nil {
|
||||
t.Fatalf("round %d: stale node aged out too early", round)
|
||||
}
|
||||
if !nb.Stale || nb.StaleCount != round {
|
||||
t.Fatalf("round %d: want stale with count %d, got stale=%v count=%d",
|
||||
round, round, nb.Stale, nb.StaleCount)
|
||||
}
|
||||
}
|
||||
// One more refresh: stale_count would exceed maxStaleRefreshes -> removed.
|
||||
c = ReconcileSub(c, "s", []string{linkA})
|
||||
if findNodeByFP(c.Nodes, fpB) != nil {
|
||||
t.Fatalf("stale node should have aged out after %d refreshes", maxStaleRefreshes)
|
||||
}
|
||||
// The live node A is still present and not stale.
|
||||
if len(c.Nodes) != 1 || c.Nodes[0].Name != "a" || c.Nodes[0].Stale {
|
||||
t.Fatalf("live node A missing/altered: %+v", c.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func findNodeByFP(nodes []CachedNode, fp string) *CachedNode {
|
||||
for i := range nodes {
|
||||
if nodes[i].Fingerprint == fp {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package main
|
||||
|
||||
// Tests for the untrusted-input hardening in sub.go: subscription names used
|
||||
// as filesystem path components, SOCKS handshake deadlines, and the
|
||||
// expiry-alert default window.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSanitizeSubName(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"work-sub", "work-sub"},
|
||||
{"my.sub_1", "my.sub_1"},
|
||||
{"../../tmp/x", ".._.._tmp_x"},
|
||||
{`..\..\x`, ".._.._x"},
|
||||
{"a b/c", "a_b_c"},
|
||||
{"", "sub"},
|
||||
{".", "sub"},
|
||||
{"..", "sub"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sanitizeSubName(c.in); got != c.want {
|
||||
t.Errorf("sanitizeSubName(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every path built from a subscription name must stay inside its fixed dir.
|
||||
func TestCachePathStaysInDir(t *testing.T) {
|
||||
base := filepath.Dir(cachePath("ok"))
|
||||
for _, evil := range []string{"../../tmp/x", `..\..\x`, "a/b", "..", ""} {
|
||||
if got := cachePath(evil); filepath.Dir(got) != base {
|
||||
t.Errorf("cachePath(%q) escapes cache dir: %q", evil, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubExpiring(t *testing.T) {
|
||||
if !subExpiring(0, 2) {
|
||||
t.Error("unset alert days: default 3-day window should flag 2 days left")
|
||||
}
|
||||
if subExpiring(0, 4) {
|
||||
t.Error("unset alert days: 4 days left is outside the default window")
|
||||
}
|
||||
if !subExpiring(7, 5) {
|
||||
t.Error("explicit 7-day window should flag 5 days left")
|
||||
}
|
||||
if subExpiring(7, 8) {
|
||||
t.Error("explicit 7-day window must not flag 8 days left")
|
||||
}
|
||||
if subExpiring(-1, 0) {
|
||||
t.Error("negative alert days disables alerts")
|
||||
}
|
||||
}
|
||||
|
||||
// A SOCKS proxy that accepts the connection but never replies must fail the
|
||||
// handshake once the ctx deadline passes — without conn deadlines the read
|
||||
// blocks forever (ctx cancellation alone does not unblock net.Conn reads).
|
||||
func TestSubSocks5DialHandshakeDeadline(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Skipf("cannot listen on loopback: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
_, _ = io.Copy(io.Discard, c) // swallow the greeting, never answer
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if _, err := subSocks5Dial(ctx, ln.Addr().String(), "example.com", 80); err == nil {
|
||||
t.Fatal("expected handshake timeout error")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||
t.Fatalf("deadline not applied: handshake blocked %v", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package main
|
||||
|
||||
// Subscription/group node filters (catalog 05 §2, T1): include/exclude by name
|
||||
// regex, by protocol, by country (emoji flag or ISO token in the node name), and
|
||||
// dedup by connection fingerprint. Applied after parse, before reconcile, so the
|
||||
// cache holds exactly the surviving nodes.
|
||||
|
||||
import "strings"
|
||||
|
||||
// FilterSpec unifies subscription- and group-level filter criteria.
|
||||
type FilterSpec struct {
|
||||
Include, Exclude []string // name regex
|
||||
Proto []string // allowed protos (empty = all)
|
||||
Country []string // ISO codes; "!XX" excludes
|
||||
Dedup bool
|
||||
}
|
||||
|
||||
func subFilterSpec(s Subscription) FilterSpec {
|
||||
return FilterSpec{s.Include, s.Exclude, s.FilterProto, s.FilterCountry, s.Dedup}
|
||||
}
|
||||
|
||||
func groupFilterSpec(g Group) FilterSpec {
|
||||
return FilterSpec{g.Include, g.Exclude, g.FilterProto, g.FilterCountry, g.Dedup}
|
||||
}
|
||||
|
||||
func (f FilterSpec) empty() bool {
|
||||
return len(f.Include) == 0 && len(f.Exclude) == 0 && len(f.Proto) == 0 &&
|
||||
len(f.Country) == 0 && !f.Dedup
|
||||
}
|
||||
|
||||
// protoSet builds a lowercase allow-set, or nil when unconstrained.
|
||||
func protoSet(protos []string) map[string]bool {
|
||||
if len(protos) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := map[string]bool{}
|
||||
for _, p := range protos {
|
||||
m[strings.ToLower(strings.TrimSpace(p))] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// linkProto sniffs a share-link's protocol from its scheme (cheap, no full parse).
|
||||
func linkProto(link string) string {
|
||||
if i := strings.Index(link, "://"); i > 0 {
|
||||
p := strings.ToLower(link[:i])
|
||||
if p == "wg" {
|
||||
return "wireguard"
|
||||
}
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// flagToISO extracts an ISO country code from a leading/embedded regional-
|
||||
// indicator emoji pair (🇳🇱 -> "NL"). ok=false if none.
|
||||
func flagToISO(name string) (string, bool) {
|
||||
rs := []rune(name)
|
||||
for i := 0; i+1 < len(rs); i++ {
|
||||
a, b := rs[i]-0x1F1E6, rs[i+1]-0x1F1E6
|
||||
if a >= 0 && a < 26 && b >= 0 && b < 26 {
|
||||
return string([]byte{byte('A' + a), byte('A' + b)}), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// nodeCountry returns the node's country (ISO) from a flag emoji, else a
|
||||
// standalone 2-letter uppercase token in the name, else "".
|
||||
func nodeCountry(name string) string {
|
||||
if iso, ok := flagToISO(name); ok {
|
||||
return iso
|
||||
}
|
||||
// standalone uppercase 2-letter token bounded by non-letters
|
||||
up := strings.ToUpper(name)
|
||||
fields := strings.FieldsFunc(up, func(r rune) bool {
|
||||
return !(r >= 'A' && r <= 'Z')
|
||||
})
|
||||
for _, f := range fields {
|
||||
if len(f) == 2 {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// countryFilter interprets the Country list: any bare entry => whitelist mode.
|
||||
type countryFilter struct {
|
||||
allow, deny map[string]bool
|
||||
whitelist bool
|
||||
}
|
||||
|
||||
func parseCountryFilter(list []string) countryFilter {
|
||||
cf := countryFilter{allow: map[string]bool{}, deny: map[string]bool{}}
|
||||
for _, c := range list {
|
||||
c = strings.ToUpper(strings.TrimSpace(c))
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(c, "!") {
|
||||
cf.deny[strings.TrimPrefix(c, "!")] = true
|
||||
} else {
|
||||
cf.allow[c] = true
|
||||
cf.whitelist = true
|
||||
}
|
||||
}
|
||||
return cf
|
||||
}
|
||||
|
||||
func (cf countryFilter) accept(cc string) bool {
|
||||
if cf.deny[cc] {
|
||||
return false
|
||||
}
|
||||
if cf.whitelist {
|
||||
return cf.allow[cc]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// subApplyFilters applies a FilterSpec to a list of share-links, preserving order.
|
||||
func subApplyFilters(links []string, f FilterSpec) []string {
|
||||
if f.empty() {
|
||||
return links
|
||||
}
|
||||
inc := compileRegexps(f.Include)
|
||||
exc := compileRegexps(f.Exclude)
|
||||
protoOK := protoSet(f.Proto)
|
||||
cf := parseCountryFilter(f.Country)
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for i, link := range links {
|
||||
name := linkName(link, i+1)
|
||||
if len(inc) > 0 && !anyMatch(inc, name) {
|
||||
continue
|
||||
}
|
||||
if len(exc) > 0 && anyMatch(exc, name) {
|
||||
continue
|
||||
}
|
||||
if protoOK != nil && !protoOK[linkProto(link)] {
|
||||
continue
|
||||
}
|
||||
if len(f.Country) > 0 && !cf.accept(nodeCountry(name)) {
|
||||
continue
|
||||
}
|
||||
if f.Dedup {
|
||||
ob, err := ParseShareLink(link, "probe")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fp := Fingerprint(ob)
|
||||
if seen[fp] {
|
||||
continue
|
||||
}
|
||||
seen[fp] = true
|
||||
}
|
||||
out = append(out, link)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package main
|
||||
|
||||
// Additional subscription formats (catalog 05 §2, T2): Clash/Mihomo YAML,
|
||||
// Xray JSON, sing-box JSON. Every parser converges to share-link URIs — the cache
|
||||
// stores links and the whole pipeline (fingerprint/reconcile/generate) re-parses
|
||||
// them via ParseShareLink, so no new outbound-in-cache path is introduced.
|
||||
//
|
||||
// stdlib-only: the Clash YAML parser is a focused subset parser for the
|
||||
// `proxies:` list (flow-style + shallow block-style), NOT a general YAML engine.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DetectSubFormat picks the parser: an explicit override wins, else content-type,
|
||||
// else a byte sniff.
|
||||
func DetectSubFormat(body []byte, contentType, override string) string {
|
||||
if o := strings.ToLower(strings.TrimSpace(override)); o != "" && o != "auto" {
|
||||
return o
|
||||
}
|
||||
ct := strings.ToLower(contentType)
|
||||
switch {
|
||||
case strings.Contains(ct, "yaml"):
|
||||
return "clash"
|
||||
case strings.Contains(ct, "json"):
|
||||
return sniffJSON(body)
|
||||
}
|
||||
trimmed := skipCommentWS(body)
|
||||
switch {
|
||||
case bytesHasKeyLine(trimmed, "proxies:"):
|
||||
return "clash"
|
||||
case len(trimmed) > 0 && trimmed[0] == '{':
|
||||
return sniffJSON(trimmed)
|
||||
default:
|
||||
return "links"
|
||||
}
|
||||
}
|
||||
|
||||
// sniffJSON distinguishes xray vs sing-box by the first outbound's shape.
|
||||
func sniffJSON(b []byte) string {
|
||||
var doc struct {
|
||||
Outbounds []map[string]any `json:"outbounds"`
|
||||
}
|
||||
if json.Unmarshal(b, &doc) == nil && len(doc.Outbounds) > 0 {
|
||||
if _, ok := doc.Outbounds[0]["type"]; ok {
|
||||
return "singbox"
|
||||
}
|
||||
}
|
||||
return "xray"
|
||||
}
|
||||
|
||||
func skipCommentWS(b []byte) []byte {
|
||||
s := strings.TrimSpace(string(b))
|
||||
for {
|
||||
nl := strings.IndexByte(s, '\n')
|
||||
line := s
|
||||
if nl >= 0 {
|
||||
line = s[:nl]
|
||||
}
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
if nl < 0 {
|
||||
return []byte("")
|
||||
}
|
||||
s = s[nl+1:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return []byte(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func bytesHasKeyLine(b []byte, key string) bool {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if strings.TrimSpace(line) == key || strings.HasPrefix(strings.TrimSpace(line), key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseSubBody dispatches to the right parser and always returns share-links.
|
||||
func ParseSubBody(body []byte, contentType, override string) []string {
|
||||
switch DetectSubFormat(body, contentType, override) {
|
||||
case "clash":
|
||||
return parseClashProxies(body)
|
||||
case "xray":
|
||||
return parseXrayOutbounds(body)
|
||||
case "singbox":
|
||||
return parseSingboxOutbounds(body)
|
||||
default:
|
||||
return ParseSubscriptionBody(body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- small value helpers shared by the format converters ---
|
||||
|
||||
func asStr(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return strconv.FormatInt(int64(x), 10)
|
||||
}
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case int:
|
||||
return strconv.Itoa(x)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func subMap(m map[string]any, k string) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
mm, _ := m[k].(map[string]any)
|
||||
return mm
|
||||
}
|
||||
|
||||
func truthy(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return x == "true" || x == "1"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(vs ...string) string {
|
||||
for _, v := range vs {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// synthLink assembles a share-link for a vless/trojan-style proxy. The id
|
||||
// (uuid/password), host and name come from UNTRUSTED subscription bodies, so
|
||||
// the link is built with url.URL: a trojan password like "p@ss/w#rd" must be
|
||||
// percent-escaped or it shifts the host/fragment boundaries on re-parse, and
|
||||
// an IPv6 server needs brackets (joinHostPort adds them).
|
||||
func synthLink(proto, id, host, port, frag string, q url.Values) string {
|
||||
u := url.URL{
|
||||
Scheme: proto,
|
||||
User: url.User(id),
|
||||
Host: joinHostPort(host, port),
|
||||
RawQuery: q.Encode(),
|
||||
}
|
||||
// Fragment appended manually with QueryEscape (not url.URL.Fragment):
|
||||
// linkName decodes names with url.QueryUnescape, and RFC fragment escaping
|
||||
// would leave a literal '+' unescaped, which QueryUnescape turns into a space.
|
||||
return u.String() + "#" + url.QueryEscape(frag)
|
||||
}
|
||||
|
||||
// vmessLink assembles the canonical base64-JSON vmess:// share-link. This is
|
||||
// the only vmess form our own parseVMess (and most clients) accept — a
|
||||
// vless-style "vmess://uuid@host:port?..." URI fails its base64-JSON decode
|
||||
// and the node would be silently dropped, so the format converters MUST emit
|
||||
// this shape. The trailing #fragment is ignored by parseVMess but lets
|
||||
// linkName recover the display name without decoding the body.
|
||||
func vmessLink(id, host, port, name, aid, scy string, so streamOpts) string {
|
||||
body := map[string]string{
|
||||
"v": "2",
|
||||
"ps": name,
|
||||
"add": host,
|
||||
"port": port,
|
||||
"id": id,
|
||||
"aid": orDefault(aid, "0"),
|
||||
"scy": orDefault(scy, "auto"),
|
||||
"net": firstNonEmptyStr(so.network, "tcp"),
|
||||
"type": "none",
|
||||
}
|
||||
// grpc carries its serviceName in "path" per the de-facto vmess JSON
|
||||
// schema; parseVMess reads "path" back for both path and serviceName.
|
||||
path := so.path
|
||||
if body["net"] == "grpc" && so.serviceName != "" {
|
||||
path = so.serviceName
|
||||
}
|
||||
if path != "" {
|
||||
body["path"] = path
|
||||
}
|
||||
if so.host != "" {
|
||||
body["host"] = so.host
|
||||
}
|
||||
// vmess JSON has no reality field — only plain "tls" is representable.
|
||||
if so.security == "tls" {
|
||||
body["tls"] = "tls"
|
||||
}
|
||||
if so.sni != "" {
|
||||
body["sni"] = so.sni
|
||||
}
|
||||
if so.alpn != "" {
|
||||
body["alpn"] = so.alpn
|
||||
}
|
||||
if so.fp != "" {
|
||||
body["fp"] = so.fp
|
||||
}
|
||||
b, _ := json.Marshal(body) // map[string]string cannot fail to marshal
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(b) + "#" + url.QueryEscape(name)
|
||||
}
|
||||
|
||||
// commonStreamQuery fills transport/security query params shared by Clash and
|
||||
// the JSON converters, from a normalised set of values.
|
||||
type streamOpts struct {
|
||||
network, security, sni, fp, flow, path, host, serviceName, pbk, sid, alpn string
|
||||
}
|
||||
|
||||
func streamQuery(so streamOpts) url.Values {
|
||||
q := url.Values{}
|
||||
q.Set("type", firstNonEmptyStr(so.network, "tcp"))
|
||||
if so.security != "" {
|
||||
q.Set("security", so.security)
|
||||
}
|
||||
if so.sni != "" {
|
||||
q.Set("sni", so.sni)
|
||||
}
|
||||
if so.fp != "" {
|
||||
q.Set("fp", so.fp)
|
||||
}
|
||||
if so.flow != "" {
|
||||
q.Set("flow", so.flow)
|
||||
}
|
||||
if so.path != "" {
|
||||
q.Set("path", so.path)
|
||||
}
|
||||
if so.host != "" {
|
||||
q.Set("host", so.host)
|
||||
}
|
||||
if so.serviceName != "" {
|
||||
q.Set("serviceName", so.serviceName)
|
||||
}
|
||||
if so.pbk != "" {
|
||||
q.Set("pbk", so.pbk)
|
||||
}
|
||||
if so.sid != "" {
|
||||
q.Set("sid", so.sid)
|
||||
}
|
||||
if so.alpn != "" {
|
||||
q.Set("alpn", so.alpn)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// ssLink builds an ss:// link (base64 of method:password). The base64 userinfo
|
||||
// needs no escaping, but an IPv6 host must be bracketed or parseSS's host:port
|
||||
// split truncates the address at its first colon.
|
||||
func ssLink(method, password, host, port, frag string) string {
|
||||
ui := base64.RawURLEncoding.EncodeToString([]byte(method + ":" + password))
|
||||
return "ss://" + ui + "@" + joinHostPort(host, port) + "#" + url.QueryEscape(frag)
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
package main
|
||||
|
||||
// Format-specific parsers for subscriptions. Each converts to share-link URIs.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --- Clash / Mihomo YAML (proxies: list) ---
|
||||
|
||||
func parseClashProxies(body []byte) []string {
|
||||
lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n")
|
||||
start := -1
|
||||
for i, l := range lines {
|
||||
// Anchor on the TOP-LEVEL (column-0) proxies: key only. proxy-groups
|
||||
// entries carry a nested, indented "proxies:" member list — matching the
|
||||
// first occurrence anywhere would anchor on group members (node names,
|
||||
// not proxy maps) whenever proxy-groups precedes the real list, and the
|
||||
// whole subscription would parse to zero nodes.
|
||||
if strings.HasPrefix(l, "proxies:") {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, item := range clashListItems(lines, start) {
|
||||
m := parseYAMLNode(item)
|
||||
if uri, ok := clashProxyToLink(m); ok {
|
||||
out = append(out, uri)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clashListItems collects each "- ..." item under proxies: as a block of lines
|
||||
// re-anchored so the item's top-level keys sit at column 0 while nested keys keep
|
||||
// their RELATIVE deeper indentation (parseBlockMap needs indentation for nesting).
|
||||
func clashListItems(lines []string, start int) []string {
|
||||
var items []string
|
||||
var cur []string
|
||||
base := -1 // content column of the current item
|
||||
flush := func() {
|
||||
if len(cur) > 0 {
|
||||
items = append(items, strings.Join(cur, "\n"))
|
||||
cur = nil
|
||||
}
|
||||
}
|
||||
for i := start; i < len(lines); i++ {
|
||||
raw := lines[i]
|
||||
if strings.TrimSpace(raw) == "" || strings.HasPrefix(strings.TrimSpace(raw), "#") {
|
||||
continue
|
||||
}
|
||||
indent := len(raw) - len(strings.TrimLeft(raw, " "))
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
flush()
|
||||
base = indent + 2 // "- " is 2 chars; item content starts here
|
||||
cur = append(cur, strings.TrimSpace(strings.TrimPrefix(trimmed, "-")))
|
||||
continue
|
||||
}
|
||||
if base < 0 || indent < base {
|
||||
break // dedent below the item content ends the proxies: list
|
||||
}
|
||||
if len(raw) >= base {
|
||||
cur = append(cur, raw[base:]) // dedent by base, keep relative nesting
|
||||
} else {
|
||||
cur = append(cur, trimmed)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return items
|
||||
}
|
||||
|
||||
// parseYAMLNode parses one proxy item (flow-style {a: b, c: d} or block-style
|
||||
// key: value lines with one level of nesting) into a map.
|
||||
func parseYAMLNode(text string) map[string]any {
|
||||
text = strings.TrimSpace(text)
|
||||
if strings.HasPrefix(text, "{") {
|
||||
return parseFlowMap(text)
|
||||
}
|
||||
return parseBlockMap(strings.Split(text, "\n"))
|
||||
}
|
||||
|
||||
func parseFlowMap(s string) map[string]any {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "{")
|
||||
s = strings.TrimSuffix(s, "}")
|
||||
m := map[string]any{}
|
||||
for _, part := range splitTopLevel(s, ',') {
|
||||
k, v, ok := strings.Cut(part, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
m[yamlUnquote(k)] = parseFlowValue(v)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func parseFlowValue(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if strings.HasPrefix(v, "{") {
|
||||
return parseFlowMap(v)
|
||||
}
|
||||
if strings.HasPrefix(v, "[") {
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(v, "["), "]")
|
||||
var list []any
|
||||
for _, e := range splitTopLevel(inner, ',') {
|
||||
list = append(list, yamlUnquote(strings.TrimSpace(e)))
|
||||
}
|
||||
return list
|
||||
}
|
||||
return yamlUnquote(v)
|
||||
}
|
||||
|
||||
func parseBlockMap(lines []string) map[string]any {
|
||||
m := map[string]any{}
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(trimmed, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = yamlUnquote(strings.TrimSpace(k))
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
// nested map: gather deeper-indented following lines
|
||||
base := len(line) - len(strings.TrimLeft(line, " "))
|
||||
var sub []string
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
ind := len(lines[j]) - len(strings.TrimLeft(lines[j], " "))
|
||||
if strings.TrimSpace(lines[j]) == "" {
|
||||
continue
|
||||
}
|
||||
if ind <= base {
|
||||
break
|
||||
}
|
||||
sub = append(sub, strings.TrimSpace(lines[j]))
|
||||
i = j
|
||||
}
|
||||
if len(sub) > 0 {
|
||||
m[k] = parseBlockMap(sub)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(v, "[") {
|
||||
m[k] = parseFlowValue(v)
|
||||
continue
|
||||
}
|
||||
m[k] = yamlUnquote(v)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// splitTopLevel splits s on sep, ignoring separators inside {}/[]/quotes.
|
||||
func splitTopLevel(s string, sep byte) []string {
|
||||
var out []string
|
||||
depth := 0
|
||||
var q rune
|
||||
last := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case q != 0:
|
||||
if rune(c) == q {
|
||||
q = 0
|
||||
}
|
||||
case c == '\'' || c == '"':
|
||||
q = rune(c)
|
||||
case c == '{' || c == '[':
|
||||
depth++
|
||||
case c == '}' || c == ']':
|
||||
depth--
|
||||
case c == sep && depth == 0:
|
||||
out = append(out, s[last:i])
|
||||
last = i + 1
|
||||
}
|
||||
}
|
||||
out = append(out, s[last:])
|
||||
return out
|
||||
}
|
||||
|
||||
func yamlUnquote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 && (s[0] == '"' && s[len(s)-1] == '"' || s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// clashProxyToLink converts one Clash proxy map to a share-link.
|
||||
func clashProxyToLink(m map[string]any) (string, bool) {
|
||||
g := func(k string) string { return asStr(m[k]) }
|
||||
name := g("name")
|
||||
typ := strings.ToLower(g("type"))
|
||||
host := g("server")
|
||||
port := g("port")
|
||||
so := streamOpts{network: strings.ToLower(g("network"))}
|
||||
switch {
|
||||
case subMap(m, "reality-opts") != nil:
|
||||
r := subMap(m, "reality-opts")
|
||||
so.security = "reality"
|
||||
so.pbk = asStr(r["public-key"])
|
||||
so.sid = asStr(r["short-id"])
|
||||
case truthy(m["tls"]):
|
||||
so.security = "tls"
|
||||
}
|
||||
so.sni = firstNonEmptyStr(g("servername"), g("sni"))
|
||||
so.fp = g("client-fingerprint")
|
||||
so.flow = g("flow")
|
||||
if ws := subMap(m, "ws-opts"); ws != nil {
|
||||
so.path = asStr(ws["path"])
|
||||
if h := subMap(ws, "headers"); h != nil {
|
||||
so.host = firstNonEmptyStr(asStr(h["Host"]), asStr(h["host"]))
|
||||
}
|
||||
}
|
||||
if gr := subMap(m, "grpc-opts"); gr != nil {
|
||||
so.serviceName = asStr(gr["grpc-service-name"])
|
||||
}
|
||||
q := streamQuery(so)
|
||||
switch typ {
|
||||
case "vless":
|
||||
return synthLink("vless", g("uuid"), host, port, name, q), true
|
||||
case "trojan":
|
||||
return synthLink("trojan", g("password"), host, port, name, q), true
|
||||
case "vmess":
|
||||
// vmess must be the base64-JSON form — parseVMess rejects a
|
||||
// vless-style URI, silently dropping the node.
|
||||
return vmessLink(g("uuid"), host, port, name, g("alterId"), g("cipher"), so), true
|
||||
case "ss", "shadowsocks":
|
||||
return ssLink(g("cipher"), g("password"), host, port, name), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// --- Xray JSON (outbounds array) ---
|
||||
|
||||
func parseXrayOutbounds(body []byte) []string {
|
||||
var doc struct {
|
||||
Outbounds []map[string]any `json:"outbounds"`
|
||||
}
|
||||
if json.Unmarshal(body, &doc) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, ob := range doc.Outbounds {
|
||||
if uri, ok := xrayOutboundToLink(ob); ok {
|
||||
out = append(out, uri)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func xrayOutboundToLink(ob map[string]any) (string, bool) {
|
||||
proto := strings.ToLower(asStr(ob["protocol"]))
|
||||
tag := asStr(ob["tag"])
|
||||
settings := subMap(ob, "settings")
|
||||
ss := subMap(ob, "streamSettings")
|
||||
so := streamOpts{}
|
||||
if ss != nil {
|
||||
so.network = strings.ToLower(asStr(ss["network"]))
|
||||
so.security = strings.ToLower(asStr(ss["security"]))
|
||||
if tls := subMap(ss, "tlsSettings"); tls != nil {
|
||||
so.sni = asStr(tls["serverName"])
|
||||
so.fp = asStr(tls["fingerprint"])
|
||||
}
|
||||
if r := subMap(ss, "realitySettings"); r != nil {
|
||||
so.sni = firstNonEmptyStr(asStr(r["serverName"]), so.sni)
|
||||
so.pbk = asStr(r["publicKey"])
|
||||
so.sid = asStr(r["shortId"])
|
||||
so.fp = firstNonEmptyStr(asStr(r["fingerprint"]), so.fp)
|
||||
}
|
||||
if ws := subMap(ss, "wsSettings"); ws != nil {
|
||||
so.path = asStr(ws["path"])
|
||||
if h := subMap(ws, "headers"); h != nil {
|
||||
so.host = asStr(h["Host"])
|
||||
}
|
||||
}
|
||||
if gr := subMap(ss, "grpcSettings"); gr != nil {
|
||||
so.serviceName = asStr(gr["serviceName"])
|
||||
}
|
||||
}
|
||||
q := streamQuery(so)
|
||||
switch proto {
|
||||
case "vless", "vmess":
|
||||
host, port, id, flow, aid, scy := xrayVnext(settings)
|
||||
if flow != "" {
|
||||
q.Set("flow", flow)
|
||||
}
|
||||
if host == "" {
|
||||
return "", false
|
||||
}
|
||||
if proto == "vmess" {
|
||||
// vmess must be the base64-JSON form — parseVMess rejects a
|
||||
// vless-style URI, silently dropping the node.
|
||||
return vmessLink(id, host, port, tag, aid, scy, so), true
|
||||
}
|
||||
return synthLink(proto, id, host, port, tag, q), true
|
||||
case "trojan", "shadowsocks":
|
||||
host, port, pw, method := xrayServer(settings)
|
||||
if host == "" {
|
||||
return "", false
|
||||
}
|
||||
if proto == "shadowsocks" {
|
||||
return ssLink(method, pw, host, port, tag), true
|
||||
}
|
||||
return synthLink("trojan", pw, host, port, tag, q), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func xrayVnext(settings map[string]any) (host, port, id, flow, aid, scy string) {
|
||||
if settings == nil {
|
||||
return
|
||||
}
|
||||
vnext, _ := settings["vnext"].([]any)
|
||||
if len(vnext) == 0 {
|
||||
return
|
||||
}
|
||||
v0, _ := vnext[0].(map[string]any)
|
||||
host = asStr(v0["address"])
|
||||
port = asStr(v0["port"])
|
||||
if users, _ := v0["users"].([]any); len(users) > 0 {
|
||||
if u0, ok := users[0].(map[string]any); ok {
|
||||
id = asStr(u0["id"])
|
||||
flow = asStr(u0["flow"])
|
||||
aid = asStr(u0["alterId"]) // vmess only
|
||||
scy = asStr(u0["security"]) // vmess only
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func xrayServer(settings map[string]any) (host, port, pw, method string) {
|
||||
if settings == nil {
|
||||
return
|
||||
}
|
||||
servers, _ := settings["servers"].([]any)
|
||||
if len(servers) == 0 {
|
||||
return
|
||||
}
|
||||
s0, _ := servers[0].(map[string]any)
|
||||
host = asStr(s0["address"])
|
||||
port = asStr(s0["port"])
|
||||
pw = asStr(s0["password"])
|
||||
method = asStr(s0["method"])
|
||||
return
|
||||
}
|
||||
|
||||
// --- sing-box JSON (outbounds, discriminated by type) ---
|
||||
|
||||
func parseSingboxOutbounds(body []byte) []string {
|
||||
var doc struct {
|
||||
Outbounds []map[string]any `json:"outbounds"`
|
||||
}
|
||||
if json.Unmarshal(body, &doc) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, ob := range doc.Outbounds {
|
||||
if uri, ok := singboxOutboundToLink(ob); ok {
|
||||
out = append(out, uri)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func singboxOutboundToLink(ob map[string]any) (string, bool) {
|
||||
typ := strings.ToLower(asStr(ob["type"]))
|
||||
tag := asStr(ob["tag"])
|
||||
host := asStr(ob["server"])
|
||||
port := asStr(ob["server_port"])
|
||||
so := streamOpts{}
|
||||
if tls := subMap(ob, "tls"); tls != nil && truthy(tls["enabled"]) {
|
||||
so.security = "tls"
|
||||
so.sni = asStr(tls["server_name"])
|
||||
if r := subMap(tls, "reality"); r != nil && truthy(r["enabled"]) {
|
||||
so.security = "reality"
|
||||
so.pbk = asStr(r["public_key"])
|
||||
so.sid = asStr(r["short_id"])
|
||||
}
|
||||
if u := subMap(tls, "utls"); u != nil {
|
||||
so.fp = asStr(u["fingerprint"])
|
||||
}
|
||||
}
|
||||
if tr := subMap(ob, "transport"); tr != nil {
|
||||
so.network = strings.ToLower(asStr(tr["type"]))
|
||||
so.path = asStr(tr["path"])
|
||||
so.serviceName = asStr(tr["service_name"])
|
||||
if h := subMap(tr, "headers"); h != nil {
|
||||
so.host = asStr(h["Host"])
|
||||
}
|
||||
}
|
||||
so.flow = asStr(ob["flow"])
|
||||
if host == "" {
|
||||
return "", false
|
||||
}
|
||||
q := streamQuery(so)
|
||||
switch typ {
|
||||
case "vless":
|
||||
return synthLink(typ, asStr(ob["uuid"]), host, port, tag, q), true
|
||||
case "vmess":
|
||||
// vmess must be the base64-JSON form — parseVMess rejects a
|
||||
// vless-style URI, silently dropping the node.
|
||||
return vmessLink(asStr(ob["uuid"]), host, port, tag, asStr(ob["alter_id"]), asStr(ob["security"]), so), true
|
||||
case "trojan":
|
||||
return synthLink("trojan", asStr(ob["password"]), host, port, tag, q), true
|
||||
case "shadowsocks":
|
||||
return ssLink(asStr(ob["method"]), asStr(ob["password"]), host, port, tag), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// assertVmessRoundTrip re-parses a converted vmess link and checks the
|
||||
// connection-defining fields survived the format conversion.
|
||||
func assertVmessRoundTrip(t *testing.T, link, id, host string, port int, network, security string) Outbound {
|
||||
t.Helper()
|
||||
if !strings.HasPrefix(link, "vmess://") {
|
||||
t.Fatalf("not a vmess link: %q", link)
|
||||
}
|
||||
ob, err := ParseShareLink(link, "n")
|
||||
if err != nil {
|
||||
t.Fatalf("reparse: %v", err)
|
||||
}
|
||||
if ob["protocol"] != "vmess" {
|
||||
t.Fatalf("protocol = %v", ob["protocol"])
|
||||
}
|
||||
v0 := ob["settings"].(map[string]any)["vnext"].([]any)[0].(map[string]any)
|
||||
if v0["address"] != host || v0["port"] != port {
|
||||
t.Fatalf("address/port = %v/%v", v0["address"], v0["port"])
|
||||
}
|
||||
u0 := v0["users"].([]any)[0].(map[string]any)
|
||||
if u0["id"] != id {
|
||||
t.Fatalf("id = %v", u0["id"])
|
||||
}
|
||||
ss := ob["streamSettings"].(map[string]any)
|
||||
if ss["network"] != network {
|
||||
t.Fatalf("network = %v", ss["network"])
|
||||
}
|
||||
if security == "" {
|
||||
if _, ok := ss["security"]; ok {
|
||||
t.Fatalf("unexpected security: %v", ss["security"])
|
||||
}
|
||||
} else if ss["security"] != security {
|
||||
t.Fatalf("security = %v", ss["security"])
|
||||
}
|
||||
return ob
|
||||
}
|
||||
|
||||
// Regression for the vmess drop: the converters used to emit a vless-style
|
||||
// "vmess://uuid@host:port?..." URI that parseVMess (base64-JSON only) rejects,
|
||||
// so every vmess node from Clash/Xray/sing-box subs silently vanished.
|
||||
func TestClashVmessRoundTrip(t *testing.T) {
|
||||
body := `proxies:
|
||||
- {name: vm-ws, type: vmess, server: vm.example.com, port: 443, uuid: 55555555-5555-5555-5555-555555555555, alterId: 0, cipher: auto, network: ws, tls: true, servername: sni.example.com, ws-opts: {path: /vm, headers: {Host: cdn.example.com}}}`
|
||||
links := parseClashProxies([]byte(body))
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("want 1 link, got %d: %v", len(links), links)
|
||||
}
|
||||
ob := assertVmessRoundTrip(t, links[0], "55555555-5555-5555-5555-555555555555", "vm.example.com", 443, "ws", "tls")
|
||||
ss := ob["streamSettings"].(map[string]any)
|
||||
ws := ss["wsSettings"].(map[string]any)
|
||||
if ws["path"] != "/vm" || ws["host"] != "cdn.example.com" {
|
||||
t.Fatalf("ws opts lost: %v", ws)
|
||||
}
|
||||
tls := ss["tlsSettings"].(map[string]any)
|
||||
if tls["serverName"] != "sni.example.com" {
|
||||
t.Fatalf("sni lost: %v", tls)
|
||||
}
|
||||
if linkName(links[0], 1) != "vm-ws" {
|
||||
t.Fatalf("name lost: %q", linkName(links[0], 1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestXrayVmessRoundTrip(t *testing.T) {
|
||||
body := `{"outbounds":[
|
||||
{"tag":"vm","protocol":"vmess","settings":{"vnext":[{"address":"a.example.com","port":8443,"users":[{"id":"66666666-6666-6666-6666-666666666666","alterId":0,"security":"aes-128-gcm"}]}]},"streamSettings":{"network":"ws","security":"tls","tlsSettings":{"serverName":"s.example.com"},"wsSettings":{"path":"/w","headers":{"Host":"h.example.com"}}}}
|
||||
]}`
|
||||
links := parseXrayOutbounds([]byte(body))
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("want 1 link, got %d: %v", len(links), links)
|
||||
}
|
||||
ob := assertVmessRoundTrip(t, links[0], "66666666-6666-6666-6666-666666666666", "a.example.com", 8443, "ws", "tls")
|
||||
u0 := ob["settings"].(map[string]any)["vnext"].([]any)[0].(map[string]any)["users"].([]any)[0].(map[string]any)
|
||||
if u0["security"] != "aes-128-gcm" {
|
||||
t.Fatalf("scy lost: %v", u0["security"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingboxVmessRoundTrip(t *testing.T) {
|
||||
body := `{"outbounds":[
|
||||
{"type":"vmess","tag":"vm","server":"a.example.com","server_port":443,"uuid":"77777777-7777-7777-7777-777777777777","alter_id":0,"security":"auto","tls":{"enabled":true,"server_name":"s.example.com"},"transport":{"type":"ws","path":"/w","headers":{"Host":"h.example.com"}}}
|
||||
]}`
|
||||
links := parseSingboxOutbounds([]byte(body))
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("want 1 link, got %d: %v", len(links), links)
|
||||
}
|
||||
ob := assertVmessRoundTrip(t, links[0], "77777777-7777-7777-7777-777777777777", "a.example.com", 443, "ws", "tls")
|
||||
ws := ob["streamSettings"].(map[string]any)["wsSettings"].(map[string]any)
|
||||
if ws["path"] != "/w" {
|
||||
t.Fatalf("ws path lost: %v", ws)
|
||||
}
|
||||
}
|
||||
|
||||
// synthLink must percent-escape untrusted userinfo and bracket IPv6 hosts —
|
||||
// a raw concatenation mis-parses "p@ss/w#rd" and truncates IPv6 addresses.
|
||||
func TestSynthLinkEscapesUserinfoAndIPv6(t *testing.T) {
|
||||
q := url.Values{}
|
||||
q.Set("type", "tcp")
|
||||
q.Set("security", "tls")
|
||||
link := synthLink("trojan", "p@ss/w#rd:x", "2001:db8::1", "443", "name with spaces", q)
|
||||
ob, err := ParseShareLink(link, "t")
|
||||
if err != nil {
|
||||
t.Fatalf("reparse: %v", err)
|
||||
}
|
||||
srv := ob["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||||
if srv["password"] != "p@ss/w#rd:x" {
|
||||
t.Fatalf("password mangled: %v", srv["password"])
|
||||
}
|
||||
if srv["address"] != "2001:db8::1" || srv["port"] != 443 {
|
||||
t.Fatalf("IPv6 host/port mangled: %v/%v", srv["address"], srv["port"])
|
||||
}
|
||||
if linkName(link, 1) != "name with spaces" {
|
||||
t.Fatalf("fragment mangled: %q", linkName(link, 1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSLinkIPv6(t *testing.T) {
|
||||
link := ssLink("aes-256-gcm", "pw", "2001:db8::2", "8388", "v6")
|
||||
ob, err := ParseShareLink(link, "s")
|
||||
if err != nil {
|
||||
t.Fatalf("reparse: %v", err)
|
||||
}
|
||||
srv := ob["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||||
if srv["address"] != "2001:db8::2" || srv["port"] != 8388 {
|
||||
t.Fatalf("IPv6 host/port mangled: %v/%v", srv["address"], srv["port"])
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: a proxy-groups section (whose entries have a nested, indented
|
||||
// "proxies:" member list) BEFORE the top-level proxies list used to anchor the
|
||||
// parser on the group members, yielding zero nodes.
|
||||
func TestParseClashProxyGroupsFirst(t *testing.T) {
|
||||
body := `port: 7890
|
||||
proxy-groups:
|
||||
- name: auto
|
||||
type: url-test
|
||||
proxies:
|
||||
- node-a
|
||||
- node-b
|
||||
proxies:
|
||||
- {name: node-a, type: trojan, server: a.example.com, port: 443, password: pw}
|
||||
- {name: node-b, type: vless, server: b.example.com, port: 443, uuid: 88888888-8888-8888-8888-888888888888}`
|
||||
links := parseClashProxies([]byte(body))
|
||||
if len(links) != 2 {
|
||||
t.Fatalf("want 2 links, got %d: %v", len(links), links)
|
||||
}
|
||||
if !strings.HasPrefix(links[0], "trojan://") || !strings.HasPrefix(links[1], "vless://") {
|
||||
t.Fatalf("wrong links: %v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClashYAMLFlow(t *testing.T) {
|
||||
body := `proxies:
|
||||
- {name: 🇳🇱 NL-reality, type: vless, server: nl.example.com, port: 443, uuid: 11111111-1111-1111-1111-111111111111, network: tcp, tls: true, servername: www.microsoft.com, flow: xtls-rprx-vision, reality-opts: {public-key: PBK, short-id: aa}}
|
||||
- {name: 🇩🇪 DE-trojan, type: trojan, server: de.example.com, port: 443, password: pw, network: tcp, tls: true, sni: de.example.com}
|
||||
proxy-groups:
|
||||
- {name: x}`
|
||||
links := parseClashProxies([]byte(body))
|
||||
if len(links) != 2 {
|
||||
t.Fatalf("want 2 links, got %d: %v", len(links), links)
|
||||
}
|
||||
if !strings.HasPrefix(links[0], "vless://") {
|
||||
t.Fatalf("link0 = %q", links[0])
|
||||
}
|
||||
ob, err := ParseShareLink(links[0], "n")
|
||||
if err != nil {
|
||||
t.Fatalf("reparse: %v", err)
|
||||
}
|
||||
ss := ob["streamSettings"].(map[string]any)
|
||||
if ss["security"] != "reality" {
|
||||
t.Fatalf("reality not carried: %v", ss)
|
||||
}
|
||||
if !strings.HasPrefix(links[1], "trojan://") {
|
||||
t.Fatalf("link1 = %q", links[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClashYAMLBlock(t *testing.T) {
|
||||
body := `proxies:
|
||||
- name: WS-node
|
||||
type: vless
|
||||
server: h.example.com
|
||||
port: 443
|
||||
uuid: 22222222-2222-2222-2222-222222222222
|
||||
network: ws
|
||||
tls: true
|
||||
ws-opts:
|
||||
path: /ws
|
||||
headers:
|
||||
Host: cdn.example.com`
|
||||
links := parseClashProxies([]byte(body))
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("want 1 link, got %d: %v", len(links), links)
|
||||
}
|
||||
if !strings.Contains(links[0], "type=ws") || !strings.Contains(links[0], "path=") {
|
||||
t.Fatalf("ws opts not carried: %q", links[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClashSkipsUnknown(t *testing.T) {
|
||||
body := `proxies:
|
||||
- {name: hy2, type: hysteria2, server: h, port: 1, password: p}
|
||||
- {name: ok, type: trojan, server: h, port: 443, password: p}`
|
||||
links := parseClashProxies([]byte(body))
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("unknown type not skipped: %v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXrayOutbounds(t *testing.T) {
|
||||
body := `{"outbounds":[
|
||||
{"tag":"v","protocol":"vless","settings":{"vnext":[{"address":"a.com","port":443,"users":[{"id":"33333333-3333-3333-3333-333333333333","flow":"xtls-rprx-vision"}]}]},"streamSettings":{"network":"tcp","security":"reality","realitySettings":{"serverName":"m.com","publicKey":"PBK","shortId":"aa"}}},
|
||||
{"protocol":"freedom","tag":"direct"}
|
||||
]}`
|
||||
links := parseXrayOutbounds([]byte(body))
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("want 1 (freedom skipped), got %d: %v", len(links), links)
|
||||
}
|
||||
ob, err := ParseShareLink(links[0], "n")
|
||||
if err != nil || ob["protocol"] != "vless" {
|
||||
t.Fatalf("reparse: %v %v", ob, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSingboxOutbounds(t *testing.T) {
|
||||
body := `{"outbounds":[
|
||||
{"type":"vless","tag":"v","server":"a.com","server_port":443,"uuid":"44444444-4444-4444-4444-444444444444","flow":"xtls-rprx-vision","tls":{"enabled":true,"server_name":"m.com","reality":{"enabled":true,"public_key":"PBK","short_id":"aa"},"utls":{"fingerprint":"chrome"}}},
|
||||
{"type":"selector","tag":"sel"}
|
||||
]}`
|
||||
links := parseSingboxOutbounds([]byte(body))
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("want 1 (selector skipped), got %d: %v", len(links), links)
|
||||
}
|
||||
if !strings.Contains(links[0], "pbk=PBK") {
|
||||
t.Fatalf("reality pbk missing: %q", links[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSubFormat(t *testing.T) {
|
||||
cases := []struct{ body, want string }{
|
||||
{"proxies:\n - {name: x}", "clash"},
|
||||
{`{"outbounds":[{"type":"vless"}]}`, "singbox"},
|
||||
{`{"outbounds":[{"protocol":"vless"}]}`, "xray"},
|
||||
{"vless://x@h:1?type=tcp#a", "links"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DetectSubFormat([]byte(c.body), "", "auto"); got != c.want {
|
||||
t.Errorf("DetectSubFormat(%.20q) = %q, want %q", c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
// explicit override wins
|
||||
if DetectSubFormat([]byte("proxies:"), "", "links") != "links" {
|
||||
t.Error("override not honored")
|
||||
}
|
||||
}
|
||||
|
||||
// --- filters ---
|
||||
|
||||
func TestSubFilterExcludeRegex(t *testing.T) {
|
||||
links := []string{
|
||||
"vless://a@h:1?type=tcp#NL-fast",
|
||||
"trojan://b@h:1?security=tls#Trojan-DE",
|
||||
"ss://YWVzOnB3@h:1#SS-jp",
|
||||
}
|
||||
out := subApplyFilters(links, FilterSpec{Exclude: []string{"(?i)trojan"}})
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("exclude regex failed: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubFilterProtoWhitelist(t *testing.T) {
|
||||
links := []string{
|
||||
"vless://a@h:1?type=tcp#a",
|
||||
"trojan://b@h:1?security=tls#b",
|
||||
}
|
||||
out := subApplyFilters(links, FilterSpec{Proto: []string{"vless"}})
|
||||
if len(out) != 1 || linkProto(out[0]) != "vless" {
|
||||
t.Fatalf("proto whitelist failed: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCountryFromFlag(t *testing.T) {
|
||||
if nodeCountry("🇳🇱 NL-Amsterdam") != "NL" {
|
||||
t.Errorf("flag NL not detected: %q", nodeCountry("🇳🇱 NL-Amsterdam"))
|
||||
}
|
||||
if nodeCountry("plain node name") != "" {
|
||||
t.Errorf("false country: %q", nodeCountry("plain node name"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubFilterCountry(t *testing.T) {
|
||||
links := []string{
|
||||
"vless://a@h:1?type=tcp#🇳🇱 NL-1",
|
||||
"vless://b@h:1?type=tcp#🇩🇪 DE-1",
|
||||
}
|
||||
nl := subApplyFilters(links, FilterSpec{Country: []string{"NL"}})
|
||||
if len(nl) != 1 || !strings.Contains(nl[0], "NL") {
|
||||
t.Fatalf("whitelist NL failed: %v", nl)
|
||||
}
|
||||
noDE := subApplyFilters(links, FilterSpec{Country: []string{"!DE"}})
|
||||
if len(noDE) != 1 || strings.Contains(noDE[0], "DE") {
|
||||
t.Fatalf("exclude DE failed: %v", noDE)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubFilterDedup(t *testing.T) {
|
||||
links := []string{
|
||||
"vless://a@h.com:443?type=tcp&security=reality&pbk=P&sni=m#one",
|
||||
"vless://a@h.com:443?type=tcp&security=reality&pbk=P&sni=m#two",
|
||||
}
|
||||
out := subApplyFilters(links, FilterSpec{Dedup: true})
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("dedup failed: %v", out)
|
||||
}
|
||||
}
|
||||
-335
@@ -1,335 +0,0 @@
|
||||
package main
|
||||
|
||||
// UCI ingestion. On a device we shell out to `uci -q export shater`; the export
|
||||
// format (package/config/option/list) is parsed by a pure function so the whole
|
||||
// mapping is unit-testable from a string fixture.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// uciSection is one `config <type> ['name']` block.
|
||||
type uciSection struct {
|
||||
Type string
|
||||
Name string
|
||||
Options map[string]string
|
||||
Lists map[string][]string
|
||||
}
|
||||
|
||||
// ReadUCI runs `uci -q export shater` and parses it into a Model.
|
||||
func ReadUCI() (*Model, error) {
|
||||
out, err := exec.Command("uci", "-q", "export", "shater").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uci export shater: %w", err)
|
||||
}
|
||||
return ParseUCIExport(string(out))
|
||||
}
|
||||
|
||||
// ParseUCIExport parses the textual `uci export` format into a Model.
|
||||
func ParseUCIExport(text string) (*Model, error) {
|
||||
secs, err := parseSections(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
for _, s := range secs {
|
||||
switch s.Type {
|
||||
case "globals":
|
||||
applyGlobals(&m.Globals, s)
|
||||
case "inbound":
|
||||
typ := s.optOr("type", "tproxy")
|
||||
m.Inbounds = append(m.Inbounds, Inbound{
|
||||
Name: s.opt("name"),
|
||||
Enabled: s.optBool("enabled", true),
|
||||
Type: typ,
|
||||
Network: s.opt("network"),
|
||||
TproxyPort: parseInt(s.opt("tproxy_port"), 12345),
|
||||
Listen: s.optOr("listen", "127.0.0.1"),
|
||||
Port: parseInt(s.opt("port"), 0),
|
||||
Auth: s.optOr("auth", "noauth"),
|
||||
User: s.opt("user"),
|
||||
Pass: s.opt("pass"),
|
||||
TargetAddr: s.opt("target_addr"),
|
||||
TargetPort: parseInt(s.opt("target_port"), 0),
|
||||
TargetNetwork: s.optOr("target_network", "udp"),
|
||||
TCP: s.optBool("tcp", true),
|
||||
UDP: s.optBool("udp", true),
|
||||
// sniff defaults on for tproxy/socks/http, off for dokodemo-wrap.
|
||||
Sniff: s.optBool("sniff", typ != "dokodemo"),
|
||||
})
|
||||
case "subscription":
|
||||
m.Subscriptions = append(m.Subscriptions, Subscription{
|
||||
Name: s.opt("name"),
|
||||
Enabled: s.optBool("enabled", true),
|
||||
URL: s.opt("url"),
|
||||
UpdateInterval: s.opt("update_interval"),
|
||||
FetchVia: s.optOr("fetch_via", "direct"),
|
||||
UA: s.opt("ua"),
|
||||
HWID: s.opt("hwid"),
|
||||
DeviceOS: s.opt("device_os"),
|
||||
VerOS: s.opt("ver_os"),
|
||||
DeviceModel: s.opt("device_model"),
|
||||
Headers: s.list("header"),
|
||||
Format: s.optOr("format", "auto"),
|
||||
Include: nonEmpty(s.list("include")),
|
||||
Exclude: nonEmpty(s.list("exclude")),
|
||||
FilterProto: nonEmpty(s.list("filter_proto")),
|
||||
FilterCountry: nonEmpty(s.list("filter_country")),
|
||||
Dedup: s.optBool("dedup", true),
|
||||
ExpireAlertDays: parseInt(s.opt("expire_alert_days"), 3),
|
||||
})
|
||||
case "node":
|
||||
m.Nodes = append(m.Nodes, Node{
|
||||
Name: s.opt("name"),
|
||||
Enabled: s.optBool("enabled", true),
|
||||
URI: s.opt("uri"),
|
||||
Mux: s.optBool("mux", false),
|
||||
MuxConcurrency: parseInt(s.opt("mux_concurrency"), 8),
|
||||
XUDPConcurrency: parseInt(s.opt("xudp_concurrency"), 16),
|
||||
XUDPProxyUDP443: s.optOr("xudp_udp443", "reject"),
|
||||
Mark: parseUint32(s.opt("sockopt_mark"), 0),
|
||||
TCPFastOpen: s.opt("tcp_fast_open"),
|
||||
TCPKeepAliveIdle: parseInt(s.opt("tcp_keepalive_idle"), 0),
|
||||
})
|
||||
case "group":
|
||||
m.Groups = append(m.Groups, Group{
|
||||
Name: s.opt("name"),
|
||||
Source: s.optOr("source", "subscription"),
|
||||
Subscription: s.opt("subscription"),
|
||||
Nodes: s.list("node"),
|
||||
Strategy: s.optOr("strategy", "leastping"),
|
||||
Include: nonEmpty(s.list("include")),
|
||||
Exclude: nonEmpty(s.list("exclude")),
|
||||
FilterProto: nonEmpty(s.list("filter_proto")),
|
||||
FilterCountry: nonEmpty(s.list("filter_country")),
|
||||
Dedup: s.optBool("dedup", false),
|
||||
// Empty unless the group EXPLICITLY overrides — otherwise the global
|
||||
// observatory default (globals.probe_url) would never win.
|
||||
ProbeURL: s.opt("probe_url"),
|
||||
ProbeInterval: s.opt("probe_interval"),
|
||||
})
|
||||
case "chain":
|
||||
m.Chains = append(m.Chains, Chain{
|
||||
Name: s.opt("name"),
|
||||
Hops: s.list("hop"),
|
||||
})
|
||||
case "egress":
|
||||
m.Egresses = append(m.Egresses, Egress{
|
||||
Name: s.opt("name"),
|
||||
Type: s.optOr("type", "direct"),
|
||||
Interface: s.opt("interface"),
|
||||
Target: s.opt("target"),
|
||||
})
|
||||
case "ruleset":
|
||||
m.Rulesets = append(m.Rulesets, Ruleset{
|
||||
Name: s.opt("name"),
|
||||
Type: s.optOr("type", "domain"),
|
||||
Source: s.optOr("source", "inline"),
|
||||
URL: s.opt("url"),
|
||||
Path: s.opt("path"),
|
||||
Format: s.optOr("format", "plain"),
|
||||
UpdateInterval: s.opt("update_interval"),
|
||||
Entries: s.list("entry"),
|
||||
})
|
||||
case "rule":
|
||||
m.Rules = append(m.Rules, Rule{
|
||||
Name: s.opt("name"),
|
||||
Enabled: s.optBool("enabled", true),
|
||||
Order: parseInt(s.opt("order"), 0),
|
||||
Src: s.list("src"),
|
||||
DstDomain: s.list("dst_domain"),
|
||||
DstRuleset: s.list("dst_ruleset"),
|
||||
DstIP: s.list("dst_ip"),
|
||||
DstPort: s.opt("dst_port"),
|
||||
Proto: s.opt("proto"),
|
||||
Target: s.opt("target"),
|
||||
Egress: s.opt("egress"),
|
||||
Kill: s.optOr("kill", "default"),
|
||||
SchedEnabled: s.optBool("sched_enabled", false),
|
||||
SchedDays: s.list("sched_day"),
|
||||
SchedStart: s.opt("sched_start"),
|
||||
SchedEnd: s.opt("sched_end"),
|
||||
SchedTZ: s.opt("sched_tz"),
|
||||
})
|
||||
case "preset":
|
||||
m.Presets = append(m.Presets, Preset{
|
||||
Name: s.optOr("name", s.Name),
|
||||
Enabled: s.optBool("enabled", false),
|
||||
Order: parseInt(s.opt("order"), 0),
|
||||
Target: s.opt("target"),
|
||||
})
|
||||
case "profile":
|
||||
m.Profiles = append(m.Profiles, Profile{
|
||||
Name: s.optOr("name", s.Name),
|
||||
Enabled: s.optBool("enabled", false),
|
||||
Priority: parseInt(s.opt("priority"), 0),
|
||||
MatchIface: nonEmpty(s.list("match_iface")),
|
||||
ProbeURL: s.opt("probe_url"),
|
||||
ProbeMode: s.optOr("probe_mode", "up"),
|
||||
SchedDays: s.list("sched_day"),
|
||||
SchedStart: s.opt("sched_start"),
|
||||
SchedEnd: s.opt("sched_end"),
|
||||
SchedTZ: s.opt("sched_tz"),
|
||||
EnableRules: nonEmpty(s.list("enable_rule")),
|
||||
DisableRules: nonEmpty(s.list("disable_rule")),
|
||||
DefaultTarget: s.opt("default_target"),
|
||||
DefaultEgress: s.opt("default_egress"),
|
||||
})
|
||||
case "resolver":
|
||||
m.Resolvers = append(m.Resolvers, Resolver{
|
||||
Name: firstNonEmpty(s.opt("name"), s.Name),
|
||||
Type: s.optOr("type", "plain"),
|
||||
Address: s.opt("address"),
|
||||
Detour: s.opt("detour"),
|
||||
Pool: s.opt("pool"),
|
||||
})
|
||||
case "dns_rule":
|
||||
m.DNSRules = append(m.DNSRules, DNSRule{
|
||||
Order: parseInt(s.opt("order"), 0),
|
||||
MatchDomain: s.list("match_domain"),
|
||||
MatchSrc: s.list("match_src"),
|
||||
Resolver: s.opt("resolver"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func applyGlobals(g *Globals, s uciSection) {
|
||||
g.Enabled = s.optBool("enabled", g.Enabled)
|
||||
g.LogLevel = s.optOr("loglevel", g.LogLevel)
|
||||
g.KillSwitch = s.optOr("kill_switch", g.KillSwitch)
|
||||
g.DNSMode = s.optOr("dns_mode", g.DNSMode)
|
||||
g.IPv6 = s.optBool("ipv6", g.IPv6)
|
||||
g.FwmarkBase = parseUint32(s.opt("fwmark_base"), g.FwmarkBase)
|
||||
g.TableBase = parseUint32(s.opt("table_base"), g.TableBase)
|
||||
g.ConfirmTimeout = parseInt(s.opt("confirm_timeout"), 0)
|
||||
g.ResolverDefault = s.opt("resolver_default")
|
||||
g.ResolverFallback = s.opt("resolver_fallback")
|
||||
g.ProbeURL = s.opt("probe_url")
|
||||
g.ProbeInterval = s.opt("probe_interval")
|
||||
g.SchemaVersion = parseInt(s.opt("schema_version"), g.SchemaVersion)
|
||||
g.ActiveProfile = s.opt("active_profile")
|
||||
}
|
||||
|
||||
// --- section accessors ---
|
||||
|
||||
func (s uciSection) opt(k string) string { return s.Options[k] }
|
||||
|
||||
func (s uciSection) optOr(k, def string) string {
|
||||
if v, ok := s.Options[k]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (s uciSection) optBool(k string, def bool) bool {
|
||||
if v, ok := s.Options[k]; ok && v != "" {
|
||||
return parseBool(v)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (s uciSection) list(k string) []string { return s.Lists[k] }
|
||||
|
||||
// --- tokenizer ---
|
||||
|
||||
func parseSections(text string) ([]uciSection, error) {
|
||||
var secs []uciSection
|
||||
var cur *uciSection
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
ln := 0
|
||||
for sc.Scan() {
|
||||
ln++
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "package ") {
|
||||
continue
|
||||
}
|
||||
kw, rest := cutWord(line)
|
||||
switch kw {
|
||||
case "config":
|
||||
if cur != nil {
|
||||
secs = append(secs, *cur)
|
||||
}
|
||||
typ, name := parseConfigLine(rest)
|
||||
cur = &uciSection{Type: typ, Name: name, Options: map[string]string{}, Lists: map[string][]string{}}
|
||||
case "option":
|
||||
if cur == nil {
|
||||
return nil, fmt.Errorf("line %d: option outside config", ln)
|
||||
}
|
||||
key, val := cutWord(rest)
|
||||
cur.Options[key] = unquote(val)
|
||||
case "list":
|
||||
if cur == nil {
|
||||
return nil, fmt.Errorf("line %d: list outside config", ln)
|
||||
}
|
||||
key, val := cutWord(rest)
|
||||
v := unquote(val)
|
||||
if v != "" {
|
||||
cur.Lists[key] = append(cur.Lists[key], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur != nil {
|
||||
secs = append(secs, *cur)
|
||||
}
|
||||
return secs, sc.Err()
|
||||
}
|
||||
|
||||
// parseConfigLine handles `inbound`, `globals 'globals'`, `resolver 'default'`.
|
||||
func parseConfigLine(rest string) (typ, name string) {
|
||||
typ, r := cutWord(rest)
|
||||
name = unquote(strings.TrimSpace(r))
|
||||
return typ, name
|
||||
}
|
||||
|
||||
// cutWord splits off the first whitespace-delimited token.
|
||||
func cutWord(s string) (word, rest string) {
|
||||
s = strings.TrimSpace(s)
|
||||
i := strings.IndexAny(s, " \t")
|
||||
if i < 0 {
|
||||
return s, ""
|
||||
}
|
||||
return s[:i], strings.TrimSpace(s[i+1:])
|
||||
}
|
||||
|
||||
// unquote strips one layer of matching single/double quotes. uci export
|
||||
// escapes an embedded single quote shell-style (close, escaped quote,
|
||||
// reopen), so after stripping the outer quotes the residue backslash-quote
|
||||
// sequence collapses back to a plain single quote. See
|
||||
// TestUnquoteEscapedQuote for the literal byte form.
|
||||
func unquote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 {
|
||||
if s[0] == '\'' && s[len(s)-1] == '\'' {
|
||||
return strings.ReplaceAll(s[1:len(s)-1], `'\''`, `'`)
|
||||
}
|
||||
if s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nonEmpty(in []string) []string {
|
||||
var out []string
|
||||
for _, v := range in {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(vs ...string) string {
|
||||
for _, v := range vs {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
const uciFixture = `package xray
|
||||
|
||||
config globals 'globals'
|
||||
option enabled '1'
|
||||
option loglevel 'warning'
|
||||
option kill_switch 'closed'
|
||||
option dns_mode 'nftset'
|
||||
option fwmark_base '0x2000'
|
||||
option table_base '0x2000'
|
||||
|
||||
config inbound
|
||||
option name 'lan'
|
||||
option enabled '1'
|
||||
option network 'br-lan'
|
||||
option tproxy_port '12345'
|
||||
option tcp '1'
|
||||
option udp '1'
|
||||
option sniff '1'
|
||||
|
||||
config subscription
|
||||
option name 'qomar'
|
||||
option enabled '1'
|
||||
option url 'https://pro.qomar.pw/sub/x'
|
||||
option hwid 'auto'
|
||||
option ua 'Happ/3.13.0'
|
||||
list header 'x-key: val'
|
||||
|
||||
config node
|
||||
option name 'reality-nl'
|
||||
option enabled '1'
|
||||
option uri 'vless://11111111-1111-1111-1111-111111111111@a.example.com:443?type=tcp&security=reality&pbk=P&sni=w#nl'
|
||||
|
||||
config group
|
||||
option name 'sub0'
|
||||
option source 'subscription'
|
||||
option subscription 'qomar'
|
||||
option strategy 'leastping'
|
||||
|
||||
config chain
|
||||
option name 'triple'
|
||||
list hop 'group:sub0'
|
||||
list hop 'node:reality-nl'
|
||||
|
||||
config rule
|
||||
option name 'pc-triple'
|
||||
option enabled '1'
|
||||
option order '10'
|
||||
list src '192.168.11.14/32'
|
||||
list dst_domain 'geosite:telegram'
|
||||
option dst_port '443'
|
||||
option proto 'tcp,udp'
|
||||
option target 'chain:triple'
|
||||
`
|
||||
|
||||
func TestParseUCIExport(t *testing.T) {
|
||||
m, err := ParseUCIExport(uciFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.Globals.Enabled || m.Globals.FwmarkBase != 0x2000 || m.Globals.TableBase != 0x2000 {
|
||||
t.Fatalf("globals = %+v", m.Globals)
|
||||
}
|
||||
if len(m.Inbounds) != 1 || m.Inbounds[0].TproxyPort != 12345 || !m.Inbounds[0].UDP {
|
||||
t.Fatalf("inbounds = %+v", m.Inbounds)
|
||||
}
|
||||
if len(m.Subscriptions) != 1 || m.Subscriptions[0].HWID != "auto" {
|
||||
t.Fatalf("subs = %+v", m.Subscriptions)
|
||||
}
|
||||
if len(m.Subscriptions[0].Headers) != 1 || m.Subscriptions[0].Headers[0] != "x-key: val" {
|
||||
t.Fatalf("headers = %+v", m.Subscriptions[0].Headers)
|
||||
}
|
||||
if len(m.Nodes) != 1 || m.Nodes[0].Name != "reality-nl" {
|
||||
t.Fatalf("nodes = %+v", m.Nodes)
|
||||
}
|
||||
if len(m.Groups) != 1 || m.Groups[0].Subscription != "qomar" {
|
||||
t.Fatalf("groups = %+v", m.Groups)
|
||||
}
|
||||
if len(m.Chains) != 1 || len(m.Chains[0].Hops) != 2 {
|
||||
t.Fatalf("chains = %+v", m.Chains)
|
||||
}
|
||||
if len(m.Rules) != 1 {
|
||||
t.Fatalf("rules = %+v", m.Rules)
|
||||
}
|
||||
r := m.Rules[0]
|
||||
if r.Order != 10 || len(r.Src) != 1 || r.DstPort != "443" || r.Target != "chain:triple" {
|
||||
t.Fatalf("rule = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileFingerprint(t *testing.T) {
|
||||
l1 := "vless://11111111-1111-1111-1111-111111111111@a.example.com:443?type=tcp&security=reality&pbk=P&sni=w#one"
|
||||
l2 := "trojan://pw@b.example.com:443?security=tls&sni=b.example.com#two"
|
||||
|
||||
// First fetch: two nodes.
|
||||
c := ReconcileSub(SubCache{Name: "s"}, "s", []string{l1, l2})
|
||||
if len(c.Nodes) != 2 {
|
||||
t.Fatalf("first reconcile: want 2, got %d", len(c.Nodes))
|
||||
}
|
||||
|
||||
// Second fetch: l2 vanished -> kept as stale; l1 present -> kept fresh.
|
||||
c2 := ReconcileSub(c, "s", []string{l1})
|
||||
var fresh, stale int
|
||||
for _, n := range c2.Nodes {
|
||||
if n.Stale {
|
||||
stale++
|
||||
} else {
|
||||
fresh++
|
||||
}
|
||||
}
|
||||
if fresh != 1 || stale != 1 {
|
||||
t.Fatalf("second reconcile: fresh=%d stale=%d nodes=%+v", fresh, stale, c2.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSubscriptionBodyBase64(t *testing.T) {
|
||||
// base64 of two links joined by newline
|
||||
body := []byte("dmxlc3M6Ly8xMTExMTExMS0xMTExLTExMTEtMTExMS0xMTExMTExMTExMTFAYS5leGFtcGxlLmNvbTo0NDM/dHlwZT10Y3Amc2VjdXJpdHk9cmVhbGl0eSNvbmUKdHJvamFuOi8vcHdAYi5leGFtcGxlLmNvbTo0NDM/c2VjdXJpdHk9dGxzI3R3bw==")
|
||||
links := ParseSubscriptionBody(body)
|
||||
if len(links) != 2 {
|
||||
t.Fatalf("want 2 links, got %d: %v", len(links), links)
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
package main
|
||||
|
||||
// WireGuard / AmneziaWG outbound support (catalog 05 §1, T2).
|
||||
//
|
||||
// Design: nodes are URI-only (a Node carries just a share-link string, re-parsed
|
||||
// by ParseShareLink at every build site). So WireGuard is a new `wireguard://`
|
||||
// share-link scheme handled in sharelink.go, exactly like vless/vmess/trojan/ss —
|
||||
// the generator, fingerprint, probe, chain, group, and egress paths all keep
|
||||
// working unchanged. A pasted wg-quick / AmneziaWG `.conf` (INI) is converted to a
|
||||
// `wireguard://` URI at import time (parseWGConf + wgConfigToURI below).
|
||||
//
|
||||
// Canonical URI grammar:
|
||||
// wireguard://<urlenc-b64-secret>@<host>:<port>?<params>#<name>
|
||||
// params (publickey required; rest optional):
|
||||
// publickey|pbk, presharedkey|psk, address|ip (csv), allowedips (csv),
|
||||
// mtu, keepalive|persistentkeepalive, reserved ("a,b,c" or base64), workers,
|
||||
// jc jmin jmax s1 s2 h1 h2 h3 h4 (AmneziaWG; emitted only if wgAWGSupported)
|
||||
//
|
||||
// wgAWGSupported gates AmneziaWG obfuscation params: mainline xray-core 25.1.30
|
||||
// does NOT implement them (emitting makes `xray -test` fail "unknown field"), so
|
||||
// the default is false — params are preserved in the URI but not rendered into
|
||||
// settings unless an AWG-capable xray is deployed.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var wgAWGSupported = false
|
||||
|
||||
// wgConfig is the intermediate parse of a WG endpoint (not persisted).
|
||||
type wgConfig struct {
|
||||
SecretKey string
|
||||
Address []string
|
||||
PublicKey string
|
||||
PSK string
|
||||
Endpoint string // host:port
|
||||
AllowedIPs []string
|
||||
KeepAlive int
|
||||
MTU int
|
||||
Reserved []int
|
||||
Workers int
|
||||
AWG map[string]int
|
||||
}
|
||||
|
||||
// splitCSV splits a comma list, trimming blanks.
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// atoiOr parses an int, returning def on failure.
|
||||
func atoiOr(s string, def int) int {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// splitEndpoint splits "host:port" (IPv6-aware) into (host, port).
|
||||
func splitEndpoint(ep string) (string, string) {
|
||||
ep = strings.TrimSpace(ep)
|
||||
if strings.HasPrefix(ep, "[") { // [::1]:51820
|
||||
if j := strings.IndexByte(ep, ']'); j >= 0 {
|
||||
host := ep[1:j]
|
||||
rest := ep[j+1:]
|
||||
if strings.HasPrefix(rest, ":") {
|
||||
return host, rest[1:]
|
||||
}
|
||||
return host, ""
|
||||
}
|
||||
}
|
||||
if i := strings.LastIndexByte(ep, ':'); i >= 0 {
|
||||
return ep[:i], ep[i+1:]
|
||||
}
|
||||
return ep, ""
|
||||
}
|
||||
|
||||
// parseReserved parses "a,b,c" (three ints) or a base64 3-byte client-id into a
|
||||
// 3-element []int; returns nil if it can't produce exactly 3 valid bytes.
|
||||
func parseReserved(s string) []int {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(s, ",") {
|
||||
parts := splitCSV(s)
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 3)
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 || n > 255 {
|
||||
return nil
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
if b, ok := b64decode(s); ok && len(b) == 3 {
|
||||
return []int{int(b[0]), int(b[1]), int(b[2])}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toAny3 converts a 3-int slice to []any.
|
||||
func toAny3(v []int) []any {
|
||||
out := make([]any, len(v))
|
||||
for i, n := range v {
|
||||
out[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseWireguard parses a wireguard:// share-link into an xray "wireguard"
|
||||
// outbound. It emits NO streamSettings — withMark() adds the loop-guard mark
|
||||
// (255) later, matching every other outbound, so the WG UDP socket escapes the
|
||||
// tproxy loop exactly like TCP protocols.
|
||||
func parseWireguard(uri, tag string) (Outbound, error) {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
if host == "" || port == "" {
|
||||
return nil, fmt.Errorf("wireguard: bad endpoint host/port")
|
||||
}
|
||||
secret := u.User.Username()
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("wireguard: missing secretKey")
|
||||
}
|
||||
q := u.Query()
|
||||
get := func(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
for qk, vs := range q {
|
||||
if strings.EqualFold(qk, k) && len(vs) > 0 && vs[0] != "" {
|
||||
return vs[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
pub := get("publickey", "pbk")
|
||||
if pub == "" {
|
||||
return nil, fmt.Errorf("wireguard: missing publicKey")
|
||||
}
|
||||
address := splitCSV(get("address", "ip"))
|
||||
if len(address) == 0 {
|
||||
address = []string{"10.0.0.2/32"}
|
||||
}
|
||||
allowed := splitCSV(get("allowedips"))
|
||||
if len(allowed) == 0 {
|
||||
allowed = []string{"0.0.0.0/0", "::/0"}
|
||||
}
|
||||
peer := map[string]any{
|
||||
"publicKey": pub,
|
||||
// joinHostPort so an IPv6 endpoint gets its brackets back —
|
||||
// u.Hostname() strips them, and xray requires "[v6]:port" here.
|
||||
"endpoint": joinHostPort(host, port),
|
||||
"allowedIPs": toAny(allowed),
|
||||
}
|
||||
if psk := get("presharedkey", "psk"); psk != "" {
|
||||
peer["preSharedKey"] = psk
|
||||
}
|
||||
if ka := atoiOr(get("keepalive", "persistentkeepalive"), 0); ka > 0 {
|
||||
peer["keepAlive"] = ka
|
||||
}
|
||||
settings := map[string]any{
|
||||
"secretKey": secret,
|
||||
"address": toAny(address),
|
||||
"peers": []any{peer},
|
||||
}
|
||||
if mtu := atoiOr(get("mtu"), 0); mtu > 0 {
|
||||
settings["mtu"] = mtu
|
||||
}
|
||||
if w := atoiOr(get("workers"), 0); w > 0 {
|
||||
settings["workers"] = w
|
||||
}
|
||||
if r := parseReserved(get("reserved")); len(r) == 3 {
|
||||
settings["reserved"] = toAny3(r)
|
||||
}
|
||||
if wgAWGSupported {
|
||||
for _, k := range []string{"jc", "jmin", "jmax", "s1", "s2", "h1", "h2", "h3", "h4"} {
|
||||
if v := atoiOr(get(k), -1); v >= 0 {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return Outbound{
|
||||
"tag": tag,
|
||||
"protocol": "wireguard",
|
||||
"settings": settings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// looksLikeWGConf reports whether a blob is a wg-quick / AmneziaWG .conf (INI).
|
||||
func looksLikeWGConf(s string) bool {
|
||||
l := strings.ToLower(s)
|
||||
return strings.Contains(l, "[interface]") && strings.Contains(l, "[peer]")
|
||||
}
|
||||
|
||||
// parseWGConf parses a wg-quick / AmneziaWG INI into a wgConfig + display name.
|
||||
func parseWGConf(text string) (*wgConfig, string, error) {
|
||||
c := &wgConfig{AWG: map[string]int{}}
|
||||
name, section := "", ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
if n := strings.TrimSpace(strings.TrimLeft(line, "#; ")); name == "" && n != "" && !strings.Contains(n, "=") {
|
||||
name = n
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
section = strings.ToLower(strings.Trim(line, "[]"))
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.ToLower(strings.TrimSpace(k))
|
||||
v = strings.TrimSpace(v)
|
||||
switch section {
|
||||
case "interface":
|
||||
switch k {
|
||||
case "privatekey":
|
||||
c.SecretKey = v
|
||||
case "address":
|
||||
c.Address = splitCSV(v)
|
||||
case "mtu":
|
||||
c.MTU = atoiOr(v, 0)
|
||||
case "jc", "jmin", "jmax", "s1", "s2", "h1", "h2", "h3", "h4":
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
c.AWG[k] = n
|
||||
}
|
||||
}
|
||||
case "peer":
|
||||
switch k {
|
||||
case "publickey":
|
||||
c.PublicKey = v
|
||||
case "presharedkey":
|
||||
c.PSK = v
|
||||
case "endpoint":
|
||||
c.Endpoint = v
|
||||
case "allowedips":
|
||||
c.AllowedIPs = splitCSV(v)
|
||||
case "persistentkeepalive":
|
||||
c.KeepAlive = atoiOr(v, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.SecretKey == "" || c.PublicKey == "" || c.Endpoint == "" {
|
||||
return nil, "", fmt.Errorf("wg conf: missing PrivateKey/PublicKey/Endpoint")
|
||||
}
|
||||
return c, name, nil
|
||||
}
|
||||
|
||||
// wgConfigToURI renders a wgConfig as the canonical wireguard:// share-link.
|
||||
func wgConfigToURI(c *wgConfig, name string) string {
|
||||
host, port := splitEndpoint(c.Endpoint)
|
||||
q := url.Values{}
|
||||
q.Set("publickey", c.PublicKey)
|
||||
if c.PSK != "" {
|
||||
q.Set("presharedkey", c.PSK)
|
||||
}
|
||||
if len(c.Address) > 0 {
|
||||
q.Set("address", strings.Join(c.Address, ","))
|
||||
}
|
||||
if len(c.AllowedIPs) > 0 {
|
||||
q.Set("allowedips", strings.Join(c.AllowedIPs, ","))
|
||||
}
|
||||
if c.MTU > 0 {
|
||||
q.Set("mtu", strconv.Itoa(c.MTU))
|
||||
}
|
||||
if c.KeepAlive > 0 {
|
||||
q.Set("keepalive", strconv.Itoa(c.KeepAlive))
|
||||
}
|
||||
for k, v := range c.AWG {
|
||||
q.Set(k, strconv.Itoa(v))
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "wireguard",
|
||||
User: url.User(c.SecretKey),
|
||||
// joinHostPort: splitEndpoint returns a bare IPv6 literal, which must be
|
||||
// re-bracketed or the URI's own host:port split corrupts the address.
|
||||
Host: joinHostPort(host, port),
|
||||
RawQuery: q.Encode(),
|
||||
Fragment: name,
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const wgFull = "wireguard://qK5s3v1e8f0mXh2wYb9cD4nJ7pR6tU1oA3sE5gH8k0%3D@203.0.113.10:51820?publickey=aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefg%3D&presharedkey=PSKPSKPSKPSKPSKPSKPSKPSKPSKPSKPSKPSKPSKPSK0%3D&address=10.0.0.2/32,fd00::2/128&allowedips=0.0.0.0/0,::/0&mtu=1420&keepalive=25&reserved=0,0,0#wg-de"
|
||||
|
||||
func peer0(ob Outbound) map[string]any {
|
||||
st, _ := ob["settings"].(map[string]any)
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
peers, _ := st["peers"].([]any)
|
||||
if len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
p, _ := peers[0].(map[string]any)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestParseWireguardURI(t *testing.T) {
|
||||
ob, err := ParseShareLink(wgFull, "wg")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if ob["protocol"] != "wireguard" {
|
||||
t.Fatalf("protocol = %v", ob["protocol"])
|
||||
}
|
||||
st := ob["settings"].(map[string]any)
|
||||
if st["secretKey"] != "qK5s3v1e8f0mXh2wYb9cD4nJ7pR6tU1oA3sE5gH8k0=" {
|
||||
t.Fatalf("secretKey = %v", st["secretKey"])
|
||||
}
|
||||
addr := st["address"].([]any)
|
||||
if len(addr) != 2 || addr[0] != "10.0.0.2/32" || addr[1] != "fd00::2/128" {
|
||||
t.Fatalf("address = %v", addr)
|
||||
}
|
||||
if st["mtu"] != 1420 {
|
||||
t.Fatalf("mtu = %v", st["mtu"])
|
||||
}
|
||||
res := st["reserved"].([]any)
|
||||
if len(res) != 3 || res[0] != 0 {
|
||||
t.Fatalf("reserved = %v", res)
|
||||
}
|
||||
p := peer0(ob)
|
||||
if p["publicKey"] != "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefg=" {
|
||||
t.Fatalf("publicKey = %v", p["publicKey"])
|
||||
}
|
||||
if p["endpoint"] != "203.0.113.10:51820" {
|
||||
t.Fatalf("endpoint = %v", p["endpoint"])
|
||||
}
|
||||
if p["keepAlive"] != 25 {
|
||||
t.Fatalf("keepAlive = %v", p["keepAlive"])
|
||||
}
|
||||
if p["preSharedKey"] == nil {
|
||||
t.Fatalf("preSharedKey missing")
|
||||
}
|
||||
aip := p["allowedIPs"].([]any)
|
||||
if len(aip) != 2 || aip[0] != "0.0.0.0/0" {
|
||||
t.Fatalf("allowedIPs = %v", aip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWireguardDefaults(t *testing.T) {
|
||||
ob, err := ParseShareLink("wireguard://SECRET%3D@host.example:51820?publickey=PUB%3D#m", "wg")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
st := ob["settings"].(map[string]any)
|
||||
addr := st["address"].([]any)
|
||||
if len(addr) != 1 || addr[0] != "10.0.0.2/32" {
|
||||
t.Fatalf("default address = %v", addr)
|
||||
}
|
||||
if _, ok := st["mtu"]; ok {
|
||||
t.Fatalf("mtu should be omitted")
|
||||
}
|
||||
p := peer0(ob)
|
||||
aip := p["allowedIPs"].([]any)
|
||||
if len(aip) != 2 || aip[1] != "::/0" {
|
||||
t.Fatalf("default allowedIPs = %v", aip)
|
||||
}
|
||||
if _, ok := p["keepAlive"]; ok {
|
||||
t.Fatalf("keepAlive should be omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWireguardMissing(t *testing.T) {
|
||||
if _, err := ParseShareLink("wireguard://@host:51820?publickey=P", "wg"); err == nil {
|
||||
t.Fatal("expected error on missing secret")
|
||||
}
|
||||
if _, err := ParseShareLink("wireguard://SECRET@host:51820", "wg"); err == nil {
|
||||
t.Fatal("expected error on missing publicKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWGConfRoundTrip(t *testing.T) {
|
||||
conf := `# wg-de
|
||||
[Interface]
|
||||
PrivateKey = qK5s3v1e8f0mXh2wYb9cD4nJ7pR6tU1oA3sE5gH8k0=
|
||||
Address = 10.0.0.2/32, fd00::2/128
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
PublicKey = aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefg=
|
||||
Endpoint = 203.0.113.10:51820
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
PersistentKeepalive = 25`
|
||||
if !looksLikeWGConf(conf) {
|
||||
t.Fatal("looksLikeWGConf false")
|
||||
}
|
||||
c, name, err := parseWGConf(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWGConf: %v", err)
|
||||
}
|
||||
if name != "wg-de" {
|
||||
t.Fatalf("name = %q", name)
|
||||
}
|
||||
uri := wgConfigToURI(c, name)
|
||||
if !strings.HasPrefix(uri, "wireguard://") {
|
||||
t.Fatalf("uri = %q", uri)
|
||||
}
|
||||
ob, err := ParseShareLink(uri, "wg")
|
||||
if err != nil {
|
||||
t.Fatalf("reparse: %v", err)
|
||||
}
|
||||
st := ob["settings"].(map[string]any)
|
||||
if st["secretKey"] != c.SecretKey || st["mtu"] != 1420 {
|
||||
t.Fatalf("round-trip lost fields: %v", st)
|
||||
}
|
||||
p := peer0(ob)
|
||||
if p["endpoint"] != "203.0.113.10:51820" || p["keepAlive"] != 25 {
|
||||
t.Fatalf("round-trip peer: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// An IPv6 endpoint must be bracketed in the peer "endpoint" field — a raw
|
||||
// host+":"+port join yields "2001:db8::1:51820", which xray cannot split.
|
||||
func TestParseWireguardIPv6Endpoint(t *testing.T) {
|
||||
ob, err := ParseShareLink("wireguard://SEC%3D@[2001:db8::1]:51820?publickey=PUB%3D#v6", "wg")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
p := peer0(ob)
|
||||
if p["endpoint"] != "[2001:db8::1]:51820" {
|
||||
t.Fatalf("IPv6 endpoint = %v", p["endpoint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWGConfIPv6RoundTrip(t *testing.T) {
|
||||
conf := "[Interface]\nPrivateKey = SEC=\nAddress = 10.0.0.2/32\n[Peer]\nPublicKey = PUB=\nEndpoint = [2001:db8::1]:51820\nAllowedIPs = 0.0.0.0/0"
|
||||
c, _, err := parseWGConf(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWGConf: %v", err)
|
||||
}
|
||||
uri := wgConfigToURI(c, "v6")
|
||||
ob, err := ParseShareLink(uri, "wg")
|
||||
if err != nil {
|
||||
t.Fatalf("reparse %q: %v", uri, err)
|
||||
}
|
||||
p := peer0(ob)
|
||||
if p["endpoint"] != "[2001:db8::1]:51820" {
|
||||
t.Fatalf("IPv6 endpoint lost in round-trip: %v (uri %q)", p["endpoint"], uri)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireguardOutboundGetsMark(t *testing.T) {
|
||||
ob, _ := ParseShareLink(wgFull, "wg")
|
||||
withMark(ob)
|
||||
so := sockoptOf(ob)
|
||||
if so == nil || so["mark"] != loopMark {
|
||||
t.Fatalf("loop-guard mark missing on WG outbound: %v", so)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireguardFingerprintUnique(t *testing.T) {
|
||||
a, _ := ParseShareLink(wgFull, "wg")
|
||||
// different peer publicKey -> different fingerprint
|
||||
b, _ := ParseShareLink(strings.Replace(wgFull, "publickey=aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefg%3D", "publickey=ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ%3D", 1), "wg")
|
||||
if Fingerprint(a) == Fingerprint(b) {
|
||||
t.Fatal("different WG peers must have different fingerprints")
|
||||
}
|
||||
// same link, different #name -> same fingerprint
|
||||
c, _ := ParseShareLink(strings.Replace(wgFull, "#wg-de", "#other", 1), "wg")
|
||||
if Fingerprint(a) != Fingerprint(c) {
|
||||
t.Fatal("same WG endpoint must have stable fingerprint across names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWireguardNode(t *testing.T) {
|
||||
m := &Model{Globals: defaultGlobals()}
|
||||
m.Nodes = []Node{{Name: "wgn", Enabled: true, URI: wgFull}}
|
||||
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Target: "node:wgn"}}
|
||||
cfg, err := BuildConfig(m)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig: %v", err)
|
||||
}
|
||||
ob := outboundByTagContains(cfg, "node_wgn")
|
||||
if ob == nil || ob["protocol"] != "wireguard" {
|
||||
t.Fatalf("wireguard outbound not emitted: %v", ob)
|
||||
}
|
||||
if sockoptOf(ob)["mark"] != loopMark {
|
||||
t.Fatalf("mark missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportWGConf(t *testing.T) {
|
||||
conf := "[Interface]\nPrivateKey = SECRET=\nAddress = 10.0.0.2/32\n[Peer]\nPublicKey = PUB=\nEndpoint = 1.2.3.4:51820\nAllowedIPs = 0.0.0.0/0"
|
||||
nodes := ImportNodes(conf)
|
||||
if len(nodes) != 1 || !strings.HasPrefix(nodes[0].URI, "wireguard://") {
|
||||
t.Fatalf("ImportNodes(wgconf) = %+v", nodes)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user