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:
2026-07-14 13:53:15 +03:00
co-authored by Claude Opus 4.8
parent 972665798f
commit 903f2345f3
99 changed files with 1077 additions and 20140 deletions
-190
View File
@@ -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/*
+584 -248
View File
@@ -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>.
+46 -111
View File
@@ -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: GPL-2.0-or-later](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSE)
![OpenWrt 24.10](https://img.shields.io/badge/OpenWrt-24.10-informational.svg)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![status: v0.2 in development](https://img.shields.io/badge/status-v0.2%20in%20development-orange.svg)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
> 🇷🇺 [Русская версия — 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
View File
@@ -1,136 +0,0 @@
# shater
**Менеджер прозрачного xray-прокси для OpenWrt** — подписки, policy-routing,
fail-closed kill-switch и современный LuCI-интерфейс. Как passwall2, только чище,
быстрее и честнее в отказах.
[![License: GPL-2.0-or-later](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSE)
![OpenWrt 24.10](https://img.shields.io/badge/OpenWrt-24.10-informational.svg)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
> 🇬🇧 [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 — отдельная немодифицированная
зависимость со своей лицензией.
-33
View File
@@ -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"
-110
View File
@@ -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"
-34
View File
@@ -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")"
-28
View File
@@ -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
-43
View File
@@ -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"
-43
View File
@@ -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"
-41
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+75
View File
@@ -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.
+84
View File
@@ -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
View File
@@ -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`. |
+72
View File
@@ -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.
-109
View File
@@ -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.
-339
View File
@@ -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'
-63
View File
@@ -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'
-31
View File
@@ -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
-21
View File
@@ -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:&lt;name&gt; | zone:&lt;name&gt;'));
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:&lt;n&gt; | group:&lt;n&gt; | node:&lt;n&gt; | 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:&lt;name&gt;". 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:&lt;name&gt; | node:&lt;name&gt;'));
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:&lt;n&gt; | group:&lt;n&gt; | node:&lt;n&gt;'));
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
View File
@@ -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');
}
}
}
};
-82
View File
@@ -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))
-36
View File
@@ -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
-188
View File
@@ -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"
}
-266
View File
@@ -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
-61
View File
@@ -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
-57
View File
@@ -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))
-840
View File
@@ -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()
}
-301
View File
@@ -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)
}
-509
View File
@@ -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)
}
}
-162
View File
@@ -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
}
-143
View File
@@ -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
}
-38
View File
@@ -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
View File
@@ -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
}
-181
View File
@@ -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
}
-59
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-315
View File
@@ -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"])
}
}
-139
View File
@@ -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")
}
}
-215
View File
@@ -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)
}
}
-72
View File
@@ -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)
}
}
-3
View File
@@ -1,3 +0,0 @@
module xrayctl
go 1.22
-119
View File
@@ -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
View File
@@ -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
`)
}
-116
View File
@@ -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")
}
-320
View File
@@ -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, ""
}
-284
View File
@@ -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
}
-122
View File
@@ -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)
}
}
-171
View File
@@ -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
}
-171
View File
@@ -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
}
-234
View File
@@ -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)
}
}
}
-83
View File
@@ -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
}
-147
View File
@@ -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
}
-370
View File
@@ -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)
}
-151
View File
@@ -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
}
-190
View File
@@ -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)
}
-141
View File
@@ -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")
}
}
-49
View File
@@ -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)
}
-264
View File
@@ -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
}
-155
View File
@@ -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
}
-89
View File
@@ -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)
}
}
-392
View File
@@ -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)
}
-141
View File
@@ -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")
}
}
-580
View File
@@ -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
View File
@@ -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,
})
}
}
}
-189
View File
@@ -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
}
-88
View File
@@ -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)
}
}
-159
View File
@@ -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
}
-262
View File
@@ -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)
}
-421
View File
@@ -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
}
-321
View File
@@ -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
View File
@@ -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 ""
}
-126
View File
@@ -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)
}
}
-304
View File
@@ -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()
}
-213
View File
@@ -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.