mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 15:45:13 +08:00
🤞 Sync 2026-09-29 21:07:16
This commit is contained in:
1 parent
ed1f010111
commit
2dda8178b3
231 files changed
+179633
-1348
No files matched your search
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter +coreutils-timeout
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_VERSION:=0.1.47
|
||||
PKG_RELEASE:=49
|
||||
PKG_RELEASE:=50
|
||||
|
||||
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
|
||||
ifdef SOURCE_DATE_EPOCH
|
||||
|
||||
@@ -168,36 +168,77 @@ return baseclass.extend({
|
||||
}
|
||||
};
|
||||
|
||||
const evalNode = function (node) {
|
||||
const validateNode = function (node) {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node))
|
||||
throw new Error('CLASSIFY_SPEC node must be an object');
|
||||
const keys = Object.keys(node);
|
||||
if (keys.length !== 1)
|
||||
throw new Error(
|
||||
'CLASSIFY_SPEC node must have exactly one key: ' + JSON.stringify(node)
|
||||
);
|
||||
const key = keys[0];
|
||||
if (key === 'and') {
|
||||
if (!Array.isArray(node.and))
|
||||
throw new Error('CLASSIFY_SPEC and node must be an array');
|
||||
for (let i = 0; i < node.and.length; i++) {
|
||||
if (!evalNode(node.and[i])) return false;
|
||||
if (key === 'and' || key === 'or') {
|
||||
const children = node[key];
|
||||
if (!Array.isArray(children))
|
||||
throw new Error('CLASSIFY_SPEC ' + key + ' node must be an array');
|
||||
if (children.length === 0)
|
||||
throw new Error('CLASSIFY_SPEC ' + key + ' node must be a non-empty array');
|
||||
for (let i = 0; i < children.length; i++) validateNode(children[i]);
|
||||
return;
|
||||
}
|
||||
if (key === 'kv' || key === 'kvAny') {
|
||||
const values = node[key];
|
||||
let valid = Array.isArray(values) && values.length > 0;
|
||||
for (let i = 0; valid && i < values.length; i++) {
|
||||
if (typeof values[i] !== 'string' || values[i].trim().length === 0)
|
||||
valid = false;
|
||||
}
|
||||
return true;
|
||||
if (!valid)
|
||||
throw new Error(
|
||||
'CLASSIFY_SPEC ' +
|
||||
key +
|
||||
' predicate must be a non-empty array of non-empty strings'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key === 'action') {
|
||||
if (node.action !== 'known')
|
||||
throw new Error('CLASSIFY_SPEC action predicate must be "known"');
|
||||
return;
|
||||
}
|
||||
if (key === 'hint') {
|
||||
if (node.hint !== true)
|
||||
throw new Error('CLASSIFY_SPEC hint predicate must be true');
|
||||
return;
|
||||
}
|
||||
throw new Error('unrecognised CLASSIFY_SPEC predicate node: ' + JSON.stringify(node));
|
||||
};
|
||||
|
||||
const evalNode = function (node) {
|
||||
const keys = Object.keys(node);
|
||||
const key = keys[0];
|
||||
if (key === 'and') {
|
||||
let matched = true;
|
||||
for (let i = 0; i < node.and.length; i++) {
|
||||
if (!evalNode(node.and[i])) matched = false;
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
if (key === 'or') {
|
||||
if (!Array.isArray(node.or))
|
||||
throw new Error('CLASSIFY_SPEC or node must be an array');
|
||||
let matched = false;
|
||||
for (let i = 0; i < node.or.length; i++) {
|
||||
if (evalNode(node.or[i])) return true;
|
||||
if (evalNode(node.or[i])) matched = true;
|
||||
}
|
||||
return false;
|
||||
return matched;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(pred, key))
|
||||
throw new Error(
|
||||
'unrecognised CLASSIFY_SPEC predicate node: ' + JSON.stringify(node)
|
||||
);
|
||||
return pred[key](node);
|
||||
};
|
||||
|
||||
if (!Array.isArray(this.CLASSIFY_SPEC.rules) || this.CLASSIFY_SPEC.rules.length === 0)
|
||||
throw new Error('CLASSIFY_SPEC rules must be a non-empty array');
|
||||
for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++)
|
||||
validateNode(this.CLASSIFY_SPEC.rules[i]);
|
||||
|
||||
for (let i = 0; i < this.CLASSIFY_SPEC.rules.length; i++) {
|
||||
if (evalNode(this.CLASSIFY_SPEC.rules[i])) return true;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ msgid ""
|
||||
"messages returned"
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:502
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:543
|
||||
msgid "%s: %s"
|
||||
msgstr ""
|
||||
|
||||
@@ -27,7 +27,7 @@ msgstr ""
|
||||
msgid "Accessible (teal/orange)"
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:362
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:403
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:37
|
||||
msgid "Action"
|
||||
msgstr ""
|
||||
@@ -183,7 +183,7 @@ msgstr ""
|
||||
msgid "Degraded — sampling (slow poll RTT; cadence reduced)."
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:366
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:407
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:45
|
||||
msgid "Destination"
|
||||
msgstr ""
|
||||
@@ -192,7 +192,7 @@ msgstr ""
|
||||
msgid "Destination IP contains (! to exclude)"
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:368
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:409
|
||||
msgid "Destination port"
|
||||
msgstr ""
|
||||
|
||||
@@ -367,7 +367,7 @@ msgstr ""
|
||||
msgid "Installation is incomplete. Reinstall luci-app-fwlive."
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:363
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:404
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:39
|
||||
msgid "Interface"
|
||||
msgstr ""
|
||||
@@ -505,7 +505,7 @@ msgstr ""
|
||||
msgid "Paused"
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:364
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:405
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:43
|
||||
msgid "Proto"
|
||||
msgstr ""
|
||||
@@ -563,7 +563,7 @@ msgstr ""
|
||||
msgid "SPort"
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:361
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:402
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -604,7 +604,7 @@ msgstr ""
|
||||
msgid "Simple"
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:365
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:406
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:44
|
||||
msgid "Source"
|
||||
msgstr ""
|
||||
@@ -613,7 +613,7 @@ msgstr ""
|
||||
msgid "Source IP contains (! to exclude)"
|
||||
msgstr ""
|
||||
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:367
|
||||
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:408
|
||||
msgid "Source port"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -103,11 +103,9 @@ function is_fw(s, action) {
|
||||
if (s == "") return 0
|
||||
if (non_fw_prefix(s)) return 0
|
||||
action = detect_action(s)
|
||||
if (has_kv(s, "SRC") && has_kv(s, "DST")) return 1
|
||||
if ((has_kv(s, "IN") || has_kv(s, "OUT")) && (has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO") || has_kv(s, "SPT") || has_kv(s, "DPT"))) return 1
|
||||
if (action != "UNKNOWN" && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "PROTO") || has_kv(s, "SRC") || has_kv(s, "DST"))) return 1
|
||||
if (has_hint(s) && action != "UNKNOWN") return 1
|
||||
if (has_hint(s) && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO"))) return 1
|
||||
if (((has_kv(s, "SRC") && has_kv(s, "DST")) || ((has_kv(s, "IN") || has_kv(s, "OUT")) && (has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO") || has_kv(s, "SPT") || has_kv(s, "DPT"))) || (action != "UNKNOWN" && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "PROTO") || has_kv(s, "SRC") || has_kv(s, "DST"))))) return 1
|
||||
if ((has_hint(s) && action != "UNKNOWN")) return 1
|
||||
if ((has_hint(s) && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO")))) return 1
|
||||
return 0
|
||||
}
|
||||
function utf8_prefix(s, max_bytes, out, i, c, width) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
/homeproxy
|
||||
/docs/
|
||||
@@ -0,0 +1,340 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
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 Library General Public License instead.) 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
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".
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
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.
|
||||
|
||||
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.)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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) 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,
|
||||
|
||||
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,
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest 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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
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
|
||||
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
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
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, write to the Free Software
|
||||
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
|
||||
|
||||
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:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision 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.
|
||||
|
||||
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:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, 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 Library General
|
||||
Public License instead of this License.
|
||||
@@ -0,0 +1,48 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2022-2023 ImmortalWrt.org
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=The modern ImmortalWrt proxy platform for ARM64/AMD64 (sing-box 1.14)
|
||||
# Pure ucode/JS payload with no compiled code, so the package itself is arch
|
||||
# independent. The real arch constraint comes from the +sing-box dependency,
|
||||
# which the feed builds for every architecture Go supports (aarch64, arm,
|
||||
# mipsel, riscv64, x86_64, ...); where no sing-box package exists, dependency
|
||||
# resolution refuses the install. Checked against the ImmortalWrt 25.12.1
|
||||
# package index (sing-box 1.14.0-r1 published for all of the above).
|
||||
LUCI_PKGARCH:=all
|
||||
LUCI_DEPENDS:= \
|
||||
+sing-box \
|
||||
+firewall4 \
|
||||
+kmod-nft-tproxy \
|
||||
+ip-full \
|
||||
+kmod-tun \
|
||||
+ucode-mod-digest
|
||||
|
||||
PKG_NAME:=luci-app-homeproxy
|
||||
PKG_VERSION:=28.9.1.14
|
||||
PKG_RELEASE:=1
|
||||
|
||||
# Only files the package actually ships belong here. The previous list named
|
||||
# four paths that exist nowhere in the tree - certs/ and ruleset/ are created at
|
||||
# runtime, and the two *_list.txt files are written by the LuCI ACL editor - so
|
||||
# those declarations did nothing. What is listed instead is the resource data
|
||||
# the router updates by itself: on a running device those files are newer than
|
||||
# the ones in the package, and without this an upgrade would overwrite them.
|
||||
define Package/luci-app-homeproxy/conffiles
|
||||
/etc/config/homeproxy
|
||||
/etc/homeproxy/resources/china_ip4.txt
|
||||
/etc/homeproxy/resources/china_ip6.txt
|
||||
/etc/homeproxy/resources/china_list.txt
|
||||
/etc/homeproxy/resources/gfw_list.txt
|
||||
/etc/homeproxy/resources/china_ip4.ver
|
||||
/etc/homeproxy/resources/china_ip6.ver
|
||||
/etc/homeproxy/resources/china_list.ver
|
||||
/etc/homeproxy/resources/gfw_list.ver
|
||||
endef
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -0,0 +1,91 @@
|
||||
<div align="center">
|
||||
|
||||
# luci-app-homeproxy-pro
|
||||
|
||||
**The modern ImmortalWrt proxy platform for ARM64 / AMD64**
|
||||
|
||||
基于 sing-box 1.14 内核的现代代理平台 — 简洁、高效、开箱即用。
|
||||
|
||||
</div>
|
||||
|
||||
## 项目定位
|
||||
|
||||
本项目是试验性产品,对 [homeproxy](https://github.com/szwjp/luci-app-homeproxy) 架构重构版:以 sing-box **1.14** 内核为唯一目标,
|
||||
充分利用 1.14 引入的新特性,不再兼容 1.13 及更早内核。
|
||||
|
||||
## 运行要求
|
||||
|
||||
- ImmortalWrt / OpenWrt ≥ 24.10(`apk` 或 `opkg` 均可安装);sing-box ≥ 1.14.0 是硬要求;低于 1.14 时服务拒绝启动并记录明确日志
|
||||
|
||||
## 按功能性对比
|
||||
|
||||
### 代码组织
|
||||
|
||||
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 后端架构 | 单文件巨型(`generate_client.uc` 1217 行 / 40 KB;`parse_uri.uc` ~400 行 / 15 KB) | orchestrator + 5 子层(`parser` / `config` / `generator` / `subscription` / `runtime`)+ 公共库(`homeproxy.uc` / `firewall_utils.uc`)+ table-driven adapters | `root/etc/homeproxy/scripts/{config,parser,generator,subscription,runtime}/` |
|
||||
| 前端架构 | 单一 66 KB `client.js` | `client.js` thin 入口 + 8 个 Tab 模块(`access` / `common` / `dns` / `nodes` / `routing` / `subscription` / `tun_dns` / `udp_nat`)+ 共享 helpers + 集中 `RPC.declare` | `htdocs/luci-static/resources/view/homeproxy/client/` |
|
||||
| 协议建模 | 单 `parse_uri.uc` 内 13 个 scheme 分支 | `parser/{uri,flatten,normalize,mapping,protocols,validator}.uc` 6 个文件 + canonical Node 模型 + adapter table | `root/etc/homeproxy/scripts/parser/` + `config/{model,adapter}.uc` |
|
||||
| Tab 模块化 | 1 个 `client.js` 含 12 个 Tab 渲染 | `homeproxy.js` (1153 行 helpers) + 4 顶层 view + 8 个 `client/*.js`;helpers 集中 RPC / MD5 / validators / statusPoller / renderSectionAdd / uploadCertificate | `view/homeproxy/client/{routing,nodes,dns,access,subscription,udp_nat,tun_dns,common}.js` |
|
||||
|
||||
### 稳定性
|
||||
|
||||
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||
|---|---|---|---|
|
||||
| reload 事务 | 写完直接 restart | 生成 → `sing-box check` → 新实例 probe → 健康门(连续采样)→ 提为 known-good;不健康自动回滚 | `root/etc/init.d/homeproxy` + `runtime/{config,health}.sh` |
|
||||
| 健康门 | `ubus` + `pgrep` 简单轮询 | `hp_wait_service` 连续 N 次采样(默认 3/30s)+ `netstat -p` 端口归属校验;不健康自动 rollback | `runtime/health.sh:hp_wait_service` |
|
||||
| UCI 1.14 迁移 | r10/r11 自述较弱 | `migrate_config.uc` 36 checks 覆盖 1.14 DNS renames / `dns_server.address` 拆分 / `rcode://` → predefined rule / `rule_set_ipcidr_match_source` rename / `block-out`/`block-dns` → `action='reject'` / `auto_firewall` 重分发 / `block-dns` default-server | `scripts/migrate_config.uc` + `tests/ucode/test_migrate_config.sh` |
|
||||
| sing-box 版本门 | 无 | `hp_require_singbox()` 在 start 时显式拒绝 `<1.14`,启动失败记录明确日志 | `runtime/service.sh:hp_require_singbox` |
|
||||
|
||||
### 安全
|
||||
|
||||
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||
|---|---|---|---|
|
||||
| capabilities | `CAP_SYS_PTRACE` + `CAP_NET_RAW` + `CAP_NET_ADMIN` + `CAP_NET_BIND_SERVICE`(4 个) | 仅 `CAP_NET_ADMIN` + `CAP_NET_BIND_SERVICE`(2 个) | `root/etc/capabilities/homeproxy.json` + arch-guard guard 12 |
|
||||
| 路径白名单(cert / rule) | 仅前端 UI 校验 | `validateHomeProxyPath()` 拒 traversal + relative,限 `/etc/homeproxy/` 与 `/tmp/homeproxy_*`;`validateCertificatePath()` 限 `/etc/homeproxy/certs/` `/etc/acme/` `/etc/ssl/`;前后端列表 arch-guard 29 锁同步 | `homeproxy.uc:56 / :104` + `homeproxy.js:HP_CERT_PATH_ROOTS` |
|
||||
| 订阅 token 脱敏 | 调用点各自脱敏 | `wGETVerbose()` 内部脱敏(scheme/host/port 保留,userinfo / path / query 全脱;mock 与生产 `redactUrl` 由 arch-guard 35 锁一致) | `homeproxy.uc:226 (redactUrl)` + arch-guard 14 / 35 |
|
||||
| crontab 权限 | `sed -i`(GNU-only)+ mv 后留给 0644 | `sed + mv` 跨 busybox / GNU / BSD + `chmod 600` 修 root crontab 0644 → 0600 | `runtime/service.sh:hp_crontab_drop` |
|
||||
| procd jail mount | 默认未对所有模式 mount | tun/wireguard 自动 unjail(要 host netns),其他模式 mount HP_DIR + /etc/acme + /etc/ssl + /tmp/dhcp.leases + /etc/localtime + /etc/TZ | `runtime/service.sh:hp_procd_client_instance` |
|
||||
| cert upload 并发 | 4 个按钮共用 `/tmp/homeproxy_certificate.tmp` | 4 个按钮各自 `/tmp/homeproxy_cert_<name>.tmp`,ACL 显式列 4 个写路径 | `homeproxy.js:uploadCertificate` + `acl.d/luci-app-homeproxy.json` |
|
||||
| firewall 字段白名单 | 部分值校验 | `firewall_pre.uc` `tun_name` 严格 `^[A-Za-z0-9_.-]{1,15}$` 防 nft 注入;`firewall_post.ut` 每个 UCI 派生值走 `*_to_nftarr()` 校验器(guard 11 锁模板全覆盖) | `firewall_pre.uc:32` + `firewall_utils.uc` |
|
||||
|
||||
### 测试 / CI
|
||||
|
||||
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 架构守卫 | 无 | `tests/arch-guard.sh` **38 个 guard / 135 个 check** 锁跨文件一致性(UCICONFIG_DIR / ACL / capabilities / conffiles / CERT_PATH_ROOTS / `redactUrl` mock / uCode 语法 / sing-box floor / adapter 共享表 / …) | `tests/arch-guard.sh` (guards 1-19, 21-39) |
|
||||
| 测试规模 | 7 个脚本 / 约 27 个 check | `tests/` 下 80 个文件:ucode 套件 + 8 个 frontend 验证器 + golden snapshot + mocks + 离线 runtime 测试;arch-guard 单跑 135 个 check | `tests/print-stats.sh` 实测 |
|
||||
| CI | `build` + `i18n` 两条平行 workflow | `build` 用 `workflow_call` 依赖 `arch-test`;`arch-test` 8 步(翻译 fast gate → toolchain cache → toolchain 校验 → `tests/run.sh` 唯一套件入口 → 模板检查);`on-target` 手动 ssh + 不安装 | `.github/workflows/{arch-test,build,on-target}.yml` |
|
||||
| uCode dialect pin | 跟随 upstream HEAD(已放宽) | pin `UCODE_REV=85922056ef7abeace3cca3ab28bc1ac2d88e31b1`(完整 40 位 commit)+ `test_ucode_grammar.sh` canary 锁 strict 语法(拒 `export function … }` 无 `;`、对象/数组解构) | `tests/toolchain/build-ucode-linux.sh:UCODE_REV` + `tests/ucode/test_ucode_grammar.sh` |
|
||||
| MD5 实现 | 388 行 minified snippet;vmess 分支漏 `vmess_global_padding` | RFC 1321 完整实现 + `tests/frontend-md5.js` 锁 RFC 向量 + 与 Node `crypto.createHash('md5')` 双向 cross-check(44 / 44 PASS) | `homeproxy.js:calcStringMD5` + `tests/frontend-md5.js` |
|
||||
|
||||
### UX / Ops
|
||||
|
||||
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||
|---|---|---|---|
|
||||
| Status 语义 | 2 态(RUNNING / NOT RUNNING) | 3 态(+ **STATUS UNKNOWN** 黄),区分 "rpc 没回" vs "service 死了" | `homeproxy.js:statusLabel` + `client.js:renderStatus` |
|
||||
| 前端特异性 bug 修复 | `stubValidator` 缺 `factory` 致 ip6addr TypeError;`L.bind(hp.renderSectionAdd, …)` 把 factory 塞 `extra_class` 槽致 `InvalidCharacterError` | 已修:`stubValidator` 持 `factory: validation`;`renderSectionAdd` 改薄 wrap(arch-guard 22 / 23 锁 binding 形态) | `view/homeproxy/client.js:73` + `homeproxy.js:1018-1026` |
|
||||
| 协议增改成本 | 改 110 行 ternary(README 旧行) | 改 5 个表行:`model.uc CREDENTIALS` + `loader.uc PROTOCOL_OPTIONS` + `adapter.uc OPTION_FIELDS` + `fixtures/` + golden snapshot;arch-guard 5 强制 fixtures 同步 | `config/{model,loader,adapter}.uc` + `tests/fixtures/generators/` |
|
||||
| 资源更新 | jsdelivr 单一镜像 | 4 镜像 fallback(`fastly.jsdelivr.net` / `gcore.jsdelivr.net` / `cdn.jsdelivr.net` / `raw.githubusercontent.com`)+ UI「上次成功时间」(arch-guard 17 / 18 锁) | `runtime/dns.sh` + arch-guard 17 / 18 |
|
||||
| ECH 上传 | 后端 case 缺失 | 补齐 `client_ech_conf` 标签 + `isValidECHConfig()` PEM 校验;ACL 显式列 4 个 tmp 路径 | `homeproxy.uc:isValidECHConfig` + `luci.homeproxy:certificate_write` |
|
||||
| 默认测试主机 | 硬编码作者内网 IP | `HP_TEST_HOST` 注入,未设时 fail-fast;`on-target.yml` ;ssh 前 `ssh -o ConnectTimeout=10` 先确认可达(不要从记忆里写地址) | `tests/run.sh` + `.github/workflows/on-target.yml` |
|
||||
| 文档 | 标准(README + CONTRIBUTING + SECURITY) | 精简(README only),`docs/` 自 2026-09-16 起只留本地(untrack + `.gitignore`),远端公开仓库不再发布 | `README.md` + `docs/`(本地) |
|
||||
| 路由 ops | — | 替换文件只动 JS / menu / acl / rpcd ucode(reload 不影响网络);更深层改动前 `cp /etc/config/homeproxy /tmp/hp-r<NN>-preflight-<DATE>/`,保留上一发版 .apk 24h;`killall -HUP rpcd` 由 apk scripts 自动做 | `runtime/{config,service}.sh` |
|
||||
|
||||
**一句话总结**:pro 的核心价值是**把"单文件能跑"变成"orchestrator + table-driven adapter + 可独立测试的模块"**,并把约束、质量、回滚三件事从靠人盯变成靠代码执行(arch-guard 135 checks 静态锁住跨文件不变量)。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **试验性**:不承诺 API/配置稳定,重大变更可能在 minor 版本里发生。
|
||||
- **真机实测单平台**:本仓库只在 x86-64 软路由上做过端到端验证。
|
||||
- 本包不会编译 sing-box。系统固件必须自带 sing-box 1.14+;否则依赖解析直接失败。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎 issue / PR。架构边界规则是可执行的,固化在 `tests/arch-guard.sh`(PR 必跑,
|
||||
每条规则都注明对应的 guard);架构层面的变更请先开 issue 讨论,避免在
|
||||
PR review 里来回拉扯。
|
||||
|
||||
## License
|
||||
|
||||
[GPL-2.0-only](LICENSE) — 版权归 ImmortalWrt.org 与各贡献者。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require form';
|
||||
'require network';
|
||||
'require poll';
|
||||
'require uci';
|
||||
'require validation';
|
||||
'require view';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require view.homeproxy.client.common as common';
|
||||
'require view.homeproxy.client.routing as routing';
|
||||
'require view.homeproxy.client.nodes as nodes';
|
||||
'require view.homeproxy.client.dns as dns';
|
||||
'require view.homeproxy.client.access as access';
|
||||
'require view.homeproxy.client.subscription as subscription';
|
||||
'require view.homeproxy.client.udp_nat as udp_nat';
|
||||
'require view.homeproxy.client.tun_dns as tun_dns';
|
||||
|
||||
/* Module-scoped guard: register the view-level status poll exactly
|
||||
* once, regardless of how many times the view's render() runs (LuCI
|
||||
* sometimes re-invokes the view handler on UCI commit). Without this,
|
||||
* poll.add() inside a section render() handler leaks a fresh handler
|
||||
* on every render and the status bar starts updating multiple times
|
||||
* per tick. */
|
||||
/* Built on the first render, so the poll closure captures that render's
|
||||
* features.version. The "have we registered" state lives inside
|
||||
* hp.statusPoller(), shared with the other view. */
|
||||
let ensureStatusPoll = null;
|
||||
|
||||
function getServiceStatus() {
|
||||
/* The fallback is `null`, not `{}`: that is what lets the caller tell
|
||||
* "the status query did not answer" from "the service is not running".
|
||||
* With the default `{}` the try/catch below swallowed the difference and
|
||||
* reported a failed query as NOT RUNNING. */
|
||||
return hp.rpcCall('list', ['homeproxy'],
|
||||
{ object: 'service', params: ['name'], expect: { '': {} }, fallback: null }).then((res) => {
|
||||
if (res === null)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return res['homeproxy']['instances']['sing-box-c']['running'] === true;
|
||||
} catch (e) {
|
||||
/* It answered, and there is no such instance. */
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus(isRunning, version) {
|
||||
/* Defense-in-depth: features.version comes from `sing-box version`
|
||||
* stdout parsed by a regex, but it is interpolated into an
|
||||
* `innerHTML =` sink. Force the version through a strict allow-list
|
||||
* regex (semver-ish) so a hostile sing-box binary - or a future
|
||||
* change to the parser - cannot smuggle HTML into this template.
|
||||
* Anything that does not match renders as 'unknown' instead of
|
||||
* being silently dropped. */
|
||||
let safeVersion = 'unknown';
|
||||
if (typeof version === 'string' && /^[\w.\-+]+$/.test(version))
|
||||
safeVersion = version;
|
||||
|
||||
const state = hp.statusLabel(isRunning);
|
||||
return '<em><span style="color:%s"><strong>%s (sing-box v%s) %s</strong></span></em>'
|
||||
.format(state.color, _('HomeProxy'), safeVersion, state.text);
|
||||
}
|
||||
|
||||
let stubValidator = {
|
||||
/* Forward the type functions from the real validator module and let
|
||||
* them call back into their factory. validation.js exports the
|
||||
* ValidatorFactory instance; without `factory` here the chained
|
||||
* apply() falls off the edge and `this.factory.parseIPv4` throws
|
||||
* "undefined is not an object" the first time a node's address is
|
||||
* fed through `ip6addr`/`ip4addr`/`cidr4`/etc. (commit 67d2c3a
|
||||
* missed the factory wiring when it added this helper). */
|
||||
factory: validation,
|
||||
apply(type, value, args) {
|
||||
if (value != null)
|
||||
this.value = value;
|
||||
|
||||
return validation.types[type].apply(this, args);
|
||||
},
|
||||
assert(condition) {
|
||||
return !!condition;
|
||||
}
|
||||
};
|
||||
|
||||
return view.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
uci.load('homeproxy'),
|
||||
hp.getBuiltinFeatures(),
|
||||
network.getHostHints()
|
||||
]);
|
||||
},
|
||||
|
||||
render(data) {
|
||||
let m, s;
|
||||
|
||||
let features = data[1],
|
||||
hosts = data[2]?.hosts;
|
||||
|
||||
/* Cache all configured proxy nodes, they will be called multiple times */
|
||||
let proxy_nodes = {};
|
||||
uci.sections('homeproxy', 'node', (res) => {
|
||||
let nodeaddr = ((res.type === 'direct') ? res.override_address : res.address) || '',
|
||||
nodeport = ((res.type === 'direct') ? res.override_port : res.port) || '';
|
||||
|
||||
proxy_nodes[res['.name']] =
|
||||
String.format('[%s] %s', res.type, res.label || ((stubValidator.apply('ip6addr', nodeaddr) ?
|
||||
String.format('[%s]', nodeaddr) : nodeaddr) + ':' + nodeport));
|
||||
});
|
||||
|
||||
m = new form.Map('homeproxy', _('HomeProxy'),
|
||||
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
|
||||
|
||||
s = m.section(form.TypedSection);
|
||||
s.render = function () {
|
||||
/* The status bar is rendered into the page body by the
|
||||
* view-level poll handler below. This section only paints the
|
||||
* placeholder; registering poll.add() here would leak a fresh
|
||||
* poll handler every time the user calls map.reset() (every
|
||||
* UCI write re-renders this section). */
|
||||
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
|
||||
E('p', { id: 'service_status' }, _('Collecting data...'))
|
||||
]);
|
||||
}
|
||||
|
||||
/* View-level poll: registered exactly once per navigation, never
|
||||
* inside a section's render() handler. getElementById may return
|
||||
* null when LuCI swaps the DOM tree between renders - guard
|
||||
* against it instead of throwing. */
|
||||
if (ensureStatusPoll === null)
|
||||
ensureStatusPoll = hp.statusPoller({
|
||||
poll: poll,
|
||||
read: getServiceStatus,
|
||||
paint: function(res) {
|
||||
let view = document.getElementById('service_status');
|
||||
if (view)
|
||||
view.innerHTML = renderStatus(res, features.version);
|
||||
}
|
||||
});
|
||||
|
||||
ensureStatusPoll();
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy');
|
||||
|
||||
/* ctx bundles the values each tab needs so the tab modules don't
|
||||
* have to recompute them. The destructuring keys are intentionally
|
||||
* explicit - a tab that grabs something not in this list is a bug. */
|
||||
const ctx = {
|
||||
s, features, hosts, proxy_nodes,
|
||||
stubValidator, self: this,
|
||||
};
|
||||
|
||||
/* Tab ordering follows the order of these calls: routing,
|
||||
* routing_node, routing_rule, dns, dns_server, dns_rule, ruleset,
|
||||
* dns_cache, control, udp_nat, tun_dns. The DNS tab sits before
|
||||
* Access Control because the pages a preset-routing-mode user
|
||||
* actually sees are Routing Settings, DNS Settings, Access Control,
|
||||
* UDP NAT Settings and TUN DNS, in that order. The shared
|
||||
* rule-section bodies live in common.js and are called out-of-order
|
||||
* with respect to their file to keep the rendering sequence intact. */
|
||||
routing.render(ctx);
|
||||
nodes.renderRoutingNodes(ctx);
|
||||
routing.renderRoutingRules(ctx);
|
||||
dns.renderDnsSettings(ctx);
|
||||
nodes.renderDnsServers(ctx);
|
||||
dns.renderDnsRules(ctx);
|
||||
subscription.render(ctx);
|
||||
dns.renderDnsCache(ctx);
|
||||
access.render(ctx);
|
||||
udp_nat.renderUdpNat(ctx);
|
||||
tun_dns.renderTunDns(ctx);
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require tools.firewall as fwtool';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
/* Access Control tab. Five sub-tabs (interface control, LAN IP policy,
|
||||
* WAN IP policy, proxy domain list, direct domain list). Each domain-list
|
||||
* sub-tab reads/writes the proxy_list / direct_list files through an
|
||||
* ubus RPC; the load callback is the only path that touches that RPC. */
|
||||
function render(ctx) {
|
||||
const { s, hosts, stubValidator } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('control', _('Access Control'));
|
||||
|
||||
o = s.taboption('control', form.SectionValue, '_control', form.NamedSection, 'control', 'homeproxy');
|
||||
ss = o.subsection;
|
||||
|
||||
/* Interface control start */
|
||||
ss.tab('interface', _('Interface Control'));
|
||||
|
||||
so = ss.taboption('interface', widgets.DeviceSelect, 'listen_interfaces', _('Listen interfaces'),
|
||||
_('Only process traffic from specific interfaces. Leave empty for all.'));
|
||||
so.multiple = true;
|
||||
so.noaliases = true;
|
||||
|
||||
so = ss.taboption('interface', widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
|
||||
_('Bind outbound traffic to specific interface. Leave empty to auto detect.'));
|
||||
so.multiple = false;
|
||||
so.noaliases = true;
|
||||
/* Interface control end */
|
||||
|
||||
/* LAN IP policy start */
|
||||
ss.tab('lan_ip_policy', _('LAN IP Policy'));
|
||||
|
||||
so = ss.taboption('lan_ip_policy', form.ListValue, 'lan_proxy_mode', _('Proxy filter mode'));
|
||||
so.value('disabled', _('Disable'));
|
||||
so.value('listed_only', _('Proxy listed only'));
|
||||
so.value('except_listed', _('Proxy all except listed'));
|
||||
so.default = 'disabled';
|
||||
so.rmempty = false;
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_direct_ipv4_ips', _('Direct IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||
so.depends('lan_proxy_mode', 'except_listed');
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_direct_ipv6_ips', _('Direct IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||
so.depends({'lan_proxy_mode': 'except_listed', 'homeproxy.config.ipv6_support': '1'});
|
||||
|
||||
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_direct_mac_addrs', _('Direct MAC-s'), null, hosts);
|
||||
so.depends('lan_proxy_mode', 'except_listed');
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_proxy_ipv4_ips', _('Proxy IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||
so.depends('lan_proxy_mode', 'listed_only');
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_proxy_ipv6_ips', _('Proxy IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||
so.depends({'lan_proxy_mode': 'listed_only', 'homeproxy.config.ipv6_support': '1'});
|
||||
|
||||
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_proxy_mac_addrs', _('Proxy MAC-s'), null, hosts);
|
||||
so.depends('lan_proxy_mode', 'listed_only');
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_gaming_mode_ipv4_ips', _('Gaming mode IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_gaming_mode_ipv6_ips', _('Gaming mode IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||
so.depends('homeproxy.config.ipv6_support', '1');
|
||||
|
||||
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_gaming_mode_mac_addrs', _('Gaming mode MAC-s'), null, hosts);
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_global_proxy_ipv4_ips', _('Global proxy IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_global_proxy_ipv6_ips', _('Global proxy IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||
so.depends({'homeproxy.config.routing_mode': /^((?!custom).)+$/, 'homeproxy.config.ipv6_support': '1'});
|
||||
|
||||
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_global_proxy_mac_addrs', _('Global proxy MAC-s'), null, hosts);
|
||||
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||
/* LAN IP policy end */
|
||||
|
||||
/* WAN IP policy start */
|
||||
ss.tab('wan_ip_policy', _('WAN IP Policy'));
|
||||
|
||||
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_proxy_ipv4_ips', _('Proxy IPv4 IP-s'));
|
||||
so.datatype = 'or(ip4addr, cidr4)';
|
||||
|
||||
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_proxy_ipv6_ips', _('Proxy IPv6 IP-s'));
|
||||
so.datatype = 'or(ip6addr, cidr6)';
|
||||
so.depends('homeproxy.config.ipv6_support', '1');
|
||||
|
||||
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_direct_ipv4_ips', _('Direct IPv4 IP-s'));
|
||||
so.datatype = 'or(ip4addr, cidr4)';
|
||||
|
||||
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_direct_ipv6_ips', _('Direct IPv6 IP-s'));
|
||||
so.datatype = 'or(ip6addr, cidr6)';
|
||||
so.depends('homeproxy.config.ipv6_support', '1');
|
||||
/* WAN IP policy end */
|
||||
|
||||
/* Proxy domain list start */
|
||||
ss.tab('proxy_domain_list', _('Proxy Domain List'));
|
||||
|
||||
so = ss.taboption('proxy_domain_list', form.TextValue, '_proxy_domain_list');
|
||||
so.rows = 10;
|
||||
so.monospace = true;
|
||||
so.datatype = 'hostname';
|
||||
so.description = _('One domain per line. No spaces, no comments, no wildcards; the backend rejects invalid characters and tells you which line.');
|
||||
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||
so.load = function(/* ... */) {
|
||||
return hp.rpcCall('acllist_read', ['proxy_list'],
|
||||
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||
/* acllist_read returns { content: null, error: '...' }
|
||||
* when the backend rejected the request (bad type, file
|
||||
* unreadable, ...). Surface the error instead of silently
|
||||
* resolving to "" which makes the field look unchanged. */
|
||||
if (res && res.error)
|
||||
ui.addNotification(null, E('p', _('Failed to read domain list: %s.').format(res.error)));
|
||||
return res ? res.content : '';
|
||||
}, {});
|
||||
}
|
||||
so.write = function(_section_id, value) {
|
||||
return hp.rpcCall('acllist_write', ['proxy_list', value], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||
/* Without this, a backend error like "UCI commit failed"
|
||||
* is silently dropped and the user sees the old value
|
||||
* reappear after refresh with no explanation. */
|
||||
if (ret && ret.result === false)
|
||||
throw (ret.error || 'unknown error');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
so.remove = function(/* ... */) {
|
||||
/* routing_mode is an option of the page's 'config' NamedSection, not
|
||||
* of this nested 'control' one: `this.section.formvalue(...)` walks
|
||||
* the children of `this.section`, never found it and returned null,
|
||||
* so the test below was always true and a reset wiped the file even
|
||||
* in custom mode, where the list is in use. Read the saved UCI value
|
||||
* the backend will act on. */
|
||||
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||
if (routing_mode !== 'custom')
|
||||
return hp.rpcCall('acllist_write', ['proxy_list', ''], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||
if (ret && ret.result === false)
|
||||
throw (ret.error || 'unknown error');
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value)
|
||||
for (let i of value.split('\n'))
|
||||
if (i && !stubValidator.apply('hostname', i))
|
||||
return _('Expecting: %s').format(_('valid hostname'));
|
||||
|
||||
return true;
|
||||
}
|
||||
/* Proxy domain list end */
|
||||
|
||||
/* Direct domain list start */
|
||||
ss.tab('direct_domain_list', _('Direct Domain List'));
|
||||
|
||||
so = ss.taboption('direct_domain_list', form.TextValue, '_direct_domain_list');
|
||||
so.rows = 10;
|
||||
so.monospace = true;
|
||||
so.datatype = 'hostname';
|
||||
so.description = _('One domain per line. No spaces, no comments, no wildcards; the backend rejects invalid characters and tells you which line.');
|
||||
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||
so.load = function(/* ... */) {
|
||||
return hp.rpcCall('acllist_read', ['direct_list'],
|
||||
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||
if (res && res.error)
|
||||
ui.addNotification(null, E('p', _('Failed to read domain list: %s.').format(res.error)));
|
||||
return res ? res.content : '';
|
||||
}, {});
|
||||
}
|
||||
so.write = function(_section_id, value) {
|
||||
return hp.rpcCall('acllist_write', ['direct_list', value], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||
if (ret && ret.result === false)
|
||||
throw (ret.error || 'unknown error');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
so.remove = function(/* ... */) {
|
||||
/* Same `this.section` mistake as the proxy list above. */
|
||||
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||
if (routing_mode !== 'custom')
|
||||
return hp.rpcCall('acllist_write', ['direct_list', ''], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||
if (ret && ret.result === false)
|
||||
throw (ret.error || 'unknown error');
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value)
|
||||
for (let i of value.split('\n'))
|
||||
if (i && !stubValidator.apply('hostname', i))
|
||||
return _('Expecting: %s').format(_('valid hostname'));
|
||||
|
||||
return true;
|
||||
}
|
||||
/* Direct domain list end */
|
||||
/* ACL settings end */
|
||||
}
|
||||
|
||||
return baseclass.extend({ render });
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
/* Build the shared body of a routing/dns rule section. The two sections
|
||||
* differ in their tab/option names, the sniffed-protocol value list, the
|
||||
* order of the protocol/network/query-type triplet, a handful of
|
||||
* descriptions and their action-specific field sets; everything else is
|
||||
* emitted identically. */
|
||||
function renderRuleSection(s, kind, self) {
|
||||
let o, ss, so;
|
||||
let is_dns = (kind === 'dns');
|
||||
let uci_type = is_dns ? 'dns_rule' : 'routing_rule';
|
||||
|
||||
s.tab(uci_type, is_dns ? _('DNS Rules') : _('Routing Rules'));
|
||||
o = s.taboption(uci_type, form.SectionValue, '_' + uci_type, form.GridSection, uci_type);
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
ss.addremove = true;
|
||||
ss.rowcolors = true;
|
||||
ss.sortable = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.modaltitle = L.bind(hp.loadModalTitle, hp, is_dns ? _('DNS rule') : _('Routing rule'),
|
||||
is_dns ? _('Add a DNS rule') : _('Add a routing rule'), 'homeproxy');
|
||||
ss.sectiontitle = L.bind(hp.loadDefaultLabel, self, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||
* form hands a call site's button factory to the wrong argument. */
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class);
|
||||
};
|
||||
|
||||
ss.tab('field_other', _('Other fields'));
|
||||
ss.tab('field_host', _('Host/IP fields'));
|
||||
ss.tab('field_port', _('Port fields'));
|
||||
ss.tab('fields_process', _('Process fields'));
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'label', _('Label'));
|
||||
so.load = L.bind(hp.loadDefaultLabel, self, 'homeproxy');
|
||||
so.validate = L.bind(hp.validateUniqueValue, self, 'homeproxy', uci_type, 'label');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'enabled', _('Enable'));
|
||||
so.default = so.enabled;
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'mode', _('Mode'),
|
||||
is_dns ? _('The default rule uses the following matching logic:<br/>' +
|
||||
'<code>(domain || domain_suffix || domain_keyword || domain_regex)</code> &&<br/>' +
|
||||
'<code>(port || port_range)</code> &&<br/>' +
|
||||
'<code>(source_ip_cidr || source_ip_is_private)</code> &&<br/>' +
|
||||
'<code>(source_port || source_port_range)</code> &&<br/>' +
|
||||
'<code>other fields</code>.<br/>' +
|
||||
'Additionally, included rule sets can be considered merged rather than as a single rule sub-item.') :
|
||||
_('The default rule uses the following matching logic:<br/>' +
|
||||
'<code>(domain || domain_suffix || domain_keyword || domain_regex || ip_cidr || ip_is_private)</code> &&<br/>' +
|
||||
'<code>(port || port_range)</code> &&<br/>' +
|
||||
'<code>(source_ip_cidr || source_ip_is_private)</code> &&<br/>' +
|
||||
'<code>(source_port || source_port_range)</code> &&<br/>' +
|
||||
'<code>other fields</code>.<br/>' +
|
||||
'Additionally, included rule sets can be considered merged rather than as a single rule sub-item.'));
|
||||
so.value('default', _('Default'));
|
||||
so.default = 'default';
|
||||
so.rmempty = false;
|
||||
so.readonly = true;
|
||||
if (is_dns)
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'ip_version', _('IP version'),
|
||||
is_dns ? null : _('4 or 6. Not limited if empty.'));
|
||||
so.value('4', _('IPv4'));
|
||||
so.value('6', _('IPv6'));
|
||||
so.value('', _('Both'));
|
||||
so.modalonly = true;
|
||||
|
||||
function addSniffedProtocol() {
|
||||
let opt = ss.taboption('field_other', form.MultiValue, 'protocol', _('Protocol'),
|
||||
_('Sniffed protocol, see <a target="_blank" href="https://sing-box.sagernet.org/configuration/route/sniff/">Sniff</a> for details.'));
|
||||
opt.value('bittorrent', _('BitTorrent'));
|
||||
if (!is_dns)
|
||||
opt.value('dns', _('DNS'));
|
||||
opt.value('dtls', _('DTLS'));
|
||||
opt.value('http', _('HTTP'));
|
||||
opt.value('quic', _('QUIC'));
|
||||
opt.value('rdp', _('RDP'));
|
||||
opt.value('ssh', _('SSH'));
|
||||
opt.value('stun', _('STUN'));
|
||||
opt.value('tls', _('TLS'));
|
||||
|
||||
return opt;
|
||||
}
|
||||
|
||||
function addNetwork() {
|
||||
let opt = ss.taboption('field_other', form.ListValue, 'network', _('Network'));
|
||||
opt.value('tcp', _('TCP'));
|
||||
opt.value('udp', _('UDP'));
|
||||
opt.value('', _('Both'));
|
||||
|
||||
return opt;
|
||||
}
|
||||
|
||||
if (is_dns) {
|
||||
so = ss.taboption('field_other', form.DynamicList, 'query_type', _('Query type'),
|
||||
_('Match query type.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = addNetwork();
|
||||
so = addSniffedProtocol();
|
||||
} else {
|
||||
so = addSniffedProtocol();
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'client', _('Client'),
|
||||
_('Sniffed client type (QUIC client type or SSH client name).'));
|
||||
so.value('chromium', _('Chromium / Cronet'));
|
||||
so.value('firefox', _('Firefox / uquic firefox'));
|
||||
so.value('quic-go', _('quic-go / uquic chrome'));
|
||||
so.value('safari', _('Safari / Apple Network API'));
|
||||
so.depends('protocol', 'quic');
|
||||
so.depends('protocol', 'ssh');
|
||||
so.modalonly = true;
|
||||
|
||||
so = addNetwork();
|
||||
}
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'user', _('User'),
|
||||
_('Match user name.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', hp.CBIStaticList, 'rule_set', _('Rule set'),
|
||||
is_dns ? _('Match rule set. DNS: rules referencing rule-sets that contain query_type are incompatible with legacy address filters (ip_cidr / ip_is_private) — use match_response instead (1.14).') :
|
||||
_('Match rule set. Since 1.14: a rule-set holding a single default rule is merged into this rule; any other rule-set matches as an OR collection.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
uci.sections('homeproxy', 'ruleset', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'rule_set_ip_cidr_match_source', _('Rule set IP CIDR as source IP'),
|
||||
is_dns ? _('Make IP CIDR in rule sets match the source IP.') : _('Make IP CIDR in rule set used to match the source IP.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'invert', _('Invert'),
|
||||
_('Invert match result.'));
|
||||
so.modalonly = true;
|
||||
|
||||
/* Action options are split by which schema they belong to:
|
||||
* dns_rule actions: route, reject, predefined, evaluate (1.14 only)
|
||||
* routing_rule actions: route, route-options, reject, resolve, hijack/sniff
|
||||
* (sniff/hijack are emitted by the proxy-mode builder,
|
||||
* not exposed in the form).
|
||||
*
|
||||
* `route-options` is a routing_rule-only action: sing-box 1.14's dns_rule
|
||||
* schema has no such action, and emitting it would make the whole dns block
|
||||
* fail with "unknown action". Earlier code added it to both branches and
|
||||
* the depends() guards for dns_disable_cache / rewrite_ttl / client_subnet
|
||||
* pinned a stale 'route-options' in the UCI that resurfaced as "unknown
|
||||
* field" the next time the user flipped back to 'route'. */
|
||||
so = ss.taboption('field_other', form.ListValue, 'action', _('Action'));
|
||||
so.value('route', _('Route'));
|
||||
so.value('reject', _('Reject'));
|
||||
if (is_dns) {
|
||||
so.value('predefined', _('Predefined'));
|
||||
so.value('evaluate', _('Evaluate (1.14)'));
|
||||
} else {
|
||||
so.value('route-options', _('Route options'));
|
||||
so.value('resolve', _('Resolve'));
|
||||
}
|
||||
so.default = 'route';
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
if (is_dns) {
|
||||
so = ss.taboption('field_other', form.ListValue, 'server', _('Server'),
|
||||
_('Tag of the target dns server.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'dns_disable_cache', _('Disable dns cache'),
|
||||
_('Disable cache and save cache in this query.'));
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'rewrite_ttl', _('Rewrite TTL'),
|
||||
_('Rewrite TTL in DNS responses.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'client_subnet', _('EDNS Client subnet'),
|
||||
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'match_response', _('Match response'),
|
||||
_('1 matches the latest untagged evaluate result; other values match an evaluate tag (1.14).'));
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'evaluate_tag', _('Evaluate tag'),
|
||||
_('Optional tag for this evaluate result.'));
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'race', _('Race'),
|
||||
_('Judge this response-dependent rule in parallel; first match wins (1.14).'));
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'speculative', _('Speculative'),
|
||||
_('Send the query before pending race rules are judged (1.14).'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'disable_optimistic_cache', _('Disable optimistic cache'),
|
||||
_('Disable optimistic DNS caching for this query.'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'dns_timeout', _('Query timeout'),
|
||||
_('Override dns.timeout for this query, in seconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'remove_client_subnet', _('Remove EDNS client subnet'),
|
||||
_('Remove the edns0-subnet record from the query (1.14).'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'response_rcode', _('Response RCode'),
|
||||
_('Match the evaluated response code (requires match_response).'));
|
||||
for (let rc of [ 'NOERROR', 'FORMERR', 'SERVFAIL', 'NXDOMAIN', 'NOTIMP', 'REFUSED' ])
|
||||
so.value(rc);
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'response_answer', _('Response answer'),
|
||||
_('Text DNS records to match in the evaluated answer.'));
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'response_ns', _('Response NS'));
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'response_extra', _('Response extra'));
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'reject_method', _('Method'));
|
||||
so.value('default', _('Reply with REFUSED'));
|
||||
so.value('drop', _('Drop requests'));
|
||||
so.default = 'default';
|
||||
so.depends('action', 'reject');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'reject_no_drop', _('Don\'t drop requests'),
|
||||
_('<code>%s</code> will be temporarily overwritten to <code>%s</code> after 50 triggers in 30s if not enabled.').format(
|
||||
_('Method'), _('Drop requests')));
|
||||
so.depends('reject_method', 'default');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'predefined_rcode', _('RCode'),
|
||||
_('The response code.'));
|
||||
so.value('NOERROR');
|
||||
so.value('FORMERR');
|
||||
so.value('SERVFAIL');
|
||||
so.value('NXDOMAIN');
|
||||
so.value('NOTIMP');
|
||||
so.value('REFUSED');
|
||||
so.default = 'NOERROR';
|
||||
so.depends('action', 'predefined');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'predefined_answer', _('Answer'),
|
||||
_('List of text DNS record to respond as answers.'));
|
||||
so.depends('action', 'predefined');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'predefined_ns', _('NS'),
|
||||
_('List of text DNS record to respond as name servers.'));
|
||||
so.depends('action', 'predefined');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'predefined_extra', _('Extra records'),
|
||||
_('List of text DNS record to respond as extra records.'));
|
||||
so.depends('action', 'predefined');
|
||||
so.modalonly = true;
|
||||
} else {
|
||||
so = ss.taboption('field_other', form.ListValue, 'outbound', _('Outbound'),
|
||||
_('Tag of the target outbound.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('direct-out', _('Direct'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.rmempty = false;
|
||||
so.depends('action', 'route');
|
||||
so.editable = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'override_address', _('Override address'),
|
||||
_('Override the connection destination address.'));
|
||||
so.datatype = 'ipaddr';
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'override_port', _('Override port'),
|
||||
_('Override the connection destination port.'));
|
||||
so.datatype = 'port';
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'udp_disable_domain_unmapping', _('Disable UDP domain unmapping'),
|
||||
_('If enabled, for UDP proxy requests addressed to a domain, the original packet address will be sent in the response instead of the mapped domain.'));
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'udp_connect', _('connect UDP connections'),
|
||||
_('If enabled, attempts to connect UDP connection to the destination instead of listen.'));
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'udp_timeout', _('UDP timeout'),
|
||||
_('Timeout for UDP connections.<br/>Setting a larger value than the UDP timeout in inbounds will have no effect.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'tls_record_fragment', _('TLS record fragment'),
|
||||
_('Fragment TLS handshake into multiple TLS records.'));
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'tls_fragment', _('TLS fragment'),
|
||||
_('Fragment TLS handshakes. Due to poor performance, try <code>%s</code> first.').format(
|
||||
_('TLS record fragment')));
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'tls_fragment_fallback_delay', _('Fragment fallback delay'),
|
||||
_('The fallback value in milliseconds used when TLS segmentation cannot automatically determine the wait time.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '500';
|
||||
so.depends('tls_fragment', '1');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'tls_spoof', _('TLS spoof SNI (1.14)'),
|
||||
_('Inject a forged TLS ClientHello carrying this SNI before the real one to fool SNI-filtering middleboxes. Requires elevated privileges.'));
|
||||
so.datatype = 'hostname';
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'tls_spoof_method', _('TLS spoof method (1.14)'),
|
||||
_('How the forged segment is rejected by the real server.'));
|
||||
so.value('', _('wrong-sequence (default)'));
|
||||
so.value('wrong-checksum', _('wrong-checksum'));
|
||||
so.value('wrong-ack', _('wrong-ack'));
|
||||
so.value('wrong-md5', _('wrong-md5'));
|
||||
so.value('wrong-timestamp', _('wrong-timestamp'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('tls_spoof', /[\s\S]/);
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'resolve_server', _('DNS server'),
|
||||
_('Specifies DNS server tag to use instead of selecting through DNS routing.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('Default'));
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'reject_method', _('Method'));
|
||||
so.value('default', _('Reply with TCP RST / ICMP port unreachable'));
|
||||
so.value('drop', _('Drop packets'));
|
||||
so.depends('action', 'reject');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'reject_no_drop', _('Don\'t drop packets'),
|
||||
_('<code>%s</code> will be temporarily overwritten to <code>%s</code> after 50 triggers in 30s if not enabled.').format(
|
||||
_('Method'), _('Drop packets')));
|
||||
so.depends('reject_method', 'default');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'resolve_strategy', _('Resolve strategy'),
|
||||
_('Domain strategy for resolving the domain names.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'resolve_disable_cache', _('Disable DNS cache'),
|
||||
_('Disable DNS cache in this query.'));
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'resolve_rewrite_ttl', _('Rewrite TTL'),
|
||||
_('Rewrite TTL in DNS responses.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'resolve_client_subnet', _('EDNS Client subnet'),
|
||||
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'resolve_disable_optimistic_cache', _('Disable optimistic cache'),
|
||||
_('Disable optimistic DNS caching in this lookup (1.14).'));
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'resolve_timeout', _('Query timeout'),
|
||||
_('Override dns.timeout for this lookup, in seconds (1.14).'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
}
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'domain', _('Domain name'),
|
||||
_('Match full domain.'));
|
||||
so.datatype = 'hostname';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'domain_suffix', _('Domain suffix'),
|
||||
_('Match domain suffix.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'domain_keyword', _('Domain keyword'),
|
||||
_('Match domain using keyword.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'domain_regex', _('Domain regex'),
|
||||
_('Match domain using regular expression.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'source_ip_cidr', _('Source IP CIDR'),
|
||||
_('Match source IP CIDR.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.Flag, 'source_ip_is_private', _('Match private source IP'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'ip_cidr', _('IP CIDR'),
|
||||
is_dns ? _('Match IP CIDR with the evaluated response (needs match_response; old rules are auto-wrapped in 1.14).') :
|
||||
_('Match IP CIDR.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.Flag, 'ip_is_private', _('Match private IP'),
|
||||
is_dns ? _('Match private IP with the evaluated response (needs match_response).') : null);
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'source_mac_address', _('Source MAC address'),
|
||||
_('Match LAN device by MAC address.'));
|
||||
so.datatype = 'macaddr';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'source_hostname', _('Source hostname'),
|
||||
_('Match LAN device hostname via neighbor resolution; enable find_neighbor.'));
|
||||
so.modalonly = true;
|
||||
|
||||
if (is_dns) {
|
||||
so = ss.taboption('field_host', form.Value, 'query_client_subnet', _('Query EDNS client subnet'),
|
||||
_('Match the EDNS Client Subnet in the query (1.14).'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.Flag, 'query_dnssec', _('Match DNSSEC OK'),
|
||||
_('Match queries with the DNSSEC OK bit set (1.14).'));
|
||||
so.modalonly = true;
|
||||
}
|
||||
|
||||
so = ss.taboption('field_port', form.DynamicList, 'source_port', _('Source port'),
|
||||
_('Match source port.'));
|
||||
so.datatype = 'port';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_port', form.DynamicList, 'source_port_range', _('Source port range'),
|
||||
_('Match source port range. Format as START:/:END/START:END.'));
|
||||
so.validate = hp.validatePortRange;
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_port', form.DynamicList, 'port', _('Port'),
|
||||
_('Match port.'));
|
||||
so.datatype = 'port';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_port', form.DynamicList, 'port_range', _('Port range'),
|
||||
_('Match port range. Format as START:/:END/START:END.'));
|
||||
so.validate = hp.validatePortRange;
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('fields_process', form.DynamicList, 'process_name', _('Process name'),
|
||||
_('Match process name.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('fields_process', form.DynamicList, 'process_path', _('Process path'),
|
||||
_('Match process path.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('fields_process', form.DynamicList, 'process_path_regex', _('Process path (regex)'),
|
||||
_('Match process path using regular expression.'));
|
||||
so.modalonly = true;
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderRuleSection });
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require view.homeproxy.client.common as common';
|
||||
|
||||
/* The four cache options exist on the 'dns' NamedSection in both the custom
|
||||
* mode tab (renderDnsSettings) and the preset mode tab (renderDnsCache).
|
||||
* Both used to spell them out separately, which registered the same four
|
||||
* option names twice in one map: LuCI keeps the first registration for writes
|
||||
* and lookupOption(), so the second copy's dependencies did nothing while its
|
||||
* description could drift. They are declared here once and rendered from
|
||||
* wherever the tab needs them, in the original order.
|
||||
*
|
||||
* The depends() are the custom-mode copy's: with the cache disabled the
|
||||
* optimistic cache and its persisted file have no effect, so both are hidden
|
||||
* (disable_cache_expire is itself conditional on disable_cache). */
|
||||
function renderDnsCacheOptions(ss) {
|
||||
let so;
|
||||
|
||||
so = ss.option(form.Flag, 'optimistic_cache', _('Optimistic DNS cache'),
|
||||
_('Return expired cache immediately and refresh in background (sing-box 1.14).'));
|
||||
so.depends('disable_cache', '0');
|
||||
so.depends('disable_cache_expire', '0');
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'optimistic_timeout', _('Optimistic cache timeout'),
|
||||
_('Max time an expired entry may be served. Examples: 3d, 1h.'));
|
||||
|
||||
so = ss.option(form.Value, 'dns_timeout', _('DNS query timeout'),
|
||||
_('Default timeout per DNS query in seconds (sing-box default: 10).'));
|
||||
so.datatype = 'uinteger';
|
||||
|
||||
so = ss.option(form.Flag, 'cache_file_store_dns', _('Store DNS cache'),
|
||||
_('Persist DNS cache across restarts (sing-box 1.14, replaces Store RDRC).'));
|
||||
so.depends('disable_cache', '0');
|
||||
so.rmempty = false;
|
||||
}
|
||||
|
||||
/* DNS settings tab plus the wrapping 'dns' NamedSection. Called after
|
||||
* routing.renderRoutingRules() so the NamedSection chain reads
|
||||
* config -> routing_node -> routing_rule -> dns from left to right. */
|
||||
function renderDnsSettings(ctx) {
|
||||
const { s } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('dns', _('DNS Settings'));
|
||||
o = s.taboption('dns', form.SectionValue, '_dns', form.NamedSection, 'dns', 'homeproxy');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
so = ss.option(form.ListValue, 'default_strategy', _('Default DNS strategy'),
|
||||
_('The DNS strategy for resolving the domain name in the address.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
|
||||
so = ss.option(form.ListValue, 'default_server', _('Default DNS server'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.default = 'default-dns';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Flag, 'disable_cache', _('Disable DNS cache'));
|
||||
|
||||
so = ss.option(form.Flag, 'disable_cache_expire', _('Disable cache expire'));
|
||||
so.depends('disable_cache', '0');
|
||||
|
||||
so = ss.option(form.Value, 'client_subnet', _('EDNS Client subnet'),
|
||||
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
|
||||
renderDnsCacheOptions(ss);
|
||||
/* DNS settings end */
|
||||
}
|
||||
|
||||
/* DNS rules sub-section. Split out from renderDnsSettings() so the
|
||||
* orchestrator can call it after nodes.renderDnsServers() and preserve
|
||||
* the original tab ordering (dns, dns_server, dns_rule). */
|
||||
function renderDnsRules(ctx) {
|
||||
const { s, self } = ctx;
|
||||
|
||||
/* DNS rules start */
|
||||
common.renderRuleSection(s, 'dns', self);
|
||||
/* DNS rules end */
|
||||
}
|
||||
|
||||
/* DNS tab for the preset routing modes. Titled 'DNS Settings' exactly like
|
||||
* the custom-mode tab in renderDnsSettings(): the two are mutually exclusive
|
||||
* (custom vs the presets), so whichever one a user lands on is called the
|
||||
* same thing.
|
||||
*
|
||||
* The three server options below are config.* (not dns.*) options that used
|
||||
* to live on the routing tab. They are DNS settings, and the routing page
|
||||
* only carried them because of the order the old monolithic client.js
|
||||
* happened to be written in. They stay on the 'config' NamedSection, so
|
||||
* only the tab they render under changed. */
|
||||
function renderDnsCache(ctx) {
|
||||
const { s, stubValidator } = ctx;
|
||||
let o, ss;
|
||||
|
||||
s.tab('dns_cache', _('DNS Settings'));
|
||||
|
||||
o = s.taboption('dns_cache', form.Value, 'dns_server', _('DNS server'),
|
||||
_('Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.'));
|
||||
o.value('wan', _('WAN DNS (read from interface)'));
|
||||
o.value('1.1.1.1', _('CloudFlare Public DNS (1.1.1.1)'));
|
||||
o.value('208.67.222.222', _('Cisco Public DNS (208.67.222.222)'));
|
||||
o.value('8.8.8.8', _('Google Public DNS (8.8.8.8)'));
|
||||
o.value('', '---');
|
||||
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
|
||||
o.value('119.29.29.29', _('Tencent Public DNS (119.29.29.29)'));
|
||||
o.value('117.50.10.10', _('ThreatBook Public DNS (117.50.10.10)'));
|
||||
o.default = '8.8.8.8';
|
||||
o.rmempty = false;
|
||||
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id && !['wan'].includes(value)) {
|
||||
if (!value)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
|
||||
let ipv6_support = this.section.formvalue(section_id, 'ipv6_support');
|
||||
try {
|
||||
let url = new URL(value.replace(/^.*:\/\//, 'http://'));
|
||||
if (stubValidator.apply('hostname', url.hostname))
|
||||
return true;
|
||||
else if (stubValidator.apply('ip4addr', url.hostname))
|
||||
return true;
|
||||
else if ((ipv6_support === '1') && stubValidator.apply('ip6addr', url.hostname.match(/^\[(.+)\]$/)?.[1]))
|
||||
return true;
|
||||
else
|
||||
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||
} catch(e) {}
|
||||
|
||||
if (!stubValidator.apply((ipv6_support === '1') ? 'ipaddr' : 'ip4addr', value))
|
||||
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
o = s.taboption('dns_cache', form.Value, 'china_dns_server', _('China DNS server'),
|
||||
_('The dns server for resolving China domains. Support UDP, TCP, DoH, DoQ, DoT.'));
|
||||
o.value('wan', _('WAN DNS (read from interface)'));
|
||||
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
|
||||
o.value('210.2.4.8', _('CNNIC Public DNS (210.2.4.8)'));
|
||||
o.value('119.29.29.29', _('Tencent Public DNS (119.29.29.29)'));
|
||||
o.value('117.50.10.10', _('ThreatBook Public DNS (117.50.10.10)'));
|
||||
o.depends('routing_mode', 'bypass_mainland_china');
|
||||
o.default = '223.5.5.5';
|
||||
o.rmempty = false;
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id && !['wan'].includes(value)) {
|
||||
if (!value)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
|
||||
try {
|
||||
let url = new URL(value.replace(/^.*:\/\//, 'http://'));
|
||||
if (stubValidator.apply('hostname', url.hostname))
|
||||
return true;
|
||||
else if (stubValidator.apply('ip4addr', url.hostname))
|
||||
return true;
|
||||
else if (stubValidator.apply('ip6addr', url.hostname.match(/^\[(.+)\]$/)?.[1]))
|
||||
return true;
|
||||
else
|
||||
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||
} catch(e) {}
|
||||
|
||||
if (!stubValidator.apply('ipaddr', value))
|
||||
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
o = s.taboption('dns_cache', form.Flag, 'cn_ip_fallback', _('CN-IP DNS fallback (sing-box 1.14)'),
|
||||
_('When the main DNS returns a mainland China IP, re-resolve via China DNS using evaluate/match_response.'));
|
||||
o.depends('routing_mode', 'bypass_mainland_china');
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('dns_cache', form.SectionValue, '_dns_cache', form.NamedSection, 'dns', 'homeproxy');
|
||||
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||
ss = o.subsection;
|
||||
|
||||
renderDnsCacheOptions(ss);
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderDnsSettings, renderDnsRules, renderDnsCache });
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
/* Routing nodes sub-grid (the per-outbound entries that routing_rule
|
||||
* actions dispatch to). Called after routing.render() so the SectionValue
|
||||
* grid nesting is intact. */
|
||||
function renderRoutingNodes(ctx) {
|
||||
const { s, proxy_nodes } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('routing_node', _('Routing Nodes'));
|
||||
o = s.taboption('routing_node', form.SectionValue, '_routing_node', form.GridSection, 'routing_node');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
ss.addremove = true;
|
||||
ss.rowcolors = true;
|
||||
ss.sortable = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('Routing node'), _('Add a routing node'), 'homeproxy');
|
||||
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||
* form hands a call site's button factory to the wrong argument. */
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class);
|
||||
};
|
||||
|
||||
so = ss.option(form.Value, 'label', _('Label'));
|
||||
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'routing_node', 'label');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||
so.default = so.enabled;
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'node', _('Node'),
|
||||
_('Outbound node'));
|
||||
so.value('urltest', _('URLTest'));
|
||||
for (let i in proxy_nodes)
|
||||
so.value(i, proxy_nodes[i]);
|
||||
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'routing_node', 'node');
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'domain_resolver', _('Domain resolver'),
|
||||
_('For resolving domain name in the server address.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('Default'));
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.depends({'node': 'urltest', '!reverse': true});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
|
||||
_('The domain strategy for resolving the domain name in the address.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
so.depends({'node': 'urltest', '!reverse': true});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
|
||||
_('The network interface to bind to.'));
|
||||
so.multiple = false;
|
||||
so.noaliases = true;
|
||||
so.depends({'outbound': '', 'node': /^((?!urltest$).)+$/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||
_('The tag of the upstream outbound.<br/>Other dial fields will be ignored when enabled.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('Direct'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res['.name'] !== section_id && res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
let node = this.section.formvalue(section_id, 'node');
|
||||
|
||||
let conflict = false;
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res['.name'] !== section_id) {
|
||||
if (res.outbound === section_id && res['.name'] == value)
|
||||
conflict = true;
|
||||
else if (res.node === 'urltest' && res.urltest_nodes?.includes(node) && res['.name'] == value)
|
||||
conflict = true;
|
||||
}
|
||||
});
|
||||
if (conflict)
|
||||
return _('Recursive outbound detected!');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.depends({'node': 'urltest', '!reverse': true});
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(hp.CBIStaticList, 'urltest_nodes', _('URLTest nodes'),
|
||||
_('List of nodes to test.'));
|
||||
for (let i in proxy_nodes)
|
||||
so.value(i, proxy_nodes[i]);
|
||||
so.depends('node', 'urltest');
|
||||
/* The repository's other validators take (section_id, value) and check
|
||||
* `value` before dereferencing it. This one re-read the value through
|
||||
* `this.section.formvalue()`, which returns null when the widget is not
|
||||
* in the DOM, so `.length` threw a TypeError - the save aborted with a
|
||||
* console error instead of the "non-empty value" message. The framework
|
||||
* already hands the current value in (getValidator is
|
||||
* L.bind(this.validate, this, section_id)), so use it. */
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value && !value.length)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
|
||||
return true;
|
||||
}
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'urltest_url', _('Test URL'),
|
||||
_('The URL to test.'));
|
||||
so.placeholder = 'https://www.gstatic.com/generate_204';
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
try {
|
||||
let url = new URL(value);
|
||||
if (!url.hostname)
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
catch(e) {
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'urltest_interval', _('Test interval'),
|
||||
_('The test interval in seconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '180';
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
let idle_timeout = this.section.formvalue(section_id, 'urltest_idle_timeout') || '1800';
|
||||
if (parseInt(value) > parseInt(idle_timeout))
|
||||
return _('Test interval must be less or equal than idle timeout.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'urltest_tolerance', _('Test tolerance'),
|
||||
_('The test tolerance in milliseconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '50';
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'urltest_idle_timeout', _('Idle timeout'),
|
||||
_('The idle timeout in seconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '1800';
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Flag, 'urltest_interrupt_exist_connections', _('Interrupt existing connections'),
|
||||
_('Interrupt existing connections when the selected outbound has changed.'));
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
/* Routing nodes end */
|
||||
|
||||
}
|
||||
|
||||
/* DNS servers sub-grid (per-server entries that dns_rule 'route'/'evaluate'
|
||||
* actions can target). Called after dns.renderDnsSettings() so the dns
|
||||
* NamedSection is in place. */
|
||||
function renderDnsServers(ctx) {
|
||||
const { s } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('dns_server', _('DNS Servers'));
|
||||
o = s.taboption('dns_server', form.SectionValue, '_dns_server', form.GridSection, 'dns_server');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
ss.addremove = true;
|
||||
ss.rowcolors = true;
|
||||
ss.sortable = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('DNS server'), _('Add a DNS server'), 'homeproxy');
|
||||
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||
* form hands a call site's button factory to the wrong argument. */
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class);
|
||||
};
|
||||
|
||||
so = ss.option(form.Value, 'label', _('Label'));
|
||||
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'dns_server', 'label');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||
so.default = so.enabled;
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'type', _('Type'));
|
||||
so.value('udp', _('UDP'));
|
||||
so.value('tcp', _('TCP'));
|
||||
so.value('tls', _('TLS'));
|
||||
so.value('https', _('HTTPS'));
|
||||
so.value('h3', _('HTTP/3'));
|
||||
so.value('quic', _('QUIC'));
|
||||
so.default = 'udp';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'server', _('Address'),
|
||||
_('The address of the dns server.'));
|
||||
so.datatype = 'or(hostname, ipaddr)';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'server_port', _('Port'),
|
||||
_('The port of the DNS server.'));
|
||||
so.placeholder = 'auto';
|
||||
so.datatype = 'port';
|
||||
|
||||
so = ss.option(form.Value, 'path', _('Path'),
|
||||
_('The path of the DNS server.'));
|
||||
so.placeholder = '/dns-query';
|
||||
so.depends('type', 'https');
|
||||
so.depends('type', 'h3');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.DynamicList, 'headers', _('Headers'),
|
||||
_('Additional headers to be sent to the DNS server.'));
|
||||
so.depends('type', 'https');
|
||||
so.depends('type', 'h3');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'tls_sni', _('TLS SNI'),
|
||||
_('Used to verify the hostname on the returned certificates.'));
|
||||
so.depends('type', 'tls');
|
||||
so.depends('type', 'https');
|
||||
so.depends('type', 'h3');
|
||||
so.depends('type', 'quic');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'address_resolver', _('Address resolver'),
|
||||
_('Tag of a another server to resolve the domain name in the address. Required if address contains domain.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('None'));
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res['.name'] !== section_id && res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
let conflict = false;
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res['.name'] !== section_id)
|
||||
if (res.address_resolver === section_id && res['.name'] == value)
|
||||
conflict = true;
|
||||
});
|
||||
if (conflict)
|
||||
return _('Recursive resolver detected!');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'address_strategy', _('Address strategy'),
|
||||
_('The domain strategy for resolving the domain name in the address.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
so.depends({'address_resolver': '', '!reverse': true});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||
_('Tag of an outbound for connecting to the dns server.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('direct-out', _('Direct'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.default = 'direct-out';
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
/* DNS servers end */
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderRoutingNodes, renderDnsServers });
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require view.homeproxy.client.common as common';
|
||||
|
||||
/* Routing tab and the wrapping custom-routing NamedSection. The
|
||||
* routing_node tab lives in nodes.js; the routing_rule sub-section is
|
||||
* shared with dns and lives in common.js as renderRuleSection. */
|
||||
function render(ctx) {
|
||||
const { s, proxy_nodes, features, stubValidator } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('routing', _('Routing Settings'));
|
||||
|
||||
o = s.taboption('routing', form.ListValue, 'main_node', _('Main node'));
|
||||
o.value('nil', _('Disable'));
|
||||
o.value('urltest', _('URLTest'));
|
||||
for (let i in proxy_nodes)
|
||||
o.value(i, proxy_nodes[i]);
|
||||
o.default = 'nil';
|
||||
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', hp.CBIStaticList, 'main_urltest_nodes', _('URLTest nodes'),
|
||||
_('List of nodes to test.'));
|
||||
for (let i in proxy_nodes)
|
||||
o.value(i, proxy_nodes[i]);
|
||||
o.depends('main_node', 'urltest');
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', form.Value, 'main_urltest_interval', _('Test interval'),
|
||||
_('The test interval in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '180';
|
||||
o.depends('main_node', 'urltest');
|
||||
|
||||
o = s.taboption('routing', form.Value, 'main_urltest_tolerance', _('Test tolerance'),
|
||||
_('The test tolerance in milliseconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '50';
|
||||
o.depends('main_node', 'urltest');
|
||||
|
||||
o = s.taboption('routing', form.ListValue, 'main_udp_node', _('Main UDP node'));
|
||||
o.value('nil', _('Disable'));
|
||||
o.value('same', _('Same as main node'));
|
||||
o.value('urltest', _('URLTest'));
|
||||
for (let i in proxy_nodes)
|
||||
o.value(i, proxy_nodes[i]);
|
||||
o.default = 'nil';
|
||||
o.depends({'routing_mode': /^((?!custom).)+$/, 'proxy_mode': /^((?!redirect$).)+$/});
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', hp.CBIStaticList, 'main_udp_urltest_nodes', _('URLTest nodes'),
|
||||
_('List of nodes to test.'));
|
||||
for (let i in proxy_nodes)
|
||||
o.value(i, proxy_nodes[i]);
|
||||
o.depends('main_udp_node', 'urltest');
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', form.Value, 'main_udp_urltest_interval', _('Test interval'),
|
||||
_('The test interval in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '180';
|
||||
o.depends('main_udp_node', 'urltest');
|
||||
|
||||
o = s.taboption('routing', form.Value, 'main_udp_urltest_tolerance', _('Test tolerance'),
|
||||
_('The test tolerance in milliseconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '50';
|
||||
o.depends('main_udp_node', 'urltest');
|
||||
|
||||
o = s.taboption('routing', form.ListValue, 'routing_mode', _('Routing mode'));
|
||||
o.value('gfwlist', _('GFWList'));
|
||||
o.value('bypass_mainland_china', _('Bypass mainland China'));
|
||||
o.value('proxy_mainland_china', _('Only proxy mainland China'));
|
||||
o.value('custom', _('Custom routing'));
|
||||
o.value('global', _('Global'));
|
||||
o.default = 'bypass_mainland_china';
|
||||
o.rmempty = false;
|
||||
o.onchange = function(ev, section_id, value) {
|
||||
/* Save on every transition, not only into custom: leaving custom
|
||||
* leaves the user's `routing.default_outbound`, `default_outbound_dns`
|
||||
* and `bypass_cn_traffic` in UCI. The generator's read-only-in-custom
|
||||
* gate (5573269) keeps the generation valid, but the residue stays in
|
||||
* the file until the next time someone enters custom and saves
|
||||
* again, which is invisible to the user. Triggering save on every
|
||||
* change makes the UCI state track the mode the user actually picked. */
|
||||
if (section_id && value)
|
||||
this.map.save(null, true);
|
||||
}
|
||||
|
||||
o = s.taboption('routing', form.Value, 'routing_port', _('Routing ports'),
|
||||
_('Specify target ports to be proxied. Multiple ports must be separated by commas.'));
|
||||
o.value('', _('All ports'));
|
||||
o.value('common', _('Common ports only (bypass P2P traffic)'));
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id && value && value !== 'common') {
|
||||
|
||||
let ports = [];
|
||||
for (let i of value.split(',')) {
|
||||
if (!stubValidator.apply('port', i) && !stubValidator.apply('portrange', i))
|
||||
return _('Expecting: %s').format(_('valid port value'));
|
||||
if (ports.includes(i))
|
||||
return _('Port %s alrealy exists!').format(i);
|
||||
ports = ports.concat(i);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
o = s.taboption('routing', form.ListValue, 'proxy_mode', _('Proxy mode'));
|
||||
o.value('redirect', _('Redirect TCP'));
|
||||
if (features.hp_has_tproxy)
|
||||
o.value('redirect_tproxy', _('Redirect TCP + TProxy UDP'));
|
||||
if (features.hp_has_ip_full && features.hp_has_tun) {
|
||||
o.value('redirect_tun', _('Redirect TCP + Tun UDP'));
|
||||
o.value('tun', _('Tun TCP/UDP'));
|
||||
} else {
|
||||
o.description = _('To enable Tun support, you need to install <code>ip-full</code> and <code>kmod-tun</code>');
|
||||
}
|
||||
o.default = 'redirect_tproxy';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', form.Flag, 'ipv6_support', _('IPv6 support'));
|
||||
o.default = o.enabled;
|
||||
o.rmempty = false;
|
||||
|
||||
/* §2.7 (linux.json 与 pro 的差距分析): opt-in switch to the tutorial's
|
||||
* universal sniffer list + 100ms timeout. Default off so an upgrade is
|
||||
* invisible; arch-guard 40 pins the default '0' against drift. */
|
||||
o = s.taboption('routing', form.Flag, 'sniffer_advanced_mode',
|
||||
_('Sniffer: advanced mode (100ms, universal list)'),
|
||||
_('Use the tutorial-recommended sniffer profile (100ms timeout, http/tls/stun/quic/dns). Default keeps the 300ms / unconstrained-list behaviour.'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
|
||||
/* Custom routing settings start */
|
||||
/* Routing settings start */
|
||||
o = s.taboption('routing', form.SectionValue, '_routing', form.NamedSection, 'routing', 'homeproxy');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
so = ss.option(form.ListValue, 'tcpip_stack', _('TCP/IP stack'),
|
||||
_('TCP/IP stack.'));
|
||||
if (features.with_gvisor) {
|
||||
so.value('mixed', _('Mixed'));
|
||||
so.value('gvisor', _('gVisor'));
|
||||
}
|
||||
so.value('system', _('System'));
|
||||
so.default = 'system';
|
||||
so.depends('homeproxy.config.proxy_mode', 'redirect_tun');
|
||||
so.depends('homeproxy.config.proxy_mode', 'tun');
|
||||
so.rmempty = false;
|
||||
so.onchange = function(ev, section_id, value) {
|
||||
let desc = ev.target.nextElementSibling;
|
||||
if (value === 'mixed')
|
||||
desc.innerHTML = _('Mixed <code>system</code> TCP stack and <code>gVisor</code> UDP stack.')
|
||||
else if (value === 'gvisor')
|
||||
desc.innerHTML = _('Based on google/gvisor.');
|
||||
else if (value === 'system')
|
||||
desc.innerHTML = _('Less compatibility and sometimes better performance.');
|
||||
}
|
||||
|
||||
so = ss.option(form.Flag, 'endpoint_independent_nat', _('Enable endpoint-independent NAT'),
|
||||
_('Performance may degrade slightly, so it is not recommended to enable on when it is not needed.'));
|
||||
so.default = so.enabled;
|
||||
so.depends('tcpip_stack', 'mixed');
|
||||
so.depends('tcpip_stack', 'gvisor');
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'udp_timeout', _('UDP NAT expiration time'),
|
||||
_('In seconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '300';
|
||||
so.depends('homeproxy.config.proxy_mode', 'redirect_tproxy');
|
||||
so.depends('homeproxy.config.proxy_mode', 'redirect_tun');
|
||||
so.depends('homeproxy.config.proxy_mode', 'tun');
|
||||
|
||||
so = ss.option(form.Flag, 'bypass_cn_traffic', _('Bypass CN traffic'),
|
||||
_('Bypass mainland China traffic via firewall rules by default.'));
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Flag, 'find_neighbor', _('Find neighbor hosts'),
|
||||
_('Enable neighbor resolution so rules can match LAN devices by hostname (1.14).'));
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
|
||||
_('If set, the requested domain name will be resolved to IP before routing.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
|
||||
so = ss.option(form.ListValue, 'default_outbound', _('Default outbound'),
|
||||
_('Default outbound for connections not matched by any routing rules.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('nil', _('Disable (the service)'));
|
||||
this.value('direct-out', _('Direct'));
|
||||
this.value('block-out', _('Block'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.default = 'nil';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.ListValue, 'default_outbound_dns', _('Default outbound DNS'),
|
||||
_('Default DNS server for resolving domain name in the server address.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.default = 'default-dns';
|
||||
so.rmempty = false;
|
||||
/* Routing settings end */
|
||||
}
|
||||
|
||||
/* Routing rule sub-section. Split out from render() so the orchestrator can
|
||||
* call it after nodes.renderRoutingNodes() and preserve the original tab
|
||||
* ordering (routing, routing_node, routing_rule). */
|
||||
function renderRoutingRules(ctx) {
|
||||
const { s, self } = ctx;
|
||||
|
||||
/* Routing rules start */
|
||||
common.renderRuleSection(s, 'routing', self);
|
||||
/* Routing rules end */
|
||||
}
|
||||
|
||||
return baseclass.extend({ render, renderRoutingRules });
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
/* Rule set sub-grid (per-rule-set entries: local file or remote URL with
|
||||
* download metadata). Called after dns.renderDnsRules() so the
|
||||
* ruleset tab appears after the dns_rule sub-tab. */
|
||||
function render(ctx) {
|
||||
const { s } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('ruleset', _('Rule Set'));
|
||||
o = s.taboption('ruleset', form.SectionValue, '_ruleset', form.GridSection, 'ruleset');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
ss.addremove = true;
|
||||
ss.rowcolors = true;
|
||||
ss.sortable = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('Rule set'), _('Add a rule set'), 'homeproxy');
|
||||
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||
* form hands a call site's button factory to the wrong argument. */
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class);
|
||||
};
|
||||
|
||||
so = ss.option(form.Value, 'label', _('Label'));
|
||||
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'ruleset', 'label');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||
so.default = so.enabled;
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'type', _('Type'));
|
||||
so.value('local', _('Local'));
|
||||
so.value('remote', _('Remote'));
|
||||
so.default = 'remote';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.ListValue, 'format', _('Format'));
|
||||
so.value('binary', _('Binary file'));
|
||||
so.value('source', _('Source file'));
|
||||
so.default = 'binary';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'path', _('Path'));
|
||||
so.datatype = 'file';
|
||||
so.placeholder = '/etc/homeproxy/ruleset/example.json';
|
||||
so.rmempty = false;
|
||||
so.depends('type', 'local');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'url', _('Rule set URL'));
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id) {
|
||||
if (!value)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
let extra = this.section.formvalue(section_id, 'extra_tags') || [];
|
||||
if (extra.length && !value.includes('{tag}'))
|
||||
return _('Expecting: %s').format(_('{tag} in URL when extra tags are used'));
|
||||
|
||||
try {
|
||||
let url = new URL(value);
|
||||
if (!url.hostname)
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
catch(e) {
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.rmempty = false;
|
||||
so.depends('type', 'remote');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||
_('Outbound used to download this rule-set (via sing-box 1.14 http_clients).'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('Default'));
|
||||
this.value('direct-out', _('Direct'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.depends('type', 'remote');
|
||||
|
||||
so = ss.option(form.Value, 'initial_path', _('Initial path'),
|
||||
_('Local file with initial rule-set content; avoids blocking startup on first download (1.14).'));
|
||||
so.datatype = 'file';
|
||||
so.depends('type', 'remote');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.DynamicList, 'extra_tags', _('Extra tags'),
|
||||
_('Extra rule-set tags sharing these options. Requires {tag} in path/url (1.14).'));
|
||||
so.depends('type', 'remote');
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value && value.length) {
|
||||
let rule_url = this.section.formvalue(section_id, 'url') || '';
|
||||
if (!rule_url.includes('{tag}'))
|
||||
return _('Expecting: %s').format(_('{tag} in URL when extra tags are used'));
|
||||
let rule_initial = this.section.formvalue(section_id, 'initial_path') || '';
|
||||
if (rule_initial && !rule_initial.includes('{tag}'))
|
||||
return _('Expecting: %s').format(_('{tag} in initial path when extra tags are used'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'update_interval', _('Update interval'),
|
||||
_('Update interval of rule set.'));
|
||||
so.placeholder = '1d';
|
||||
so.depends('type', 'remote');
|
||||
/* Rule set settings end */
|
||||
}
|
||||
|
||||
return baseclass.extend({ render });
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
/* TUN DNS tab - the rightmost tab. These two options configure sing-box's
|
||||
* own DNS handling inside the TUN interface, which is a different thing from
|
||||
* the DNS servers and cache settings on the "DNS Settings" page; keeping them
|
||||
* there made three labels starting with "DNS server"/"TUN DNS" sit next to
|
||||
* each other on one page.
|
||||
*
|
||||
* They are config.* options and depend on proxy_mode (TUN modes only), not on
|
||||
* routing_mode, so they appear for custom and preset routing alike. The tab
|
||||
* has no depends() of its own: both options depend on proxy_mode, so any
|
||||
* other proxy mode hides them and LuCI drops the empty tab with them. */
|
||||
function renderTunDns(ctx) {
|
||||
const { s } = ctx;
|
||||
let o;
|
||||
|
||||
s.tab('tun_dns', _('TUN DNS'));
|
||||
|
||||
o = s.taboption('tun_dns', form.ListValue, 'tun_dns_mode', _('TUN DNS mode (1.14)'),
|
||||
_('Since sing-box 1.14 the default (Unset) behaves as hijack: sing-box sets the platform interface DNS and hijacks port 53. On OpenWrt this overlaps with the own dnsmasq/nftables DNS hijack of this plugin, so keep Disabled on a gateway unless you need sing-box to own TUN DNS.'));
|
||||
o.value('default', _('Unset (default)'));
|
||||
o.value('disabled', _('Disabled'));
|
||||
o.value('native', _('Native'));
|
||||
o.value('hijack', _('Hijack'));
|
||||
o.depends('proxy_mode', 'redirect_tun');
|
||||
o.depends('proxy_mode', 'tun');
|
||||
o.default = 'default';
|
||||
o.rmempty = true;
|
||||
|
||||
o = s.taboption('tun_dns', form.DynamicList, 'tun_dns_address', _('TUN DNS addresses (1.14)'));
|
||||
o.datatype = 'ipaddr';
|
||||
/* The old `proxy_mode: /^((?!custom).)+$/` was written as if proxy_mode
|
||||
* could be 'custom' - it cannot (the values are redirect / redirect_tproxy
|
||||
* / redirect_tun / tun), so the condition was always true and the address
|
||||
* field showed up in plain redirect mode too. Match the two TUN modes,
|
||||
* the same way tun_dns_mode above does. */
|
||||
o.depends({'proxy_mode': /^(redirect_tun|tun)$/, 'tun_dns_mode': /^(disabled|native|hijack)$/});
|
||||
o.modalonly = true;
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderTunDns });
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
/* UDP NAT tab - the rightmost tab. sing-box's UDP NAT knobs are not routing
|
||||
* decisions, so they get their own page instead of sitting at the bottom of
|
||||
* the routing tab. They are config.* options and stay on the 'config'
|
||||
* NamedSection, so only the tab they render under changes.
|
||||
*
|
||||
* The tab has no depends() of its own: all three options depend on
|
||||
* proxy_mode, so a custom proxy mode hides every option and LuCI drops the
|
||||
* empty tab with them. */
|
||||
function renderUdpNat(ctx) {
|
||||
const { s } = ctx;
|
||||
let o;
|
||||
|
||||
s.tab('udp_nat', _('UDP NAT Settings'));
|
||||
|
||||
o = s.taboption('udp_nat', form.ListValue, 'udp_mapping', _('UDP NAT mapping (1.14)'));
|
||||
o.value('endpoint_independent', _('Endpoint independent'));
|
||||
o.value('address_dependent', _('Address dependent'));
|
||||
o.value('address_and_port_dependent', _('Address and port dependent'));
|
||||
o.description = _('sing-box default; recommended for home use (small NAT table, QUIC/game friendly). Choose stricter only for specific UDP issues.');
|
||||
o.depends('proxy_mode', 'redirect_tproxy');
|
||||
o.depends('proxy_mode', 'redirect_tun');
|
||||
o.depends('proxy_mode', 'tun');
|
||||
o.default = 'endpoint_independent';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('udp_nat', form.ListValue, 'udp_filtering', _('UDP NAT filtering (1.14)'));
|
||||
o.value('endpoint_independent', _('Endpoint independent'));
|
||||
o.value('address_dependent', _('Address dependent'));
|
||||
o.value('address_and_port_dependent', _('Address and port dependent'));
|
||||
o.description = _('sing-box default; recommended for home use (accepts replies from any remote after mapping). Choose stricter only for specific UDP issues.');
|
||||
o.depends('proxy_mode', 'redirect_tproxy');
|
||||
o.depends('proxy_mode', 'redirect_tun');
|
||||
o.depends('proxy_mode', 'tun');
|
||||
o.default = 'endpoint_independent';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('udp_nat', form.Value, 'udp_nat_max', _('UDP NAT sessions max (1.14)'));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('proxy_mode', 'redirect_tproxy');
|
||||
o.depends('proxy_mode', 'redirect_tun');
|
||||
o.depends('proxy_mode', 'tun');
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderUdpNat });
|
||||
@@ -0,0 +1,936 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require form';
|
||||
'require uci';
|
||||
'require ui';
|
||||
'require view';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
function allowInsecureConfirm(ev, _section_id, value) {
|
||||
if (value === '1' && !confirm(_('Are you sure to allow insecure?')))
|
||||
ev.target.firstElementChild.checked = null;
|
||||
}
|
||||
|
||||
function renderNodeSettings(section, data, features, main_node, routing_mode) {
|
||||
let s = section, o;
|
||||
s.rowcolors = true;
|
||||
s.sortable = true;
|
||||
s.nodescriptions = true;
|
||||
s.modaltitle = L.bind(hp.loadModalTitle, hp, _('Node'), _('Add a node'), 'homeproxy');
|
||||
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
|
||||
if (routing_mode !== 'custom') {
|
||||
o = s.option(form.Button, '_apply', _('Apply'));
|
||||
o.editable = true;
|
||||
o.modalonly = false;
|
||||
o.inputstyle = 'apply';
|
||||
o.inputtitle = function(section_id) {
|
||||
if (main_node == section_id) {
|
||||
this.readonly = true;
|
||||
return _('Applied');
|
||||
} else {
|
||||
this.readonly = false;
|
||||
return _('Apply');
|
||||
}
|
||||
}
|
||||
o.onclick = function(ev, section_id) {
|
||||
uci.set('homeproxy', 'config', 'main_node', section_id);
|
||||
|
||||
return this.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
o = s.option(form.Value, 'label', _('Label'));
|
||||
o.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'node', 'label');
|
||||
o.modalonly = true;
|
||||
|
||||
o = hp.renderProtocolOptions(s, { features: features, side: 'client' });
|
||||
|
||||
o = s.option(form.Value, 'address', _('Address'));
|
||||
o.datatype = 'host';
|
||||
o.depends({'type': 'direct', '!reverse': true});
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.option(form.Value, 'port', _('Port'));
|
||||
o.datatype = 'port';
|
||||
o.depends({'type': 'direct', '!reverse': true});
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.option(form.Value, 'username', _('Username'));
|
||||
o.depends('type', 'http');
|
||||
o.depends('type', 'socks');
|
||||
o.depends('type', 'ssh');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'password', _('Password'));
|
||||
o.password = true;
|
||||
o.depends('type', 'anytls');
|
||||
o.depends('type', 'http');
|
||||
o.depends('type', 'hysteria2');
|
||||
o.depends('type', 'shadowsocks');
|
||||
o.depends('type', 'snell');
|
||||
o.depends('type', 'ssh');
|
||||
o.depends('type', 'trojan');
|
||||
o.depends('type', 'tuic');
|
||||
o.depends({'type': 'shadowtls', 'shadowtls_version': '2'});
|
||||
o.depends({'type': 'shadowtls', 'shadowtls_version': '3'});
|
||||
o.depends({'type': 'socks', 'socks_version': '5'});
|
||||
o.validate = hp.validatePassword([ 'anytls', 'shadowsocks', 'shadowtls', 'snell', 'trojan' ]);
|
||||
o.modalonly = true;
|
||||
|
||||
/* Direct config */
|
||||
o = s.option(form.ListValue, 'proxy_protocol', _('Proxy protocol'),
|
||||
_('Write proxy protocol in the connection header.'));
|
||||
o.value('', _('Disable'));
|
||||
o.value('1', _('v1'));
|
||||
o.value('2', _('v2'));
|
||||
o.depends('type', 'direct');
|
||||
o.modalonly = true;
|
||||
|
||||
/* AnyTLS config start */
|
||||
o = s.option(form.Value, 'anytls_idle_session_check_interval', _('Idle session check interval'),
|
||||
_('Interval checking for idle sessions, in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '30';
|
||||
o.depends('type', 'anytls');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'anytls_idle_session_timeout', _('Idle session check timeout'),
|
||||
_('In the check, close sessions that have been idle for longer than this, in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '30';
|
||||
o.depends('type', 'anytls');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'anytls_min_idle_session', _('Minimum idle sessions'),
|
||||
_('In the check, at least the first <code>n</code> idle sessions are kept open.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '0';
|
||||
o.depends('type', 'anytls');
|
||||
o.modalonly = true;
|
||||
/* AnyTLS config end */
|
||||
|
||||
/* Hysteria (2) config start */
|
||||
o = s.option(form.DynamicList, 'hysteria_hopping_port', _('Hopping port'));
|
||||
o.depends('type', 'hysteria');
|
||||
o.depends('type', 'hysteria2');
|
||||
o.validate = hp.validatePortRange;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'hysteria_hop_interval', _('Hop interval'),
|
||||
_('Port hopping interval in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '30';
|
||||
o.depends({'type': 'hysteria', 'hysteria_hopping_port': /[\s\S]/});
|
||||
o.depends({'type': 'hysteria2', 'hysteria_hopping_port': /[\s\S]/});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'hysteria_hop_interval_max', _('Max hop interval (1.14)'),
|
||||
_('Maximum port hopping interval in seconds; the actual interval is randomized between the two values. Hysteria2 only.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '300';
|
||||
o.depends({'type': 'hysteria2', 'hysteria_hopping_port': /[\s\S]/});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol'));
|
||||
o.value('udp');
|
||||
/* WeChat-Video / FakeTCP are unsupported by sing-box currently
|
||||
* o.value('wechat-video');
|
||||
* o.value('faketcp');
|
||||
*/
|
||||
o.default = 'udp';
|
||||
o.depends('type', 'hysteria');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
/* Shared with the server form: the auth/obfs cluster and the bandwidth
|
||||
caps. They are separate calls because the two forms place them at
|
||||
different points, which is why the order is preserved rather than
|
||||
normalised. */
|
||||
hp.renderHysteriaAuthObfs(s, {});
|
||||
hp.renderHysteriaBandwidth(s);
|
||||
|
||||
o = s.option(form.ListValue, 'hysteria_bbr_profile', _('BBR profile (1.14)'),
|
||||
_('BBR congestion control algorithm profile. Hysteria2 only.'));
|
||||
o.value('', _('Standard (default)'));
|
||||
o.value('conservative', _('Conservative'));
|
||||
o.value('aggressive', _('Aggressive'));
|
||||
o.depends('type', 'hysteria2');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'hysteria_disable_chrome_parrot', _('Disable Chrome QUIC fingerprint (1.14)'),
|
||||
_('Disable Chrome QUIC handshake parroting, which is enabled by default since sing-box 1.14. Turn this on only when the server uses an Ed25519 certificate or the handshake otherwise fails.'));
|
||||
o.depends('type', 'hysteria2');
|
||||
o.modalonly = true;
|
||||
/* Hysteria (2) config end */
|
||||
|
||||
/* Shadowsocks config start */
|
||||
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
|
||||
for (let i of hp.shadowsocks_encrypt_methods)
|
||||
o.value(i);
|
||||
for (let i of hp.shadowsocks_stream_methods)
|
||||
o.value(i);
|
||||
o.default = 'aes-128-gcm';
|
||||
o.depends('type', 'shadowsocks');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'shadowsocks_plugin', _('Plugin'));
|
||||
o.value('', _('none'));
|
||||
o.value('obfs-local');
|
||||
o.value('v2ray-plugin');
|
||||
o.depends('type', 'shadowsocks');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'shadowsocks_plugin_opts', _('Plugin opts'));
|
||||
o.depends('shadowsocks_plugin', 'obfs-local');
|
||||
o.depends('shadowsocks_plugin', 'v2ray-plugin');
|
||||
o.modalonly = true;
|
||||
/* Shadowsocks config end */
|
||||
|
||||
/* ShadowTLS config */
|
||||
o = s.option(form.ListValue, 'shadowtls_version', _('ShadowTLS version'));
|
||||
o.value('1', _('v1'));
|
||||
o.value('2', _('v2'));
|
||||
o.value('3', _('v3'));
|
||||
o.default = '1';
|
||||
o.depends('type', 'shadowtls');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
/* Socks config */
|
||||
o = s.option(form.ListValue, 'socks_version', _('Socks version'));
|
||||
o.value('4', _('Socks4'));
|
||||
o.value('4a', _('Socks4A'));
|
||||
o.value('5', _('Socks5'));
|
||||
o.default = '5';
|
||||
o.depends('type', 'socks');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
/* SSH config start */
|
||||
o = s.option(form.Value, 'ssh_client_version', _('Client version'),
|
||||
_('Random version will be used if empty.'));
|
||||
o.depends('type', 'ssh');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'ssh_host_key', _('Host key'),
|
||||
_('Accept any if empty.'));
|
||||
o.depends('type', 'ssh');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'ssh_host_key_algo', _('Host key algorithms'))
|
||||
o.depends('type', 'ssh');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'ssh_priv_key', _('Private key'));
|
||||
o.password = true;
|
||||
o.depends('type', 'ssh');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'ssh_priv_key_pp', _('Private key passphrase'));
|
||||
o.password = true;
|
||||
o.depends('type', 'ssh');
|
||||
o.modalonly = true;
|
||||
/* SSH config end */
|
||||
|
||||
/* TUIC config start */
|
||||
hp.renderTuicOptions(s, { side: 'client' });
|
||||
/* Tuic config end */
|
||||
|
||||
/* VMess / VLESS config start */
|
||||
o = s.option(form.ListValue, 'vless_flow', _('Flow'));
|
||||
o.value('', _('None'));
|
||||
o.value('xtls-rprx-vision');
|
||||
o.depends('type', 'vless');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'vmess_alterid', _('Alter ID'),
|
||||
_('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('type', 'vmess');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'vmess_encrypt', _('Encrypt method'));
|
||||
o.value('auto');
|
||||
o.value('none');
|
||||
o.value('zero');
|
||||
o.value('aes-128-gcm');
|
||||
o.value('chacha20-poly1305');
|
||||
o.default = 'auto';
|
||||
o.depends('type', 'vmess');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'vmess_global_padding', _('Global padding'),
|
||||
_('Protocol parameter. Will waste traffic randomly if enabled (enabled by default in v2ray and cannot be disabled).'));
|
||||
o.default = o.enabled;
|
||||
o.depends('type', 'vmess');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'vmess_authenticated_length', _('Authenticated length'),
|
||||
_('Protocol parameter. Enable length block encryption.'));
|
||||
o.depends('type', 'vmess');
|
||||
o.modalonly = true;
|
||||
/* VMess config end */
|
||||
|
||||
/* Transport config start */
|
||||
hp.renderTransportOptions(s, { features: features, side: 'client' });
|
||||
/* Transport config end */
|
||||
|
||||
/* Wireguard config start */
|
||||
o = s.option(form.DynamicList, 'wireguard_local_address', _('Local address'),
|
||||
_('List of IP (v4 or v6) addresses prefixes to be assigned to the interface.'));
|
||||
o.datatype = 'cidr';
|
||||
o.depends('type', 'wireguard');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'wireguard_private_key', _('Private key'),
|
||||
_('WireGuard requires base64-encoded private keys.'));
|
||||
o.password = true;
|
||||
o.depends('type', 'wireguard');
|
||||
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'wireguard_peer_public_key', _('Peer pubkic key'),
|
||||
_('WireGuard peer public key.'));
|
||||
o.depends('type', 'wireguard');
|
||||
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'wireguard_pre_shared_key', _('Pre-shared key'),
|
||||
_('WireGuard pre-shared key.'));
|
||||
o.password = true;
|
||||
o.depends('type', 'wireguard');
|
||||
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'wireguard_reserved', _('Reserved field bytes'));
|
||||
o.datatype = 'integer';
|
||||
o.depends('type', 'wireguard');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'wireguard_mtu', _('MTU'));
|
||||
o.datatype = 'range(0,9000)';
|
||||
o.placeholder = '1408';
|
||||
o.depends('type', 'wireguard');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'wireguard_persistent_keepalive_interval', _('Persistent keepalive interval'),
|
||||
_('In seconds. Disabled by default.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('type', 'wireguard');
|
||||
o.modalonly = true;
|
||||
/* Wireguard config end */
|
||||
|
||||
/* Mux config start */
|
||||
/* The flag, padding and the TCP Brutal group are shared with the server
|
||||
form; only the dialling knobs below are client-specific. */
|
||||
hp.renderMuxOptions(s, { features: features });
|
||||
|
||||
o = s.option(form.ListValue, 'multiplex_protocol', _('Protocol'),
|
||||
_('Multiplex protocol.'));
|
||||
o.value('h2mux');
|
||||
o.value('smux');
|
||||
o.value('yamux');
|
||||
o.default = 'h2mux';
|
||||
o.depends('multiplex', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'multiplex_max_connections', _('Maximum connections'));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('multiplex', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'multiplex_min_streams', _('Minimum streams'),
|
||||
_('Minimum multiplexed streams in a connection before opening a new connection.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('multiplex', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'multiplex_max_streams', _('Maximum streams'),
|
||||
_('Maximum multiplexed streams in a connection before opening a new connection.<br/>' +
|
||||
'Conflict with <code>%s</code> and <code>%s</code>.').format(
|
||||
_('Maximum connections'), _('Minimum streams')));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends({'multiplex': '1', 'multiplex_max_connections': '', 'multiplex_min_streams': ''});
|
||||
o.modalonly = true;
|
||||
|
||||
/* Mux config end */
|
||||
|
||||
/* Snell config start */
|
||||
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
|
||||
_('sing-box implements Snell v4/v5 wire as v4 and v6. The pre-shared key (Password above) must be 12-255 bytes for v6.'));
|
||||
/* {4, 6} is the OUTBOUND's valid set, not a subset someone forgot to
|
||||
finish: sing-box 1.14 rejects version 5 on a snell outbound
|
||||
("unsupported version: 5") and accepts 4 and 6. The server form offers
|
||||
{5, 6} because its INBOUND is the mirror image (version 4 is rejected
|
||||
there). Measured against the target sing-box; see the plan's 2.11. */
|
||||
o.value('4', _('v4'));
|
||||
o.value('6', _('v6'));
|
||||
o.default = '4';
|
||||
o.depends('type', 'snell');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'snell_userkey', _('User key'),
|
||||
_('Optional; only required when connecting to a multi-user Snell server.'));
|
||||
o.depends('type', 'snell');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'snell_reuse', _('Connection reuse'),
|
||||
_('Enable connection reuse (the Snell v2 CONNECT command).'));
|
||||
o.depends('type', 'snell');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'snell_obfs_mode', _('Obfuscation mode'),
|
||||
_('HTTP obfuscation. v4 only.'));
|
||||
o.value('', _('none'));
|
||||
o.value('http', _('http'));
|
||||
o.depends({'type': 'snell', 'snell_version': '4'});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'snell_obfs_host', _('Obfuscation host'),
|
||||
_('HTTP Host header sent when obfuscation mode is http. bing.com is used by default.'));
|
||||
o.depends({'type': 'snell', 'snell_version': '4', 'snell_obfs_mode': 'http'});
|
||||
o.modalonly = true;
|
||||
/* Snell config end */
|
||||
|
||||
/* TLS config start */
|
||||
hp.renderTlsOptions(s, {
|
||||
side: 'client',
|
||||
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'shadowtls', 'trojan', 'tuic', 'vless', 'vmess' ],
|
||||
tls_forced_types: [ 'anytls', 'hysteria', 'hysteria2', 'shadowtls', 'tuic' ],
|
||||
oninsecurechange: allowInsecureConfirm
|
||||
});
|
||||
|
||||
o = s.option(form.Flag, 'tls_self_sign', _('Append self-signed certificate'),
|
||||
_('If you have the root certificate, use this option instead of allowing insecure.'));
|
||||
o.depends('tls_insecure', '0');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_cert_path', _('Certificate path'),
|
||||
_('The path to the server certificate, in PEM format.'));
|
||||
o.value('/etc/homeproxy/certs/client_ca.pem');
|
||||
o.depends('tls_self_sign', '1');
|
||||
o.validate = hp.validateCertificatePath;
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Button, '_upload_cert', _('Upload certificate'),
|
||||
_('<strong>Save your configuration before uploading files!</strong>'));
|
||||
o.inputstyle = 'action';
|
||||
o.inputtitle = _('Upload...');
|
||||
o.depends({'tls_self_sign': '1', 'tls_cert_path': '/etc/homeproxy/certs/client_ca.pem'});
|
||||
o.onclick = L.bind(hp.uploadCertificate, hp, _('certificate'), 'client_ca');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tls_ech', _('Enable ECH'),
|
||||
_('ECH (Encrypted Client Hello) is a TLS extension that allows a client to encrypt the first part of its ClientHello message.'));
|
||||
o.depends('tls', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_ech_config_path', _('ECH config path'),
|
||||
_('The path to the ECH config, in PEM format. If empty, load from DNS will be attempted.'));
|
||||
o.value('/etc/homeproxy/certs/client_ech_conf.pem');
|
||||
/* Same policy as cert_path / key_path: sing-box reads this file as root and
|
||||
the backend drops any path outside CERT_PATH_ROOTS (validateCertificatePath
|
||||
in homeproxy.uc). Without it the UI accepted a path the generator then
|
||||
silently discarded. */
|
||||
o.validate = hp.validateCertificatePath;
|
||||
o.depends('tls_ech', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Button, '_upload_ech_config', _('Upload ECH config'),
|
||||
_('<strong>Save your configuration before uploading files!</strong>'));
|
||||
o.inputstyle = 'action';
|
||||
o.inputtitle = _('Upload...');
|
||||
o.depends({'tls_ech': '1', 'tls_ech_config_path': '/etc/homeproxy/certs/client_ech_conf.pem'});
|
||||
o.onclick = L.bind(hp.uploadCertificate, hp, _('ECH config'), 'client_ech_conf');
|
||||
o.modalonly = true;
|
||||
|
||||
if (features.with_utls) {
|
||||
o = s.option(form.ListValue, 'tls_utls', _('uTLS fingerprint'),
|
||||
_('uTLS is a fork of "crypto/tls", which provides ClientHello fingerprinting resistance.'));
|
||||
o.value('', _('Disable'));
|
||||
o.value('360');
|
||||
o.value('android');
|
||||
o.value('chrome');
|
||||
o.value('edge');
|
||||
o.value('firefox');
|
||||
o.value('ios');
|
||||
o.value('qq');
|
||||
o.value('random');
|
||||
o.value('randomized');
|
||||
o.value('safari');
|
||||
o.depends({'tls': '1', 'type': /^((?!hysteria2?|tuic$).)+$/});
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id) {
|
||||
let tls_reality = this.map.findElement('id', 'cbid.homeproxy.%s.tls_reality'.format(section_id)).firstElementChild;
|
||||
if (tls_reality.checked && !value)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
|
||||
let vless_flow = this.map.lookupOption('vless_flow', section_id)[0].formvalue(section_id);
|
||||
if ((tls_reality.checked || vless_flow) && ['360', 'android'].includes(value))
|
||||
return _('Unsupported fingerprint!');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tls_reality', _('REALITY'));
|
||||
o.depends({'tls': '1', 'type': 'anytls'});
|
||||
o.depends({'tls': '1', 'type': 'vless'});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_reality_public_key', _('REALITY public key'));
|
||||
o.password = true;
|
||||
o.depends('tls_reality', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_reality_short_id', _('REALITY short ID'));
|
||||
o.password = true;
|
||||
o.depends('tls_reality', '1');
|
||||
o.modalonly = true;
|
||||
}
|
||||
/* TLS config end */
|
||||
|
||||
/* Extra settings start */
|
||||
o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open'));
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tcp_multi_path', _('MultiPath TCP'));
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'udp_fragment', _('UDP Fragment'),
|
||||
_('Enable UDP fragmentation.'));
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'udp_over_tcp', _('UDP over TCP'),
|
||||
_('Enable the SUoT protocol, requires server support. Conflict with multiplex.'));
|
||||
o.depends('type', 'socks');
|
||||
o.depends({'type': 'shadowsocks', 'multiplex': '0'});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'udp_over_tcp_version', _('SUoT version'));
|
||||
o.value('1', _('v1'));
|
||||
o.value('2', _('v2'));
|
||||
o.default = '2';
|
||||
o.depends('udp_over_tcp', '1');
|
||||
o.modalonly = true;
|
||||
/* Extra settings end */
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
uci.load('homeproxy'),
|
||||
hp.getBuiltinFeatures()
|
||||
]);
|
||||
},
|
||||
|
||||
render(data) {
|
||||
let m, s, o, ss, so;
|
||||
let main_node = uci.get('homeproxy', 'config', 'main_node');
|
||||
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||
let features = data[1];
|
||||
|
||||
/* Cache subscription information, it will be called multiple times.
|
||||
* hp.subscriptionInfo() owns the URL parsing, the malformed-escape
|
||||
* fallback and the escaping of the fragment, all three of which used
|
||||
* to be inline here: decodeURIComponent threw on a fragment like
|
||||
* '#100%' and took the whole page render down with it, and the decoded
|
||||
* fragment went unescaped into a tab title.
|
||||
*
|
||||
* Dedupe by hash: subscriptionInfo() drops the fragment before the
|
||||
* md5 so two entries whose URL only differs by their fragment (e.g.
|
||||
* a user-added entry and a fragment-labelled alias) used to push
|
||||
* the same `info.hash` twice and the second s.tab() call threw
|
||||
* "Tab already declared", taking the entire Node Settings render
|
||||
* down with it. The backend dedupes the same way (update_subscriptions.uc
|
||||
* also strips the fragment before computing the groupHash), so the
|
||||
* first-seen entry is the one both sides agree on. */
|
||||
let subinfo = [];
|
||||
const seen_hashes = {};
|
||||
for (let suburl of (uci.get('homeproxy', 'subscription', 'subscription_url') || [])) {
|
||||
const info = hp.subscriptionInfo(suburl);
|
||||
if (!info)
|
||||
continue;
|
||||
if (seen_hashes[info.hash])
|
||||
continue;
|
||||
seen_hashes[info.hash] = true;
|
||||
subinfo.push(info);
|
||||
}
|
||||
|
||||
m = new form.Map('homeproxy', _('Edit nodes'));
|
||||
|
||||
s = m.section(form.NamedSection, 'subscription', 'homeproxy');
|
||||
|
||||
/* Node settings start */
|
||||
/* User nodes start */
|
||||
s.tab('node', _('Nodes'));
|
||||
o = s.taboption('node', form.SectionValue, '_node', form.GridSection, 'node');
|
||||
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
|
||||
ss.addremove = true;
|
||||
ss.filter = function(section_id) {
|
||||
for (let info of subinfo)
|
||||
if (info.hash === uci.get('homeproxy', section_id, 'grouphash'))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
/* Import subscription links start */
|
||||
/* Thanks to luci-app-shadowsocks-libev */
|
||||
ss.handleLinkImport = function() {
|
||||
let textarea = new ui.Textarea();
|
||||
ui.showModal(_('Import share links'), [
|
||||
E('p', _('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.')),
|
||||
textarea.render(),
|
||||
E('div', { class: 'right' }, [
|
||||
E('button', {
|
||||
class: 'btn',
|
||||
click: ui.hideModal
|
||||
}, [ _('Cancel') ]),
|
||||
'',
|
||||
E('button', {
|
||||
class: 'btn cbi-button-action',
|
||||
click: ui.createHandlerFn(this, () => {
|
||||
let input_links = textarea.getValue().trim().split('\n');
|
||||
if (!input_links || !input_links[0])
|
||||
return ui.hideModal();
|
||||
|
||||
/* Remove duplicate lines */
|
||||
input_links = input_links.reduce((pre, cur) =>
|
||||
(!pre.includes(cur) && pre.push(cur), pre), []);
|
||||
|
||||
let allow_insecure = uci.get('homeproxy', 'subscription', 'allow_insecure');
|
||||
let packet_encoding = uci.get('homeproxy', 'subscription', 'packet_encoding');
|
||||
|
||||
/* Every link is parsed - and rejected - by the
|
||||
* backend parser (parser/uri.uc), so importing a
|
||||
* link and receiving it through a subscription
|
||||
* can no longer disagree about what it means. */
|
||||
return Promise.all(input_links.map((l) => hp.parseShareLink(l)))
|
||||
.then((configs) => {
|
||||
let imported_node = 0;
|
||||
configs.forEach((config) => {
|
||||
if (!config)
|
||||
return;
|
||||
|
||||
if (config.tls === '1' && allow_insecure === '1')
|
||||
config.tls_insecure = '1';
|
||||
if (['vless', 'vmess'].includes(config.type))
|
||||
config.packet_encoding = packet_encoding;
|
||||
|
||||
let nameHash = hp.calcStringMD5(config.label);
|
||||
let sid = uci.add('homeproxy', 'node', nameHash);
|
||||
Object.keys(config).forEach((k) => {
|
||||
uci.set('homeproxy', sid, k, config[k]);
|
||||
});
|
||||
imported_node++;
|
||||
});
|
||||
|
||||
if (imported_node === 0)
|
||||
ui.addNotification(null, E('p', _('No valid share link found.')));
|
||||
else
|
||||
ui.addNotification(null, E('p', _('Successfully imported %s nodes of total %s.').format(
|
||||
imported_node, input_links.length)));
|
||||
|
||||
return uci.save()
|
||||
.then(L.bind(this.map.load, this.map))
|
||||
.then(L.bind(this.map.reset, this.map))
|
||||
.then(ui.hideModal)
|
||||
.catch((e) => {
|
||||
/* The .catch above used to swallow
|
||||
* every error silently, so a failed
|
||||
* hideModal (L.ui is undefined; this
|
||||
* module imported ui, not L.ui) made
|
||||
* the modal look stuck after a
|
||||
* successful import. Surface it now:
|
||||
* the import itself ran, the modal
|
||||
* just did not close, and the user
|
||||
* deserves to know which. */
|
||||
ui.addNotification(null, E('p', e && e.message
|
||||
? _('Failed to close the import dialog: %s').format(e.message)
|
||||
: _('Imported the nodes, but the dialog did not close automatically. Close it manually.')));
|
||||
});
|
||||
});
|
||||
})
|
||||
}, [ _('Import') ])
|
||||
])
|
||||
])
|
||||
}
|
||||
/* The shared renderer (homeproxy.js) appends the extra button this
|
||||
* page needs; it used to carry a verbatim copy of the whole method.
|
||||
* The factory gets the section back so the handler binds to it, the
|
||||
* same receiver the copy's `this` had.
|
||||
*
|
||||
* Why a plain wrapper, not L.bind(hp.renderSectionAdd, this, ss, factory):
|
||||
* L.bind forwards its preset args in front of the caller's args, so
|
||||
* the bound function calls hp.renderSectionAdd.call(this, ss, factory,
|
||||
* extra_class) - the factory ends up in the extra_class slot and the
|
||||
* parent's classList.add() then coerces the function source to a
|
||||
* string and throws InvalidCharacterError the moment CBI invokes
|
||||
* renderSectionAdd('cbi-tblsection-create'). Passing them through
|
||||
* in their natural positions (section, extra_class, extra_button)
|
||||
* is the only order that lines up. */
|
||||
const importShareLinksFactory = (section) => E('button', {
|
||||
'class': 'cbi-button cbi-button-add',
|
||||
'title': _('Import share links'),
|
||||
'click': ui.createHandlerFn(section, 'handleLinkImport')
|
||||
}, [ _('Import share links') ]);
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class, importShareLinksFactory);
|
||||
};
|
||||
/* Import subscription links end */
|
||||
/* User nodes end */
|
||||
|
||||
/* Subscription nodes start */
|
||||
for (const info of subinfo) {
|
||||
s.tab('sub_' + info.hash, _('Sub (%s)').format(info.title));
|
||||
o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node');
|
||||
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
|
||||
ss.filter = function(section_id) {
|
||||
return (uci.get('homeproxy', section_id, 'grouphash') === info.hash);
|
||||
}
|
||||
}
|
||||
/* Subscription nodes end */
|
||||
/* Node settings end */
|
||||
|
||||
/* Subscriptions settings start */
|
||||
s.tab('subscription', _('Subscriptions'));
|
||||
|
||||
o = s.taboption('subscription', form.Flag, 'auto_update', _('Auto update'),
|
||||
_('Auto update subscriptions and geodata.'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('subscription', form.ListValue, 'auto_update_time', _('Update time'));
|
||||
for (let i = 0; i < 24; i++)
|
||||
o.value(i, i + ':00');
|
||||
o.default = '2';
|
||||
o.depends('auto_update', '1');
|
||||
|
||||
o = s.taboption('subscription', form.Flag, 'update_via_proxy', _('Update via proxy'),
|
||||
_('Update subscriptions via proxy.'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('subscription', form.DynamicList, 'subscription_url', _('Subscription URL-s'),
|
||||
_('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.'));
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
try {
|
||||
let url = new URL(value);
|
||||
if (!url.hostname)
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
catch(e) {
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
o = s.taboption('subscription', form.ListValue, 'filter_nodes', _('Filter nodes'),
|
||||
_('Drop/keep specific nodes from subscriptions.'));
|
||||
o.value('disabled', _('Disable'));
|
||||
o.value('blacklist', _('Blacklist mode'));
|
||||
o.value('whitelist', _('Whitelist mode'));
|
||||
o.default = 'disabled';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('subscription', form.DynamicList, 'filter_keywords', _('Filter keywords'),
|
||||
_('Drop/keep nodes that contain the specific keywords. <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions">Regex</a> is supported.'));
|
||||
o.depends({'filter_nodes': 'disabled', '!reverse': true});
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('subscription', form.Value, 'user_agent', _('User-Agent'));
|
||||
o.placeholder = 'Wget/1.21 (HomeProxy, like v2rayN)';
|
||||
|
||||
o = s.taboption('subscription', form.Flag, 'allow_insecure', _('Allow insecure'),
|
||||
_('Allow insecure connection by default when add nodes from subscriptions.') +
|
||||
'<br/>' +
|
||||
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!'));
|
||||
o.rmempty = false;
|
||||
o.onchange = allowInsecureConfirm;
|
||||
|
||||
o = s.taboption('subscription', form.ListValue, 'packet_encoding', _('Default packet encoding'));
|
||||
o.value('', _('none'));
|
||||
o.value('packetaddr', _('packet addr (v2ray-core v5+)'));
|
||||
o.value('xudp', _('Xudp (Xray-core)'));
|
||||
|
||||
o = s.taboption('subscription', form.Button, '_save_subscriptions', _('Save subscriptions settings'),
|
||||
_('NOTE: Save current settings before updating subscriptions.'));
|
||||
o.inputstyle = 'apply';
|
||||
o.inputtitle = _('Save current settings');
|
||||
o.onclick = function() {
|
||||
return this.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
}
|
||||
|
||||
o = s.taboption('subscription', form.Button, '_update_subscriptions', _('Update nodes from subscriptions'));
|
||||
o.inputstyle = 'apply';
|
||||
o.inputtitle = function(section_id) {
|
||||
let sublist = uci.get('homeproxy', section_id, 'subscription_url') || [];
|
||||
if (sublist.length > 0) {
|
||||
return _('Update %s subscriptions').format(sublist.length);
|
||||
} else {
|
||||
this.readonly = true;
|
||||
return _('No subscription available')
|
||||
}
|
||||
}
|
||||
o.onclick = function() {
|
||||
/* The updater pipeline (wget through a CN -> overseas link,
|
||||
UCI commit, /etc/init.d/homeproxy reload, the 30 s sing-box
|
||||
health gate) takes 5-15 s on a real device, which exceeds
|
||||
the browser XHR timeout. The old synchronous RPC waited
|
||||
for the whole thing inside one XHR; the browser saw
|
||||
"XHR request timed out" while the backend completed
|
||||
cleanly, and the user got a stale page with no signal.
|
||||
|
||||
The ubus method now spawns the script detached and
|
||||
returns immediately with {result:true, async:true}; we
|
||||
poll update_subscriptions_status (which reads the
|
||||
orchestrator's lock + the last log lines) until the run
|
||||
finishes, then reload. A failure is a status poll that
|
||||
stays in 'running' past the deadline, or a log tail that
|
||||
never recorded the success marker. */
|
||||
return hp.rpcCall('update_subscriptions').then((res) => {
|
||||
if (res && res.running)
|
||||
return ui.addNotification(null, E('p',
|
||||
_('Subscription update is already running. This page will reload when the previous run finishes.')));
|
||||
|
||||
if (res && res.result === false)
|
||||
return ui.addNotification(null, E('p', [
|
||||
_('Could not start subscription update: %s').format(res.error || _('unknown error'))
|
||||
]));
|
||||
|
||||
const notification = ui.addNotification(null,
|
||||
E('p', _('Updating subscriptions\u2026')));
|
||||
|
||||
/* The status page already drives LuCI's poll-status
|
||||
* indicator via hp.statusPoller(); client/server do
|
||||
* too. The node view polls on demand for a single
|
||||
* click, so it doesn't go through that path - mirror
|
||||
* the same "Refreshing\u2026" pill on the top right
|
||||
* via ui.showIndicator so the user sees the same
|
||||
* affordance on every tab. The hide is in every exit
|
||||
* branch below; without it the indicator would survive
|
||||
* until the next page load. */
|
||||
ui.showIndicator('hp-subscriptions',
|
||||
_('Updating subscriptions\u2026'));
|
||||
|
||||
const finish = (fn) => {
|
||||
ui.hideIndicator('hp-subscriptions');
|
||||
return fn();
|
||||
};
|
||||
|
||||
/* 90 s is a generous ceiling: even a slow fetch plus a
|
||||
worst-case 60 s rollback restart fits, and a stuck
|
||||
poll past that is worth telling the user about
|
||||
rather than reloading silently. */
|
||||
const deadline = Date.now() + 90000;
|
||||
|
||||
const poll = () => hp.rpcCall('update_subscriptions_status').then((s) => {
|
||||
if (!s || !s.running) {
|
||||
/* Detect a clean run by looking for the
|
||||
* success marker the orchestrator writes at
|
||||
* the very end ("Successfully updated
|
||||
* subscriptions"). A run that ends without
|
||||
* it - the orchestrator's try/catch still
|
||||
* removes the lock - is treated as a
|
||||
* failure so the user sees what went
|
||||
* wrong instead of reloading onto a
|
||||
* silently broken state. */
|
||||
const ok = s && s.log_tail &&
|
||||
/Successfully updated subscriptions/m.test(s.log_tail);
|
||||
return Promise.resolve(notification).then((n) => {
|
||||
if (n && typeof n.close === 'function')
|
||||
n.close();
|
||||
if (ok)
|
||||
return finish(() => location.reload());
|
||||
return finish(() => ui.addNotification(null, E('p', [
|
||||
_('Subscription update did not complete cleanly. Last log:'),
|
||||
E('pre', {}, [s && s.log_tail ? s.log_tail : _('(empty)')])
|
||||
])));
|
||||
});
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
return Promise.resolve(notification).then((n) => {
|
||||
if (n && typeof n.close === 'function')
|
||||
n.close();
|
||||
return finish(() => ui.addNotification(null, E('p',
|
||||
_('Subscription update is taking longer than 90 seconds. Check the log and reload manually.'))));
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, 1000)).then(poll);
|
||||
}).catch(() => {
|
||||
/* A transient RPC failure (ubusd hiccup, etc.)
|
||||
* is not the run failing - keep polling. */
|
||||
return new Promise((resolve) => setTimeout(resolve, 1000)).then(poll);
|
||||
});
|
||||
|
||||
return poll();
|
||||
});
|
||||
}
|
||||
|
||||
o = s.taboption('subscription', form.Button, '_remove_subscriptions', _('Remove all nodes from subscriptions'));
|
||||
o.inputstyle = 'reset';
|
||||
o.inputtitle = function() {
|
||||
let subnodes = [];
|
||||
uci.sections('homeproxy', 'node', (res) => {
|
||||
if (res.grouphash)
|
||||
subnodes = subnodes.concat(res['.name'])
|
||||
});
|
||||
|
||||
if (subnodes.length > 0) {
|
||||
return _('Remove %s nodes').format(subnodes.length);
|
||||
} else {
|
||||
this.readonly = true;
|
||||
return _('No subscription node');
|
||||
}
|
||||
}
|
||||
o.onclick = function() {
|
||||
let subnodes = [];
|
||||
uci.sections('homeproxy', 'node', (res) => {
|
||||
if (res.grouphash)
|
||||
subnodes = subnodes.concat(res['.name'])
|
||||
});
|
||||
|
||||
for (let i in subnodes)
|
||||
uci.remove('homeproxy', subnodes[i]);
|
||||
|
||||
if (subnodes.includes(uci.get('homeproxy', 'config', 'main_node')))
|
||||
uci.set('homeproxy', 'config', 'main_node', 'nil');
|
||||
|
||||
if (subnodes.includes(uci.get('homeproxy', 'config', 'main_udp_node')))
|
||||
uci.set('homeproxy', 'config', 'main_udp_node', 'nil');
|
||||
|
||||
this.inputtitle = _('%s nodes removed').format(subnodes.length);
|
||||
this.readonly = true;
|
||||
|
||||
return this.map.save(null, true);
|
||||
}
|
||||
/* Subscriptions settings end */
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require form';
|
||||
'require poll';
|
||||
'require uci';
|
||||
'require ui';
|
||||
'require view';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
/* Module-scoped guard for the view-level status poll: see the comment
|
||||
* next to the registration site in render(). Without it, the section's
|
||||
* render() leaks a fresh poll handler every time LuCI re-renders on
|
||||
* a UCI commit, and the bar updates multiple times per tick. */
|
||||
/* Built on the first render, so the poll closure captures that render's
|
||||
* features.version. The "have we registered" state lives inside
|
||||
* hp.statusPoller(), shared with the other view. */
|
||||
let ensureStatusPoll = null;
|
||||
|
||||
const CBIGenValue = form.Value.extend({
|
||||
__name__: 'CBI.GenValue',
|
||||
|
||||
renderWidget(/* ... */) {
|
||||
let node = form.Value.prototype.renderWidget.apply(this, arguments);
|
||||
|
||||
if (!this.password)
|
||||
node.classList.add('control-group');
|
||||
|
||||
(node.querySelector('.control-group') || node).appendChild(E('button', {
|
||||
class: 'cbi-button cbi-button-add',
|
||||
title: _('Generate'),
|
||||
click: ui.createHandlerFn(this, handleGenKey, this.hp_options || this.option)
|
||||
}, [ _('Generate') ]));
|
||||
|
||||
return node;
|
||||
}
|
||||
});
|
||||
|
||||
function getServiceStatus() {
|
||||
/* The fallback is `null`, not `{}`: that is what lets the caller tell
|
||||
* "the status query did not answer" from "the service is not running".
|
||||
* With the default `{}` the try/catch below swallowed the difference and
|
||||
* reported a failed query as NOT RUNNING. */
|
||||
return hp.rpcCall('list', ['homeproxy'],
|
||||
{ object: 'service', params: ['name'], expect: { '': {} }, fallback: null }).then((res) => {
|
||||
if (res === null)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return res['homeproxy']['instances']['sing-box-s']['running'] === true;
|
||||
} catch (e) {
|
||||
/* It answered, and there is no such instance. */
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus(isRunning, version) {
|
||||
/* Same allow-list gate as client.js renderStatus(): the sing-box
|
||||
* version string is interpolated into an innerHTML sink, so we
|
||||
* force it through a strict regex before formatting. Anything that
|
||||
* does not look like a version (alnum + . - +) becomes 'unknown'. */
|
||||
let safeVersion = 'unknown';
|
||||
if (typeof version === 'string' && /^[\w.\-+]+$/.test(version))
|
||||
safeVersion = version;
|
||||
|
||||
const state = hp.statusLabel(isRunning);
|
||||
return '<em><span style="color:%s"><strong>%s (sing-box v%s) %s</strong></span></em>'
|
||||
.format(state.color, _('HomeProxy Server'), safeVersion, state.text);
|
||||
}
|
||||
|
||||
function handleGenKey(option) {
|
||||
let section_id = this.section.section;
|
||||
let type = this.section.getOption('type')?.formvalue(section_id);
|
||||
let widget = L.bind((option) => {
|
||||
return this.map.findElement('id', 'widget.' + this.cbid(section_id).replace(/\.[^\.]+$/, '.') + option);
|
||||
}, this);
|
||||
|
||||
if (typeof option === 'object') {
|
||||
return hp.rpcCall('singbox_generator', [option.type, option.params],
|
||||
{ params: ['type', 'params'], expect: { '': {} } }).then((res) => {
|
||||
if (res.result)
|
||||
option.callback.call(this, res.result).forEach(([k, v]) => {
|
||||
widget(k).value = v ?? '';
|
||||
});
|
||||
else
|
||||
ui.addNotification(null, E('p', _('Failed to generate %s, error: %s.').format(type, res.error)));
|
||||
});
|
||||
} else {
|
||||
let password, required_method;
|
||||
|
||||
if (option === 'uuid')
|
||||
required_method = 'uuid';
|
||||
else if (type === 'shadowsocks')
|
||||
required_method = this.section.getOption('shadowsocks_encrypt_method')?.formvalue(section_id);
|
||||
|
||||
switch (required_method) {
|
||||
case 'none':
|
||||
password = '';
|
||||
break;
|
||||
case 'uuid':
|
||||
password = hp.generateRand('uuid');
|
||||
break;
|
||||
default:
|
||||
password = hp.generateRand('hex', 16);
|
||||
break;
|
||||
}
|
||||
/* AEAD */
|
||||
((length) => {
|
||||
if (length && length > 0)
|
||||
password = hp.generateRand('base64', length);
|
||||
})(hp.shadowsocks_encrypt_length[required_method]);
|
||||
|
||||
return widget(option).value = password;
|
||||
}
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
uci.load('homeproxy'),
|
||||
hp.getBuiltinFeatures()
|
||||
]);
|
||||
},
|
||||
|
||||
render(data) {
|
||||
let m, s, o;
|
||||
let features = data[1];
|
||||
|
||||
m = new form.Map('homeproxy', _('HomeProxy Server'),
|
||||
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
|
||||
|
||||
s = m.section(form.TypedSection);
|
||||
s.render = function() {
|
||||
/* The status bar is rendered into the page body by the
|
||||
* view-level poll handler below. Registering poll.add()
|
||||
* here would leak a fresh handler every time the user calls
|
||||
* map.reset() (every UCI write re-renders this section). */
|
||||
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
|
||||
E('p', { id: 'service_status' }, _('Collecting data...'))
|
||||
]);
|
||||
}
|
||||
|
||||
/* View-level poll: registered exactly once per navigation. The
|
||||
* getElementById lookup may return null when LuCI swaps the DOM
|
||||
* between renders, so guard against it. */
|
||||
if (ensureStatusPoll === null)
|
||||
ensureStatusPoll = hp.statusPoller({
|
||||
poll: poll,
|
||||
read: getServiceStatus,
|
||||
paint: function(res) {
|
||||
let view = document.getElementById('service_status');
|
||||
if (view)
|
||||
view.innerHTML = renderStatus(res, features.version);
|
||||
}
|
||||
});
|
||||
|
||||
ensureStatusPoll();
|
||||
|
||||
s = m.section(form.NamedSection, 'server', 'homeproxy', _('Global settings'));
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enable'));
|
||||
o.rmempty = false;
|
||||
|
||||
s = m.section(form.GridSection, 'server', _('Server settings'));
|
||||
s.addremove = true;
|
||||
s.rowcolors = true;
|
||||
s.sortable = true;
|
||||
s.nodescriptions = true;
|
||||
s.modaltitle = L.bind(hp.loadModalTitle, hp, _('Server'), _('Add a server'), 'homeproxy');
|
||||
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the
|
||||
* bound form hands a call site's button factory to the wrong argument. */
|
||||
s.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(s, extra_class);
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'label', _('Label'));
|
||||
o.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'server', 'label');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enable'));
|
||||
o.default = o.enabled;
|
||||
o.rmempty = false;
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Flag, 'firewall', _('Firewall'),
|
||||
_('Allow access from the Internet.'));
|
||||
o.editable = true;
|
||||
|
||||
o = hp.renderProtocolOptions(s, { features: features, side: 'server' });
|
||||
|
||||
o = s.option(form.Value, 'address', _('Listen address'));
|
||||
o.placeholder = '::';
|
||||
o.datatype = 'ipaddr';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'port', _('Listen port'),
|
||||
_('The port must be unique.'));
|
||||
o.datatype = 'port';
|
||||
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'server', 'port');
|
||||
|
||||
o = s.option(form.Value, 'username', _('Username'));
|
||||
o.depends('type', 'http');
|
||||
o.depends('type', 'mixed');
|
||||
o.depends('type', 'naive');
|
||||
o.depends('type', 'socks');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(CBIGenValue, 'password', _('Password'));
|
||||
o.password = true;
|
||||
o.depends('type', 'anytls');
|
||||
o.depends({'type': /^(http|mixed|naive|socks)$/, 'username': /[\s\S]/});
|
||||
o.depends('type', 'hysteria2');
|
||||
o.depends('type', 'shadowsocks');
|
||||
o.depends('type', 'snell');
|
||||
o.depends('type', 'trojan');
|
||||
o.depends('type', 'tuic');
|
||||
o.validate = hp.validatePassword([ 'anytls', 'http', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan' ]);
|
||||
o.modalonly = true;
|
||||
|
||||
/* AnyTLS config */
|
||||
o = s.option(form.DynamicList, 'anytls_padding_scheme', _('Padding scheme'),
|
||||
_('AnyTLS padding scheme in array.'));
|
||||
o.depends('type', 'anytls');
|
||||
o.modalonly = true;
|
||||
|
||||
/* Hysteria (2) config start */
|
||||
o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol'));
|
||||
o.value('udp');
|
||||
/* WeChat-Video / FakeTCP are unsupported by sing-box currently
|
||||
o.value('wechat-video');
|
||||
o.value('faketcp');
|
||||
*/
|
||||
o.default = 'udp';
|
||||
o.depends('type', 'hysteria');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
hp.renderHysteriaBandwidth(s);
|
||||
hp.renderHysteriaAuthObfs(s, { obfsPasswordWidget: CBIGenValue });
|
||||
|
||||
o = s.option(form.Flag, 'hysteria_ignore_client_bandwidth', _('Ignore client bandwidth'),
|
||||
_('Tell the client to use the BBR flow control algorithm instead of Hysteria CC.'));
|
||||
o.depends({'type': 'hysteria2', 'hysteria_down_mbps': '', 'hysteria_up_mbps': ''});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'hysteria_masquerade', _('Masquerade'),
|
||||
_('HTTP3 server behavior when authentication fails.<br/>A 404 page will be returned if empty.'));
|
||||
o.depends('type', 'hysteria2');
|
||||
o.modalonly = true;
|
||||
/* Hysteria (2) config end */
|
||||
|
||||
/* Snell config start */
|
||||
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
|
||||
_('The pre-shared key (password) above must be 12-255 bytes for v6.'));
|
||||
/* {5, 6} is the INBOUND's valid set: sing-box 1.14 rejects version 4
|
||||
on a snell inbound and accepts 5 and 6 (6 needs a >= 12 byte psk).
|
||||
The node form offers {4, 6} for the same reason, mirrored. Do not
|
||||
"unify" these two lists - see the plan's 2.11. */
|
||||
o.value('5', _('v5'));
|
||||
o.value('6', _('v6'));
|
||||
o.default = '5';
|
||||
o.depends('type', 'snell');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'snell_obfs_mode', _('Obfuscation mode'),
|
||||
_('HTTP obfuscation. v5 only.'));
|
||||
o.value('', _('none'));
|
||||
o.value('http', _('http'));
|
||||
o.depends({'type': 'snell', 'snell_version': '5'});
|
||||
o.modalonly = true;
|
||||
/* Snell config end */
|
||||
|
||||
/* Shadowsocks config */
|
||||
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
|
||||
for (let i of hp.shadowsocks_encrypt_methods)
|
||||
o.value(i);
|
||||
/* The legacy stream ciphers are client-only; see homeproxy.js's
|
||||
shadowsocks_stream_methods for why, and the plan's 2.11. */
|
||||
o.default = 'aes-128-gcm';
|
||||
o.depends('type', 'shadowsocks');
|
||||
o.modalonly = true;
|
||||
|
||||
/* Tuic config start */
|
||||
hp.renderTuicOptions(s, { side: 'server', uuidWidget: CBIGenValue });
|
||||
/* Tuic config end */
|
||||
|
||||
/* VLESS / VMess config start */
|
||||
o = s.option(form.ListValue, 'vless_flow', _('Flow'));
|
||||
o.value('', _('None'));
|
||||
o.value('xtls-rprx-vision');
|
||||
o.depends('type', 'vless');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'vmess_alterid', _('Alter ID'),
|
||||
_('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('type', 'vmess');
|
||||
o.modalonly = true;
|
||||
/* VMess config end */
|
||||
|
||||
/* Transport config start */
|
||||
hp.renderTransportOptions(s, { features: features, side: 'server' });
|
||||
/* Transport config end */
|
||||
|
||||
/* Mux config start */
|
||||
hp.renderMuxOptions(s, { features: features });
|
||||
/* Mux config end */
|
||||
|
||||
/* TLS config start */
|
||||
hp.renderTlsOptions(s, {
|
||||
side: 'server',
|
||||
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'naive', 'trojan', 'tuic', 'vless', 'vmess' ],
|
||||
tls_forced_types: [ 'hysteria', 'hysteria2', 'tuic' ]
|
||||
});
|
||||
|
||||
if (features.with_acme) {
|
||||
o = s.option(form.Flag, 'tls_acme', _('Enable ACME'),
|
||||
_('Use ACME TLS certificate issuer.'));
|
||||
o.depends('tls', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'tls_acme_domain', _('Domains'));
|
||||
o.datatype = 'hostname';
|
||||
o.depends('tls_acme', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_dsn', _('Default server name'),
|
||||
_('Server name to use when choosing a certificate if the ClientHello\'s ServerName field is empty.'));
|
||||
o.depends('tls_acme', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_email', _('Email'),
|
||||
_('The email address to use when creating or selecting an existing ACME server account.'));
|
||||
o.depends('tls_acme', '1');
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id) {
|
||||
if (!value)
|
||||
return _('Expecting: %s').format('non-empty value');
|
||||
else if (!value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/))
|
||||
return _('Expecting: %s').format('valid email address');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_key_type', _('ACME key type'),
|
||||
_('Private key type for new certificates (1.14). Empty uses the default.'));
|
||||
o.value('ed25519');
|
||||
o.value('p256');
|
||||
o.value('p384');
|
||||
o.value('rsa2048');
|
||||
o.value('rsa4096');
|
||||
o.depends('tls_acme', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_profile', _('ACME profile'),
|
||||
_('ACME profile for certificate issuance (1.14). Example: shortlived.'));
|
||||
o.placeholder = 'shortlived';
|
||||
o.depends('tls_acme', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.TextValue, 'tls_acme_account_key', _('ACME account key'),
|
||||
_('PEM-encoded private key of an existing ACME account (1.14).'));
|
||||
o.monospace = true;
|
||||
o.rows = 3;
|
||||
o.depends('tls_acme', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_provider', _('CA provider'),
|
||||
_('The ACME CA provider to use.'));
|
||||
o.value('letsencrypt', _('Let\'s Encrypt'));
|
||||
o.value('zerossl', _('ZeroSSL'));
|
||||
o.depends('tls_acme', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tls_dns01_challenge', _('DNS01 challenge'))
|
||||
o.depends('tls_acme', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'tls_dns01_provider', _('DNS provider'));
|
||||
o.value('alidns', _('Alibaba Cloud DNS'));
|
||||
o.value('cloudflare', _('Cloudflare'));
|
||||
o.depends('tls_dns01_challenge', '1');
|
||||
o.default = 'cloudflare';
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_dns01_ali_akid', _('Access key ID'));
|
||||
o.password = true;
|
||||
o.depends('tls_dns01_provider', 'alidns');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_dns01_ali_aksec', _('Access key secret'));
|
||||
o.password = true;
|
||||
o.depends('tls_dns01_provider', 'alidns');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_dns01_ali_rid', _('Region ID'));
|
||||
o.depends('tls_dns01_provider', 'alidns');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_dns01_cf_api_token', _('API token'));
|
||||
o.password = true;
|
||||
o.depends('tls_dns01_provider', 'cloudflare');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tls_acme_dhc', _('Disable HTTP challenge'));
|
||||
o.depends('tls_dns01_challenge', '0');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tls_acme_dtac', _('Disable TLS ALPN challenge'));
|
||||
o.depends('tls_dns01_challenge', '0');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_ahp', _('Alternative HTTP port'),
|
||||
_('The alternate port to use for the ACME HTTP challenge; if non-empty, this port will be used instead of 80 to spin up a listener for the HTTP challenge.'));
|
||||
o.datatype = 'port';
|
||||
o.depends('tls_dns01_challenge', '0');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_atp', _('Alternative TLS port'),
|
||||
_('The alternate port to use for the ACME TLS-ALPN challenge; the system must forward 443 to this port for challenge to succeed.'));
|
||||
o.datatype = 'port';
|
||||
o.depends('tls_dns01_challenge', '0');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tls_acme_external_account', _('External Account Binding'),
|
||||
_('EAB (External Account Binding) contains information necessary to bind or map an ACME account to some other account known by the CA.' +
|
||||
'<br/>External account bindings are "used to associate an ACME account with an existing account in a non-ACME system, such as a CA customer database.'));
|
||||
o.depends('tls_acme', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_ea_keyid', _('External account key ID'));
|
||||
o.password = true;
|
||||
o.depends('tls_acme_external_account', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_acme_ea_mackey', _('External account MAC key'));
|
||||
o.password = true;
|
||||
o.depends('tls_acme_external_account', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
}
|
||||
|
||||
o = s.option(form.Flag, 'tls_reality', _('REALITY'));
|
||||
o.depends({'tls': '1', 'tls_acme': '0', 'type': /^(anytls|vless)$/});
|
||||
o.depends({'tls': '1', 'tls_acme': null, 'type': /^(anytls|vless)$/});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(CBIGenValue, 'tls_reality_private_key', _('REALITY private key'));
|
||||
o.password = true;
|
||||
o.hp_options = {
|
||||
type: 'reality-keypair',
|
||||
params: '',
|
||||
callback: function(result) {
|
||||
return [
|
||||
[this.option, result.private_key],
|
||||
['tls_reality_public_key', result.public_key]
|
||||
]
|
||||
}
|
||||
}
|
||||
o.depends('tls_reality', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_reality_public_key', _('REALITY public key'));
|
||||
o.depends('tls_reality', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'tls_reality_short_id', _('REALITY short ID'));
|
||||
o.depends('tls_reality', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_reality_max_time_difference', _('Max time difference'),
|
||||
_('The maximum time difference between the server and the client.'));
|
||||
o.depends('tls_reality', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_reality_server_addr', _('Handshake server address'));
|
||||
o.datatype = 'hostname';
|
||||
o.depends('tls_reality', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_reality_server_port', _('Handshake server port'));
|
||||
o.datatype = 'port';
|
||||
o.depends('tls_reality', '1');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_cert_path', _('Certificate path'),
|
||||
_('The server public key, in PEM format.'));
|
||||
o.value('/etc/homeproxy/certs/server_publickey.pem');
|
||||
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': null});
|
||||
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': '0'});
|
||||
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': '0'});
|
||||
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': null});
|
||||
o.validate = hp.validateCertificatePath;
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Button, '_upload_cert', _('Upload certificate'),
|
||||
_('<strong>Save your configuration before uploading files!</strong>'));
|
||||
o.inputstyle = 'action';
|
||||
o.inputtitle = _('Upload...');
|
||||
o.depends({'tls': '1', 'tls_cert_path': '/etc/homeproxy/certs/server_publickey.pem'});
|
||||
o.onclick = L.bind(hp.uploadCertificate, hp, _('certificate'), 'server_publickey');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'tls_key_path', _('Key path'),
|
||||
_('The server private key, in PEM format.'));
|
||||
o.value('/etc/homeproxy/certs/server_privatekey.pem');
|
||||
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': '0'});
|
||||
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': null});
|
||||
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': '0'});
|
||||
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': null});
|
||||
o.validate = hp.validateCertificatePath;
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Button, '_upload_key', _('Upload key'),
|
||||
_('<strong>Save your configuration before uploading files!</strong>'));
|
||||
o.inputstyle = 'action';
|
||||
o.inputtitle = _('Upload...');
|
||||
o.depends({'tls': '1', 'tls_key_path': '/etc/homeproxy/certs/server_privatekey.pem'});
|
||||
o.onclick = L.bind(hp.uploadCertificate, hp, _('private key'), 'server_privatekey');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.TextValue, 'tls_ech_key', _('ECH key'));
|
||||
o.placeholder = '-----BEGIN ECH KEYS-----\nACBE2+piYBLrOywCbRYU+ZpEkk8keeBlUXbKqLRmQ/68FwBL/g0ARwAAIAAgn8HI\n93RfdV/LaDk+LC9H4h+4WhVBFmWKdhiT3vvpGi8ACAABAAEAAQADABRvdXRlci1z\nbmkuYW55LmRvbWFpbgAA\n-----END ECH KEYS-----';
|
||||
o.monospace = true;
|
||||
o.cols = 30
|
||||
o.rows = 3;
|
||||
o.hp_options = {
|
||||
type: 'ech-keypair',
|
||||
params: '',
|
||||
callback: function(result) {
|
||||
return [
|
||||
[this.option, result.ech_key],
|
||||
['tls_ech_config', result.ech_cfg]
|
||||
]
|
||||
}
|
||||
}
|
||||
o.renderWidget = function(section_id, option_index, cfgvalue) {
|
||||
let node = form.TextValue.prototype.renderWidget.apply(this, arguments);
|
||||
const cbid = this.cbid(section_id) + '._outer_sni';
|
||||
|
||||
node.appendChild(E('div', { 'class': 'control-group' }, [
|
||||
E('input', {
|
||||
id: cbid,
|
||||
class: 'cbi-input-text',
|
||||
style: 'width: 10em',
|
||||
placeholder: 'outer-sni.any.domain'
|
||||
}),
|
||||
E('button', {
|
||||
class: 'cbi-button cbi-button-add',
|
||||
click: ui.createHandlerFn(this, () => {
|
||||
this.hp_options.params = document.getElementById(cbid).value;
|
||||
|
||||
return handleGenKey.call(this, this.hp_options);
|
||||
})
|
||||
}, [ _('Generate') ])
|
||||
]));
|
||||
|
||||
return node;
|
||||
}
|
||||
o.depends('tls', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.TextValue, 'tls_ech_config', _('ECH config'));
|
||||
o.placeholder = '-----BEGIN ECH CONFIGS-----\nAEv+DQBHAAAgACCfwcj3dF91X8toOT4sL0fiH7haFUEWZYp2GJPe++kaLwAIAAEA\nAQABAAMAFG91dGVyLXNuaS5hbnkuZG9tYWluAAA=\n-----END ECH CONFIGS-----';
|
||||
o.monospace = true;
|
||||
o.cols = 30
|
||||
o.rows = 3;
|
||||
o.depends('tls', '1');
|
||||
o.modalonly = true;
|
||||
/* TLS config end */
|
||||
|
||||
/* Extra settings start */
|
||||
o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open'),
|
||||
_('Enable tcp fast open for listener.'));
|
||||
o.depends({'network': 'udp', '!reverse': true});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'tcp_multi_path', _('MultiPath TCP'));
|
||||
o.depends({'network': 'udp', '!reverse': true});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'udp_fragment', _('UDP Fragment'),
|
||||
_('Enable UDP fragmentation.'));
|
||||
o.depends({'network': 'tcp', '!reverse': true});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'udp_timeout', _('UDP NAT expiration time'),
|
||||
_('In seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '300';
|
||||
o.depends({'network': 'tcp', '!reverse': true});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'network', _('Network'));
|
||||
o.value('tcp', _('TCP'));
|
||||
o.value('udp', _('UDP'));
|
||||
o.value('', _('Both'));
|
||||
o.depends('type', 'naive');
|
||||
o.depends('type', 'shadowsocks');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
|
||||
_('The network interface to bind to.'));
|
||||
o.multiple = false;
|
||||
o.noaliases = true;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'reuse_addr', _('Reuse address'),
|
||||
_('Reuse listener address.'));
|
||||
o.modalonly = true;
|
||||
/* Extra settings end */
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require dom';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require poll';
|
||||
'require uci';
|
||||
'require ui';
|
||||
'require view';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
/* Thanks to luci-app-aria2 */
|
||||
const css = ' \
|
||||
#log_textarea { \
|
||||
padding: 10px; \
|
||||
text-align: left; \
|
||||
} \
|
||||
#log_textarea pre { \
|
||||
padding: .5rem; \
|
||||
word-break: break-all; \
|
||||
margin: 0; \
|
||||
}';
|
||||
|
||||
const hp_dir = '/var/run/homeproxy';
|
||||
|
||||
/* One view-level poll for all three log views.
|
||||
*
|
||||
* getRuntimeLog() is bound to an option's render() hook, and every map.reset()
|
||||
* - any UCI write, a log level change, saving the token, updating a resource -
|
||||
* re-renders each of those options. poll.add() in there therefore added a
|
||||
* fresh handler per re-render, so every tick read each log file once per
|
||||
* leftover handler, and the stale handlers painted the detached textarea of a
|
||||
* previous render.
|
||||
*
|
||||
* This reuses hp.statusPoller() (see client.js), the same once-per-view
|
||||
* registrar that fixes the status bar leak: its flag lives inside the returned
|
||||
* registrar, so one handler is added no matter how often render() runs. It is
|
||||
* called with all three files at once because a single tick reads them
|
||||
* together.
|
||||
*
|
||||
* `log_targets` always holds the textarea installed by the *current* render,
|
||||
* so the poll paints into the live DOM without an id lookup - the three
|
||||
* textareas share the `#log_textarea` id and cannot be told apart by id. */
|
||||
const log_targets = {};
|
||||
|
||||
function readRuntimeLog(filename) {
|
||||
return fs.read_direct(String.format('%s/%s.log', hp_dir, filename), 'text')
|
||||
.then((res) => E('pre', { 'wrap': 'pre' }, [
|
||||
res.trim() || _('Log is empty.')
|
||||
]))
|
||||
.catch((err) => {
|
||||
if (err.toString().includes('NotFoundError'))
|
||||
return E('pre', { 'wrap': 'pre' }, [ _('Log file does not exist.') ]);
|
||||
|
||||
return E('pre', { 'wrap': 'pre' }, [ _('Unknown error: %s').format(err) ]);
|
||||
});
|
||||
}
|
||||
|
||||
const ensureLogPoll = hp.statusPoller({
|
||||
poll: poll,
|
||||
read: () => Promise.all(Object.keys(log_targets).map((filename) =>
|
||||
readRuntimeLog(filename).then((node) => ({ filename: filename, node: node })))),
|
||||
paint: (entries) => {
|
||||
for (const entry of entries) {
|
||||
const target = log_targets[entry.filename];
|
||||
if (target)
|
||||
dom.content(target, entry.node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getConnStat(o, site) {
|
||||
o.default = E('div', { 'style': 'cbi-value-field' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return hp.rpcCall('connection_check', [site],
|
||||
{ params: ['site'], expect: { '': {} } }).then((ret) => {
|
||||
let ele = o.default.firstElementChild.nextElementSibling;
|
||||
if (ret.result) {
|
||||
ele.style.setProperty('color', 'green');
|
||||
ele.innerHTML = _('passed');
|
||||
} else {
|
||||
ele.style.setProperty('color', 'red');
|
||||
ele.innerHTML = _('failed');
|
||||
}
|
||||
});
|
||||
})
|
||||
}, [ _('Check') ]),
|
||||
' ',
|
||||
E('strong', { 'style': 'color:gray' }, _('unchecked')),
|
||||
]);
|
||||
}
|
||||
|
||||
function getResVersion(o, type) {
|
||||
return hp.rpcCall('resources_get_version', [type],
|
||||
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||
/* Review M7: the version string (the upstream commit date) and
|
||||
* `res.updated_at` (when *this router* last succeeded) are
|
||||
* distinct. Show both: the upstream date stays the same across
|
||||
* a successful re-run, so it cannot answer "did the cron job run
|
||||
* last night" - only `updated_at` does. */
|
||||
const updated_label = res.updated_at
|
||||
? _('(last updated %s)').format(res.updated_at)
|
||||
: _('(never updated on this device)');
|
||||
let spanTemp = E('div', { 'style': 'cbi-value-field' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return hp.rpcCall('resources_update', [type],
|
||||
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||
switch (res.status) {
|
||||
case 0:
|
||||
o.description = _('Successfully updated.');
|
||||
break;
|
||||
case 1:
|
||||
o.description = _('Update failed.');
|
||||
break;
|
||||
case 2:
|
||||
o.description = _('Already in updating.');
|
||||
break;
|
||||
case 3:
|
||||
o.description = _('Already at the latest version.');
|
||||
break;
|
||||
default:
|
||||
o.description = _('Unknown error.');
|
||||
break;
|
||||
}
|
||||
|
||||
return o.map.reset();
|
||||
});
|
||||
})
|
||||
}, [ _('Check update') ]),
|
||||
' ',
|
||||
E('strong', { 'style': (res.error ? 'color:red' : 'color:green') },
|
||||
[ res.error ? 'not found' : res.version ]
|
||||
),
|
||||
' ',
|
||||
E('small', { 'style': 'color:gray' }, [ updated_label ]),
|
||||
]);
|
||||
|
||||
o.default = spanTemp;
|
||||
});
|
||||
}
|
||||
|
||||
/* `filename` is the log's basename (and log_clean's type argument), passed in
|
||||
* by the caller. It used to be reverse-engineered from the option name with
|
||||
* `o.option.split('_')[1]`, which only works while every option is spelled
|
||||
* `_<filename>_logview` - rename one and the reader, the poll and the clean
|
||||
* button silently disagree about which file they mean.
|
||||
*
|
||||
* The parameter sits after `name`, before the framework's
|
||||
* (option_index, section_id, in_table): L.bind prepends the bound arguments,
|
||||
* so the call sites pass it and the framework keeps handing in the rest. */
|
||||
function getRuntimeLog(o, name, filename, _option_index, section_id, _in_table) {
|
||||
let section, log_level_el;
|
||||
switch (filename) {
|
||||
case 'homeproxy':
|
||||
section = null;
|
||||
break;
|
||||
case 'sing-box-c':
|
||||
section = 'config';
|
||||
break;
|
||||
case 'sing-box-s':
|
||||
section = 'server';
|
||||
break;
|
||||
}
|
||||
|
||||
if (section) {
|
||||
const selected = uci.get('homeproxy', section, 'log_level') || 'warn';
|
||||
const choices = {
|
||||
trace: _('Trace'),
|
||||
debug: _('Debug'),
|
||||
info: _('Info'),
|
||||
warn: _('Warn'),
|
||||
error: _('Error'),
|
||||
fatal: _('Fatal'),
|
||||
panic: _('Panic')
|
||||
};
|
||||
|
||||
log_level_el = E('select', {
|
||||
'id': o.cbid(section_id),
|
||||
'class': 'cbi-input-select',
|
||||
'style': 'margin-left: 4px; width: 6em;',
|
||||
'change': ui.createHandlerFn(this, (ev) => {
|
||||
uci.set('homeproxy', section, 'log_level', ev.target.value);
|
||||
return o.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
Object.keys(choices).forEach((v) => {
|
||||
log_level_el.appendChild(E('option', {
|
||||
'value': v,
|
||||
'selected': (v === selected) ? '' : null
|
||||
}, [ choices[v] ]));
|
||||
});
|
||||
}
|
||||
|
||||
const log_textarea = E('div', { 'id': 'log_textarea' },
|
||||
E('img', {
|
||||
'src': L.resource('icons/loading.svg'),
|
||||
'alt': _('Loading'),
|
||||
'style': 'vertical-align:middle'
|
||||
}, _('Collecting data...'))
|
||||
);
|
||||
|
||||
log_targets[filename] = log_textarea;
|
||||
ensureLogPoll();
|
||||
|
||||
return E([
|
||||
E('style', [ css ]),
|
||||
E('div', {'class': 'cbi-map'}, [
|
||||
E('h3', {'name': 'content', 'style': 'align-items: center; display: flex;'}, [
|
||||
_('%s log').format(name),
|
||||
log_level_el || '',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'style': 'margin-left: 4px;',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return hp.rpcCall('log_clean', [filename],
|
||||
{ params: ['type'], expect: { '': {} } });
|
||||
})
|
||||
}, [ _('Clean log') ])
|
||||
]),
|
||||
E('div', {'class': 'cbi-section'}, [
|
||||
log_textarea,
|
||||
E('div', {'style': 'text-align:right'},
|
||||
E('small', {}, _('Refresh every %s seconds.').format(L.env.pollinterval))
|
||||
)
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
render() {
|
||||
let m, s, o;
|
||||
|
||||
m = new form.Map('homeproxy');
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Connection check'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_check_baidu', _('BaiDu'));
|
||||
o.cfgvalue = L.bind(getConnStat, this, o, 'baidu');
|
||||
|
||||
o = s.option(form.DummyValue, '_check_google', _('Google'));
|
||||
o.cfgvalue = L.bind(getConnStat, this, o, 'google');
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_china_ip4_version', _('China IPv4 list version'));
|
||||
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip4');
|
||||
o.rawhtml = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_china_ip6_version', _('China IPv6 list version'));
|
||||
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip6');
|
||||
o.rawhtml = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_china_list_version', _('China list version'));
|
||||
o.cfgvalue = L.bind(getResVersion, this, o, 'china_list');
|
||||
o.rawhtml = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_gfw_list_version', _('GFW list version'));
|
||||
o.cfgvalue = L.bind(getResVersion, this, o, 'gfw_list');
|
||||
o.rawhtml = true;
|
||||
|
||||
o = s.option(form.Value, 'github_token', _('GitHub token'));
|
||||
o.password = true;
|
||||
o.renderWidget = function() {
|
||||
let node = form.Value.prototype.renderWidget.apply(this, arguments);
|
||||
|
||||
(node.querySelector('.control-group') || node).appendChild(E('button', {
|
||||
'class': 'cbi-button cbi-button-apply',
|
||||
'title': _('Save'),
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return this.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
})
|
||||
}, [ _('Save') ]));
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy');
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_homeproxy_logview');
|
||||
o.render = L.bind(getRuntimeLog, this, o, _('HomeProxy'), 'homeproxy');
|
||||
|
||||
o = s.option(form.DummyValue, '_sing-box-c_logview');
|
||||
o.render = L.bind(getRuntimeLog, this, o, _('sing-box client'), 'sing-box-c');
|
||||
|
||||
o = s.option(form.DummyValue, '_sing-box-s_logview');
|
||||
o.render = L.bind(getRuntimeLog, this, o, _('sing-box server'), 'sing-box-s');
|
||||
|
||||
return m.render();
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+1
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"bounding": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
],
|
||||
"effective": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
],
|
||||
"ambient": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
],
|
||||
"permitted": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
],
|
||||
"inheritable": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
|
||||
config homeproxy 'infra'
|
||||
option __warning 'DO NOT EDIT THIS SECTION, OR YOU ARE ON YOUR OWN!'
|
||||
option common_port '22,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418'
|
||||
option mixed_port '5330'
|
||||
option redirect_port '5331'
|
||||
option tproxy_port '5332'
|
||||
option dns_port '5333'
|
||||
option dns_redirect '1'
|
||||
option ntp_server 'nil'
|
||||
option udp_timeout ''
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option table_mark '100'
|
||||
option self_mark '100'
|
||||
option tproxy_mark '101'
|
||||
option tun_mark '102'
|
||||
|
||||
config homeproxy 'migration'
|
||||
option crontab '1'
|
||||
|
||||
config homeproxy 'config'
|
||||
option main_node 'nil'
|
||||
option main_udp_node 'same'
|
||||
option dns_server '8.8.8.8'
|
||||
option china_dns_server '223.5.5.5'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option routing_port 'common'
|
||||
option proxy_mode 'redirect_tproxy'
|
||||
option ipv6_support '1'
|
||||
option github_token ''
|
||||
option log_level 'warn'
|
||||
# Functional health probes (runtime/health.sh). They run after the
|
||||
# process/listener gate and only log by default: a probe can fail because
|
||||
# the uplink is down or a captive portal answers, and rolling a good
|
||||
# configuration back for that would be worse than the condition probed
|
||||
# for. Set health_probe_strict to 1 only if a failing probe must fail the
|
||||
# start (and therefore trigger the rollback).
|
||||
option health_probe_strict '0'
|
||||
option health_probe_name 'example.com'
|
||||
option cn_ip_fallback '1'
|
||||
option sniffer_advanced_mode '0'
|
||||
option tun_dns_mode ''
|
||||
option udp_mapping ''
|
||||
option udp_filtering ''
|
||||
option udp_nat_max ''
|
||||
|
||||
config homeproxy 'control'
|
||||
option lan_proxy_mode 'disabled'
|
||||
list wan_proxy_ipv4_ips '91.105.192.0/23'
|
||||
list wan_proxy_ipv4_ips '91.108.4.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.8.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.16.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.12.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.20.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.56.0/22'
|
||||
list wan_proxy_ipv4_ips '149.154.160.0/20'
|
||||
list wan_proxy_ipv4_ips '185.76.151.0/24'
|
||||
list wan_proxy_ipv4_ips '203.208.50.66/32'
|
||||
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23c::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23d::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23f::/48'
|
||||
list wan_proxy_ipv6_ips '2a0a:f280::/32'
|
||||
|
||||
config homeproxy 'routing'
|
||||
option default_outbound 'direct-out'
|
||||
option default_outbound_dns 'default-dns'
|
||||
option find_neighbor '0'
|
||||
|
||||
config homeproxy 'dns'
|
||||
option default_strategy 'prefer_ipv4'
|
||||
option default_server 'default-dns'
|
||||
option disable_cache '0'
|
||||
option disable_cache_expire '0'
|
||||
option optimistic_cache '0'
|
||||
option optimistic_timeout ''
|
||||
option dns_timeout ''
|
||||
option cache_file_store_dns '0'
|
||||
|
||||
config homeproxy 'subscription'
|
||||
option auto_update '0'
|
||||
option allow_insecure '0'
|
||||
option packet_encoding 'xudp'
|
||||
option update_via_proxy '0'
|
||||
option filter_nodes 'blacklist'
|
||||
list filter_keywords '重置|到期|过期|剩余|套餐'
|
||||
list filter_keywords 'Expiration|Remaining'
|
||||
|
||||
config homeproxy 'server'
|
||||
option enabled '0'
|
||||
option log_level 'warn'
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
2026-09-25 6723a8edb901e7ed1ad82e3f8b37d53534794cc3
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
2026-09-25 6723a8edb901e7ed1ad82e3f8b37d53534794cc3
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2022-2023 ImmortalWrt.org
|
||||
|
||||
NAME="homeproxy"
|
||||
|
||||
log_max_size="50" #KB
|
||||
main_log_file="/var/run/$NAME/$NAME.log"
|
||||
singc_log_file="/var/run/$NAME/sing-box-c.log"
|
||||
sings_log_file="/var/run/$NAME/sing-box-s.log"
|
||||
|
||||
while true; do
|
||||
sleep 180
|
||||
for i in "$main_log_file" "$singc_log_file" "$sings_log_file"; do
|
||||
[ -s "$i" ] || continue
|
||||
[ "$(( $(wc -c < "$i") / 1024 >= log_max_size))" -eq "0" ] || : > "$i"
|
||||
done
|
||||
done
|
||||
@@ -0,0 +1,765 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* Node -> Protocol Adapter -> SingBox Outbound
|
||||
*
|
||||
* This replaces the single 110-line `generate_outbound()` whose protocol
|
||||
* branches were expressed as ~60 ternaries inside one object literal. Three
|
||||
* properties are deliberate:
|
||||
*
|
||||
* kept the shared TLS/transport/multiplex code is still the existing
|
||||
* production builders - the adapter only supplies them a
|
||||
* Node-shaped view instead of a flat UCI dict.
|
||||
* pinned the emitted JSON is frozen by tests/snapshots/generator/
|
||||
* outbounds.json, so a field change for any protocol is a
|
||||
* reviewable diff (test_golden_outbounds.sh).
|
||||
* dropped a per-protocol Adapter class hierarchy. The protocols differ by
|
||||
* which *fields* they carry, not by behaviour, so the honest
|
||||
* extension point is the field tables below, not eleven classes.
|
||||
*
|
||||
* Adding a protocol means adding three entries: CREDENTIALS (model.uc),
|
||||
* PROTOCOL_OPTIONS (loader.uc) and OPTION_FIELDS here - and a node in
|
||||
* tests/fixtures/generators/outbounds.uci, which
|
||||
* tests/ucode/test_protocol_inventory.sh enforces.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
isEmpty, strToBool, strToInt, strToTime, removeBlankAttrs,
|
||||
buildTLSObject, buildTransportObject, parse_port
|
||||
} from '../homeproxy.uc';
|
||||
|
||||
import { Node, Inbound, INBOUND_TLS_SERVER } from './model.uc';
|
||||
|
||||
/* Per-protocol required credential fields. Node.validate() covers the
|
||||
* cross-protocol rules (type/address/port/TLS); this table is the
|
||||
* per-protocol half - "vless needs uuid, vmess needs uuid, trojan needs
|
||||
* password, snell needs psk, ..." - and it is the Adapter's job because
|
||||
* the requirement is shaped by what the sing-box outbound for each
|
||||
* protocol will actually accept, not by anything the model knows about
|
||||
* UCI. The keys are the canonical credential names the Loader uses
|
||||
* (see CREDENTIALS in model.uc), so the check is data-driven and adding
|
||||
* a protocol is a one-line change. */
|
||||
export const REQUIRED_CREDENTIALS = {
|
||||
vless: ['uuid'],
|
||||
vmess: ['uuid'],
|
||||
trojan: ['password'],
|
||||
hysteria2: ['password'],
|
||||
tuic: ['uuid', 'password'],
|
||||
shadowsocks: ['password'],
|
||||
snell: ['psk'],
|
||||
anytls: ['password'],
|
||||
shadowtls: ['password'],
|
||||
ssh: ['user'],
|
||||
/* These three have no hard required credential - the protocol can
|
||||
* run anonymously (http / socks) or the auth is delivered out of
|
||||
* band (hysteria, direct). Node.validate()'s address/port check
|
||||
* is enough. */
|
||||
hysteria: [],
|
||||
http: [],
|
||||
socks: [],
|
||||
direct: []
|
||||
};
|
||||
|
||||
/* Run a Node through the per-protocol required-field check. Returns
|
||||
* the (possibly empty) list of human-readable problems.
|
||||
*
|
||||
* The same ucode quirk that bit Node.validate() bites here too: push()
|
||||
* returns the value pushed, not the new array, so we build the result
|
||||
* via spread instead of the obvious `problems = push(problems, X)`. */
|
||||
function protocol_problems(node) {
|
||||
const required = REQUIRED_CREDENTIALS[node.type] || [];
|
||||
const creds = node.credentials || {};
|
||||
let problems = [];
|
||||
|
||||
for (let field in required) {
|
||||
if (!creds[field])
|
||||
problems = [...problems, `${node.type} requires ${field}`];
|
||||
}
|
||||
|
||||
/* WireGuard is the one protocol whose material does not live in
|
||||
* `credentials`: the endpoint builder reads node.protocol_options.
|
||||
* Checking it here means a broken WireGuard node is caught by the
|
||||
* same "is this node buildable?" path as every other protocol, which
|
||||
* is what lets a urltest list prune it instead of failing the whole
|
||||
* configuration. */
|
||||
if (node.type === 'wireguard') {
|
||||
const opts = node.protocol_options || {};
|
||||
for (let field in ['local_address', 'private_key', 'peer_public_key'])
|
||||
if (!opts[field])
|
||||
problems = [...problems, `wireguard requires ${field}`];
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* --- field transforms --------------------------------------------------- */
|
||||
|
||||
/* A table value is either a function of the domain object (a Node for the
|
||||
* outbound tables, an Inbound for the server tables), a literal, or a
|
||||
* string naming a transform (in which case the value is null - the spec
|
||||
* is present so the key still gets emitted, but the value will be
|
||||
* stripped by removeBlankAttrs()). */
|
||||
const TRANSFORMS = {
|
||||
raw: (value) => value,
|
||||
bool: (value) => strToBool(value),
|
||||
int: (value) => strToInt(value),
|
||||
time: (value) => strToTime(value)
|
||||
};
|
||||
|
||||
/* Shared by the outbound and the inbound field tables: the spec is a
|
||||
* plain function in every table except the outbound one, which carries a
|
||||
* few TRANSFORMS markers for keys that exist but are runtime-owned. */
|
||||
function resolve(spec, subject) {
|
||||
if (type(spec) === 'function')
|
||||
return spec(subject);
|
||||
|
||||
if (type(spec) === 'string' && spec in TRANSFORMS)
|
||||
return null;
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* --- field tables ------------------------------------------------------- */
|
||||
|
||||
/* Fields every protocol emits. Each is null when absent, and nulls are
|
||||
* stripped once, at the end, by removeBlankAttrs(). Cross-protocol common
|
||||
* fields live in node.common (Loader's load_common); protocol-specific
|
||||
* canonical names live in node.protocol_options. The legacy
|
||||
* `node.raw` opaque bag was dropped - the Adapter reads only the explicit
|
||||
* sub-objects, so no escape hatch exists.
|
||||
*
|
||||
* This table is applied AFTER the literal in build_outbound(), so a key
|
||||
* listed here wins over whatever that literal set. A field the runtime owns
|
||||
* (routing_mark, from the `mark` argument) therefore must NOT be listed
|
||||
* here, not even as `null`: the loop would overwrite the value with null and
|
||||
* removeBlankAttrs() would drop the field entirely. routing_mark is what
|
||||
* exempts sing-box's own proxy connections from the nft redirect chain, so
|
||||
* losing it loops the traffic back into the redirect inbound. Guard 32 in
|
||||
* tests/arch-guard.sh keeps the literal's keys and this table disjoint. */
|
||||
const COMMON_FIELDS = {
|
||||
server: (node) => node.address,
|
||||
server_port: (node) => strToInt(node.port),
|
||||
proxy_protocol: (node) => strToInt(node.common.proxy_protocol),
|
||||
tcp_fast_open: (node) => strToBool(node.common.tcp_fast_open),
|
||||
tcp_multi_path: (node) => strToBool(node.common.tcp_multi_path),
|
||||
udp_fragment: (node) => strToBool(node.common.udp_fragment),
|
||||
packet_encoding: (node) => node.protocol_options.packet_encoding || null
|
||||
};
|
||||
|
||||
/* The node form stores an SSH private key in a DynamicList, i.e. one UCI
|
||||
* list entry per line of the OpenSSH/PEM blob (a UCI option cannot hold
|
||||
* newlines). sing-box wants the whole key as one string, so re-join the
|
||||
* lines. A single-entry value is passed through unchanged.
|
||||
*
|
||||
* Declared before CLAIM_FIELDS on purpose: ucode resolves module-level names
|
||||
* lexically, so a callee has to appear above its caller in the file. */
|
||||
function ssh_private_key(value) {
|
||||
if (type(value) === 'array')
|
||||
return length(value) ? join('\n', value) : null;
|
||||
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/* Credential fields: one canonical set, emitted under the name each protocol
|
||||
* wants. This is where the old builder repeated itself. */
|
||||
function CLAIM_FIELDS(node) {
|
||||
switch (node.type) {
|
||||
case 'snell':
|
||||
return {
|
||||
psk: node.credentials.psk,
|
||||
userkey: node.credentials.userkey,
|
||||
reuse: strToBool(node.protocol_options.reuse)
|
||||
};
|
||||
case 'ssh':
|
||||
return {
|
||||
user: node.credentials.user,
|
||||
password: node.credentials.password,
|
||||
private_key: ssh_private_key(node.credentials.private_key),
|
||||
private_key_passphrase: node.credentials.private_key_passphrase
|
||||
};
|
||||
case 'hysteria':
|
||||
return {
|
||||
auth: (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null
|
||||
};
|
||||
default:
|
||||
return {
|
||||
username: node.credentials.username,
|
||||
uuid: node.credentials.uuid,
|
||||
password: node.credentials.password,
|
||||
method: node.credentials.method
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-protocol option fields, keyed by the same canonical names the model
|
||||
* exposes. Anything not modelled here is still in node.protocol_options
|
||||
* (which is itself populated from the protocol's PROTOCOL_OPTIONS row in
|
||||
* the Loader). */
|
||||
export const OPTION_FIELDS = {
|
||||
vless: {
|
||||
flow: (node) => node.protocol_options.flow
|
||||
/* No udp_over_tcp here: sing-box 1.14 rejects that field on a vless
|
||||
* outbound ("json: unknown field"). It used to be emitted, so a
|
||||
* node whose type was switched from shadowsocks to vless kept the
|
||||
* stale UCI option and produced a config sing-box refused outright.
|
||||
* The node form never offered the option for vless anyway. */
|
||||
},
|
||||
/* `mode` (snell v6 traffic shaping) is deliberately absent: sing-box
|
||||
* 1.14 rejects it as an outbound field, and the node form only offered
|
||||
* it for snell_version 6, which this sing-box does not support either. */
|
||||
snell: {
|
||||
version: (node) => strToInt(node.protocol_options.version) || 4,
|
||||
obfs_mode: (node) => node.protocol_options.obfs_mode || null,
|
||||
obfs_host: (node) => node.protocol_options.obfs_host || null
|
||||
},
|
||||
/* A4.1: shadowsocks. SIP002 plugin support is emitted only when both
|
||||
* plugin and plugin_opts are present, matching the generator's ternary
|
||||
* that emits `plugin: ...` unconditionally and lets removeBlankAttrs
|
||||
* drop empties. `udp_over_tcp` mirrors the vless shape. */
|
||||
shadowsocks: {
|
||||
plugin: (node) => node.protocol_options.plugin || null,
|
||||
plugin_opts: (node) => node.protocol_options.plugin_opts || null,
|
||||
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
|
||||
enabled: true,
|
||||
version: strToInt(node.protocol_options.udp_over_tcp_version)
|
||||
} : null
|
||||
},
|
||||
/* A4.2: anytls idle session tuning (sing-box 1.14 lets the client probe
|
||||
* upstream sessions and proactively close stuck ones). */
|
||||
anytls: {
|
||||
idle_session_check_interval: (node) => strToTime(node.protocol_options.idle_session_check_interval),
|
||||
idle_session_timeout: (node) => strToTime(node.protocol_options.idle_session_timeout),
|
||||
min_idle_session: (node) => strToInt(node.protocol_options.min_idle_session)
|
||||
},
|
||||
/* A4.3: http - no protocol-specific options; transport / tls / multiplex
|
||||
* are shared. */
|
||||
http: {},
|
||||
/* A4.4: socks. The node form offers UDP-over-TCP for socks and sing-box
|
||||
* accepts it on the socks outbound, but the adapter never emitted it, so
|
||||
* the setting was silently ignored. */
|
||||
socks: {
|
||||
version: (node) => node.protocol_options.version,
|
||||
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
|
||||
enabled: true,
|
||||
version: strToInt(node.protocol_options.udp_over_tcp_version)
|
||||
} : null
|
||||
},
|
||||
/* A4.5: tuic */
|
||||
tuic: {
|
||||
congestion_control: (node) => node.protocol_options.congestion_control,
|
||||
udp_relay_mode: (node) => node.protocol_options.udp_relay_mode,
|
||||
udp_over_stream: (node) => strToBool(node.protocol_options.udp_over_stream),
|
||||
zero_rtt_handshake: (node) => strToBool(node.protocol_options.zero_rtt_handshake),
|
||||
heartbeat: (node) => strToTime(node.protocol_options.heartbeat)
|
||||
},
|
||||
/* A4.6: trojan - no protocol-specific options. */
|
||||
trojan: {},
|
||||
/* A4.7: shadowtls */
|
||||
shadowtls: {
|
||||
version: (node) => strToInt(node.protocol_options.version)
|
||||
},
|
||||
/* A4.8: hysteria (v1). The generator emits two fields: `auth` when the
|
||||
* payload is base64, `auth_str` when it is a string. Node.validate()
|
||||
* only catches the obvious case; the conditional keeps the table-driven
|
||||
* shape.
|
||||
*
|
||||
* obfs is the plain obfuscation password string here. The node form
|
||||
* only offers the password for v1 (the type selector is hysteria2-only)
|
||||
* and sing-box rejects the {type, password} object on a hysteria
|
||||
* outbound - that shape belongs to hysteria2. Emitting the object made
|
||||
* any obfuscated hysteria node produce a config sing-box refused. */
|
||||
hysteria: {
|
||||
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
|
||||
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
|
||||
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
|
||||
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
|
||||
obfs: (node) => node.protocol_options.obfs_password || null
|
||||
},
|
||||
/* A4.9: hysteria2 - extends hysteria with hop_interval_max /
|
||||
* server_ports / bbr_profile / disable_chrome_parrot. The obfs
|
||||
* shape is the same. Note sing-box 1.14 uses `server_ports`
|
||||
* (port hopping list), not `hopping_port` - the latter is the
|
||||
* UCI option name, so the rename happens here. */
|
||||
hysteria2: {
|
||||
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
|
||||
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
|
||||
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
|
||||
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
|
||||
hop_interval_max: (node) => strToTime(node.protocol_options.hop_interval_max),
|
||||
server_ports: (node) => node.protocol_options.hopping_port,
|
||||
obfs: (node) => node.protocol_options.obfs_type ? {
|
||||
type: node.protocol_options.obfs_type,
|
||||
password: node.protocol_options.obfs_password
|
||||
} : null,
|
||||
bbr_profile: (node) => node.protocol_options.bbr_profile || null,
|
||||
disable_chrome_parrot: (node) => (node.protocol_options.disable_chrome_parrot === '1') ? true : null
|
||||
},
|
||||
/* A4.10: vmess */
|
||||
vmess: {
|
||||
alter_id: (node) => strToInt(node.protocol_options.alter_id),
|
||||
security: (node) => node.protocol_options.security,
|
||||
global_padding: (node) => strToBool(node.protocol_options.global_padding)
|
||||
},
|
||||
/* SSH: host-key pinning and the client banner, matching sing-box's ssh
|
||||
* outbound field names. */
|
||||
ssh: {
|
||||
client_version: (node) => node.protocol_options.client_version || null,
|
||||
host_key: (node) => node.protocol_options.host_key || null,
|
||||
host_key_algorithms: (node) => node.protocol_options.host_key_algorithms || null
|
||||
}
|
||||
};
|
||||
|
||||
/* --- shared builders ---------------------------------------------------- */
|
||||
|
||||
function build_multiplex(mux) {
|
||||
if (!mux || mux.enabled !== '1')
|
||||
return null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
protocol: mux.protocol,
|
||||
max_connections: strToInt(mux.max_connections),
|
||||
min_streams: strToInt(mux.min_streams),
|
||||
max_streams: strToInt(mux.max_streams),
|
||||
padding: strToBool(mux.padding),
|
||||
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
|
||||
enabled: true,
|
||||
up_mbps: strToInt(mux.brutal.up_mbps),
|
||||
down_mbps: strToInt(mux.brutal.down_mbps)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
/* --- adapter ------------------------------------------------------------ */
|
||||
|
||||
/* Both validation layers as one list. Standalone (not a method) because the
|
||||
* OutboundFactory literal below cannot reference itself during its own
|
||||
* initialisation: ucode resolves the binding lexically and throws
|
||||
* "Can't access lexical declaration before initialization". */
|
||||
function outbound_problems(node) {
|
||||
return [
|
||||
...Node.validate(node),
|
||||
...protocol_problems(node)
|
||||
];
|
||||
}
|
||||
|
||||
/* Node -> sing-box outbound object. Pure: no UCI, no module state, no file
|
||||
* access; `mark` is passed in instead of read from a global.
|
||||
*
|
||||
* Standalone for the same reason outbound_problems() is: the literal below
|
||||
* cannot name OutboundFactory in any nested scope, not even inside an arrow
|
||||
* body, without tripping ucode's "Can't access lexical declaration before
|
||||
* initialization" check. Validation is done by the callers. */
|
||||
function build_outbound(node, mark) {
|
||||
const outbound = {
|
||||
type: node.type,
|
||||
tag: Node.tag(node),
|
||||
routing_mark: strToInt(mark)
|
||||
};
|
||||
|
||||
for (let field, spec in COMMON_FIELDS)
|
||||
outbound[field] = resolve(spec, node);
|
||||
|
||||
const claims = CLAIM_FIELDS(node);
|
||||
for (let field, value in claims)
|
||||
outbound[field] = value;
|
||||
|
||||
const options = OPTION_FIELDS[node.type] || {};
|
||||
for (let field, spec in options)
|
||||
outbound[field] = resolve(spec, node);
|
||||
|
||||
outbound.multiplex = build_multiplex(node.multiplex);
|
||||
outbound.tls = buildTLSObject(node.tls, false);
|
||||
outbound.transport = buildTransportObject(node.transport, false);
|
||||
|
||||
if (node.type === 'direct')
|
||||
outbound.proxy_protocol = strToInt(node.common.proxy_protocol);
|
||||
|
||||
/* removeBlankAttrs() is what the generator applies before writing, and
|
||||
it drops every null the field tables produced. Cleaning here means
|
||||
this function returns the final artifact, so a golden-snapshot test
|
||||
can compare it against what sing-box would actually be handed. */
|
||||
return removeBlankAttrs(outbound);
|
||||
}
|
||||
|
||||
export const OutboundFactory = {
|
||||
/* Validation split out of create() so a caller can decide whether a
|
||||
* broken node is fatal. Two-layer: Node.validate() covers
|
||||
* cross-protocol rules, protocol_problems() the per-protocol ones. */
|
||||
problems: outbound_problems,
|
||||
|
||||
/* True when create() would succeed. Used to prune optional node lists
|
||||
* (urltest candidates) so one misconfigured node cannot abort the
|
||||
* entire configuration. */
|
||||
buildable: (node) => length(outbound_problems(node)) === 0,
|
||||
|
||||
/* Node -> { outbound, problems }. Same result as create() but never
|
||||
* dies, for nodes the user only *may* route through. */
|
||||
tryCreate: (node, mark) => {
|
||||
const problems = outbound_problems(node);
|
||||
|
||||
if (length(problems))
|
||||
return { outbound: null, problems: problems };
|
||||
|
||||
return { outbound: build_outbound(node, mark), problems: [] };
|
||||
},
|
||||
|
||||
/* Node -> sing-box outbound object, or die() when the node is not
|
||||
* buildable. Use problems()/buildable()/tryCreate() for nodes whose
|
||||
* breakage must not be fatal (urltest candidates). */
|
||||
create: (node, mark) => {
|
||||
const problems = outbound_problems(node);
|
||||
|
||||
if (length(problems))
|
||||
die(`node '${node.id}': ${join(', ', problems)}\n`);
|
||||
|
||||
return build_outbound(node, mark);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- WireGuard endpoint ------------------------------------------------- */
|
||||
|
||||
/* WireGuard is emitted as a
|
||||
* sing-box *endpoint*, not an outbound, so it needs its own builder.
|
||||
* That builder used to live in generator/outbound.uc, which meant the
|
||||
* generator still held protocol business logic for one protocol. It
|
||||
* now lives here, next to the outbound factory, and the generator only
|
||||
* decides *when* an endpoint is needed (routing-mode split, urltest
|
||||
* resolution).
|
||||
*
|
||||
* `ctx` carries the process-wide UDP tuning the runtime owns
|
||||
* (udp_mapping / udp_filtering / udp_nat_max) - same shape
|
||||
* build_outbounds() already passes around, so callers do not change. */
|
||||
function build_endpoint(node, ctx) {
|
||||
const opts = node.protocol_options || {};
|
||||
const common = node.common || {};
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
tag: 'cfg-' + node.id + '-out',
|
||||
address: opts.local_address,
|
||||
mtu: strToInt(opts.mtu),
|
||||
private_key: opts.private_key,
|
||||
peers: (node.type === 'wireguard') ? [
|
||||
{
|
||||
address: node.address,
|
||||
port: strToInt(node.port),
|
||||
allowed_ips: [
|
||||
'0.0.0.0/0',
|
||||
'::/0'
|
||||
],
|
||||
persistent_keepalive_interval: strToInt(opts.persistent_keepalive_interval),
|
||||
public_key: opts.peer_public_key,
|
||||
pre_shared_key: opts.pre_shared_key,
|
||||
reserved: parse_port(opts.reserved),
|
||||
}
|
||||
] : null,
|
||||
system: (node.type === 'wireguard') ? false : null,
|
||||
tcp_fast_open: strToBool(common.tcp_fast_open),
|
||||
tcp_multi_path: strToBool(common.tcp_multi_path),
|
||||
udp_fragment: strToBool(common.udp_fragment),
|
||||
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
|
||||
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
|
||||
udp_nat_max: ctx.udp_nat_max
|
||||
};
|
||||
}
|
||||
|
||||
export const EndpointFactory = {
|
||||
/* Node -> sing-box endpoint object, or null when there is nothing to
|
||||
* build. Unlike OutboundFactory.create() this never dies: an endpoint
|
||||
* is only ever reached for a node the routing layer already selected,
|
||||
* and the caller treats null as "skip this endpoint". */
|
||||
create: (node, ctx) => {
|
||||
if (type(node) !== 'object' || isEmpty(node))
|
||||
return null;
|
||||
|
||||
return build_endpoint(node, ctx);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- server inbound ----------------------------------------------------- */
|
||||
|
||||
/* The server half of the Adapter. Same data-table approach as
|
||||
* the outbound side: the protocol differences are *fields*, not
|
||||
* behaviour, so they are expressed as tables rather than a branch per
|
||||
* protocol. generator/server.uc used to spell every one of these out
|
||||
* inline while reading flat UCI keys off the section dict.
|
||||
*
|
||||
* Adding a protocol means adding two rows: INBOUND_CREDENTIALS and
|
||||
* INBOUND_OPTIONS in model.uc, and an INBOUND_OPTION_FIELDS row here. */
|
||||
|
||||
/* Listener-level fields every inbound emits. Each value is a function of
|
||||
* the Inbound, or null (which removeBlankAttrs() strips). */
|
||||
const INBOUND_COMMON_FIELDS = {
|
||||
listen: (inbound) => inbound.address || '::',
|
||||
listen_port: (inbound) => strToInt(inbound.port),
|
||||
bind_interface: (inbound) => inbound.common.bind_interface,
|
||||
reuse_addr: (inbound) => strToBool(inbound.common.reuse_addr),
|
||||
tcp_fast_open: (inbound) => strToBool(inbound.common.tcp_fast_open),
|
||||
tcp_multi_path: (inbound) => strToBool(inbound.common.tcp_multi_path),
|
||||
udp_fragment: (inbound) => strToBool(inbound.common.udp_fragment),
|
||||
udp_timeout: (inbound) => strToTime(inbound.common.udp_timeout),
|
||||
network: (inbound) => inbound.common.network
|
||||
};
|
||||
|
||||
/* Listener fields a protocol must NOT be given even when the UCI section
|
||||
* happens to carry the option. sing-box 1.14's snell inbound takes no
|
||||
* udp_fragment / udp_timeout / network; the previous generator had a
|
||||
* dedicated build_snell_inbound() that omitted them, and this table
|
||||
* keeps that behaviour instead of relying on the user not setting them. */
|
||||
const INBOUND_COMMON_OMIT = {
|
||||
snell: ['udp_fragment', 'udp_timeout', 'network']
|
||||
};
|
||||
|
||||
/* Protocols whose sing-box inbound has no users[] block. snell carries a
|
||||
* single top-level psk, shadowsocks a top-level password + method; giving
|
||||
* either of them a users[] entry makes sing-box reject the section
|
||||
* ("snell: bad user key" - the users entry is interpreted as an
|
||||
* additional user key). */
|
||||
const INBOUND_NO_USERS = ['shadowsocks', 'snell'];
|
||||
|
||||
/* Credential fields the inbound emits, keyed by the sing-box field
|
||||
* name. Most protocols put them in the users[] block (below); snell
|
||||
* wants a single top-level `psk`, and shadowsocks / shadowtls a
|
||||
* top-level password (plus method for shadowsocks), so those are
|
||||
* handled here. */
|
||||
function INBOUND_CLAIM_FIELDS(inbound) {
|
||||
switch (inbound.type) {
|
||||
case 'snell':
|
||||
return { psk: inbound.credentials.psk };
|
||||
case 'shadowsocks':
|
||||
return {
|
||||
method: inbound.credentials.method,
|
||||
password: inbound.credentials.password
|
||||
};
|
||||
case 'shadowtls':
|
||||
return { password: inbound.credentials.password };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/* The users[] entry. sing-box wants one user per inbound; every
|
||||
* protocol not listed in INBOUND_NO_USERS accepts the block. */
|
||||
function build_inbound_user(inbound) {
|
||||
const creds = inbound.credentials || {};
|
||||
const opts = inbound.protocol_options || {};
|
||||
|
||||
return {
|
||||
name: !(inbound.type in ['http', 'mixed', 'naive', 'socks']) ?
|
||||
'cfg-' + inbound.id + '-server' : null,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
|
||||
/* Hysteria (1) authentication: exactly one of the two
|
||||
* spellings, selected by the auth type. */
|
||||
auth: (opts.auth_type === 'base64') ? creds.auth_base64 : null,
|
||||
auth_str: (opts.auth_type === 'string') ? creds.auth_str : null,
|
||||
|
||||
uuid: creds.uuid,
|
||||
|
||||
/* VLESS / VMess. Both are per-user fields: sing-box has no
|
||||
* top-level `flow` on a vless inbound nor `alterId` on a vmess
|
||||
* one, which is why INBOUND_OPTION_FIELDS has no row for these
|
||||
* two protocols. */
|
||||
flow: opts.flow,
|
||||
alterId: strToInt(opts.alter_id)
|
||||
};
|
||||
}
|
||||
|
||||
/* Per-protocol inbound fields, keyed by the sing-box field name. Only
|
||||
* the protocols whose sing-box inbound carries something beyond the
|
||||
* shared listener/credential/TLS/transport set need a row. */
|
||||
const INBOUND_OPTION_FIELDS = {
|
||||
anytls: {
|
||||
padding_scheme: (inbound) => inbound.protocol_options.padding_scheme
|
||||
},
|
||||
/* Hysteria (1) and Hysteria2 share three fields and differ on
|
||||
* obfs: v1 carries the obfuscation as a plain password string,
|
||||
* v2 as a {type, password, min_packet_size, max_packet_size}
|
||||
* object. Emitting v1's obfs as a string is what the generator
|
||||
* always did - the {type, ...} shape on a v1 inbound is a
|
||||
* sing-box rejection. */
|
||||
hysteria: {
|
||||
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
|
||||
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
|
||||
obfs: (inbound) => inbound.protocol_options.obfs_password,
|
||||
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
|
||||
masquerade: (inbound) => inbound.protocol_options.masquerade
|
||||
},
|
||||
hysteria2: {
|
||||
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
|
||||
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
|
||||
obfs: (inbound) => inbound.protocol_options.obfs_type ? {
|
||||
type: inbound.protocol_options.obfs_type,
|
||||
password: inbound.protocol_options.obfs_password,
|
||||
min_packet_size: strToInt(inbound.protocol_options.obfs_min_packet_size),
|
||||
max_packet_size: strToInt(inbound.protocol_options.obfs_max_packet_size)
|
||||
/* hysteria2 takes an obfs *object* here. Falling back to the bare
|
||||
* password string (which is what hysteria v1 wants) makes sing-box
|
||||
* reject the inbound with "cannot unmarshal string into Go struct
|
||||
* field Hysteria2InboundOptions.obfs" - so no type means no obfs. */
|
||||
} : null,
|
||||
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
|
||||
masquerade: (inbound) => inbound.protocol_options.masquerade
|
||||
},
|
||||
snell: {
|
||||
version: (inbound) => strToInt(inbound.protocol_options.version) || 5,
|
||||
obfs_mode: (inbound) => inbound.protocol_options.obfs_mode
|
||||
/* no `mode`: sing-box 1.14 rejects it on a snell inbound; it
|
||||
* was a v6-only option and v6 is not supported. */
|
||||
},
|
||||
tuic: {
|
||||
congestion_control: (inbound) => inbound.protocol_options.congestion_control,
|
||||
auth_timeout: (inbound) => strToTime(inbound.protocol_options.auth_timeout),
|
||||
zero_rtt_handshake: (inbound) => strToBool(inbound.protocol_options.zero_rtt_handshake),
|
||||
heartbeat: (inbound) => strToTime(inbound.protocol_options.heartbeat)
|
||||
}
|
||||
/* No row for vless / vmess: both protocols' only per-protocol knobs
|
||||
* (flow, alterId) are per-user fields, emitted by
|
||||
* build_inbound_user() above. sing-box has no top-level `flow` on a
|
||||
* vless inbound nor `alterId` on a vmess one, so adding a row here
|
||||
* would emit a field sing-box rejects. The Loader still loads them
|
||||
* into inbound.protocol_options (INBOUND_OPTIONS in model.uc), which
|
||||
* is where build_inbound_user() reads them from.
|
||||
*
|
||||
* No row for trojan / shadowtls either: everything they carry is
|
||||
* shared (credentials / TLS / transport). */
|
||||
};
|
||||
|
||||
/* The server-only TLS tail, re-keyed from the Inbound's canonical
|
||||
* names into the UCI option names buildTLSObject() reads. That shared
|
||||
* builder is exercised directly by tests/ucode/test_tls_transport.uc
|
||||
* and is used by both the client and the server path, so its signature
|
||||
* stays as it is; this is the single place the flat names survive. */
|
||||
function build_tls_server_extras(tls_server) {
|
||||
const extras = {};
|
||||
|
||||
for (let canonical, uci in INBOUND_TLS_SERVER)
|
||||
extras[uci] = tls_server[canonical];
|
||||
|
||||
return extras;
|
||||
}
|
||||
|
||||
/* Build the inbound `multiplex` block. The server side has no
|
||||
* max_connections / min_streams / max_streams (those are client
|
||||
* dialling knobs), so this is deliberately a smaller shape than the
|
||||
* outbound factory's build_multiplex(). */
|
||||
function build_inbound_multiplex(mux) {
|
||||
if (!mux || mux.enabled !== '1')
|
||||
return null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
padding: strToBool(mux.padding),
|
||||
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
|
||||
enabled: true,
|
||||
up_mbps: strToInt(mux.brutal.up_mbps),
|
||||
down_mbps: strToInt(mux.brutal.down_mbps)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
/* Inbound -> sing-box inbound object. Pure: no UCI, no file access. */
|
||||
function build_inbound(inbound) {
|
||||
const out = {
|
||||
type: inbound.type,
|
||||
tag: Inbound.tag(inbound)
|
||||
};
|
||||
|
||||
const omit = INBOUND_COMMON_OMIT[inbound.type] || [];
|
||||
|
||||
for (let field, spec in INBOUND_COMMON_FIELDS) {
|
||||
if (field in omit)
|
||||
continue;
|
||||
out[field] = resolve(spec, inbound);
|
||||
}
|
||||
|
||||
const claims = INBOUND_CLAIM_FIELDS(inbound);
|
||||
for (let field, value in claims)
|
||||
out[field] = value;
|
||||
|
||||
const options = INBOUND_OPTION_FIELDS[inbound.type] || {};
|
||||
for (let field, spec in options)
|
||||
out[field] = resolve(spec, inbound);
|
||||
|
||||
out.users = (inbound.type in INBOUND_NO_USERS) ? null : [ build_inbound_user(inbound) ];
|
||||
|
||||
out.multiplex = build_inbound_multiplex(inbound.multiplex);
|
||||
out.tls = buildTLSObject(inbound.tls, true, build_tls_server_extras(inbound.tls_server || {}));
|
||||
out.transport = buildTransportObject(inbound.transport, true);
|
||||
|
||||
/* Same contract as build_outbound(): return the final artifact so a
|
||||
* golden snapshot can compare it against what sing-box is handed. */
|
||||
return removeBlankAttrs(out);
|
||||
}
|
||||
|
||||
/* Per-protocol requirements, mirroring OutboundFactory's
|
||||
* REQUIRED_CREDENTIALS: what the sing-box inbound for this protocol
|
||||
* needs in order to be usable. */
|
||||
const REQUIRED_INBOUND_CREDENTIALS = {
|
||||
vless: ['uuid'],
|
||||
vmess: ['uuid'],
|
||||
trojan: ['password'],
|
||||
hysteria2: ['password'],
|
||||
tuic: ['uuid', 'password'],
|
||||
shadowsocks: ['password'],
|
||||
snell: ['psk'],
|
||||
anytls: ['password'],
|
||||
shadowtls: ['password'],
|
||||
/* These run without a credential: http / mixed / socks / naive can
|
||||
* be anonymous, and hysteria (1) may carry its auth out of band. */
|
||||
hysteria: [],
|
||||
http: [],
|
||||
mixed: [],
|
||||
naive: [],
|
||||
socks: []
|
||||
};
|
||||
|
||||
function inbound_problems(inbound) {
|
||||
let problems = [...Inbound.validate(inbound)];
|
||||
|
||||
const required = REQUIRED_INBOUND_CREDENTIALS[inbound.type] || [];
|
||||
const creds = inbound.credentials || {};
|
||||
|
||||
for (let field in required)
|
||||
if (!creds[field])
|
||||
problems = [...problems, `${inbound.type} requires ${field}`];
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
export const InboundFactory = {
|
||||
problems: inbound_problems,
|
||||
|
||||
buildable: (inbound) => length(inbound_problems(inbound)) === 0,
|
||||
|
||||
/* Inbound -> { inbound, problems }. Never dies, so a single broken
|
||||
* server section can be reported by the caller instead of taking
|
||||
* the whole server instance down. */
|
||||
tryCreate: (inbound) => {
|
||||
const problems = inbound_problems(inbound);
|
||||
|
||||
if (length(problems))
|
||||
return { inbound: null, problems: problems };
|
||||
|
||||
return { inbound: build_inbound(inbound), problems: [] };
|
||||
},
|
||||
|
||||
/* Inbound -> sing-box inbound object, or die(). */
|
||||
create: (inbound) => {
|
||||
const problems = inbound_problems(inbound);
|
||||
|
||||
if (length(problems))
|
||||
die(`inbound '${inbound.id}': ${join(', ', problems)}\n`);
|
||||
|
||||
return build_inbound(inbound);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* UCI -> Config Loader -> HomeProxyConfig
|
||||
*
|
||||
* This file originated as the demo loader and was promoted into the
|
||||
* production tree; the demo copy is gone, so this is the only one.
|
||||
*
|
||||
* What this layer does:
|
||||
* - owns the only `uci.cursor()` in the client configuration path
|
||||
* - reads general + infra + nodes + dns + routing + access_control + server
|
||||
* - never emits sing-box JSON
|
||||
* - never mutates/commits UCI
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
|
||||
import {
|
||||
Config, Node, CREDENTIALS, Inbound,
|
||||
INBOUND_CREDENTIALS, INBOUND_OPTIONS, INBOUND_COMMON, INBOUND_TLS_SERVER
|
||||
} from './model.uc';
|
||||
|
||||
import { PROTOCOL_TO_UCI as PROTOCOL_OPTIONS } from '../parser/mapping.uc';
|
||||
|
||||
const UCICONFIG = 'homeproxy';
|
||||
|
||||
const SECTION = {
|
||||
main: 'config',
|
||||
infra: 'infra',
|
||||
node: 'node',
|
||||
dns: 'dns',
|
||||
dns_server: 'dns_server',
|
||||
dns_rule: 'dns_rule',
|
||||
routing: 'routing',
|
||||
routing_node: 'routing_node',
|
||||
routing_rule: 'routing_rule',
|
||||
ruleset: 'ruleset',
|
||||
control: 'control',
|
||||
subscription: 'subscription',
|
||||
server: 'server'
|
||||
};
|
||||
|
||||
/* Booleans are UCI '0'/'1'; keep the conversion in one place. */
|
||||
function bool(value) {
|
||||
return value === '1';
|
||||
}
|
||||
|
||||
function opt(uci, section, name) {
|
||||
/* UCI returns null for an absent option; the old code papered over that
|
||||
* with `|| ''` at every call site, losing the distinction between "not
|
||||
* set" and "set to empty". The model keeps null instead. */
|
||||
return uci.get(UCICONFIG, SECTION[section], name);
|
||||
}
|
||||
|
||||
function node_opt(uci, section_id, name) {
|
||||
return uci.get(UCICONFIG, section_id, name);
|
||||
}
|
||||
|
||||
/* --- sub-object extraction --------------------------------------------- */
|
||||
|
||||
/* These three functions are the whole reason a Node is hierarchical: the
|
||||
* shared TLS/transport/multiplex builders (homeproxy.uc) take a flat,
|
||||
* prefixed dict today, so every caller has to hand them the right subset. A
|
||||
* sub-object makes the boundary explicit and testable.
|
||||
*
|
||||
* load_tls and load_transport are exported so the server generator can
|
||||
* shape its UCI inbound sections the same way; only the multiplex shape is
|
||||
* Loader-internal because no caller outside the client node path needs it. */
|
||||
|
||||
export function load_tls(get) {
|
||||
return {
|
||||
enabled: get('tls'),
|
||||
server_name: get('tls_sni'),
|
||||
insecure: get('tls_insecure'),
|
||||
alpn: get('tls_alpn'),
|
||||
min_version: get('tls_min_version'),
|
||||
max_version: get('tls_max_version'),
|
||||
handshake_timeout: get('tls_handshake_timeout'),
|
||||
cipher_suites: get('tls_cipher_suites'),
|
||||
cert_path: get('tls_cert_path'),
|
||||
ech: {
|
||||
enabled: get('tls_ech'),
|
||||
config: get('tls_ech_config'),
|
||||
config_path: get('tls_ech_config_path')
|
||||
},
|
||||
utls: {
|
||||
fingerprint: get('tls_utls')
|
||||
},
|
||||
reality: {
|
||||
enabled: get('tls_reality'),
|
||||
public_key: get('tls_reality_public_key'),
|
||||
short_id: get('tls_reality_short_id')
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function load_transport(get) {
|
||||
const transport = get('transport');
|
||||
|
||||
if (transport == null || transport === '')
|
||||
return { type: null };
|
||||
|
||||
return {
|
||||
type: transport,
|
||||
host: get('http_host') || get('httpupgrade_host'),
|
||||
path: get('http_path') || get('ws_path'),
|
||||
headers: get('ws_host') ? { Host: get('ws_host') } : null,
|
||||
method: get('http_method'),
|
||||
max_early_data: get('websocket_early_data'),
|
||||
early_data_header_name: get('websocket_early_data_header'),
|
||||
service_name: get('grpc_servicename'),
|
||||
idle_timeout: get('http_idle_timeout'),
|
||||
ping_timeout: get('http_ping_timeout'),
|
||||
permit_without_stream: get('grpc_permit_without_stream')
|
||||
};
|
||||
};
|
||||
|
||||
function load_multiplex(get) {
|
||||
return {
|
||||
enabled: get('multiplex'),
|
||||
protocol: get('multiplex_protocol'),
|
||||
max_connections: get('multiplex_max_connections'),
|
||||
min_streams: get('multiplex_min_streams'),
|
||||
max_streams: get('multiplex_max_streams'),
|
||||
padding: get('multiplex_padding'),
|
||||
brutal: {
|
||||
enabled: get('multiplex_brutal'),
|
||||
up_mbps: get('multiplex_brutal_up'),
|
||||
down_mbps: get('multiplex_brutal_down')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Cross-protocol common fields (every outbound protocol carries them, not
|
||||
* just one). Lives as its own sub-object instead of being repeated in
|
||||
* every PROTOCOL_OPTIONS row, and the Adapter reads from `node.common`. */
|
||||
function load_common(get) {
|
||||
return {
|
||||
proxy_protocol: get('proxy_protocol'),
|
||||
tcp_fast_open: get('tcp_fast_open'),
|
||||
tcp_multi_path: get('tcp_multi_path'),
|
||||
udp_fragment: get('udp_fragment')
|
||||
};
|
||||
}
|
||||
|
||||
/* --- protocol options --------------------------------------------------- */
|
||||
|
||||
/* The canonical <-> UCI mapping table lives in scripts/parser/mapping.uc and
|
||||
* is imported as PROTOCOL_OPTIONS above. The Loader no longer maintains its
|
||||
* own copy, so the parser, the Loader, the parser's normalize(), and the
|
||||
* Repository all read from the same source. Anything not listed in
|
||||
* parser/mapping.uc for a given protocol is not part of the domain
|
||||
* contract; a stale UCI option is now dropped at the parser side too.
|
||||
*/
|
||||
|
||||
function load_protocol_options(get, type) {
|
||||
const mapping = PROTOCOL_OPTIONS[type] || {};
|
||||
const options = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
options[canonical] = get(uci_name);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/* Pull the protocol's credential fields out under canonical names, using the
|
||||
* single CREDENTIALS table from the model. This is the loader half of the
|
||||
* mapping that the old code spread across ~20 ternaries in generate_outbound().
|
||||
* Declared before Loader: ucode resolves module-level names at call time but
|
||||
* not before they are declared. */
|
||||
function load_credentials(get, type) {
|
||||
const mapping = CREDENTIALS[type] || {};
|
||||
const credentials = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
credentials[canonical] = get(uci_name);
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/* --- domain sub-objects ------------------------------------------------ */
|
||||
|
||||
/* Settings: only the keys that exist in UCI are kept (null is dropped), so
|
||||
* the consumer can tell "not set" from "set to empty". A3 (Generator split)
|
||||
* will own the actual key list - this loader just preserves what UCI has. */
|
||||
function load_settings(uci, section, keys) {
|
||||
const settings = {};
|
||||
|
||||
for (let key in keys) {
|
||||
const v = uci.get(UCICONFIG, section, key);
|
||||
if (v != null)
|
||||
settings[key] = v;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/* The raw UCI section dict that
|
||||
* uci.foreach() yields carries internal fields prefixed with a dot
|
||||
* (`.name`, `.index`, `.type`); downstream generators had to read those
|
||||
* directly, and every consumer had to remember `cfg.enabled !== '1'`
|
||||
* to gate a section. Normalise once here so consumers see a flat
|
||||
* domain object: the dot-prefixed pseudo-fields are dropped, the UCI
|
||||
* section name is exposed as `name`, and `enabled` (when present) is
|
||||
* coerced to a real boolean.
|
||||
*
|
||||
* Fields with non-prefixed names that happen to collide with the UCI
|
||||
* metadata (e.g. an option literally called `name` or `index`) would
|
||||
* be shadowed; in this codebase that does not happen, and the Adapter keeps
|
||||
* it that way. */
|
||||
function normalize_section(cfg) {
|
||||
const item = {};
|
||||
|
||||
for (let k, v in cfg) {
|
||||
/* substr(), not k[0]: the target ucode (2026.01.16) rejects
|
||||
* string indexing with "left-hand side expression is not an
|
||||
* array or object". subscription/repository.uc already uses
|
||||
* the substr() form for the same check. */
|
||||
if (substr(k, 0, 1) === '.')
|
||||
continue;
|
||||
item[k] = v;
|
||||
}
|
||||
|
||||
if ('enabled' in item)
|
||||
item.enabled = (item.enabled === '1');
|
||||
|
||||
item.name = cfg['.name'];
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/* Single-section + list sections grouped under a domain sub-object.
|
||||
* Each list entry is the normalised section dict produced by
|
||||
* normalize_section(): the UCI `.name`/`.index`/`.type` pseudo-fields
|
||||
* are gone, `name` is set to the section name, and `enabled` is a
|
||||
* boolean. Sing-box field names are still produced by the generator
|
||||
* modules; this loader just preserves what UCI has. */
|
||||
function load_sections(uci, type) {
|
||||
const items = [];
|
||||
|
||||
uci.foreach(UCICONFIG, type, (cfg) => push(items, normalize_section(cfg)));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function load_dns(uci) {
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.dns, [
|
||||
'default_strategy', 'default_server',
|
||||
'disable_cache', 'disable_cache_expire',
|
||||
'client_subnet',
|
||||
'optimistic_cache', 'optimistic_timeout',
|
||||
'dns_timeout', 'cache_file_store_dns'
|
||||
]),
|
||||
servers: load_sections(uci, SECTION.dns_server),
|
||||
rules: load_sections(uci, SECTION.dns_rule)
|
||||
};
|
||||
}
|
||||
|
||||
function load_routing(uci) {
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.routing, [
|
||||
'default_outbound', 'default_outbound_dns',
|
||||
'domain_strategy', 'find_neighbor',
|
||||
'udp_timeout', 'tcpip_stack', 'endpoint_independent_nat'
|
||||
]),
|
||||
nodes: load_sections(uci, SECTION.routing_node),
|
||||
rules: load_sections(uci, SECTION.routing_rule),
|
||||
rulesets: load_sections(uci, SECTION.ruleset)
|
||||
};
|
||||
}
|
||||
|
||||
/* access_control is two single sections: `control` (lan_proxy_mode,
|
||||
* wan_proxy_*_ips) and `subscription` (auto_update, filter, urls). */
|
||||
function load_access_control(uci) {
|
||||
return {
|
||||
control: load_settings(uci, SECTION.control, [
|
||||
'bind_interface', 'lan_proxy_mode'
|
||||
]),
|
||||
/* wan_proxy_*_ips are list options on the control section; collect
|
||||
them in their canonical form. */
|
||||
wan_proxy_ipv4_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv4_ips') || [],
|
||||
wan_proxy_ipv6_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv6_ips') || [],
|
||||
subscription: load_settings(uci, SECTION.subscription, [
|
||||
'auto_update', 'allow_insecure',
|
||||
'packet_encoding', 'update_via_proxy',
|
||||
'filter_nodes', 'user_agent'
|
||||
]),
|
||||
subscription_urls: uci.get(UCICONFIG, SECTION.subscription, 'subscription_url') || [],
|
||||
filter_keywords: uci.get(UCICONFIG, SECTION.subscription, 'filter_keywords') || []
|
||||
};
|
||||
}
|
||||
|
||||
/* The two "read a table of canonical -> UCI names off a section"
|
||||
* helpers shared by the node and inbound paths. Same shape as
|
||||
* load_protocol_options() above; split out so the server side reads
|
||||
* the same way the client side does. */
|
||||
function load_table(get, mapping) {
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
out[canonical] = get(uci_name);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* A server inbound is now a
|
||||
* domain object like a Node, built here and shaped into sing-box JSON
|
||||
* by config/adapter.uc's InboundFactory. Before this the generator read
|
||||
* the flat UCI section directly (`cfg.snell_version`,
|
||||
* `cfg.shadowsocks_encrypt_method`, ...), so the server path had no
|
||||
* domain model at all.
|
||||
*
|
||||
* Same sub-object split as Node: common (listener-level), credentials,
|
||||
* tls (the shared shape the client uses too), tls_server (the
|
||||
* server-only tail), transport, multiplex, protocol_options. */
|
||||
function load_inbound(get, section) {
|
||||
const type = get('type');
|
||||
|
||||
return Inbound.create({
|
||||
id: section['.name'],
|
||||
name: get('label') || section['.name'],
|
||||
type: type,
|
||||
enabled: section.enabled === '1',
|
||||
|
||||
address: get('address'),
|
||||
port: get('port'),
|
||||
firewall: get('firewall'),
|
||||
|
||||
common: load_table(get, INBOUND_COMMON),
|
||||
credentials: load_table(get, INBOUND_CREDENTIALS[type] || {}),
|
||||
tls: load_tls(get),
|
||||
tls_server: load_table(get, INBOUND_TLS_SERVER),
|
||||
transport: load_transport(get),
|
||||
multiplex: load_multiplex(get),
|
||||
protocol_options: load_table(get, INBOUND_OPTIONS[type] || {})
|
||||
});
|
||||
}
|
||||
|
||||
/* server has one enabled/log_level single section + N inbound sections
|
||||
* (each with a `type` of vless / trojan / shadowsocks / ...). */
|
||||
function load_server(uci) {
|
||||
const inbounds = [];
|
||||
|
||||
uci.foreach(UCICONFIG, SECTION.server, (section) => {
|
||||
push(inbounds, load_inbound((name) => node_opt(uci, section['.name'], name), section));
|
||||
});
|
||||
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.server, [
|
||||
'enabled', 'log_level'
|
||||
]),
|
||||
inbounds
|
||||
};
|
||||
}
|
||||
|
||||
/* --- loader ------------------------------------------------------------- */
|
||||
|
||||
export const Loader = {
|
||||
/* Load the client side of the configuration. `dir` defaults to the
|
||||
* production UCI directory but can be overridden, which removes the need
|
||||
* for the test suite's `sed` of `const uci = cursor();`. */
|
||||
load: (dir) => {
|
||||
const uci = dir ? cursor(dir) : cursor();
|
||||
|
||||
uci.load(UCICONFIG);
|
||||
|
||||
const config = Config.create('homeproxy');
|
||||
|
||||
config.general = {
|
||||
routing_mode: opt(uci, 'main', 'routing_mode') || 'bypass_mainland_china',
|
||||
proxy_mode: opt(uci, 'main', 'proxy_mode') || 'redirect_tproxy',
|
||||
main_node: opt(uci, 'main', 'main_node') || 'nil',
|
||||
main_udp_node: opt(uci, 'main', 'main_udp_node') || 'nil',
|
||||
/* Booleans stay as raw UCI strings here, same as Node. The
|
||||
Adapter (A3) is the one that calls strToBool(); coercing in
|
||||
the Loader and again in the Adapter produced two different
|
||||
defaults for absent / garbage values during A1.1. */
|
||||
ipv6_support: opt(uci, 'main', 'ipv6_support'),
|
||||
|
||||
/* A2.1: scalar + list fields on the `config` section. */
|
||||
dns_server: opt(uci, 'main', 'dns_server'),
|
||||
china_dns_server: opt(uci, 'main', 'china_dns_server'),
|
||||
log_level: opt(uci, 'main', 'log_level') || 'warn',
|
||||
tun_dns_mode: opt(uci, 'main', 'tun_dns_mode'),
|
||||
tun_dns_address: opt(uci, 'main', 'tun_dns_address'),
|
||||
udp_mapping: opt(uci, 'main', 'udp_mapping'),
|
||||
udp_filtering: opt(uci, 'main', 'udp_filtering'),
|
||||
udp_nat_max: opt(uci, 'main', 'udp_nat_max'),
|
||||
cn_ip_fallback: opt(uci, 'main', 'cn_ip_fallback'),
|
||||
sniffer_advanced_mode: opt(uci, 'main', 'sniffer_advanced_mode'),
|
||||
main_urltest_nodes: opt(uci, 'main', 'main_urltest_nodes') || [],
|
||||
main_urltest_interval: opt(uci, 'main', 'main_urltest_interval'),
|
||||
main_urltest_tolerance: opt(uci, 'main', 'main_urltest_tolerance'),
|
||||
main_udp_urltest_nodes: opt(uci, 'main', 'main_udp_urltest_nodes') || [],
|
||||
main_udp_urltest_interval: opt(uci, 'main', 'main_udp_urltest_interval'),
|
||||
main_udp_urltest_tolerance: opt(uci, 'main', 'main_udp_urltest_tolerance')
|
||||
};
|
||||
|
||||
/* udp_timeout lives on two UCI sections: routing.udp_timeout
|
||||
* (custom mode) and infra.udp_timeout (everything else). Mirror
|
||||
* that split here so A2 can read either path without falling back
|
||||
* to uci.get(). */
|
||||
config.infra = load_settings(uci, SECTION.infra, [
|
||||
'common_port', 'mixed_port', 'redirect_port', 'tproxy_port',
|
||||
'dns_port', 'dns_redirect',
|
||||
'tun_name', 'tun_addr4', 'tun_addr6', 'tun_mtu',
|
||||
'table_mark', 'self_mark', 'tproxy_mark', 'tun_mark',
|
||||
'ntp_server', 'udp_timeout'
|
||||
]);
|
||||
|
||||
uci.foreach(UCICONFIG, SECTION.node, (section) => {
|
||||
const get = (name) => node_opt(uci, section['.name'], name);
|
||||
|
||||
push(config.nodes, Node.create({
|
||||
id: section['.name'],
|
||||
name: get('label') || section['.name'],
|
||||
type: get('type'),
|
||||
address: get('address'),
|
||||
port: get('port'),
|
||||
common: load_common(get),
|
||||
credentials: load_credentials(get, get('type')),
|
||||
tls: load_tls(get),
|
||||
transport: load_transport(get),
|
||||
multiplex: load_multiplex(get),
|
||||
protocol_options: load_protocol_options(get, get('type'))
|
||||
}));
|
||||
});
|
||||
|
||||
config.dns = load_dns(uci);
|
||||
config.routing = load_routing(uci);
|
||||
config.access_control = load_access_control(uci);
|
||||
config.server = load_server(uci);
|
||||
|
||||
return config;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* UCI -> Config Loader -> HomeProxyConfig / Node -> Adapter -> sing-box
|
||||
*
|
||||
* The point of this layer is that nothing below it may touch UCI, and nothing
|
||||
* in it may know about sing-box JSON. A Node therefore describes *what the
|
||||
* user configured*, not what sing-box wants: no `outbound`, no `type` field
|
||||
* named after a sing-box outbound kind, no `tag` arithmetic.
|
||||
*
|
||||
* Every field the Adapter used to read off
|
||||
* `node.raw.*` has been promoted to an explicit Node sub-object
|
||||
* (`credentials`, `tls`, `transport`, `multiplex`, `common`,
|
||||
* `protocol_options`), so the legacy `raw` opaque bag was dropped - no code
|
||||
* reads it and leaving it in only made the next reader assume it was
|
||||
* authoritative.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty } from '../homeproxy.uc';
|
||||
|
||||
/* Canonical credential multiplexing for a Node. The current generator picks
|
||||
* these apart with ternaries per field (`username` vs `user` vs `password` vs
|
||||
* `psk`, keyed on every protocol); expressing them as data is what makes a new
|
||||
* protocol a table entry instead of another branch.
|
||||
*
|
||||
* The value is the UCI option name (as the loader sees it on the flat UCI
|
||||
* section), the key is the canonical credential name. The adapter later
|
||||
* emits the field under whatever name the sing-box outbound wants
|
||||
* (e.g. snell: credentials.psk is emitted as the outbound `psk` field,
|
||||
* shadowsocks: credentials.method becomes outbound `method`).
|
||||
*
|
||||
* Fixed during Stage A1.1: snell `userkey` actually comes from the
|
||||
* `snell_userkey` UCI option (not `username`), and shadowsocks `method`
|
||||
* actually comes from `shadowsocks_encrypt_method` (not `method`).
|
||||
* The earlier table assumed both mapped to bare option names and failed
|
||||
* the skeleton test against the real fixture. */
|
||||
export const CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
socks: { username: 'username', password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
snell: { psk: 'password', userkey: 'snell_userkey' },
|
||||
/* The node form writes ssh_priv_key / ssh_priv_key_pp, not the bare
|
||||
* private_key this table used to claim, so the key the user pasted was
|
||||
* never read. (There was also no PROTOCOL_OPTIONS row for ssh, so the
|
||||
* rest of the SSH options were dropped as well.) */
|
||||
ssh: { user: 'username', password: 'password',
|
||||
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
|
||||
anytls: { password: 'password' },
|
||||
shadowtls: { password: 'password' },
|
||||
direct: {}
|
||||
};
|
||||
|
||||
/* Canonical credentials for a server Inbound. A different table from
|
||||
* the client's CREDENTIALS because the server form exposes a smaller
|
||||
* set (no ssh material, no snell userkey) and because snell's server
|
||||
* side reads the psk off the same `password` UCI option the client
|
||||
* node uses for its own psk. Keeping the two tables separate means a
|
||||
* change to one side cannot silently alter the other.
|
||||
*/
|
||||
export const INBOUND_CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'hysteria_auth_payload', auth_base64: 'hysteria_auth_payload' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
shadowtls: { password: 'password' },
|
||||
snell: { psk: 'password' },
|
||||
anytls: { password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
mixed: { username: 'username', password: 'password' },
|
||||
naive: { username: 'username', password: 'password' },
|
||||
socks: { username: 'username', password: 'password' }
|
||||
};
|
||||
|
||||
/* Per-protocol options on a server Inbound, canonical -> UCI. This is
|
||||
* the server-side counterpart of parser/mapping.uc's PROTOCOL_TO_UCI;
|
||||
* the two are NOT identical - the server side exposes inbound-only
|
||||
* knobs (anytls padding_scheme, hysteria masquerade / packet sizes /
|
||||
* ignore_client_bandwidth, tuic auth_timeout) and does not expose the
|
||||
* client's port-hopping options - so they stay separate tables. A
|
||||
* protocol whose server-side row is missing here produces no
|
||||
* protocol_options rather than reading a flat UCI key. */
|
||||
export const INBOUND_OPTIONS = {
|
||||
anytls: {
|
||||
padding_scheme: 'anytls_padding_scheme'
|
||||
},
|
||||
hysteria: {
|
||||
auth_type: 'hysteria_auth_type',
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
masquerade: 'hysteria_masquerade',
|
||||
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
|
||||
},
|
||||
hysteria2: {
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
obfs_type: 'hysteria_obfs_type',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
obfs_min_packet_size: 'hysteria_obfs_min_packet_size',
|
||||
obfs_max_packet_size: 'hysteria_obfs_max_packet_size',
|
||||
masquerade: 'hysteria_masquerade',
|
||||
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
|
||||
},
|
||||
shadowsocks: {
|
||||
method: 'shadowsocks_encrypt_method'
|
||||
},
|
||||
snell: {
|
||||
version: 'snell_version',
|
||||
obfs_mode: 'snell_obfs_mode'
|
||||
},
|
||||
tuic: {
|
||||
congestion_control: 'tuic_congestion_control',
|
||||
auth_timeout: 'tuic_auth_timeout',
|
||||
zero_rtt_handshake: 'tuic_enable_zero_rtt',
|
||||
heartbeat: 'tuic_heartbeat'
|
||||
},
|
||||
vless: {
|
||||
flow: 'vless_flow'
|
||||
},
|
||||
vmess: {
|
||||
alter_id: 'vmess_alterid'
|
||||
}
|
||||
};
|
||||
|
||||
/* Listener-level fields every server inbound carries. Inbound-only
|
||||
* (no client outbound emits them), so they are not part of the
|
||||
* client's load_common() table. */
|
||||
export const INBOUND_COMMON = {
|
||||
bind_interface: 'bind_interface',
|
||||
reuse_addr: 'reuse_addr',
|
||||
tcp_fast_open: 'tcp_fast_open',
|
||||
tcp_multi_path: 'tcp_multi_path',
|
||||
udp_fragment: 'udp_fragment',
|
||||
udp_timeout: 'udp_timeout',
|
||||
network: 'network'
|
||||
};
|
||||
|
||||
/* The server-only TLS tail. These options exist because a client
|
||||
* outbound never needs them (key material, ACME, the server half of
|
||||
* the REALITY handshake), so they live on the Inbound rather than
|
||||
* being folded into the shared `tls` sub-object the Loader builds for
|
||||
* both sides. Canonical -> UCI; the Adapter re-keys them back to the
|
||||
* UCI names buildTLSObject() reads, which is the one place the flat
|
||||
* names still exist. */
|
||||
export const INBOUND_TLS_SERVER = {
|
||||
key_path: 'tls_key_path',
|
||||
ech_key: 'tls_ech_key',
|
||||
reality_private_key: 'tls_reality_private_key',
|
||||
reality_max_time_difference: 'tls_reality_max_time_difference',
|
||||
reality_server_addr: 'tls_reality_server_addr',
|
||||
reality_server_port: 'tls_reality_server_port',
|
||||
acme: 'tls_acme',
|
||||
acme_domain: 'tls_acme_domain',
|
||||
acme_dsn: 'tls_acme_dsn',
|
||||
acme_email: 'tls_acme_email',
|
||||
acme_provider: 'tls_acme_provider',
|
||||
acme_account_key: 'tls_acme_account_key',
|
||||
acme_key_type: 'tls_acme_key_type',
|
||||
acme_profile: 'tls_acme_profile',
|
||||
acme_disable_http_challenge: 'tls_acme_dhc',
|
||||
acme_disable_tls_alpn_challenge: 'tls_acme_dtac',
|
||||
acme_alternative_http_port: 'tls_acme_ahp',
|
||||
acme_alternative_tls_port: 'tls_acme_atp',
|
||||
acme_external_account: 'tls_acme_external_account',
|
||||
acme_external_account_key_id: 'tls_acme_ea_keyid',
|
||||
acme_external_account_mac_key: 'tls_acme_ea_mackey',
|
||||
dns01_challenge: 'tls_dns01_challenge',
|
||||
dns01_provider: 'tls_dns01_provider',
|
||||
dns01_access_key_id: 'tls_dns01_ali_akid',
|
||||
dns01_access_key_secret: 'tls_dns01_ali_aksec',
|
||||
dns01_region_id: 'tls_dns01_ali_rid',
|
||||
dns01_api_token: 'tls_dns01_cf_api_token'
|
||||
};
|
||||
|
||||
/* --- Domain types ------------------------------------------------------- */
|
||||
|
||||
export const Config = {
|
||||
create: (name) => ({
|
||||
name: name,
|
||||
general: {},
|
||||
nodes: [],
|
||||
|
||||
/* The five domain sub-objects the Loader fills in from the
|
||||
* corresponding UCI sections (single + list). The Loader now
|
||||
* normalises the list sections; downstream generators read only
|
||||
* these sub-objects. */
|
||||
dns: {},
|
||||
routing: {},
|
||||
access_control: {},
|
||||
server: {}
|
||||
})
|
||||
};
|
||||
|
||||
export const Node = {
|
||||
create: (opts) => ({
|
||||
/* identity */
|
||||
id: opts.id,
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
|
||||
/* endpoint */
|
||||
address: opts.address,
|
||||
port: opts.port,
|
||||
|
||||
/* cross-protocol common fields (proxy_protocol, tcp_fast_open,
|
||||
* tcp_multi_path, udp_fragment). The Adapter reads from here so
|
||||
* it never has to reach back into UCI for these. */
|
||||
common: opts.common || {},
|
||||
|
||||
/* credentials, already canonical */
|
||||
credentials: opts.credentials || {},
|
||||
|
||||
/* transport/TLS are sub-objects, not flat prefixed keys: that is the
|
||||
boundary the adapter needs in order to reuse the shared builders. */
|
||||
tls: opts.tls || {},
|
||||
transport: opts.transport || {},
|
||||
multiplex: opts.multiplex || {},
|
||||
|
||||
/* per-protocol extras, canonical names only */
|
||||
protocol_options: opts.protocol_options || {}
|
||||
}),
|
||||
|
||||
/* A node is usable only if the adapter can build a valid outbound from it.
|
||||
* The old code discovers this by crashing inside the JSON builder.
|
||||
*
|
||||
* Scope: generic, cross-protocol sanity only. The Adapter layer owns
|
||||
* per-protocol required-field rules (a vless needs uuid, vmess needs
|
||||
* both uuid and a security cipher, snell needs a psk, ...) because
|
||||
* those rules are sing-box-outbound-shaped. Anything the model can
|
||||
* express without naming a protocol or a JSON field goes here.
|
||||
*
|
||||
* Booleans are still raw UCI strings at this layer; this validator
|
||||
* compares against the literal '1' the way UCI emits them.
|
||||
*
|
||||
* Implementation note: ucode's push() returns the value pushed, not
|
||||
* the new array, so the natural-looking `problems = push(problems, X)`
|
||||
* pattern reassigns problems to a string and silently corrupts the
|
||||
* accumulator. Build the array via spread instead - cheap because the
|
||||
* validator is at most four checks. */
|
||||
validate: (node) => {
|
||||
let problems = [];
|
||||
|
||||
if (!node.type)
|
||||
problems = [...problems, 'missing type'];
|
||||
|
||||
/* A direct outbound can run without a server/server_port - sing-box
|
||||
* uses the inbound's destination as the upstream, which is the
|
||||
* transparent-proxy case. For every other protocol the server
|
||||
* and port are mandatory. */
|
||||
if (node.type !== 'direct') {
|
||||
if (!node.address)
|
||||
problems = [...problems, 'missing address'];
|
||||
|
||||
if (node.port == null || int(node.port) < 1 || int(node.port) > 65535)
|
||||
problems = [...problems, `invalid port '${node.port}'`];
|
||||
}
|
||||
|
||||
/* TLS without server_name is a sing-box-side rejection, but the
|
||||
* rule itself is protocol-agnostic - the reality branch covers the
|
||||
* only legitimate escape - so it stays here. */
|
||||
if (node.tls.enabled === '1' && !node.tls.server_name && node.tls.reality.enabled !== '1')
|
||||
problems = [...problems, 'TLS enabled without server_name'];
|
||||
|
||||
return problems;
|
||||
},
|
||||
|
||||
/* sing-box tag convention lives here, not scattered through the generator */
|
||||
tag: (node) => 'cfg-' + node.id + '-out'
|
||||
};
|
||||
|
||||
/* The server-side counterpart of Node. Same layering discipline: it
|
||||
* describes what the user configured (listen address/port, protocol,
|
||||
* credentials, TLS, transport, per-protocol knobs) and knows nothing
|
||||
* about sing-box JSON. The Inbound adapter (config/adapter.uc,
|
||||
* InboundFactory) is what turns it into a sing-box inbound.
|
||||
*
|
||||
* Field grouping mirrors Node deliberately so the Adapter's two halves
|
||||
* read the same way:
|
||||
* common listener-level shared fields (bind_interface,
|
||||
* reuse_addr, tcp_fast_open, ...)
|
||||
* credentials canonical credential names
|
||||
* tls shared TLS shape (same load_tls() the client uses)
|
||||
* tls_server server-only TLS tail (key material, ACME, the
|
||||
* server half of the REALITY handshake)
|
||||
* transport shared transport shape (same load_transport())
|
||||
* multiplex shared multiplex shape
|
||||
* protocol_options per-protocol knobs
|
||||
*/
|
||||
export const Inbound = {
|
||||
create: (opts) => ({
|
||||
/* identity */
|
||||
id: opts.id,
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
enabled: opts.enabled,
|
||||
|
||||
/* listener */
|
||||
address: opts.address,
|
||||
port: opts.port,
|
||||
|
||||
/* firewall is consumed by firewall_pre.uc (which decides the
|
||||
* nft accept rules), not by the inbound adapter. It stays on the
|
||||
* model so the server section is described in one place. */
|
||||
firewall: opts.firewall,
|
||||
|
||||
common: opts.common || {},
|
||||
credentials: opts.credentials || {},
|
||||
tls: opts.tls || {},
|
||||
tls_server: opts.tls_server || {},
|
||||
transport: opts.transport || {},
|
||||
multiplex: opts.multiplex || {},
|
||||
|
||||
protocol_options: opts.protocol_options || {}
|
||||
}),
|
||||
|
||||
/* Same scope as Node.validate(): cross-protocol sanity the model
|
||||
* can express without naming a sing-box field. The per-protocol
|
||||
* rules live in the Adapter (InboundFactory.problems). */
|
||||
validate: (inbound) => {
|
||||
let problems = [];
|
||||
|
||||
if (!inbound.type)
|
||||
problems = [...problems, 'missing type'];
|
||||
|
||||
/* An inbound with no listen address is legitimate (sing-box
|
||||
* defaults to all interfaces), but a port is required. */
|
||||
if (inbound.port == null || int(inbound.port) < 1 || int(inbound.port) > 65535)
|
||||
problems = [...problems, `invalid port '${inbound.port}'`];
|
||||
|
||||
return problems;
|
||||
},
|
||||
|
||||
/* sing-box tag convention for a server inbound. Uses `id` (the UCI
|
||||
* section name) rather than `name` (the user's label) so renaming a
|
||||
* label does not change the tag. */
|
||||
tag: (inbound) => 'cfg-' + inbound.id + '-in'
|
||||
};
|
||||
|
||||
/* --- Config helpers ----------------------------------------------------- */
|
||||
|
||||
export const ConfigQuery = {
|
||||
node_by_id: (config, id) => {
|
||||
for (let node in config.nodes)
|
||||
if (node.id === id)
|
||||
return node;
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/* Linear search by section name. The Loader normalises list sections so
|
||||
* `name` is the UCI section name; the dotted `.name` UCI uses
|
||||
* internally is gone from the loaded shape. */
|
||||
find_by_name: (items, name) => {
|
||||
for (let it in items)
|
||||
if (it.name === name)
|
||||
return it;
|
||||
return null;
|
||||
},
|
||||
|
||||
/* Which UDP node the routing modes route through. The default (`nil`)
|
||||
* means "no proxy node", which is a legitimate configuration. The
|
||||
* equivalent main_node read is one-liner enough that no helper
|
||||
* exists. */
|
||||
main_udp_node_id: (config) => config.general.main_udp_node || 'nil'
|
||||
};
|
||||
@@ -0,0 +1,762 @@
|
||||
#!/usr/bin/utpl -S
|
||||
|
||||
{%-
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*
|
||||
* Declares every homeproxy_* fw4 chain/set. The inventory of those objects is
|
||||
* tracked in scripts/fw4_names.sh, which init.d/homeproxy uses to clean them up
|
||||
* on stop; add/remove entries there together with any change below.
|
||||
* tests/ucode/test_fw4_names.sh compares both lists, and
|
||||
* tests/ucode/test_firewall_template.sh renders this template and checks the
|
||||
* objects actually survive into the generated nftables script.
|
||||
*
|
||||
* Every UCI-derived value (IPv4, MAC, interface name, port) goes through
|
||||
* one of the *_to_nftarr() helpers in firewall_utils.uc - not because the
|
||||
* UCI writers are untrusted (UI validation is there), but because UCI is
|
||||
* writable from anywhere on the LAN and the package principle is "backend
|
||||
* must enforce". Why those helpers exist, and why dropping silently
|
||||
* matches the IPv6 path, is pinned by tests/ucode/test_firewall_validators.uc
|
||||
* and guard 11 in tests/arch-guard.sh.
|
||||
*
|
||||
* Keep this comment inside the ucode block: text placed before `{%-` has its
|
||||
* trailing newline trimmed, which would glue it onto the first nft statement.
|
||||
*/
|
||||
import { readfile } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { isEmpty, isValidCIDR } from '/etc/homeproxy/scripts/homeproxy.uc';
|
||||
import {
|
||||
ipv4_to_nftarr,
|
||||
mac_to_nftarr,
|
||||
iface_to_nftarr,
|
||||
ports_to_nftarr,
|
||||
isValidPort,
|
||||
isValidIface
|
||||
} from '/etc/homeproxy/scripts/firewall_utils.uc';
|
||||
|
||||
const fw4 = require('fw4');
|
||||
|
||||
function array_to_nftarr(array) {
|
||||
if (type(array) !== 'array')
|
||||
return null;
|
||||
|
||||
return `{ ${join(', ', uniq(array))} }`;
|
||||
}
|
||||
|
||||
function resolve_ipv6(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
let ipv6 = fw4.parse_subnet(str)?.[0];
|
||||
if (!ipv6 || ipv6.family !== 6)
|
||||
return null;
|
||||
|
||||
if (ipv6.bits > -1)
|
||||
return `${ipv6.addr}/${ipv6.bits}`;
|
||||
else
|
||||
return ipv6.addr;
|
||||
}
|
||||
|
||||
/* Normalize a list of IPv6 entries into an nftables anonymous set
|
||||
(or null when empty), so IPv6 ACLs match in a single set lookup
|
||||
instead of one rule per address. */
|
||||
function ipv6_to_nftarr(list) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let ip in list) {
|
||||
const r = resolve_ipv6(ip);
|
||||
if (!isEmpty(r))
|
||||
push(out, r);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', out)} }`;
|
||||
}
|
||||
|
||||
function resolve_mark(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
let mark = fw4.parse_mark(str);
|
||||
if (isEmpty(mark))
|
||||
return null;
|
||||
|
||||
if (mark.mask === 0xffffffff)
|
||||
return fw4.hex(mark.mark);
|
||||
else if (mark.mark === 0)
|
||||
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)}`;
|
||||
else if (mark.mark === mark.mask)
|
||||
return `mark or ${fw4.hex(mark.mark)}`;
|
||||
else if (mark.mask === 0)
|
||||
return `mark xor ${fw4.hex(mark.mark)}`;
|
||||
else
|
||||
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)} xor ${fw4.hex(mark.mark)}`;
|
||||
}
|
||||
|
||||
/* Single-value guards for the five fields that were still read straight from
|
||||
* UCI and interpolated into the nft text: self_mark, redirect_port,
|
||||
* tproxy_port, dns_port and tun_name. H1 fixed the *_to_nftarr() families but
|
||||
* missed these; a ';' in one of them is a statement boundary in nft, and a
|
||||
* non-numeric value makes fw4 reject the whole ruleset, which takes the
|
||||
* router's firewall down with it. A rejected value falls back to the built-in
|
||||
* default rather than being emitted, so the side keeps working. */
|
||||
function port_or(str, fallback) {
|
||||
return isValidPort(str) ? str : fallback;
|
||||
}
|
||||
|
||||
function iface_or(str, fallback) {
|
||||
return isValidIface(str) ? str : fallback;
|
||||
}
|
||||
|
||||
/* self_mark is used as a *match* value (`meta mark <x>`), so it has to be a
|
||||
* plain number. resolve_mark() also returns set-expressions such as
|
||||
* `mark and 0xff xor 0x100` for masked input, and those are only valid after
|
||||
* `mark set`; anything that is not a bare hex value falls back. */
|
||||
function mark_value_or(str, fallback) {
|
||||
const m = resolve_mark(str);
|
||||
return match(m || '', /^0x[0-9a-fA-F]+$/) ? m : fallback;
|
||||
}
|
||||
|
||||
/* gfwlist mode routes by the address set that dnsmasq fills through
|
||||
* `nftset=`, and the gates below compare the destination against it. A dnsmasq
|
||||
* built without HAVE_NFTSET never fills the set, and `ip daddr != @<empty set>`
|
||||
* is true for every packet - the whole mode then silently routes everything
|
||||
* direct while the DNS side keeps answering through sing-box. init.d records
|
||||
* the capability in resources/.dnsmasq-nftset before this template is rendered,
|
||||
* so the gates can be omitted there and sing-box decides by geoip instead.
|
||||
* A missing file means "capability unknown" and keeps the historical output. */
|
||||
function dnsmasq_nftset() {
|
||||
let marker = null;
|
||||
try {
|
||||
marker = trim(readfile(resources_dir + '/.dnsmasq-nftset'));
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
return marker === '' || marker === '1';
|
||||
}
|
||||
|
||||
/* Misc config */
|
||||
const resources_dir = '/etc/homeproxy/resources';
|
||||
|
||||
/* UCI config start */
|
||||
const cfgname = 'homeproxy';
|
||||
const uci = cursor();
|
||||
uci.load(cfgname);
|
||||
|
||||
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
|
||||
let outbound_node, outbound_udp_node, china_dns_server, bypass_cn_traffic;
|
||||
|
||||
if (routing_mode !== 'custom') {
|
||||
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
|
||||
china_dns_server = uci.get(cfgname, 'config', 'china_dns_server');
|
||||
} else {
|
||||
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
|
||||
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
|
||||
}
|
||||
|
||||
let routing_port = uci.get(cfgname, 'config', 'routing_port');
|
||||
if (routing_port === 'common')
|
||||
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
|
||||
|
||||
const proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy',
|
||||
ipv6_support = uci.get(cfgname, 'config', 'ipv6_support') || '0';
|
||||
|
||||
let self_mark, redirect_port,
|
||||
tproxy_port, tproxy_mark,
|
||||
tun_name, tun_mark;
|
||||
|
||||
if (match(proxy_mode, /redirect/)) {
|
||||
self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100'));
|
||||
redirect_port = port_or(uci.get(cfgname, 'infra', 'redirect_port'), '5331');
|
||||
}
|
||||
if (match(proxy_mode, /tproxy/))
|
||||
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
|
||||
tproxy_port = port_or(uci.get(cfgname, 'infra', 'tproxy_port'), '5332');
|
||||
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101') || resolve_mark('101');
|
||||
}
|
||||
if (match(proxy_mode, /tun/)) {
|
||||
tun_name = iface_or(uci.get(cfgname, 'infra', 'tun_name'), 'singtun0');
|
||||
tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102') || resolve_mark('102');
|
||||
}
|
||||
|
||||
const control_options = [
|
||||
"listen_interfaces", "lan_proxy_mode",
|
||||
"lan_direct_mac_addrs", "lan_direct_ipv4_ips", "lan_direct_ipv6_ips",
|
||||
"lan_proxy_mac_addrs", "lan_proxy_ipv4_ips", "lan_proxy_ipv6_ips",
|
||||
"lan_gaming_mode_mac_addrs", "lan_gaming_mode_ipv4_ips", "lan_gaming_mode_ipv6_ips",
|
||||
"lan_global_proxy_mac_addrs", "lan_global_proxy_ipv4_ips", "lan_global_proxy_ipv6_ips",
|
||||
"wan_proxy_ipv4_ips", "wan_proxy_ipv6_ips",
|
||||
"wan_direct_ipv4_ips", "wan_direct_ipv6_ips"
|
||||
];
|
||||
const control_info = {};
|
||||
|
||||
for (let i in control_options)
|
||||
control_info[i] = uci.get(cfgname, 'control', i);
|
||||
|
||||
const dns_redirect = uci.get(cfgname, 'infra', 'dns_redirect') || '1';
|
||||
const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0',
|
||||
/* dnsmasq's own port - NOT the sing-box DNS inbound port (infra.dns_port,
|
||||
* 5333). Both were called `dns_port` here and in the generator, which
|
||||
* made the hijack rule look like it pointed at the sing-box listener. */
|
||||
dnsmasq_port = port_or(uci.get('dhcp', '@dnsmasq[0]', 'port'), '53');
|
||||
/* UCI config end */
|
||||
-%}
|
||||
|
||||
{# Reserved addresses -#}
|
||||
set homeproxy_local_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
0.0.0.0/8,
|
||||
10.0.0.0/8,
|
||||
100.64.0.0/10,
|
||||
127.0.0.0/8,
|
||||
169.254.0.0/16,
|
||||
172.16.0.0/12,
|
||||
192.0.0.0/24,
|
||||
192.0.2.0/24,
|
||||
192.31.196.0/24,
|
||||
192.52.193.0/24,
|
||||
192.88.99.0/24,
|
||||
192.168.0.0/16,
|
||||
192.175.48.0/24,
|
||||
198.18.0.0/15,
|
||||
198.51.100.0/24,
|
||||
203.0.113.0/24,
|
||||
224.0.0.0/4,
|
||||
240.0.0.0/4
|
||||
}
|
||||
}
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_local_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
::/128,
|
||||
::1/128,
|
||||
::ffff:0:0/96,
|
||||
100::/64,
|
||||
64:ff9b::/96,
|
||||
2001::/32,
|
||||
2001:10::/28,
|
||||
2001:20::/28,
|
||||
2001:db8::/28,
|
||||
2002::/16,
|
||||
fc00::/7,
|
||||
fe80::/10,
|
||||
ff00::/8
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist'): %}
|
||||
set homeproxy_gfw_list_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
}
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_gfw_list_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
}
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (match(routing_mode, /mainland_china/) || bypass_cn_traffic === '1'): %}
|
||||
set homeproxy_mainland_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
{% for (let cnip4 in split(trim(readfile(resources_dir + '/china_ip4.txt')), /[\r\n]/)): %}
|
||||
{% if (isValidCIDR(cnip4, 4)): %}
|
||||
{{ cnip4 }},
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% if ((ipv6_support === '1') || china_dns_server): %}
|
||||
set homeproxy_mainland_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
{% for (let cnip6 in split(trim(readfile(resources_dir + '/china_ip6.txt')), /[\r\n]/)): %}
|
||||
{% if (isValidCIDR(cnip6, 6)): %}
|
||||
{{ cnip6 }},
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{# WAN ACL addresses #}
|
||||
set homeproxy_wan_proxy_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_proxy_ipv4_ips): %}
|
||||
elements = { {{ ipv4_to_nftarr(control_info.wan_proxy_ipv4_ips) }} }
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_wan_proxy_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_proxy_ipv6_ips): %}
|
||||
elements = { {{ ipv6_to_nftarr(control_info.wan_proxy_ipv6_ips) }} }
|
||||
{% endif /* wan_proxy_ipv6_ips*/ %}
|
||||
}
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
set homeproxy_wan_direct_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_direct_ipv4_ips): %}
|
||||
elements = { {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} }
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_wan_direct_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_direct_ipv6_ips): %}
|
||||
elements = { {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} }
|
||||
{% endif /* wan_direct_ipv6_ips */ %}
|
||||
}
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
{% if (routing_port): %}
|
||||
set homeproxy_routing_port {
|
||||
type inet_service
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = { {{ ports_to_nftarr(routing_port) }} }
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# DNS hijack & TCP redirect #}
|
||||
chain dstnat {
|
||||
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname {{ iface_to_nftarr(control_info.listen_interfaces) }}
|
||||
{%- endif /* listen_interfaces */ %}
|
||||
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dnsmasq_port }} comment "!{{ cfgname }}: DNS hijack"
|
||||
{% endif /* dns_hijacked */ %}
|
||||
{% if (match(proxy_mode, /redirect/)): %}
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac
|
||||
{% endif /* proxy_mode */ %}
|
||||
}
|
||||
|
||||
{# TCP redirect #}
|
||||
{% if (match(proxy_mode, /redirect/)): %}
|
||||
chain homeproxy_redirect_proxy {
|
||||
meta l4proto tcp counter redirect to :{{ redirect_port }}
|
||||
}
|
||||
|
||||
chain homeproxy_redirect_proxy_port {
|
||||
{% if (routing_port): %}
|
||||
tcp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
goto homeproxy_redirect_proxy
|
||||
}
|
||||
|
||||
chain homeproxy_redirect_lanac {
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return
|
||||
{% endif %}
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
|
||||
counter goto homeproxy_redirect
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain homeproxy_redirect {
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_local_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_local_addr_v6 counter return
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
ip daddr @homeproxy_wan_direct_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_redirect_proxy_port
|
||||
}
|
||||
|
||||
chain homeproxy_output_redir {
|
||||
type nat hook output priority filter -105; policy accept
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# UDP tproxy #}
|
||||
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom')): %}
|
||||
chain homeproxy_mangle_tproxy {
|
||||
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept
|
||||
{% if (ipv6_support === '1'): %}
|
||||
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip6 to [::1]:{{ tproxy_port }} counter accept
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_tproxy_port {
|
||||
{% if (routing_port): %}
|
||||
udp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
goto homeproxy_mangle_tproxy
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_mark {
|
||||
{% if (routing_port): %}
|
||||
udp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
meta l4proto udp meta mark set {{ tproxy_mark }} counter accept
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_lanac {
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr([...control_info.listen_interfaces, 'lo']) }} counter return
|
||||
{% endif %}
|
||||
meta iifname != lo udp dport 53 counter return
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
|
||||
counter goto homeproxy_mangle_prerouting
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_prerouting {
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_local_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_local_addr_v6 counter return
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
ip daddr @homeproxy_wan_direct_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* routing_mode */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_mangle_tproxy_port
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_output {
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_local_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_local_addr_v6 counter return
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_wan_direct_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
counter goto homeproxy_mangle_mark
|
||||
}
|
||||
|
||||
chain mangle_prerouting {
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_lanac
|
||||
}
|
||||
|
||||
chain mangle_output {
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_output
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# TUN #}
|
||||
{% if (match(proxy_mode, /tun/)): %}
|
||||
chain homeproxy_mangle_lanac {
|
||||
iifname {{ tun_name }} counter return
|
||||
udp dport 53 counter return
|
||||
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return
|
||||
{% endif %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
|
||||
counter goto homeproxy_mangle_tun
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_tun_mark {
|
||||
{% if (routing_port): %}
|
||||
{% if (proxy_mode === 'tun'): %}
|
||||
tcp dport != @homeproxy_routing_port counter return
|
||||
{% endif /* proxy_mode */ %}
|
||||
udp dport != @homeproxy_routing_port counter return
|
||||
{% endif /* routing_port */ %}
|
||||
|
||||
counter meta mark set {{ tun_mark }}
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_tun {
|
||||
iifname {{ tun_name }} counter return
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tun_mark
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tun_mark
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_local_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_local_addr_v6 counter return
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (control_info.wan_direct_ipv4_ips): %}
|
||||
ip daddr {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} counter return
|
||||
{% endif /* wan_direct_ipv4_ips */ %}
|
||||
{% if (control_info.wan_direct_ipv6_ips): %}
|
||||
ip6 daddr {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} counter return
|
||||
{% endif /* wan_direct_ipv6_ips */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* routing_mode */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_mangle_tun_mark
|
||||
}
|
||||
|
||||
chain mangle_prerouting {
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_lanac
|
||||
}
|
||||
|
||||
chain mangle_output {
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_tun
|
||||
}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/ucode
|
||||
/* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { writefile } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { isEmpty, RUN_DIR, validation } from './homeproxy.uc';
|
||||
|
||||
const cfgname = 'homeproxy';
|
||||
const uci = cursor();
|
||||
uci.load(cfgname);
|
||||
|
||||
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china',
|
||||
proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy';
|
||||
|
||||
let outbound_node, tun_name;
|
||||
if (match(proxy_mode, /tun/)) {
|
||||
if (routing_mode === 'custom')
|
||||
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
|
||||
else
|
||||
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||
|
||||
/* Validate the interface name before it is interpolated into an nft
|
||||
* ruleset that fw4 loads as root. It is a UCI value, and this file's own
|
||||
* threat model treats UCI as untrusted - the port and network fields a few
|
||||
* lines below are validated for exactly that reason, while tun_name was
|
||||
* not. `;` and `}` need no newline, so a value like
|
||||
* "singtun0 } ; chain evil { ..." would balance the template's trailing
|
||||
* brace and add a chain of the attacker's choosing.
|
||||
*
|
||||
* Linux interface names are at most 15 characters and cannot contain
|
||||
* whitespace or shell/nft metacharacters; anything outside that is not a
|
||||
* name we could have created, so the tun rules are simply not emitted. */
|
||||
if (outbound_node !== 'nil') {
|
||||
const candidate = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
|
||||
|
||||
if (match(candidate, /^[A-Za-z0-9_.-]{1,15}$/) && candidate !== '.' && candidate !== '..')
|
||||
tun_name = candidate;
|
||||
else
|
||||
print(sprintf('WARN: ignoring invalid tun_name "%s" (not a valid interface name).', candidate));
|
||||
}
|
||||
}
|
||||
|
||||
const server_enabled = uci.get(cfgname, 'server', 'enabled');
|
||||
|
||||
let forward = [],
|
||||
input = [];
|
||||
|
||||
if (tun_name) {
|
||||
push(forward, `oifname ${tun_name} counter accept comment "!${cfgname}: accept tun forward"`);
|
||||
push(input ,`iifname ${tun_name} counter accept comment "!${cfgname}: accept tun input"`);
|
||||
}
|
||||
|
||||
if (server_enabled === '1') {
|
||||
uci.foreach(cfgname, 'server', (s) => {
|
||||
if (s.enabled !== '1' || s.firewall !== '1')
|
||||
return;
|
||||
|
||||
/* The section name is interpolated into the nft comment below, in a
|
||||
* file fw4 loads as root. libuci only accepts [A-Za-z0-9_] in a
|
||||
* section name (a `"`, `}`, `;`, space, `.` or `-` is "invalid
|
||||
* character in name field" / "Invalid argument" on both the parser
|
||||
* and the `uci set` path), so today no name that could close the
|
||||
* comment can reach this loop through UCI at all - this mirrors the
|
||||
* tun_name guard above rather than trusting a writer's in-memory
|
||||
* cursor or a future libuci. The alphabet is exactly libuci's and
|
||||
* there is deliberately no length cap (libuci has none, and a cap
|
||||
* would skip a legal name and silently drop that server's rule).
|
||||
* The check runs first so the two warnings below only ever echo a
|
||||
* name that already passed it. */
|
||||
if (!match(s['.name'], /^[A-Za-z0-9_]+$/)) {
|
||||
print(`WARN: skipping server ${s['.name']}: invalid section name.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validation('port', s.port)) {
|
||||
print(`WARN: skipping server ${s['.name']}: invalid port "${s.port}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
let proto = s.network || '{ tcp, udp }';
|
||||
if (!(proto in ['tcp', 'udp', '{ tcp, udp }'])) {
|
||||
print(`WARN: skipping server ${s['.name']}: invalid network "${proto}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
push(input, `meta l4proto ${proto} th dport ${s.port} counter accept comment "!${cfgname}: accept server ${s['.name']}"`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEmpty(forward))
|
||||
writefile(RUN_DIR + '/fw4_forward.nft', join('\n', forward) + '\n');
|
||||
|
||||
if (!isEmpty(input))
|
||||
writefile(RUN_DIR + '/fw4_input.nft', join('\n', input) + '\n');
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Field validators for the nftables statements firewall_post.ut renders.
|
||||
*
|
||||
* Why this module exists: every UCI value the template concatenates into an
|
||||
* nft expression is an injection surface. A malformed entry closes a set
|
||||
* expression with `}`, and the next thing nft sees is whatever the attacker
|
||||
* put after it - up to and including `accept` for a chain that should be a
|
||||
* drop, or a `reject` outside any chain that the fw4 reload fails to
|
||||
* compile, taking the whole router firewall with it. The homeproxy section
|
||||
* in /etc/config/homeproxy is UCI-writable from the subscription updater,
|
||||
* from any shell on the LAN and from any cron job the user has not locked
|
||||
* down; `tests/ucode/test_firewall_validators.uc` covers that the helpers
|
||||
* drop these inputs rather than letting them through, and `tests/arch-guard.sh`
|
||||
* guard 11 covers that firewall_post.ut routes every field of these families
|
||||
* through the helpers.
|
||||
*
|
||||
* Each helper returns a defensive shape:
|
||||
* - null when the input was empty or every entry was rejected
|
||||
* - otherwise `{ v1, v2, ... }` (the nftables anonymous-set spelling)
|
||||
* so the template can plug the result into a statement unchanged. Invalid
|
||||
* entries are dropped, matching the IPv6 helper's behaviour so the IPv4
|
||||
* path is finally as defensive as the IPv6 path was.
|
||||
*
|
||||
* No logging on the drop path: utpl templates render to stdout, and any
|
||||
* print() here would inject text into the nft ruleset. If a debug trail
|
||||
* is ever wanted, do it from the template itself, not from these helpers.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* isEmpty() lives in homeproxy.uc, not in the ucode globals. Without this
|
||||
* import every helper below threw "access to undeclared variable isEmpty" the
|
||||
* moment it was called, which no host-side test caught because the test
|
||||
* harness stages its own stand-in module. */
|
||||
import { isEmpty } from './homeproxy.uc';
|
||||
|
||||
/* IPv4 (with optional /CIDR) - the regex is the standard four-octet body
|
||||
* followed by an optional /0..32. Anything outside that - "1.2.3.4.5",
|
||||
* "1.2.3", " 1.2.3.4 ", "1.2.3.4 " - is rejected.
|
||||
*
|
||||
* Spelled out as one literal rather than built with `new RegExp(...)`: ucode
|
||||
* has no `new` keyword, so the constructor form was a compile error
|
||||
* ("Unexpected token") and the module never loaded. The capture groups are
|
||||
* plain `(...)` because ucode regexes go through POSIX regcomp(), which has
|
||||
* no `(?:...)` non-capturing group; nothing here reads the captures, so the
|
||||
* extra ones are harmless. */
|
||||
const IPV4_CIDR_RE = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/(3[0-2]|[12]?[0-9]))?$/;
|
||||
|
||||
export function isValidIPv4(s) {
|
||||
if (!s || type(s) !== 'string')
|
||||
return false;
|
||||
return !!match(s, IPV4_CIDR_RE);
|
||||
};
|
||||
|
||||
export function ipv4_to_nftarr(list) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let ip in list) {
|
||||
if (isValidIPv4(ip))
|
||||
push(out, ip);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||
};
|
||||
|
||||
/* MAC address: six colon-separated hex octets, case-insensitive. */
|
||||
const MAC_RE = /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/;
|
||||
|
||||
export function isValidMAC(s) {
|
||||
if (!s || type(s) !== 'string')
|
||||
return false;
|
||||
return !!match(s, MAC_RE);
|
||||
};
|
||||
|
||||
export function mac_to_nftarr(list) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let m in list) {
|
||||
if (isValidMAC(m))
|
||||
push(out, m);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||
};
|
||||
|
||||
/* Linux interface names: alnum, dot, underscore, hyphen. Anything that
|
||||
* would survive `ip link show` parsing is accepted here. */
|
||||
const IFACE_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
export function isValidIface(s) {
|
||||
if (!s || type(s) !== 'string')
|
||||
return false;
|
||||
return !!match(s, IFACE_RE);
|
||||
};
|
||||
|
||||
export function iface_to_nftarr(list) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let i in list) {
|
||||
if (isValidIface(i))
|
||||
push(out, i);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||
};
|
||||
|
||||
/* Routing-port validator. UCI delivers routing_port as a comma-separated
|
||||
* string ('22,53,80,443,...'), so we split first and then validate each
|
||||
* piece as a single port. The old template did `split(routing_port, ',')`
|
||||
* and `join(', ', ...)` directly: an entry like '7890; reject' or '22,foo'
|
||||
* would land verbatim in an nft inet_service set. */
|
||||
const PORT_RE = /^[0-9]+$/;
|
||||
|
||||
export function isValidPort(s) {
|
||||
if (!s || type(s) !== 'string')
|
||||
return false;
|
||||
if (!match(s, PORT_RE))
|
||||
return false;
|
||||
const n = +s;
|
||||
/* Port 0 is not a port: nft rejects `th dport 0` and `redirect to :0`, and a
|
||||
* ruleset that carries one is rolled back as a whole by fw4. The callers
|
||||
* use this as "the value is usable", so 0 has to be a rejection. */
|
||||
return n >= 1 && n <= 65535;
|
||||
};
|
||||
|
||||
export function ports_to_nftarr(s) {
|
||||
if (isEmpty(s) || type(s) !== 'string')
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let p in split(s, ',')) {
|
||||
const t = trim(p);
|
||||
if (isValidPort(t))
|
||||
push(out, t);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Single source of truth for every fw4 chain and set owned by homeproxy.
|
||||
#
|
||||
# * root/etc/homeproxy/scripts/firewall_post.ut declares these objects and
|
||||
# decides which of them actually exist for the current proxy mode.
|
||||
# * root/etc/init.d/homeproxy flushes and deletes them on stop.
|
||||
#
|
||||
# When you add or remove a chain/set in firewall_post.ut, update this list in
|
||||
# the same commit -- tests/ucode/test_fw4_names.sh compares both sides and
|
||||
# fails when they drift.
|
||||
#
|
||||
# Each object is cleaned up independently: the cleanup loop deletes one at a
|
||||
# time so that a chain/set which does not exist in the current mode cannot
|
||||
# abort the whole batch. A single `nft -f` batch is atomic and would roll back
|
||||
# every preceding command in that case.
|
||||
|
||||
HP_FW4_CHAINS="
|
||||
homeproxy_output_redir
|
||||
homeproxy_redirect
|
||||
homeproxy_redirect_proxy
|
||||
homeproxy_redirect_proxy_port
|
||||
homeproxy_redirect_lanac
|
||||
homeproxy_mangle_prerouting
|
||||
homeproxy_mangle_output
|
||||
homeproxy_mangle_tproxy
|
||||
homeproxy_mangle_tproxy_port
|
||||
homeproxy_mangle_lanac
|
||||
homeproxy_mangle_mark
|
||||
homeproxy_mangle_tun
|
||||
homeproxy_mangle_tun_mark
|
||||
"
|
||||
|
||||
HP_FW4_SETS="
|
||||
homeproxy_local_addr_v4
|
||||
homeproxy_local_addr_v6
|
||||
homeproxy_gfw_list_v4
|
||||
homeproxy_gfw_list_v6
|
||||
homeproxy_mainland_addr_v4
|
||||
homeproxy_mainland_addr_v6
|
||||
homeproxy_wan_proxy_addr_v4
|
||||
homeproxy_wan_proxy_addr_v6
|
||||
homeproxy_wan_direct_addr_v4
|
||||
homeproxy_wan_direct_addr_v6
|
||||
homeproxy_routing_port
|
||||
"
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* scripts/generate_client.uc: process-level entry point.
|
||||
*
|
||||
* The actual client generator is generator/client.uc; this script is the
|
||||
* thin entry point that init.d/homeproxy execve's. Responsibilities:
|
||||
*
|
||||
* 1. Load UCI into the HomeProxyConfig domain model.
|
||||
* 2. Resolve the GenerationContext env: the WAN resolver (ubus) and the two
|
||||
* domain-resource lists (fs). This is the process-level boundary, which
|
||||
* is why it lives here and not under generator/.
|
||||
* 3. Call generate(dm, env) to get the sing-box JSON object.
|
||||
* 4. Write atomically: candidate tmp -> `sing-box check` -> mv to live.
|
||||
*
|
||||
* The check is here (and not in the runtime, and not in the generator)
|
||||
* because the architecture guide mandates the generator produce
|
||||
* "atomic write candidate + serialization" and the runtime is already
|
||||
* structured around "a failing generation leaves the previous file in
|
||||
* place" (see runtime/config.sh's hp_ensure_live). Having the shell do
|
||||
* the check keeps that contract single-sourced.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { connect } from 'ubus';
|
||||
import { mkdtemp, readfile, writefile } from 'fs';
|
||||
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { generate } from './generator/client.uc';
|
||||
import { removeBlankAttrs, HP_DIR, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy.uc';
|
||||
|
||||
/* Resolve the GenerationContext inputs. This is the only impure step on the
|
||||
* client generation path, and it is deliberately here rather than under
|
||||
* generator/:
|
||||
*
|
||||
* - wan_dns is the upstream the default-dns server detours to. ubus may be
|
||||
* unreachable (no ubusd, a dev host, no WAN lease); build_context() then
|
||||
* applies the same mode-dependent public fallback the pre-split generator
|
||||
* used, so an unresolved value stays safe rather than fatal.
|
||||
* - direct_domain_list / proxy_domain_list are the two files the resource
|
||||
* updater maintains. Custom mode ignores both, so they are not read
|
||||
* there - matching the pre-split read exactly.
|
||||
*
|
||||
* The extra parentheses around the ubus call keep the ?. chain guarded when
|
||||
* connect() returns null. */
|
||||
function resolve_env(dm) {
|
||||
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
|
||||
const ubus = connect();
|
||||
|
||||
const env = {
|
||||
wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0],
|
||||
direct_domain_list: [],
|
||||
proxy_domain_list: []
|
||||
};
|
||||
|
||||
if (routing_mode !== 'custom') {
|
||||
const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt');
|
||||
env.direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : [];
|
||||
|
||||
const proxy_list_raw = readfile(HP_DIR + '/resources/proxy_list.txt');
|
||||
env.proxy_domain_list = proxy_list_raw ? split(trim(proxy_list_raw), /[\r\n]/) : [];
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
const dm = Loader.load(UCICONFIG_DIR);
|
||||
const config = removeBlankAttrs(generate(dm, resolve_env(dm)));
|
||||
|
||||
system('mkdir -p ' + shellQuote(RUN_DIR));
|
||||
|
||||
/* A private scratch directory rather than a fixed `<out>.tmp`.
|
||||
*
|
||||
* Two generation runs can still overlap (a LuCI apply while the cron entry
|
||||
* reloads, or a manual and a triggered reload). With a fixed name both wrote
|
||||
* the same file, and `sing-box check` could be validating a file the other run
|
||||
* was still writing - the winner then installed a half-written config.
|
||||
*
|
||||
* mkdtemp() is this package's existing primitive for that (executeCommand()
|
||||
* uses it) and gives a 0700 directory under /tmp. The path here comes from
|
||||
* mkdtemp() so it is safe today, but it goes through shellQuote() anyway:
|
||||
* the "all shell args quoted" rule is the machine-checkable
|
||||
* invariant, not a comment about today's safety. */
|
||||
const work_dir = mkdtemp();
|
||||
const tmp = work_dir + '/sing-box-c.json';
|
||||
|
||||
/* writefile() returns null on failure, and ignoring that turned a full disk or
|
||||
* a permission error into a later "sing-box check failed", which points at the
|
||||
* wrong thing entirely. */
|
||||
if (writefile(tmp, sprintf('%.J\n', config)) == null) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
die('failed to write the generated client configuration to ' + tmp);
|
||||
}
|
||||
|
||||
if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-c.json') !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* The generated config carries every node credential - passwords, UUIDs,
|
||||
* private keys - and writefile() has no mode argument, so it lands with the
|
||||
* process umask (world-readable at the usual 022). sing-box runs as its own
|
||||
* user and reads the file directly, so 0600 is enough. */
|
||||
system('chmod 600 ' + shellQuote(RUN_DIR) + '/sing-box-c.json');
|
||||
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* scripts/generate_server.uc: process-level entry point.
|
||||
*
|
||||
* Mirror of scripts/generate_client.uc for the server instance. The
|
||||
* server generator (generator/server.uc) returns null when no inbound
|
||||
* is enabled; the shell then exits 1 so init.d can detect
|
||||
* "server disabled" without a separate UCI check.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { mkdtemp, writefile } from 'fs';
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { generate_server } from './generator/server.uc';
|
||||
import { removeBlankAttrs, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy.uc';
|
||||
|
||||
const dm = Loader.load(UCICONFIG_DIR);
|
||||
const config = generate_server(dm);
|
||||
if (!config)
|
||||
exit(1);
|
||||
|
||||
const cleaned = removeBlankAttrs(config);
|
||||
system('mkdir -p ' + shellQuote(RUN_DIR));
|
||||
|
||||
/* A private scratch directory rather than a fixed `<out>.tmp`.
|
||||
*
|
||||
* Two generation runs can still overlap (a LuCI apply while the cron entry
|
||||
* reloads, or a manual and a triggered reload). With a fixed name both wrote
|
||||
* the same file, and `sing-box check` could be validating a file the other run
|
||||
* was still writing - the winner then installed a half-written config.
|
||||
*
|
||||
* mkdtemp() is this package's existing primitive for that (executeCommand()
|
||||
* uses it) and gives a 0700 directory under /tmp. The path here comes from
|
||||
* mkdtemp() so it is safe today, but it goes through shellQuote() anyway:
|
||||
* the "all shell args quoted" rule is the machine-checkable
|
||||
* invariant, not a comment about today's safety. */
|
||||
const work_dir = mkdtemp();
|
||||
const tmp = work_dir + '/sing-box-s.json';
|
||||
|
||||
/* Same reason as the client path: a failed write used to be reported as a
|
||||
* failed `sing-box check`. */
|
||||
if (writefile(tmp, sprintf('%.J\n', cleaned)) == null) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
die('failed to write the generated server configuration to ' + tmp);
|
||||
}
|
||||
|
||||
if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-s.json') !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* The server config carries inbound credentials and the REALITY/ECH key
|
||||
* material, and writefile() has no mode argument, so it lands with the
|
||||
* process umask. 0600 matches what sing-box needs to read it as its own
|
||||
* user. */
|
||||
system('chmod 600 ' + shellQuote(RUN_DIR) + '/sing-box-s.json');
|
||||
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/client.uc: client-side orchestrator.
|
||||
*
|
||||
* This file is the only place the client generator still knows the
|
||||
* overall shape. Each section builder lives in its own module under
|
||||
* generator/; this file glues them together.
|
||||
*
|
||||
* generate(dm, env)
|
||||
* -> build_context(dm, env)
|
||||
* -> build_dns
|
||||
* -> build_inbounds
|
||||
* -> build_outbounds (mutates direct_overrides)
|
||||
* -> build_route
|
||||
* -> build_user_rulesets (custom mode only)
|
||||
* -> build_http_clients
|
||||
* -> attachSchema, attachExperimental
|
||||
* -> final config object (caller writes + sing-box check)
|
||||
*
|
||||
* Every section builder takes (config, ctx, ...) where `ctx` carries
|
||||
* the routing-mode- and proxy-mode-derived scalars (main_node,
|
||||
* dns_server, proxy_mode, ...) plus the mutable accumulators (outbounds,
|
||||
* endpoints, ...) the builders append to. The orchestrator owns them
|
||||
* and threads them through, which is what kept the original
|
||||
* generate_client.uc at 1240 lines: a 1100-line config blob with a
|
||||
* single closure scope.
|
||||
*
|
||||
* The output is the final config object: no `$schema`, no atomic write,
|
||||
* no `sing-box check`. The CLI shell (`scripts/generate_client.uc`)
|
||||
* is responsible for those - per the architecture guide, the generator
|
||||
* must not double as the runner.
|
||||
*
|
||||
* It is also responsible for resolving `env` (the WAN DNS via ubus and the
|
||||
* two resource lists) and passing it in. This module imports no ubus and no
|
||||
* fs: `generate(dm, env)` is a pure function, so the same arguments always
|
||||
* produce the same bytes. tests/arch-guard.sh guard 27 keeps it that way.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, RUN_DIR } from '../homeproxy.uc';
|
||||
|
||||
import { build_context } from './context.uc';
|
||||
import { build_dns } from './dns.uc';
|
||||
import { build_inbounds } from './inbound.uc';
|
||||
import { build_outbounds } from './outbound.uc';
|
||||
import { build_route } from './route.uc';
|
||||
import { build_user_rulesets, build_http_clients } from './ruleset.uc';
|
||||
import { attachSchema, attachExperimental } from './common.uc';
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* Build the sing-box client config object. Pure function: takes the
|
||||
* HomeProxyConfig from the Loader plus the GenerationContext inputs the CLI
|
||||
* resolved (`env`), and returns the JSON object the caller writes and
|
||||
* `sing-box check`s. No UCI, no file I/O, no ubus, no procd. */
|
||||
export function generate(dm, env) {
|
||||
const ctx = build_context(dm, env);
|
||||
|
||||
const config = {
|
||||
log: {
|
||||
disabled: false,
|
||||
level: ctx.log_level,
|
||||
output: RUN_DIR + '/sing-box-c.log',
|
||||
timestamp: true
|
||||
}
|
||||
};
|
||||
|
||||
if (!isEmpty(ctx.ntp_server))
|
||||
config.ntp = {
|
||||
enabled: true,
|
||||
server: ctx.ntp_server,
|
||||
detour: 'direct-out',
|
||||
domain_resolver: 'default-dns',
|
||||
};
|
||||
|
||||
/* `direct_overrides` is the single side effect that survives the
|
||||
* split: a direct node with override_address / override_port
|
||||
* records the override here so build_route can emit the
|
||||
* route-options action later. The orchestrator owns the map and
|
||||
* passes it to both builders. */
|
||||
const direct_overrides = {};
|
||||
|
||||
build_dns(config, dm, ctx);
|
||||
build_inbounds(config, ctx);
|
||||
|
||||
/* direct_outbounds live on config.outbounds regardless of mode:
|
||||
* the DNS path always references them, and a custom-mode config
|
||||
* without any direct-out/block-out would not have a fallback for
|
||||
* a routing rule that emits a missing tag. Initialised AFTER
|
||||
* build_dns / build_inbounds so the JSON key order is
|
||||
* log, ntp, dns, inbounds, outbounds, ... matching the
|
||||
* pre-refactor generator. The builder appends to the same array
|
||||
* (no reassignment), which preserves the key order that
|
||||
* removeBlankAttrs() emits and keeps golden snapshots stable. */
|
||||
config.outbounds = [
|
||||
{
|
||||
type: 'direct',
|
||||
tag: 'direct-out',
|
||||
routing_mark: strToInt(ctx.self_mark)
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
tag: 'block-out'
|
||||
}
|
||||
];
|
||||
config.endpoints = [];
|
||||
|
||||
build_outbounds(config, dm, ctx, direct_overrides);
|
||||
|
||||
build_route(config, dm, ctx, direct_overrides);
|
||||
|
||||
/* User-defined rulesets are custom-mode only; passing an empty
|
||||
* dm.routing.rulesets through build_user_rulesets is a no-op
|
||||
* because every cfg in it is filtered by !cfg.enabled. */
|
||||
if (ctx.routing_mode === 'custom')
|
||||
build_user_rulesets(config.route.rule_set, dm, ctx);
|
||||
|
||||
const http_clients = build_http_clients(config.route.rule_set, dm, ctx);
|
||||
if (length(http_clients))
|
||||
config.http_clients = http_clients;
|
||||
|
||||
/* A direct node used as the main node yields a main-out with no fields of
|
||||
* its own: generate_outbound() turns its override_address / override_port
|
||||
* into a route action rather than outbound fields, leaving a bare
|
||||
* {"type":"direct","tag":"main-out"}. sing-box refuses to detour into
|
||||
* such an outbound and the service never starts:
|
||||
*
|
||||
* FATAL start service: start dns/tcp[main-dns]: detour to an empty
|
||||
* direct outbound makes no sense
|
||||
* FATAL start service: initialize rule-set[0]: ... detour to an empty
|
||||
* direct outbound makes no sense
|
||||
*
|
||||
* Dropping the detour is the equivalent behaviour - the query or the
|
||||
* rule-set download leaves through the host's own network, which is what a
|
||||
* direct outbound does - and mirrors the `ctx.self_mark ? 'direct-out' :
|
||||
* null` that build_dns already applies to the other DNS servers. Only
|
||||
* main-out is touched: direct-out is deliberate and carries a routing
|
||||
* mark.
|
||||
*
|
||||
* This has to run after build_http_clients(): the rule-set downloads
|
||||
* reach main-out through an http_client, not through a detour of their
|
||||
* own. */
|
||||
let bare_main_out = false;
|
||||
for (let i = 0; i < length(config.outbounds); i++) {
|
||||
const ob = config.outbounds[i];
|
||||
/* build_outbounds leaves a null in the array for a node it could not
|
||||
* emit (a pruned urltest candidate); skip those. */
|
||||
if (ob && ob.tag === 'main-out' && ob.type === 'direct' && length(keys(ob)) === 2)
|
||||
bare_main_out = true;
|
||||
}
|
||||
if (bare_main_out) {
|
||||
for (let i = 0; i < length(config.dns.servers); i++) {
|
||||
const s = config.dns.servers[i];
|
||||
if (s && s.detour === 'main-out')
|
||||
delete s.detour;
|
||||
}
|
||||
for (let i = 0; i < length(config.http_clients || []); i++) {
|
||||
const c = config.http_clients[i];
|
||||
if (c && c.detour === 'main-out')
|
||||
delete c.detour;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty(config.route.rule_set))
|
||||
config.route.rule_set = null;
|
||||
if (isEmpty(config.endpoints))
|
||||
config.endpoints = null;
|
||||
|
||||
attachExperimental(config, ctx.routing_mode, ctx.dns_store_dns);
|
||||
attachSchema(config);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/common.uc: shared helpers used by every generator module.
|
||||
*
|
||||
* Every "select a tag from a UCI-style reference" function lives in this
|
||||
* file because the same selector logic (a routing_node name, an
|
||||
* array-of-names, a direct/block/default marker, a ruleset id list) is
|
||||
* reached by the DNS section, the route section, the outbound/endpoint
|
||||
* builder and the ruleset section. Duplicating them per-module would
|
||||
* drift; consolidating them here keeps the convention "one place to
|
||||
* change a tag shape".
|
||||
*
|
||||
* The Loader owns UCI; nothing in here reads UCI directly. The orchestrator
|
||||
* passes `dm` for the routing_node lookups that need to resolve a name into
|
||||
* the underlying node id.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty } from '../homeproxy.uc';
|
||||
|
||||
import { ConfigQuery } from '../config/model.uc';
|
||||
|
||||
/* parse_port() lives in homeproxy.uc. The Adapter layer needs it
|
||||
* too (EndpointFactory builds the WireGuard endpoint) and an adapter must
|
||||
* not import from generator/, so the util moved to the module both layers
|
||||
* already share. Callers import it from '../homeproxy.uc' now. */
|
||||
|
||||
/* Resolve a UCI outbound-style reference (string or array of strings)
|
||||
* into the sing-box tag the generator should emit. The arrays carry
|
||||
* either 'block-out' / 'direct-out' sentinels or routing_node section
|
||||
* names; a single string is the same thing with one entry.
|
||||
*
|
||||
* `dm` is needed for the routing_node -> node id lookup; we deliberately
|
||||
* do not read `dm.routing.nodes` until we know we need it (the common
|
||||
* case is the sentinel). */
|
||||
export function get_outbound(cfg, dm) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
if (type(cfg) === 'array') {
|
||||
let outbounds = [];
|
||||
for (let i in cfg)
|
||||
push(outbounds, get_outbound(i, dm));
|
||||
return outbounds;
|
||||
}
|
||||
|
||||
switch (cfg) {
|
||||
case 'block-out':
|
||||
case 'direct-out':
|
||||
return cfg;
|
||||
default:
|
||||
const rn = ConfigQuery.find_by_name(dm.routing.nodes, cfg);
|
||||
if (!rn || isEmpty(rn.node))
|
||||
die(sprintf("%s's node is missing, please check your configuration.", cfg));
|
||||
else if (rn.node === 'urltest')
|
||||
return 'cfg-' + cfg + '-out';
|
||||
else
|
||||
return 'cfg-' + rn.node + '-out';
|
||||
}
|
||||
};
|
||||
|
||||
/* Find a loaded section by name, treating `enabled` as a gate. The Loader
|
||||
* normalises `enabled` on every list section; a section with the field unset
|
||||
* is enabled (boolean sections default to 0 in UCI, but a dns_server/ruleset
|
||||
* without the option is a valid, enabled row).
|
||||
*
|
||||
* Declared before its callers on purpose: ucode resolves function references
|
||||
* at call time from what has already been evaluated, so a `function` statement
|
||||
* placed after get_resolver() fails with "access to undeclared variable". */
|
||||
function find_enabled(items, name) {
|
||||
const item = ConfigQuery.find_by_name(items || [], name);
|
||||
if (!item)
|
||||
return null;
|
||||
if (item.enabled === false || item.enabled === '0')
|
||||
return null;
|
||||
return item;
|
||||
};
|
||||
|
||||
/* Resolve a UCI resolver reference (a dns_server section name or one of
|
||||
* the sentinels) into a sing-box resolver tag.
|
||||
*
|
||||
* `dm` is required for the same reason get_outbound() needs it: a reference to
|
||||
* a section that was deleted or disabled produces `cfg-<name>-dns`, which
|
||||
* nothing defines - sing-box then rejects the whole configuration with
|
||||
* "dns server not found", the reload silently keeps the previous one, and the
|
||||
* user only sees that their change did not take. Fail with the name instead. */
|
||||
export function get_resolver(cfg, dm) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
switch (cfg) {
|
||||
case 'default-dns':
|
||||
case 'system-dns':
|
||||
return cfg;
|
||||
default:
|
||||
if (dm && !find_enabled((dm.dns || {}).servers, cfg))
|
||||
die(sprintf("the DNS server '%s' does not exist or is disabled; check the rule that refers to it.", cfg));
|
||||
return 'cfg-' + cfg + '-dns';
|
||||
}
|
||||
};
|
||||
|
||||
/* Resolve a UCI ruleset reference list (array of ruleset section names)
|
||||
* into the corresponding sing-box rule_set tag list. Same existence gate as
|
||||
* get_resolver(): ruleset.uc skips disabled entries, so a reference to one
|
||||
* would leave a dangling `cfg-<name>-rule` tag behind. */
|
||||
export function get_ruleset(cfg, dm) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
let rules = [];
|
||||
for (let i in cfg) {
|
||||
if (isEmpty(i))
|
||||
continue;
|
||||
if (dm && !find_enabled((dm.routing || {}).rulesets, i))
|
||||
die(sprintf("the rule-set '%s' does not exist or is disabled; check the rule that refers to it.", i));
|
||||
push(rules, 'cfg-' + i + '-rule');
|
||||
}
|
||||
return rules;
|
||||
};
|
||||
|
||||
/* Resolve the direct-node destination override that the route builder
|
||||
* needs for a `direct` routing_node target. The override is recorded
|
||||
* earlier by generate_outbound() (see outbound.uc) when a direct node
|
||||
* carries override_address / override_port; this helper just looks it
|
||||
* up. Returns null when the target is not a direct node or no override
|
||||
* is configured. */
|
||||
export function get_direct_override(outbound_selector, dm, direct_overrides) {
|
||||
if (type(outbound_selector) === 'array' || isEmpty(outbound_selector))
|
||||
return null;
|
||||
|
||||
switch (outbound_selector) {
|
||||
case 'direct-out':
|
||||
case 'block-out':
|
||||
return null;
|
||||
default:
|
||||
const rn = ConfigQuery.find_by_name(dm.routing.nodes, outbound_selector);
|
||||
const node = rn && rn.node;
|
||||
return (!isEmpty(node) && node !== 'urltest') ? (direct_overrides[node] || null) : null;
|
||||
}
|
||||
};
|
||||
|
||||
/* True when `tag` (the sing-box outbound tag) belongs to a direct outbound.
|
||||
* Used by the http_clients builder to drop the detour field on a pure-TUN
|
||||
* setup: sing-box 1.14 rejects detouring to a direct outbound that has
|
||||
* nothing to detour through. */
|
||||
export function isDirectOutboundTag(tag, dm) {
|
||||
if (isEmpty(tag) || tag === 'block-out')
|
||||
return false;
|
||||
if (tag === 'direct-out')
|
||||
return true;
|
||||
|
||||
/* The caller passes the tag the generator emits - get_outbound() builds
|
||||
* `cfg-<node>-out` - not the routing_node section name, so that wrapper has
|
||||
* to be undone before the lookup. Without this the two queries below were
|
||||
* given a name that matches neither table, and the function returned false
|
||||
* for every routing_node: build_http_clients() then kept a `detour` on a
|
||||
* rule-set download in pure TUN mode, where sing-box refuses it ("detour to
|
||||
* an empty direct outbound makes no sense") and rejects the whole
|
||||
* configuration. */
|
||||
const unwrapped = match(tag, /^cfg-(.+)-out$/);
|
||||
const name = unwrapped ? unwrapped[1] : tag;
|
||||
|
||||
const rn = ConfigQuery.find_by_name(dm.routing.nodes, name);
|
||||
const node_name = (rn && rn.node) || name;
|
||||
const node = ConfigQuery.node_by_id(dm, node_name);
|
||||
return !!(node && node.type === 'direct');
|
||||
};
|
||||
|
||||
/* Append the sing-box JSON $schema field. Kept here because the value is
|
||||
* fixed and the two generators used to spell it the same way; if it ever
|
||||
* changes, both should pick up the new URL automatically. */
|
||||
export function attachSchema(config) {
|
||||
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
|
||||
return config;
|
||||
};
|
||||
|
||||
/* Attach the experimental cache_file block. Every routing mode benefits
|
||||
* from it: bypass_mainland_china and custom match a lot of DNS, and the
|
||||
* other modes still resolve the proxy node hostname and resolve any
|
||||
* domain the user clicks through. cache_file is a no-op on a fresh
|
||||
* install (no cache to read) and the file is the only piece of state the
|
||||
* generator writes outside /etc/config/homeproxy. Routing-mode gating
|
||||
* used to limit it to bypass_mainland_china + custom, which left
|
||||
* gfwlist / proxy_mainland_china / global with cold-start DNS on every
|
||||
* reload and made the difference between modes hard to explain.
|
||||
*
|
||||
* Note: docs/linux.json 与 pro 的差距分析.md §2.5 once asked for an
|
||||
* explicit `reverse_mapping: true` here (so a future sing-box change to
|
||||
* the default would not silently move us off the mapping table). The
|
||||
* sing-box 1.14.0-r1 we test against rejects that field as
|
||||
* "json: unknown field" - the field is recognised by newer builds but
|
||||
* not the floor pinned by arch-guard 31. sing-box's default is already
|
||||
* `true`, so leaving it implicit matches runtime behaviour and keeps the
|
||||
* generated config compatible with the test target. Revisit if the
|
||||
* floor moves past the release where the field was introduced. */
|
||||
export function attachExperimental(config, routing_mode, dns_store_dns) {
|
||||
config.experimental = {
|
||||
cache_file: {
|
||||
enabled: true,
|
||||
path: '/etc/homeproxy/cache.db',
|
||||
store_dns: (dns_store_dns === '1') ? true : null
|
||||
}
|
||||
};
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* GenerationContext: the routing-mode/proxy-mode scalars every section
|
||||
* builder consumes, built as a *pure* function of the domain model plus a
|
||||
* small, explicit environment record.
|
||||
*
|
||||
* build_context(dm, env) -> ctx
|
||||
*
|
||||
* env = {
|
||||
* wan_dns, the WAN resolver the CLI resolved via ubus
|
||||
* direct_domain_list, /etc/homeproxy/resources/direct_list.txt
|
||||
* proxy_domain_list, /etc/homeproxy/resources/proxy_list.txt
|
||||
* }
|
||||
*
|
||||
* Why the split exists: this module used to sit inside generator/client.uc
|
||||
* and resolve `env` itself - it called `ubus network.interface status` for
|
||||
* the WAN DNS and `readfile()` for the two resource lists. The generator was
|
||||
* documented as a pure function while depending on live router state, so
|
||||
* "generate the same config twice" was not guaranteed. That is what made
|
||||
* reload's preflight generation and start_service's generation two different
|
||||
* artifacts in principle: the WAN lease can change between them.
|
||||
*
|
||||
* Now the impure part (ubus call, resource read, mode-dependent fallback for
|
||||
* a router with no lease) belongs to the CLI shells - scripts/
|
||||
* generate_client.uc and scripts/generate_server.uc - which are the
|
||||
* process-level boundary and already own the atomic write and
|
||||
* `sing-box check`. Everything under generator/ is a pure function of its
|
||||
* arguments, which tests/arch-guard.sh enforces (guard 27: no ubus/fs import
|
||||
* in generator/).
|
||||
*
|
||||
* `env` is allowed to be partial. A missing wan_dns still yields the same
|
||||
* public fallback the pre-split code used, as a pure function of the routing
|
||||
* mode - not a probe - so a caller that has no ubus keeps producing a valid
|
||||
* config instead of failing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt } from '../homeproxy.uc';
|
||||
|
||||
/* Build the routing-mode-independent ctx the section builders consume.
|
||||
* Mode-dependent defaults are filled in here so each builder can stay a
|
||||
* straight consumer of its fields without recomputing them. */
|
||||
export function build_context(dm, env) {
|
||||
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
|
||||
const proxy_mode = dm.general.proxy_mode || 'redirect_tproxy';
|
||||
|
||||
/* Mode-shared scalars */
|
||||
const ipv6_support = dm.general.ipv6_support || '0';
|
||||
const log_level = dm.general.log_level || 'warn';
|
||||
const ntp_server = dm.infra.ntp_server || 'time.apple.com';
|
||||
const dns_port = dm.infra.dns_port || '5333';
|
||||
const mixed_port = dm.infra.mixed_port || '5330';
|
||||
const udp_timeout = (routing_mode === 'custom')
|
||||
? (dm.routing.settings.udp_timeout)
|
||||
: dm.infra.udp_timeout;
|
||||
|
||||
/* self_mark is intentionally null for proxy_mode 'tun' only:
|
||||
* the DNS server detour (default-dns / system-dns / china-dns) is
|
||||
* gated on it, and the old generator kept the same code shape -
|
||||
* any non-empty string is truthy, so the test fixture's
|
||||
* self_mark='100' would leak a 'detour: direct-out' onto every
|
||||
* DNS server when proxy_mode is tun. removeBlankAttrs() then
|
||||
* strips the null detour, so the generated JSON stays identical
|
||||
* to the pre-refactor output. */
|
||||
const self_mark = match(proxy_mode, /redirect/)
|
||||
? (dm.infra.self_mark || '100') : null;
|
||||
|
||||
/* proxy-mode-derived ports / TUN params */
|
||||
let redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6,
|
||||
tun_mtu, tcpip_stack, endpoint_independent_nat;
|
||||
if (match(proxy_mode, /redirect/))
|
||||
redirect_port = dm.infra.redirect_port || '5331';
|
||||
if (match(proxy_mode, /tproxy/))
|
||||
if ((dm.general.main_udp_node || 'nil') !== 'nil' || routing_mode === 'custom')
|
||||
tproxy_port = dm.infra.tproxy_port || '5332';
|
||||
if (match(proxy_mode, /tun/)) {
|
||||
tun_name = dm.infra.tun_name || 'singtun0';
|
||||
tun_addr4 = dm.infra.tun_addr4 || '172.19.0.1/30';
|
||||
tun_addr6 = dm.infra.tun_addr6 || 'fdfe:dcba:9876::1/126';
|
||||
tun_mtu = dm.infra.tun_mtu || '9000';
|
||||
tcpip_stack = 'system';
|
||||
if (routing_mode === 'custom') {
|
||||
tcpip_stack = dm.routing.settings.tcpip_stack || 'system';
|
||||
endpoint_independent_nat = dm.routing.settings.endpoint_independent_nat;
|
||||
}
|
||||
}
|
||||
|
||||
/* The WAN resolver is an input, not a lookup. See the module header for
|
||||
* the fallback's provenance - it is the same mode-dependent public
|
||||
* resolver the pre-split generator chose when ubus had nothing to say. */
|
||||
const wan_dns = !isEmpty(env.wan_dns)
|
||||
? env.wan_dns
|
||||
: ((routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5');
|
||||
|
||||
/* dns/routing fields used by both sections */
|
||||
const dns_optimistic_cache = (dm.dns.settings || {}).optimistic_cache || '0';
|
||||
const dns_optimistic_timeout = (dm.dns.settings || {}).optimistic_timeout;
|
||||
const dns_query_timeout = (dm.dns.settings || {}).dns_timeout;
|
||||
const dns_store_dns = (dm.dns.settings || {}).cache_file_store_dns || '0';
|
||||
|
||||
const tun_dns_mode_raw = dm.general.tun_dns_mode,
|
||||
tun_dns_address = dm.general.tun_dns_address,
|
||||
udp_mapping_raw = dm.general.udp_mapping,
|
||||
udp_filtering_raw = dm.general.udp_filtering,
|
||||
udp_nat_max = strToInt(dm.general.udp_nat_max);
|
||||
|
||||
const tun_dns_mode = (tun_dns_mode_raw === 'default') ? '' : tun_dns_mode_raw,
|
||||
udp_mapping = (udp_mapping_raw === 'default') ? '' : udp_mapping_raw,
|
||||
udp_filtering = (udp_filtering_raw === 'default') ? '' : udp_filtering_raw;
|
||||
|
||||
/* Routing-mode-dependent defaults */
|
||||
const ctx = {
|
||||
routing_mode, proxy_mode, ipv6_support, log_level,
|
||||
self_mark, ntp_server, dns_port, mixed_port,
|
||||
redirect_port, tproxy_port,
|
||||
tun_name, tun_addr4, tun_addr6, tun_mtu,
|
||||
tcpip_stack, endpoint_independent_nat,
|
||||
udp_timeout, udp_mapping, udp_filtering, udp_nat_max,
|
||||
tun_dns_mode, tun_dns_address,
|
||||
wan_dns, dns_optimistic_cache, dns_optimistic_timeout,
|
||||
dns_query_timeout, dns_store_dns,
|
||||
default_interface: (dm.access_control.control || {}).bind_interface,
|
||||
|
||||
/* udp/tcp shaping knobs the endpoint builder reads */
|
||||
endpoint_options: {
|
||||
udp_mapping, udp_filtering, udp_nat_max
|
||||
},
|
||||
|
||||
/* Routing-mode-specific scalars; the dns/route modules consult
|
||||
* only the fields their branch needs.
|
||||
*
|
||||
* The main-node reference is mode-dependent, and custom routing has
|
||||
* its own outbound/rule tables (routing.default_outbound plus the
|
||||
* routing_node / routing_rule / ruleset sections). A residual
|
||||
* config.main_node must therefore not switch the generator back to
|
||||
* the preset path: LuCI hides that field in custom mode but does not
|
||||
* clear it (`rmempty = false`), so a router that was configured in a
|
||||
* preset mode and then switched to custom keeps the old value in
|
||||
* UCI - and the pre-refactor code read it only when the mode was not
|
||||
* custom. Reading it here anyway silently dropped every custom
|
||||
* routing rule and made route.final 'main-out'. */
|
||||
main_node: (routing_mode === 'custom') ? null : dm.general.main_node,
|
||||
main_udp_node: (routing_mode === 'custom') ? null : dm.general.main_udp_node,
|
||||
/* Custom-only scalars. The gate is not cosmetic: route.uc picks its
|
||||
* branch with `isEmpty(default_outbound)` and outbound.uc does the
|
||||
* same, while the preset branch keys off main_node. A residual
|
||||
* default_outbound from an earlier custom configuration therefore
|
||||
* switched a preset-mode router onto the custom branch with the
|
||||
* preset DNS builder bailing out (it keys off main_node) - the DNS
|
||||
* block then ended up with only default-dns/system-dns and no
|
||||
* main-dns/china-dns, the route block with leftover custom rules -
|
||||
* and the rule-set tags those rules reference are only built in
|
||||
* custom mode, so `sing-box check` rejected the whole generation.
|
||||
* Preset modes read custom-only fields nowhere; gate them all.
|
||||
*
|
||||
* The proxy/custom asymmetry the generator then exposes to users:
|
||||
* proxy mode's route.default_domain_resolver is the literal
|
||||
* 'default-dns' (route.uc line 78-81), while custom mode's is the
|
||||
* get_resolver(default_outbound_dns, dm) lookup (route.uc line
|
||||
* 217-220). The two are deliberately different: proxy mode does
|
||||
* not surface a user-facing "default outbound DNS" choice (the
|
||||
* preset path always resolves through the WAN DNS), while custom
|
||||
* mode lets the user pick. README does not promise otherwise. */
|
||||
default_outbound: (routing_mode === 'custom') ? (dm.routing.settings || {}).default_outbound : null,
|
||||
default_outbound_dns: (routing_mode === 'custom')
|
||||
? ((dm.routing.settings || {}).default_outbound_dns || 'default-dns') : null,
|
||||
domain_strategy: (routing_mode === 'custom') ? (dm.routing.settings || {}).domain_strategy : null,
|
||||
find_neighbor: (routing_mode === 'custom') ? (dm.routing.settings || {}).find_neighbor : null,
|
||||
|
||||
/* Main-line defaults, mirroring what the pre-refactor
|
||||
* generate_client.uc did inline: an empty UCI value becomes 'wan'
|
||||
* for the main DNS server, and 'wan' - the value the UI writes for
|
||||
* "WAN DNS (read from interface)" - is replaced by the resolver the
|
||||
* CLI read off the WAN interface (`wan_dns`, which itself falls back
|
||||
* to a public resolver when ubus has no answer). Leaving the
|
||||
* literal 'wan' in place makes sing-box resolve a host by that name.
|
||||
* The China DNS server falls back to the Aliyun public resolver
|
||||
* instead of leaking into the default-dns pipeline. */
|
||||
dns_server: (isEmpty(dm.general.dns_server) || dm.general.dns_server === 'wan')
|
||||
? wan_dns : dm.general.dns_server,
|
||||
china_dns_server: (dm.general.china_dns_server && dm.general.china_dns_server !== 'wan')
|
||||
? dm.general.china_dns_server : '223.5.5.5',
|
||||
/* dns_default_strategy: in proxy mode, ipv4_only is the safe
|
||||
* default whenever ipv6_support is off; in custom mode the
|
||||
* UCI default_strategy field is the source of truth (no
|
||||
* implicit fallback - missing UCI => strategy: null =>
|
||||
* removeBlankAttrs strips it). */
|
||||
dns_default_strategy: (routing_mode === 'custom')
|
||||
? (dm.dns.settings || {}).default_strategy
|
||||
: ((ipv6_support !== '1') ? 'ipv4_only' : null),
|
||||
/* cn_ip_fallback: evaluate/match_response fallback for unknown-host
|
||||
* queries (tutorial §2.1 / docs/linux.json 与 pro 的差距分析.md).
|
||||
* The v28.9.1.16 flip turns this on by default for fresh installs;
|
||||
* migrate_config.uc writes '0' explicitly for upgrade-existing users
|
||||
* so they keep the prior behaviour until they opt in. '0' is truthy
|
||||
* in JS so the || picks it over '1' when UCI sets it to '0' - a
|
||||
* user who has cn_ip_fallback='0' written (the upgrade path) gets
|
||||
* exactly what they had. */
|
||||
cn_ip_fallback: dm.general.cn_ip_fallback || '1',
|
||||
/* sniffer_advanced_mode: when '1' (opt-in), the route sniff rule
|
||||
* gets the universal protocol list + 100ms timeout (tutorial §2.7 /
|
||||
* docs/linux.json 与 pro 的差距分析.md). Default '0' preserves the
|
||||
* 300ms / default-list behaviour so an upgrade is invisible. */
|
||||
sniffer_advanced_mode: dm.general.sniffer_advanced_mode || '0',
|
||||
main_urltest_nodes: dm.general.main_urltest_nodes || [],
|
||||
main_urltest_interval: dm.general.main_urltest_interval,
|
||||
main_urltest_tolerance: dm.general.main_urltest_tolerance,
|
||||
main_udp_urltest_nodes: dm.general.main_udp_urltest_nodes || [],
|
||||
main_udp_urltest_interval: dm.general.main_udp_urltest_interval,
|
||||
main_udp_urltest_tolerance: dm.general.main_udp_urltest_tolerance,
|
||||
|
||||
/* Custom-mode DNS settings */
|
||||
dns_default_server: (dm.dns.settings || {}).default_server,
|
||||
dns_disable_cache: (dm.dns.settings || {}).disable_cache,
|
||||
dns_disable_cache_expire: (dm.dns.settings || {}).disable_cache_expire,
|
||||
dns_client_subnet: (dm.dns.settings || {}).client_subnet,
|
||||
};
|
||||
|
||||
/* Inline static rule-sets (direct-domain, proxy-domain) only fire
|
||||
* when the routing mode has a non-empty domain list. Both lists are
|
||||
* inputs now: the CLI reads the two files under
|
||||
* /etc/homeproxy/resources/ and passes them in. Custom mode ignores
|
||||
* them entirely, exactly as the pre-split read did. */
|
||||
ctx.direct_domain_list = [];
|
||||
ctx.proxy_domain_list = [];
|
||||
if (routing_mode !== 'custom') {
|
||||
ctx.direct_domain_list = env.direct_domain_list || [];
|
||||
ctx.proxy_domain_list = env.proxy_domain_list || [];
|
||||
}
|
||||
|
||||
/* `dedicated_udp_node` means main_udp_node was set to a different
|
||||
* proxy-able node than main_node (the 'same'/'nil' sentinels don't
|
||||
* qualify). Kept on ctx so both build_outbounds and build_route
|
||||
* see the same value. */
|
||||
ctx.dedicated_udp_node = !isEmpty(ctx.main_udp_node) && !(ctx.main_udp_node in ['same', ctx.main_node]);
|
||||
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/dns.uc: build the sing-box `dns` block.
|
||||
*
|
||||
* Two routing-mode families exist:
|
||||
*
|
||||
* 1. bypass_mainland_china / proxy_mainland_china / global /
|
||||
* gfwlist - the proxy path. The orchestrator passes main_node
|
||||
* (possibly 'urltest'), main_udp_node, china_dns_server,
|
||||
* direct_domain_list and proxy_domain_list in ctx; the legacy
|
||||
* NAPTR trick (qtype 35) and the CN-IP fallback (cn_ip_fallback)
|
||||
* are part of this path.
|
||||
*
|
||||
* 2. custom - the user-defined rules path. The orchestrator passes
|
||||
* default_outbound, default_outbound_dns, the dns settings
|
||||
* (default_strategy, default_server, disable_cache, ...) and the
|
||||
* dns_server / dns_rule section lists under dm.dns.
|
||||
*
|
||||
* The dns_server / dns_rule iteration is the part that is mode-agnostic
|
||||
* and lives in this module rather than the orchestrator because every
|
||||
* field comes straight from UCI and the legacy 1.14 evaluate/match_response
|
||||
* rewrite is the per-field payload.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToBool, strToInt, strToTime, parseURL, validation, parse_port } from '../homeproxy.uc';
|
||||
|
||||
|
||||
import { get_outbound, get_resolver, get_ruleset } from './common.uc';
|
||||
|
||||
/* Build a DNS server spec from a UCI address string. sing-box wants the
|
||||
* server name, the protocol kind, an optional path, and an integer port;
|
||||
* the UCI representation is a single string, optionally without a
|
||||
* scheme, which is why a default_protocol is supplied. */
|
||||
function parse_dnsserver(server_addr, default_protocol) {
|
||||
if (isEmpty(server_addr))
|
||||
return null;
|
||||
|
||||
if (!match(server_addr, /:\/\//))
|
||||
server_addr = (default_protocol || 'udp') + '://' + (validation('ip6addr', server_addr) ? `[${server_addr}]` : server_addr);
|
||||
|
||||
/* parseURL() returns null for an address it cannot make sense of (a bad
|
||||
* host, an unknown scheme). The old code dereferenced the result straight
|
||||
* away, so one malformed UCI value took the whole generator down with
|
||||
* "left-hand side expression is null" instead of naming the dns_server. */
|
||||
server_addr = parseURL(server_addr);
|
||||
if (!server_addr)
|
||||
return null;
|
||||
|
||||
return {
|
||||
type: server_addr.protocol,
|
||||
server: server_addr.hostname,
|
||||
server_port: strToInt(server_addr.port),
|
||||
path: (server_addr.pathname !== '/') ? server_addr.pathname : null,
|
||||
}
|
||||
}
|
||||
|
||||
/* Parse the DNS rule query_type list UCI emits. A single value comes as
|
||||
* a string, a list as an array of strings; both forms are turned into an
|
||||
* array of strings or integers (mixing the two is intentional - the type
|
||||
* can be a name or a numeric code in UCI). */
|
||||
function parse_dnsquery(strquery) {
|
||||
if (type(strquery) !== 'array' || isEmpty(strquery))
|
||||
return null;
|
||||
|
||||
let querys = [];
|
||||
for (let i in strquery)
|
||||
isnan(int(i)) ? push(querys, i) : push(querys, int(i));
|
||||
|
||||
return querys;
|
||||
}
|
||||
|
||||
/* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */
|
||||
|
||||
/* Domains whose NAPTR (qtype 35) queries bypass china-dns. Aliyun
|
||||
* (china-dns's default backend, 223.5.5.5) returns NAPTR with multi-
|
||||
* second first responses for these suffixes, which sing-box reports as
|
||||
* 'context deadline exceeded' (sipgz12.hbq.r.10086.cn being the
|
||||
* reproducible offender); routing the qtype to default-dns (the ISP
|
||||
* resolver or the wan_dns fallback) keeps IMS/VoLTE registration fast.
|
||||
*
|
||||
* The list is deliberately small: every entry is a suffix match, so
|
||||
* adding a single host name can broaden the rule by accident. Add new
|
||||
* suffixes here when a fresh NAPTR offender is observed, not when
|
||||
* someone reads the comment and thinks "I know a domain that should
|
||||
* go direct". */
|
||||
const NAPTR_BYPASS_SUFFIXES = [
|
||||
'r.10086.cn',
|
||||
'10086.cn',
|
||||
'pub.3gppnetwork.org'
|
||||
];
|
||||
|
||||
function append_proxy_dns(config, dm, ctx) {
|
||||
if (isEmpty(ctx.main_node))
|
||||
return;
|
||||
|
||||
/* Main DNS */
|
||||
push(config.dns.servers, {
|
||||
tag: 'main-dns',
|
||||
domain_resolver: {
|
||||
server: 'default-dns',
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : null
|
||||
},
|
||||
detour: 'main-out',
|
||||
...parse_dnsserver(ctx.dns_server, 'tcp')
|
||||
});
|
||||
config.dns.final = 'main-dns';
|
||||
|
||||
if (length(ctx.direct_domain_list))
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'direct-domain',
|
||||
action: 'route',
|
||||
server: (ctx.routing_mode === 'bypass_mainland_china') ? 'china-dns' : 'default-dns'
|
||||
});
|
||||
|
||||
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains */
|
||||
push(config.dns.rules, {
|
||||
query_type: [64, 65],
|
||||
action: 'reject'
|
||||
});
|
||||
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
push(config.dns.servers, {
|
||||
tag: 'china-dns',
|
||||
domain_resolver: {
|
||||
server: 'default-dns',
|
||||
/* Mirror default-dns's strategy: china-dns itself is
|
||||
* overwhelmingly IPv4-only in practice (223.5.5.5 etc.),
|
||||
* so the field mostly affects the upstream chain; the
|
||||
* two should still track each other for the same reason
|
||||
* default-dns follows ipv6_support. */
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : 'prefer_ipv6'
|
||||
},
|
||||
detour: ctx.self_mark ? 'direct-out' : null,
|
||||
...parse_dnsserver(ctx.china_dns_server)
|
||||
});
|
||||
|
||||
/* Route NAPTR (qtype 35) queries for SIP/ENUM domains to the ISP
|
||||
default-dns directly: see NAPTR_BYPASS_SUFFIXES for the
|
||||
rationale and the explicit offenders. */
|
||||
push(config.dns.rules, {
|
||||
query_type: [35],
|
||||
domain_suffix: NAPTR_BYPASS_SUFFIXES,
|
||||
action: 'route',
|
||||
server: 'default-dns'
|
||||
});
|
||||
|
||||
if (length(ctx.proxy_domain_list))
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'proxy-domain',
|
||||
action: 'route',
|
||||
server: 'main-dns'
|
||||
});
|
||||
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'geosite-cn',
|
||||
action: 'route',
|
||||
server: 'china-dns'
|
||||
});
|
||||
|
||||
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */
|
||||
if (ctx.cn_ip_fallback === '1') {
|
||||
push(config.dns.rules, {
|
||||
action: 'evaluate',
|
||||
server: 'main-dns',
|
||||
tag: 'cn-fallback'
|
||||
});
|
||||
push(config.dns.rules, {
|
||||
match_response: 'cn-fallback',
|
||||
rule_set: 'geoip-cn',
|
||||
action: 'route',
|
||||
server: 'china-dns'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Known DoH/DoT/DoQ hostnames and the IPs we should pin them to. The
|
||||
* table is consulted by append_custom_dns() to detect when a user
|
||||
* configured one of these encrypted upstreams; without the predefined
|
||||
* entry, the resolver itself has to be resolved through the system DNS,
|
||||
* and a DNS outage of any kind taken once out leaves the resolver
|
||||
* unreachable - a chicken-and-egg loop. Mirrors the hosts server in
|
||||
* linux.json line 10; see docs/linux.json 与 pro 的差距分析.md §2.4.
|
||||
*
|
||||
* Additions go here when the user's pool of encrypted resolvers grows;
|
||||
* a new entry needs no code change beyond this table. */
|
||||
const KNOWN_ENCRYPTED_DNS_HOSTS = {
|
||||
'doh.pub': ['1.12.12.21', '120.53.53.53'],
|
||||
'dns.pub': ['1.12.12.21', '120.53.53.53'],
|
||||
'dns.alidns.com': ['223.5.5.5', '223.6.6.6'],
|
||||
'dns.google': ['8.8.8.8', '8.8.4.4'],
|
||||
'one.one.one.one': ['1.1.1.1', '1.0.0.1']
|
||||
};
|
||||
|
||||
/* --- custom routing-mode path (user-defined dns_server / dns_rule) ----- */
|
||||
|
||||
function append_custom_dns(config, dm, ctx) {
|
||||
/* §2.4 DoH fallback. Pre-emit a hosts-type DNS server pinning any
|
||||
* encrypted-resolver hostname the user configured to its real IPs. The
|
||||
* loop also remembers which server sections hit the table, so the user
|
||||
* server loop below can stamp `domain_resolver: 'hp-dns-hosts'` onto
|
||||
* them - sing-box only consults the hosts table for resolvers that
|
||||
* explicitly point at it, mirroring linux.json line 7
|
||||
* (`{"domain_resolver": "hosts"}`). Without the stamp, the resolver
|
||||
* still has to look up its own hostname through the system DNS and the
|
||||
* chicken-and-egg loop we are trying to break stays open. */
|
||||
let doh_predefined = null;
|
||||
const doh_resolved_hosts = {};
|
||||
for (let cfg in (dm.dns.servers || [])) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
if (cfg.type !== 'https' && cfg.type !== 'tls' && cfg.type !== 'quic')
|
||||
continue;
|
||||
const host = cfg.server;
|
||||
if (!host || !KNOWN_ENCRYPTED_DNS_HOSTS[host])
|
||||
continue;
|
||||
if (!doh_predefined)
|
||||
doh_predefined = {};
|
||||
doh_predefined[host] = KNOWN_ENCRYPTED_DNS_HOSTS[host];
|
||||
doh_resolved_hosts[cfg.name] = true;
|
||||
}
|
||||
if (doh_predefined) {
|
||||
push(config.dns.servers, {
|
||||
tag: 'hp-dns-hosts',
|
||||
type: 'hosts',
|
||||
predefined: doh_predefined
|
||||
});
|
||||
}
|
||||
|
||||
/* DNS servers */
|
||||
for (let cfg in dm.dns.servers) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
let outbound = get_outbound(cfg.outbound, dm);
|
||||
if (outbound === 'direct-out' && isEmpty(ctx.self_mark))
|
||||
outbound = null;
|
||||
|
||||
push(config.dns.servers, {
|
||||
tag: 'cfg-' + cfg.name + '-dns',
|
||||
type: cfg.type,
|
||||
server: cfg.server,
|
||||
server_port: strToInt(cfg.server_port),
|
||||
path: cfg.path,
|
||||
headers: cfg.headers,
|
||||
tls: cfg.tls_sni ? {
|
||||
enabled: true,
|
||||
server_name: cfg.tls_sni
|
||||
} : null,
|
||||
domain_resolver: doh_resolved_hosts[cfg.name]
|
||||
? 'hp-dns-hosts'
|
||||
: ((cfg.address_resolver || cfg.address_strategy) ? {
|
||||
server: get_resolver(cfg.address_resolver || ctx.dns_default_server, dm),
|
||||
strategy: cfg.address_strategy
|
||||
} : null),
|
||||
detour: outbound
|
||||
});
|
||||
}
|
||||
|
||||
/* DNS rules */
|
||||
/* sing-box >= 1.14: legacy address-filter rules are auto-wrapped with an
|
||||
evaluate action; deprecated strategy/accept_empty fields are dropped. */
|
||||
const builtin_dns_rules = [];
|
||||
/* §2.2 First safe rule: reject SVCB (qtype 64) and HTTPS (qtype 65)
|
||||
* queries. A client that learns the answer from these record types
|
||||
* connects to the embedded IP without going through the resolver again,
|
||||
* which makes Fake-IP and any route that hinges on A/AAAA resolution
|
||||
* bypass. Mirrors append_proxy_dns() line 102; emit here as the FIRST
|
||||
* entry so a user rule that targets the same query_type cannot shadow
|
||||
* it (sing-box walks the array in order and stops at the first match).
|
||||
* Without this prefix, a user who adds a higher-priority rule on the
|
||||
* same query_type in custom mode loses Fake-IP integrity. */
|
||||
push(builtin_dns_rules, {
|
||||
query_type: [64, 65],
|
||||
action: 'reject'
|
||||
});
|
||||
for (let cfg in dm.dns.rules) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
/* Match fields are valid for every action; action-specific fields
|
||||
* are emitted below per sing-box 1.14's per-action schema. The
|
||||
* previous code dumped server/method/no_drop/rcode/answer/ns/extra
|
||||
* (and the cache / TTL / client-subnet family) onto every rule
|
||||
* regardless of action, and sing-box then rejected the whole
|
||||
* configuration with "json: unknown field" the moment a user
|
||||
* changed a rule's action and left the old fields in UCI -
|
||||
* LuCI's form value parsing does not delete the previous value
|
||||
* when a depends() guard hides it, the same path route.uc
|
||||
* already had to fix. */
|
||||
const rule = {
|
||||
ip_version: strToInt(cfg.ip_version),
|
||||
query_type: parse_dnsquery(cfg.query_type),
|
||||
network: cfg.network,
|
||||
protocol: cfg.protocol,
|
||||
domain: cfg.domain,
|
||||
domain_suffix: cfg.domain_suffix,
|
||||
domain_keyword: cfg.domain_keyword,
|
||||
domain_regex: cfg.domain_regex,
|
||||
port: parse_port(cfg.port),
|
||||
port_range: cfg.port_range,
|
||||
source_ip_cidr: cfg.source_ip_cidr,
|
||||
source_ip_is_private: strToBool(cfg.source_ip_is_private),
|
||||
source_port: parse_port(cfg.source_port),
|
||||
source_port_range: cfg.source_port_range,
|
||||
process_name: cfg.process_name,
|
||||
process_path: cfg.process_path,
|
||||
process_path_regex: cfg.process_path_regex,
|
||||
user: cfg.user,
|
||||
rule_set: get_ruleset(cfg.rule_set, dm),
|
||||
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
|
||||
invert: strToBool(cfg.invert),
|
||||
race: strToBool(cfg.race),
|
||||
speculative: strToBool(cfg.speculative),
|
||||
action: cfg.action
|
||||
};
|
||||
|
||||
/* `route` (and `evaluate`, which needs a server for the eval step).
|
||||
* `resolve` is also a possibility but is not currently exposed in
|
||||
* the UI; if it ever is, this branch is where it lands. */
|
||||
if (cfg.action === 'route' || cfg.action === 'evaluate' || cfg.action === 'resolve') {
|
||||
rule.server = get_resolver(cfg.server, dm);
|
||||
rule.disable_cache = strToBool(cfg.dns_disable_cache);
|
||||
rule.disable_optimistic_cache = strToBool(cfg.disable_optimistic_cache);
|
||||
rule.rewrite_ttl = strToInt(cfg.rewrite_ttl);
|
||||
rule.client_subnet = cfg.client_subnet;
|
||||
rule.remove_client_subnet = strToBool(cfg.remove_client_subnet);
|
||||
}
|
||||
|
||||
if (cfg.action === 'route' || cfg.action === 'resolve')
|
||||
rule.timeout = strToTime(cfg.dns_timeout);
|
||||
|
||||
/* `reject` only accepts method + no_drop. Anything else here is
|
||||
* an unknown field for the action and the whole config fails. */
|
||||
if (cfg.action === 'reject') {
|
||||
rule.method = cfg.reject_method;
|
||||
rule.no_drop = strToBool(cfg.reject_no_drop);
|
||||
}
|
||||
|
||||
/* `predefined` only accepts rcode + answer + ns + extra. */
|
||||
if (cfg.action === 'predefined') {
|
||||
rule.rcode = cfg.predefined_rcode;
|
||||
rule.answer = cfg.predefined_answer;
|
||||
rule.ns = cfg.predefined_ns;
|
||||
rule.extra = cfg.predefined_extra;
|
||||
}
|
||||
|
||||
if (cfg.action === 'evaluate')
|
||||
rule.tag = cfg.evaluate_tag || null;
|
||||
|
||||
if (cfg.match_response && cfg.match_response !== '0')
|
||||
rule.match_response = (cfg.match_response === '1') ? true : cfg.match_response;
|
||||
|
||||
rule.query_client_subnet = cfg.query_client_subnet;
|
||||
rule.query_dnssec = strToBool(cfg.query_dnssec);
|
||||
rule.source_mac_address = cfg.source_mac_address;
|
||||
rule.source_hostname = cfg.source_hostname;
|
||||
|
||||
const legacy_filter = !isEmpty(cfg.ip_cidr) || strToBool(cfg.ip_is_private) === true;
|
||||
/* Every action needs the evaluate prefix to see the response, not just
|
||||
* `route`: with the old `&& cfg.action === 'route'` an address-filtered
|
||||
* reject rule lost the filter entirely and became an unconditional
|
||||
* reject - a DNS black hole for every query. */
|
||||
if (legacy_filter && !rule.match_response) {
|
||||
/* Wrap a legacy address-filter rule into the 1.14 evaluate/match_response
|
||||
paradigm. Carry the original query-matching fields onto the evaluate
|
||||
prefix rule so only queries that would have hit this rule get
|
||||
pre-resolved; an unconditional evaluate would resolve every query. */
|
||||
const eval_tag = '_hp_eval_' + cfg.name;
|
||||
const eval_rule = {
|
||||
action: 'evaluate',
|
||||
server: get_resolver(cfg.server, dm),
|
||||
tag: eval_tag
|
||||
};
|
||||
const eval_match_fields = [
|
||||
'inbound', 'ip_version', 'query_type', 'network', 'protocol', 'auth_user',
|
||||
'domain', 'domain_suffix', 'domain_keyword', 'domain_regex',
|
||||
'port', 'port_range', 'source_ip_cidr', 'source_ip_is_private',
|
||||
'source_port', 'source_port_range', 'process_name', 'process_path',
|
||||
'process_path_regex', 'user', 'rule_set', 'rule_set_ip_cidr_match_source',
|
||||
'invert', 'query_client_subnet', 'query_dnssec', 'source_mac_address',
|
||||
'source_hostname'
|
||||
];
|
||||
for (let f in eval_match_fields)
|
||||
eval_rule[f] = rule[f];
|
||||
push(builtin_dns_rules, eval_rule);
|
||||
rule.match_response = eval_tag;
|
||||
}
|
||||
|
||||
/* Response-match fields are only valid together with match_response in
|
||||
* 1.14: sing-box rejects the rule with "Response Match Fields (...)
|
||||
* require match_response to be enabled". ip_cidr / ip_is_private were
|
||||
* already gated here; the response_* quartet was emitted
|
||||
* unconditionally next door. */
|
||||
if (rule.match_response) {
|
||||
rule.ip_cidr = cfg.ip_cidr;
|
||||
rule.ip_is_private = strToBool(cfg.ip_is_private);
|
||||
rule.response_rcode = cfg.response_rcode;
|
||||
rule.response_answer = cfg.response_answer;
|
||||
rule.response_ns = cfg.response_ns;
|
||||
rule.response_extra = cfg.response_extra;
|
||||
}
|
||||
|
||||
push(builtin_dns_rules, rule);
|
||||
}
|
||||
config.dns.rules = builtin_dns_rules;
|
||||
|
||||
config.dns.final = get_resolver(ctx.dns_default_server, dm);
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* The always-on DNS block. Every routing mode (proxy and custom) gets
|
||||
* default-dns (UDP via the WAN resolver) and system-dns (local, used as
|
||||
* a fallback). The routing-mode-specific builders push additional
|
||||
* servers/rules on top of this list. Keeping the common prefix here is
|
||||
* why the proxy-mode and custom-mode paths stay almost mirror images:
|
||||
* both branches see config.dns already populated. */
|
||||
function initDns(config, ctx) {
|
||||
/* sing-box refuses the combination outright:
|
||||
* FATAL initialize DNS router: `optimistic` is conflict with `disable_cache`
|
||||
* (reproduced against 1.14.1 on a real config). Both are opt-in switches
|
||||
* in the UI, so turning both on used to produce a configuration that
|
||||
* cannot start - the reload fails and rolls back, and the only symptom the
|
||||
* user sees is "my setting did not take". optimistic wins; the disable_*
|
||||
* switches are dropped rather than emitted. */
|
||||
const optimistic = (ctx.dns_optimistic_cache === '1');
|
||||
|
||||
config.dns = {
|
||||
servers: [
|
||||
{
|
||||
tag: 'default-dns',
|
||||
type: 'udp',
|
||||
server: ctx.wan_dns,
|
||||
detour: ctx.self_mark ? 'direct-out' : null
|
||||
},
|
||||
{
|
||||
tag: 'system-dns',
|
||||
type: 'local',
|
||||
detour: ctx.self_mark ? 'direct-out' : null
|
||||
}
|
||||
],
|
||||
rules: [],
|
||||
strategy: ctx.dns_default_strategy,
|
||||
disable_cache: optimistic ? null : strToBool(ctx.dns_disable_cache),
|
||||
disable_expire: optimistic ? null : strToBool(ctx.dns_disable_cache_expire),
|
||||
client_subnet: ctx.dns_client_subnet,
|
||||
optimistic: optimistic ? {
|
||||
enabled: true,
|
||||
/* strToTime(), not the raw UCI string: sing-box parses this
|
||||
* field as a duration and rejects a bare number outright
|
||||
* ("time: missing unit in duration \"3600\""), so the whole
|
||||
* generation would fail - and a failed generation keeps the
|
||||
* previous configuration running, which the user sees as
|
||||
* "my setting did not take". The UI documents the field as
|
||||
* "Examples: 3d, 1h" but does not enforce a unit, so both
|
||||
* forms have to work: strToTime() turns "3600" into "3600s"
|
||||
* and leaves "3d" alone. dns_query_timeout below already
|
||||
* does this. */
|
||||
timeout: !isEmpty(ctx.dns_optimistic_timeout) ? strToTime(ctx.dns_optimistic_timeout) : '3d'
|
||||
} : null,
|
||||
timeout: !isEmpty(ctx.dns_query_timeout) ? strToTime(ctx.dns_query_timeout) : null
|
||||
};
|
||||
}
|
||||
|
||||
/* Build the sing-box `dns` block. Mutates `config` and returns it for
|
||||
* chaining. Routing mode is determined by ctx.routing_mode: any non-
|
||||
* 'custom' mode follows the proxy path (the proxy-mode split inside the
|
||||
* proxy path is just which rules to emit). */
|
||||
export function build_dns(config, dm, ctx) {
|
||||
initDns(config, ctx);
|
||||
|
||||
if (ctx.routing_mode === 'custom')
|
||||
append_custom_dns(config, dm, ctx);
|
||||
else
|
||||
append_proxy_dns(config, dm, ctx);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/inbound.uc: build the sing-box `inbounds` block.
|
||||
*
|
||||
* The client always needs the dns-in and mixed-in inbounds, plus
|
||||
* whichever of redirect-in / tproxy-in / tun-in matches proxy_mode.
|
||||
* udp_* fields on tproxy-in and tun-in come from the same UCI settings
|
||||
* (a single source of truth in init.d, but the orchestrator extracts
|
||||
* them and re-shapes them for sing-box here).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, strToTime, strToBool } from '../homeproxy.uc';
|
||||
|
||||
/* Append the always-on inbounds. dns-in is the local listener the DNS chain
|
||||
* hands queries to - runtime/dns.sh points dnsmasq at 127.0.0.1#<dns_port>,
|
||||
* so nothing outside this host has any reason to reach it. It used to
|
||||
* listen on '::', every interface, and a `direct` inbound forwards without
|
||||
* consulting the route rules: any client that connected to the DNS port got
|
||||
* its traffic dialled straight out instead of routed. A router log showed
|
||||
* exactly that - connections to a blocked address timing out through the
|
||||
* built-in `direct` outbound while every rule pointed at main-out.
|
||||
* Loopback-only removes the entry point; mixed-in stays on '::' because that
|
||||
* one is the SOCKS/HTTP listener clients are meant to reach. Their order
|
||||
* matters because dns-in has to come first when sing-box matches inbounds in
|
||||
* declaration order. */
|
||||
function pushBaseInbounds(inbounds, ctx) {
|
||||
push(inbounds, {
|
||||
type: 'direct',
|
||||
tag: 'dns-in',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: int(ctx.dns_port)
|
||||
});
|
||||
|
||||
push(inbounds, {
|
||||
type: 'mixed',
|
||||
tag: 'mixed-in',
|
||||
listen: '::',
|
||||
listen_port: int(ctx.mixed_port),
|
||||
udp_timeout: strToTime(ctx.udp_timeout),
|
||||
set_system_proxy: false
|
||||
});
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* Build the sing-box `inbounds` block. The caller supplies an empty
|
||||
* array on `config.inbounds` so this can be a pure builder. */
|
||||
export function build_inbounds(config, ctx) {
|
||||
const inbounds = config.inbounds || [];
|
||||
|
||||
pushBaseInbounds(inbounds, ctx);
|
||||
|
||||
if (match(ctx.proxy_mode, /redirect/))
|
||||
push(inbounds, {
|
||||
type: 'redirect',
|
||||
tag: 'redirect-in',
|
||||
|
||||
listen: '::',
|
||||
listen_port: int(ctx.redirect_port)
|
||||
});
|
||||
|
||||
/* tproxy-in is gated on the port existing, not only on the proxy mode:
|
||||
* context.uc only assigns tproxy_port when a dedicated UDP node is
|
||||
* configured (or in custom mode), because that is exactly when
|
||||
* firewall_post.ut emits the tproxy chain. Emitting the inbound anyway
|
||||
* bound a UDP socket to listen_port 0 - a random port that no nft rule
|
||||
* ever points at - so the two layers now share one condition. */
|
||||
if (match(ctx.proxy_mode, /tproxy/) && !isEmpty(ctx.tproxy_port))
|
||||
push(inbounds, {
|
||||
type: 'tproxy',
|
||||
tag: 'tproxy-in',
|
||||
|
||||
listen: '::',
|
||||
listen_port: int(ctx.tproxy_port),
|
||||
network: 'udp',
|
||||
udp_timeout: strToTime(ctx.udp_timeout),
|
||||
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
|
||||
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
|
||||
udp_nat_max: ctx.udp_nat_max,
|
||||
});
|
||||
|
||||
if (match(ctx.proxy_mode, /tun/))
|
||||
push(inbounds, {
|
||||
type: 'tun',
|
||||
tag: 'tun-in',
|
||||
|
||||
interface_name: ctx.tun_name,
|
||||
address: (ctx.ipv6_support === '1') ? [ctx.tun_addr4, ctx.tun_addr6] : [ctx.tun_addr4],
|
||||
mtu: strToInt(ctx.tun_mtu),
|
||||
auto_route: false,
|
||||
endpoint_independent_nat: strToBool(ctx.endpoint_independent_nat),
|
||||
udp_timeout: strToTime(ctx.udp_timeout),
|
||||
dns_mode: !isEmpty(ctx.tun_dns_mode) ? ctx.tun_dns_mode : null,
|
||||
dns_address: !isEmpty(ctx.tun_dns_address) ? ctx.tun_dns_address : null,
|
||||
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
|
||||
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
|
||||
udp_nat_max: ctx.udp_nat_max,
|
||||
stack: ctx.tcpip_stack,
|
||||
});
|
||||
|
||||
config.inbounds = inbounds;
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/outbound.uc: build the sing-box `outbounds` and `endpoints`
|
||||
* blocks, plus the urltest pruning the route
|
||||
* builder depends on.
|
||||
*
|
||||
* Two builders live here:
|
||||
*
|
||||
* generate_endpoint(node, ctx) -> sing-box endpoint object (WireGuard only
|
||||
* at the moment; the tag convention is the same as for outbounds).
|
||||
*
|
||||
* generate_outbound(node, mark, direct_overrides) -> sing-box outbound
|
||||
* object. Side effect: a direct node carrying override_address /
|
||||
* override_port is recorded into the caller-supplied `direct_overrides`
|
||||
* map so the route builder can later emit the route-options action.
|
||||
* The Adapter (config/adapter.uc) already handles every protocol's
|
||||
* payload; this module is just the orchestration and the override side
|
||||
* effect.
|
||||
*
|
||||
* build_outbounds(dm, ctx, direct_overrides) -> { outbounds, endpoints }
|
||||
* Implements the routing-mode split (proxy vs custom), the urltest
|
||||
* pruning via keep_candidate / buildable_candidates, and the
|
||||
* routing_node -> outbound / endpoint resolution.
|
||||
*
|
||||
* The `direct_overrides` parameter is a mutable map the orchestrator owns
|
||||
* and threads through the generators. Making it explicit avoids the
|
||||
* "module-level side effect" the old generate_client.uc had, which was
|
||||
* the only thing tying outbound building to route building.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, strToBool, strToTime } from '../homeproxy.uc';
|
||||
|
||||
import { ConfigQuery } from '../config/model.uc';
|
||||
import { OutboundFactory, EndpointFactory } from '../config/adapter.uc';
|
||||
|
||||
import { get_outbound, get_resolver } from './common.uc';
|
||||
|
||||
/* --- single-endpoint / single-outbound builders ------------------------ */
|
||||
|
||||
/* WireGuard goes through the same Node as every other protocol; the
|
||||
* fields come from node.protocol_options (Loader's wireguard row) and
|
||||
* the cross-protocol options from node.common.
|
||||
*
|
||||
* This used to read the flat UCI keys (node.wireguard_*, and
|
||||
* node.tcp_fast_open / node.tcp_multi_path / node.udp_fragment) off a
|
||||
* Node. Those keys only existed on the flat section dict the
|
||||
* pre-refactor call sites passed; once A2/A3 made every call site pass
|
||||
* a Node they were simply null, so every WireGuard main / UDP /
|
||||
* urltest node emitted an endpoint with no private key and a peer with
|
||||
* no public key. tests/fixtures/generators/wireguard.uci guards it.
|
||||
*
|
||||
* The endpoint *shaping* lives in EndpointFactory in
|
||||
* config/adapter.uc, so the generator no longer holds protocol business
|
||||
* logic for WireGuard. This stays as the call sites' entry point, which
|
||||
* keeps the module's endpoint vocabulary without making every caller
|
||||
* import the adapter directly. */
|
||||
export function generate_endpoint(node, ctx) {
|
||||
return EndpointFactory.create(node, ctx);
|
||||
};
|
||||
|
||||
/* Build one outbound from a Node via the Adapter layer. A direct node
|
||||
* with override_address / override_port also gets recorded into the
|
||||
* caller's `direct_overrides` map so the route builder can emit the
|
||||
* route-options action; this is the only side effect of the function
|
||||
* beyond returning the outbound object. */
|
||||
export function generate_outbound(node, mark, direct_overrides) {
|
||||
/* Stage A3+A5: this used to be a 110-line ternary soup that built the
|
||||
* sing-box outbound by hand for every protocol. It now takes a
|
||||
* Node (looked up via ConfigQuery.node_by_id) and hands it straight
|
||||
* to OutboundFactory.create() (the Adapter layer).
|
||||
*
|
||||
* The direct-node override table is the one side effect that survives;
|
||||
* the Adapter layer has no place to record it, and the route builder
|
||||
* reads it later. */
|
||||
if (type(node) !== 'object' || isEmpty(node))
|
||||
return null;
|
||||
|
||||
if (node.type === 'direct' && (!isEmpty(node.protocol_options.override_address) || !isEmpty(node.protocol_options.override_port)))
|
||||
direct_overrides[node.id] = {
|
||||
override_address: node.protocol_options.override_address,
|
||||
override_port: strToInt(node.protocol_options.override_port)
|
||||
};
|
||||
|
||||
return OutboundFactory.create(node, mark);
|
||||
};
|
||||
|
||||
/* Keep only the candidate ids the Adapter can actually build.
|
||||
*
|
||||
* urltest member lists are the one place a broken node must not be fatal:
|
||||
* a single bad node in a 200-node subscription used to make the Adapter
|
||||
* die(), which aborted the whole generator, so the service could not start
|
||||
* at all. The node stays in UCI (the UI shows it and the next
|
||||
* subscription update can repair or drop it); it is only left out of the
|
||||
* urltest group, and the reason is logged.
|
||||
*
|
||||
* Nodes that routing rules or DNS actually resolve through are NOT pruned:
|
||||
* a rule pointing at a missing tag is invalid sing-box config, so those
|
||||
* stay fatal on purpose. */
|
||||
function keep_candidate(dm, id) {
|
||||
const node = ConfigQuery.node_by_id(dm, id);
|
||||
|
||||
if (!node) {
|
||||
warn(sprintf("homeproxy: urltest candidate '%s' no longer exists, skipping.", id));
|
||||
return false;
|
||||
}
|
||||
|
||||
const problems = OutboundFactory.problems(node);
|
||||
if (length(problems)) {
|
||||
warn(sprintf("homeproxy: skipping urltest candidate '%s': %s.", id, join(', ', problems)));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Map over a candidate id list, dropping the unbuildable ones. */
|
||||
function buildable_candidates(dm, ids) {
|
||||
return filter(ids || [], (id) => keep_candidate(dm, id));
|
||||
}
|
||||
|
||||
/* --- main-outbound building (proxy mode) ------------------------------- */
|
||||
|
||||
/* Decide whether a candidate node should be emitted as a sing-box endpoint
|
||||
* (WireGuard) or as an outbound (everything else). Returns the populated
|
||||
* object so the caller can retag it. */
|
||||
function emitNode(node, mark, tag, direct_overrides, ctx) {
|
||||
if (node && node.type === 'wireguard') {
|
||||
const endpoint = generate_endpoint(node, ctx);
|
||||
endpoint.tag = tag;
|
||||
return { kind: 'endpoint', object: endpoint };
|
||||
}
|
||||
|
||||
const outbound = generate_outbound(node, mark, direct_overrides);
|
||||
|
||||
/* generate_outbound() returns null for an empty node, and a reference can
|
||||
* point at a section that no longer exists: main_node and main_udp_node
|
||||
* are stored by name, and deleting a node in the UI does not rewrite
|
||||
* them. Setting `.tag` on null raised "left-hand side expression is
|
||||
* null", which says nothing about which reference is stale. */
|
||||
if (outbound == null)
|
||||
die(sprintf("cannot build %s: the node it refers to does not exist (it may have been deleted, or the reference is stale).\n", tag));
|
||||
|
||||
outbound.tag = tag;
|
||||
return { kind: 'outbound', object: outbound };
|
||||
}
|
||||
|
||||
function buildMainOutbounds(dm, config, ctx, direct_overrides) {
|
||||
const outbounds = config.outbounds;
|
||||
const endpoints = config.endpoints;
|
||||
const mark = ctx.self_mark;
|
||||
let urltest_nodes = [];
|
||||
|
||||
if (ctx.main_node === 'urltest') {
|
||||
const main_urltest_nodes = buildable_candidates(dm, ctx.main_urltest_nodes);
|
||||
const main_urltest_interval = ctx.main_urltest_interval;
|
||||
const main_urltest_tolerance = ctx.main_urltest_tolerance;
|
||||
|
||||
/* An empty group means every candidate was unbuildable: there is
|
||||
* nothing left to route through, so fail with the reason instead
|
||||
* of emitting an urltest outbound with no members. */
|
||||
if (isEmpty(main_urltest_nodes))
|
||||
die("no buildable node in the main urltest group, please check your configuration.\n");
|
||||
|
||||
push(outbounds, {
|
||||
type: 'urltest',
|
||||
tag: 'main-out',
|
||||
outbounds: map(main_urltest_nodes, (k) => `cfg-${k}-out`),
|
||||
interval: strToTime(main_urltest_interval),
|
||||
tolerance: strToInt(main_urltest_tolerance),
|
||||
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
|
||||
});
|
||||
urltest_nodes = main_urltest_nodes;
|
||||
} else {
|
||||
const main_node_cfg = ConfigQuery.node_by_id(dm, ctx.main_node);
|
||||
const emitted = emitNode(main_node_cfg, mark, 'main-out', direct_overrides, ctx);
|
||||
if (emitted.kind === 'endpoint')
|
||||
push(endpoints, emitted.object);
|
||||
else
|
||||
push(outbounds, emitted.object);
|
||||
}
|
||||
|
||||
if (ctx.main_udp_node === 'urltest') {
|
||||
const main_udp_urltest_nodes = buildable_candidates(dm, ctx.main_udp_urltest_nodes);
|
||||
const main_udp_urltest_interval = ctx.main_udp_urltest_interval;
|
||||
const main_udp_urltest_tolerance = ctx.main_udp_urltest_tolerance;
|
||||
|
||||
if (isEmpty(main_udp_urltest_nodes))
|
||||
die("no buildable node in the main UDP urltest group, please check your configuration.\n");
|
||||
|
||||
push(outbounds, {
|
||||
type: 'urltest',
|
||||
tag: 'main-udp-out',
|
||||
outbounds: map(main_udp_urltest_nodes, (k) => `cfg-${k}-out`),
|
||||
interval: strToTime(main_udp_urltest_interval),
|
||||
tolerance: strToInt(main_udp_urltest_tolerance),
|
||||
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
|
||||
});
|
||||
urltest_nodes = [...urltest_nodes, ...filter(main_udp_urltest_nodes, (l) => !~index(urltest_nodes, l))];
|
||||
} else if (ctx.dedicated_udp_node) {
|
||||
const main_udp_node_cfg = ConfigQuery.node_by_id(dm, ctx.main_udp_node);
|
||||
const emitted = emitNode(main_udp_node_cfg, mark, 'main-udp-out', direct_overrides, ctx);
|
||||
if (emitted.kind === 'endpoint')
|
||||
push(endpoints, emitted.object);
|
||||
else
|
||||
push(outbounds, emitted.object);
|
||||
}
|
||||
|
||||
for (let i in urltest_nodes) {
|
||||
const urltest_node = ConfigQuery.node_by_id(dm, i);
|
||||
if (!urltest_node)
|
||||
continue;
|
||||
const emitted = emitNode(urltest_node, mark, 'cfg-' + i + '-out', direct_overrides, ctx);
|
||||
if (emitted.kind === 'endpoint')
|
||||
push(endpoints, emitted.object);
|
||||
else
|
||||
push(outbounds, emitted.object);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- custom-mode outbound building (routing_node iteration) ------------- */
|
||||
|
||||
function buildCustomOutbounds(dm, config, ctx, direct_overrides) {
|
||||
const outbounds = config.outbounds;
|
||||
const endpoints = config.endpoints;
|
||||
const mark = ctx.self_mark;
|
||||
let urltest_nodes = [],
|
||||
routing_nodes = [];
|
||||
|
||||
for (let cfg in dm.routing.nodes) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
if (cfg.node === 'urltest') {
|
||||
const cfg_urltest_nodes = buildable_candidates(dm, cfg.urltest_nodes);
|
||||
push(outbounds, {
|
||||
type: 'urltest',
|
||||
tag: 'cfg-' + cfg.name + '-out',
|
||||
outbounds: map(cfg_urltest_nodes, (k) => `cfg-${k}-out`),
|
||||
url: cfg.urltest_url,
|
||||
interval: strToTime(cfg.urltest_interval),
|
||||
tolerance: strToInt(cfg.urltest_tolerance),
|
||||
idle_timeout: strToTime(cfg.urltest_idle_timeout),
|
||||
interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections)
|
||||
});
|
||||
urltest_nodes = [...urltest_nodes, ...filter(cfg_urltest_nodes, (l) => !~index(urltest_nodes, l))];
|
||||
} else {
|
||||
const outbound = ConfigQuery.node_by_id(dm, cfg.node);
|
||||
|
||||
/* Same stale-reference case as emitNode(), but here the reference
|
||||
* comes from a routing_node. `|| {}` made it look non-null and the
|
||||
* failure surfaced as a null dereference two lines later. */
|
||||
if (isEmpty(outbound))
|
||||
die(sprintf("routing node '%s' refers to a node that does not exist.\n", cfg['.name']));
|
||||
|
||||
if (outbound.type === 'wireguard') {
|
||||
/* WireGuard goes through generate_endpoint() which tags
|
||||
* the endpoint as cfg-<node_id>-out (same convention as
|
||||
* generate_outbound()). The routing_node section name is
|
||||
* used by routing rules via get_outbound() but not as the
|
||||
* tag itself; both paths agree on cfg-<node_id>-out. */
|
||||
const endpoint = generate_endpoint(outbound, ctx);
|
||||
endpoint.bind_interface = cfg.bind_interface;
|
||||
endpoint.detour = get_outbound(cfg.outbound, dm);
|
||||
if (cfg.domain_resolver)
|
||||
endpoint.domain_resolver = {
|
||||
server: get_resolver(cfg.domain_resolver, dm),
|
||||
strategy: cfg.domain_strategy
|
||||
};
|
||||
push(endpoints, endpoint);
|
||||
} else {
|
||||
const ob = generate_outbound(outbound, mark, direct_overrides);
|
||||
|
||||
if (ob == null)
|
||||
die(sprintf("routing node '%s' cannot be built from node '%s'.\n", cfg['.name'], cfg.node));
|
||||
|
||||
ob.bind_interface = cfg.bind_interface;
|
||||
ob.detour = get_outbound(cfg.outbound, dm);
|
||||
if (cfg.domain_resolver)
|
||||
ob.domain_resolver = {
|
||||
server: get_resolver(cfg.domain_resolver, dm),
|
||||
strategy: cfg.domain_strategy
|
||||
};
|
||||
push(outbounds, ob);
|
||||
}
|
||||
push(routing_nodes, cfg.node);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i in filter (urltest_nodes, (l) => !~index(routing_nodes, l))) {
|
||||
const urltest_node = ConfigQuery.node_by_id(dm, i) || {};
|
||||
if (urltest_node.type === 'wireguard')
|
||||
push(endpoints, generate_endpoint(urltest_node, ctx));
|
||||
else
|
||||
push(outbounds, generate_outbound(urltest_node, mark, direct_overrides));
|
||||
}
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* Build the sing-box `outbounds` and `endpoints` blocks. Mutates
|
||||
* config.endpoints and config.outbounds; the caller initialises them
|
||||
* with the always-on default outbounds (direct-out + block-out) and
|
||||
* passes the mutable accumulator in `ctx.acc` so the same arrays can
|
||||
* be threaded through this module, the ruleset module and the route
|
||||
* module without copying.
|
||||
*
|
||||
* `direct_overrides` is the same mutable map the route module will read
|
||||
* later; populate it here so the override information is available
|
||||
* when the route builder runs. */
|
||||
export function build_outbounds(config, dm, ctx, direct_overrides) {
|
||||
if (isEmpty(ctx.main_node) && isEmpty(ctx.default_outbound))
|
||||
return config;
|
||||
|
||||
if (!isEmpty(ctx.main_node))
|
||||
buildMainOutbounds(dm, config, ctx, direct_overrides);
|
||||
else if (!isEmpty(ctx.default_outbound))
|
||||
buildCustomOutbounds(dm, config, ctx, direct_overrides);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/route.uc: build the sing-box `route` block.
|
||||
*
|
||||
* The route block has the same routing-mode split as the DNS block:
|
||||
*
|
||||
* proxy mode - dns-in hijack, sniff, the bypass_mainland_china
|
||||
* resolve + geoip-cn split, dedicated UDP route,
|
||||
* main_override route-options, main-out final,
|
||||
* plus the static inline + remote rule_sets
|
||||
* (direct-domain / proxy-domain / geoip-cn /
|
||||
* geosite-cn).
|
||||
* custom mode - the user-defined routing_node -> outbound graph,
|
||||
* routing_rule iteration with the 1.14 action-specific
|
||||
* fields, find_neighbor, the optional resolve prefix,
|
||||
* final_outbound resolution, final_override
|
||||
* route-options, and the rule_set user-defined loop
|
||||
* (the loop itself is in ruleset.uc).
|
||||
*
|
||||
* The orchestrator owns config.route.rule_set: route.uc may push static
|
||||
* entries (proxy mode), ruleset.uc may push user entries (custom mode),
|
||||
* and build_http_clients() runs at the end to normalise the legacy
|
||||
* download_detour into 1.14's http_clients / http_client pairing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, strToTime, strToBool, parse_port } from '../homeproxy.uc';
|
||||
|
||||
|
||||
import { get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
|
||||
|
||||
/* --- shared initial block (every routing mode) ------------------------- */
|
||||
|
||||
/* Initialise config.route with the always-on rules and the defaults
|
||||
* that every mode shares (dns-in hijack, sniff, optional auto_detect).
|
||||
* The caller must have created an empty config.route; we mutate it.
|
||||
*
|
||||
* Sniff tuning follows §2.7 of docs/linux.json 与 pro 的差距分析.md:
|
||||
* the default rule is the conservative 300ms / unconstrained sniffer list
|
||||
* (tutorial demo recommends 100ms + ['http','tls','stun','quic','dns'],
|
||||
* which costs less CPU but is a behaviour change). sniffer_advanced_mode
|
||||
* is the opt-in switch that flips to the tutorial profile. */
|
||||
function initRoute(config, ctx) {
|
||||
const sniff_rule = (ctx.sniffer_advanced_mode === '1')
|
||||
? {
|
||||
action: 'sniff',
|
||||
sniffer: ['http', 'tls', 'stun', 'quic', 'dns'],
|
||||
timeout: '100ms'
|
||||
}
|
||||
: {
|
||||
action: 'sniff',
|
||||
timeout: '300ms'
|
||||
};
|
||||
config.route = {
|
||||
rules: [
|
||||
{
|
||||
inbound: 'dns-in',
|
||||
action: 'hijack-dns'
|
||||
},
|
||||
sniff_rule
|
||||
],
|
||||
rule_set: [],
|
||||
/* auto_detect_interface: explicit true when the user did not
|
||||
* pin a default_interface, explicit false when they did - not
|
||||
* null (the previous "null means no field" shape silently kept
|
||||
* the auto-detect on when the user expected it off). */
|
||||
auto_detect_interface: isEmpty(ctx.default_interface) ? true : false,
|
||||
default_interface: ctx.default_interface
|
||||
};
|
||||
}
|
||||
|
||||
/* --- proxy mode route rules + rule_set --------------------------------- */
|
||||
|
||||
function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
/* Resolve outbound server domains through the WAN default resolver.
|
||||
Do not use china-dns here: china-dns is for resolving mainland China
|
||||
destinations, not the proxy node itself. Coupling node bootstrap to
|
||||
china_dns_server can break dialing when that resolver is polluted or
|
||||
unsuitable for the node domain. */
|
||||
config.route.default_domain_resolver = {
|
||||
server: 'default-dns',
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
|
||||
};
|
||||
|
||||
/* Direct list */
|
||||
if (length(ctx.direct_domain_list))
|
||||
push(config.route.rules, {
|
||||
rule_set: 'direct-domain',
|
||||
action: 'route',
|
||||
outbound: 'direct-out'
|
||||
});
|
||||
|
||||
/* Bypass CN traffic: resolve the destination first, then route by IP.
|
||||
sing-box does not match an IP-based rule set (geoip-cn) against a domain
|
||||
destination unless it is resolved first, so add an explicit resolve
|
||||
action; geoip-cn then sends China IPs to direct and everything else falls
|
||||
through to main-out (proxy). This avoids relying on the geosite-* domain
|
||||
lists, which can mis-classify foreign domains (e.g. Google's gvt2.com
|
||||
beacons) as "cn" and send them direct to time out. Keep the direct-domain
|
||||
fast-path above for known direct domains. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
push(config.route.rules, {
|
||||
action: 'resolve',
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
|
||||
});
|
||||
push(config.route.rules, {
|
||||
rule_set: 'geoip-cn',
|
||||
action: 'route',
|
||||
outbound: 'direct-out'
|
||||
});
|
||||
}
|
||||
|
||||
/* Main UDP out */
|
||||
if (ctx.dedicated_udp_node) {
|
||||
const udp_override = direct_overrides[ctx.main_udp_node] || null;
|
||||
push(config.route.rules, {
|
||||
network: 'udp',
|
||||
action: 'route',
|
||||
outbound: 'main-udp-out',
|
||||
override_address: udp_override ? udp_override.override_address : null,
|
||||
override_port: udp_override ? udp_override.override_port : null
|
||||
});
|
||||
}
|
||||
|
||||
/* Direct-node destination override, emitted as route-options action
|
||||
(direct outbound options removed since sing-box 1.13) */
|
||||
const main_override = direct_overrides[ctx.main_node] || null;
|
||||
if (main_override)
|
||||
push(config.route.rules, {
|
||||
action: 'route-options',
|
||||
override_address: main_override.override_address,
|
||||
override_port: main_override.override_port
|
||||
});
|
||||
|
||||
config.route.final = 'main-out';
|
||||
|
||||
/* --- proxy-mode rule_set block ----------------------------------- */
|
||||
|
||||
/* Direct list */
|
||||
if (length(ctx.direct_domain_list))
|
||||
push(config.route.rule_set, {
|
||||
type: 'inline',
|
||||
tag: 'direct-domain',
|
||||
rules: [
|
||||
{
|
||||
domain_keyword: ctx.direct_domain_list,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/* Proxy list */
|
||||
if (length(ctx.proxy_domain_list))
|
||||
push(config.route.rule_set, {
|
||||
type: 'inline',
|
||||
tag: 'proxy-domain',
|
||||
rules: [
|
||||
{
|
||||
domain_keyword: ctx.proxy_domain_list,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
/*
|
||||
* Fetched straight from the upstream SagerNet repositories and
|
||||
* downloaded through the selected node. A direct fetch depends on
|
||||
* the CDN staying reachable from mainland China, where DNS pollution
|
||||
* makes it fail intermittently; the files total ~250 KB per
|
||||
* day, so proxying the download costs almost nothing.
|
||||
*
|
||||
* Both geosite-cn and geoip-cn are still emitted, but they are
|
||||
* consumed by different blocks:
|
||||
*
|
||||
* geoip-cn - referenced by the route rule above (line 105) to
|
||||
* split CN destinations to direct-out.
|
||||
* geosite-cn - referenced only by the DNS rule in
|
||||
* generator/dns.uc line 135-139 (rule_set: geosite-cn
|
||||
* -> server: china-dns); the route layer never
|
||||
* matches it. Sing-box still loads and keeps the
|
||||
* rule-set in memory even when only one block
|
||||
* references it, so removing it would also break the
|
||||
* DNS split.
|
||||
*
|
||||
* History: a third geosite-noncn used to be declared here as well,
|
||||
* but no rule referenced it and sing-box still loaded and updated
|
||||
* it daily, so it was removed (the comment above was rewritten
|
||||
* during the 2026-09-29 review to clarify that geosite-cn is still
|
||||
* in use - the DNS side - just not from this block).
|
||||
*/
|
||||
push(config.route.rule_set, {
|
||||
type: 'remote',
|
||||
tag: 'geoip-cn',
|
||||
format: 'binary',
|
||||
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
|
||||
update_interval: '24h',
|
||||
download_detour: 'main-out'
|
||||
});
|
||||
push(config.route.rule_set, {
|
||||
type: 'remote',
|
||||
tag: 'geosite-cn',
|
||||
format: 'binary',
|
||||
url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
|
||||
update_interval: '24h',
|
||||
download_detour: 'main-out'
|
||||
});
|
||||
}
|
||||
|
||||
if (isEmpty(config.route.rule_set))
|
||||
config.route.rule_set = null;
|
||||
}
|
||||
|
||||
/* --- custom-mode route rules ------------------------------------------- */
|
||||
|
||||
function build_route_custom(config, dm, ctx, direct_overrides) {
|
||||
config.route.default_domain_resolver = {
|
||||
server: get_resolver(ctx.default_outbound_dns, dm)
|
||||
};
|
||||
|
||||
if (ctx.find_neighbor === '1')
|
||||
config.route.find_neighbor = true;
|
||||
|
||||
if (ctx.domain_strategy)
|
||||
push(config.route.rules, {
|
||||
action: 'resolve',
|
||||
strategy: ctx.domain_strategy
|
||||
});
|
||||
|
||||
for (let cfg in dm.routing.rules) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
const rule_outbound = get_outbound(cfg.outbound, dm);
|
||||
const rule_direct_override = get_direct_override(cfg.outbound, dm, direct_overrides);
|
||||
let rule_override_address = cfg.override_address,
|
||||
rule_override_port = strToInt(cfg.override_port);
|
||||
if (isEmpty(rule_override_address) && isEmpty(rule_override_port) && rule_direct_override) {
|
||||
rule_override_address = rule_direct_override.override_address;
|
||||
rule_override_port = rule_direct_override.override_port;
|
||||
}
|
||||
|
||||
/* sing-box routing rule: emit match fields plus action-specific fields.
|
||||
client is the sniffed client type (only set with protocol quic/ssh);
|
||||
resolve adds server/strategy/disable_cache/rewrite_ttl/client_subnet;
|
||||
reject adds method/no_drop. Emitted conditionally so a given action
|
||||
never carries fields sing-box rejects for it. */
|
||||
const rule = {
|
||||
ip_version: strToInt(cfg.ip_version),
|
||||
protocol: cfg.protocol,
|
||||
network: cfg.network,
|
||||
client: cfg.client,
|
||||
domain: cfg.domain,
|
||||
domain_suffix: cfg.domain_suffix,
|
||||
domain_keyword: cfg.domain_keyword,
|
||||
domain_regex: cfg.domain_regex,
|
||||
source_ip_cidr: cfg.source_ip_cidr,
|
||||
source_ip_is_private: strToBool(cfg.source_ip_is_private),
|
||||
ip_cidr: cfg.ip_cidr,
|
||||
ip_is_private: strToBool(cfg.ip_is_private),
|
||||
source_port: parse_port(cfg.source_port),
|
||||
source_port_range: cfg.source_port_range,
|
||||
port: parse_port(cfg.port),
|
||||
port_range: cfg.port_range,
|
||||
process_name: cfg.process_name,
|
||||
process_path: cfg.process_path,
|
||||
process_path_regex: cfg.process_path_regex,
|
||||
user: cfg.user,
|
||||
rule_set: get_ruleset(cfg.rule_set, dm),
|
||||
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
|
||||
invert: strToBool(cfg.invert),
|
||||
action: cfg.action,
|
||||
source_mac_address: cfg.source_mac_address,
|
||||
source_hostname: cfg.source_hostname
|
||||
};
|
||||
|
||||
/* `route-options` only takes the override_* fields; `outbound` is the
|
||||
* `route` action's field. The two used to share one branch and
|
||||
* `route-options` ended up carrying an `outbound` sing-box then
|
||||
* refused as "unknown field" - the configuration as a whole was
|
||||
* rejected and the user only saw "setting did not take".
|
||||
* Match fields above are valid for every action, so the gates below
|
||||
* cover the action-specific payload only. */
|
||||
if (cfg.action === 'route' || cfg.action === 'route-options') {
|
||||
rule.override_address = rule_override_address;
|
||||
rule.override_port = rule_override_port;
|
||||
rule.udp_disable_domain_unmapping = strToBool(cfg.udp_disable_domain_unmapping);
|
||||
rule.udp_connect = strToBool(cfg.udp_connect);
|
||||
rule.udp_timeout = strToTime(cfg.udp_timeout);
|
||||
rule.tls_fragment = strToBool(cfg.tls_fragment);
|
||||
rule.tls_fragment_fallback_delay = strToTime(cfg.tls_fragment_fallback_delay);
|
||||
rule.tls_record_fragment = strToBool(cfg.tls_record_fragment);
|
||||
rule.tls_spoof = cfg.tls_spoof || null;
|
||||
rule.tls_spoof_method = cfg.tls_spoof_method || null;
|
||||
}
|
||||
/* `route` alone is the action that picks a destination - `route-options`
|
||||
* adjusts the existing connection, so it does not take `outbound`. */
|
||||
if (cfg.action === 'route')
|
||||
rule.outbound = rule_outbound;
|
||||
|
||||
if (cfg.action === 'resolve') {
|
||||
rule.server = get_resolver(cfg.resolve_server, dm);
|
||||
rule.strategy = cfg.resolve_strategy;
|
||||
rule.disable_cache = strToBool(cfg.resolve_disable_cache);
|
||||
rule.disable_optimistic_cache = strToBool(cfg.resolve_disable_optimistic_cache);
|
||||
rule.rewrite_ttl = strToInt(cfg.resolve_rewrite_ttl);
|
||||
rule.timeout = strToTime(cfg.resolve_timeout);
|
||||
rule.client_subnet = cfg.resolve_client_subnet;
|
||||
}
|
||||
if (cfg.action === 'reject') {
|
||||
rule.method = cfg.reject_method;
|
||||
rule.no_drop = strToBool(cfg.reject_no_drop);
|
||||
}
|
||||
push(config.route.rules, rule);
|
||||
}
|
||||
|
||||
/* Direct-node destination override, emitted as route-options action
|
||||
(direct outbound options removed since sing-box 1.13) */
|
||||
const final_override = get_direct_override(ctx.default_outbound, dm, direct_overrides);
|
||||
if (final_override)
|
||||
push(config.route.rules, {
|
||||
action: 'route-options',
|
||||
override_address: final_override.override_address,
|
||||
override_port: final_override.override_port
|
||||
});
|
||||
|
||||
config.route.final = get_outbound(ctx.default_outbound, dm);
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
export function build_route(config, dm, ctx, direct_overrides) {
|
||||
initRoute(config, ctx);
|
||||
|
||||
if (!isEmpty(ctx.main_node))
|
||||
build_route_proxy(config, dm, ctx, direct_overrides);
|
||||
else if (!isEmpty(ctx.default_outbound))
|
||||
build_route_custom(config, dm, ctx, direct_overrides);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/ruleset.uc: user-defined rule_set entries + the sing-box
|
||||
* 1.14 http_clients normalisation.
|
||||
*
|
||||
* Two distinct pieces share this module because they both iterate over
|
||||
* `config.route.rule_set` after every other module has had a chance to
|
||||
* append:
|
||||
*
|
||||
* build_user_rulesets(rule_set_array, dm, ctx)
|
||||
* The custom-mode `dm.routing.rulesets` loop, which builds the
|
||||
* user-defined rule_set entries (cfg-<name>-rule tag) and pre-1.14
|
||||
* download_detour. The legacy field is stripped later, in the same
|
||||
* module, by build_http_clients(); leaving it here would make
|
||||
* sing-box 1.14 reject the config.
|
||||
*
|
||||
* build_http_clients(rule_set_array, dm, ctx)
|
||||
* sing-box 1.14 dropped download_detour from local/inline rule-sets
|
||||
* and moved the remote rule-set fetch into top-level http_clients.
|
||||
* This walks every entry, strips download_detour (sing-box 1.14
|
||||
* rejects it on local/inline), and emits a http_clients array whose
|
||||
* `detour` is dropped for pure-TUN setups (direct outbound with no
|
||||
* self_mark; sing-box rejects that detour).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, validateHomeProxyPath } from '../homeproxy.uc';
|
||||
|
||||
import { get_outbound, isDirectOutboundTag } from './common.uc';
|
||||
|
||||
/* --- user-defined rule_set entries (custom mode only) ----------------- */
|
||||
|
||||
/* Append user-defined rule_set entries to `rule_set_array`. Each cfg
|
||||
* produces one entry; `extra_tags` adds additional rule_set tags that
|
||||
* share the same fetch source (sing-box 1.14 multi-tag). */
|
||||
export function build_user_rulesets(rule_set_array, dm, ctx) {
|
||||
for (let cfg in dm.routing.rulesets) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
const extra_tags = cfg.extra_tags || [];
|
||||
let rs_tag = 'cfg-' + cfg.name + '-rule';
|
||||
if (length(extra_tags) && cfg.type !== 'inline') {
|
||||
rs_tag = [rs_tag];
|
||||
for (let t in extra_tags)
|
||||
push(rs_tag, 'cfg-' + t + '-rule');
|
||||
/* sing-box 1.14: multi-tag requires a {tag} placeholder in the fetch source
|
||||
(remote: url and initial_path, local: path). A missing
|
||||
placeholder makes sing-box try to literal-substitute the
|
||||
first tag name and reject the whole config; the reload then
|
||||
keeps the previous one and the user only sees "my change did
|
||||
not take". die() early so the saved UCI is rejected at
|
||||
apply time, the same way get_resolver/get_ruleset already
|
||||
fail loud on a missing/disabled reference. */
|
||||
const fetch_ref = (cfg.type === 'remote') ? (cfg.url || '') : (cfg.path || '');
|
||||
if (!match(fetch_ref, /\{tag\}/))
|
||||
die(sprintf("homeproxy: rule-set '%s' uses extra tags but its %s source lacks a {tag} placeholder; add {tag} to the source or drop the extra tags.", cfg.name, cfg.type));
|
||||
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path) && !match(cfg.initial_path, /\{tag\}/))
|
||||
die(sprintf("homeproxy: rule-set '%s' uses extra tags but its initial_path lacks a {tag} placeholder.", cfg.name));
|
||||
}
|
||||
|
||||
/* Local rule-set path is read by sing-box as root. The whitelist
|
||||
* is enforced here because the LuCI form's datatype='file' is
|
||||
* only UX - UCI can be set from anywhere on the LAN, and an
|
||||
* arbitrary /etc/passwd would leak the file to anyone who
|
||||
* could write UCI. die() early (same as get_resolver on a
|
||||
* missing/disabled dns_server): silently dropping path left
|
||||
* sing-box checking whatever the field evaluated to, and the
|
||||
* user only saw the reload keep the previous config. */
|
||||
if (cfg.type === 'local' && cfg.path && !validateHomeProxyPath(cfg.path))
|
||||
die(sprintf("homeproxy: rule-set '%s' path '%s' is outside the homeproxy whitelist; choose a path under /etc/homeproxy/.", cfg.name, cfg.path));
|
||||
|
||||
const ruleset = {
|
||||
type: cfg.type,
|
||||
tag: rs_tag,
|
||||
format: cfg.format,
|
||||
path: cfg.path,
|
||||
url: cfg.url,
|
||||
update_interval: cfg.update_interval
|
||||
};
|
||||
/* download_detour is a pre-1.14 option that only makes sense for
|
||||
remote rule-sets; emitting it for local/inline ones makes sing-box
|
||||
* 1.14 reject the whole config. It is translated into http_clients
|
||||
right below. */
|
||||
if (cfg.type === 'remote')
|
||||
ruleset.download_detour = get_outbound(cfg.outbound, dm) || get_outbound(ctx.default_outbound, dm);
|
||||
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
|
||||
/* initial_path is read off the local disk as root; same
|
||||
* whitelist as the local ruleset path. */
|
||||
ruleset.initial_path = validateHomeProxyPath(cfg.initial_path) ? cfg.initial_path : null;
|
||||
push(rule_set_array, ruleset);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- http_clients normalisation (every routing mode) ------------------ */
|
||||
|
||||
/* Strip the legacy download_detour from every entry (sing-box 1.14
|
||||
* rejects it on local/inline), and for remote rule-sets translate it
|
||||
* into the http_client tag pointing at the corresponding http_clients
|
||||
* entry. Returns the http_clients array (caller attaches it to
|
||||
* config.http_clients) and mutates the rule_set entries in place. */
|
||||
export function build_http_clients(rule_set_array, dm, ctx) {
|
||||
const http_clients = [];
|
||||
const http_seen = {};
|
||||
for (let rs in (rule_set_array || [])) {
|
||||
/* Strip the legacy field from every entry first, including the
|
||||
local/inline ones: sing-box 1.14 rejects it everywhere except on
|
||||
remote rule-sets, where it is replaced by http_client below. */
|
||||
let detour = rs.download_detour;
|
||||
delete rs.download_detour;
|
||||
|
||||
if (rs.type !== 'remote')
|
||||
continue;
|
||||
|
||||
if (isEmpty(detour))
|
||||
detour = (ctx.routing_mode === 'custom') ? (get_outbound(ctx.default_outbound, dm) || 'direct-out') : 'direct-out';
|
||||
|
||||
const tag = 'hp-' + detour;
|
||||
rs.http_client = tag;
|
||||
if (!http_seen[detour]) {
|
||||
http_seen[detour] = true;
|
||||
/* sing-box 1.14 rejects detouring to an empty direct outbound
|
||||
(pure TUN mode has no self_mark on direct-out). Omitting detour
|
||||
uses the same system direct dialer, so behavior is unchanged. */
|
||||
const client = { tag: tag };
|
||||
if (!(isEmpty(ctx.self_mark) && isDirectOutboundTag(detour, dm)))
|
||||
client.detour = detour;
|
||||
push(http_clients, client);
|
||||
}
|
||||
}
|
||||
return http_clients;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/server.uc: server-side orchestrator.
|
||||
*
|
||||
* This module no longer shapes
|
||||
* any protocol. It used to read the flat UCI section directly
|
||||
* (`cfg.snell_version`, `cfg.shadowsocks_encrypt_method`,
|
||||
* `cfg.hysteria_obfs_min_packet_size`, ...) and spell out every
|
||||
* per-protocol sing-box field inline, which meant the server path had
|
||||
* no domain model and adding a protocol touched the generator.
|
||||
*
|
||||
* Now:
|
||||
*
|
||||
* UCI -> Loader.load_inbound() -> Inbound (domain) -> InboundFactory
|
||||
* -> sing-box inbound JSON
|
||||
*
|
||||
* The protocol knowledge lives in config/model.uc (the canonical <->
|
||||
* UCI tables) and config/adapter.uc (InboundFactory's sing-box field
|
||||
* tables). What is left here is the orchestration: gate on `enabled`,
|
||||
* build the log block, and decide that "no inbound" means "server
|
||||
* disabled" (the CLI shell turns the null into exit 1).
|
||||
*
|
||||
* generate_server(dm) -> config object. Pure: no UCI, no file I/O.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { InboundFactory } from '../config/adapter.uc';
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* Build the sing-box server config object. Returns null when no
|
||||
* enabled inbound exists (the caller treats that as "service disabled",
|
||||
* which the runtime interprets as "do not start the server instance"). */
|
||||
export function generate_server(dm) {
|
||||
const log_level = ((dm.server || {}).settings || {}).log_level || 'warn';
|
||||
|
||||
const config = {
|
||||
log: {
|
||||
disabled: false,
|
||||
level: log_level,
|
||||
output: '/var/run/homeproxy/sing-box-s.log',
|
||||
timestamp: true
|
||||
},
|
||||
inbounds: []
|
||||
};
|
||||
|
||||
for (let inbound in ((dm.server || {}).inbounds || [])) {
|
||||
if (!inbound.enabled)
|
||||
continue;
|
||||
|
||||
/* create() dies on a broken section, which is the previous
|
||||
* behaviour too: the old code would emit a sing-box config
|
||||
* that `sing-box check` then rejected, and the runtime's
|
||||
* candidate/known-good path caught it. Failing here instead
|
||||
* names the offending section in the log. */
|
||||
push(config.inbounds, InboundFactory.create(inbound));
|
||||
}
|
||||
|
||||
if (length(config.inbounds) === 0)
|
||||
return null;
|
||||
|
||||
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,819 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2023 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
import { access, lstat, mkdtemp, open, rmdir, unlink } from 'fs';
|
||||
import { urldecode_params } from 'luci.http';
|
||||
|
||||
/* Global variables start */
|
||||
export const HP_DIR = '/etc/homeproxy';
|
||||
export const RUN_DIR = '/var/run/homeproxy';
|
||||
/* Where the UCI configuration lives. This is /etc/config - NOT HP_DIR/config.
|
||||
* The package ships its config as the conffile /etc/config/homeproxy, and
|
||||
* ucode's `cursor(dir)` treats `dir` as the *confdir*, so
|
||||
* cursor(HP_DIR + '/config') would read /etc/homeproxy/config/homeproxy, a
|
||||
* path that exists nowhere. uci.load() then returns null, every uci.get()
|
||||
* returns null, and the Loader silently yields pure defaults: no main-out,
|
||||
* no route/direct final, none of the user's ports or nodes.
|
||||
* Verified on a device: cursor('/etc/homeproxy/config').get(...) is empty
|
||||
* while cursor().get(...) returns the configured value.
|
||||
* The test suite stages a rewritten copy of this constant instead of the
|
||||
* old `__LOADER_DIR__` source sed. */
|
||||
export const UCICONFIG_DIR = '/etc/config';
|
||||
/* Largest subscription body we will fetch, in bytes. See wGETVerbose(). */
|
||||
export const HP_FETCH_CAP = 5 * 1024 * 1024;
|
||||
/* Global variables end */
|
||||
|
||||
/* Utilities start */
|
||||
/* Kanged from luci-app-commands */
|
||||
export function shellQuote(s) {
|
||||
return `'${replace(s, "'", "'\\''")}'`;
|
||||
};
|
||||
|
||||
export function isBinary(str) {
|
||||
for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off))
|
||||
if (byte <= 8 || (byte >= 14 && byte <= 31))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/* Whitelist absolute paths the generators are allowed to put into
|
||||
* sing-box config. sing-box runs as root and reads the file itself; an
|
||||
* arbitrary UCI value of e.g. /etc/passwd would leak the file to anyone
|
||||
* who could write to the UCI tree. The LuCI UI already gates the input
|
||||
* via validateCertificatePath() in homeproxy.js, but UCI can be set from
|
||||
* any node on the LAN (subscription updates, scripted edits) and the
|
||||
* UI check is only UX - we have to enforce on the backend.
|
||||
*
|
||||
* /etc/homeproxy/... - project-managed certs / ruleset paths / etc.
|
||||
* /tmp/homeproxy_* - upload staging files (cert_upload_* writes)
|
||||
*
|
||||
* Anything else (relative paths, /etc/passwd, /var/run/...) returns false
|
||||
* and the caller is expected to die() / warn() / strip the value. */
|
||||
export function validateHomeProxyPath(p) {
|
||||
if (!p || type(p) !== 'string')
|
||||
return false;
|
||||
|
||||
/* Reject traversal *before* the prefix checks below: a plain prefix
|
||||
* comparison accepts '/etc/homeproxy/../../etc/shadow', and sing-box reads
|
||||
* these paths as root. */
|
||||
if (match(p, /(^|\/)\.\.(\/|$)/))
|
||||
return false;
|
||||
|
||||
/* Reject anything that does not start with '/' - relative paths in
|
||||
* sing-box resolve against the process CWD, which is /tmp at boot
|
||||
* but is not a position we want any UCI value to land in. */
|
||||
if (substr(p, 0, 1) !== '/')
|
||||
return false;
|
||||
|
||||
if (substr(p, 0, length('/etc/homeproxy/')) === '/etc/homeproxy/')
|
||||
return true;
|
||||
|
||||
if (substr(p, 0, length('/tmp/homeproxy_')) === '/tmp/homeproxy_')
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/* Certificates the sing-box instances are allowed to read.
|
||||
*
|
||||
* TLS certificate_path / key_path are not rule-set paths: a server's
|
||||
* certificate normally lives under /etc/ssl/ or under the ACME state in
|
||||
* /etc/acme/, and the two sing-box jails mount exactly those directories
|
||||
* (see runtime/service.sh's procd_add_jail_mount calls). validateHomeProxyPath()
|
||||
* only knows /etc/homeproxy/ and /tmp/homeproxy_, so a certificate the user
|
||||
* legitimately picked from /etc/ssl/ was accepted by the LuCI validator and
|
||||
* then silently dropped here - certificate_path became null and the TLS
|
||||
* listener failed with no error pointing at the path.
|
||||
*
|
||||
* The list is mirrored by HP_CERT_PATH_ROOTS in
|
||||
* htdocs/luci-static/resources/homeproxy.js; guard 29 in tests/arch-guard.sh
|
||||
* keeps the two in step.
|
||||
*/
|
||||
export const CERT_PATH_ROOTS = ['/etc/homeproxy/certs/', '/etc/acme/', '/etc/ssl/'];
|
||||
|
||||
/* validateCertificatePath(p) - the TLS certificate/key path gate.
|
||||
*
|
||||
* Same shape as validateHomeProxyPath(): reject traversal and relative paths,
|
||||
* then require one of CERT_PATH_ROOTS. Kept separate rather than folded into
|
||||
* validateHomeProxyPath() so a rule-set path cannot be pointed at /etc/ssl/
|
||||
* and a certificate cannot be pointed at /tmp/homeproxy_*. */
|
||||
export function validateCertificatePath(p) {
|
||||
if (!p || type(p) !== 'string')
|
||||
return false;
|
||||
|
||||
if (match(p, /(^|\/)\.\.(\/|$)/))
|
||||
return false;
|
||||
|
||||
if (substr(p, 0, 1) !== '/')
|
||||
return false;
|
||||
|
||||
for (let root in CERT_PATH_ROOTS)
|
||||
if (length(p) > length(root) && substr(p, 0, length(root)) === root)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/* Read at most `limit` bytes from a file, or '' when it does not exist.
|
||||
* The cap is deliberate: a command's output is not trustworthy input. */
|
||||
function read_capped(path, limit) {
|
||||
const f = open(path);
|
||||
|
||||
if (!f)
|
||||
return '';
|
||||
|
||||
const data = f.read(limit) ?? '';
|
||||
f.close();
|
||||
return data;
|
||||
};
|
||||
|
||||
/* Remove the scratch files and the directory created for one run. Best
|
||||
* effort: a command the shell could not even parse leaves no files behind,
|
||||
* and a failed cleanup must never mask the command's own result. */
|
||||
function cleanup_exec_dir(dir, outpath, errpath) {
|
||||
try {
|
||||
if (access(outpath))
|
||||
unlink(outpath);
|
||||
if (access(errpath))
|
||||
unlink(errpath);
|
||||
rmdir(dir);
|
||||
} catch (e) {
|
||||
/* nothing useful to do - the results are already captured */
|
||||
}
|
||||
};
|
||||
|
||||
export function executeCommand(...args) {
|
||||
const command = join(' ', args);
|
||||
const dir = mkdtemp();
|
||||
const outpath = dir + '/stdout';
|
||||
const errpath = dir + '/stderr';
|
||||
|
||||
let exitcode = null, stdout = '', stderr = '';
|
||||
|
||||
try {
|
||||
/* Redirect to real paths, not to the descriptors of two mkstemp()
|
||||
* files. The old form appended `>&N 2>&N`, which only works when
|
||||
* the child shell can see those descriptors; /bin/sh reports
|
||||
* "Bad file descriptor" (bash) / "Bad fd number" (dash) when it
|
||||
* cannot, so the command was never executed and every caller got
|
||||
* an empty result with exit status 2. That is what happened in
|
||||
* CI, where /bin/sh is dash. Redirecting to paths is plain POSIX
|
||||
* and behaves identically under busybox ash, dash and bash. */
|
||||
exitcode = system(sprintf('%s >%s 2>%s', command, outpath, errpath));
|
||||
|
||||
/* The reader has to be able to return one byte more than
|
||||
* HP_FETCH_CAP, or wGETVerbose()'s "response exceeds the limit"
|
||||
* branch below is unreachable: the reader used to stop at 512 KiB,
|
||||
* so every subscription between 512 KiB and 5 MiB was handed to the
|
||||
* parser as a truncated body with error: null - the size check
|
||||
* could never fire and the caller had no way to tell. stderr keeps
|
||||
* the smaller cap: it is a diagnostic message, not a payload. The
|
||||
* stderr_truncated flag on the result tells the caller the cap
|
||||
* actually fired, so a "reload exited with status N" line is not
|
||||
* the only signal that something went wrong upstream. */
|
||||
stdout = read_capped(outpath, HP_FETCH_CAP + 1);
|
||||
stderr = read_capped(errpath, 1024 * 512);
|
||||
} catch (e) {
|
||||
/* Never leave the scratch directory behind on a failing run.
|
||||
* ucode has no `finally` clause, so the exception is re-raised
|
||||
* by hand. */
|
||||
cleanup_exec_dir(dir, outpath, errpath);
|
||||
|
||||
die(e);
|
||||
}
|
||||
|
||||
const binary = isBinary(stdout);
|
||||
|
||||
/* Did stderr actually overflow the 512 KiB cap? The temp file is
|
||||
* unlinked right after this, so size is read while it still exists.
|
||||
* lstat() returns null on missing files, so the .size fallback is
|
||||
* defensive against an exception-driven early cleanup. */
|
||||
const stderr_size = (lstat(errpath) || {}).size || 0;
|
||||
const stderr_truncated = stderr_size > 1024 * 512;
|
||||
|
||||
cleanup_exec_dir(dir, outpath, errpath);
|
||||
|
||||
return {
|
||||
command,
|
||||
stdout: binary ? null : stdout,
|
||||
stderr,
|
||||
stderr_truncated,
|
||||
exitcode,
|
||||
binary
|
||||
};
|
||||
};
|
||||
|
||||
export function getTime(epoch) {
|
||||
const local_time = localtime(epoch);
|
||||
return replace(replace(sprintf(
|
||||
'%d-%2d-%2d@%2d:%2d:%2d',
|
||||
local_time.year,
|
||||
local_time.mon,
|
||||
local_time.mday,
|
||||
local_time.hour,
|
||||
local_time.min,
|
||||
local_time.sec
|
||||
), ' ', '0'), '@', ' ');
|
||||
|
||||
};
|
||||
|
||||
/* Redact the credential-bearing parts of a URL before logging it. Any
|
||||
* subscription URL we ship into /var/run/homeproxy/homeproxy.log is
|
||||
* readable by anyone who can read /var/run/homeproxy - including UCI
|
||||
* defaults that ship on the device and anyone with shell on the LAN.
|
||||
* (Confirmed on the device: the log is 0644.)
|
||||
*
|
||||
* Only the scheme, host and port are kept - enough to answer "which
|
||||
* provider/endpoint failed?". The userinfo, the path and the query string
|
||||
* all carry subscription tokens in practice: many providers hand out
|
||||
* `https://host:port/<user>/<token>` with no query string at all, which is
|
||||
* exactly what the device this was found on was configured with, and the
|
||||
* path used to survive the redaction. The original URL is still passed to
|
||||
* wGETVerbose.
|
||||
*
|
||||
* https://user:token@host.example.com/path/sub?q=abc&token=secret
|
||||
* -> https://***@host.example.com/***?*** */
|
||||
export function redactUrl(url) {
|
||||
if (!url || type(url) !== 'string')
|
||||
return '';
|
||||
|
||||
let u = url;
|
||||
|
||||
/* userinfo: scheme://user:pass@host -> scheme://***@host */
|
||||
const at = index(u, '@');
|
||||
const scheme = match(u, /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
|
||||
if (scheme && at !== -1 && at > length(scheme[0]))
|
||||
u = substr(u, 0, length(scheme[0])) + '***' + substr(u, at);
|
||||
|
||||
/* path and query: keep the authority, mark the rest. Splitting them
|
||||
* rather than masking from the first '/' keeps the structural markers
|
||||
* (`/***?***`) so a reader can still tell a path from a query, and it is
|
||||
* why the authority end is the *earlier* of the two separators. */
|
||||
const scheme_end = scheme ? length(scheme[0]) : 0;
|
||||
const rest = substr(u, scheme_end);
|
||||
const path_at = index(rest, '/');
|
||||
const query_at = index(rest, '?');
|
||||
const has_path = path_at !== -1 && (query_at === -1 || path_at < query_at);
|
||||
|
||||
let authority_end = length(rest);
|
||||
if (has_path)
|
||||
authority_end = path_at;
|
||||
else if (query_at !== -1)
|
||||
authority_end = query_at;
|
||||
|
||||
let tail = '';
|
||||
if (has_path)
|
||||
tail += '/***';
|
||||
if (query_at !== -1)
|
||||
tail += '?***';
|
||||
|
||||
u = substr(u, 0, scheme_end + authority_end) + tail;
|
||||
|
||||
return u;
|
||||
};
|
||||
|
||||
/* Scan a free-form error string and redact every URL in it. GNU wget's
|
||||
* stderr writes the requested URL back into the message:
|
||||
*
|
||||
* https://host/path?q=token=secret: Bad port '80080'.
|
||||
*
|
||||
* The trailing `:` separator is greedy-matched here, which is harmless -
|
||||
* redactUrl leaves it as-is. Doing this here, in wGETVerbose, means the
|
||||
* returned `error` is safe no matter where the caller ships it - the
|
||||
* fetcher logs it, the orchestrator returns it, anything that prints it
|
||||
* afterwards has already lost the token. Centralising the redaction at
|
||||
* the source is the point: every call site used to have to
|
||||
* remember to redact, and the one that forgot was the original bug.
|
||||
*
|
||||
* A non-string input is returned unchanged so this is safe to apply to
|
||||
* the trimmed wget stderr even when it is empty.
|
||||
*
|
||||
* Both definitions sit above wGETVerbose on purpose: ucode does not hoist
|
||||
* `export function`, so a call compiled before the declaration is bound
|
||||
* fails at runtime with "access to undeclared variable". */
|
||||
export function redactReason(reason) {
|
||||
if (!reason || type(reason) !== 'string')
|
||||
return reason;
|
||||
|
||||
return replace(reason, /https?:\/\/\S+/g, (url) => redactUrl(url));
|
||||
};
|
||||
|
||||
/*
|
||||
* Fetch a URL and report both the body and, on failure, the reason. The
|
||||
* reason is wget's own stderr (whitespace collapsed, length-capped) so the
|
||||
* caller can tell a DNS failure from a timeout or a TLS handshake error.
|
||||
*/
|
||||
export function wGETVerbose(url, ua) {
|
||||
if (!url || type(url) !== 'string')
|
||||
return { content: null, error: 'invalid URL' };
|
||||
|
||||
if (!ua)
|
||||
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
|
||||
|
||||
/* -nv (not -q) so wget still reports *why* a fetch failed on stderr.
|
||||
*
|
||||
* The size cap is enforced by piping through `head -c`, NOT with wget's
|
||||
* --max-filesize: that option does not exist in busybox wget (the target's
|
||||
* /usr/bin/wget), which exits 2 with "unrecognized option" before making a
|
||||
* single request - so every subscription fetch failed. GNU wget has no
|
||||
* such option either. `head` closing the pipe stops wget early, which
|
||||
* bounds the download; the cap is therefore CAP+1 bytes rather than
|
||||
* exactly CAP, and one byte past the limit means "too large".
|
||||
*
|
||||
* 5 MiB covers a 10 000-node subscription with ~3 KB per node plus the
|
||||
* base64 inflation. Anything larger is almost certainly an attack or a
|
||||
* misconfiguration.
|
||||
*
|
||||
* The pipeline does cost the exit status: `system()` returns head's, which
|
||||
* is always 0. A wget failure therefore arrives as an empty body plus
|
||||
* wget's own message on stderr, and that is reported below. */
|
||||
/* The braces matter: executeCommand() appends `>out 2>err` to the command,
|
||||
* and in `a | b >out 2>err` those redirections bind to b only - wget's
|
||||
* stderr would go to the caller's terminal and the failure message would
|
||||
* be lost. Grouping the pipeline makes both stream to the capture files. */
|
||||
const output = executeCommand(`{ /usr/bin/wget -nv -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)} | head -c ${HP_FETCH_CAP + 1}; }`) || {};
|
||||
let reason = trim(output.stderr || '');
|
||||
reason = reason ? replace(reason, /\s+/g, ' ') : '';
|
||||
/* Review H3: an HTTP-level wget failure echoes the full URL - query
|
||||
* string and subscription token included - as in
|
||||
* `https://host/path?token=secret: 404 Not Found`. (A pure connection
|
||||
* failure prints only `failed: Connection refused.` and leaks nothing,
|
||||
* but the 404/403 case is enough.) Redact at the source so every caller
|
||||
* of wGETVerbose gets a safe `error` whether or not it remembers to call
|
||||
* redactUrl itself. */
|
||||
if (reason)
|
||||
reason = redactReason(reason);
|
||||
|
||||
if (length(output.stdout || '') > HP_FETCH_CAP)
|
||||
return { content: null, error: `response exceeds the ${HP_FETCH_CAP} byte limit` };
|
||||
|
||||
if (output.exitcode !== 0) {
|
||||
if (length(reason) > 200)
|
||||
reason = substr(reason, 0, 200) + '...';
|
||||
|
||||
return { content: null, error: `wget exited with status ${output.exitcode}: ${reason || 'no error output'}` };
|
||||
}
|
||||
|
||||
/* head() masks wget's status, so a failed fetch shows up here instead. */
|
||||
if (!length(trim(output.stdout)) && reason) {
|
||||
if (length(reason) > 200)
|
||||
reason = substr(reason, 0, 200) + '...';
|
||||
|
||||
return { content: null, error: `wget failed: ${reason}` };
|
||||
}
|
||||
|
||||
return { content: trim(output.stdout), error: null };
|
||||
};
|
||||
|
||||
/* Utilities end */
|
||||
|
||||
/* String helper start */
|
||||
export function isEmpty(res) {
|
||||
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
|
||||
};
|
||||
|
||||
export function strToBool(str) {
|
||||
return str === '1' ? true : (str === '0' ? false : null);
|
||||
};
|
||||
|
||||
export function strToInt(str) {
|
||||
/* Zero reads back as "not set" on purpose: several sing-box fields mean
|
||||
* "omit me" at zero - a vmess user's alterId, for instance - and the
|
||||
* generators depend on removeBlankAttrs() dropping the null this produces.
|
||||
* A review flagged it as "a legitimate 0 becomes null"; the golden outbound
|
||||
* snapshot and the vmess alterId assertion both say otherwise. */
|
||||
return !isEmpty(str) ? (int(str) || null) : null;
|
||||
};
|
||||
|
||||
export function strToTime(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
/* Preserve values that already carry a time unit (e.g. "30s", "1m") */
|
||||
return match(str, /[a-zA-Z]$/) ? str : (str + 's');
|
||||
};
|
||||
|
||||
/* Turn a UCI list of port strings into the int array sing-box wants
|
||||
* (e.g. the WireGuard `reserved` list, a routing rule's `port` /
|
||||
* `source_port`). Returns null for anything that is not a non-empty
|
||||
* array, so a caller can let removeBlankAttrs() drop the field.
|
||||
*
|
||||
* Lives here rather than in generator/common.uc because the Adapter
|
||||
* layer needs it too (EndpointFactory builds the WireGuard endpoint)
|
||||
* and an adapter must not import from generator/. */
|
||||
export function parse_port(strport) {
|
||||
if (type(strport) !== 'array' || isEmpty(strport))
|
||||
return null;
|
||||
|
||||
let ports = [];
|
||||
for (let i in strport) {
|
||||
/* Only a bare decimal is a port: int() truncates, so '80-90' used to
|
||||
* become 80 and '443/tcp' become 443, silently widening the rule
|
||||
* instead of dropping the malformed entry. */
|
||||
if (match(i, /^[0-9]+$/) && int(i) >= 1 && int(i) <= 65535)
|
||||
push(ports, int(i));
|
||||
}
|
||||
|
||||
return ports;
|
||||
};
|
||||
|
||||
export function removeBlankAttrs(res) {
|
||||
let content;
|
||||
|
||||
if (type(res) === 'object') {
|
||||
content = {};
|
||||
map(keys(res), (k) => {
|
||||
if (type(res[k]) in ['array', 'object'])
|
||||
content[k] = removeBlankAttrs(res[k]);
|
||||
else if (res[k] !== null && res[k] !== '')
|
||||
content[k] = res[k];
|
||||
});
|
||||
} else if (type(res) === 'array') {
|
||||
content = [];
|
||||
map(res, (k, i) => {
|
||||
if (type(k) in ['array', 'object'])
|
||||
push(content, removeBlankAttrs(k));
|
||||
else if (k !== null && k !== '')
|
||||
push(content, k);
|
||||
});
|
||||
} else
|
||||
return res;
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export function validation(datatype, data) {
|
||||
if (!datatype || !data)
|
||||
return null;
|
||||
|
||||
const ret = system(`/sbin/validate_data ${shellQuote(datatype)} ${shellQuote(data)} 2>/dev/null`);
|
||||
return (ret === 0);
|
||||
};
|
||||
|
||||
/* Validate IP/CIDR format to prevent nftables template injection from resource files */
|
||||
export function isValidCIDR(addr, family) {
|
||||
if (isEmpty(addr))
|
||||
return false;
|
||||
|
||||
/* Strip leading/trailing whitespace */
|
||||
addr = trim(addr);
|
||||
if (!addr)
|
||||
return false;
|
||||
|
||||
/* Split address and optional prefix. Reject a second '/' outright:
|
||||
* the only legal form is "ip" or "ip/prefix", and a resource-list
|
||||
* entry like "1.2.3.4/24/32" or "1.2.3.4/;}" would otherwise split
|
||||
* into a benign-looking ip and a prefix the next checks would then
|
||||
* have to defend. Drop it here so all the following regexes only have
|
||||
* to police a one-or-zero-slash input. */
|
||||
const parts = split(addr, '/');
|
||||
if (length(parts) > 2)
|
||||
return false;
|
||||
const ip = parts[0];
|
||||
const prefix = parts[1];
|
||||
|
||||
/* Validate IP part */
|
||||
if (family === 4) {
|
||||
if (!match(ip, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/))
|
||||
return false;
|
||||
/* Validate octet ranges */
|
||||
const octets = split(ip, '.');
|
||||
for (let o in octets)
|
||||
if (int(o) > 255)
|
||||
return false;
|
||||
/* Validate prefix if present. Anchor with a digit-only regex:
|
||||
* without it, `int(prefix)` happily parses `24;}` as 24 (strtoll
|
||||
* stops at the first non-digit), so a poisoned china_ip4.txt line
|
||||
* `1.2.3.4/24;}` sailed past this check and ended up verbatim
|
||||
* in the fw4 ruleset fw4 then loaded as root.
|
||||
*
|
||||
* ucode has no `undefined` (an absent index reads as null), and
|
||||
* `if (prefix)` is also false for the empty string, so a
|
||||
* trailing-slash entry like `1.2.3.4/` would slip past a plain
|
||||
* `if (prefix)` gate and pass overall. Gate on null, which
|
||||
* keeps `''` from sneaking in; the regex naturally rejects the
|
||||
* empty string and any non-digit. */
|
||||
if (prefix !== null) {
|
||||
if (!match(prefix, /^\d{1,3}$/) || int(prefix) > 32)
|
||||
return false;
|
||||
}
|
||||
} else if (family === 6) {
|
||||
/* Only hex digits and colons */
|
||||
if (!match(ip, /^[0-9a-fA-F:]+$/))
|
||||
return false;
|
||||
|
||||
/* At most one "::" */
|
||||
if (match(ip, /::.*::/))
|
||||
return false;
|
||||
|
||||
/* Validate group structure */
|
||||
const dcolon = index(ip, '::');
|
||||
if (dcolon === -1) {
|
||||
/* Uncompressed form: exactly 8 groups of 1-4 hex digits */
|
||||
const groups = split(ip, ':');
|
||||
if (length(groups) !== 8)
|
||||
return false;
|
||||
for (let g in groups)
|
||||
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
|
||||
return false;
|
||||
} else {
|
||||
/* Compressed form: "::" expands to at least one zero group */
|
||||
let count = 0;
|
||||
for (let part in [substr(ip, 0, dcolon), substr(ip, dcolon + 2)]) {
|
||||
if (part === '')
|
||||
continue;
|
||||
for (let g in split(part, ':')) {
|
||||
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
|
||||
return false;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 7)
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Same anchor as the IPv4 branch: `1.2.3.4/24;}` would let
|
||||
* `int('24;}')` slip through unanchored and end up in fw4. Same
|
||||
* null-presence guard for `::1/` (empty prefix). */
|
||||
if (prefix !== null) {
|
||||
if (!match(prefix, /^\d{1,3}$/) || int(prefix) > 128)
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
/* String helper end */
|
||||
|
||||
/* String parser start */
|
||||
export function decodeBase64Str(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
str = trim(str);
|
||||
str = replace(str, /_/g, '/');
|
||||
str = replace(str, /-/g, '+');
|
||||
|
||||
const padding = length(str) % 4;
|
||||
if (padding)
|
||||
str = str + substr('====', padding);
|
||||
|
||||
return b64dec(str);
|
||||
};
|
||||
|
||||
export function parseURL(url) {
|
||||
if (type(url) !== 'string')
|
||||
return null;
|
||||
|
||||
const services = {
|
||||
http: '80',
|
||||
https: '443'
|
||||
};
|
||||
|
||||
const objurl = {};
|
||||
|
||||
objurl.href = url;
|
||||
|
||||
url = replace(url, /#(.+)$/, (_, val) => {
|
||||
objurl.hash = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /^(\w[A-Za-z0-9\+\-\.]+):/, (_, val) => {
|
||||
objurl.protocol = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /\?(.+)/, (_, val) => {
|
||||
objurl.search = val;
|
||||
objurl.searchParams = urldecode_params(val);
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /^\/\/([^\/]+)/, (_, val) => {
|
||||
val = replace(val, /^([^@]+)@/, (_, val) => {
|
||||
objurl.userinfo = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
val = replace(val, /:(\d+)$/, (_, val) => {
|
||||
objurl.port = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (validation('ip4addr', val) ||
|
||||
validation('ip6addr', replace(val, /\[|\]/g, '')) ||
|
||||
validation('hostname', val))
|
||||
objurl.hostname = val;
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
objurl.pathname = url || '/';
|
||||
|
||||
if (!objurl.protocol || !objurl.hostname)
|
||||
return null;
|
||||
|
||||
if (objurl.userinfo) {
|
||||
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
|
||||
objurl.password = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
|
||||
objurl.username = objurl.userinfo;
|
||||
delete objurl.userinfo;
|
||||
} else {
|
||||
delete objurl.userinfo;
|
||||
delete objurl.password;
|
||||
}
|
||||
};
|
||||
|
||||
if (!objurl.port)
|
||||
objurl.port = services[objurl.protocol];
|
||||
|
||||
objurl.host = objurl.hostname + (objurl.port ? `:${objurl.port}` : '');
|
||||
objurl.origin = `${objurl.protocol}://${objurl.host}`;
|
||||
|
||||
return objurl;
|
||||
};
|
||||
/* String parser end */
|
||||
|
||||
/* Config generator helper start */
|
||||
/*
|
||||
* Shared sing-box TLS object builder for the client outbound and the server
|
||||
* inbound. The property order below is significant: the generated JSON keeps
|
||||
* the insertion order of this object after removeBlankAttrs() drops the blank
|
||||
* attributes, and both generators used to emit the fields in exactly this
|
||||
* order. Keep client-only fields (insecure/handshake_timeout/utls) and
|
||||
* server-only fields (key_path/certificate_provider) at their current
|
||||
* positions when editing.
|
||||
*
|
||||
* P1-B: the first arg is now a Node.tls-shape structured sub-object
|
||||
* (the same shape the Loader produces) rather than a flat UCI section.
|
||||
* The Adapter passes node.tls; the server generator builds a
|
||||
* structured view of its UCI inbound before calling. Server-only
|
||||
* fields (key material, ACME, server-side reality handshake) still
|
||||
* live on the flat UCI section - they are not part of the Node model
|
||||
* because no client outbound needs them - so the server passes them
|
||||
* through `server_extras` instead of expecting the structured tls
|
||||
* to carry them.
|
||||
*/
|
||||
export function buildTLSObject(tls, is_server, server_extras) {
|
||||
if ((tls && tls.enabled) !== '1')
|
||||
return null;
|
||||
|
||||
/* When is_server the caller hands a flat UCI section's server-only
|
||||
* tail. We accept either an object or null; null means "no server
|
||||
* extras" which only matters when the caller still wants a TLS
|
||||
* object built for an inbound that does not actually serve TLS
|
||||
* itself (the enabled check above returns null first in that case). */
|
||||
const extras = is_server ? (server_extras || {}) : {};
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
server_name: tls.server_name,
|
||||
insecure: is_server ? null : strToBool(tls.insecure),
|
||||
alpn: tls.alpn,
|
||||
min_version: tls.min_version,
|
||||
max_version: tls.max_version,
|
||||
handshake_timeout: is_server ? null : strToTime(tls.handshake_timeout),
|
||||
cipher_suites: tls.cipher_suites,
|
||||
certificate_path: tls.cert_path && validateCertificatePath(tls.cert_path) ? tls.cert_path : null,
|
||||
key_path: (is_server && extras.tls_key_path && validateCertificatePath(extras.tls_key_path)) ? extras.tls_key_path : null,
|
||||
certificate_provider: (is_server && extras.tls_acme === '1') ? {
|
||||
type: 'acme',
|
||||
domain: (type(extras.tls_acme_domain) === 'array') ? extras.tls_acme_domain
|
||||
: (isEmpty(extras.tls_acme_domain) ? [] : [extras.tls_acme_domain]),
|
||||
data_directory: HP_DIR + '/certs',
|
||||
default_server_name: extras.tls_acme_dsn,
|
||||
email: extras.tls_acme_email,
|
||||
provider: extras.tls_acme_provider,
|
||||
account_key: extras.tls_acme_account_key,
|
||||
key_type: extras.tls_acme_key_type,
|
||||
profile: extras.tls_acme_profile,
|
||||
disable_http_challenge: strToBool(extras.tls_acme_dhc),
|
||||
disable_tls_alpn_challenge: strToBool(extras.tls_acme_dtac),
|
||||
alternative_http_port: strToInt(extras.tls_acme_ahp),
|
||||
alternative_tls_port: strToInt(extras.tls_acme_atp),
|
||||
external_account: (extras.tls_acme_external_account === '1') ? {
|
||||
key_id: extras.tls_acme_ea_keyid,
|
||||
mac_key: extras.tls_acme_ea_mackey
|
||||
} : null,
|
||||
dns01_challenge: (extras.tls_dns01_challenge === '1') ? {
|
||||
provider: extras.tls_dns01_provider,
|
||||
access_key_id: extras.tls_dns01_ali_akid,
|
||||
access_key_secret: extras.tls_dns01_ali_aksec,
|
||||
region_id: extras.tls_dns01_ali_rid,
|
||||
api_token: extras.tls_dns01_cf_api_token
|
||||
} : null
|
||||
} : null,
|
||||
ech: is_server ? (extras.tls_ech_key ? {
|
||||
enabled: true,
|
||||
key: split(extras.tls_ech_key, '\n')
|
||||
/* config: split(extras.tls_ech_config, '\n') */
|
||||
} : null) : ((tls.ech && tls.ech.enabled === '1') ? {
|
||||
enabled: true,
|
||||
config: tls.ech.config,
|
||||
/* Same gate as certificate_path: this is a file sing-box opens as
|
||||
* root, and it used to reach the config unvalidated. */
|
||||
config_path: tls.ech.config_path && validateCertificatePath(tls.ech.config_path) ? tls.ech.config_path : null
|
||||
} : null),
|
||||
utls: (is_server || isEmpty(tls.utls && tls.utls.fingerprint)) ? null : {
|
||||
enabled: true,
|
||||
fingerprint: tls.utls.fingerprint
|
||||
},
|
||||
reality: ((tls.reality && tls.reality.enabled) !== '1') ? null : (is_server ? {
|
||||
enabled: true,
|
||||
private_key: extras.tls_reality_private_key,
|
||||
short_id: tls.reality.short_id,
|
||||
max_time_difference: strToTime(extras.tls_reality_max_time_difference),
|
||||
handshake: {
|
||||
server: extras.tls_reality_server_addr,
|
||||
server_port: strToInt(extras.tls_reality_server_port)
|
||||
}
|
||||
} : {
|
||||
enabled: true,
|
||||
public_key: tls.reality.public_key,
|
||||
short_id: tls.reality.short_id
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
/* Shared sing-box transport object builder; the client transport additionally
|
||||
supports the gRPC keepalive hint, the server one does not.
|
||||
P1-B: the first arg is now a Node.transport-shape structured sub-object. */
|
||||
export function buildTransportObject(transport, is_server) {
|
||||
if (isEmpty(transport && transport.type))
|
||||
return null;
|
||||
|
||||
return {
|
||||
type: transport.type,
|
||||
host: transport.host,
|
||||
path: transport.path,
|
||||
headers: transport.headers,
|
||||
method: transport.method,
|
||||
max_early_data: strToInt(transport.max_early_data),
|
||||
early_data_header_name: transport.early_data_header_name,
|
||||
service_name: transport.service_name,
|
||||
idle_timeout: strToTime(transport.idle_timeout),
|
||||
ping_timeout: strToTime(transport.ping_timeout),
|
||||
permit_without_stream: is_server ? null : strToBool(transport.permit_without_stream)
|
||||
};
|
||||
};
|
||||
/* Config generator helper end */
|
||||
|
||||
/* PEM validation start */
|
||||
/*
|
||||
* Check that `content` is a PEM certificate (is_private_key = false) or an
|
||||
* RSA/EC private key (is_private_key = true): matching BEGIN/END boundaries
|
||||
* with a base64 body in between. Kanged from luci-proto-openconnect; used by
|
||||
* the rpcd certificate upload and available for future certificate features.
|
||||
*/
|
||||
/* Shared by the certificate, private-key and ECH-config validators: only the
|
||||
* markers differ, the body rule does not (base64 lines - or a single 64
|
||||
* character line - between a BEGIN and an END marker). */
|
||||
function validatePEM(content, beg, end) {
|
||||
if (isEmpty(content))
|
||||
return false;
|
||||
|
||||
const lines = split(trim(content), /[\r\n]/);
|
||||
let start = false, i;
|
||||
|
||||
for (i = 0; i < length(lines); i++) {
|
||||
if (match(lines[i], beg))
|
||||
start = true;
|
||||
else if (start && !b64dec(lines[i]) && length(lines[i]) !== 64)
|
||||
break;
|
||||
}
|
||||
|
||||
if (!start || i < length(lines) - 1 || !match(lines[i], end))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function isValidPEM(content, is_private_key) {
|
||||
const beg = is_private_key ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ : /^-----BEGIN CERTIFICATE-----$/,
|
||||
end = is_private_key ? /^-----END (RSA|EC) PRIVATE KEY-----$/ : /^-----END CERTIFICATE-----$/;
|
||||
|
||||
return validatePEM(content, beg, end);
|
||||
};
|
||||
|
||||
/* A client ECH config list is neither a certificate nor a private key - it is
|
||||
* wrapped in its own markers ("-----BEGIN ECH CONFIGS-----"), so isValidPEM()
|
||||
* rejects it. The node form's "Upload ECH config" button has always sent
|
||||
* certificate_write('client_ech_conf'); the backend had no case for that name,
|
||||
* and would have rejected the body here even with one. */
|
||||
export function isValidECHConfig(content) {
|
||||
return validatePEM(content,
|
||||
/^-----BEGIN ECH CONFIGS-----$/,
|
||||
/^-----END ECH CONFIGS-----$/);
|
||||
};
|
||||
/* PEM validation end */
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
import { isEmpty, parseURL, shellQuote, validation } from './homeproxy.uc';
|
||||
|
||||
const uci = cursor();
|
||||
|
||||
const uciconfig = 'homeproxy';
|
||||
uci.load(uciconfig);
|
||||
|
||||
const uciinfra = 'infra',
|
||||
ucimigration = 'migration',
|
||||
ucimain = 'config',
|
||||
ucinode = 'node',
|
||||
ucidns = 'dns',
|
||||
ucidnsserver = 'dns_server',
|
||||
ucidnsrule = 'dns_rule',
|
||||
ucirouting = 'routing',
|
||||
uciroutingnode = 'routing_node',
|
||||
uciroutingrule = 'routing_rule',
|
||||
uciserver = 'server';
|
||||
|
||||
/* chinadns-ng has been removed */
|
||||
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
|
||||
uci.delete(uciconfig, uciinfra, 'china_dns_port');
|
||||
|
||||
/* chinadns server now only accepts single server */
|
||||
const china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
|
||||
if (type(china_dns_server) === 'array') {
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', china_dns_server[0]);
|
||||
} else {
|
||||
if (china_dns_server === 'wan_114')
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', '114.114.114.114');
|
||||
else if (match(china_dns_server, /,/))
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', split(china_dns_server, ',')[0]);
|
||||
}
|
||||
|
||||
/* github_token option has been moved to config section */
|
||||
const github_token = uci.get(uciconfig, uciinfra, 'github_token');
|
||||
if (github_token) {
|
||||
uci.set(uciconfig, ucimain, 'github_token', github_token);
|
||||
uci.delete(uciconfig, uciinfra, 'github_token')
|
||||
}
|
||||
|
||||
/* ntp_server was introduced */
|
||||
if (!uci.get(uciconfig, uciinfra, 'ntp_server'))
|
||||
uci.set(uciconfig, uciinfra, 'ntp_server', 'nil');
|
||||
|
||||
/* tun_gso was deprecated in sb 1.11 */
|
||||
if (!isEmpty(uci.get(uciconfig, uciinfra, 'tun_gso')))
|
||||
uci.delete(uciconfig, uciinfra, 'tun_gso');
|
||||
|
||||
/* create migration section */
|
||||
if (!uci.get(uciconfig, ucimigration))
|
||||
uci.set(uciconfig, ucimigration, uciconfig);
|
||||
|
||||
/* delete old crontab command.
|
||||
*
|
||||
* Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host
|
||||
* with BSD sed it fails with "invalid command code". The old form sent stderr
|
||||
* to /dev/null as well, so the failure was invisible and the stale entry stayed
|
||||
* behind - which is exactly what the shell twin (hp_crontab_drop in
|
||||
* runtime/service.sh) documents and avoids. Editing through a temporary file
|
||||
* behaves the same on busybox, GNU and BSD sed, and lets the failure be seen. */
|
||||
const migration_crontab = uci.get(uciconfig, ucimigration, 'crontab');
|
||||
if (!migration_crontab) {
|
||||
const crontab = '/etc/crontabs/root';
|
||||
const tmp = crontab + '.hp-new';
|
||||
|
||||
if (system(sprintf('sed "/update_crond.sh/d" %s > %s', shellQuote(crontab), shellQuote(tmp))) === 0
|
||||
&& system(sprintf('mv -f %s %s', shellQuote(tmp), shellQuote(crontab))) === 0) {
|
||||
uci.set(uciconfig, ucimigration, 'crontab', '1');
|
||||
}
|
||||
else {
|
||||
/* Leave a trace instead of a silent no-op; the marker is not set, so
|
||||
* the next run tries again. */
|
||||
system(sprintf('rm -f %s', shellQuote(tmp)));
|
||||
warn('migrate_config: could not drop the legacy update_crond.sh crontab entry\n');
|
||||
}
|
||||
}
|
||||
|
||||
/* log_level was introduced */
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
|
||||
uci.set(uciconfig, ucimain, 'log_level', 'warn');
|
||||
|
||||
/* v28.9.1.16: cn_ip_fallback default flipped to '1' (tutorial §2.1 / docs/linux.json 与 pro 的差距分析.md).
|
||||
* Existing users get the behaviour change only when they ask for it, so write
|
||||
* '0' explicitly into their config on upgrade; new installs read '1' straight
|
||||
* from /etc/config/homeproxy (the package ships with that as the default).
|
||||
* isEmpty() is the same gate log_level uses, so an upgrade that already has
|
||||
* the option set (the fresh-install path of a prior release) keeps its value. */
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'cn_ip_fallback')))
|
||||
uci.set(uciconfig, ucimain, 'cn_ip_fallback', '0');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
|
||||
uci.set(uciconfig, uciserver, 'log_level', 'warn');
|
||||
|
||||
/* empty value defaults to all ports now */
|
||||
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
|
||||
uci.delete(uciconfig, ucimain, 'routing_port');
|
||||
|
||||
/* experimental section was removed */
|
||||
if (uci.get(uciconfig, 'experimental'))
|
||||
uci.delete(uciconfig, 'experimental');
|
||||
|
||||
/* block-dns was removed from built-in dns servers */
|
||||
const default_dns_server = uci.get(uciconfig, ucidns, 'default_server');
|
||||
if (default_dns_server === 'block-dns') {
|
||||
/* append a rule at last to block all DNS queries */
|
||||
uci.set(uciconfig, '_migration_dns_final_block', ucidnsrule);
|
||||
uci.set(uciconfig, '_migration_dns_final_block', 'label', 'migration_final_block_dns');
|
||||
uci.set(uciconfig, '_migration_dns_final_block', 'enabled', '1');
|
||||
uci.set(uciconfig, '_migration_dns_final_block', 'mode', 'default');
|
||||
uci.set(uciconfig, '_migration_dns_final_block', 'action', 'reject');
|
||||
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
|
||||
}
|
||||
else if (default_dns_server === 'local-dns')
|
||||
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
|
||||
|
||||
const dns_server_migration = {};
|
||||
/* DNS servers options */
|
||||
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
|
||||
/* legacy format was deprecated in sb 1.12 */
|
||||
if (cfg.address) {
|
||||
const addr = parseURL((!match(cfg.address, /:\/\//) ? 'udp://' : '') + (validation('ip6addr', cfg.address) ? `[${cfg.address}]` : cfg.address));
|
||||
/* parseURL() gives null for an address it cannot parse; the old code
|
||||
* dereferenced it immediately and aborted the migration. */
|
||||
if (!addr)
|
||||
return;
|
||||
/* RCode was moved into DNS rules */
|
||||
if (addr.protocol === 'rcode') {
|
||||
dns_server_migration[cfg['.name']] = { action: 'predefined' };
|
||||
switch (addr.hostname) {
|
||||
case 'success':
|
||||
dns_server_migration[cfg['.name']].rcode = 'NOERROR';
|
||||
break;
|
||||
case 'format_error':
|
||||
dns_server_migration[cfg['.name']].rcode = 'FORMERR';
|
||||
break;
|
||||
case 'server_failure':
|
||||
dns_server_migration[cfg['.name']].rcode = 'SERVFAIL';
|
||||
break;
|
||||
case 'name_error':
|
||||
dns_server_migration[cfg['.name']].rcode = 'NXDOMAIN';
|
||||
break;
|
||||
case 'not_implemented':
|
||||
dns_server_migration[cfg['.name']].rcode = 'NOTIMP';
|
||||
break;
|
||||
case 'refused':
|
||||
default:
|
||||
dns_server_migration[cfg['.name']].rcode = 'REFUSED';
|
||||
break;
|
||||
}
|
||||
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
return;
|
||||
}
|
||||
uci.set(uciconfig, cfg['.name'], 'type', addr.protocol);
|
||||
uci.set(uciconfig, cfg['.name'], 'server', addr.hostname);
|
||||
uci.set(uciconfig, cfg['.name'], 'server_port', addr.port);
|
||||
uci.set(uciconfig, cfg['.name'], 'path', (addr.pathname !== '/') ? addr.pathname : null);
|
||||
uci.delete(uciconfig, cfg['.name'], 'address');
|
||||
}
|
||||
|
||||
if (cfg.strategy) {
|
||||
if (cfg['.name'] === default_dns_server)
|
||||
uci.set(uciconfig, ucidns, 'default_strategy', cfg.strategy);
|
||||
dns_server_migration[cfg['.name']] = { strategy: cfg.strategy };
|
||||
uci.delete(uciconfig, cfg['.name'], 'strategy');
|
||||
}
|
||||
|
||||
if (cfg.client_subnet) {
|
||||
if (cfg['.name'] === default_dns_server)
|
||||
uci.set(uciconfig, ucidns, 'client_subnet', cfg.client_subnet);
|
||||
|
||||
if (isEmpty(dns_server_migration[cfg['.name']]))
|
||||
dns_server_migration[cfg['.name']] = {};
|
||||
dns_server_migration[cfg['.name']].client_subnet = cfg.client_subnet;
|
||||
uci.delete(uciconfig, cfg['.name'], 'client_subnet');
|
||||
}
|
||||
});
|
||||
|
||||
/* DNS rules options */
|
||||
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
|
||||
/* outbound was removed in sb 1.12 */
|
||||
if (cfg.outbound) {
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
if (!cfg.enabled)
|
||||
return;
|
||||
|
||||
/* uci.foreach hands a single-valued option over as a string, and map()
|
||||
* returns null for anything that is not an array - so a rule with one
|
||||
* outbound was deleted on the line above and then never migrated. */
|
||||
const outbounds = (type(cfg.outbound) === 'array') ? cfg.outbound : [ cfg.outbound ];
|
||||
map(outbounds, (outbound) => {
|
||||
switch (outbound) {
|
||||
case 'direct-out':
|
||||
case 'block-out':
|
||||
break;
|
||||
case 'any-out':
|
||||
uci.set(uciconfig, ucirouting, 'default_outbound_dns', cfg.server);
|
||||
break;
|
||||
default:
|
||||
uci.set(uciconfig, outbound, 'domain_resolver', cfg.server);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
|
||||
if (cfg.rule_set_ipcidr_match_source === '1')
|
||||
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
|
||||
|
||||
/* block-dns was moved into action in sb 1.11 */
|
||||
if (cfg.server === 'block-dns') {
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
|
||||
uci.delete(uciconfig, cfg['.name'], 'server');
|
||||
} else if (!cfg.action) {
|
||||
/* add missing 'action' field */
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'route');
|
||||
}
|
||||
|
||||
/* strategy and client_subnet were moved into dns rules */
|
||||
if (dns_server_migration[cfg.server]) {
|
||||
if (dns_server_migration[cfg.server].strategy)
|
||||
uci.set(uciconfig, cfg['.name'], 'strategy', dns_server_migration[cfg.server].strategy);
|
||||
|
||||
if (dns_server_migration[cfg.server].client_subnet)
|
||||
uci.set(uciconfig, cfg['.name'], 'client_subnet', dns_server_migration[cfg.server].client_subnet);
|
||||
|
||||
if (dns_server_migration[cfg.server].rcode) {
|
||||
/* The key the generator and the form both read is
|
||||
* `predefined_rcode`; writing `rcode` left the migrated rule with
|
||||
* no rcode at all, so a NXDOMAIN / REFUSED predefined answer
|
||||
* silently became the default. */
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'predefined');
|
||||
uci.set(uciconfig, cfg['.name'], 'predefined_rcode', dns_server_migration[cfg.server].rcode);
|
||||
uci.delete(uciconfig, cfg['.name'], 'server');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* nodes options */
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
/* tls_ech_tls_disable_drs is useless and deprecated in sb 1.12 */
|
||||
if (!isEmpty(cfg.tls_ech_tls_disable_drs))
|
||||
uci.delete(uciconfig, cfg['.name'], 'tls_ech_tls_disable_drs');
|
||||
|
||||
/* tls_ech_enable_pqss is useless and deprecated in sb 1.12 */
|
||||
if (!isEmpty(cfg.tls_ech_enable_pqss))
|
||||
uci.delete(uciconfig, cfg['.name'], 'tls_ech_enable_pqss');
|
||||
|
||||
/* wireguard_gso was deprecated in sb 1.11 */
|
||||
if (!isEmpty(cfg.wireguard_gso))
|
||||
uci.delete(uciconfig, cfg['.name'], 'wireguard_gso');
|
||||
|
||||
/* hysteria_revc_window was a typo, renamed to hysteria_recv_window */
|
||||
if (!isEmpty(cfg.hysteria_revc_window))
|
||||
uci.rename(uciconfig, cfg['.name'], 'hysteria_revc_window', 'hysteria_recv_window');
|
||||
});
|
||||
|
||||
/* sing-box 1.14: remove deprecated DNS semantics */
|
||||
if (!isEmpty(uci.get(uciconfig, ucidns, 'independent_cache')))
|
||||
uci.delete(uciconfig, ucidns, 'independent_cache');
|
||||
|
||||
const legacy_store_rdrc = uci.get(uciconfig, ucidns, 'cache_file_store_rdrc');
|
||||
if (!isEmpty(legacy_store_rdrc)) {
|
||||
uci.set(uciconfig, ucidns, 'cache_file_store_dns', legacy_store_rdrc);
|
||||
uci.delete(uciconfig, ucidns, 'cache_file_store_rdrc');
|
||||
}
|
||||
if (!isEmpty(uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout')))
|
||||
uci.delete(uciconfig, ucidns, 'cache_file_rdrc_timeout');
|
||||
|
||||
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
|
||||
if (!isEmpty(cfg.domain_strategy))
|
||||
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
|
||||
if (!isEmpty(cfg.rule_set_ip_cidr_accept_empty))
|
||||
uci.delete(uciconfig, cfg['.name'], 'rule_set_ip_cidr_accept_empty');
|
||||
});
|
||||
|
||||
/* routing rules options */
|
||||
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
|
||||
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
|
||||
if (cfg.rule_set_ipcidr_match_source === '1')
|
||||
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
|
||||
|
||||
/* block-out was moved into action in sb 1.11 */
|
||||
if (cfg.outbound === 'block-out') {
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
|
||||
uci.delete(uciconfig, cfg['.name'], 'outbound');
|
||||
} else if (!cfg.action) {
|
||||
/* add missing 'action' field */
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'route');
|
||||
}
|
||||
});
|
||||
|
||||
/* server options */
|
||||
/* auto_firewall was moved into server options */
|
||||
const auto_firewall = uci.get(uciconfig, uciserver, 'auto_firewall');
|
||||
if (!isEmpty(auto_firewall))
|
||||
uci.delete(uciconfig, uciserver, 'auto_firewall');
|
||||
|
||||
uci.foreach(uciconfig, uciserver, (cfg) => {
|
||||
/* auto_firewall was moved into server options */
|
||||
if (auto_firewall === '1')
|
||||
uci.set(uciconfig, cfg['.name'], 'firewall' , '1');
|
||||
|
||||
/* sniff_override was deprecated in sb 1.11 */
|
||||
if (!isEmpty(cfg.sniff_override))
|
||||
uci.delete(uciconfig, cfg['.name'], 'sniff_override');
|
||||
|
||||
/* domain_strategy is now pointless without sniff override */
|
||||
if (!isEmpty(cfg.domain_strategy))
|
||||
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
|
||||
});
|
||||
|
||||
if (!isEmpty(uci.changes(uciconfig)))
|
||||
uci.commit(uciconfig);
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* The inverse of
|
||||
* parser/normalize.uc. Where normalize() turns the parser's flat
|
||||
* UCI-key output into the canonical Node shape the Adapter reads,
|
||||
* flatten() turns a canonical Node back into the flat UCI-key dict
|
||||
* UCI needs.
|
||||
*
|
||||
* The Repository's apply_nodes() is the only production caller: it is
|
||||
* the one boundary where a canonical Node becomes UCI keys, which is
|
||||
* what lets the orchestrator, the filter and the policy step work on
|
||||
* canonical Nodes alone.
|
||||
*
|
||||
* Same single-source-of-truth discipline: every canonical -> UCI
|
||||
* mapping comes from parser/mapping.uc's PROTOCOL_TO_UCI. Adding a
|
||||
* protocol option is a one-place change in mapping.uc, and both
|
||||
* directions pick it up.
|
||||
*
|
||||
* The output is the same shape the parser emits plus whatever
|
||||
* metadata fields the orchestrator (or Repository) added
|
||||
* (label, grouphash, isExisting). flatten() does not invent or
|
||||
* strip them: it only does the canonical-sub-object -> flat-key
|
||||
* expansion, then layers identity on top, then copies metadata
|
||||
* through unchanged.
|
||||
*
|
||||
* `null` / `undefined` canonical values are written as null too -
|
||||
* UCI serialises null as an empty option, which the Loader reads
|
||||
* back as null, so the round-trip is preserved.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { PROTOCOL_TO_UCI } from './mapping.uc';
|
||||
|
||||
/* Per-protocol option fields: canonical -> UCI keys. Mirrors the
|
||||
* loader's load_protocol_options() / normalize.uc's
|
||||
* build_protocol_options(). */
|
||||
function flatten_protocol_options(protocol, node) {
|
||||
const map = PROTOCOL_TO_UCI[protocol] || {};
|
||||
const opts = node.protocol_options || {};
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci in map)
|
||||
out[uci] = opts[canonical];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* TLS fields: shared across protocols, sourced from the canonical
|
||||
* `tls` sub-object. Mirrors normalize.uc's build_tls(). */
|
||||
function flatten_tls(node) {
|
||||
const tls = node.tls || {};
|
||||
const ech = tls.ech || {};
|
||||
const utls = tls.utls || {};
|
||||
const reality = tls.reality || {};
|
||||
|
||||
return {
|
||||
tls: tls.enabled,
|
||||
tls_sni: tls.server_name,
|
||||
tls_insecure: tls.insecure,
|
||||
tls_alpn: tls.alpn,
|
||||
tls_min_version: tls.min_version,
|
||||
tls_max_version: tls.max_version,
|
||||
tls_handshake_timeout: tls.handshake_timeout,
|
||||
tls_cipher_suites: tls.cipher_suites,
|
||||
tls_cert_path: tls.cert_path,
|
||||
tls_ech: ech.enabled,
|
||||
tls_ech_config: ech.config,
|
||||
tls_ech_config_path: ech.config_path,
|
||||
tls_utls: utls.fingerprint,
|
||||
tls_reality: reality.enabled,
|
||||
tls_reality_public_key: reality.public_key,
|
||||
tls_reality_short_id: reality.short_id
|
||||
};
|
||||
}
|
||||
|
||||
/* Transport: share-link parsers spread transport fields across
|
||||
* multiple UCI keys (http_host, ws_host, ws_path, httpupgrade_host,
|
||||
* http_path, http_method, websocket_early_data(_header),
|
||||
* grpc_servicename, http_idle_timeout, http_ping_timeout,
|
||||
* grpc_permit_without_stream). Mirrors normalize.uc's
|
||||
* build_transport(). */
|
||||
function flatten_transport(node) {
|
||||
const t = (node.transport || {}).type;
|
||||
const out = { transport: t || null };
|
||||
|
||||
if (!t)
|
||||
return out;
|
||||
|
||||
/* The host / path UCI option names depend on the transport: a ws
|
||||
* transport stores them as ws_path / ws_host, an httpupgrade as
|
||||
* http_path / httpupgrade_host, plain http as http_host / http_path.
|
||||
* normalize() collapses both path spellings into transport.path, so
|
||||
* flatten() has to pick the right one back or a re-written node
|
||||
* would sprout an http_path the parser never produced (and drop the
|
||||
* ws_path the loader's `http_path || ws_path` fallback had read). */
|
||||
if (t === 'ws') {
|
||||
out.ws_host = node.transport.headers?.Host;
|
||||
out.ws_path = node.transport.path;
|
||||
}
|
||||
|
||||
if (t === 'httpupgrade') {
|
||||
out.httpupgrade_host = node.transport.host;
|
||||
out.http_path = node.transport.path;
|
||||
}
|
||||
|
||||
if (t === 'http') {
|
||||
out.http_host = node.transport.host;
|
||||
out.http_path = node.transport.path;
|
||||
out.http_method = node.transport.method;
|
||||
}
|
||||
|
||||
if (t === 'grpc')
|
||||
out.grpc_servicename = node.transport.service_name;
|
||||
|
||||
out.http_idle_timeout = node.transport.idle_timeout;
|
||||
out.http_ping_timeout = node.transport.ping_timeout;
|
||||
out.grpc_permit_without_stream = node.transport.permit_without_stream;
|
||||
out.websocket_early_data_header = node.transport.early_data_header_name;
|
||||
out.websocket_early_data = node.transport.max_early_data;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Common: every outbound protocol carries these. */
|
||||
function flatten_common(node) {
|
||||
const c = node.common || {};
|
||||
return {
|
||||
proxy_protocol: c.proxy_protocol,
|
||||
tcp_fast_open: c.tcp_fast_open,
|
||||
tcp_multi_path: c.tcp_multi_path,
|
||||
udp_fragment: c.udp_fragment
|
||||
};
|
||||
}
|
||||
|
||||
/* Multiplex: shares a brutal nested sub-shape with normalize.uc. */
|
||||
function flatten_multiplex(node) {
|
||||
const m = node.multiplex || {};
|
||||
const brutal = m.brutal || {};
|
||||
|
||||
return {
|
||||
multiplex: m.enabled,
|
||||
multiplex_protocol: m.protocol,
|
||||
multiplex_max_connections: m.max_connections,
|
||||
multiplex_min_streams: m.min_streams,
|
||||
multiplex_max_streams: m.max_streams,
|
||||
multiplex_padding: m.padding,
|
||||
multiplex_brutal: brutal.enabled,
|
||||
multiplex_brutal_up: brutal.up_mbps,
|
||||
multiplex_brutal_down: brutal.down_mbps
|
||||
};
|
||||
}
|
||||
|
||||
/* Credentials: same CREDENTIALS table as normalize.uc, traversed
|
||||
* in reverse: canonical credential name -> UCI option. */
|
||||
const CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
socks: { username: 'username', password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
snell: { psk: 'password', userkey: 'snell_userkey' },
|
||||
ssh: { user: 'username', password: 'password',
|
||||
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
|
||||
anytls: { password: 'password' },
|
||||
shadowtls: { password: 'password' },
|
||||
direct: {}
|
||||
};
|
||||
|
||||
function flatten_credentials(protocol, node) {
|
||||
const map = CREDENTIALS[protocol] || {};
|
||||
const creds = node.credentials || {};
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci in map)
|
||||
out[uci] = creds[canonical];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* The full pipeline, returning a flat UCI-key dict. Metadata that
|
||||
* the orchestrator / Repository attaches to the canonical Node
|
||||
* (label, grouphash) is not a canonical sub-object, so we copy
|
||||
* those fields through explicitly rather than walk `keys(node)`
|
||||
* and pull whatever happens to be there.
|
||||
*
|
||||
* `isExisting` is deliberately NOT copied: it is a within-run
|
||||
* marker Repository sets after a successful write, used to skip
|
||||
* re-adding in the same loop. It is never written to / read from
|
||||
* UCI. Identity (id / type / address / port) and `name` (which
|
||||
* becomes the UCI `label` option) are the Adapter's identity
|
||||
* half; everything else is the metadata tail. */
|
||||
function copy_meta(out, node) {
|
||||
if ('label' in node)
|
||||
out.label = node.label;
|
||||
if ('grouphash' in node)
|
||||
out.grouphash = node.grouphash;
|
||||
}
|
||||
|
||||
/* Top-level: take a canonical Node, return a flat UCI-key dict
|
||||
* suitable for uci.set() per key. Returns null when the input is
|
||||
* null / empty, mirroring normalize()'s `null -> null` contract
|
||||
* so callers can compose `flatten(normalize(parse_uri(uri)))`. */
|
||||
export function flatten(node) {
|
||||
if (!node)
|
||||
return null;
|
||||
|
||||
const protocol = node.type;
|
||||
const out = {
|
||||
type: protocol,
|
||||
address: node.address,
|
||||
port: node.port,
|
||||
label: node.name
|
||||
};
|
||||
|
||||
/* Identity sub-fields: drop empties so a freshly-saved
|
||||
* canonical Node (which always carries the full shape, even
|
||||
* when `null`) does not suddenly litter /etc/config with
|
||||
* `multiplex_max_connections ''` entries. The Loader treats
|
||||
* null and absent the same, so this is observably equivalent. */
|
||||
const sub = {
|
||||
...flatten_common(node),
|
||||
...flatten_credentials(protocol, node),
|
||||
...flatten_tls(node),
|
||||
...flatten_transport(node),
|
||||
...flatten_multiplex(node),
|
||||
...flatten_protocol_options(protocol, node)
|
||||
};
|
||||
|
||||
/* ucode has no `undefined`: an absent key reads as null, so a
|
||||
* single null check covers both. */
|
||||
for (let k, v in sub)
|
||||
if (v !== null)
|
||||
out[k] = v;
|
||||
|
||||
copy_meta(out, node);
|
||||
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Single source of truth for the canonical-name <-> UCI-option-name
|
||||
* mapping per protocol. The same mapping used to live in three places:
|
||||
*
|
||||
* - parse_uri.uc (wrote UCI-flat keys directly: `tls`, `tls_sni`,
|
||||
* `vless_flow`, `shadowsocks_encrypt_method`, ...)
|
||||
* - config/loader.uc PROTOCOL_OPTIONS (canonical -> UCI)
|
||||
* - config/adapter.uc OPTION_FIELDS (canonical -> adapter code)
|
||||
*
|
||||
* The Loader's PROTOCOL_OPTIONS is derived from the table in this file,
|
||||
* and both directions of the pipeline read it: normalize.uc renames UCI
|
||||
* keys to canonical names, flatten.uc renames them back on the way to
|
||||
* UCI. Adding a protocol option is one edit here, not one per layer.
|
||||
*
|
||||
* `keys()` direction: every row's left side is the canonical name the
|
||||
* Loader hands the Adapter; the right side is the UCI option name the
|
||||
* Loader reads and the Repository writes. Keep them sorted by
|
||||
* canonical name so a diff that adds a row is obvious.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* `canonical -> uci` for per-protocol options. Anything not listed here
|
||||
* is not part of the domain contract for that protocol (the form might
|
||||
* still surface it, the Adapter ignores it). */
|
||||
export const PROTOCOL_TO_UCI = {
|
||||
vless: {
|
||||
flow: 'vless_flow',
|
||||
packet_encoding: 'packet_encoding'
|
||||
/* udp_over_tcp used to be here. It is a shadowsocks/socks
|
||||
* option: sing-box 1.14 rejects it on a vless outbound, so
|
||||
* mapping it here turned a stale UCI option into an
|
||||
* unusable config. */
|
||||
},
|
||||
snell: {
|
||||
version: 'snell_version',
|
||||
reuse: 'snell_reuse',
|
||||
obfs_mode: 'snell_obfs_mode',
|
||||
obfs_host: 'snell_obfs_host'
|
||||
/* snell_mode is not mapped: sing-box 1.14 has no `mode`
|
||||
* field on a snell outbound (or inbound). It was v6-only
|
||||
* in the form, and v6 is not supported by the target
|
||||
* sing-box. */
|
||||
},
|
||||
shadowsocks: {
|
||||
plugin: 'shadowsocks_plugin',
|
||||
plugin_opts: 'shadowsocks_plugin_opts',
|
||||
udp_over_tcp: 'udp_over_tcp',
|
||||
udp_over_tcp_version: 'udp_over_tcp_version'
|
||||
},
|
||||
anytls: {
|
||||
idle_session_check_interval: 'anytls_idle_session_check_interval',
|
||||
idle_session_timeout: 'anytls_idle_session_timeout',
|
||||
min_idle_session: 'anytls_min_idle_session'
|
||||
},
|
||||
http: {},
|
||||
socks: {
|
||||
version: 'socks_version',
|
||||
udp_over_tcp: 'udp_over_tcp',
|
||||
udp_over_tcp_version: 'udp_over_tcp_version'
|
||||
},
|
||||
tuic: {
|
||||
congestion_control: 'tuic_congestion_control',
|
||||
udp_relay_mode: 'tuic_udp_relay_mode',
|
||||
udp_over_stream: 'tuic_udp_over_stream',
|
||||
zero_rtt_handshake: 'tuic_enable_zero_rtt',
|
||||
heartbeat: 'tuic_heartbeat'
|
||||
},
|
||||
trojan: {},
|
||||
shadowtls: {
|
||||
version: 'shadowtls_version'
|
||||
},
|
||||
hysteria: {
|
||||
protocol: 'hysteria_protocol',
|
||||
auth_type: 'hysteria_auth_type',
|
||||
auth_payload: 'hysteria_auth_payload',
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
hopping_port: 'hysteria_hopping_port',
|
||||
hop_interval: 'hysteria_hop_interval'
|
||||
},
|
||||
hysteria2: {
|
||||
obfs_type: 'hysteria_obfs_type',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
hop_interval: 'hysteria_hop_interval',
|
||||
hop_interval_max: 'hysteria_hop_interval_max',
|
||||
hopping_port: 'hysteria_hopping_port',
|
||||
auth_payload: 'hysteria_auth_payload',
|
||||
bbr_profile: 'hysteria_bbr_profile',
|
||||
disable_chrome_parrot: 'hysteria_disable_chrome_parrot'
|
||||
},
|
||||
vmess: {
|
||||
alter_id: 'vmess_alterid',
|
||||
security: 'vmess_encrypt',
|
||||
global_padding: 'vmess_global_padding',
|
||||
auth_payload: 'vmess_auth_payload',
|
||||
/* packet_encoding was missing here, so a vmess node's UCI option
|
||||
* never reached protocol_options and the outbound had none - even
|
||||
* though the node form offers the option for vmess and the
|
||||
* subscription default is applied to it. vless declares it too. */
|
||||
packet_encoding: 'packet_encoding'
|
||||
},
|
||||
ssh: {
|
||||
client_version: 'ssh_client_version',
|
||||
host_key: 'ssh_host_key',
|
||||
host_key_algorithms: 'ssh_host_key_algo'
|
||||
},
|
||||
wireguard: {
|
||||
local_address: 'wireguard_local_address',
|
||||
private_key: 'wireguard_private_key',
|
||||
peer_public_key: 'wireguard_peer_public_key',
|
||||
pre_shared_key: 'wireguard_pre_shared_key',
|
||||
reserved: 'wireguard_reserved',
|
||||
mtu: 'wireguard_mtu',
|
||||
persistent_keepalive_interval: 'wireguard_persistent_keepalive_interval'
|
||||
},
|
||||
direct: {
|
||||
override_address: 'override_address',
|
||||
override_port: 'override_port'
|
||||
}
|
||||
};
|
||||
|
||||
/* Reverse direction: uci -> canonical. Built once at module load so
|
||||
* the parser can look up "what canonical name is `tls_sni` for
|
||||
* <protocol>" without hand-coding the inverse in protocols.uc.
|
||||
*
|
||||
* A protocol with no per-protocol options maps to an empty object,
|
||||
* which is intentional: `for ... in {}` is a no-op and the loader
|
||||
* still has the protocol-shape boundary on the Node. */
|
||||
export const UCI_TO_PROTOCOL = {};
|
||||
for (let proto, row in PROTOCOL_TO_UCI) {
|
||||
UCI_TO_PROTOCOL[proto] = {};
|
||||
for (let canonical, uci in row)
|
||||
UCI_TO_PROTOCOL[proto][uci] = canonical;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* The per-scheme parsers in
|
||||
* parser/protocols.uc produce a FLAT UCI-key dict; that is their output
|
||||
* shape. The Adapter, the Loader, the policy step and the Repository's
|
||||
* write path all think in the canonical Node shape instead: nested tls /
|
||||
* transport / multiplex / credentials / protocol_options sub-objects.
|
||||
*
|
||||
* normalize() is the crossing point: every caller that needs the
|
||||
* canonical Node - the subscription orchestrator, the RPC that imports a
|
||||
* share link, tests - runs the parsed config through it. parser/flatten.uc
|
||||
* is the inverse and is used only where UCI has to be written, which is
|
||||
* the Repository's apply_nodes().
|
||||
*
|
||||
* The mapping is sourced from parser/mapping.uc (canonical -> UCI)
|
||||
* so the Loader, the Adapter and the parser cannot drift apart.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { PROTOCOL_TO_UCI } from './mapping.uc';
|
||||
|
||||
/* Pick every field that PROTOCOL_TO_UCI maps for this protocol off
|
||||
* the flat config and rename it from UCI to its canonical name. The
|
||||
* result is the protocol_options sub-object. Fields the parser wrote
|
||||
* that are not mapped (e.g. a stale `vless_udp_over_tcp` left over
|
||||
* from a shadowsocks -> vless type switch) are dropped here, which is
|
||||
* the same "stale UCI option becomes a config error" failure the
|
||||
* Loader has been guarding against via PROTOCOL_OPTIONS. */
|
||||
function build_protocol_options(protocol, flat) {
|
||||
const map = PROTOCOL_TO_UCI[protocol] || {};
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci in map) {
|
||||
if (uci in flat) {
|
||||
const value = flat[uci];
|
||||
/* Empty-string or null means "absent": do not propagate
|
||||
* to the canonical side either. */
|
||||
if (value !== '' && value !== null)
|
||||
out[canonical] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* TLS fields are shared across protocols, not per-protocol, so they
|
||||
* live outside PROTOCOL_TO_UCI. The Loader uses the same shape
|
||||
* (load_tls() in config/loader.uc), so we mirror it. */
|
||||
function build_tls(flat) {
|
||||
const tls = {
|
||||
enabled: flat.tls,
|
||||
server_name: flat.tls_sni,
|
||||
insecure: flat.tls_insecure,
|
||||
alpn: flat.tls_alpn,
|
||||
min_version: flat.tls_min_version,
|
||||
max_version: flat.tls_max_version,
|
||||
handshake_timeout: flat.tls_handshake_timeout,
|
||||
cipher_suites: flat.tls_cipher_suites,
|
||||
cert_path: flat.tls_cert_path,
|
||||
ech: {
|
||||
enabled: flat.tls_ech,
|
||||
config: flat.tls_ech_config,
|
||||
config_path: flat.tls_ech_config_path
|
||||
},
|
||||
utls: {
|
||||
fingerprint: flat.tls_utls
|
||||
},
|
||||
reality: {
|
||||
enabled: flat.tls_reality,
|
||||
public_key: flat.tls_reality_public_key,
|
||||
short_id: flat.tls_reality_short_id
|
||||
}
|
||||
};
|
||||
|
||||
return tls;
|
||||
}
|
||||
|
||||
/* Transport fields are also shared; mirror the shape the Loader's
|
||||
* load_transport() produces. Returns `{ type: null }` when the parser
|
||||
* did not write a transport. */
|
||||
function build_transport(flat) {
|
||||
const t = flat.transport;
|
||||
if (t == null || t === '')
|
||||
return { type: null };
|
||||
|
||||
return {
|
||||
type: t,
|
||||
host: flat.http_host || flat.httpupgrade_host,
|
||||
path: flat.http_path || flat.ws_path,
|
||||
headers: flat.ws_host ? { Host: flat.ws_host } : null,
|
||||
method: flat.http_method,
|
||||
max_early_data: flat.websocket_early_data,
|
||||
early_data_header_name: flat.websocket_early_data_header,
|
||||
service_name: flat.grpc_servicename,
|
||||
idle_timeout: flat.http_idle_timeout,
|
||||
ping_timeout: flat.http_ping_timeout,
|
||||
permit_without_stream: flat.grpc_permit_without_stream
|
||||
};
|
||||
}
|
||||
|
||||
/* Common fields that every outbound protocol carries. Mirrors the
|
||||
* Loader's load_common(). */
|
||||
function build_common(flat) {
|
||||
return {
|
||||
proxy_protocol: flat.proxy_protocol,
|
||||
tcp_fast_open: flat.tcp_fast_open,
|
||||
tcp_multi_path: flat.tcp_multi_path,
|
||||
udp_fragment: flat.udp_fragment
|
||||
};
|
||||
}
|
||||
|
||||
/* Multiplex fields - mirrors the Loader's load_multiplex(). */
|
||||
function build_multiplex(flat) {
|
||||
return {
|
||||
enabled: flat.multiplex,
|
||||
protocol: flat.multiplex_protocol,
|
||||
max_connections: flat.multiplex_max_connections,
|
||||
min_streams: flat.multiplex_min_streams,
|
||||
max_streams: flat.multiplex_max_streams,
|
||||
padding: flat.multiplex_padding,
|
||||
brutal: {
|
||||
enabled: flat.multiplex_brutal,
|
||||
up_mbps: flat.multiplex_brutal_up,
|
||||
down_mbps: flat.multiplex_brutal_down
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Credentials: pull every UCI key the protocol's CREDENTIALS row lists
|
||||
* (canonical -> UCI) and put them under the canonical name. Empty /
|
||||
* null values stay null so the Adapter's null-strip pass keeps the
|
||||
* field out of the emitted sing-box JSON. */
|
||||
const CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
socks: { username: 'username', password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
snell: { psk: 'password', userkey: 'snell_userkey' },
|
||||
ssh: { user: 'username', password: 'password',
|
||||
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
|
||||
anytls: { password: 'password' },
|
||||
shadowtls: { password: 'password' },
|
||||
direct: {}
|
||||
};
|
||||
|
||||
function build_credentials(protocol, flat) {
|
||||
const map = CREDENTIALS[protocol] || {};
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci in map)
|
||||
out[canonical] = flat[uci];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Top-level: produce a Node-shaped object from the parser's flat
|
||||
* output. Returns null when the input is null / empty so callers can
|
||||
* do `normalize(parse_uri(uri))` and propagate parse failures
|
||||
* unchanged. */
|
||||
export function normalize(flat) {
|
||||
if (!flat)
|
||||
return null;
|
||||
|
||||
const protocol = flat.type;
|
||||
|
||||
return {
|
||||
/* identity */
|
||||
id: null,
|
||||
name: flat.label,
|
||||
type: protocol,
|
||||
|
||||
/* endpoint */
|
||||
address: flat.address,
|
||||
port: flat.port,
|
||||
|
||||
/* sub-objects */
|
||||
common: build_common(flat),
|
||||
credentials: build_credentials(protocol, flat),
|
||||
tls: build_tls(flat),
|
||||
transport: build_transport(flat),
|
||||
multiplex: build_multiplex(flat),
|
||||
protocol_options: build_protocol_options(protocol, flat)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,450 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Every protocol's parse_<scheme>_uri()
|
||||
* used to live in the top-level parse_uri.uc. They are now grouped
|
||||
* here so the dispatch (parser/uri.uc), the validation
|
||||
* (parser/validator.uc), and the canonical normalisation
|
||||
* (parser/normalize.uc) can each be edited in isolation.
|
||||
*
|
||||
* Each parser returns a FLAT UCI-key dict - the key names UCI uses,
|
||||
* produced through the mapping table (parser/mapping.uc) so a protocol
|
||||
* option has one definition. That is the parser's output shape, not the
|
||||
* pipeline's: normalize() turns it into a canonical Node, the policy step
|
||||
* and the Repository work on that Node, and the Repository flattens it
|
||||
* back to UCI keys on the way out. Nothing downstream of normalize() sees
|
||||
* these flat dicts.
|
||||
*
|
||||
* `features` is the sing-box feature map (with_quic / with_utls / ...),
|
||||
* `log` a logging callback, both injected so the parsers stay free of
|
||||
* ubus / uci.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { urldecode, urlencode } from 'luci.http';
|
||||
|
||||
import { decodeBase64Str, isEmpty, parseURL } from '../homeproxy.uc';
|
||||
|
||||
/* https://shadowsocks.org/guide/sip008.html */
|
||||
export function parse_sip008_uri(uri) {
|
||||
return {
|
||||
label: uri.remarks,
|
||||
type: 'shadowsocks',
|
||||
address: uri.server,
|
||||
port: uri.server_port,
|
||||
shadowsocks_encrypt_method: uri.method,
|
||||
password: uri.password,
|
||||
shadowsocks_plugin: uri.plugin,
|
||||
shadowsocks_plugin_opts: uri.plugin_opts
|
||||
};
|
||||
};
|
||||
|
||||
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
|
||||
export function parse_anytls_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'anytls',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
password: urldecode(url.username),
|
||||
tls: '1',
|
||||
tls_sni: params.sni,
|
||||
tls_insecure: (params.insecure === '1') ? '1' : '0'
|
||||
};
|
||||
};
|
||||
|
||||
export function parse_http_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'http',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
username: url.username ? urldecode(url.username) : null,
|
||||
password: url.password ? urldecode(url.password) : null,
|
||||
tls: (uri[0] === 'https') ? '1' : '0'
|
||||
};
|
||||
};
|
||||
|
||||
export function parse_hysteria_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
if (!features.with_quic || (params.protocol && params.protocol !== 'udp')) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
if (!features.with_quic)
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'hysteria',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
hysteria_protocol: params.protocol || 'udp',
|
||||
hysteria_auth_type: params.auth ? 'string' : null,
|
||||
hysteria_auth_payload: params.auth,
|
||||
hysteria_obfs_password: params.obfsParam,
|
||||
hysteria_down_mbps: params.downmbps,
|
||||
hysteria_up_mbps: params.upmbps,
|
||||
tls: '1',
|
||||
tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0',
|
||||
tls_sni: params.peer,
|
||||
tls_alpn: params.alpn
|
||||
};
|
||||
};
|
||||
|
||||
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
|
||||
export function parse_hysteria2_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
if (!features.with_quic) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'hysteria2',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
password: url.username ? (
|
||||
urldecode(url.username + (url.password ? (':' + url.password) : ''))
|
||||
) : null,
|
||||
hysteria_obfs_type: params.obfs,
|
||||
hysteria_obfs_password: params['obfs-password'],
|
||||
tls: '1',
|
||||
tls_insecure: (params.insecure === '1') ? '1' : '0',
|
||||
tls_sni: params.sni
|
||||
};
|
||||
};
|
||||
|
||||
/* Surge Snell share link: snell://host:port?psk=..&obfs=http&obfs-host=..#name */
|
||||
export function parse_snell_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'snell',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
password: url.username ? urldecode(url.username)
|
||||
: (params.psk ? urldecode(params.psk) : null),
|
||||
snell_version: params.version || '4',
|
||||
snell_userkey: params.userkey,
|
||||
snell_obfs_mode: (params.obfs === 'http') ? 'http' : null,
|
||||
snell_obfs_host: params['obfs-host'],
|
||||
snell_reuse: (params.reuse === '1') ? '1' : '0'
|
||||
};
|
||||
};
|
||||
|
||||
export function parse_socks_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'socks',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
username: url.username ? urldecode(url.username) : null,
|
||||
password: url.password ? urldecode(url.password) : null,
|
||||
socks_version: (match(uri[0], /4/)) ? '4' : '5'
|
||||
};
|
||||
};
|
||||
|
||||
export function parse_ss_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
/* "Lovely" Shadowrocket format */
|
||||
const ss_suri = split(uri[1], '#');
|
||||
let ss_slabel = '';
|
||||
if (length(ss_suri) <= 2) {
|
||||
if (length(ss_suri) === 2)
|
||||
ss_slabel = '#' + urlencode(ss_suri[1]);
|
||||
if (decodeBase64Str(ss_suri[0]))
|
||||
uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel;
|
||||
}
|
||||
|
||||
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
|
||||
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
|
||||
|
||||
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
|
||||
let ss_userinfo = {};
|
||||
if (url.username && url.password)
|
||||
/* User info encoded with URIComponent */
|
||||
ss_userinfo = [url.username, urldecode(url.password)];
|
||||
else if (url.username) {
|
||||
/* User info encoded with base64. decodeBase64Str() returns null for
|
||||
* anything that is not valid base64 (a '.' in the userinfo is
|
||||
* enough), and ucode's split(null, ...) is null, so indexing it
|
||||
* raised a ReferenceError. parse_uri() does not catch that: the RPC
|
||||
* import path happens to, update_subscriptions.uc does not, so one
|
||||
* malformed link aborted an entire subscription update. A link with
|
||||
* no usable userinfo is simply not a node we can build. */
|
||||
const decoded = decodeBase64Str(urldecode(url.username));
|
||||
|
||||
ss_userinfo = decoded ? split(decoded, ':', 2) : {};
|
||||
}
|
||||
|
||||
let ss_plugin, ss_plugin_opts;
|
||||
if (url.search && url.searchParams.plugin) {
|
||||
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
|
||||
ss_plugin = ss_plugin_info[0];
|
||||
if (ss_plugin === 'simple-obfs')
|
||||
/* Fix non-standard plugin name */
|
||||
ss_plugin = 'obfs-local';
|
||||
ss_plugin_opts = ss_plugin_info[1];
|
||||
}
|
||||
|
||||
if (isEmpty(ss_userinfo[0]))
|
||||
return null;
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'shadowsocks',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
shadowsocks_encrypt_method: ss_userinfo[0],
|
||||
password: ss_userinfo[1],
|
||||
shadowsocks_plugin: ss_plugin,
|
||||
shadowsocks_plugin_opts: ss_plugin_opts
|
||||
};
|
||||
};
|
||||
|
||||
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
|
||||
export function parse_trojan_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
const config = {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'trojan',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
password: urldecode(url.username),
|
||||
transport: (params.type !== 'tcp') ? params.type : null,
|
||||
tls: '1',
|
||||
tls_sni: params.sni
|
||||
};
|
||||
switch (params.type) {
|
||||
case 'grpc':
|
||||
config.grpc_servicename = params.serviceName;
|
||||
break;
|
||||
case 'ws':
|
||||
config.ws_host = params.host ? urldecode(params.host) : null;
|
||||
config.ws_path = params.path ? urldecode(params.path) : null;
|
||||
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
||||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||||
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
||||
config.ws_path = split(config.ws_path, '?ed=')[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/* https://github.com/daeuniverse/dae/discussions/182 */
|
||||
export function parse_tuic_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
if (!features.with_quic) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'tuic',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
uuid: url.username,
|
||||
password: url.password ? urldecode(url.password) : null,
|
||||
tuic_congestion_control: params.congestion_control,
|
||||
tuic_udp_relay_mode: params.udp_relay_mode,
|
||||
tls: '1',
|
||||
tls_sni: params.sni,
|
||||
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
|
||||
};
|
||||
};
|
||||
|
||||
/* https://github.com/XTLS/Xray-core/discussions/716 */
|
||||
export function parse_vless_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
/* Unsupported protocol */
|
||||
if (params.type === 'kcp') {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
return null;
|
||||
} else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !features.with_quic)) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
if (!features.with_quic)
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'vless',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
uuid: url.username,
|
||||
transport: (params.type !== 'tcp') ? params.type : null,
|
||||
tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0',
|
||||
tls_sni: params.sni,
|
||||
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
|
||||
tls_reality: (params.security === 'reality') ? '1' : '0',
|
||||
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
|
||||
tls_reality_short_id: params.sid,
|
||||
tls_utls: features.with_utls ? params.fp : null,
|
||||
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : null
|
||||
};
|
||||
switch (params.type) {
|
||||
case 'grpc':
|
||||
config.grpc_servicename = params.serviceName;
|
||||
break;
|
||||
case 'http':
|
||||
case 'tcp':
|
||||
if (params.type === 'http' || params.headerType === 'http') {
|
||||
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
|
||||
config.http_path = params.path ? urldecode(params.path) : null;
|
||||
}
|
||||
break;
|
||||
case 'httpupgrade':
|
||||
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
|
||||
config.http_path = params.path ? urldecode(params.path) : null;
|
||||
break;
|
||||
case 'ws':
|
||||
config.ws_host = params.host ? urldecode(params.host) : null;
|
||||
config.ws_path = params.path ? urldecode(params.path) : null;
|
||||
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
||||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||||
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
||||
config.ws_path = split(config.ws_path, '?ed=')[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
|
||||
export function parse_vmess_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
/* "Lovely" shadowrocket format. `uri` is the split() result, so the old
|
||||
* match(uri, /&/) could never fire - match() takes a string. */
|
||||
if (length(uri) > 1 && match(uri[1], /&/)) {
|
||||
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = json(decodeBase64Str(uri[1])) || {};
|
||||
} catch(e) {
|
||||
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.v != '2') {
|
||||
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
||||
return null;
|
||||
/* Unsupported protocol */
|
||||
} else if (payload.net === 'kcp') {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
|
||||
return null;
|
||||
} else if (payload.net === 'quic' && ((payload.type && payload.type !== 'none') || payload.path || !features.with_quic)) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
|
||||
if (!features.with_quic)
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
|
||||
return null;
|
||||
}
|
||||
/*
|
||||
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
|
||||
* else if (payload.aid && int(payload.aid) !== 0) {
|
||||
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
|
||||
* return null;
|
||||
* }
|
||||
*/
|
||||
|
||||
const config = {
|
||||
label: payload.ps ? urldecode(payload.ps) : null,
|
||||
type: 'vmess',
|
||||
address: payload.add,
|
||||
port: payload.port,
|
||||
uuid: payload.id,
|
||||
vmess_alterid: payload.aid,
|
||||
vmess_encrypt: payload.scy || 'auto',
|
||||
vmess_global_padding: '1',
|
||||
transport: (payload.net !== 'tcp') ? payload.net : null,
|
||||
tls: (payload.tls === 'tls') ? '1' : '0',
|
||||
tls_sni: payload.sni || payload.host,
|
||||
tls_alpn: payload.alpn ? split(payload.alpn, ',') : null,
|
||||
tls_utls: features.with_utls ? payload.fp : null
|
||||
};
|
||||
switch (payload.net) {
|
||||
case 'grpc':
|
||||
config.grpc_servicename = payload.path;
|
||||
break;
|
||||
case 'h2':
|
||||
case 'tcp':
|
||||
if (payload.net === 'h2' || payload.type === 'http') {
|
||||
config.transport = 'http';
|
||||
config.http_host = payload.host ? split(payload.host, ',') : null;
|
||||
config.http_path = payload.path;
|
||||
}
|
||||
break;
|
||||
case 'httpupgrade':
|
||||
config.httpupgrade_host = payload.host;
|
||||
config.http_path = payload.path;
|
||||
break;
|
||||
case 'ws':
|
||||
config.ws_host = payload.host;
|
||||
config.ws_path = payload.path;
|
||||
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
||||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||||
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
||||
config.ws_path = split(config.ws_path, '?ed=')[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* parse_uri() is a thin dispatcher.
|
||||
* The 11 per-scheme parsers moved to parser/protocols.uc; the
|
||||
* categorised post-parse checks moved to parser/validator.uc; the
|
||||
* canonical-shape conversion lives in parser/normalize.uc; and the
|
||||
* canonical <-> UCI mapping table lives in parser/mapping.uc.
|
||||
*
|
||||
* This file is the only one parse_uri.uc's old callers should reach
|
||||
* for. The output shape is unchanged (flat UCI-key dict) so the
|
||||
* Repository's contract is preserved; the next PR can flip the
|
||||
* parser to canonical Node output and teach the Repository to
|
||||
* flatten, with parser/mapping.uc as the single source of truth.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, validation } from '../homeproxy.uc';
|
||||
|
||||
import {
|
||||
parse_sip008_uri, parse_anytls_uri, parse_http_uri,
|
||||
parse_hysteria_uri, parse_hysteria2_uri, parse_snell_uri,
|
||||
parse_socks_uri, parse_ss_uri, parse_trojan_uri,
|
||||
parse_tuic_uri, parse_vless_uri, parse_vmess_uri
|
||||
} from './protocols.uc';
|
||||
|
||||
import { validate } from './validator.uc';
|
||||
|
||||
/* Default label: ip6addr uses brackets for readability, otherwise
|
||||
* `address:port`. Exported because every scheme benefits and because a
|
||||
* caller that mutates a parsed config's address (subscription
|
||||
* apply_policy does not, but a future one could) can re-derive it.
|
||||
*
|
||||
* Declared above parse_uri on purpose: ucode does not hoist
|
||||
* `export function`, so the call below would otherwise fail at runtime
|
||||
* with "access to undeclared variable". */
|
||||
export function derive_label(config) {
|
||||
if (config.label)
|
||||
return config.label;
|
||||
if (!config.address || !config.port)
|
||||
return null;
|
||||
return (validation('ip6addr', config.address) ?
|
||||
`[${config.address}]` : config.address) + ':' + config.port;
|
||||
};
|
||||
|
||||
export function parse_uri(uri, features, log) {
|
||||
if (!features) features = {};
|
||||
if (!log) log = function() {};
|
||||
|
||||
let config;
|
||||
|
||||
if (type(uri) === 'object') {
|
||||
if (uri.nodetype === 'sip008')
|
||||
config = parse_sip008_uri(uri);
|
||||
} else if (type(uri) === 'string') {
|
||||
switch (split(trim(uri), '://')[0]) {
|
||||
case 'anytls':
|
||||
config = parse_anytls_uri(uri, features, log);
|
||||
break;
|
||||
case 'http':
|
||||
case 'https':
|
||||
config = parse_http_uri(uri, features, log);
|
||||
break;
|
||||
case 'hysteria':
|
||||
config = parse_hysteria_uri(uri, features, log);
|
||||
break;
|
||||
case 'hysteria2':
|
||||
case 'hy2':
|
||||
config = parse_hysteria2_uri(uri, features, log);
|
||||
break;
|
||||
case 'snell':
|
||||
config = parse_snell_uri(uri, features, log);
|
||||
break;
|
||||
case 'socks':
|
||||
case 'socks4':
|
||||
case 'socks4a':
|
||||
case 'socks5':
|
||||
case 'socks5h':
|
||||
config = parse_socks_uri(uri, features, log);
|
||||
break;
|
||||
case 'ss':
|
||||
config = parse_ss_uri(uri, features, log);
|
||||
break;
|
||||
case 'trojan':
|
||||
config = parse_trojan_uri(uri, features, log);
|
||||
break;
|
||||
case 'tuic':
|
||||
config = parse_tuic_uri(uri, features, log);
|
||||
break;
|
||||
case 'vless':
|
||||
config = parse_vless_uri(uri, features, log);
|
||||
break;
|
||||
case 'vmess':
|
||||
config = parse_vmess_uri(uri, features, log);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty(config))
|
||||
return null;
|
||||
|
||||
/* Strip the brackets UCI still allows around IPv6 literals - they
|
||||
* survive into a sing-box `server` field verbatim otherwise. */
|
||||
if (config.address)
|
||||
config.address = replace(config.address, /\[|\]/g, '');
|
||||
|
||||
/* validate() returns null (and logs) when the host or port is not
|
||||
* usable, so the result has to be tested rather than merged with
|
||||
* `|| config` - that fallback would hand the invalid config back. */
|
||||
const checked = validate(config, log);
|
||||
if (!checked)
|
||||
return null;
|
||||
|
||||
if (!checked.label)
|
||||
checked.label = derive_label(checked);
|
||||
|
||||
return checked;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* parse_uri() used to do its host/port
|
||||
* check inline at the end of the dispatcher, just before returning the
|
||||
* config. Per the guidance doc (section 八, Parse -> Normalize ->
|
||||
* Validate three-layer separation), the post-parse checks belong in
|
||||
* their own module so:
|
||||
*
|
||||
* - failures are categorised (host / port / credential / tls /
|
||||
* scheme), so the orchestrator's log line can name the failure
|
||||
* instead of dumping the whole config;
|
||||
* - new rules (REQUIRED_CREDENTIALS, TLS sanity) can land without
|
||||
* touching the dispatcher or the per-scheme parsers;
|
||||
* - tests can exercise the validator without going through any
|
||||
* scheme-specific parser.
|
||||
*
|
||||
* Today only the original two checks (host / port) are implemented,
|
||||
* so the categorisation is mostly plumbing. Adding REQUIRED_CREDENTIALS
|
||||
* in a follow-up is now a one-place change.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { validation } from '../homeproxy.uc';
|
||||
|
||||
/* Each entry carries a stable `kind` (so callers / tests can branch on
|
||||
* it) and a `message` for the log line. Adding a new rule is:
|
||||
*
|
||||
* { kind: 'missing_credential', field: 'uuid',
|
||||
* message: sprintf('%s requires %s', type, field) }
|
||||
*/
|
||||
export function validate(config, log) {
|
||||
let errors = [];
|
||||
|
||||
if (!validation('host', config.address)) {
|
||||
errors = [...errors, {
|
||||
kind: 'invalid_host',
|
||||
message: sprintf('Skipping invalid %s node: %s.',
|
||||
config.type, config.label || 'NULL')
|
||||
}];
|
||||
}
|
||||
|
||||
if (!validation('port', config.port)) {
|
||||
errors = [...errors, {
|
||||
kind: 'invalid_port',
|
||||
message: sprintf('Skipping invalid %s node: %s.',
|
||||
config.type, config.label || 'NULL')
|
||||
}];
|
||||
}
|
||||
|
||||
if (length(errors)) {
|
||||
/* Stay log-compatible with the old code path: one warn line
|
||||
* per failure. The orchestrator already swallows `null`
|
||||
* returns from parse_uri(), so it does not need to learn
|
||||
* about the categorised shape yet. */
|
||||
for (let err in errors)
|
||||
log(err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/* Exposed for tests so they can assert categorisation without going
|
||||
* through the full dispatcher. Returns the (possibly empty) array of
|
||||
* error descriptors that `validate()` would log, but does not log
|
||||
* anything itself. */
|
||||
export function check(config) {
|
||||
let errors = [];
|
||||
|
||||
if (!validation('host', config.address))
|
||||
errors = [...errors, { kind: 'invalid_host', type: config.type, address: config.address }];
|
||||
|
||||
if (!validation('port', config.port))
|
||||
errors = [...errors, { kind: 'invalid_port', type: config.type, port: config.port }];
|
||||
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Print a file's git blob id: sha1("blob <byte-length>\0" + content).
|
||||
*
|
||||
* That is the identifier GitHub reports for a path in a commit (the `.sha`
|
||||
* field of the contents API), so the resource updater can check that the bytes
|
||||
* it just downloaded are the bytes that commit contains. The download URL is
|
||||
* already pinned to a commit, which is a guarantee about *where* the bytes
|
||||
* come from, not about their content - a mirror, a CDN edge or anything else
|
||||
* between the router and it can answer for that URL with something else.
|
||||
*
|
||||
* It lives in its own file rather than as a few lines inside
|
||||
* update_resources.sh because the shell has no SHA-1: the target's busybox
|
||||
* ships sha256sum but not sha1sum, and the GitHub API only offers SHA-1 blob
|
||||
* ids. ucode-mod-digest is already a package dependency (md5() is used by the
|
||||
* subscription updater), so this adds no new requirement.
|
||||
*
|
||||
* Usage: ucode -S resource_blob_sha.uc <file>
|
||||
* Prints the hex digest and exits 0, or writes a message to stderr and exits 1.
|
||||
*
|
||||
* The failures use warn() + exit(1) rather than die(): on the target's ucode
|
||||
* (2026.01.16) die() exits 254, and the caller's contract is "nothing on stdout
|
||||
* means cannot verify", so a predictable status is worth having for anything
|
||||
* that does look at it.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { sha1 } from 'digest';
|
||||
import { readfile } from 'fs';
|
||||
|
||||
if (!length(ARGV) || ARGV[0] == null) {
|
||||
warn('usage: resource_blob_sha.uc <file>\n');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const data = readfile(ARGV[0]);
|
||||
|
||||
/* readfile() returns null when the file cannot be read and '' for an empty
|
||||
* one. The empty file has a real blob id (e69de29b...), so the two must not be
|
||||
* conflated - and neither may be reported as a digest. */
|
||||
if (type(data) !== 'string') {
|
||||
warn(sprintf('cannot read %s\n', ARGV[0]));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* length() counts bytes, which is what git hashes; chr(0) is the NUL git puts
|
||||
* between the header and the content. */
|
||||
printf('%s\n', sha1(sprintf('blob %d', length(data)) + chr(0) + data));
|
||||
@@ -0,0 +1,151 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Configuration transaction helpers for homeproxy.
|
||||
#
|
||||
# The generated sing-box configuration is the only thing standing between the
|
||||
# router and a working service, so it is handled as a transaction:
|
||||
#
|
||||
# generate the generator writes atomically and only after
|
||||
# `sing-box check` accepted the result, so a failed
|
||||
# generation leaves the previous file in place
|
||||
# -> known-good every accepted file is copied aside, so there is
|
||||
# always something to fall back to
|
||||
# -> ensure-live if generation produced nothing, restore the
|
||||
# known-good copy instead of failing the start
|
||||
# -> rollback a reload that comes up unhealthy puts the known-good
|
||||
# copy back (see reload_service in init.d/homeproxy)
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy. The functions only take explicit paths
|
||||
# and never call procd or ubus, so tests/runtime/test_config_transaction.sh
|
||||
# can exercise them without a router.
|
||||
|
||||
# hp_known_good <live> <known-good>
|
||||
# Record <live> as the new known-good copy. Non-zero when <live> is missing
|
||||
# or empty, in which case the previous known-good copy is left untouched.
|
||||
hp_known_good() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$live" ] || return 1
|
||||
|
||||
mkdir -p "$(dirname "$good")" 2>/dev/null
|
||||
cp -f "$live" "$good" 2>/dev/null || return 1
|
||||
# The known-good copy holds the same credentials as the live file, so the
|
||||
# two have to stay in step: otherwise a rollback restores a copy that is
|
||||
# world-readable whenever the source was.
|
||||
chmod 600 "$good" 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_ensure_live <live> <known-good>
|
||||
# 0 <live> is present and usable
|
||||
# 1 <live> was missing and the known-good copy was restored
|
||||
# 2 nothing usable: neither file exists
|
||||
hp_ensure_live() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$live" ] && return 0
|
||||
|
||||
if [ -s "$good" ]; then
|
||||
cp -f "$good" "$live" 2>/dev/null && return 1
|
||||
fi
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
# hp_rollback <live> <known-good>
|
||||
# Replace <live> with the known-good copy. Non-zero when there is nothing to
|
||||
# roll back to, so the caller can distinguish "restored" from "no fallback".
|
||||
hp_rollback() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$good" ] || return 1
|
||||
|
||||
cp -f "$good" "$live" 2>/dev/null || return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_same_file <a> <b>
|
||||
# 0 when both files exist and have identical content. Used to skip the
|
||||
# rollback when the failing configuration is already the known-good one.
|
||||
hp_same_file() {
|
||||
[ -s "$1" ] && [ -s "$2" ] || return 1
|
||||
cmp -s "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# hp_restore_known_good_uci <good-dir>
|
||||
# Re-apply the UCI snapshot written by hp_promote_known_good (the keys that
|
||||
# steer the firewall / dnsmasq / nft layer - see HP_INTERCEPT_SCALARS in
|
||||
# runtime/service.sh). Returns 0 on success, 1 when the snapshot is missing -
|
||||
# the rollback path logs and continues, since a missing snapshot just means
|
||||
# the install is older than the snapshot feature (best-effort).
|
||||
#
|
||||
# Two passes over the file, because the snapshot carries two shapes:
|
||||
#
|
||||
# homeproxy.<section>.<option>=<value> scalar, replayed with `uci set`
|
||||
# list homeproxy.<section>.<option>=a,b UCI list, replayed with
|
||||
# `uci delete` + `uci add_list`
|
||||
#
|
||||
# A list replayed through `uci set` becomes a *string*, and the firewall
|
||||
# validators (ipv4_to_nftarr / iface_to_nftarr) reject a non-array by
|
||||
# returning null - so the ACL rules reading it would silently disappear and
|
||||
# the rollback would come back with the wrong clients exempted.
|
||||
#
|
||||
# The commit below is what makes this safe: every write is staged and only
|
||||
# becomes visible when uci commit succeeds. A partial failure leaves UCI
|
||||
# unchanged; the rollback caller logs and the next reload path will see the
|
||||
# same failing configuration it just rolled back from, so the failure mode
|
||||
# stays bounded (no new corruption).
|
||||
hp_restore_known_good_uci() {
|
||||
local good_dir="$1"
|
||||
local snap="$good_dir/uci-snapshot.txt"
|
||||
|
||||
[ -s "$snap" ] || return 1
|
||||
|
||||
local key value lkey el saved=0
|
||||
|
||||
# Pass 1: scalars. `uci set key=` with an empty value is how UCI spells
|
||||
# "the option was not set", so a key that was absent in the known-good
|
||||
# state is removed again here rather than surviving as a failing value.
|
||||
# stdin is closed for every uci call so none of them can swallow the
|
||||
# file the loop is reading.
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
""|list\ *|"#"*) continue ;;
|
||||
esac
|
||||
uci set "$key=$value" </dev/null || return 1
|
||||
saved=1
|
||||
done < "$snap"
|
||||
|
||||
# Pass 2: list options. `uci delete` on an option that is not there is
|
||||
# expected - the known-good state may simply never have had this list -
|
||||
# so its exit status is deliberately ignored. An empty recorded value
|
||||
# therefore means "absent or empty", and the delete is the whole
|
||||
# operation: a list the failing configuration added does not survive.
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
list\ *) lkey="${key#list }" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
uci delete "$lkey" >/dev/null 2>&1 </dev/null
|
||||
saved=1
|
||||
[ -n "$value" ] || continue
|
||||
while :; do
|
||||
case "$value" in
|
||||
*,*) el="${value%%,*}"; value="${value#*,}" ;;
|
||||
*) el="$value"; value="" ;;
|
||||
esac
|
||||
uci add_list "$lkey=$el" </dev/null || return 1
|
||||
[ -n "$value" ] || break
|
||||
done
|
||||
done < "$snap"
|
||||
|
||||
[ "$saved" = "1" ] && uci commit homeproxy </dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# dnsmasq integration for homeproxy.
|
||||
#
|
||||
# homeproxy does not run its own DNS front end: it points dnsmasq at the
|
||||
# sing-box DNS listener and, in the list-based routing modes, feeds it the
|
||||
# per-domain `server=` / `nftset=` mappings. All of that used to live inline
|
||||
# in /etc/init.d/homeproxy.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, in
|
||||
# hp_dnsmasq_write_snippets, relies on `config_load` having run (see
|
||||
# runtime/service.sh's header for the rationale).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy.
|
||||
|
||||
# hp_dnsmasq_resolve_dir
|
||||
# Locate the directory dnsmasq reads the homeproxy snippets from and store it
|
||||
# in the global DNSMASQ_DIR.
|
||||
#
|
||||
# The UCI section name of the dnsmasq config is part of
|
||||
# /tmp/etc/dnsmasq.conf.<name>, but `uci get` cannot return a section name
|
||||
# (there is no `__name__`), so it is derived from the first `uci show` line.
|
||||
# Either step can fail on an unusual setup; fall back to the generic
|
||||
# directory and say so in the log instead of silently writing dnsmasq
|
||||
# snippets nobody reads.
|
||||
#
|
||||
# Called ONCE, at source time. stop_service deliberately reuses the value
|
||||
# instead of recomputing it: the directory must be the one the snippets were
|
||||
# written to, and a second resolution could log the warning twice or pick a
|
||||
# different directory after a dnsmasq reconfiguration.
|
||||
hp_dnsmasq_resolve_dir() {
|
||||
local dnsmasq_uci_config dnsmasq_conf_dir
|
||||
|
||||
dnsmasq_uci_config="$(uci -q show "dhcp.@dnsmasq[0]" 2>"/dev/null" | head -n1)"
|
||||
dnsmasq_uci_config="${dnsmasq_uci_config#*.}"
|
||||
dnsmasq_uci_config="${dnsmasq_uci_config%%=*}"
|
||||
|
||||
DNSMASQ_DIR=""
|
||||
if [ -n "$dnsmasq_uci_config" ] && [ -f "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config" ]; then
|
||||
dnsmasq_conf_dir="$(awk -F '=' '/^conf-dir=/ {print $2; exit}' "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config")"
|
||||
[ -n "$dnsmasq_conf_dir" ] && DNSMASQ_DIR="$dnsmasq_conf_dir/dnsmasq-homeproxy.d"
|
||||
fi
|
||||
|
||||
if [ -z "$DNSMASQ_DIR" ]; then
|
||||
log "WARNING: unable to derive the dnsmasq conf-dir (section '${dnsmasq_uci_config:-unknown}'), using /tmp/dnsmasq.d/dnsmasq-homeproxy.d."
|
||||
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy.d"
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_dnsmasq_has_nftset
|
||||
# 0 when the running dnsmasq understands `nftset=`. The base dnsmasq variant
|
||||
# is built without HAVE_NFTSET, and it does not ignore the directive: it aborts
|
||||
# with "recompile with HAVE_NFTSET defined", and because the snippet is read
|
||||
# through conf-dir that abort takes dnsmasq itself down - the LAN loses DNS and
|
||||
# DHCP, not just the domain routing. Probe once and degrade instead.
|
||||
hp_dnsmasq_has_nftset() {
|
||||
if [ -z "${HP_DNSMASQ_NFTSET:-}" ]; then
|
||||
# Look it up through PATH so the off-target harness can stand in a
|
||||
# dnsmasq of either flavour; fall back to the absolute path for an
|
||||
# init environment with a bare PATH.
|
||||
local bin
|
||||
bin="$(command -v dnsmasq 2>"/dev/null" || echo /usr/sbin/dnsmasq)"
|
||||
|
||||
if "$bin" --version 2>"/dev/null" | grep -qw nftset; then
|
||||
HP_DNSMASQ_NFTSET=1
|
||||
else
|
||||
HP_DNSMASQ_NFTSET=0
|
||||
fi
|
||||
fi
|
||||
|
||||
[ "$HP_DNSMASQ_NFTSET" = "1" ]
|
||||
}
|
||||
|
||||
# hp_dnsmasq_render_snippets <stage-dir> <hp-dir> <routing-mode> <dns-port> <ipv6>
|
||||
# Render the desired snippet set into <stage-dir>. Writes nothing outside it,
|
||||
# restarts nothing: the caller decides whether the result differs from what is
|
||||
# already installed.
|
||||
#
|
||||
# The input list gates are `-s` rather than the original unconditional sed:
|
||||
# when the resource file is missing the old code produced an empty snippet
|
||||
# (or a stale one never got removed), which made "is this the same as last
|
||||
# time" undecidable. An empty list now means "no snippet for this mode",
|
||||
# which is also what a fresh install means.
|
||||
hp_dnsmasq_render_snippets() {
|
||||
local stage="$1"
|
||||
local hp_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local dns_port="$4"
|
||||
local ipv6="$5"
|
||||
local gfw_nftset_v6="" wan_nftset_v6=""
|
||||
local nftset_ok=1
|
||||
|
||||
# dns_port comes from `infra`, a section with no UI and no validator, and
|
||||
# it is interpolated both into the snippet text and into sed's replacement
|
||||
# below. A value carrying a newline reaches sed as `\n` and becomes a real
|
||||
# dnsmasq directive (the generator accepts it too: int() is strtoll-based,
|
||||
# so '5333\nlog-queries' is simply 5333 there), and a value with a `/`
|
||||
# makes sed fail - while `> "$stage/x.conf"` has already truncated the
|
||||
# file, so an empty snippet used to be installed and then cached by the
|
||||
# "unchanged" comparison. Keep the numeric prefix, refuse the rest.
|
||||
dns_port="$(printf '%s' "$dns_port" | sed -n 's/^\([0-9]\{1,\}\).*/\1/p')"
|
||||
case "$dns_port" in
|
||||
''|*[!0-9]*)
|
||||
log "Warning: infra.dns_port is not a port (${4:-}); skipping the dnsmasq snippets."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [ "$dns_port" -lt 1 ] || [ "$dns_port" -gt 65535 ]; then
|
||||
log "Warning: infra.dns_port=$dns_port is out of range; skipping the dnsmasq snippets."
|
||||
return 1
|
||||
fi
|
||||
|
||||
hp_dnsmasq_has_nftset || nftset_ok=0
|
||||
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom"|"global")
|
||||
cat <<-EOF > "$stage/redirect-dns.conf" || return 1
|
||||
no-poll
|
||||
no-resolv
|
||||
server=127.0.0.1#$dns_port
|
||||
EOF
|
||||
;;
|
||||
"gfwlist")
|
||||
if [ -s "$hp_dir/resources/gfw_list.txt" ]; then
|
||||
if [ "$nftset_ok" = "1" ]; then
|
||||
[ "$ipv6" -eq "0" ] || gfw_nftset_v6=",6#inet#fw4#homeproxy_gfw_list_v6"
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_gfw_list_v4$gfw_nftset_v6/g" \
|
||||
"$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1
|
||||
else
|
||||
log "Warning: this dnsmasq has no nftset support; gfwlist routing degrades to plain server= entries (no address sets)."
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
"proxy_mainland_china")
|
||||
if [ -s "$hp_dir/resources/china_list.txt" ]; then
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/china_list.txt" > "$stage/china_list.conf" || return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$routing_mode" != "custom" ] && [ -s "$hp_dir/resources/proxy_list.txt" ]; then
|
||||
if [ "$nftset_ok" = "1" ]; then
|
||||
[ "$ipv6" -eq "0" ] || wan_nftset_v6=",6#inet#fw4#homeproxy_wan_proxy_addr_v6"
|
||||
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_wan_proxy_addr_v4$wan_nftset_v6/g" \
|
||||
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
|
||||
else
|
||||
log "Warning: this dnsmasq has no nftset support; the proxy list degrades to plain server= entries."
|
||||
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_dnsmasq_dir_differs <stage-dir> <live-dir>
|
||||
# 0 when the installed snippet set is not exactly the staged one, 1 when it is
|
||||
# identical. Compares file names as well as contents, so a snippet that the
|
||||
# current routing mode no longer produces (but a previous one did) counts as a
|
||||
# change and gets cleaned up.
|
||||
hp_dnsmasq_dir_differs() {
|
||||
local stage="$1"
|
||||
local live="$2"
|
||||
local staged installed f
|
||||
|
||||
[ -d "$live" ] || return 0
|
||||
|
||||
staged="$(ls -1 "$stage" 2>"/dev/null" | sort)"
|
||||
installed="$(ls -1 "$live" 2>"/dev/null" | sort)"
|
||||
[ "$staged" = "$installed" ] || return 0
|
||||
|
||||
for f in $staged; do
|
||||
cmp -s "$stage/$f" "$live/$f" || return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_dnsmasq_write_snippets <dns-dir> <hp-dir> <routing-mode>
|
||||
# Write the include file plus the mode-specific snippet, then restart
|
||||
# dnsmasq. Requires config_load (reads ipv6_support and dns_port).
|
||||
#
|
||||
# The snippets are rendered into a staging directory first and compared with
|
||||
# what is installed. dnsmasq restart drops every client's DNS cache, and the
|
||||
# list files it feeds on change once a day at most while the routing mode and
|
||||
# the port stay the same - so the previous unconditional rewrite-and-restart
|
||||
# flushed the LAN's DNS cache on every reload for no reason.
|
||||
hp_dnsmasq_write_snippets() {
|
||||
local dnsmasq_dir="$1"
|
||||
local hp_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local ipv6_support dns_port
|
||||
local include="$dnsmasq_dir/../dnsmasq-homeproxy.conf"
|
||||
local stage changed=0
|
||||
|
||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
||||
config_get dns_port "infra" "dns_port" "5333"
|
||||
|
||||
stage="$(mktemp -d "${TMPDIR:-/tmp}/hp-dnsmasq.XXXXXX")" || {
|
||||
log "Warning: failed to create a staging directory for the dnsmasq snippets."
|
||||
return 1
|
||||
}
|
||||
|
||||
# A refused render (an unusable dns_port, or sed failing) must leave the
|
||||
# installed snippet set untouched: the staged directory is discarded, so
|
||||
# the caller keeps the snippets that are already in place instead of
|
||||
# installing a truncated one.
|
||||
if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support"; then
|
||||
rm -rf "$stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$include" ] || [ "$(cat "$include" 2>"/dev/null")" != "conf-dir=$dnsmasq_dir" ]; then
|
||||
changed=1
|
||||
fi
|
||||
if hp_dnsmasq_dir_differs "$stage" "$dnsmasq_dir"; then
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$changed" -eq 0 ]; then
|
||||
rm -rf "$stage"
|
||||
log "dnsmasq snippets unchanged, skipping the restart."
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$dnsmasq_dir")" 2>"/dev/null" \
|
||||
|| log "Warning: failed to create the dnsmasq conf-dir parent."
|
||||
rm -rf "$dnsmasq_dir"
|
||||
if ! mv "$stage" "$dnsmasq_dir"; then
|
||||
log "Warning: failed to install the dnsmasq snippets into ${dnsmasq_dir}."
|
||||
rm -rf "$stage"
|
||||
return 1
|
||||
fi
|
||||
# printf, not `echo -e`: busybox ash honours -e but a POSIX sh does not,
|
||||
# and on one the file would start with a literal "-e " and dnsmasq would
|
||||
# refuse to parse it. The file is one line of conf-dir= either way.
|
||||
printf 'conf-dir=%s\n' "$dnsmasq_dir" > "$include" \
|
||||
|| log "Warning: failed to write the dnsmasq conf-dir file."
|
||||
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|
||||
|| log "Warning: failed to restart dnsmasq, DNS-based routing may be stale."
|
||||
}
|
||||
|
||||
# hp_dnsmasq_remove_snippets <dns-dir>
|
||||
# Remove the include file and the snippet directory, then restart dnsmasq.
|
||||
# The snippets are removed rather than restored: nothing ever captures the
|
||||
# pre-existing state, so there is no original to put back.
|
||||
hp_dnsmasq_remove_snippets() {
|
||||
local dnsmasq_dir="$1"
|
||||
local include="$dnsmasq_dir/../dnsmasq-homeproxy.conf"
|
||||
|
||||
# Nothing to undo when nothing was written - which is the normal case on a
|
||||
# router whose outbound node is 'nil', or one that never started. Removing
|
||||
# paths that do not exist is not an error, and restarting dnsmasq for it is
|
||||
# pure noise (it drops every client's DNS cache for no reason).
|
||||
if [ ! -e "$include" ] && [ ! -d "$dnsmasq_dir" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -rf "$include" "$dnsmasq_dir"
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|
||||
|| log "Warning: failed to restart dnsmasq after removing the homeproxy snippets."
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# fw4 integration for homeproxy.
|
||||
#
|
||||
# homeproxy owns a fixed set of nft chains and sets inside the `fw4` table.
|
||||
# They are created by the rendered template (firewall_post.ut, applied by
|
||||
# `fw4 reload`) and torn down one object at a time on stop. All of that used
|
||||
# to live inline in /etc/init.d/homeproxy.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, for the
|
||||
# teardown, requires the HP_FW4_CHAINS / HP_FW4_SETS inventory to be in scope
|
||||
# (root/etc/homeproxy/scripts/fw4_names.sh, sourced by init.d).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy.
|
||||
|
||||
# hp_restore_upnp_mappings
|
||||
# fw4 reload rewrites the whole ruleset, which drops the miniupnpd mappings,
|
||||
# so ask miniupnpd to reinstall them - but only when it is actually enabled
|
||||
# and running.
|
||||
hp_restore_upnp_mappings() {
|
||||
[ -x /etc/init.d/miniupnpd ] && /etc/init.d/miniupnpd enabled && /etc/init.d/miniupnpd running \
|
||||
&& /etc/init.d/miniupnpd restart >"/dev/null" 2>&1
|
||||
}
|
||||
|
||||
# hp_firewall_apply <hp-dir> <run-dir> <client-enabled>
|
||||
# Run the pre-script, render the post-template for the client side, reload
|
||||
# fw4 and restore the upnp mappings. <client-enabled> is "1" when a client
|
||||
# outbound exists; the post-template is client-only.
|
||||
hp_firewall_apply() {
|
||||
local hp_dir="$1"
|
||||
local run_dir="$2"
|
||||
local client_enabled="$3"
|
||||
|
||||
local failed=0
|
||||
|
||||
# Truncate the include fragments first. firewall_pre.uc only writes them
|
||||
# when it has something to put in them, so a start that produces no
|
||||
# forward/input rules would leave the previous run's files in place and fw4
|
||||
# would keep loading them. teardown already truncates for the same reason.
|
||||
: > "$run_dir/fw4_forward.nft"
|
||||
: > "$run_dir/fw4_input.nft"
|
||||
|
||||
ucode "$hp_dir/scripts/firewall_pre.uc" 2>"/dev/null" \
|
||||
|| { log "Error: firewall pre-script failed."; failed=1; }
|
||||
|
||||
if [ "$client_enabled" = "1" ]; then
|
||||
# Render to a temp file and install it only on success. The old
|
||||
# `> "$run_dir/fw4_post.nft"` truncated the very file fw4 is about to
|
||||
# include, so a failed render left fw4 loading an empty ruleset.
|
||||
if utpl -S "$hp_dir/scripts/firewall_post.ut" > "$run_dir/fw4_post.nft.new" 2>"/dev/null"; then
|
||||
mv -f "$run_dir/fw4_post.nft.new" "$run_dir/fw4_post.nft"
|
||||
else
|
||||
rm -f "$run_dir/fw4_post.nft.new"
|
||||
log "Error: firewall post-script failed."
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
|
||||
fw4 reload >"/dev/null" 2>&1 \
|
||||
|| { log "Error: fw4 reload failed."; failed=1; }
|
||||
|
||||
# The return value has to describe this function, not whatever
|
||||
# hp_restore_upnp_mappings happened to return last.
|
||||
hp_restore_upnp_mappings
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
# hp_firewall_teardown <run-dir> [keep-pre]
|
||||
# Flush and delete every fw4 object homeproxy owns. Deleting one at a time
|
||||
# matters: `nft -f` batches are atomic, so a single object that does not
|
||||
# exist in the current proxy mode would roll back the rest.
|
||||
#
|
||||
# "keep-pre" leaves fw4_forward.nft / fw4_input.nft alone. Those two are the
|
||||
# firewall_pre.uc fragments - the server's inbound accepts and the TUN
|
||||
# forwards - and they are not what black-holes the LAN when the client fails.
|
||||
# A failed client has to release the redirect/tproxy layer without closing
|
||||
# the server's ports, so that path passes keep-pre; stop_service does not.
|
||||
hp_firewall_teardown() {
|
||||
local run_dir="$1"
|
||||
local keep_pre="${2:-}"
|
||||
local fw4_name
|
||||
|
||||
for fw4_name in $HP_FW4_CHAINS; do
|
||||
nft flush chain inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
nft delete chain inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
done
|
||||
for fw4_name in $HP_FW4_SETS; do
|
||||
nft flush set inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
nft delete set inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
done
|
||||
|
||||
if [ "$keep_pre" != "keep-pre" ]; then
|
||||
: > "$run_dir/fw4_forward.nft"
|
||||
: > "$run_dir/fw4_input.nft"
|
||||
fi
|
||||
: > "$run_dir/fw4_post.nft"
|
||||
|
||||
# Stop-time fw4 reload is best-effort on purpose: every homeproxy-owned
|
||||
# chain and set has already been flushed and deleted above, so the
|
||||
# live ruleset no longer references them. A reload that succeeds here
|
||||
# also re-loads /etc/config/firewall; a reload that fails leaves the
|
||||
# rest of the user's ruleset untouched, which is the safer outcome -
|
||||
# logging a Warning used to suggest the stop had lost something when
|
||||
# in fact nothing homeproxy-owned was left to lose.
|
||||
fw4 reload >"/dev/null" 2>&1 || true
|
||||
hp_restore_upnp_mappings
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Runtime health helpers for homeproxy.
|
||||
#
|
||||
# After a reload the service must be able to answer "did the instance
|
||||
# actually come up with the new configuration?". That answer decides whether
|
||||
# the transaction in runtime/config.sh rolls back, so it is kept here instead
|
||||
# of being inlined in init.d/homeproxy.
|
||||
#
|
||||
# A single "is a matching process alive?" probe is NOT a health check, and the
|
||||
# first version of this file learned that the hard way. Measured on the test
|
||||
# device with a configuration whose mixed_port was already taken:
|
||||
#
|
||||
# * procd restarts the crashing instance about once a second, and a STALE
|
||||
# sing-box process from an earlier run kept matching `pgrep -f`, so the
|
||||
# process check returned success for the whole observation window while
|
||||
# the service was dead;
|
||||
# * `ubus service list` was correct throughout: `running` flipped false and
|
||||
# `exit_code` was 1 whenever it was down;
|
||||
# * the configured port WAS listening - owned by the process that had taken
|
||||
# it, not by sing-box. Checking "the port is in the listen table" would
|
||||
# therefore have passed too; the listener has to be attributed to an owner.
|
||||
#
|
||||
# So the gate is: procd says running, procd does not report a failed exit
|
||||
# code, the declared listeners are owned by sing-box, and that holds for
|
||||
# several consecutive samples (a service that binds and then dies must not be
|
||||
# able to pass on a single lucky poll).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy; testable off-target by stubbing pgrep /
|
||||
# ubus / jsonfilter / netstat (see tests/runtime/).
|
||||
|
||||
: "${HP_SERVICE:=homeproxy}"
|
||||
|
||||
# How many consecutive healthy samples hp_wait_service requires, and how many
|
||||
# seconds it may spend trying.
|
||||
#
|
||||
# Measured on the test device: procd waits out `procd_set_param respawn`'s
|
||||
# timeout (5s) before restarting a dead instance, and after a crash-restart
|
||||
# sequence has exhausted its retries the next start takes longer still. A
|
||||
# crash-restart loop produced at most one healthy sample in a row, so 3
|
||||
# separates the two cases while a healthy start only costs ~3s; but the BUDGET
|
||||
# has to cover procd's backoff, otherwise the gate gives up on an instance that
|
||||
# is still coming up (observed with a 15s budget: the rollback restart was
|
||||
# healthy moments after the gate had already declared failure).
|
||||
: "${HP_HEALTH_STABLE:=3}"
|
||||
: "${HP_HEALTH_BUDGET:=30}"
|
||||
|
||||
# The rollback restart runs right after a crash-restart sequence has exhausted
|
||||
# procd's retries, so it gets a longer budget than a normal start.
|
||||
: "${HP_HEALTH_ROLLBACK_BUDGET:=60}"
|
||||
|
||||
# The process name a listening socket must be attributed to.
|
||||
: "${HP_HEALTH_OWNER:=sing-box}"
|
||||
|
||||
# hp_procd_available
|
||||
# 0 when the procd/ubus state can be queried at all.
|
||||
hp_procd_available() {
|
||||
command -v ubus > "/dev/null" 2>&1 && command -v jsonfilter > "/dev/null" 2>&1
|
||||
}
|
||||
|
||||
# hp_procd_running <instance-name>
|
||||
# 0 when procd reports the instance as running AND without a non-zero exit
|
||||
# code. Both halves matter: during a crash-restart loop `running` flickers
|
||||
# and `exit_code` is set, and a `running: true` sample taken between two
|
||||
# failures must not be read as success on its own.
|
||||
hp_procd_running() {
|
||||
local name="$1"
|
||||
local state running exit_code
|
||||
|
||||
hp_procd_available || return 1
|
||||
|
||||
state="$(ubus call service list "{\"name\":\"$HP_SERVICE\"}" 2>"/dev/null" \
|
||||
| jsonfilter -e "@['$HP_SERVICE'].instances['$name']" 2>"/dev/null")"
|
||||
[ -n "$state" ] || return 1
|
||||
|
||||
running="$(printf '%s' "$state" | jsonfilter -e '@.running' 2>"/dev/null")"
|
||||
[ "$running" = "true" ] || return 1
|
||||
|
||||
exit_code="$(printf '%s' "$state" | jsonfilter -e '@.exit_code' 2>"/dev/null")"
|
||||
case "$exit_code" in
|
||||
""|"0") return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# hp_instance_running <instance-name> <config-path>
|
||||
# 0 when the instance is alive.
|
||||
#
|
||||
# procd's view comes first: it is the authority on whether the instance it
|
||||
# supervises is up, and unlike a process scan it cannot be satisfied by a
|
||||
# stale or orphaned process. The process scan is only a fallback for hosts
|
||||
# without ubus, and it is deliberately the weaker evidence.
|
||||
hp_instance_running() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
|
||||
if hp_procd_running "$name"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fallback: accept a matching process only when procd could not be asked
|
||||
# at all. When procd *was* asked and said "not running", that answer
|
||||
# wins - a stale process must not override it.
|
||||
if ! hp_procd_available; then
|
||||
if command -v pgrep > "/dev/null" 2>&1; then
|
||||
pgrep -f "run --config $config" > "/dev/null" 2>&1 && return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_listener_owned <owner> <port>...
|
||||
# 0 every port is listening and attributed to <owner>
|
||||
# 1 a port is missing, or is listening but belongs to something else
|
||||
# 2 the check cannot be performed here (no netstat, or no owner column)
|
||||
#
|
||||
# Attribution is the whole point: in the failure this gate exists for, the
|
||||
# port was listening - because the process that had taken it was still
|
||||
# running. "The port is in the listen table" would have reported success for
|
||||
# a service that could not bind it.
|
||||
hp_listener_owned() {
|
||||
local owner="$1"
|
||||
shift
|
||||
local table port
|
||||
|
||||
command -v netstat > "/dev/null" 2>&1 || return 2
|
||||
|
||||
# -p adds the owning pid/name; it needs root, which is how init.d runs.
|
||||
table="$(netstat -ltnup 2>"/dev/null")"
|
||||
[ -n "$table" ] || return 2
|
||||
# Without a "<pid>/<name>" column the attribution cannot be made at all.
|
||||
# The header row says "PID/Program name", so the probe has to require a
|
||||
# digit before the slash - matching a bare "/" would accept the header.
|
||||
case "$table" in
|
||||
*[0-9]/*) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
for port in "$@"; do
|
||||
[ -n "$port" ] || continue
|
||||
if ! printf '%s\n' "$table" | awk -v p="$port" -v o="$owner" '
|
||||
$1 ~ /^(tcp|udp)/ {
|
||||
# Local address is field 4; its last :/. component
|
||||
# is the port. This reads both ":::5330" and
|
||||
# "127.0.0.1.5330".
|
||||
n = split($4, a, /[:.]/)
|
||||
if (a[n] != p)
|
||||
next
|
||||
if ($NF ~ ("/" o "$")) {
|
||||
found = 1
|
||||
exit
|
||||
}
|
||||
}
|
||||
END { exit(found ? 0 : 1) }
|
||||
'; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_config_ports <config-file>
|
||||
# Print the ports a generated configuration declares as listeners.
|
||||
#
|
||||
# The gate must validate the configuration that is ACTUALLY running, not the
|
||||
# one UCI currently describes. After a rollback the live file is the older
|
||||
# known-good copy, and checking it against the candidate's ports made the gate
|
||||
# wait forever for a port the restored configuration never binds - a rollback
|
||||
# that had already brought the service back was reported as failed.
|
||||
hp_config_ports() {
|
||||
grep -o '"listen_port"[[:space:]]*:[[:space:]]*[0-9]\+' "$1" 2>"/dev/null" \
|
||||
| grep -o '[0-9]\+$'
|
||||
}
|
||||
|
||||
# hp_config_port_for_tag <config-file> <tag>
|
||||
# Print the listen_port of the inbound whose "tag" is <tag>, or nothing.
|
||||
#
|
||||
# The functional probes need to know which listener is the DNS inbound and
|
||||
# which is the mixed one, and they have to read that from the same place the
|
||||
# gate does - the running configuration. Using the UCI values instead would
|
||||
# point the probes at the candidate's ports after a rollback restored the older
|
||||
# known-good file: a warning in the log at best, and a rollback of a healthy
|
||||
# service when health_probe_strict is on.
|
||||
#
|
||||
# The generated JSON is pretty-printed with one field per line and `tag`
|
||||
# precedes `listen_port` inside each inbound object (generator/inbound.uc), so
|
||||
# a two-state walk is enough. The sed first splits any compact JSON onto the
|
||||
# same one-field-per-line shape, so the helper does not silently depend on the
|
||||
# generator's formatting: with both keys on one line the greedy tag match would
|
||||
# keep the LAST tag and find no port at all.
|
||||
hp_config_port_for_tag() {
|
||||
sed -e 's/[{},]/\n/g' "$1" 2>"/dev/null" | awk -v tag="$2" '
|
||||
/"tag"[[:space:]]*:/ {
|
||||
t = $0
|
||||
sub(/.*"tag"[[:space:]]*:[[:space:]]*"/, "", t)
|
||||
sub(/".*/, "", t)
|
||||
}
|
||||
/"listen_port"[[:space:]]*:/ && t == tag {
|
||||
p = $0
|
||||
sub(/.*"listen_port"[[:space:]]*:[[:space:]]*/, "", p)
|
||||
sub(/[^0-9].*/, "", p)
|
||||
print p
|
||||
exit
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# hp_service_healthy <instance-name> <config-path> [port...]
|
||||
# One sample of the gate. Ports are optional (the server side has no fixed
|
||||
# listening port worth checking).
|
||||
hp_service_healthy() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
shift 2
|
||||
|
||||
hp_instance_running "$name" "$config" || return 1
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
hp_listener_owned "$HP_HEALTH_OWNER" "$@"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
2)
|
||||
# No netstat owner column here. Say so once instead of
|
||||
# silently pretending the listeners were verified.
|
||||
if [ -z "${HP_HEALTH_LISTENER_WARNED:-}" ]; then
|
||||
HP_HEALTH_LISTENER_WARNED=1
|
||||
export HP_HEALTH_LISTENER_WARNED
|
||||
log "Warning: cannot verify the listeners on this target (netstat -p unavailable); relying on the procd check only."
|
||||
fi
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_wait_service <instance-name> <config-path> [budget] [stable] [port...]
|
||||
# 0 once the instance has been healthy for <stable> consecutive samples,
|
||||
# within <budget> seconds. 1 on timeout.
|
||||
hp_wait_service() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
local budget="${3:-$HP_HEALTH_BUDGET}"
|
||||
local stable="${4:-$HP_HEALTH_STABLE}"
|
||||
local good=0 tries=0
|
||||
|
||||
[ "$#" -ge 4 ] && shift 4 || shift "$#"
|
||||
|
||||
while [ "$tries" -lt "$budget" ]; do
|
||||
if hp_service_healthy "$name" "$config" "$@"; then
|
||||
good=$((good + 1))
|
||||
[ "$good" -ge "$stable" ] && return 0
|
||||
else
|
||||
good=0
|
||||
fi
|
||||
|
||||
tries=$((tries + 1))
|
||||
[ "$tries" -ge "$budget" ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_wait_instance <instance-name> <config-path> [seconds]
|
||||
# Kept for callers and tests that only want the plain liveness poll. New code
|
||||
# should use hp_wait_service: this one cannot tell a crash-restart loop from a
|
||||
# healthy service.
|
||||
hp_wait_instance() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
local tries="${3:-10}"
|
||||
|
||||
while [ "$tries" -gt 0 ]; do
|
||||
hp_instance_running "$name" "$config" && return 0
|
||||
sleep 1
|
||||
tries=$((tries - 1))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- functional probes -----------------------------------------------------
|
||||
#
|
||||
# Everything above answers "is the instance up, and does it own the ports it
|
||||
# declared?". None of it answers "does it actually serve?". A configuration
|
||||
# can pass `sing-box check`, bind every listener, hold them for the whole
|
||||
# observation window - and still not resolve a name, because the DNS router
|
||||
# failed to initialize or a jail mount is missing.
|
||||
#
|
||||
# The probes below add that layer, deliberately as *evidence* first and as a
|
||||
# gate only when asked for:
|
||||
#
|
||||
# * "cannot probe" is reported separately from "the probe failed", so a
|
||||
# target without nslookup/nc does not look like a broken service;
|
||||
# * a failure only fails the gate when health_probe_strict is on. A probe
|
||||
# can fail for reasons that have nothing to do with sing-box - no uplink,
|
||||
# a captive portal, an upstream resolver outage - and rolling a perfectly
|
||||
# good configuration back because of that is worse than the condition
|
||||
# being probed for. A rollback loop is the failure mode to avoid here.
|
||||
#
|
||||
# The client side is the side with a fixed capability worth probing (its DNS
|
||||
# inbound and the mixed inbound). The server side's inbounds are
|
||||
# protocol-specific: a bare TCP connect to a REALITY/trojan port proves
|
||||
# nothing, so it is not probed.
|
||||
: "${HP_PROBE_STRICT:=0}"
|
||||
: "${HP_PROBE_NAME:=example.com}"
|
||||
|
||||
# hp_probe_dns <port> [name]
|
||||
# 0 the DNS inbound answered, 1 it did not, 2 cannot probe here.
|
||||
hp_probe_dns() {
|
||||
local port="$1"
|
||||
local name="${2:-$HP_PROBE_NAME}"
|
||||
|
||||
command -v nslookup > "/dev/null" 2>&1 || return 2
|
||||
[ -n "$port" ] || return 2
|
||||
|
||||
nslookup -port="$port" "$name" 127.0.0.1 > "/dev/null" 2>&1 && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_probe_tcp <port>
|
||||
# 0 something accepted a connection on loopback:<port>, 1 nothing did,
|
||||
# 2 cannot probe here.
|
||||
hp_probe_tcp() {
|
||||
local port="$1"
|
||||
|
||||
[ -n "$port" ] || return 2
|
||||
command -v nc > "/dev/null" 2>&1 || return 2
|
||||
|
||||
nc 127.0.0.1 "$port" < "/dev/null" > "/dev/null" 2>&1 && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_run_probes <label> <dns-port> <mixed-port>
|
||||
# Report the probe results for one side. Returns 1 only when a probe failed
|
||||
# AND HP_PROBE_STRICT=1; a probe that could not be performed never fails it.
|
||||
hp_run_probes() {
|
||||
local label="$1"
|
||||
local dns_port="$2"
|
||||
local mixed_port="$3"
|
||||
local failed=0 rc
|
||||
|
||||
hp_probe_dns "$dns_port" "$HP_PROBE_NAME"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
1)
|
||||
failed=1
|
||||
log "Warning: the ${label} DNS inbound (127.0.0.1:${dns_port}) did not answer a query for ${HP_PROBE_NAME}." ;;
|
||||
2)
|
||||
log "Warning: cannot probe the ${label} DNS inbound (no nslookup, or no dns-in listener in the running configuration)." ;;
|
||||
esac
|
||||
|
||||
hp_probe_tcp "$mixed_port"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
1)
|
||||
failed=1
|
||||
log "Warning: nothing accepted a connection on the ${label} mixed inbound (127.0.0.1:${mixed_port})." ;;
|
||||
2)
|
||||
log "Warning: cannot probe the ${label} mixed inbound (no nc, or no mixed-in listener in the running configuration)." ;;
|
||||
esac
|
||||
|
||||
if [ "$failed" = "1" ] && [ "${HP_PROBE_STRICT:-0}" = "1" ]; then
|
||||
log "Error: the ${label} functional probes failed and health_probe_strict is on."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Routing/network plumbing for homeproxy.
|
||||
#
|
||||
# The tproxy and TUN paths need ip rules, a routing table and (for TUN) the
|
||||
# device itself before procd starts sing-box, and they need to be torn down
|
||||
# again on stop. All of that used to live inline in /etc/init.d/homeproxy.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, like the
|
||||
# other runtime modules, requires `config_load` to have run (it reads the
|
||||
# infra marks and the TUN device name itself).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy.
|
||||
|
||||
# hp_net_wait_wan [seconds]
|
||||
# Block until a default route or an "up" wan interface exists. Bounded: after
|
||||
# the timeout sing-box is started anyway, because a missing default route is
|
||||
# a common transient state at boot and refusing to start would be worse.
|
||||
hp_net_wait_wan() {
|
||||
local limit="${1:-60}"
|
||||
local wan_waited=0
|
||||
|
||||
while ! { ip route show default 2>"/dev/null" | grep -q . \
|
||||
|| ip -6 route show default 2>"/dev/null" | grep -q . \
|
||||
|| ifstatus wan 2>"/dev/null" | grep -q '"up": true'; }; do
|
||||
wan_waited=$((wan_waited + 1))
|
||||
if [ "$wan_waited" -ge "$limit" ]; then
|
||||
log "WARNING: No network route after ${limit}s, starting sing-box anyway."
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# hp_net_try <描述> <命令...>
|
||||
# Run a step that may legitimately fail, logging the wrapped message.
|
||||
hp_net_try() {
|
||||
local message="$1"; shift
|
||||
"$@" 2>"/dev/null" || log "Error: ${message}"
|
||||
}
|
||||
|
||||
# hp_net_rule_add <family> <fwmark> <table> <描述>
|
||||
# Install one ip rule idempotently. `ip rule add` has no replace form and the
|
||||
# kernel appends a second identical rule instead of reporting a conflict, so a
|
||||
# repeated start (rc.common's `start` never calls stop_service) used to leave
|
||||
# duplicates behind. The teardown side already drains them with `while del`;
|
||||
# this is the matching install side. family is "" for ip, "-6" for ip -6.
|
||||
hp_net_rule_add() {
|
||||
local family="$1" mark="$2" table="$3" message="$4"
|
||||
|
||||
while ip $family rule del fwmark "$mark" table "$table" 2>"/dev/null"; do :; done
|
||||
hp_net_try "$message" ip $family rule add fwmark "$mark" table "$table"
|
||||
}
|
||||
|
||||
# hp_net_setup <proxy-mode> <routing-mode>
|
||||
# Create the tproxy or TUN routing state for the configured proxy mode.
|
||||
# Requires config_load.
|
||||
#
|
||||
# Every step is idempotent on purpose: `start` on an already-running service
|
||||
# re-runs this function without a preceding teardown. The old code reported
|
||||
# "failed to add the tproxy local route (table 100)" on every such start
|
||||
# (EEXIST from `ip route add`) while silently appending a duplicate ip rule.
|
||||
# Routes use `replace`, rules are drained before they are added, and the TUN
|
||||
# device is only created when it does not already exist.
|
||||
hp_net_setup() {
|
||||
local proxy_mode="$1"
|
||||
local routing_mode="$2"
|
||||
local ipv6_support table_mark outbound_udp_node tproxy_mark tun_name tun_mark
|
||||
|
||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
||||
config_get table_mark "infra" "table_mark" "100"
|
||||
|
||||
case "$proxy_mode" in
|
||||
"redirect_tproxy")
|
||||
config_get outbound_udp_node "config" "main_udp_node" "nil"
|
||||
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ]; then
|
||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
||||
|
||||
hp_net_rule_add "" "$tproxy_mark" "$table_mark" \
|
||||
"failed to add the tproxy ip rule (fwmark ${tproxy_mark}, table ${table_mark})."
|
||||
hp_net_try "failed to add the tproxy local route (table ${table_mark})." \
|
||||
ip route replace local 0.0.0.0/0 dev lo table "$table_mark"
|
||||
|
||||
if [ "$ipv6_support" -eq "1" ]; then
|
||||
hp_net_rule_add "-6" "$tproxy_mark" "$table_mark" \
|
||||
"failed to add the tproxy IPv6 rule (fwmark ${tproxy_mark}, table ${table_mark})."
|
||||
hp_net_try "failed to add the tproxy IPv6 local route (table ${table_mark})." \
|
||||
ip -6 route replace local ::/0 dev lo table "$table_mark"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
"redirect_tun"|"tun")
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
config_get tun_mark "infra" "tun_mark" "102"
|
||||
|
||||
# Only wait after a real creation: the old unconditional `sleep 1s`
|
||||
# delayed every start (including reloads) even when the device was
|
||||
# already present.
|
||||
if ! ip link show "$tun_name" >"/dev/null" 2>&1; then
|
||||
hp_net_try "failed to create the TUN device ${tun_name}." \
|
||||
ip tuntap add mode tun user root name "$tun_name"
|
||||
sleep 1s
|
||||
fi
|
||||
hp_net_try "failed to bring up the TUN device ${tun_name}." \
|
||||
ip link set "$tun_name" up
|
||||
|
||||
hp_net_try "failed to set the TUN default route (table ${table_mark})." \
|
||||
ip route replace default dev "$tun_name" table "$table_mark"
|
||||
hp_net_rule_add "" "$tun_mark" "$table_mark" \
|
||||
"failed to add the TUN ip rule (fwmark ${tun_mark}, table ${table_mark})."
|
||||
|
||||
if [ "$ipv6_support" -eq "1" ]; then
|
||||
hp_net_try "failed to set the TUN IPv6 default route (table ${table_mark})." \
|
||||
ip -6 route replace default dev "$tun_name" table "$table_mark"
|
||||
hp_net_rule_add "-6" "$tun_mark" "$table_mark" \
|
||||
"failed to add the TUN IPv6 rule (fwmark ${tun_mark}, table ${table_mark})."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# hp_net_teardown
|
||||
# Remove every tproxy/TUN rule and route homeproxy added. Requires
|
||||
# config_load.
|
||||
#
|
||||
# ip rule del removes a single matching rule, so a repeated start (or an
|
||||
# upgrade that starts before the old instance stopped) leaves duplicates
|
||||
# behind. Delete every match instead of only the first one.
|
||||
hp_net_teardown() {
|
||||
local table_mark tproxy_mark tun_mark tun_name
|
||||
|
||||
config_get table_mark "infra" "table_mark" "100"
|
||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
||||
config_get tun_mark "infra" "tun_mark" "102"
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
|
||||
while ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
|
||||
while ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
|
||||
|
||||
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
|
||||
while ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
|
||||
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
|
||||
while ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
}
|
||||
|
||||
# hp_net_remove_tun
|
||||
# service_stopped() hook on the genuine-stop path only (HP_RELOAD_IN_PROGRESS=0):
|
||||
# procd calls it after the instances are gone, so the TUN device can be removed.
|
||||
# Requires config_load.
|
||||
#
|
||||
# On the reload path (HP_RELOAD_IN_PROGRESS=1) the caller skips this entirely,
|
||||
# so the device survives across the stop;start that reload_service drives; the
|
||||
# table-100 default route is therefore not pulled mid-reload - the
|
||||
# "tun mode reload exposes the LAN with the real IP" failure that the previous
|
||||
# TODO documented (review P2, 2026-09-28) is the issue the caller now closes.
|
||||
hp_net_remove_tun() {
|
||||
local tun_name
|
||||
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
|
||||
ip link set "$tun_name" down 2>"/dev/null"
|
||||
ip tuntap del mode tun name "$tun_name" 2>"/dev/null"
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Service-side runtime helpers for homeproxy.
|
||||
#
|
||||
# These are the device-side orchestration pieces that used to be inlined in
|
||||
# /etc/init.d/homeproxy: the sing-box version gate, the auto-update cron
|
||||
# entry, the runtime-file preparation, the procd instance registrations and
|
||||
# the "generate -> known-good" transaction that start_service runs for each
|
||||
# side.
|
||||
#
|
||||
# Layering note: unlike
|
||||
# runtime/config.sh and runtime/health.sh - which are deliberately pure and
|
||||
# only take explicit paths - the extracted modules are device-side
|
||||
# by nature. They call `log`, `config_get` and `procd_*`, so a caller must
|
||||
# have run `config_load` and must be inside a procd init context. That is
|
||||
# exactly why they were moved out of init.d and not turned into pure helpers.
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy.
|
||||
|
||||
# hp_require_singbox
|
||||
# Refuse to start on a sing-box older than 1.14: the generated configuration
|
||||
# uses 1.14-only fields, and an older binary would reject it at run time
|
||||
# instead of failing here. Returns 1 (and logs) when unusable.
|
||||
#
|
||||
# `sing-box` is looked up through PATH, exactly as the previous init script
|
||||
# did - it is deliberately not PROG, so a PATH mismatch between the two is
|
||||
# not silently introduced by this refactor.
|
||||
hp_require_singbox() {
|
||||
local sb_ver sb_major sb_minor
|
||||
|
||||
sb_ver="$(sing-box version -n 2>/dev/null)"
|
||||
if [ -z "$sb_ver" ]; then
|
||||
log "Error: cannot detect sing-box version, abort."
|
||||
return 1
|
||||
fi
|
||||
|
||||
sb_major="${sb_ver%%.*}"
|
||||
sb_minor="${sb_ver#*.}"
|
||||
sb_minor="${sb_minor%%.*}"
|
||||
|
||||
if [ "$sb_major" -lt 1 ] || { [ "$sb_major" -eq 1 ] && [ "$sb_minor" -lt 14 ]; }; then
|
||||
log "Error: sing-box >= 1.14.0 required, found ${sb_ver}."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_crontab_drop <crontab>
|
||||
# Remove the auto-update entry from <crontab>.
|
||||
#
|
||||
# Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host with
|
||||
# BSD sed it fails ("invalid command code"), which silently left the stale entry
|
||||
# behind with only a warning in the log. Editing through a temporary file
|
||||
# behaves identically on busybox, GNU and BSD sed, and it is what lets this
|
||||
# module be exercised off-target at all
|
||||
# (tests/runtime/test_runtime_extraction.sh stages and drives it).
|
||||
hp_crontab_drop() {
|
||||
local crontab="$1"
|
||||
local tmp="${crontab}.hp-new"
|
||||
|
||||
sed "/#${CONF}_autosetup/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
|
||||
mv -f "$tmp" "$crontab" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
|
||||
|
||||
# mv replaces the *file*, not its contents: the temporary was created by the
|
||||
# shell (0666 & ~umask = 0644) while procd creates /etc/crontabs/root as
|
||||
# 0600, so every stop/start left the root crontab world-readable - verified
|
||||
# on the device (0600 after boot, 0644 after one reload). Only root needs
|
||||
# to read it (crond runs as root), and a root crontab can hold other jobs'
|
||||
# secrets, so set the mode rather than inherit it - which also repairs a
|
||||
# file an older version already loosened.
|
||||
chmod 600 "$crontab" 2>"/dev/null"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_sync_autoupdate_cron <enabled> <hour>
|
||||
# Install (or drop) the subscription auto-update cron entry. <enabled> is a
|
||||
# config_get_bool result; the entry is only touched when it is "1".
|
||||
hp_sync_autoupdate_cron() {
|
||||
local auto_update="$1"
|
||||
local auto_update_time="$2"
|
||||
|
||||
[ "$auto_update" = "1" ] || return 0
|
||||
|
||||
# auto_update_time comes from UCI, which anything on the LAN can write, and
|
||||
# it used to be interpolated into a root crontab line verbatim: a value
|
||||
# like '2 * * * * root echo pwned #' appends a second job. Validate it
|
||||
# before the line exists, and write it with printf rather than `echo -e`
|
||||
# so no backslash sequence is interpreted either.
|
||||
case "$auto_update_time" in
|
||||
''|*[!0-9]*)
|
||||
log "Warning: auto_update_time='$auto_update_time' is not an hour of the day, skipping the cron entry."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [ "$auto_update_time" -gt 23 ]; then
|
||||
log "Warning: auto_update_time=$auto_update_time is out of range (0-23), skipping the cron entry."
|
||||
return 1
|
||||
fi
|
||||
|
||||
hp_crontab_drop "/etc/crontabs/root" \
|
||||
|| log "Warning: failed to drop the previous auto-update cron entry."
|
||||
printf '0 %s * * * %s/scripts/update_crond.sh #%s_autosetup\n' \
|
||||
"$auto_update_time" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \
|
||||
|| log "Warning: failed to install the auto-update cron entry."
|
||||
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
|
||||
}
|
||||
|
||||
# hp_clear_autoupdate_cron
|
||||
# Drop the auto-update cron entry and restart cron. Used by stop_service.
|
||||
hp_clear_autoupdate_cron() {
|
||||
hp_crontab_drop "/etc/crontabs/root" \
|
||||
|| log "Warning: failed to drop the auto-update cron entry."
|
||||
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
|
||||
}
|
||||
|
||||
# hp_prepare_runtime_files <hp-dir> <run-dir> <routing-mode> <client> <server>
|
||||
# Create the mode-specific working files, truncate the instance logs and hand
|
||||
# every runtime file to the sing-box user. <client>/<server> are "1"/"0".
|
||||
hp_prepare_runtime_files() {
|
||||
local hp_dir="$1"
|
||||
local run_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local client_enabled="$4"
|
||||
local server_enabled="$5"
|
||||
|
||||
# cache_file is enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental). The file has to exist before
|
||||
# the client jail binds it, or sing-box cannot create it in there.
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom")
|
||||
[ -e "$hp_dir/cache.db" ] || touch "$hp_dir/cache.db" \
|
||||
|| log "Warning: failed to create ${hp_dir}/cache.db."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$routing_mode" = "custom" ]; then
|
||||
[ -d "$hp_dir/ruleset" ] || mkdir -p "$hp_dir/ruleset" \
|
||||
|| log "Warning: failed to create ${hp_dir}/ruleset."
|
||||
# A local rule-set is opened by the client, which no longer runs as
|
||||
# root now that the jail is on for this mode too, so the files have to
|
||||
# be readable by the sing-box user. Only the documented directory is
|
||||
# claimed here; a rule-set placed elsewhere under HP_DIR has to be
|
||||
# readable already.
|
||||
chown -R sing-box:sing-box "$hp_dir/ruleset" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${hp_dir}/ruleset to sing-box."
|
||||
fi
|
||||
|
||||
[ "$client_enabled" = "1" ] && echo > "$run_dir/sing-box-c.log"
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
echo > "$run_dir/sing-box-s.log"
|
||||
if mkdir -p "$hp_dir/certs" 2>"/dev/null"; then
|
||||
# The server runs jailed as the sing-box user and reads its
|
||||
# certificate, key and ECH config from this directory, while an
|
||||
# upload lands as root:0600 (luci.homeproxy). Hand the directory
|
||||
# and its files to that user and keep them 0700/0600: the jailed
|
||||
# server can read them and nobody else can reach the private key.
|
||||
# Without this the choice was "world-readable key" or "jailed
|
||||
# server cannot start".
|
||||
chown sing-box:sing-box "$hp_dir/certs" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${hp_dir}/certs to sing-box."
|
||||
chmod 700 "$hp_dir/certs" 2>"/dev/null"
|
||||
for f in "$hp_dir/certs"/*.pem "$hp_dir/certs"/*.crt "$hp_dir/certs"/*.key; do
|
||||
[ -f "$f" ] || continue
|
||||
chown sing-box:sing-box "$f" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${f} to sing-box."
|
||||
chmod 600 "$f" 2>"/dev/null"
|
||||
done
|
||||
else
|
||||
log "Warning: failed to create ${hp_dir}/certs."
|
||||
fi
|
||||
fi
|
||||
|
||||
# chown each path that is actually there.
|
||||
#
|
||||
# The old one-shot chown listed every path unconditionally. A mode whose
|
||||
# start path does not create one of them (custom mode and cache.db, before
|
||||
# that file became shared with it) then failed and logged "failed to
|
||||
# change the ownership of the runtime files" on every single start - a
|
||||
# warning that was always there and never meant anything. A missing path is
|
||||
# not worth reporting; failing to chown a file that exists still is.
|
||||
for f in "$run_dir"/sing-box-*.json "$run_dir"/sing-box-*.log "$hp_dir"/cache.db; do
|
||||
[ -e "$f" ] || continue
|
||||
chown sing-box:sing-box "$f" 2>"/dev/null" \
|
||||
|| log "Warning: failed to change the ownership of ${f} to sing-box."
|
||||
done
|
||||
}
|
||||
|
||||
# hp_procd_client_instance <prog> <hp-dir> <run-dir> <routing-mode> <disable-gso>
|
||||
# Register the sing-box client instance. The ujail gate is client-specific:
|
||||
# a wireguard/tun outbound cannot be jailed (it needs the host network stack),
|
||||
# and custom routing needs HP_DIR inside the jail because a local rule-set may
|
||||
# point at any path validateHomeProxyPath() accepts. The `procd_append_param
|
||||
# command` text below is what runtime/health.sh's pgrep pattern matches -
|
||||
# change both together.
|
||||
hp_procd_client_instance() {
|
||||
local prog="$1"
|
||||
local hp_dir="$2"
|
||||
local run_dir="$3"
|
||||
local routing_mode="$4"
|
||||
local disable_gso="$5"
|
||||
|
||||
procd_open_instance "sing-box-c"
|
||||
|
||||
procd_set_param command "$prog"
|
||||
procd_append_param command run --config "$run_dir/sing-box-c.json"
|
||||
|
||||
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
|
||||
|
||||
if [ -x "/sbin/ujail" ] && ! grep -Eq '"type": "(wireguard|tun)"' "$run_dir/sing-box-c.json"; then
|
||||
procd_add_jail "sing-box-c" log procfs
|
||||
procd_add_jail_mount "$run_dir/sing-box-c.json"
|
||||
procd_add_jail_mount_rw "$run_dir/sing-box-c.log"
|
||||
# A custom routing table may point a local rule-set anywhere under
|
||||
# HP_DIR, so the whole directory goes in read-only rather than a
|
||||
# guessed subset. procd orders the mounts by path, so this parent is
|
||||
# bound before the read-write cache.db below and cannot shadow it.
|
||||
procd_add_jail_mount "$hp_dir/"
|
||||
# cache_file is enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental), so both need the file
|
||||
# writable inside the jail.
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom")
|
||||
procd_add_jail_mount_rw "$hp_dir/cache.db" ;;
|
||||
esac
|
||||
procd_add_jail_mount "$hp_dir/certs/"
|
||||
# The certificate path gate accepts /etc/acme as well (a client TLS
|
||||
# certificate managed by acme.sh), so the client jail has to carry it
|
||||
# too - it was missing here while the server jail already had it.
|
||||
procd_add_jail_mount "/etc/acme/"
|
||||
procd_add_jail_mount "/etc/ssl/"
|
||||
# custom routing's find_neighbor resolves LAN hostnames out of the
|
||||
# dnsmasq lease file; its MAC lookups go over netlink and need no file,
|
||||
# but a `source_hostname` rule silently stops matching without this.
|
||||
procd_add_jail_mount "/tmp/dhcp.leases"
|
||||
procd_add_jail_mount "/etc/localtime"
|
||||
procd_add_jail_mount "/etc/TZ"
|
||||
procd_set_param capabilities "/etc/capabilities/homeproxy.json"
|
||||
procd_set_param no_new_privs 1
|
||||
procd_set_param user sing-box
|
||||
procd_set_param group sing-box
|
||||
fi
|
||||
|
||||
procd_set_param limits core="unlimited"
|
||||
procd_set_param limits nofile="1000000 1000000"
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
# hp_procd_server_instance <prog> <hp-dir> <run-dir> <disable-gso>
|
||||
# Register the sing-box server instance. The jail only depends on ujail
|
||||
# being installed, and it needs the certificate/ACME material instead of the
|
||||
# client's cache.db.
|
||||
hp_procd_server_instance() {
|
||||
local prog="$1"
|
||||
local hp_dir="$2"
|
||||
local run_dir="$3"
|
||||
local disable_gso="$4"
|
||||
|
||||
procd_open_instance "sing-box-s"
|
||||
|
||||
procd_set_param command "$prog"
|
||||
procd_append_param command run --config "$run_dir/sing-box-s.json"
|
||||
|
||||
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
|
||||
|
||||
if [ -x "/sbin/ujail" ]; then
|
||||
procd_add_jail "sing-box-s" log procfs
|
||||
procd_add_jail_mount "$run_dir/sing-box-s.json"
|
||||
procd_add_jail_mount_rw "$run_dir/sing-box-s.log"
|
||||
procd_add_jail_mount_rw "$hp_dir/certs/"
|
||||
procd_add_jail_mount "/etc/acme/"
|
||||
procd_add_jail_mount "/etc/ssl/"
|
||||
procd_add_jail_mount "/etc/localtime"
|
||||
procd_add_jail_mount "/etc/TZ"
|
||||
procd_set_param capabilities "/etc/capabilities/homeproxy.json"
|
||||
procd_set_param no_new_privs 1
|
||||
procd_set_param user sing-box
|
||||
procd_set_param group sing-box
|
||||
fi
|
||||
|
||||
procd_set_param limits core="unlimited"
|
||||
procd_set_param limits nofile="1000000 1000000"
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
# hp_procd_log_cleaner <hp-dir>
|
||||
# Register the log-rotator instance. It is unconditional: it also has to run
|
||||
# when neither client nor server is enabled.
|
||||
hp_procd_log_cleaner() {
|
||||
local hp_dir="$1"
|
||||
|
||||
procd_open_instance "log-cleaner"
|
||||
procd_set_param command "$hp_dir/scripts/clean_log.sh"
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
# hp_capture_candidate <run-dir> <side>
|
||||
# Stage the configuration the reload preflight just generated and validated.
|
||||
#
|
||||
# The copy is necessary because stop_service() removes $RUN_DIR/sing-box-*.json
|
||||
# on its way to start_service (they are runtime state, not configuration), so
|
||||
# the file the preflight validated is gone by the time the new instance would
|
||||
# use it - which is exactly why the old reload regenerated it. The staged copy
|
||||
# lives in $RUN_DIR/candidate/, which stop_service() does not touch, and it is
|
||||
# what hp_start_generated_config() activates. Non-zero when there is nothing
|
||||
# staged, and the caller aborts the reload rather than continuing without a
|
||||
# validated candidate.
|
||||
hp_capture_candidate() {
|
||||
local run_dir="$1"
|
||||
local side="$2"
|
||||
local live="$run_dir/sing-box-${side}.json"
|
||||
local cand_dir="$run_dir/candidate"
|
||||
local staged="$cand_dir/sing-box-${side}.json"
|
||||
|
||||
[ -s "$live" ] || return 1
|
||||
|
||||
mkdir -p "$cand_dir" 2>/dev/null || return 1
|
||||
cp -f "$live" "$staged" 2>/dev/null || return 1
|
||||
# The staged file carries the same credentials as the live one; the
|
||||
# generator already wrote it 0600 and this copy has to stay that way.
|
||||
chmod 600 "$staged" 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_start_generated_config <side> <hp-dir> <run-dir> <good-dir>
|
||||
# The start-path transaction for one side ("c" or "s"):
|
||||
#
|
||||
# HP_USE_KNOWN_GOOD=1 -> copy the recorded configuration over the live one
|
||||
# instead of regenerating (a rollback: regenerating
|
||||
# would rebuild the very file that just failed)
|
||||
# HP_CANDIDATE_<SIDE>=1 -> the reload preflight generated and `sing-box
|
||||
# check`ed this side; activate the copy
|
||||
# hp_capture_candidate() staged, do not generate again
|
||||
# otherwise -> generate, then make sure a live file exists,
|
||||
# falling back to the known-good copy when it does not
|
||||
#
|
||||
# Returns 1 only when there is nothing to run at all (generation failed and
|
||||
# no known-good copy exists), which is what made start_service bail out.
|
||||
#
|
||||
# It deliberately does NOT touch the known-good copy any more. It used to
|
||||
# record the freshly generated file immediately, which meant that by the time
|
||||
# the health gate ran, the "previous" configuration the rollback would need had
|
||||
# already been overwritten by the candidate - so a failed reload had nothing to
|
||||
# roll back to. Promotion is now a separate step, taken only after the gate
|
||||
# has passed (hp_promote_known_good, called from start_service).
|
||||
#
|
||||
# A3: the candidate flag exists because the generator is no longer guaranteed to
|
||||
# produce the same bytes twice - its env carries the WAN resolver and the two
|
||||
# domain-resource lists, either of which can change between two invocations. A
|
||||
# reload that generated the config, validated it, and then let start_service
|
||||
# generate it *again* would run an artifact that had never been checked:
|
||||
# validate A -> discard A -> generate B -> run B. The flag makes the validated
|
||||
# bytes the ones that run. It is per side, so a config_load disagreement between
|
||||
# reload_service and start_service (a concurrent UCI change) generates the side
|
||||
# the preflight did not cover instead of activating a stale file.
|
||||
hp_start_generated_config() {
|
||||
local side="$1"
|
||||
local hp_dir="$2"
|
||||
local run_dir="$3"
|
||||
local good_dir="$4"
|
||||
local label generator live good candidate staged
|
||||
|
||||
case "$side" in
|
||||
c)
|
||||
label="client"
|
||||
generator="$hp_dir/scripts/generate_client.uc"
|
||||
candidate="${HP_CANDIDATE_CLIENT:-0}"
|
||||
;;
|
||||
s)
|
||||
label="server"
|
||||
generator="$hp_dir/scripts/generate_server.uc"
|
||||
candidate="${HP_CANDIDATE_SERVER:-0}"
|
||||
;;
|
||||
*) log "Error: unknown configuration side '${side}'."; return 1 ;;
|
||||
esac
|
||||
|
||||
live="$run_dir/sing-box-${side}.json"
|
||||
good="$good_dir/sing-box-${side}.json"
|
||||
|
||||
if [ "${HP_USE_KNOWN_GOOD:-0}" = "1" ] && [ -s "$good" ]; then
|
||||
# Rollback path: start from the recorded configuration instead
|
||||
# of regenerating. Regenerating would rebuild the very config
|
||||
# that just failed to come up, because the generator is a pure
|
||||
# function of UCI.
|
||||
log "Starting with the last known-good ${label} configuration."
|
||||
cp -f "$good" "$live"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$candidate" = "1" ]; then
|
||||
# The reload preflight's artifact is the one that passed `sing-box
|
||||
# check`. stop_service() removed the live copy on the way here, so the
|
||||
# staged copy is what gets activated - the same bytes that were
|
||||
# validated, not a second generation.
|
||||
staged="$run_dir/candidate/sing-box-${side}.json"
|
||||
|
||||
if [ -s "$staged" ] && cp -f "$staged" "$live" 2>/dev/null; then
|
||||
# One shot: the staged copy must not survive to be activated by a
|
||||
# later start that never validated it.
|
||||
rm -f "$staged"
|
||||
log "Activating the ${label} configuration validated by the reload preflight."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Defensive: the flag was set but nothing was staged. Generate rather
|
||||
# than activate an unvalidated or stale file.
|
||||
log "Warning: no validated ${label} candidate is staged; generating the configuration again."
|
||||
fi
|
||||
|
||||
ucode -S "$generator" 2>>"$LOG_PATH"
|
||||
|
||||
# The generator is transactional on its own: it writes a temporary file
|
||||
# and only renames it over the live one once `sing-box check` accepted
|
||||
# it. A failed generation therefore leaves the previous file untouched -
|
||||
# and that is the only `sing-box check` in this path (the runtime used to
|
||||
# run a second one on the same file).
|
||||
hp_ensure_live "$live" "$good"
|
||||
case "$?" in
|
||||
0)
|
||||
;;
|
||||
1)
|
||||
log "Error: failed to generate a valid ${label} configuration; falling back to the last known-good one." ;;
|
||||
*)
|
||||
log "Error: failed to generate ${label} configuration."
|
||||
return 1 ;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# The UCI keys the intercept layer reads, split by UCI type.
|
||||
#
|
||||
# hp_restore_known_good_uci() replays the snapshot before the rollback
|
||||
# restart, so anything that steers firewall_post.ut / firewall_pre.uc /
|
||||
# net.sh / dns.sh has to be in here: a key left out survives the rollback as
|
||||
# the *new* (failing) value and start_service reinstalls a layer that no
|
||||
# longer matches the sing-box file it just restored.
|
||||
#
|
||||
# Review 2026-09-29 (docs/review-report-20260929-full.md §1.2) found the
|
||||
# previous five-line list covered 4 real keys out of 22, and that the fifth
|
||||
# line named `infra.tun_address` - an option that exists nowhere. The real
|
||||
# keys are `tun_addr4` / `tun_addr6` (config/loader.uc:411), so that line was
|
||||
# always empty and the restore was a silent no-op; the golden trace recorded
|
||||
# the empty line as expected, which is how the defect survived. tests/
|
||||
# arch-guard.sh guard 43 derives the read set from the layer consumers and
|
||||
# compares it against this list in both directions.
|
||||
#
|
||||
# The two TUN address keys are the one deliberate inclusion that no layer
|
||||
# consumer reads: the address is only ever installed by the generated sing-box
|
||||
# file, so leaving UCI describing the failed address would make the next
|
||||
# reload regenerate the configuration that just failed.
|
||||
HP_INTERCEPT_SCALARS="config.main_node
|
||||
config.main_udp_node
|
||||
config.proxy_mode
|
||||
config.routing_mode
|
||||
config.routing_port
|
||||
config.ipv6_support
|
||||
config.china_dns_server
|
||||
infra.self_mark
|
||||
infra.redirect_port
|
||||
infra.tproxy_port
|
||||
infra.tproxy_mark
|
||||
infra.tun_mark
|
||||
infra.tun_name
|
||||
infra.tun_addr4
|
||||
infra.tun_addr6
|
||||
infra.dns_redirect
|
||||
infra.dns_port
|
||||
infra.common_port
|
||||
infra.table_mark
|
||||
routing.bypass_cn_traffic
|
||||
routing.default_outbound
|
||||
server.enabled
|
||||
control.lan_proxy_mode"
|
||||
|
||||
# List options are kept apart because `uci set key=a,b` stores a *string*:
|
||||
# ipv4_to_nftarr() / iface_to_nftarr() reject a non-array (they return null),
|
||||
# so a snapshot replayed through `uci set` would drop every ACL rule that
|
||||
# reads the list - a rollback that silently un-proxies the LAN. The writer
|
||||
# joins the elements with ',' and the replay uses `uci delete` +
|
||||
# `uci add_list`. No value in this set can contain a comma: they are IPv4 /
|
||||
# IPv6 addresses, MAC addresses or interface names.
|
||||
HP_INTERCEPT_LISTS="control.listen_interfaces
|
||||
control.lan_direct_ipv4_ips
|
||||
control.lan_direct_ipv6_ips
|
||||
control.lan_direct_mac_addrs
|
||||
control.lan_proxy_ipv4_ips
|
||||
control.lan_proxy_ipv6_ips
|
||||
control.lan_proxy_mac_addrs
|
||||
control.lan_gaming_mode_ipv4_ips
|
||||
control.lan_gaming_mode_ipv6_ips
|
||||
control.lan_gaming_mode_mac_addrs
|
||||
control.lan_global_proxy_ipv4_ips
|
||||
control.lan_global_proxy_ipv6_ips
|
||||
control.lan_global_proxy_mac_addrs
|
||||
control.wan_proxy_ipv4_ips
|
||||
control.wan_proxy_ipv6_ips
|
||||
control.wan_direct_ipv4_ips
|
||||
control.wan_direct_ipv6_ips"
|
||||
|
||||
# hp_write_uci_snapshot <snap>
|
||||
# Serialise HP_INTERCEPT_SCALARS / HP_INTERCEPT_LISTS into <snap>.
|
||||
#
|
||||
# One line per key so a reader without uci-tools can iterate the file:
|
||||
#
|
||||
# homeproxy.<section>.<option>=<value> scalar
|
||||
# list homeproxy.<section>.<option>=a,b list, ','-joined
|
||||
# list homeproxy.<section>.<option>= list that was absent or empty
|
||||
#
|
||||
# An absent scalar is written as an empty value, which is how the replay
|
||||
# spells "remove the option again" (UCI deletes an option set to an empty
|
||||
# value) - so the snapshot round-trips absence as well as presence.
|
||||
hp_write_uci_snapshot() {
|
||||
local snap="$1"
|
||||
local pair sec opt val el out
|
||||
|
||||
config_load "$CONF"
|
||||
|
||||
{
|
||||
for pair in $HP_INTERCEPT_SCALARS; do
|
||||
sec="${pair%%.*}"
|
||||
opt="${pair#*.}"
|
||||
config_get val "$sec" "$opt"
|
||||
printf 'homeproxy.%s.%s=%s\n' "$sec" "$opt" "$val"
|
||||
done
|
||||
|
||||
for pair in $HP_INTERCEPT_LISTS; do
|
||||
sec="${pair%%.*}"
|
||||
opt="${pair#*.}"
|
||||
config_get val "$sec" "$opt"
|
||||
# config_get hands a UCI list over space-separated; rejoin it.
|
||||
out=""
|
||||
for el in $val; do
|
||||
[ -z "$out" ] && out="$el" || out="$out,$el"
|
||||
done
|
||||
printf 'list homeproxy.%s.%s=%s\n' "$sec" "$opt" "$out"
|
||||
done
|
||||
} > "$snap.tmp" 2>"/dev/null" && mv -f "$snap.tmp" "$snap"
|
||||
}
|
||||
|
||||
# hp_promote_known_good <side> <run-dir> <good-dir>
|
||||
# Record the live configuration as the new known-good copy. Called only after
|
||||
# the health gate has proven that the configuration actually runs, so the
|
||||
# rollback target is always something that came up.
|
||||
#
|
||||
# Also writes $good_dir/uci-snapshot.txt (see HP_INTERCEPT_SCALARS above).
|
||||
# The rollback path in init.d/homeproxy reads that file back BEFORE its
|
||||
# stop;start, otherwise start_service reinstalls the new (failing) layer on
|
||||
# top of the rolled-back sing-box bytes - the classic "rollback restored the
|
||||
# config but nft is still the new chains" silent failure that took a
|
||||
# real-machine repro to spot (review P1-3, 2026-09-28). Snapshotting only
|
||||
# the keys that steer the network layer keeps the rollback bounded: a stray
|
||||
# key elsewhere never overwrites itself.
|
||||
hp_promote_known_good() {
|
||||
local side="$1"
|
||||
local run_dir="$2"
|
||||
local good_dir="$3"
|
||||
local label
|
||||
|
||||
case "$side" in
|
||||
c) label="client" ;;
|
||||
s) label="server" ;;
|
||||
*) log "Error: unknown configuration side '${side}'."; return 1 ;;
|
||||
esac
|
||||
|
||||
hp_known_good "$run_dir/sing-box-${side}.json" "$good_dir/sing-box-${side}.json" \
|
||||
|| log "Warning: could not refresh the known-good ${label} configuration."
|
||||
|
||||
# Written once on every successful promote. Both sides promote the same
|
||||
# content, so the two calls cannot disagree.
|
||||
hp_write_uci_snapshot "$good_dir/uci-snapshot.txt"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Pure subscription decoder. The fetcher returns raw bytes
|
||||
* and the orchestrator used to inline the JSON / base64 / SIP008
|
||||
* handling. Pulling it out gives the orchestrator a single
|
||||
* `decode(content, log, url)` call and lets us unit-test each input
|
||||
* shape without involving the network or UCI.
|
||||
*
|
||||
* Three input shapes are recognised, in order:
|
||||
* 1. JSON with a top-level 'servers' array (clash proxy-provider)
|
||||
* 2. JSON array directly (the same data, just unwrapped)
|
||||
* 3. Shadowsocks SIP008 JSON: top-level array of objects with
|
||||
* server+method. Each entry is tagged nodetype='sip008' so the
|
||||
* orchestrator can dispatch them differently downstream.
|
||||
*
|
||||
* On JSON failure, decode the content as base64 and split on '\n'
|
||||
* to get one share-link URI per line (sing-box subscription format).
|
||||
*
|
||||
* On total failure (no JSON, no base64), return an empty list and
|
||||
* let the caller log the empty result.
|
||||
*
|
||||
* `url` is optional and only used to enrich the parse-failed log
|
||||
* line; the decoder itself never makes a network call.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, decodeBase64Str, redactUrl } from '../homeproxy.uc';
|
||||
|
||||
export function decode(content, log, url) {
|
||||
if (isEmpty(content))
|
||||
return [];
|
||||
|
||||
let nodes;
|
||||
try {
|
||||
const parsed = json(content);
|
||||
nodes = parsed.servers || parsed;
|
||||
|
||||
/* Shadowsocks SIP008: each entry is a JSON object with
|
||||
* server + method, not a URI string.
|
||||
*
|
||||
* The probe has to check the type first. It used to be a bare
|
||||
* `nodes[0].server`, which THROWS in ucode when nodes[0] is a
|
||||
* string ("left-hand side expression is not an array or
|
||||
* object") - and the throw was swallowed by the JSON
|
||||
* try/catch below, so a perfectly valid `{"servers":["vless://…"]}`
|
||||
* or `["vless://…"]` subscription fell through to the base64
|
||||
* fallback, failed there too and decoded to an empty list. Two
|
||||
* real subscription shapes were silently discarded, and the
|
||||
* decoder test had recorded that as the contract. The same
|
||||
* guard applies inside the loop so a mixed list (SIP008 objects
|
||||
* plus share links) keeps both halves instead of losing
|
||||
* everything on the first string. */
|
||||
if (type(nodes[0]) === 'object' && nodes[0].server && nodes[0].method) {
|
||||
for (let i = 0; i < length(nodes); i++)
|
||||
if (type(nodes[i]) === 'object')
|
||||
nodes[i].nodetype = 'sip008';
|
||||
}
|
||||
} catch (e) {
|
||||
/* The URL carries the subscription token and this line lands in
|
||||
* homeproxy.log, which is world-readable. fetcher.uc and the
|
||||
* orchestrator both redact; this path was missed. */
|
||||
const tag = url ? sprintf('for %s, ', redactUrl(url)) : '';
|
||||
log(sprintf('JSON parse failed %strying base64: %s', tag, e.message));
|
||||
const decoded = decodeBase64Str(content);
|
||||
nodes = decoded ? split(trim(decoded), '\n') : [];
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Thin wrapper around homeproxy's wGETVerbose that logs
|
||||
* the failure and returns a flat { content, error } shape. The
|
||||
* orchestrator used to inline the empty-content check + log
|
||||
* message right next to the call, which made the loop body hard
|
||||
* to read and conflated the fetch's success/failure decision with
|
||||
* the downstream decode step.
|
||||
*
|
||||
* On success: returns { content: <string>, error: null }.
|
||||
* On failure: returns { content: null, error: <string> }, having
|
||||
* already logged the failure with the URL. The caller can keep
|
||||
* the loop flat: 'if (result.content === null) continue;'.
|
||||
*
|
||||
* No caching, no retry, no backoff - the orchestrator decides
|
||||
* those if it ever needs them. The Fetcher is a single HTTP GET
|
||||
* + error log, nothing more.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, redactUrl, wGETVerbose } from '../homeproxy.uc';
|
||||
|
||||
export function fetch(url, user_agent, log) {
|
||||
const result = wGETVerbose(url, user_agent);
|
||||
if (isEmpty(result.content)) {
|
||||
/* Redact the URL before logging: the query string is the
|
||||
* subscription token and homeproxy.log is world-readable on
|
||||
* a default install. The original URL goes back to the caller
|
||||
* via result.error so error propagation still works. */
|
||||
log(sprintf('Failed to fetch resources from %s: %s',
|
||||
redactUrl(url), result.error || 'empty response'));
|
||||
return { content: null, error: result.error || 'empty response' };
|
||||
}
|
||||
return { content: result.content, error: null };
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Pure subscription filter. The orchestrator
|
||||
* (update_subscriptions.uc) used to inline both the keyword
|
||||
* whitelist/blacklist check and the post-parse policy application
|
||||
* (tls.insecure override, protocol_options.packet_encoding for
|
||||
* vless/vmess). They live here so the orchestrator stays focused on
|
||||
* the fetch/cache/repository flow and the pure logic is unit-testable
|
||||
* without UCI access.
|
||||
*
|
||||
* Neither function touches module state: mode / keywords / opts
|
||||
* come in as arguments, and a logger is passed in for the
|
||||
* invalid-regex warning. That makes the module callable from a
|
||||
* plain test stub and means the orchestrator can keep its own
|
||||
* file-writer log() around the call.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty } from '../homeproxy.uc';
|
||||
|
||||
/* Decide whether a node named `name` should be dropped. `mode` is
|
||||
* one of:
|
||||
* 'disabled' - filter is off, return false (keep)
|
||||
* 'blacklist' - drop if any keyword regex matches
|
||||
* 'whitelist' - drop unless any keyword regex matches
|
||||
*
|
||||
* `keywords` is a list of strings; each is compiled as a regexp.
|
||||
* One bad regex is logged and skipped, not treated as fatal - this
|
||||
* matches the pre-B1 behaviour, where a stray `[` would not block
|
||||
* the whole subscription.
|
||||
*
|
||||
* Returns true when the node should be skipped. */
|
||||
export function check(name, mode, keywords, log) {
|
||||
if (isEmpty(name) || mode === 'disabled' || isEmpty(keywords))
|
||||
return false;
|
||||
|
||||
let matched = false;
|
||||
for (let i in keywords) {
|
||||
let patten;
|
||||
try {
|
||||
patten = regexp(i);
|
||||
} catch (e) {
|
||||
log(sprintf('Skipping invalid filter keyword regex: %s.', i));
|
||||
continue;
|
||||
}
|
||||
if (patten && match(name, patten))
|
||||
matched = true;
|
||||
}
|
||||
if (mode === 'whitelist')
|
||||
matched = !matched;
|
||||
|
||||
return matched;
|
||||
};
|
||||
|
||||
/* Apply the two policy tweaks the orchestrator used to do inline. The subject
|
||||
* is a canonical Node - the same shape parser/normalize.uc produces and
|
||||
* parser/flatten.uc turns back into UCI keys - not the parser's flat
|
||||
* UCI-key dict:
|
||||
*
|
||||
* - tls.insecure is set when the node has TLS enabled and the user opted in
|
||||
* via subscription.allow_insecure='1'
|
||||
* - protocol_options.packet_encoding is set on vless/vmess nodes to the
|
||||
* subscription's default
|
||||
*
|
||||
* This used to run on the flat dict, which is why the orchestrator applied
|
||||
* policy on one representation and handed a different one to the Repository.
|
||||
* The canonical Node is now the only business object between the parser and
|
||||
* the Repository, and this was the last step still forcing the flat shape.
|
||||
* The tweaks are a property of the subscription, not of the parsed link, so
|
||||
* they deliberately do not take part in the duplicate fingerprint - which is
|
||||
* computed from the parser output before this runs.
|
||||
*
|
||||
* Mutates and returns the node; the return value is the same object as the
|
||||
* input, so callers can chain. */
|
||||
export function apply_policy(node, opts) {
|
||||
if (!node)
|
||||
return node;
|
||||
|
||||
if (node.tls && node.tls.enabled === '1' && opts.allow_insecure === '1')
|
||||
node.tls.insecure = '1';
|
||||
|
||||
if (node.type in ['vless', 'vmess']) {
|
||||
/* normalize() always builds protocol_options; a hand-made node
|
||||
* (a test fixture, a future caller) may not have it, and the flat
|
||||
* version simply created the key. */
|
||||
if (!node.protocol_options)
|
||||
node.protocol_options = {};
|
||||
|
||||
node.protocol_options.packet_encoding = opts.packet_encoding;
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Every UCI write the
|
||||
* subscription pipeline performs now lives in this module. Three
|
||||
* entry points cover what used to be six uci.set+commit sites in
|
||||
* update_subscriptions.uc plus the existing node add/update/remove
|
||||
* walk:
|
||||
*
|
||||
* apply_nodes - the add / update / remove walk
|
||||
* for the nodes the subscription
|
||||
* fetch returned this run.
|
||||
* apply_main_node_refs - main_node / main_udp_node cleanup:
|
||||
* remove stale urltest members, switch
|
||||
* main_node if its target is gone,
|
||||
* reset both to 'nil' when the
|
||||
* subscription has no nodes.
|
||||
* scrub_stale_urltest_refs - one-shot pass over every UCI
|
||||
* routing_node that purges dead
|
||||
* urltest_nodes entries left behind by
|
||||
* nodes that have since been removed.
|
||||
*
|
||||
* Writes are driven by canonical Node objects (post- parser/normalize).
|
||||
* UCI is flat, so apply_nodes expands each node with parser/flatten.uc -
|
||||
* and that call is the module's only canonical -> UCI boundary: callers
|
||||
* hand this module canonical Nodes, never flat dicts, and every UCI key
|
||||
* name comes from parser/mapping.uc. The orchestrator
|
||||
* (update_subscriptions.uc) never writes UCI directly; it orchestrates
|
||||
* fetch + parse + normalize + filter + policy + reload and owns the one
|
||||
* commit.
|
||||
*
|
||||
* A2 (single commit): this module mutates the caller's cursor and never
|
||||
* commits. It used to commit at nine sites - once at the end of
|
||||
* apply_nodes, seven inside apply_main_node_refs, and once per scrubbed
|
||||
* routing_node - while update_subscriptions.uc's own comments called
|
||||
* that "the repository's single commit". A crash between two of them
|
||||
* left the nodes written but main_node still pointing at a deleted
|
||||
* section, and there was no single point at which the run became
|
||||
* visible. The transaction boundary is now the caller's one commit,
|
||||
* after every mutation is staged.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { md5 } from 'digest';
|
||||
|
||||
import { isEmpty } from '../homeproxy.uc';
|
||||
|
||||
/* Relative, like config/loader.uc's '../parser/mapping.uc': ucode's
|
||||
* resolver only searches top-level module names in the -L tree, so a
|
||||
* bare 'parser/flatten.uc' does not resolve. */
|
||||
import { flatten } from '../parser/flatten.uc';
|
||||
|
||||
/* --- apply_nodes -------------------------------------------------------- */
|
||||
|
||||
/* Add / update / remove the subscription-owned nodes against UCI.
|
||||
*
|
||||
* node_cache - { [groupHash]: { [nameHash]: canonical_node } }
|
||||
* A lookup table keyed by subscription URL group
|
||||
* and then by the per-node name hash. The fetch
|
||||
* loop populates this for de-duplication.
|
||||
* node_result - [[canonical_node, ...]] (one inner list per URL)
|
||||
* The de-duplicated, policy-applied nodes in the
|
||||
* order they should appear in UCI.
|
||||
*
|
||||
* Behaviour parity with the previous code:
|
||||
* - user-created nodes (no grouphash) are never touched
|
||||
* - nodes whose subscription has no cache entry (network
|
||||
* failure for that URL) are left in place; we don't delete
|
||||
* a node just because today's fetch didn't return it
|
||||
* - a node in UCI but not in the cache is deleted
|
||||
* - a node in both has every field of the new config written
|
||||
* (including options the stored section did not have yet),
|
||||
* and the options the new config no longer carries are removed
|
||||
* - new nodes are added under name = md5(groupHash + label), the
|
||||
* same hash the cache uses, so a subsequent re-fetch recognises
|
||||
* them as existing
|
||||
*
|
||||
* Returns the { added, removed } counts. No commit here: the caller's
|
||||
* single commit is the boundary of "this run wrote something". */
|
||||
function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
let added = 0, removed = 0;
|
||||
/* Mark the canonical Node objects the foreach loop above has
|
||||
* already updated in place, so the second loop skips them. We
|
||||
* use object identity (the same Node object is held in node_cache
|
||||
* and in node_result, so `seen[node] === node` works as a set),
|
||||
* not the section name or nameHash: the orchestrator hands the
|
||||
* Repository the same Node instances, and tying the marker to the
|
||||
* object means a stale string key cannot match a renamed node. */
|
||||
const seen = {};
|
||||
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
/* User-created nodes do not have a grouphash. The
|
||||
* repository must never touch them, even if the
|
||||
* subscription fetch returns nothing for today. */
|
||||
if (!cfg.grouphash)
|
||||
return null;
|
||||
|
||||
/* No cache entry for this subscription group means
|
||||
* the fetch failed for that URL. Leave the existing
|
||||
* nodes in place so the user does not lose them on
|
||||
* a transient network blip. */
|
||||
if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0)
|
||||
return null;
|
||||
|
||||
const incoming = node_cache[cfg.grouphash][cfg['.name']];
|
||||
|
||||
if (!incoming) {
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
removed++;
|
||||
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Canonical Node -> flat UCI keys. flatten() drops
|
||||
* nulls so a freshly-saved canonical Node does not
|
||||
* litter /etc/config with `tls_sni ''` entries for
|
||||
* fields the parser never set; the Loader treats null
|
||||
* and absent the same, so this is observably
|
||||
* equivalent. */
|
||||
const flat = flatten(incoming);
|
||||
|
||||
/* Write every option the new config carries, including
|
||||
* ones the stored section did not have yet. Walking only
|
||||
* the OLD section's keys used to miss new options
|
||||
* (plugin, tls_sni, packet_encoding) the subscription
|
||||
* started sending after the node was first imported. */
|
||||
for (let v in keys(flat))
|
||||
uci.set(uciconfig, cfg['.name'], v, flat[v]);
|
||||
|
||||
/* Then drop the options the new config no longer
|
||||
* carries. `.name` / `.type` / `.index` are section
|
||||
* metadata, not options; deleting them is meaningless
|
||||
* at best. */
|
||||
for (let v in keys(cfg)) {
|
||||
if (substr(v, 0, 1) === '.')
|
||||
continue;
|
||||
if (!(v in flat))
|
||||
uci.delete(uciconfig, cfg['.name'], v);
|
||||
}
|
||||
|
||||
seen[incoming] = true;
|
||||
});
|
||||
|
||||
for (let nodes in node_result)
|
||||
map(nodes, (node) => {
|
||||
if (seen[node])
|
||||
return null;
|
||||
|
||||
/* normalize() exposes the source label as `name`; `label`
|
||||
* is only set when the orchestrator carries it over. Relying
|
||||
* on `label` alone hashed every node of a subscription to the
|
||||
* same section name, because undefined stringifies
|
||||
* identically - four nodes collapsed into one section mixing
|
||||
* several protocols' options. Accept either. */
|
||||
const label = node.label || node.name;
|
||||
const nameHash = md5(node.grouphash + label);
|
||||
|
||||
/* The one boundary where a canonical Node becomes UCI keys.
|
||||
*
|
||||
* UCI is a flat key/value store, so this expansion has to
|
||||
* happen somewhere; keeping it inside the Repository's write
|
||||
* path is what lets every other layer - the orchestrator, the
|
||||
* filter, the policy step - speak canonical only. If a field
|
||||
* must not reach UCI, or its UCI name must change, this call
|
||||
* and parser/mapping.uc are the places to look; no caller
|
||||
* hands this function a flat dict. */
|
||||
const flat = flatten(node);
|
||||
|
||||
uci.set(uciconfig, nameHash, 'node');
|
||||
for (let v in keys(flat))
|
||||
uci.set(uciconfig, nameHash, v, flat[v]);
|
||||
|
||||
added++;
|
||||
log(sprintf('Adding node: %s.', label));
|
||||
});
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
/* --- apply_main_node_refs ---------------------------------------------- */
|
||||
|
||||
/* Prune the entries of an UCI list that no longer point at a live
|
||||
* node. Used twice below for `main_urltest_nodes` and
|
||||
* `main_udp_urltest_nodes`; the helper writes the cleaned list back
|
||||
* only when it actually changed, and returns it for the caller to
|
||||
* inspect (an empty list is the trigger to switch to first_server). */
|
||||
function maybeScrubUrltestNodes(uci, uciconfig, ucimain, listOption, log) {
|
||||
const old_list = uci.get(uciconfig, ucimain, listOption) || [];
|
||||
const cleaned = filter(old_list, (v) => {
|
||||
if (!uci.get(uciconfig, v)) {
|
||||
log(sprintf('Node %s is gone, removing from urltest list.', v));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (length(cleaned) !== length(old_list))
|
||||
uci.set(uciconfig, ucimain, listOption, cleaned);
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/* Switch a main_* field off the dangling reference and onto the first
|
||||
* available node. The two callers below differ only in the UCI option
|
||||
* name (`main_node` vs `main_udp_node`) and the user-facing log line,
|
||||
* so this is what made the pre-refactor code mirror itself.
|
||||
*
|
||||
* `current_value` is what the field currently points at (the user-chosen
|
||||
* section name, the literal 'urltest', or 'same' / 'nil'). We only
|
||||
* overwrite the field when the section named by `current_value` no
|
||||
* longer exists in UCI; the previous version asked `uci.get(uciconfig,
|
||||
* field)` which is "does a section literally named `field` exist" - the
|
||||
* config has cfg0xxxx names, never one named `main_node`, so the check
|
||||
* was always false and the user's pick got silently replaced with
|
||||
* first_server on every subscription update.
|
||||
*
|
||||
* The new value is also pushed onto `result.log` so the orchestrator
|
||||
* can replay it (it uses `result.log` to log the same lines verbatim;
|
||||
* the homeproxy.log capture goes through the separate `log` arg). */
|
||||
function switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, field, current_value, first_server, result, log) {
|
||||
if (uci.get(uciconfig, current_value))
|
||||
return current_value;
|
||||
uci.set(uciconfig, ucimain, field, first_server);
|
||||
const msg = sprintf('Main%s node is gone, switching to the first node.',
|
||||
field === 'main_udp_node' ? ' UDP' : '');
|
||||
log(msg);
|
||||
push(result.log, msg);
|
||||
return first_server;
|
||||
}
|
||||
|
||||
/* Replace the 4 inline uci.set/commit sites (plus the 2-line
|
||||
* 'reset to nil' block) in update_subscriptions.uc. Logic parity:
|
||||
*
|
||||
* - if main_node is set and at least one subscription node exists:
|
||||
* * when main_node === 'urltest', scrub main_urltest_nodes down
|
||||
* to nodes that still exist, then switch main_node to the first
|
||||
* surviving one if the urltest set is empty
|
||||
* * otherwise, switch main_node to first_server if its target is
|
||||
* gone
|
||||
* * same shape for main_udp_node, only when main_udp_node is set
|
||||
* AND not 'same'
|
||||
* - if main_node is set but no subscription nodes exist, reset
|
||||
* both main_node and main_udp_node to 'nil'
|
||||
*
|
||||
* `ctx` carries the inputs the orchestrator used to read from UCI:
|
||||
* { main_node, main_udp_node, has_nodes }
|
||||
*
|
||||
* Mutates only, no commit. The orchestrator commits once after
|
||||
* apply_nodes, this method and scrub_stale_urltest_refs have all
|
||||
* staged their changes, so a run is either fully visible or not at all.
|
||||
*
|
||||
* Returns { main_node: <new>, main_udp_node: <new>, log: [..] } so
|
||||
* the orchestrator can log "Main node is gone, switching to ..." /
|
||||
* "No available node, disable tproxy." exactly as before. */
|
||||
function apply_main_node_refs(uci, uciconfig, ucimain, ucinode, ctx, log) {
|
||||
/* Object destructuring is not part of the dialect the ucode on the
|
||||
* target accepts (ImmortalWrt ucode 2026.01.16 rejects
|
||||
* `const { a } = x` with "Expecting variable name"), so pull the
|
||||
* fields out by name. tests/ucode/test_ucode_grammar.sh guards this. */
|
||||
const main_node = ctx.main_node,
|
||||
main_udp_node = ctx.main_udp_node;
|
||||
const result = { main_node, main_udp_node, log: [] };
|
||||
|
||||
if (isEmpty(main_node))
|
||||
return result;
|
||||
|
||||
const first_server = uci.get_first(uciconfig, ucinode);
|
||||
if (!first_server) {
|
||||
/* No nodes left at all: reset both to 'nil'. */
|
||||
uci.set(uciconfig, ucimain, 'main_node', 'nil');
|
||||
uci.set(uciconfig, ucimain, 'main_udp_node', 'nil');
|
||||
result.main_node = 'nil';
|
||||
result.main_udp_node = 'nil';
|
||||
push(result.log, 'No available node, disable tproxy.');
|
||||
return result;
|
||||
}
|
||||
|
||||
if (main_node === 'urltest') {
|
||||
const cleaned = maybeScrubUrltestNodes(uci, uciconfig, ucimain, 'main_urltest_nodes', log);
|
||||
|
||||
if (!length(cleaned))
|
||||
result.main_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_node', main_node, first_server, result, log);
|
||||
} else {
|
||||
result.main_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_node', main_node, first_server, result, log);
|
||||
}
|
||||
|
||||
if (!isEmpty(main_udp_node) && main_udp_node !== 'same') {
|
||||
if (main_udp_node === 'urltest') {
|
||||
const cleaned = maybeScrubUrltestNodes(uci, uciconfig, ucimain, 'main_udp_urltest_nodes', log);
|
||||
|
||||
if (!length(cleaned))
|
||||
result.main_udp_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_udp_node', main_udp_node, first_server, result, log);
|
||||
} else {
|
||||
result.main_udp_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_udp_node', main_udp_node, first_server, result, log);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* --- scrub_stale_urltest_refs ----------------------------------------- */
|
||||
|
||||
/* Walk every routing_node and prune the urltest_nodes list to drop
|
||||
* entries that no longer point at a live node. Mutates only: the caller
|
||||
* commits once after this returns. Returns { changed } - the number of
|
||||
* routing_nodes it rewrote - which is what the old `commits` counter
|
||||
* measured (it counted its own commits; the previous code committed
|
||||
* inside the foreach loop, once per scrubbed routing_node). */
|
||||
function scrub_stale_urltest_refs(uci, uciconfig, log) {
|
||||
let changed = 0;
|
||||
|
||||
uci.foreach(uciconfig, 'routing_node', (cfg) => {
|
||||
if (cfg.node !== 'urltest' || isEmpty(cfg.urltest_nodes))
|
||||
return null;
|
||||
|
||||
const cleaned_nodes = filter(cfg.urltest_nodes, (v) => uci.get(uciconfig, v));
|
||||
if (length(cleaned_nodes) === length(cfg.urltest_nodes))
|
||||
return null;
|
||||
|
||||
uci.set(uciconfig, cfg['.name'], 'urltest_nodes', cleaned_nodes);
|
||||
changed++;
|
||||
log(sprintf('Routing node %s: removed gone nodes from urltest list.', cfg['.name']));
|
||||
});
|
||||
|
||||
return { changed };
|
||||
}
|
||||
|
||||
export const Repository = {
|
||||
apply_nodes,
|
||||
apply_main_node_refs,
|
||||
scrub_stale_urltest_refs
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2023 ImmortalWrt.org
|
||||
|
||||
SCRIPTS_DIR="/etc/homeproxy/scripts"
|
||||
|
||||
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
|
||||
"$SCRIPTS_DIR"/update_resources.sh "$i"
|
||||
done
|
||||
|
||||
"$SCRIPTS_DIR"/update_subscriptions.uc
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
#
|
||||
# Fetch and install one resource list (china_ip4 / china_ip6 / gfw_list /
|
||||
# china_list). Each list comes from a fixed upstream repository; the current
|
||||
# commit is resolved through the GitHub API, the file is downloaded from the
|
||||
# first reachable mirror at that exact commit, and the result is installed only
|
||||
# after its git blob id has been checked against the one the API reports for
|
||||
# that path and commit (resource_blob_sha.uc). A list that fails the check is
|
||||
# discarded and the installed copy is left in place.
|
||||
#
|
||||
# Driven by the rpcd method `resources_update` and by the auto-update cron
|
||||
# entry; tests/runtime/test_resource_update.sh exercises the verification path
|
||||
# off-target with stubbed wget/jsonfilter/ucode.
|
||||
|
||||
NAME="homeproxy"
|
||||
|
||||
# The blob-digest helper sits next to this script and is invoked through it, so
|
||||
# its location follows however the script was started (rpcd, cron, a shell).
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>"/dev/null" && pwd)"
|
||||
|
||||
RESOURCES_DIR="/etc/$NAME/resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
|
||||
RUN_DIR="/var/run/$NAME"
|
||||
LOG_PATH="$RUN_DIR/$NAME.log"
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
log() {
|
||||
printf '%s %s\n' "$(date "+%Y-%m-%d %H:%M:%S")" "$*" >> "$LOG_PATH"
|
||||
}
|
||||
|
||||
to_upper() {
|
||||
printf '%s' "$1" | tr "[a-z]" "[A-Z]"
|
||||
}
|
||||
|
||||
# Review M7: each entry is tried in order, success stops the loop. Order
|
||||
# matters: fastly.jsdelivr.net is the CDN edge closest to the original
|
||||
# report and used to be the only mirror; gcore and cdn are sibling edges;
|
||||
# raw.githubusercontent.com is the upstream fallback (the file lives in
|
||||
# the same GitHub repo we just queried for the SHA, so it is reachable
|
||||
# whenever the version query was reachable). Putting the CDN edges first
|
||||
# keeps the common case fast; the GitHub fallback catches the case where
|
||||
# the CDN is blocked but GitHub is reachable - which is the shape of the
|
||||
# `api.github.com` rate-limit problem the report calls out.
|
||||
MIRRORS="fastly.jsdelivr.net gcore.jsdelivr.net cdn.jsdelivr.net raw.githubusercontent.com"
|
||||
|
||||
# Pick the first mirror that responds to a HEAD with HTTP 200 in 10s.
|
||||
# Called with: <path-suffix>. Echoes the chosen base URL on stdout, or
|
||||
# fails the script if every mirror timed out.
|
||||
pick_mirror() {
|
||||
local path_suffix="$1"
|
||||
for base in $MIRRORS; do
|
||||
if [ "$base" = "raw.githubusercontent.com" ]; then
|
||||
# GitHub raw serves paths from the repo root, not the
|
||||
# /gh/<repo>@<sha>/<file> shape jsdelivr uses. The caller
|
||||
# passes the suffix already split out, so we re-stitch it.
|
||||
local probe_url="https://$base/$listrepo/$list_sha/$listname"
|
||||
else
|
||||
local probe_url="https://$base/gh/$listrepo@$list_sha/$listname"
|
||||
fi
|
||||
if wget --timeout=10 --spider -q "$probe_url" 2>"/dev/null"; then
|
||||
printf '%s\n' "$base"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_list_update() {
|
||||
local listtype="$1"
|
||||
local listrepo="$2"
|
||||
local listref="$3"
|
||||
local listname="$4"
|
||||
local lock="$RUN_DIR/update_resources-$listtype.lock"
|
||||
local github_token="$(uci -q get homeproxy.config.github_token)"
|
||||
local wget="wget --timeout=10 -q"
|
||||
|
||||
# fd 9, not 200: POSIX only guarantees 0-9, and dash fails the whole
|
||||
# `exec 200>"$lock"` with "exec: 200: not found". busybox ash and bash
|
||||
# accept multi-digit descriptors, so this only ever showed up off-target -
|
||||
# but the shebang says /bin/sh, and the off-target driver runs it there.
|
||||
# `&>` is a bashism for the same reason; `> file 2>&1` is the POSIX form.
|
||||
exec 9>"$lock"
|
||||
if ! flock -n 9 >"/dev/null" 2>&1; then
|
||||
log "[$(to_upper "$listtype")] A task is already running."
|
||||
return 2
|
||||
fi
|
||||
|
||||
# The token travels in argv, which the previous form avoided by writing it
|
||||
# to a 0600 file and passing --header-file - an option neither busybox nor
|
||||
# GNU wget has, so it failed with "unrecognized option" and the version
|
||||
# query never worked at all with a token configured. There is no
|
||||
# file-based header option in either wget, so argv is the only way; on a
|
||||
# single-user router that is the right trade for a feature that otherwise
|
||||
# does not function. `wget` is invoked with the header only when a token
|
||||
# is set, and nothing logs the command line.
|
||||
local github_header=""
|
||||
[ -n "$github_token" ] && github_header="Authorization: Bearer $github_token"
|
||||
|
||||
local list_info="$($wget ${github_header:+--header "$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
|
||||
local wget_exit=$?
|
||||
|
||||
if [ $wget_exit -ne 0 ]; then
|
||||
log "[$(to_upper "$listtype")] Failed to fetch version info (wget exit $wget_exit)."
|
||||
return 1
|
||||
fi
|
||||
local list_sha="$(printf '%s' "$list_info" | jsonfilter -qe "@[0].sha")"
|
||||
local list_date="$(printf '%s' "$list_info" | jsonfilter -qe "@[0].commit.committer.date" | cut -d 'T' -f1)"
|
||||
if [ -z "$list_sha" ]; then
|
||||
log "[$(to_upper "$listtype")] Failed to get the latest version, please retry later."
|
||||
return 1
|
||||
fi
|
||||
local list_ver="${list_date:+$list_date }$list_sha"
|
||||
|
||||
local local_list_ver="$(cat "$RESOURCES_DIR/$listtype.ver" 2>"/dev/null" || echo "NOT_FOUND")"
|
||||
local local_list_sha="${local_list_ver##* }"
|
||||
local local_list_disp="${local_list_ver%% *}"
|
||||
if [ "$local_list_sha" = "$list_sha" ]; then
|
||||
[ "$local_list_ver" = "$local_list_sha" ] && [ -n "$list_date" ] && \
|
||||
printf '%s\n' "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
|
||||
log "[$(to_upper "$listtype")] Current version: ${list_ver%% *}."
|
||||
log "[$(to_upper "$listtype")] You're already at the latest version."
|
||||
return 3
|
||||
else
|
||||
log "[$(to_upper "$listtype")] Local version: $local_list_disp, latest version: ${list_ver%% *}."
|
||||
fi
|
||||
|
||||
# Pick a mirror, then download. pick_mirror walks the list and uses
|
||||
# the first reachable one; raw.githubusercontent.com is a separate
|
||||
# path shape so the helper handles it.
|
||||
local mirror="$(pick_mirror)"
|
||||
if [ -z "$mirror" ]; then
|
||||
log "[$(to_upper "$listtype")] All mirrors unreachable (tried: $MIRRORS)."
|
||||
return 1
|
||||
fi
|
||||
local mirror_url
|
||||
if [ "$mirror" = "raw.githubusercontent.com" ]; then
|
||||
mirror_url="https://raw.githubusercontent.com/$listrepo/$list_sha/$listname"
|
||||
else
|
||||
mirror_url="https://$mirror/gh/$listrepo@$list_sha/$listname"
|
||||
fi
|
||||
log "[$(to_upper "$listtype")] Downloading from $mirror."
|
||||
|
||||
if ! $wget -O "$RUN_DIR/$listname" "$mirror_url" || [ ! -s "$RUN_DIR/$listname" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Download failed ($mirror)."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Integrity: the URL is pinned to $list_sha, so the mirror is *supposed* to
|
||||
# serve the bytes that commit contains. That is a statement about the URL,
|
||||
# not about the response - a mirror, a CDN edge or anything else in the
|
||||
# path can answer with different content, and the resource lists drive
|
||||
# nft sets and DNS snippets. Compare the file's git blob id against the one
|
||||
# GitHub reports for the same path and commit.
|
||||
#
|
||||
# This fails closed. The version query above already requires
|
||||
# api.github.com, so a router that cannot reach the API could not update
|
||||
# resources before this check either; the alternative - installing bytes
|
||||
# nobody vouched for - is the thing being fixed.
|
||||
local api_blob local_blob
|
||||
api_blob="$($wget ${github_header:+--header "$github_header"} -O- \
|
||||
"https://api.github.com/repos/$listrepo/contents/$listname?ref=$list_sha" \
|
||||
| jsonfilter -qe '@.sha')"
|
||||
if [ -z "$api_blob" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Refusing to install: GitHub reports no blob id for $listname at $list_sha."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local_blob="$(ucode -S "$SCRIPT_DIR/resource_blob_sha.uc" "$RUN_DIR/$listname" 2>"/dev/null")"
|
||||
if [ -z "$local_blob" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Refusing to install: cannot compute the blob id of the downloaded $listname."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$local_blob" != "$api_blob" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Refusing to install: $listname from $mirror does not match the content of commit $list_sha (expected $api_blob, got $local_blob)."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if mv -f "$RUN_DIR/$listname" "$RESOURCES_DIR/$listtype.${listname##*.}"; then
|
||||
printf '%s\n' "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
|
||||
# Review M7: persist the time *this router* last succeeded.
|
||||
# $list_date is the upstream commit date and can be months old
|
||||
# even on a successful run, so it is not a stand-in. Stored in
|
||||
# the same directory as the .ver file so resources_get_version
|
||||
# can read it.
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$RESOURCES_DIR/$listtype.updated_at"
|
||||
log "[$(to_upper "$listtype")] Successfully updated via $mirror (blob $local_blob)."
|
||||
else
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Failed to install update (mv failed)."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
"china_ip4")
|
||||
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv4.txt"
|
||||
;;
|
||||
"china_ip6")
|
||||
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv6.txt"
|
||||
;;
|
||||
"gfw_list")
|
||||
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "gfw.txt"
|
||||
;;
|
||||
"china_list")
|
||||
# Not `sed -i`: the bare -i form is a busybox/GNU extension, and the same
|
||||
# script is exercised off-device where a non-busybox sed fails it with
|
||||
# "invalid command code". Edit through a temp file, the way the crontab
|
||||
# helper in runtime/service.sh does.
|
||||
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" && \
|
||||
sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt" > "$RESOURCES_DIR/china_list.txt.hp-new" && \
|
||||
mv -f "$RESOURCES_DIR/china_list.txt.hp-new" "$RESOURCES_DIR/china_list.txt" || \
|
||||
rm -f "$RESOURCES_DIR/china_list.txt.hp-new"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2023 ImmortalWrt.org
|
||||
*
|
||||
* The orchestrator no
|
||||
* longer touches UCI directly. All UCI writes live in
|
||||
* subscription/repository.uc. The pipeline is:
|
||||
*
|
||||
* Loader.load() -> read subscription + main_node refs
|
||||
* parse_uri() -> flat UCI keys (the parser's output shape)
|
||||
* normalize() -> canonical Node (parser/normalize.uc)
|
||||
* apply_policy() -> policy tweaks on the canonical Node
|
||||
* (tls.insecure, protocol_options.packet_encoding)
|
||||
* Repository.apply_nodes -> writes UCI for subscription nodes
|
||||
* (internally flatten()s the canonical
|
||||
* nodes, so the orchestrator never
|
||||
* touches UCI).
|
||||
* Repository.apply_main_node_refs -> writes UCI for main_node /
|
||||
* main_udp_node / urltest cleanup.
|
||||
* Repository.scrub_stale_urltest_refs -> one-shot pass over UCI
|
||||
* routing_nodes for stale urltest_nodes
|
||||
* entries.
|
||||
*
|
||||
* The canonical Node is the only object the orchestrator hands the
|
||||
* Repository, and the only one policy is applied to. The parser's flat
|
||||
* UCI-key dict survives here for one thing only: the duplicate
|
||||
* fingerprint, which identifies the parsed link and must not move when a
|
||||
* subscription-level policy changes.
|
||||
*
|
||||
* Every UCI write the run performs is therefore in
|
||||
* subscription/repository.uc. The orchestrator's only UCI side
|
||||
* effect is passing its cursor to the Repository methods.
|
||||
*
|
||||
* A2 (single commit): the Repository commits nothing; this script owns the
|
||||
* one uci.commit() the run performs, after every mutation is staged and
|
||||
* before the reload. Read the comment above that commit for what the
|
||||
* multi-commit order used to allow.
|
||||
*
|
||||
* A4 (lock before snapshot): the cursor, the domain model and the recovery
|
||||
* snapshot are all read inside the lock. See load_locked_state().
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { md5 } from 'digest';
|
||||
import { lstat, open, readfile, writefile } from 'fs';
|
||||
import { connect } from 'ubus';
|
||||
import { cursor } from 'uci';
|
||||
|
||||
import {
|
||||
executeCommand, getTime, isEmpty, HP_DIR, RUN_DIR, redactUrl, shellQuote,
|
||||
UCICONFIG_DIR
|
||||
} from './homeproxy.uc';
|
||||
|
||||
import { parse_uri } from './parser/uri.uc';
|
||||
import { normalize } from './parser/normalize.uc';
|
||||
|
||||
import { check as filter_check, apply_policy } from './subscription/filter.uc';
|
||||
import { decode as decode_subscription } from './subscription/decoder.uc';
|
||||
import { fetch as fetch_subscription } from './subscription/fetcher.uc';
|
||||
import { Repository } from './subscription/repository.uc';
|
||||
|
||||
import { Loader } from './config/loader.uc';
|
||||
|
||||
/* UCI cursor, configuration and recovery snapshot the run works on.
|
||||
*
|
||||
* A4: none of this is read at module scope any more. The lock used to guard
|
||||
* only the write phase - the subscription list, the domain model and the
|
||||
* backup were all read first - so a run that read while another one held the
|
||||
* lock and then acquired it after that one finished would commit against the
|
||||
* base it had read before, and, on failure, restore a snapshot older than the
|
||||
* run that had just committed. Everything below is now filled by
|
||||
* load_locked_state(), which is called only after acquire_lock() returns true.
|
||||
*
|
||||
* A2: the run performs exactly one uci.commit(). The Repository modules stage
|
||||
* mutations only.
|
||||
*
|
||||
* UCICONFIG_DIR (homeproxy.uc) is the config *directory* Loader.load() takes,
|
||||
* and the sandboxed test points it at its own config instead of /etc/config
|
||||
* (tests/ucode/test_subscription_updater_runs.sh rewrites that line in
|
||||
* homeproxy.uc and fails loudly if the rewrite did not apply). */
|
||||
const CONFIG_FILE = UCICONFIG_DIR + '/homeproxy';
|
||||
const uciconfig = 'homeproxy';
|
||||
|
||||
const ucimain = 'config',
|
||||
ucinode = 'node';
|
||||
|
||||
/* Filled by load_locked_state(). `let`, so the reads could move into the lock
|
||||
* without threading a state object through every use site. */
|
||||
let uci, loaded, sub, routing_mode,
|
||||
allow_insecure, filter_mode, filter_keywords, packet_encoding,
|
||||
subscription_urls, user_agent, main_node, main_udp_node,
|
||||
config_backup = null;
|
||||
|
||||
/* Read everything the run needs, under the lock. Read-only: nothing here
|
||||
* mutates, so a failure cannot leave a partial state behind. */
|
||||
function load_locked_state() {
|
||||
uci = cursor();
|
||||
uci.load(uciconfig);
|
||||
|
||||
/* The orchestrator used to hold its own
|
||||
* uci.cursor() and re-read subscription.* / config.* one field at
|
||||
* a time. Now it goes through Loader.load(), which is read-only
|
||||
* and exposes the canonical sub-objects
|
||||
* (`config.access_control.subscription`). The Repository methods
|
||||
* continue to take the raw cursor for the writes because the
|
||||
* cursor is the UCI writer contract. */
|
||||
loaded = Loader.load(UCICONFIG_DIR);
|
||||
sub = loaded.access_control.subscription;
|
||||
routing_mode = loaded.general.routing_mode;
|
||||
|
||||
allow_insecure = sub.allow_insecure || '0';
|
||||
filter_mode = sub.filter_nodes || 'disabled';
|
||||
/* subscription_urls and filter_keywords are siblings of `subscription`,
|
||||
* not members of it: load_access_control() puts them straight on
|
||||
* access_control (loader.uc:303-304), and test_domain_model_skeleton.uc
|
||||
* asserts that shape. Reading them through `sub.` yielded [] forever, so
|
||||
* the guard below never called main() and the updater exited 0 having done
|
||||
* nothing - the LuCI button and the cron entry were both silent no-ops. */
|
||||
filter_keywords = loaded.access_control.filter_keywords || [];
|
||||
packet_encoding = sub.packet_encoding || 'xudp';
|
||||
subscription_urls = loaded.access_control.subscription_urls || [];
|
||||
user_agent = sub.user_agent;
|
||||
|
||||
main_node = null;
|
||||
main_udp_node = null;
|
||||
if (routing_mode !== 'custom') {
|
||||
main_node = loaded.general.main_node;
|
||||
main_udp_node = loaded.general.main_udp_node;
|
||||
}
|
||||
|
||||
/* The snapshot the failure path restores. It is read here, under the
|
||||
* lock, so it is the state this run actually started from - not whatever
|
||||
* was on disk while the process was still waiting for the lock. */
|
||||
config_backup = readfile(CONFIG_FILE);
|
||||
}
|
||||
|
||||
|
||||
function log(...args) {
|
||||
const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a');
|
||||
logfile.write(`${getTime()} [SUBSCRIBE] ${join(' ', args)}\n`);
|
||||
logfile.close();
|
||||
}
|
||||
|
||||
/* One updater at a time.
|
||||
*
|
||||
* The cron entry and the LuCI button can both start this script, and the update
|
||||
* is a sequence of commits (nodes, then main_node refs, then the repository's
|
||||
* single commit). Two runs interleaving them is last-writer-wins, and a failing
|
||||
* run restores ITS snapshot over whatever the other just wrote - so a
|
||||
* concurrent update can lose a whole node set.
|
||||
*
|
||||
* mkdir is the lock: it is atomic on every filesystem this runs on and needs no
|
||||
* flock binding. A lock older than HP_LOCK_STALE is treated as abandoned (a
|
||||
* killed process cannot clean up after itself) and broken, so a crash cannot
|
||||
* block every future update. */
|
||||
const LOCK_DIR = RUN_DIR + '/update_subscriptions.lock';
|
||||
const LOCK_STALE = 600;
|
||||
|
||||
function lock_age() {
|
||||
const st = lstat(LOCK_DIR);
|
||||
|
||||
if (!st)
|
||||
return null;
|
||||
|
||||
/* time() is the epoch; getTime() is this package's *formatter*
|
||||
* (getTime(epoch) -> 'YYYY-MM-DD@HH:MM:SS'), so subtracting it produced
|
||||
* NaN and the stale check never fired. */
|
||||
return max(0, time() - (st.mtime || 0));
|
||||
};
|
||||
|
||||
function acquire_lock() {
|
||||
system(sprintf('mkdir -p %s', shellQuote(RUN_DIR)));
|
||||
|
||||
if (system(sprintf('mkdir %s 2>/dev/null', shellQuote(LOCK_DIR))) === 0)
|
||||
return true;
|
||||
|
||||
const age = lock_age();
|
||||
|
||||
if (age != null && age > LOCK_STALE) {
|
||||
log(sprintf('Breaking a stale update lock (%s seconds old).', age));
|
||||
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
|
||||
|
||||
return system(sprintf('mkdir %s 2>/dev/null', shellQuote(LOCK_DIR))) === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
function release_lock() {
|
||||
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
|
||||
};
|
||||
|
||||
|
||||
function main() {
|
||||
const node_cache = {};
|
||||
const node_result = [];
|
||||
|
||||
const ubus = connect();
|
||||
/* ubus is unreachable when no ubusd is running (dev host)
|
||||
* and the feature query is optional: fall back to the same
|
||||
* empty feature set the code below already handles. */
|
||||
const sing_features = (ubus?.call('luci.homeproxy', 'singbox_get_features', {})) || {};
|
||||
if (isEmpty(sing_features))
|
||||
log('Warning: Failed to query sing-box features via ubus, assuming defaults.');
|
||||
|
||||
/* Fetch, decode, parse and filter everything BEFORE touching
|
||||
* the service or UCI. The previous version stopped the proxy
|
||||
* first, so a slow or failing subscription left the router
|
||||
* without a proxy for the whole fetch - the stop -> modify
|
||||
* -> discover-an-error pattern the refactor guide forbids.
|
||||
* Nothing below runs until a full candidate set exists in
|
||||
* memory. */
|
||||
for (let url in subscription_urls) {
|
||||
url = replace(url, /#.*$/, '');
|
||||
const groupHash = md5(url);
|
||||
node_cache[groupHash] = {};
|
||||
|
||||
/* Keep the lock's mtime current for as long as this run is alive.
|
||||
* The stale window (LOCK_STALE) is what lets a later run reclaim a
|
||||
* lock a killed process left behind, but nothing refreshed it while
|
||||
* the fetch was in progress - and the fetch is one wget per URL,
|
||||
* ten seconds each. A run that crosses the threshold while still
|
||||
* working can therefore have its lock broken underneath it, and the
|
||||
* two runs then interleave their read-modify-write exactly like the
|
||||
* comment above acquire_lock() describes. */
|
||||
system(sprintf('touch %s 2>/dev/null', shellQuote(LOCK_DIR)));
|
||||
|
||||
const fetched = fetch_subscription(url, user_agent, log);
|
||||
if (fetched.content === null)
|
||||
continue;
|
||||
|
||||
const nodes = decode_subscription(fetched.content, log, url);
|
||||
|
||||
let count = 0;
|
||||
for (let node in nodes) {
|
||||
let flat;
|
||||
if (!isEmpty(node))
|
||||
flat = parse_uri(node, sing_features, log);
|
||||
if (isEmpty(flat))
|
||||
continue;
|
||||
|
||||
const label = flat.label;
|
||||
flat.label = null;
|
||||
const confHash = md5(sprintf('%J', flat)),
|
||||
nameHash = md5(groupHash + label);
|
||||
flat.label = label;
|
||||
|
||||
if (filter_check(flat.label, filter_mode, filter_keywords, log))
|
||||
log(sprintf('Skipping blacklist node: %s.', flat.label));
|
||||
else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash])
|
||||
log(sprintf('Skipping duplicate node: %s.', flat.label));
|
||||
else {
|
||||
/* normalize() first: the canonical Node is the only
|
||||
* business object this orchestrator hands the Repository.
|
||||
* The policy tweaks used to be applied to `flat` before
|
||||
* this, which meant the pipeline was flat -> flat(tweaked)
|
||||
* -> canonical, and only the Repository's internal
|
||||
* flatten() brought it back. Now it is
|
||||
* parse -> normalize -> apply_policy -> Repository.
|
||||
*
|
||||
* `flat` stays in scope for the fingerprint above only.
|
||||
* That fingerprint is taken from the parser's output on
|
||||
* purpose: it identifies the parsed link, and the policy
|
||||
* tweaks belong to the subscription rather than to the
|
||||
* node. */
|
||||
const node_canonical = normalize(flat);
|
||||
node_canonical.grouphash = groupHash;
|
||||
|
||||
/* normalize() exposes the source label as `name`; the
|
||||
* Repository identifies a node by
|
||||
* md5(grouphash + label), so without this every node in
|
||||
* one subscription hashes to the SAME section name and
|
||||
* they overwrite each other - a 4-node subscription
|
||||
* collapsed into a single section carrying one protocol's
|
||||
* `type` with several protocols' options mixed in.
|
||||
* The cache is keyed by the same hash, so this also makes
|
||||
* an unchanged node match on the next run instead of
|
||||
* being deleted and re-added every time. */
|
||||
node_canonical.label = flat.label;
|
||||
|
||||
apply_policy(node_canonical, { allow_insecure, packet_encoding });
|
||||
|
||||
push(node_result, [ node_canonical ]);
|
||||
node_cache[groupHash][confHash] = node_canonical;
|
||||
node_cache[groupHash][nameHash] = node_canonical;
|
||||
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0)
|
||||
log(sprintf('No valid node found in %s.', redactUrl(url)));
|
||||
else
|
||||
log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), redactUrl(url)));
|
||||
}
|
||||
|
||||
if (isEmpty(node_result)) {
|
||||
/* Nothing was touched yet (the fetch phase never writes),
|
||||
* so the running service and the stored configuration
|
||||
* stay as they are. */
|
||||
log('Failed to update subscriptions: no valid node found.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/* The add / update / remove walk and the
|
||||
* final commit now live in subscription/repository.uc. The
|
||||
* orchestrator just hands it the canonical Node cache +
|
||||
* result built during the fetch phase and uses the
|
||||
* { added, removed } counts for the end-of-run log. */
|
||||
const repository_result = Repository.apply_nodes(
|
||||
uci, uciconfig, ucinode, node_cache, node_result, log
|
||||
);
|
||||
const added = repository_result.added,
|
||||
removed = repository_result.removed;
|
||||
|
||||
/* The 6 inline uci.set/commit sites
|
||||
* (main_urltest_nodes cleanup, main_node switch on missing
|
||||
* target, main_udp_urltest_nodes cleanup, main_udp_node
|
||||
* switch, reset-to-'nil', routing_node urltest scrub) moved
|
||||
* into subscription/repository.uc. The orchestrator now
|
||||
* drives three Repository methods and replays the log lines
|
||||
* the Repository attached to its result. */
|
||||
if (!isEmpty(main_node)) {
|
||||
const main_refs = Repository.apply_main_node_refs(
|
||||
uci, uciconfig, ucimain, ucinode,
|
||||
{ main_node, main_udp_node, has_nodes: added > 0 },
|
||||
log
|
||||
);
|
||||
for (let line in main_refs.log)
|
||||
log(line);
|
||||
}
|
||||
|
||||
Repository.scrub_stale_urltest_refs(uci, uciconfig, log);
|
||||
|
||||
/* A2: the run's single transaction boundary.
|
||||
*
|
||||
* apply_nodes(), apply_main_node_refs() and scrub_stale_urltest_refs()
|
||||
* stage every mutation on this one cursor and commit nothing. Before
|
||||
* this, each of them committed for itself - eight sites inside the
|
||||
* repository - so a crash (or a kill) between two of them could leave
|
||||
* the subscription's nodes written while main_node still pointed at a
|
||||
* section that had just been deleted. The commit below is the only
|
||||
* point at which the run becomes visible, and the reload only ever sees
|
||||
* a fully applied state.
|
||||
*
|
||||
* commit() returns true on success, so a failure is detected here rather
|
||||
* than by generating a configuration from a half-written file. */
|
||||
if (uci.commit(uciconfig) !== true) {
|
||||
log('FAILED to commit the new configuration; the previous configuration is kept.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Reload once, after the whole candidate set is committed
|
||||
* and stale references are scrubbed. The old code stopped
|
||||
* the service before fetching and then did stop+start; the
|
||||
* reload path now validates the new configuration and rolls
|
||||
* back if an instance fails to come up, so the service is
|
||||
* only ever restarted onto a config that passed `sing-box
|
||||
* check`. */
|
||||
log('Reloading service...');
|
||||
|
||||
/* Run the init script directly. The previous code called
|
||||
* `init_action('homeproxy', 'reload')` imported from luci.sys, but
|
||||
* neither openwrt/luci nor immortalwrt/luci exports an `init_action`
|
||||
* from that module (it has process_list / conntrack_list /
|
||||
* init_list / init_index / init_enabled), so the import failed to
|
||||
* resolve and this script could not load on a router at all.
|
||||
*
|
||||
* executeCommand() is the package's own runner (homeproxy.uc); unlike
|
||||
* a bare system() call it returns the exit status and the captured
|
||||
* stderr, so a failed reload is recorded instead of disappearing. */
|
||||
const reload = executeCommand('/etc/init.d/homeproxy', 'reload');
|
||||
if (reload.exitcode !== 0)
|
||||
log(sprintf('Warning: reload exited with status %d: %s',
|
||||
reload.exitcode, trim(reload.stderr || '')));
|
||||
else if (reload.stderr_truncated)
|
||||
/* Non-zero exit + stderr_truncated is unambiguous; the
|
||||
* log line above already carries the message. The truncated
|
||||
* case is the subtle one: a clean exit can still hide a
|
||||
* sing-box check warning that was truncated out of view. */
|
||||
log('Warning: reload stderr was truncated (>512 KiB); the captured output is incomplete.');
|
||||
|
||||
log(sprintf('%s nodes added, %s removed.', added, removed));
|
||||
log('Successfully updated subscriptions.');
|
||||
}
|
||||
|
||||
/* A4: the lock comes first, and every read happens inside it.
|
||||
*
|
||||
* This block used to test the subscription list before taking the lock, so
|
||||
* the read of the configuration happened outside the critical section. The
|
||||
* order is now: acquire -> read -> mutate -> commit -> reload -> release.
|
||||
* The pre-lock "nothing configured" shortcut is preserved in meaning (no
|
||||
* fetch, no write, no log) but it is decided after the lock is held; taking
|
||||
* and releasing an uncontended mkdir lock costs nothing measurable next to
|
||||
* the fetch that would follow. */
|
||||
if (!acquire_lock()) {
|
||||
log('Another subscription update is already running; skipping this one.');
|
||||
}
|
||||
else {
|
||||
/* ucode has no `finally`, so the release is written out on both paths by
|
||||
* the explicit call after the try/catch. */
|
||||
try {
|
||||
load_locked_state();
|
||||
|
||||
if (isEmpty(subscription_urls)) {
|
||||
/* Nothing configured: no fetch, no write, no log line - the same
|
||||
* silent no-op the pre-lock check performed. */
|
||||
}
|
||||
else {
|
||||
call(main);
|
||||
}
|
||||
} catch(e) {
|
||||
log('[FATAL ERROR] An error occurred during updating subscriptions:');
|
||||
log(sprintf('%s: %s', e.type, e.message));
|
||||
log(e.stacktrace[0].context);
|
||||
|
||||
if (config_backup != null) {
|
||||
/* Write the snapshot beside the config and rename it into place.
|
||||
* A bare writefile() truncates first, so a crash or a full disk
|
||||
* part-way through the restore leaves /etc/config/homeproxy
|
||||
* partial or empty - losing the very configuration this is
|
||||
* meant to protect. rename() is atomic within a directory. */
|
||||
const restore_tmp = CONFIG_FILE + '.hp-restore';
|
||||
|
||||
if (writefile(restore_tmp, config_backup) != null
|
||||
&& system(sprintf('mv -f %s %s', shellQuote(restore_tmp), shellQuote(CONFIG_FILE))) === 0) {
|
||||
log('Restored the previous configuration; the running service was not stopped.');
|
||||
}
|
||||
else {
|
||||
system(sprintf('rm -f %s', shellQuote(restore_tmp)));
|
||||
log('FAILED to restore the previous configuration - the file on disk may be incomplete.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
release_lock();
|
||||
}
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2022-2023 ImmortalWrt.org
|
||||
#
|
||||
# This file is the lifecycle orchestrator only. Everything
|
||||
# that is not "read the configuration, decide, and drive procd" now lives in
|
||||
# /etc/homeproxy/scripts/runtime/:
|
||||
#
|
||||
# config.sh generated-configuration transaction (known-good, restore,
|
||||
# rollback) - pure, takes explicit paths
|
||||
# health.sh the health gate: procd state + listener ownership, sustained
|
||||
# over several samples
|
||||
# service.sh sing-box version gate, auto-update cron, runtime-file
|
||||
# preparation, procd instance registration, the
|
||||
# generate->known-good transaction
|
||||
# dns.sh dnsmasq snippet directory, snippet writing, teardown
|
||||
# firewall.sh fw4 pre/post scripts, reload, owned-object teardown, upnp
|
||||
# net.sh tproxy / TUN ip rules and routes, teardown
|
||||
#
|
||||
# What deliberately stays here: USE_PROCD and the start/stop/reload/
|
||||
# service_triggers skeletons, the configuration reads that choose between
|
||||
# client, server and neither, and the ordering that makes a reload safe
|
||||
# (generate first, tear down later, prove it came up, roll back otherwise).
|
||||
#
|
||||
# The transaction, in order:
|
||||
#
|
||||
# start_service generate -> register with procd -> PROVE IT RUNS ->
|
||||
# record known-good
|
||||
# reload_service generate (abort before teardown if invalid) -> stop ->
|
||||
# start -> if start could not prove the new configuration,
|
||||
# roll back to the known-good copy and restart
|
||||
#
|
||||
# A3: on the reload path the generation happens exactly once. reload_service
|
||||
# generates each enabled side and hands start_service the per-side
|
||||
# HP_CANDIDATE_* flags; hp_start_generated_config() then activates that exact
|
||||
# file instead of generating a second one. The generator's env (WAN resolver,
|
||||
# domain-resource lists) can change between two invocations, so "validate A,
|
||||
# discard A, generate B, run B" is no longer possible.
|
||||
#
|
||||
# "Prove it runs" is the health gate in runtime/health.sh. It used to be a
|
||||
# single "is a matching process alive?" probe, which was satisfied by a stale
|
||||
# process and by procd's own respawn loop while the service was dead - and
|
||||
# because the generated file was recorded as known-good *before* that probe
|
||||
# ran, the rollback had nothing left to restore.
|
||||
#
|
||||
# What the rollback does NOT cover: the dnsmasq snippets, the ip rules/routes
|
||||
# and the nft rules are rebuilt from the *current* UCI on every start, so a
|
||||
# reload that fails because of a UCI-level change (a port that is now taken, a
|
||||
# proxy-mode switch) restores the sing-box configuration and leaves the network
|
||||
# layer describing the new settings. Rolling that back as well would mean
|
||||
# snapshotting UCI at promotion time; that is deliberately not attempted here,
|
||||
# which is why this paragraph exists rather than a note in a plan document.
|
||||
|
||||
USE_PROCD=1
|
||||
|
||||
START=99
|
||||
STOP=10
|
||||
|
||||
CONF="homeproxy"
|
||||
PROG="/usr/bin/sing-box"
|
||||
|
||||
HP_DIR="/etc/homeproxy"
|
||||
RUN_DIR="/var/run/homeproxy"
|
||||
LOG_PATH="$RUN_DIR/homeproxy.log"
|
||||
|
||||
# Last configuration that was proven to run. start_service records it only
|
||||
# after the health gate passed, and reload_service restores it when a new
|
||||
# configuration fails to come up.
|
||||
GOOD_DIR="$RUN_DIR/known-good"
|
||||
|
||||
|
||||
# fw4 chain/set inventory; single source of truth shared with firewall_post.ut
|
||||
[ -f "$HP_DIR/scripts/fw4_names.sh" ] && . "$HP_DIR/scripts/fw4_names.sh"
|
||||
|
||||
# Configuration transaction (known-good copy, restore, rollback) and the
|
||||
# health gate that decides whether a configuration may be recorded or must be
|
||||
# rolled back.
|
||||
[ -f "$HP_DIR/scripts/runtime/config.sh" ] && . "$HP_DIR/scripts/runtime/config.sh"
|
||||
[ -f "$HP_DIR/scripts/runtime/health.sh" ] && . "$HP_DIR/scripts/runtime/health.sh"
|
||||
|
||||
# Device-side orchestration extracted from the init script.
|
||||
[ -f "$HP_DIR/scripts/runtime/service.sh" ] && . "$HP_DIR/scripts/runtime/service.sh"
|
||||
[ -f "$HP_DIR/scripts/runtime/dns.sh" ] && . "$HP_DIR/scripts/runtime/dns.sh"
|
||||
[ -f "$HP_DIR/scripts/runtime/firewall.sh" ] && . "$HP_DIR/scripts/runtime/firewall.sh"
|
||||
[ -f "$HP_DIR/scripts/runtime/net.sh" ] && . "$HP_DIR/scripts/runtime/net.sh"
|
||||
|
||||
log() {
|
||||
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
|
||||
}
|
||||
|
||||
# hp_client_outbound_node <routing-mode> <dest-var>
|
||||
# Read the client's "main node" reference into <dest-var>.
|
||||
#
|
||||
# The reference is mode-dependent: the built-in routing modes keep it on
|
||||
# config.main_node, while custom routing keeps it on routing.default_outbound.
|
||||
# start_service, service_started and reload_service all need this value to
|
||||
# decide whether a client instance exists at all, and each of them used to
|
||||
# spell the branch out itself.
|
||||
hp_client_outbound_node() {
|
||||
if [ "$1" != "custom" ]; then
|
||||
config_get "$2" "config" "main_node" "nil"
|
||||
else
|
||||
config_get "$2" "routing" "default_outbound" "nil"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve the dnsmasq snippet directory once, at load time. stop_service
|
||||
# reuses the stored value rather than recomputing it, so a dnsmasq
|
||||
# reconfiguration between start and stop cannot make it delete the wrong
|
||||
# directory (see runtime/dns.sh).
|
||||
hp_dnsmasq_resolve_dir
|
||||
|
||||
# hp_intercept_installed
|
||||
# True when a previous start actually installed the intercept layer: the fw4
|
||||
# post fragment holds the redirect/tproxy chains, the dnsmasq include file
|
||||
# points at the snippets. Both live in tmpfs, so after a reboot there is
|
||||
# nothing stale to release.
|
||||
hp_intercept_installed() {
|
||||
[ -s "$RUN_DIR/fw4_post.nft" ] || [ -e "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf" ]
|
||||
}
|
||||
|
||||
# hp_release_intercept
|
||||
# Take the intercept layer back down, without touching the service's own
|
||||
# instances (procd keeps supervising them).
|
||||
#
|
||||
# A start that cannot prove the client came up must not leave the redirect
|
||||
# rules and the DNS snippets in place: they point the LAN's TCP and DNS at
|
||||
# listeners that are not there, so the routers' clients lose both instead of
|
||||
# merely not being proxied. The reload path gets that for free through its
|
||||
# rollback; the start path (boot, a manual start, LuCI's Start button) has no
|
||||
# rollback at all, which is the asymmetry this closes.
|
||||
#
|
||||
# The server's inbound accepts (fw4_forward/fw4_input) are left in place: a
|
||||
# failed *client* does not make the server unreachable, and the two sides are
|
||||
# independent instances.
|
||||
#
|
||||
# The release is recorded in $RUN_DIR/intercept-released so that service_started
|
||||
# can put the layer back once the instance does come up. Without that marker the
|
||||
# release is one-way: procd keeps respawning the instance, the health check can
|
||||
# start passing on a later attempt, and nothing reinstalls the rules - the router
|
||||
# then reports the service as running while the LAN is not proxied at all.
|
||||
hp_release_intercept() {
|
||||
log "Reverting the intercept layer; traffic will go out unproxied until the client is up."
|
||||
hp_net_teardown
|
||||
hp_firewall_teardown "$RUN_DIR" keep-pre
|
||||
hp_dnsmasq_remove_snippets "$DNSMASQ_DIR"
|
||||
printf '%s\n' "1" > "$RUN_DIR/intercept-released"
|
||||
}
|
||||
|
||||
# hp_rearm_intercept <proxy-mode> <routing-mode>
|
||||
# Reinstall the layer a failed start released, once the client is proven up.
|
||||
# It only runs when that failure actually happened - on a healthy start there is
|
||||
# nothing to redo - which is what makes the release in hp_release_intercept()
|
||||
# reversible instead of a silent, permanent downgrade to direct routing.
|
||||
hp_rearm_intercept() {
|
||||
[ -f "$RUN_DIR/intercept-released" ] || return 0
|
||||
rm -f "$RUN_DIR/intercept-released"
|
||||
log "Client is up again; reinstalling the intercept layer."
|
||||
hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2"
|
||||
hp_net_setup "$1" "$2"
|
||||
# hp_firewall_apply already writes $RUN_DIR/failed-firewall on its own
|
||||
# failure, but the helper's caller is service_started, which only reads
|
||||
# that marker when it comes from the candidate path. A failed rearm
|
||||
# must surface the same way: writing the file here makes the run a
|
||||
# "firewall did not come up" candidate, the health gate will not pass,
|
||||
# and the user sees a definite failure rather than "running but LAN
|
||||
# not proxied". The marker is cleared by service_started once the run
|
||||
# is otherwise healthy (see the rm -f near $RUN_DIR/failed-firewall below).
|
||||
if ! hp_firewall_apply "$HP_DIR" "$RUN_DIR" "1"; then
|
||||
log "Error: the firewall rules could not be reinstalled; the proxy may not be enforced."
|
||||
printf '%s\n' "firewall" > "$RUN_DIR/failed-firewall"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_release_stale_intercept
|
||||
# The same, but only when a previous start left something installed. Without
|
||||
# the check every start of an unconfigured router would pay a full fw4 reload
|
||||
# and a dnsmasq restart to undo rules that were never installed.
|
||||
hp_release_stale_intercept() {
|
||||
hp_intercept_installed || return 0
|
||||
hp_release_intercept
|
||||
}
|
||||
|
||||
start_service() {
|
||||
config_load "$CONF"
|
||||
|
||||
# Every refusal below happens *before* this run installs anything, so a
|
||||
# stale intercept layer is released rather than re-applied. Without it,
|
||||
# a router whose node was removed (or whose sing-box just failed its
|
||||
# version gate) keeps redirecting the LAN into a listener that no instance
|
||||
# is serving any more.
|
||||
if ! hp_require_singbox; then
|
||||
hp_release_stale_intercept
|
||||
return 1
|
||||
fi
|
||||
|
||||
local routing_mode proxy_mode outbound_node server_enabled
|
||||
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
|
||||
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
|
||||
hp_client_outbound_node "$routing_mode" outbound_node
|
||||
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
|
||||
if [ "$outbound_node" = "nil" ] && [ "$server_enabled" = "0" ]; then
|
||||
log "Neither client nor server is configured. Service not started."
|
||||
hp_release_stale_intercept
|
||||
return 1
|
||||
fi
|
||||
|
||||
local client_enabled=0
|
||||
[ "$outbound_node" = "nil" ] || client_enabled=1
|
||||
|
||||
# Client switched off (main_node cleared, or custom routing without a
|
||||
# default outbound): the redirect rules and the DNS snippets from the
|
||||
# previous start have to go, or the LAN keeps pointing at listeners no
|
||||
# instance is serving. This matters more since a reload keeps the DNS
|
||||
# layer across its stop (see stop_service): without it, a reload that
|
||||
# disables the client would leave the snippets installed forever.
|
||||
# The server's accepts (fw4_forward/fw4_input) are left alone.
|
||||
if [ "$client_enabled" != "1" ]; then
|
||||
hp_release_stale_intercept
|
||||
fi
|
||||
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
# Kernel 6.6.x has a TUN/GSO bug that needs GSO turned off for the tun
|
||||
# inbound; other releases either carry the fix or never had the problem.
|
||||
local disable_gso=0
|
||||
uname -r | grep -Eq "^6\.6" && disable_gso=1
|
||||
|
||||
# The health gate reads the ports the running configuration declares from
|
||||
# sing-box-c.json (service_started -> hp_config_ports), not from UCI: after
|
||||
# a rollback the live file is the older known-good copy and may bind
|
||||
# different ports. Reading mixed_port/dns_port here as well only produced
|
||||
# two unused locals.
|
||||
if [ "$client_enabled" = "1" ]; then
|
||||
hp_net_wait_wan 60
|
||||
|
||||
if ! hp_start_generated_config c "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then
|
||||
hp_release_stale_intercept
|
||||
return 1
|
||||
fi
|
||||
|
||||
local auto_update auto_update_time
|
||||
config_get_bool auto_update "subscription" "auto_update" "0"
|
||||
config_get auto_update_time "subscription" "auto_update_time" "2"
|
||||
hp_sync_autoupdate_cron "$auto_update" "$auto_update_time"
|
||||
|
||||
# The DNS snippets are the other half of the intercept layer: without
|
||||
# them the LAN resolves through the ISP while the nft rules still send
|
||||
# its DNS to sing-box's dns-in, which the redirect chain does not cover.
|
||||
# The writer already reports failures (render or install) instead of
|
||||
# leaving an empty snippet behind; record it the same way a firewall
|
||||
# failure is recorded, so the configuration is not promoted to
|
||||
# known-good and the log names the real problem.
|
||||
if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode"; then
|
||||
log "Error: the dnsmasq snippets were not installed; LAN DNS keeps using the previous configuration."
|
||||
printf '%s\n' "dns" > "$RUN_DIR/failed-dns"
|
||||
fi
|
||||
hp_net_setup "$proxy_mode" "$routing_mode"
|
||||
|
||||
# R-08: gfwlist mode routes by the nft set that dnsmasq populates
|
||||
# (`nftset=`), and the post-template gates every proxy chain on
|
||||
# `ip daddr != @homeproxy_gfw_list_v4`. On a dnsmasq without nftset the
|
||||
# set is never filled, so that gate is true for every packet and the
|
||||
# whole mode silently degrades to direct routing. Record the capability
|
||||
# where the template can read it (it renders before this point on the
|
||||
# next start, so the file has to exist by then) and let the template
|
||||
# omit the gate instead: traffic then reaches sing-box, which routes it
|
||||
# by geoip exactly like the nftset gate would have.
|
||||
hp_dnsmasq_has_nftset && printf '%s\n' "1" > "$HP_DIR/resources/.dnsmasq-nftset" \
|
||||
|| rm -f "$HP_DIR/resources/.dnsmasq-nftset"
|
||||
|
||||
hp_procd_client_instance "$PROG" "$HP_DIR" "$RUN_DIR" "$routing_mode" "$disable_gso"
|
||||
fi
|
||||
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
if ! hp_start_generated_config s "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then
|
||||
# Only the server side failed. hp_release_stale_intercept() tears
|
||||
# down the *client* layer (net rules + redirect chains + DNS
|
||||
# snippets), so calling it here took a healthy, already-verified
|
||||
# client off the air because a server certificate or port was bad -
|
||||
# the opposite of the "the two sides are independent instances"
|
||||
# rule this file states above. service_started()'s
|
||||
# failed="sing-box-s" path handles the server side and already
|
||||
# leaves the client layer alone.
|
||||
return 1
|
||||
fi
|
||||
|
||||
hp_procd_server_instance "$PROG" "$HP_DIR" "$RUN_DIR" "$disable_gso"
|
||||
fi
|
||||
|
||||
hp_procd_log_cleaner "$HP_DIR"
|
||||
|
||||
hp_prepare_runtime_files "$HP_DIR" "$RUN_DIR" "$routing_mode" "$client_enabled" "$server_enabled"
|
||||
|
||||
# A firewall that did not apply means traffic is not actually being
|
||||
# intercepted. sing-box still runs (the service stays up), but the
|
||||
# configuration must not become the rollback target - service_started()
|
||||
# reads this marker before promoting.
|
||||
if ! hp_firewall_apply "$HP_DIR" "$RUN_DIR" "$client_enabled"; then
|
||||
log "Error: the firewall rules were not applied completely; the proxy may not be enforced."
|
||||
printf '%s\n' "firewall" > "$RUN_DIR/failed-firewall"
|
||||
fi
|
||||
|
||||
# Nothing is verified here on purpose. With USE_PROCD=1 the
|
||||
# procd_open_instance/procd_close_instance calls above only *register* the
|
||||
# instances; procd starts them when rc.common closes the service, which
|
||||
# happens after this function returns. Gating here would sample a service
|
||||
# that has not been started yet and always time out - the health gate
|
||||
# therefore lives in service_started(), which rc.common calls at the point
|
||||
# where the instances are actually up.
|
||||
return 0
|
||||
}
|
||||
|
||||
# service_started() is rc.common's post-start hook:
|
||||
#
|
||||
# start() { rc_procd start_service "$@"; service_started; }
|
||||
#
|
||||
# so this runs after procd_close_service. It is where a configuration earns
|
||||
# the right to be called good.
|
||||
service_started() {
|
||||
config_load "$CONF"
|
||||
|
||||
local routing_mode outbound_node server_enabled proxy_mode
|
||||
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
|
||||
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
|
||||
hp_client_outbound_node "$routing_mode" outbound_node
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
|
||||
local client_enabled=0
|
||||
[ "$outbound_node" = "nil" ] || client_enabled=1
|
||||
|
||||
[ "$client_enabled" = "1" ] || [ "$server_enabled" = "1" ] || return 0
|
||||
|
||||
# The listeners to verify are the ones the running configuration declares,
|
||||
# read from the file rather than from UCI: after a rollback the live file
|
||||
# is the older known-good copy, so UCI describes ports this configuration
|
||||
# does not use.
|
||||
#
|
||||
# The gate checks that sing-box OWNS them: a port that is listening but
|
||||
# belongs to another process is exactly the failure it has to catch.
|
||||
#
|
||||
# `sing-box check` cannot see a port that is already taken or a
|
||||
# certificate it cannot read, and a single process probe cannot tell a
|
||||
# healthy instance from procd restarting a dying one.
|
||||
local failed="" client_ports server_ports
|
||||
if [ "$client_enabled" = "1" ]; then
|
||||
client_ports="$(hp_config_ports "$RUN_DIR/sing-box-c.json")"
|
||||
hp_wait_service "sing-box-c" "$RUN_DIR/sing-box-c.json" \
|
||||
"$HP_HEALTH_BUDGET" "$HP_HEALTH_STABLE" $client_ports \
|
||||
|| failed="sing-box-c"
|
||||
|
||||
# The probes read their ports from the running configuration, not from
|
||||
# UCI: after a rollback the live file is the older known-good copy and
|
||||
# may bind different ports, and probing the candidate's ports would
|
||||
# warn about a healthy service (or, with health_probe_strict on, roll
|
||||
# it back again).
|
||||
if [ -z "$failed" ]; then
|
||||
local probe_dns_port probe_mixed_port probe_strict probe_name
|
||||
probe_dns_port="$(hp_config_port_for_tag "$RUN_DIR/sing-box-c.json" 'dns-in')"
|
||||
probe_mixed_port="$(hp_config_port_for_tag "$RUN_DIR/sing-box-c.json" 'mixed-in')"
|
||||
config_get_bool probe_strict "config" "health_probe_strict" "0"
|
||||
config_get probe_name "config" "health_probe_name" "example.com"
|
||||
|
||||
HP_PROBE_STRICT="$probe_strict"
|
||||
HP_PROBE_NAME="$probe_name"
|
||||
export HP_PROBE_STRICT HP_PROBE_NAME
|
||||
|
||||
hp_run_probes "client" "$probe_dns_port" "$probe_mixed_port" \
|
||||
|| failed="sing-box-c"
|
||||
fi
|
||||
fi
|
||||
if [ "$server_enabled" = "1" ] && [ -z "$failed" ]; then
|
||||
server_ports="$(hp_config_ports "$RUN_DIR/sing-box-s.json")"
|
||||
hp_wait_service "sing-box-s" "$RUN_DIR/sing-box-s.json" \
|
||||
"$HP_HEALTH_BUDGET" "$HP_HEALTH_STABLE" $server_ports \
|
||||
|| failed="sing-box-s"
|
||||
fi
|
||||
|
||||
if [ -n "$failed" ]; then
|
||||
# start() returns procd's status, not start_service's, so the failure
|
||||
# is recorded where reload_service cannot miss it. A file is used
|
||||
# rather than a shell variable because service_started and
|
||||
# reload_service are not the same shell invocation.
|
||||
printf '%s\n' "$failed" > "$RUN_DIR/failed-side"
|
||||
log "Error: $failed did not come up with the new configuration."
|
||||
# The intercept rules were installed by start_service before this
|
||||
# gate ran, and on the *start* path nothing else takes them back
|
||||
# down: the LAN would keep sending TCP to the redirect port and DNS
|
||||
# to the dnsmasq snippets while no instance is listening. Only a
|
||||
# failed client black-holes the LAN - the server's accepts are
|
||||
# inbound and a failed *server* leaves the client working - so this
|
||||
# is scoped to the client side. On the reload path the rollback
|
||||
# below re-applies everything on its way to the old configuration.
|
||||
[ "$failed" = "sing-box-c" ] && hp_release_intercept
|
||||
# The rollback is driven by reload_service, which can stop and
|
||||
# restart the service.
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -f "$RUN_DIR/failed-side"
|
||||
|
||||
# A previous start may have released the intercept layer because the client
|
||||
# did not come up (hp_release_intercept). procd respawns the instance, and
|
||||
# now that it is proven healthy the rules have to come back - otherwise the
|
||||
# service runs and reports "running" while the LAN is not proxied.
|
||||
[ "$client_enabled" = "1" ] && hp_rearm_intercept "$proxy_mode" "$routing_mode"
|
||||
|
||||
# Proven good: this is what a later failed reload rolls back to. A
|
||||
# configuration whose intercept layer is incomplete is not "good" - traffic
|
||||
# is not being intercepted the way the configuration says - so it is not
|
||||
# recorded, and the previous known-good survives as the rollback target.
|
||||
# Two independent markers, because a successful hp_firewall_apply() must not
|
||||
# be able to mask a failed snippet install (or the other way round).
|
||||
if [ -s "$RUN_DIR/failed-firewall" ] || [ -s "$RUN_DIR/failed-dns" ]; then
|
||||
[ -s "$RUN_DIR/failed-firewall" ] \
|
||||
&& log "Warning: the firewall rules did not apply; keeping the previous known-good configuration."
|
||||
[ -s "$RUN_DIR/failed-dns" ] \
|
||||
&& log "Warning: the DNS snippets were not installed; keeping the previous known-good configuration."
|
||||
rm -f "$RUN_DIR/failed-firewall" "$RUN_DIR/failed-dns"
|
||||
else
|
||||
[ "$client_enabled" = "1" ] && hp_promote_known_good c "$RUN_DIR" "$GOOD_DIR"
|
||||
[ "$server_enabled" = "1" ] && hp_promote_known_good s "$RUN_DIR" "$GOOD_DIR"
|
||||
fi
|
||||
|
||||
log "sing-box $(sing-box version -n 2>/dev/null || echo "unknown") started."
|
||||
return 0
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
hp_clear_autoupdate_cron
|
||||
|
||||
config_load "$CONF"
|
||||
|
||||
# P1-8: on a reload the network layer is left in place too. The ip rules and
|
||||
# the routing table are the same before and after a configuration change
|
||||
# (they derive from the infra marks, not from the nodes), hp_net_setup() is
|
||||
# idempotent since r20, and tearing them down here only opened a window in
|
||||
# which the tproxy rules were gone. A real stop still removes them.
|
||||
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || hp_net_teardown
|
||||
hp_firewall_teardown "$RUN_DIR"
|
||||
# A reload is a restart, not a shutdown, and it keeps the DNS layer: the
|
||||
# snippets are re-rendered by start_service moments later, and their writer
|
||||
# compares content before restarting dnsmasq. Taking them down here would
|
||||
# leak the LAN's queries to the ISP resolvers for the whole stop/start
|
||||
# window and restart dnsmasq twice (flushing every client's cache) for a
|
||||
# configuration that did not change. The cost is that DNS points at
|
||||
# sing-box's dns-in while the instances are down - a few seconds of failed
|
||||
# lookups instead of a few seconds of plaintext queries. A real stop (no
|
||||
# flag) removes them, and a reload that disables the client releases them
|
||||
# through hp_release_stale_intercept() in start_service.
|
||||
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || hp_dnsmasq_remove_snippets "$DNSMASQ_DIR"
|
||||
|
||||
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-c.log" \
|
||||
"$RUN_DIR/sing-box-s.json" "$RUN_DIR/sing-box-s.log" \
|
||||
"$RUN_DIR/failed-side" "$RUN_DIR/failed-firewall" "$RUN_DIR/failed-dns"
|
||||
# A released marker only means something within one start; a stop clears it
|
||||
# so the next start does not reinstall a layer it is about to install anyway.
|
||||
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || rm -f "$RUN_DIR/intercept-released"
|
||||
|
||||
log "Service stopped."
|
||||
}
|
||||
|
||||
service_stopped() {
|
||||
config_load "$CONF"
|
||||
|
||||
# Review P2 (2026-09-28): a reload stops then starts the service
|
||||
# inside the same procd invocation, and HP_RELOAD_IN_PROGRESS is set
|
||||
# during that window (set by reload_service just before the
|
||||
# `stop; start`). When the reload is a tun -> tun reload, removing
|
||||
# the tun device here pulls the default route out of table 100, so
|
||||
# marked traffic falls back to the main table while the new sing-box
|
||||
# is starting up - the LAN hits the WAN with the real IP. Skip the
|
||||
# tuntap del on the reload path; the next start will reuse the
|
||||
# existing device if its name and address match (hp_net_setup checks).
|
||||
# A genuine stop (HP_RELOAD_IN_PROGRESS=0) keeps the original
|
||||
# behaviour: the device is torn down so a subsequent start can
|
||||
# create a fresh one with whatever the new UCI asked for.
|
||||
if [ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ]; then
|
||||
:
|
||||
else
|
||||
hp_net_remove_tun
|
||||
fi
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
log "Reloading service..."
|
||||
|
||||
config_load "$CONF"
|
||||
local routing_mode
|
||||
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
|
||||
|
||||
local outbound_node
|
||||
hp_client_outbound_node "$routing_mode" outbound_node
|
||||
|
||||
# Generate first, tear down later. If the new configuration is invalid
|
||||
# the generator leaves the running one in place, so the reload is
|
||||
# aborted *before* stop_service() - the old configuration keeps running
|
||||
# instead of the router ending up with no service at all.
|
||||
#
|
||||
# A3: whichever sides are generated here are the candidates start_service
|
||||
# must activate - it must not generate them a second time. Two invocations
|
||||
# of the generator are not guaranteed to write the same bytes (its env
|
||||
# carries the WAN resolver and the domain-resource lists), so regenerating
|
||||
# in start_service would run a file that was never `sing-box check`ed:
|
||||
# validate A -> discard A -> generate B -> run B.
|
||||
#
|
||||
# The flags are per side on purpose: reload_service and start_service each
|
||||
# do their own config_load, and if the two disagree (a concurrent UCI
|
||||
# change) the side this preflight did not generate must still be generated
|
||||
# rather than activated from a stale file.
|
||||
HP_CANDIDATE_CLIENT=0
|
||||
HP_CANDIDATE_SERVER=0
|
||||
|
||||
if [ "$outbound_node" != "nil" ]; then
|
||||
if ! ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"; then
|
||||
log "Error: new client configuration is invalid, reload aborted; the running configuration is kept."
|
||||
return 1
|
||||
fi
|
||||
HP_CANDIDATE_CLIENT=1
|
||||
fi
|
||||
|
||||
local server_enabled
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
if ! ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"; then
|
||||
log "Error: new server configuration is invalid, reload aborted; the running configuration is kept."
|
||||
return 1
|
||||
fi
|
||||
HP_CANDIDATE_SERVER=1
|
||||
fi
|
||||
|
||||
# stop_service() deletes $RUN_DIR/sing-box-*.json (runtime state), so the
|
||||
# file that was just validated would not survive to start_service. Stage a
|
||||
# copy of each validated side first; the stop that follows cannot reach it,
|
||||
# and start_service activates those exact bytes. A staging failure aborts
|
||||
# the reload while the running configuration is still untouched.
|
||||
if [ "$HP_CANDIDATE_CLIENT" = "1" ]; then
|
||||
if ! hp_capture_candidate "$RUN_DIR" c; then
|
||||
log "Error: could not stage the validated client configuration; reload aborted; the running configuration is kept."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ "$HP_CANDIDATE_SERVER" = "1" ]; then
|
||||
if ! hp_capture_candidate "$RUN_DIR" s; then
|
||||
log "Error: could not stage the validated server configuration; reload aborted; the running configuration is kept."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# `start` runs start_service in this same shell, but export them anyway so
|
||||
# the contract does not depend on that: HP_USE_KNOWN_GOOD is exported the
|
||||
# same way in the rollback path below.
|
||||
export HP_CANDIDATE_CLIENT HP_CANDIDATE_SERVER
|
||||
|
||||
# Both configurations passed `sing-box check`; now it is safe to restart.
|
||||
# start_service owns the rest of the transaction: it proves the instances
|
||||
# came up and only then records the new known-good, so the previous one
|
||||
# survives until that point - which is what makes the rollback below
|
||||
# possible.
|
||||
#
|
||||
# The flag tells stop_service that this stop belongs to a reload, so the
|
||||
# DNS layer is left in place for start_service to re-render (it compares
|
||||
# content and only restarts dnsmasq when something actually changed). It
|
||||
# stays set for the rollback path below, which is also a reload.
|
||||
HP_RELOAD_IN_PROGRESS=1
|
||||
export HP_RELOAD_IN_PROGRESS
|
||||
stop
|
||||
start
|
||||
|
||||
# start_service proved the candidate does not run (it left the marker).
|
||||
# The known-good copy still holds the previous configuration, because a
|
||||
# candidate is no longer recorded before it has proven itself.
|
||||
local failed=""
|
||||
[ -s "$RUN_DIR/failed-side" ] && failed="$(cat "$RUN_DIR/failed-side")"
|
||||
|
||||
# reload_service and start_service each do their own config_load. If the
|
||||
# two disagree - a concurrent reload, or a read that fell back to the UCI
|
||||
# defaults - the marker can be missing while nothing was started at all.
|
||||
# The filesystem is the tie-breaker: a side this reload believed was
|
||||
# enabled must have produced a live configuration.
|
||||
if [ -z "$failed" ] && [ "$outbound_node" != "nil" ] && [ ! -s "$RUN_DIR/sing-box-c.json" ]; then
|
||||
failed="sing-box-c"
|
||||
fi
|
||||
if [ -z "$failed" ] && [ "$server_enabled" = "1" ] && [ ! -s "$RUN_DIR/sing-box-s.json" ]; then
|
||||
failed="sing-box-s"
|
||||
fi
|
||||
|
||||
if [ -z "$failed" ]; then
|
||||
log "Reload completed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Error: $failed did not come up with the new configuration, rolling back."
|
||||
|
||||
# Review P1-3 (2026-09-28): restore the nft-layer-driving UCI keys
|
||||
# *before* the rolled-back stop;start. Without this, start_service()
|
||||
# re-reads CURRENT UCI (the failing, new configuration) and installs
|
||||
# the wrong layer - the classic "rollback worked but LAN still not
|
||||
# proxied" symptom. hp_promote_known_good snapshots these keys when
|
||||
# the candidate is healthy; this is the symmetric restore.
|
||||
if ! hp_restore_known_good_uci "$GOOD_DIR"; then
|
||||
log "Warning: no uci-snapshot alongside the known-good copy; the firewall/dnsmasq layer will be reinstalled from the failing UCI on the rollback restart. This snapshot exists from r27 onwards; older installs fall back to the previous behaviour."
|
||||
fi
|
||||
|
||||
local good="$GOOD_DIR/$failed.json"
|
||||
local live="$RUN_DIR/$failed.json"
|
||||
local rolled_back=0
|
||||
|
||||
if hp_same_file "$live" "$good"; then
|
||||
log "Error: the failing configuration is already the known-good one; nothing to roll back to."
|
||||
# Review P1-4 (2026-09-28): the failing candidate is byte-equal to
|
||||
# known-good and we are skipping stop;start, so the
|
||||
# intercept-released marker set by hp_release_intercept during
|
||||
# the failed start would otherwise survive until a manual reload.
|
||||
# Clear it here and let the next reload or stop:start rearm the
|
||||
# intercept layer; the proxy still does not work in this exact
|
||||
# moment, but at least no marker lies about its state.
|
||||
[ -f "$RUN_DIR/intercept-released" ] && rm -f "$RUN_DIR/intercept-released"
|
||||
elif hp_rollback "$live" "$good"; then
|
||||
rolled_back=1
|
||||
else
|
||||
log "Error: no known-good configuration to roll back to."
|
||||
fi
|
||||
|
||||
if [ "$rolled_back" = "0" ]; then
|
||||
log "Error: rollback was not possible; the service may be down. Check $LOG_PATH."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Start from the recorded file rather than regenerating: the generator
|
||||
# is a pure function of UCI, so it would rebuild the config that just
|
||||
# failed.
|
||||
HP_USE_KNOWN_GOOD=1
|
||||
# The failed candidate has just exhausted procd's respawn retries, so the
|
||||
# restart below can take noticeably longer than a normal start.
|
||||
HP_HEALTH_BUDGET="$HP_HEALTH_ROLLBACK_BUDGET"
|
||||
export HP_USE_KNOWN_GOOD HP_HEALTH_BUDGET
|
||||
|
||||
stop
|
||||
start
|
||||
|
||||
if [ ! -s "$RUN_DIR/failed-side" ]; then
|
||||
log "Rollback completed; the previous configuration is running."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Error: rollback did not restore the service; manual intervention required."
|
||||
return 1
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "$CONF"
|
||||
# `wan` is the logical name netifd and the firewall use on every target
|
||||
# (a PPPoE uplink is `wan` at the logical layer, `pppoe-wan` only at the
|
||||
# device layer), so this does not need a per-device lookup.
|
||||
procd_add_interface_trigger "interface.*.up" wan /etc/init.d/$CONF reload
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ -f "/www/luci-static/resources/icons/loading.gif" ] && \
|
||||
sed -i "s,/loading.svg,/loading.gif,g" "/www/luci-static/resources/view/homeproxy/status.js"
|
||||
|
||||
uci -q batch <<-EOF >"/dev/null"
|
||||
delete firewall.homeproxy_pre
|
||||
|
||||
delete firewall.homeproxy_forward
|
||||
set firewall.homeproxy_forward=include
|
||||
set firewall.homeproxy_forward.type=nftables
|
||||
set firewall.homeproxy_forward.path="/var/run/homeproxy/fw4_forward.nft"
|
||||
set firewall.homeproxy_forward.position="chain-pre"
|
||||
set firewall.homeproxy_forward.chain="forward"
|
||||
|
||||
delete firewall.homeproxy_input
|
||||
set firewall.homeproxy_input=include
|
||||
set firewall.homeproxy_input.type=nftables
|
||||
set firewall.homeproxy_input.path="/var/run/homeproxy/fw4_input.nft"
|
||||
set firewall.homeproxy_input.position="chain-pre"
|
||||
set firewall.homeproxy_input.chain="input"
|
||||
|
||||
delete firewall.homeproxy_post
|
||||
set firewall.homeproxy_post=include
|
||||
set firewall.homeproxy_post.type=nftables
|
||||
set firewall.homeproxy_post.path="/var/run/homeproxy/fw4_post.nft"
|
||||
set firewall.homeproxy_post.position="table-post"
|
||||
commit firewall
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
ucode "/etc/homeproxy/scripts/migrate_config.uc"
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"admin/services/homeproxy": {
|
||||
"title": "HomeProxy",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "firstchild"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-homeproxy" ],
|
||||
"uci": { "homeproxy": true }
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/client": {
|
||||
"title": "Client Settings",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/client"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/node": {
|
||||
"title": "Node Settings",
|
||||
"order": 15,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/node"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/server": {
|
||||
"title": "Server Settings",
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/server"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/status": {
|
||||
"title": "Service Status",
|
||||
"order": 30,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/status"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"luci-app-homeproxy": {
|
||||
"description": "Grant access to homeproxy configuration",
|
||||
"read": {
|
||||
"file": {
|
||||
"/var/run/homeproxy/homeproxy.log": [ "read" ],
|
||||
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
|
||||
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
|
||||
},
|
||||
"ubus": {
|
||||
"service": [ "list" ],
|
||||
"luci.homeproxy": [
|
||||
"acllist_read",
|
||||
"connection_check",
|
||||
"node_parse",
|
||||
"resources_get_version",
|
||||
"singbox_get_features",
|
||||
"update_subscriptions_status"
|
||||
]
|
||||
},
|
||||
"uci": [ "homeproxy" ]
|
||||
},
|
||||
"write": {
|
||||
"file": {
|
||||
"/tmp/homeproxy_cert_server_publickey.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_server_privatekey.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_client_ca.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_client_ech_conf.tmp": [ "write" ]
|
||||
},
|
||||
"ubus": {
|
||||
"luci.homeproxy": [
|
||||
"acllist_write",
|
||||
"certificate_write",
|
||||
"log_clean",
|
||||
"resources_update",
|
||||
"singbox_generator",
|
||||
"update_subscriptions"
|
||||
]
|
||||
},
|
||||
"uci": [ "homeproxy" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2023-2024 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { access, error, lstat, popen, readfile, writefile } from 'fs';
|
||||
|
||||
import { isBinary, isValidECHConfig, isValidPEM, shellQuote, executeCommand } from '/etc/homeproxy/scripts/homeproxy.uc';
|
||||
import { parse_uri } from '/etc/homeproxy/scripts/parser/uri.uc';
|
||||
|
||||
/* Kanged from ucode/luci - replaced by the imported shellQuote() so every shell
|
||||
* argument in this file goes through one helper. */
|
||||
function hasKernelModule(kmod) {
|
||||
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellQuote(kmod))) === 0);
|
||||
}
|
||||
|
||||
const HP_DIR = '/etc/homeproxy';
|
||||
const RUN_DIR = '/var/run/homeproxy';
|
||||
const LOG_PATH = `${RUN_DIR}/homeproxy.log`;
|
||||
|
||||
/* Queried by singbox_get_features and by node_parse: the share-link parser
|
||||
* skips QUIC protocols and uTLS fingerprints the running sing-box cannot
|
||||
* serve, so it needs the same feature set the UI displays. */
|
||||
function collectFeatures() {
|
||||
let features = {};
|
||||
|
||||
const fd = popen('/usr/bin/sing-box version');
|
||||
if (fd) {
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
if (match(trim(line), /^sing-box version (.*)/))
|
||||
features.version = match(trim(line), /^sing-box version (.*)/)[1];
|
||||
|
||||
let tags = match(trim(line), /^Tags: (.*)/);
|
||||
if (tags)
|
||||
for (let i in split(tags[1], ','))
|
||||
features[i] = true;
|
||||
}
|
||||
|
||||
fd.close();
|
||||
}
|
||||
|
||||
features.hp_has_ip_full = access('/usr/libexec/ip-full');
|
||||
features.hp_has_tcp_brutal = hasKernelModule('brutal.ko');
|
||||
features.hp_has_tproxy = hasKernelModule('nft_tproxy.ko') || access('/etc/modules.d/nft-tproxy');
|
||||
features.hp_has_tun = hasKernelModule('tun.ko') || access('/etc/modules.d/30-tun');
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
acllist_read: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
|
||||
return { content: null, error: 'illegal type' };
|
||||
|
||||
const filecontent = readfile(`${HP_DIR}/resources/${req.args?.type}.txt`);
|
||||
return { content: filecontent };
|
||||
}
|
||||
},
|
||||
acllist_write: {
|
||||
args: { type: 'type', content: 'content' },
|
||||
call: function(req) {
|
||||
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const file = `${HP_DIR}/resources/${req.args?.type}.txt`;
|
||||
let content = req.args?.content;
|
||||
|
||||
/* Sanitize content */
|
||||
if (content) {
|
||||
content = trim(content);
|
||||
content = replace(content, /\r\n?/g, '\n');
|
||||
if (!match(content, /\n$/))
|
||||
content += '\n';
|
||||
|
||||
/* Domains only: reject lines that could break the downstream
|
||||
dnsmasq/nftset injection (whitespace / shell or directive chars).
|
||||
Review L1: the rejection used to say "invalid character in
|
||||
domain list" with no line number, so a 200-line list with one
|
||||
bad entry forced the user to bisect by hand. Report line 1
|
||||
for the first bad row so the user can jump straight to it. */
|
||||
const lines = split(content, /\n/);
|
||||
for (let i = 0; i < length(lines); i++) {
|
||||
const line = lines[i];
|
||||
if (!trim(line))
|
||||
continue;
|
||||
if (match(line, /[ \t\/\\;<>="'&#|]/))
|
||||
return { result: false, error: `line ${i + 1}: invalid character in domain list (only domains, no spaces/comments)` };
|
||||
}
|
||||
}
|
||||
|
||||
/* writefile() with a null body writes nothing useful; an empty
|
||||
* body is what clearing the field means. */
|
||||
if (content == null)
|
||||
content = '';
|
||||
|
||||
system(`mkdir -p ${shellQuote(HP_DIR)}/resources`);
|
||||
writefile(file, content);
|
||||
|
||||
return { result: true };
|
||||
}
|
||||
},
|
||||
|
||||
certificate_write: {
|
||||
args: { filename: 'filename' },
|
||||
call: function(req) {
|
||||
/* `kind` is 'cert', 'key' or 'ech'. Each certificate button
|
||||
* has its own PEM shape; an ECH config list in particular is
|
||||
* neither a certificate nor a key, so reusing isValidPEM()
|
||||
* for it would reject every valid upload with a misleading
|
||||
* "does not look like a correct PEM file". */
|
||||
const writeCertificate = (filename, kind) => {
|
||||
/* Per-button staging path. The previous code used a
|
||||
* single /tmp/homeproxy_certificate.tmp shared by every
|
||||
* certificate button, which was a race: the second upload
|
||||
* landed on top of the first while the backend was still
|
||||
* reading it, and a slow upload could overwrite a faster
|
||||
* one. The frontend (homeproxy.js uploadCertificate)
|
||||
* derives the same path from the UCI option name, and
|
||||
* the ACL write list enumerates all four. */
|
||||
const tmpcert = `/tmp/homeproxy_cert_${filename}.tmp`;
|
||||
const filestat = lstat(tmpcert);
|
||||
|
||||
if (!filestat || filestat.type !== 'file' || filestat.size <= 0) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'empty certificate file' };
|
||||
}
|
||||
|
||||
let filecontent = readfile(tmpcert);
|
||||
if (isBinary(filecontent)) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'illegal file type: binary' };
|
||||
}
|
||||
|
||||
/* Validate the PEM structure before storing it */
|
||||
const wellformed = kind === 'ech'
|
||||
? isValidECHConfig(filecontent)
|
||||
: isValidPEM(filecontent, kind === 'key');
|
||||
|
||||
if (!wellformed) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'this does not look like a correct PEM file' };
|
||||
}
|
||||
|
||||
/* Sanitize certificate */
|
||||
filecontent = trim(filecontent);
|
||||
filecontent = replace(filecontent, /\r\n?/g, '\n');
|
||||
if (!match(filecontent, /\n$/))
|
||||
filecontent += '\n';
|
||||
|
||||
system(`mkdir -p ${shellQuote(HP_DIR)}/certs`);
|
||||
writefile(`${HP_DIR}/certs/${filename}.pem`, filecontent);
|
||||
/* writefile() is fopen(path, "w"): 0666 & ~umask, i.e. 0644 at
|
||||
* the usual 022 - world-readable, on a file that can be a TLS
|
||||
* private key. Start it at 0600; the start path
|
||||
* (runtime/service.sh, hp_prepare_runtime_files) hands the
|
||||
* directory and its files to the sing-box user so the jailed
|
||||
* server can read them, with the mode kept at 0600. */
|
||||
system(`chmod 600 ${shellQuote(HP_DIR)}/certs/${shellQuote(filename + '.pem')}`);
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
|
||||
return { result: true };
|
||||
};
|
||||
|
||||
const filename = req.args?.filename;
|
||||
switch (filename) {
|
||||
case 'client_ca':
|
||||
case 'server_publickey':
|
||||
return writeCertificate(filename, 'cert');
|
||||
break;
|
||||
case 'server_privatekey':
|
||||
return writeCertificate(filename, 'key');
|
||||
break;
|
||||
/* The node form's fourth upload button. It was missing here
|
||||
* from the initial commit, so the button always failed with
|
||||
* "illegal cerificate filename" while the ACL granted the
|
||||
* two write paths - the frontend assumed the backend case
|
||||
* existed and nothing tested that it did. */
|
||||
case 'client_ech_conf':
|
||||
return writeCertificate(filename, 'ech');
|
||||
break;
|
||||
default:
|
||||
return { result: false, error: 'illegal cerificate filename' };
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
connection_check: {
|
||||
args: { site: 'site' },
|
||||
call: function(req) {
|
||||
let url;
|
||||
switch(req.args?.site) {
|
||||
case 'baidu':
|
||||
url = 'https://www.baidu.com';
|
||||
break;
|
||||
case 'google':
|
||||
url = 'https://www.google.com';
|
||||
break;
|
||||
default:
|
||||
return { result: false, error: 'illegal site' };
|
||||
break;
|
||||
}
|
||||
|
||||
return { result: (system(`/usr/bin/wget --spider -qT3 ${shellQuote(url)} 2>"/dev/null"`, 3100) === 0) };
|
||||
}
|
||||
},
|
||||
|
||||
log_clean: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['homeproxy', 'sing-box-c', 'sing-box-s'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const filestat = lstat(`${RUN_DIR}/${req.args?.type}.log`);
|
||||
if (filestat)
|
||||
writefile(`${RUN_DIR}/${req.args?.type}.log`, '');
|
||||
return { result: true };
|
||||
}
|
||||
},
|
||||
|
||||
singbox_generator: {
|
||||
args: { type: 'type', params: 'params' },
|
||||
call: function(req) {
|
||||
if (index(['ech-keypair', 'uuid', 'reality-keypair', 'vapid-keypair', 'wg-keypair'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const type = req.args?.type;
|
||||
let result = {};
|
||||
|
||||
const fd = popen(`/usr/bin/sing-box generate ${shellQuote(type)} ${shellQuote(req.args?.params || '')}`);
|
||||
if (fd) {
|
||||
let ech_cfg_set = false;
|
||||
let ech_key_set = false;
|
||||
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
if (type === 'uuid')
|
||||
result.uuid = trim(line);
|
||||
else if (index(['reality-keypair', 'vapid-keypair', 'wg-keypair'], type) !== -1) {
|
||||
let priv = match(trim(line), /PrivateKey: (.*)/);
|
||||
if (priv)
|
||||
result.private_key = priv[1];
|
||||
let pub = match(trim(line), /PublicKey: (.*)/);
|
||||
if (pub)
|
||||
result.public_key = pub[1];
|
||||
} else if (type === 'ech-keypair') {
|
||||
if (trim(line) === '-----BEGIN ECH CONFIGS-----')
|
||||
ech_cfg_set = true;
|
||||
else if (trim(line) === '-----BEGIN ECH KEYS-----')
|
||||
ech_key_set = true;
|
||||
|
||||
if (ech_cfg_set)
|
||||
result.ech_cfg = result.ech_cfg ? result.ech_cfg + '\n' + trim(line) : trim(line) ;
|
||||
if (ech_key_set)
|
||||
result.ech_key = result.ech_key ? result.ech_key + '\n' + trim(line) : trim(line) ;
|
||||
|
||||
if (trim(line) === '-----END ECH CONFIGS-----')
|
||||
ech_cfg_set = false;
|
||||
else if (trim(line) === '-----END ECH KEYS-----')
|
||||
ech_key_set = false;
|
||||
}
|
||||
}
|
||||
|
||||
fd.close();
|
||||
}
|
||||
|
||||
return { result };
|
||||
}
|
||||
},
|
||||
|
||||
singbox_get_features: {
|
||||
call: function() {
|
||||
return collectFeatures();
|
||||
}
|
||||
},
|
||||
|
||||
/* Parse one share link with the same parser the subscription pipeline
|
||||
* uses (parse_uri.uc). This exists so the browser does not need a second
|
||||
* implementation: the LuCI form used to carry a 388-line JavaScript copy
|
||||
* of the parser that had already drifted from the backend (the vmess
|
||||
* branch lost vmess_global_padding, anytls defaulted a missing port to
|
||||
* 80) and that wrote nodes into UCI without the /sbin/validate_data
|
||||
* host/port checks parse_uri() applies. */
|
||||
node_parse: {
|
||||
args: { uri: 'string' },
|
||||
call: function(req) {
|
||||
const uri = req.args?.uri;
|
||||
|
||||
if (type(uri) !== 'string' || !length(uri) || length(uri) > 4096)
|
||||
return { config: null, error: 'illegal share link' };
|
||||
|
||||
let config;
|
||||
try {
|
||||
/* parse_uri() reports skipped nodes through the logger;
|
||||
* the caller only needs the verdict. */
|
||||
config = parse_uri(trim(uri), collectFeatures(), function() {});
|
||||
} catch (e) {
|
||||
return { config: null, error: 'parse failed: ' + e.message };
|
||||
}
|
||||
|
||||
if (!config)
|
||||
return { config: null, error: 'unsupported or invalid share link' };
|
||||
|
||||
return { config: config };
|
||||
}
|
||||
},
|
||||
|
||||
resources_get_version: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
/* whitelist resource types to prevent path traversal */
|
||||
if (index(['china_ip4', 'china_ip6', 'gfw_list', 'china_list'], req.args?.type) === -1)
|
||||
return { error: 'illegal type', version: null, updated_at: null };
|
||||
let version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
|
||||
/* Review M7: the version file holds the upstream commit date
|
||||
* and SHA; .updated_at is when *this router* last succeeded.
|
||||
* The two are not the same - a successful run on a stale
|
||||
* router still records the same upstream date, so the UI
|
||||
* shows both. */
|
||||
/* The shipped .ver files hold a single timestamp (the format the
|
||||
* resource updater used before it started recording the upstream
|
||||
* commit); a runtime update writes "<date> <sha>". Show the date
|
||||
* part when there is one, and the value as it stands otherwise.
|
||||
*
|
||||
* No non-capturing groups: ucode's regex engine rejects `(?:...)`
|
||||
* with "Repetition not preceded by valid expression" when the
|
||||
* literal is evaluated, so it compiles fine and then takes this
|
||||
* method down at call time. */
|
||||
const fields = version ? match(version, /^(\S+) \S+$/) : null;
|
||||
version = fields ? fields[1] : version;
|
||||
/* Only a missing .ver means "not found"; that is the case the
|
||||
* UI paints red. */
|
||||
if (!version)
|
||||
return { version: null, updated_at: null, error: 'no version file' };
|
||||
/* .updated_at is written by update_resources.sh after a
|
||||
* successful run, so a device that has never updated anything
|
||||
* legitimately has none. Returning error() here reported the
|
||||
* errno left behind by *that* missing file, which made every
|
||||
* fresh install show "not found" in red next to a perfectly
|
||||
* good version string. */
|
||||
const updated_at = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.updated_at`));
|
||||
return { version: version, updated_at: updated_at || null };
|
||||
}
|
||||
},
|
||||
resources_update: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['china_ip4', 'china_ip6', 'gfw_list', 'china_list'], req.args?.type) === -1)
|
||||
return { status: 255, error: 'illegal type' };
|
||||
if (req.args?.type) {
|
||||
const exit_code = system(`${shellQuote(HP_DIR)}/scripts/update_resources.sh ${shellQuote(req.args?.type)}`);
|
||||
return { status: exit_code };
|
||||
} else
|
||||
return { status: 255, error: 'illegal type' };
|
||||
}
|
||||
},
|
||||
update_subscriptions: {
|
||||
/* Run the subscription updater once and report how it went.
|
||||
*
|
||||
* The LuCI button used fs.exec_direct() on
|
||||
* /etc/homeproxy/scripts/update_subscriptions.uc, which is the only
|
||||
* reason the package ACL had to grant `file exec` on a root-owned
|
||||
* entry point: the browser session was authorised to execute that
|
||||
* path. Through one named ubus method the session is granted the
|
||||
* action instead, and the ACL lists no executable path at all
|
||||
* (arch-guard 30).
|
||||
*
|
||||
* The previous synchronous shape returned the orchestrator's exit
|
||||
* status and captured stderr, but the full pipeline (wget fetch
|
||||
* through a CN -> overseas link, UCI commit, /etc/init.d/homeproxy
|
||||
* reload, the 30 s sing-box health gate) regularly took 10-15 s,
|
||||
* which exceeded the browser XHR timeout on every browser we tried.
|
||||
* The UI button then surfaced "XHR request timed out" while the
|
||||
* backend happily completed the work; the user was left with a
|
||||
* stale page and no obvious way to know whether anything happened.
|
||||
*
|
||||
* The fix: spawn the script detached via "sh -c '... &'", return
|
||||
* immediately, and let the frontend poll update_subscriptions_status
|
||||
* until the orchestrator's lock file is gone (created in
|
||||
* acquire_lock(), removed on every exit path). The lock is also
|
||||
* what serialises two simultaneous updates, so refusing a second
|
||||
* call while the lock is present is consistent with the script's
|
||||
* own behaviour. The script's stdio is redirected so it cannot
|
||||
* block on a closed pipe when the rpcd process exits. */
|
||||
call: function() {
|
||||
const lock = `${RUN_DIR}/update_subscriptions.lock`;
|
||||
if (lstat(lock))
|
||||
return { result: false, error: 'Subscription update already running', running: true };
|
||||
|
||||
/* Trailing '&' is what makes this return immediately: /bin/sh
|
||||
* backgrounds the script before the shell exits, and system()
|
||||
* sees only the (sub-)shell's exit. Redirections (<dev/null,
|
||||
* >dev/null, 2>&1) are the other half: the orphaned script
|
||||
* must not hold open any fd inherited from rpcd, or it
|
||||
* keeps rpcd's stdio alive across the call. */
|
||||
const script = shellQuote(`${HP_DIR}/scripts/update_subscriptions.uc`);
|
||||
system(`sh -c ${shellQuote(`${script} </dev/null >/dev/null 2>&1 &`)}`);
|
||||
|
||||
return { result: true, async: true };
|
||||
}
|
||||
},
|
||||
|
||||
update_subscriptions_status: {
|
||||
/* Status companion to update_subscriptions. Reports whether the
|
||||
* updater is still running (lock file present) and the last ~12
|
||||
* lines of the homeproxy log so the page can show what the run
|
||||
* said without re-reading everything. The log is world-readable
|
||||
* and is rotated by runtime/service.sh -> log-cleaner.sh.
|
||||
*
|
||||
* `readfile()` returns null (not throws) on a missing file in this
|
||||
* ucode build, and the downstream split/slice/join then propagate
|
||||
* that null straight through. Guard with a truthy check on the
|
||||
* raw text so log_tail stays a string even when the log has never
|
||||
* been written - the test_rpc_methods shape contract pins it as
|
||||
* `string`, and the UI otherwise shows "null" in the toast. */
|
||||
call: function() {
|
||||
const lock = `${RUN_DIR}/update_subscriptions.lock`;
|
||||
const running = !!lstat(lock);
|
||||
|
||||
let tail = '';
|
||||
try {
|
||||
const text = readfile(`${LOG_PATH}`);
|
||||
if (text) {
|
||||
const lines = split(text, '\n');
|
||||
const start = length(lines) > 12 ? length(lines) - 12 : 0;
|
||||
tail = join('\n', slice(lines, start)) || '';
|
||||
}
|
||||
} catch (e) {
|
||||
tail = '';
|
||||
}
|
||||
|
||||
return { running, log_tail: tail };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { 'luci.homeproxy': methods };
|
||||
@@ -0,0 +1,427 @@
|
||||
# luci-app-homeproxy tests
|
||||
|
||||
Automated checks for the pieces that `sing-box check` cannot validate: the
|
||||
share-link parsers, the UCI→JSON generators and the LuCI form definitions.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
tests/run.sh
|
||||
```
|
||||
|
||||
* The **LuCI form snapshots** run locally and only need `node`. They re-render
|
||||
the node/server views through a mock LuCI runtime and diff the result against
|
||||
`tests/snapshots/*.json`.
|
||||
* The **ucode tests** need `ucode`; the generator cases additionally run
|
||||
`sing-box check`, so they need sing-box ≥ 1.14 as well (available on an
|
||||
OpenWrt/ImmortalWrt target). `tests/run.sh` picks its branch in this order:
|
||||
|
||||
1. **local toolchain** — if both `ucode` and `sing-box` are on `PATH`, the
|
||||
layer runs here, gated on sing-box ≥ 1.14;
|
||||
2. **ssh** — otherwise, if `$HP_TEST_HOST` is set, the checkout is copied to
|
||||
that machine and run there;
|
||||
3. **skip** — otherwise the layer is skipped with a clear message.
|
||||
|
||||
The local toolchain is checked **first** on purpose: the testbed is the
|
||||
documented way to run this layer off-target, so an unset `$HP_TEST_HOST` must
|
||||
not shadow it. A skip is reported as `SKIP`, never as a pass: the run ends
|
||||
with exit 3 and a `PARTIAL:` line unless `HP_ALLOW_PARTIAL=1` is set.
|
||||
|
||||
```sh
|
||||
tests/toolchain/build-ucode-linux.sh # Linux host, then:
|
||||
export PATH="$HOME/.local/ucode-testbed/bin:$PATH"
|
||||
tests/run.sh # branch 1
|
||||
|
||||
HP_TEST_HOST=root@<test-machine> tests/run.sh # branch 2
|
||||
HP_TEST_DIR=/tmp/hp-tests tests/run.sh
|
||||
```
|
||||
|
||||
`tests/run.sh` will not ssh into any guessed address, and the production
|
||||
router (`192.168.1.1` or anything in `192.168.1.1:*`) is refused by the
|
||||
on-target workflow regardless.
|
||||
|
||||
### Test statistics
|
||||
|
||||
The guard / check / test-file counts quoted in the top-level `README.md` are
|
||||
measured, not hand-maintained: `tests/print-stats.sh` prints one `key=value`
|
||||
line each and is the number's single source.
|
||||
|
||||
```sh
|
||||
sh tests/print-stats.sh
|
||||
# guards=<n>
|
||||
# checks=<n>
|
||||
# test_files=<n>
|
||||
```
|
||||
|
||||
`checks` costs one `tests/arch-guard.sh` run, because that is where the total is
|
||||
counted; the script propagates the guard's exit status, so a red guard cannot be
|
||||
reported as a green statistic. `test_files` counts files under `tests/` minus the
|
||||
gitignored `.DS_Store`, which keeps the number the same before and after the
|
||||
commit that adds a test file (and equal to `git ls-files tests | wc -l`).
|
||||
|
||||
### The staging policy: never install
|
||||
|
||||
The ssh path **copies the checkout and runs it in place**. It never installs the
|
||||
package and never runs `apk` or `opkg` on the target. That is a rule, not a
|
||||
preference.
|
||||
|
||||
Installing on a live device runs the package manager, which rewrites
|
||||
`/etc/config/homeproxy` from the feed package — and on 2026-09-15 that destroyed
|
||||
the test machine's node configuration. There was no backup and no snapshot, and
|
||||
six nodes plus the `dns`, `server` and `subscription` sections were
|
||||
unrecoverable. Staging cannot do that: everything it writes lives under
|
||||
`$HP_TEST_DIR` and nothing outlives the run.
|
||||
|
||||
Two consequences are printed rather than left to be discovered. `tests/run.sh`
|
||||
reports both versions before staging:
|
||||
|
||||
```
|
||||
== target and source versions (staging; the package is not installed) ==
|
||||
source : 28.9.1.14-r1
|
||||
target : luci-app-homeproxy-26.236.50544~cb5d434
|
||||
sing-box: 1.14.1
|
||||
```
|
||||
|
||||
* the target may have a different build of the app installed, or none. **What is
|
||||
tested is the checkout, not what the device runs** — so "it passes on the
|
||||
device" never means "the installed package is good".
|
||||
* `sing-box` on the target is whatever the target has. The local branch gates on
|
||||
≥ 1.14; the staging branch records the version instead, because it cannot
|
||||
install one.
|
||||
|
||||
If a test genuinely needs the installed package, that is a different test and
|
||||
needs a different safety story (a config backup, taken first, on the device).
|
||||
|
||||
`tests/run.sh` exits non-zero if anything fails. The ucode part can also be run
|
||||
on its own on a target:
|
||||
|
||||
```sh
|
||||
sh tests/ucode/run.sh "$PWD"
|
||||
```
|
||||
|
||||
### The standard device regression
|
||||
|
||||
One command, no install, safe to run against a target that is also in use:
|
||||
|
||||
```sh
|
||||
HP_TEST_HOST=root@<test-machine> sh tests/run.sh # stage + run everywhere
|
||||
HP_TEST_HOST=root@<test-machine> HP_REQUIRE_FW4=0 sh tests/run.sh # target without firewall4
|
||||
```
|
||||
|
||||
`HP_REQUIRE_FW4` defaults to `1` on this branch and is validated as `0`/`1`;
|
||||
the on-target workflow's `require_fw4` input maps onto it. Both the script and
|
||||
the workflow print which value was used, so a fw4 layer that reported `NOT RUN`
|
||||
is visible rather than inferred.
|
||||
|
||||
Installing the package and reloading the router is **not** part of this command.
|
||||
If a change needs that (anything in `/etc/init.d/homeproxy`,
|
||||
`scripts/runtime/*.sh`, or the generation path), do it as a separate, deliberate
|
||||
step — and take `/etc/config/homeproxy` plus `/etc/homeproxy` aside first, with
|
||||
the previous release's package kept for re-install. `apk` keeps a modified
|
||||
conffile and drops a `<file>.apk-new` next to it; check `md5sum` on the config
|
||||
before and after and delete the `.apk-new` files only once that matches.
|
||||
|
||||
## The off-target toolchain (Linux only)
|
||||
|
||||
The ucode layer needs a Linux host: ucode's `uci`/`ubus` modules link against
|
||||
the OpenWrt libraries, `utpl` renders the fw4 template through the `fw4` ucode
|
||||
module, and `sing-box check` rejects the Linux-only `routing_mark` field
|
||||
everywhere else. The toolchain is therefore built from source into a private
|
||||
prefix **on Linux**:
|
||||
|
||||
```sh
|
||||
sh tests/toolchain/build-ucode-linux.sh
|
||||
export PATH="$HOME/.local/ucode-testbed/bin:$PATH"
|
||||
tests/run.sh
|
||||
```
|
||||
|
||||
There is deliberately no macOS variant. One existed, and to make the fixtures
|
||||
pass on Darwin it rewrote `routing_mark` to `null` inside the generator copy —
|
||||
so the one field that keeps sing-box's proxy connection out of the nft redirect
|
||||
chain was deleted before every test, and the regression that deleted it in
|
||||
production (see guard 32) stayed invisible. A host that cannot validate the
|
||||
generated configuration says so instead of weakening it.
|
||||
|
||||
On a development machine, run the whole suite in a Linux container:
|
||||
|
||||
```sh
|
||||
docker run --rm -v "$PWD:/w" -w /w debian:stable-slim sh -c '
|
||||
apt-get update -qq &&
|
||||
apt-get install -y -qq --no-install-recommends build-essential ca-certificates
|
||||
cmake curl gettext git libjson-c-dev libmd-dev libssl-dev meson ninja-build
|
||||
pkg-config python3 zlib1g-dev &&
|
||||
sh tests/toolchain/build-ucode-linux.sh &&
|
||||
PATH="$HOME/.local/ucode-testbed/bin:$PATH" sh tests/run.sh'
|
||||
```
|
||||
|
||||
The script installs into `~/.local/ucode-testbed` and removes nothing outside
|
||||
that directory plus the clone/build scratch tree (`/tmp/ucode-build` by
|
||||
default), so dropping the prefix undoes it completely. It builds:
|
||||
|
||||
| Component | Why |
|
||||
| --- | --- |
|
||||
| `libubox`, `libuci`, `libubus` | ucode's `uci`/`ubus`/`uloop` plugins need them |
|
||||
| `libmd` | the `digest` module (`ucode-mod-digest` in the package Makefile) |
|
||||
| `ucode` | the interpreter itself, plus `utpl`/`ucc`. Pinned to the revision ImmortalWrt/OpenWrt snapshots ship as `2026.01.16~85922056` — see "Why ucode is pinned" below |
|
||||
| `liblucihttp` | `luci.http` is a thin wrapper over this C module and `homeproxy.uc` imports it |
|
||||
| `luci` ucode sources | `http.uc`, `sys.uc`, … installed to `<prefix>/share/ucode/luci` like OpenWrt does |
|
||||
| `sing-box` | the official 1.14.0 release binary; the fixtures are validated with `sing-box check` and the package targets 1.14 |
|
||||
|
||||
The script ends with a module import check (`lucihttp`, `luci.http`,
|
||||
`luci.sys`, `uci`, `ubus`, `digest`, …), a check that `utpl` exists and that the
|
||||
ucode grammar is still the strict one the package targets, and a
|
||||
`urldecode_params()` behaviour assertion. It exits non-zero if any of them fail.
|
||||
|
||||
`tests/run.sh` refuses to run the fixtures against an older `sing-box`
|
||||
(`FAIL: sing-box >= 1.14 required`), so keep the prefix first in `PATH`.
|
||||
|
||||
### What a host without the toolchain reports
|
||||
|
||||
The pure-shell, node and python suites still run, and `tests/run.sh` ends with
|
||||
**exit 3 and `PARTIAL: … the ucode layer did not run`** rather than a pass: a
|
||||
skipped layer is not evidence. `HP_ALLOW_PARTIAL=1` turns that run back into
|
||||
"the subset passed" (exit 0) for callers that only want the fast layers; CI
|
||||
does not set it, so a toolchain that fails to build there fails the job.
|
||||
|
||||
### Why ucode is pinned
|
||||
|
||||
`UCODE_REV` in the toolchain script is the revision the ImmortalWrt/OpenWrt
|
||||
snapshot ships (`2026.01.16~85922056`). That ucode is stricter than current
|
||||
upstream HEAD: it requires a terminating `;` after `export function ... }` and
|
||||
rejects object and array destructuring, both of which upstream relaxed
|
||||
afterwards (`openwrt/openwrt@main` pins `b885dd0f`, whose own test suite uses
|
||||
the semicolon-free form). Building ucode from the default branch therefore
|
||||
compiles code that no router accepts, which is how five refactor modules
|
||||
shipped unparseable while CI stayed green.
|
||||
|
||||
`tests/ucode/test_ucode_grammar.sh` pins the *dialect* rather than trusting the
|
||||
revision: it asserts the toolchain still rejects the two constructs and still
|
||||
accepts the ones the package uses. It runs at the end of the toolchain build
|
||||
and as the first step of `tests/ucode/run.sh`, so a permissive toolchain fails
|
||||
loudly instead of turning into a green run. Set
|
||||
`HP_ALLOW_PERMISSIVE_UCODE=1` to demote that to a warning while deliberately
|
||||
probing a newer ucode.
|
||||
|
||||
**If the canary reports `exported_function_without_semicolon compiles, but the
|
||||
target ucode rejects it`, the problem is usually the local build, not the pin.**
|
||||
A testbed built before `UCODE_REV` was introduced tracks upstream HEAD and is
|
||||
too permissive; rebuild it with `tests/toolchain/build-ucode-linux.sh` and the
|
||||
canary passes. This is worth doing rather than reaching
|
||||
for `HP_ALLOW_PERMISSIVE_UCODE=1`: on a permissive toolchain the canary's
|
||||
"rejected" probes pass for the wrong reason, and a real
|
||||
missing-`;`/destructuring regression would go unnoticed — which is precisely
|
||||
how the generator subtree shipped unloadable.
|
||||
|
||||
### What the local testbed cannot cover
|
||||
|
||||
Nothing is skipped any more. The suite used to report four checks as `SKIP` on
|
||||
a development host, and those skips are exactly what let a non-compiling
|
||||
`update_subscriptions.uc` (and, once that was fixed, a destructuring statement)
|
||||
reach a device:
|
||||
|
||||
| Was skipped | Now |
|
||||
| --- | --- |
|
||||
| `scripts/update_subscriptions.uc` | compiled like every other source; `luci.sys` comes from the toolchain |
|
||||
| `scripts/firewall_pre.uc` | compiled; its `homeproxy` import resolves through `-L` |
|
||||
| `rpcd/ucode/luci.homeproxy` | compiled from a copy whose absolute `/etc/homeproxy/...` imports are rewritten to the checkout |
|
||||
| `tests/ucode/test_firewall_template.sh` | runs; `utpl` is a symlink to `ucode` and ships with the toolchain |
|
||||
|
||||
A missing `utpl`, or any other gap in the toolchain, now FAILS instead of
|
||||
silently reducing coverage.
|
||||
|
||||
The one remaining `NOT RUN` is honest rather than a skip: the render half of
|
||||
`tests/ucode/test_firewall_template.sh` needs the device-only `fw4` ucode
|
||||
module. Its source-level half always runs, and it checks the actual
|
||||
precondition of the bug that test exists for (nothing but the shebang may
|
||||
precede the `{%-` tag, otherwise the trimmed newline glues the first generated
|
||||
statement onto a comment). Stubbing `fw4` would let the render run everywhere,
|
||||
but the assertions would then be about a ruleset the real `fw4` never
|
||||
produced — see that script's header for the reasoning.
|
||||
|
||||
It is not left as a bare skip, though: `HP_REQUIRE_FW4=1` turns the skip into a
|
||||
failure, and `tests/run.sh` defaults it to 1 in the ssh branch. A target always
|
||||
has firewall4, so the one environment able to run the render must run it — and a
|
||||
target that somehow cannot now fails instead of quietly reporting `NOT RUN`. The
|
||||
default can be turned off for a target that legitimately lacks fw4
|
||||
(`HP_REQUIRE_FW4=0`, or `require_fw4: false` on the on-target workflow).
|
||||
|
||||
Two further host differences are bridged so the remaining checks still run:
|
||||
|
||||
* **`/sbin/validate_data`** — `homeproxy.uc` shells out to this OpenWrt helper
|
||||
for hostname/address/port validation. `tests/toolchain/validate-data.sh`
|
||||
reproduces the caller's contract (exit 0 = valid) using the same validation
|
||||
code as the parser unit tests; `tests/ucode/run.sh` exports it as
|
||||
`HP_VALIDATE_DATA` when `/sbin/validate_data` is absent, and
|
||||
`tests/ucode/test_generators.sh` substitutes it while staging the generator.
|
||||
* **`routing_mark`** — a Linux-only `SO_MARK` socket option in sing-box (1.14
|
||||
has no portable `set_mark` route action), so redirect/tproxy configs cannot
|
||||
pass `sing-box check` outside Linux. This is a property of the *host*, not of
|
||||
the product: the ucode layer runs on Linux (the toolchain, CI and the target
|
||||
all are), and the assertion that the mark is emitted reads the generated JSON
|
||||
instead of asking the local sing-box about it. The suite used to rewrite the
|
||||
generated field to `null` on Darwin instead — which is how the regression
|
||||
that deleted it in production stayed invisible (see guard 32).
|
||||
|
||||
## What is covered
|
||||
|
||||
| Path | Checks |
|
||||
| --- | --- |
|
||||
| `tests/i18n-coverage.py` | Reports how many `po/templates/homeproxy.pot` strings have a non-fuzzy, non-empty `po/zh_Hans/homeproxy.po` translation. Technical tokens that stay as-is are listed in `tests/i18n-ignore.txt`; anything else missing fails the run (`--fail-below 100`) and adds a job-summary entry. The release workflow (`build.yml`) deliberately warns instead of failing, so a translation gap cannot block packaging - the PR gates already catch it. |
|
||||
| `tests/luci-form-snapshot.js` | Dumps every option (name, kind, title, description, depends, values, datatype/default/…) of the node, client and server views. Diffs against `tests/snapshots/{node,client,server}.json`, so a refactor that changes a field or its visibility fails. |
|
||||
| `tests/ucode/test_homeproxy_utils.uc` | `executeCommand()` return shape, stderr/exit-code capture, binary detection, and a descriptor-leak check (200 calls). |
|
||||
| `tests/ucode/test_resource_blob_sha.sh` | `resource_blob_sha.uc`, the digest the resource updater verifies downloads with. The expected values are git's own (`git hash-object` on the same bytes), covering the two cases an implementation gets wrong quietly: the empty file (a real blob id, not "no digest") and content whose byte length differs from its character count. Unreadable input must produce *no* digest and exit non-zero - the updater treats an empty answer as "cannot verify" and refuses, so a made-up digest would turn an unreadable file into a passing check. Re-checks the repository's real resource files against git when the host has it. |
|
||||
| `tests/ucode/test_homeproxy_utils_inject.uc` | The failure path of `executeCommand()`: the script stages a copy of `homeproxy.uc` whose `system()` call is replaced by `die()`, then checks that the exception still propagates and that neither descriptor leaks. |
|
||||
| `tests/ucode/test_tls_transport.uc` | Direct calls into `buildTLSObject()` / `buildTransportObject()`: client-vs-server-only fields (no `insecure` on a server inbound, no `utls` on a server, `public_key` only on the client and `private_key` only on the server for reality), the ECH client/server split, and the `validateHomeProxyPath()` gate on `cert_path` / `key_path`. This is the boundary the generator fixtures only exercised end-to-end, so it is where the "server emitted insecure=true" and "reality key on the wrong side" classes of bug get named. |
|
||||
| `tests/ucode/test_subscription_fetcher.uc` | `subscription/fetcher.uc` against a shimmed `wGETVerbose`: empty content logs a **redacted** URL and returns `{content:null,error}`; non-empty content returns the body and logs nothing. The redaction assertions are what stop the subscription token from reappearing in `homeproxy.log`: the userinfo, the **path** (providers hand out `https://host:port/<user>/<token>` with no query string) and the query string are all gone, while the host and port survive so the message still says which endpoint failed. The mocks carry verbatim copies of `redactUrl()`, and arch-guard 35 fails when one of them drifts from `homeproxy.uc`. |
|
||||
| `tests/ucode/test_migrate_config.sh` | Runs `migrate_config.uc` (36 checks) against a sandboxed UCI file that needs every migration step it can see at once — the 1.14 DNS renames, the legacy `dns_server.address` split, `rcode://` → predefined rule, `rule_set_ipcidr_match_source` rename, `block-out`/`block-dns` → `action='reject'`, `auto_firewall` redistribution, and the `block-dns` default-server replacement. The staged copy's cursor is redirected at the sandbox; the runner aborts if that rewrite stops matching rather than letting the migration touch the live `/etc/config`. |
|
||||
| `tests/ucode/test_firewall_pre.sh` | Behaviour tests for `firewall_pre.uc` (8 scenarios, one process each because the script has no exports): the tun accept pair, the per-server accept rules, the explicit-network narrowing, and — most importantly — that an invalid port or network skips the server with a WARN instead of interpolating garbage into an nft statement, which would make nft reject the entire ruleset. |
|
||||
| `tests/ucode/test_fw4_names.sh` | Keeps the fw4 chain/set inventory in `scripts/fw4_names.sh` (used by `init.d/homeproxy` to clean up on stop) in sync with the objects declared in `scripts/firewall_post.ut`. |
|
||||
| `tests/ucode/test_parse_uri.uc` | Per-scheme assertions on the `parse_uri()` result: anytls, http/https, hysteria, hysteria2/hy2, snell, socks(4/4a/5/5h), shadowsocks (SIP002 base64 + plain + plugin, Shadowrocket), trojan (ws/grpc), tuic, vless (reality/ws/http/httpupgrade), vmess (ws/h2/httpupgrade/grpc) and SIP008 objects, plus the rejection paths (unsupported kcp/quic, no QUIC support, invalid port, unknown scheme). |
|
||||
| `tests/ucode/test_parser_normalize.uc` | Asserts that `parser/normalize.uc` correctly maps the parser's flat UCI-key output to the canonical Node shape (`tls`, `transport`, `multiplex`, `common`, `credentials`, `protocol_options`); pins the contract between the parser, the Loader's `PROTOCOL_OPTIONS` (now derived from `parser/mapping.uc`) and the Adapter. |
|
||||
| `tests/ucode/test_parser_flatten.uc` | Round-trip invariant for the canonical-Node pipeline: `flatten(normalize(parse_uri(uri)))` must equal the parser's flat UCI dict field-for-field for every supported scheme. This is what stops the parser, the Loader's `PROTOCOL_OPTIONS` (now derived from `parser/mapping.uc`) and the Repository's flatten from drifting apart. |
|
||||
| `tests/ucode/test_subscription_repository.uc` | Integration test for `subscription/repository.uc`: stages a sandboxed UCI file with a user node, a kept subscription node, and a dropped subscription node, then runs `Repository.apply_nodes` and asserts (a) the user node is untouched, (b) the kept node has the new fields and no stale fields, (c) the dropped node is gone, (d) a brand-new node is added under `md5(group+label)`. PR-03 added the `apply_main_node_refs` paths (urltest prune + missing-target switch + reset-to-nil) and the `scrub_stale_urltest_refs` pass to the same test. |
|
||||
| `tests/ucode/test_generators.sh` | Runs `generate_client.uc`/`generate_server.uc` against the UCI fixtures in `tests/fixtures/generators/` inside a scratch directory (the `uci` cursor and `HP_DIR`/`RUN_DIR` are redirected) and validates each result with `sing-box check`. The `redirect` case is the product's default `proxy_mode` and asserts, from the emitted JSON, that every dialling outbound carries the runtime `routing_mark`. |
|
||||
| `tests/toolchain/build-ucode-linux.sh` | Builds the local ucode toolchain described above; not part of `tests/run.sh`. |
|
||||
| `tests/toolchain/validate-data.sh` | Off-target stand-in for `/sbin/validate_data`, used through `HP_VALIDATE_DATA`; not part of `tests/run.sh`. |
|
||||
| `tests/ucode/test_ucode_grammar.sh` | Pins the ucode dialect: the toolchain must reject `export function ... }` without `;` and object/array destructuring, and must accept the constructs the package uses (`?.`/`??`, object spread, computed keys, template literals). |
|
||||
| `tests/ucode/test_generators.sh` (wireguard case) | Asserts the WireGuard fixture keeps its private key, peer public key and local address list, so the endpoint builder cannot silently regress to reading flat UCI option names. |
|
||||
| `tests/ucode/test_golden_outbounds.sh` | Builds one outbound per protocol from `tests/fixtures/generators/outbounds.uci` and diffs the result against `tests/snapshots/generator/outbounds.json`, so a field change for any protocol is a reviewable diff. Regenerate with `HP_UPDATE_SNAPSHOTS=1`. |
|
||||
| `tests/ucode/test_golden_inbounds.sh` | PR-04: the server-side counterpart. Builds one inbound per protocol from `tests/fixtures/generators/server.uci` and diffs the result against `tests/snapshots/generator/inbounds.json`. The ACME `data_directory` is normalised to `<HP_DIR>/certs` so the snapshot is portable across hosts. Before this existed the server path had no snapshot at all, which is how the fixture's unused `listen_port` option survived. Regenerate with `HP_UPDATE_SNAPSHOTS=1`. |
|
||||
| `tests/ucode/test_inbound_adapter.uc` | PR-04: `InboundFactory`'s protocol-shape decisions — snell / shadowsocks must get no `users[]` block (a snell users entry is read as an extra user key and makes sing-box reject the section), vless / vmess keep `flow` / `alterId` per-user, the snell listener set omits `udp_fragment` / `udp_timeout` / `network`, the server-only TLS tail (key material, ECH key, REALITY private key + handshake, ACME) reaches `buildTLSObject()`, hysteria v1 emits `obfs` as a string while hysteria2 emits the object, and the per-protocol credential requirements are enforced. |
|
||||
| `tests/ucode/test_protocol_inventory.sh` | Cross-checks the protocol surface: every type named by `parse_uri.uc`, `CREDENTIALS`, `PROTOCOL_OPTIONS`, `REQUIRED_CREDENTIALS`, `OPTION_FIELDS` and the golden snapshot must agree. This is the check that catches "added a protocol to one table and forgot another". |
|
||||
| `tests/frontend-protocol-inventory.js` | The frontend's single ordered protocol table (`homeproxy.js`'s `protocols`) against the backend tables that decide what the generators can build: every offered type must be modelled (`PROTOCOL_TO_UCI` on the client side, `INBOUND_CREDENTIALS` on the server side), every outbound-capable protocol (`OPTION_FIELDS`, `REQUIRED_CREDENTIALS`) must be selectable in the node form, both rendered orders are pinned, and protocols the backend models but no form offers must be listed as deliberate decisions. This is the check that would have caught `snell`: it had a form block, a credentials row and a golden outbound, but was missing from the node form's value list so it could not be selected. Node-only, no ucode needed. |
|
||||
| `tests/frontend-rpc-inventory.js` | The frontend RPC boundary: exactly one `rpc.declare` site (inside `homeproxy.js`'s `rpcCall`), no view wrapping a call in `L.resolveDefault`, `rpcCall` itself catching and falling back and reporting, and no view left with an unused `require rpc`. Source-level on purpose - the behaviour it protects needs a browser, but "nobody reintroduces a second declaration site" does not. Comments are stripped before matching, because a naive grep matches the doc comment that quotes the old idiom. |
|
||||
| `tests/frontend-validators.js` | Drives the shared form validators with a fake form context. The snapshots cannot cover these: a `validate` callback is a function, and the dump skips function-valued properties. Sharing the password validator between the node and server forms added the 2022-blake3 key-length check to the node form, which the server form had and the node form did not - a client could save a key that makes the generated configuration fail to decode. Reverse-proved: deleting that branch fails six checks. |
|
||||
| `tests/lib/luci-module.js` | The off-target LuCI module loader (`'require x as y';` rewritten into a dependency lookup), shared by the snapshot renderer and the frontend invariant tests. Its default `_()` returns a LuCI String object with `.format`, because the form code calls both halves. |
|
||||
| `tests/runtime/test_config_transaction.sh` | The `runtime/` helpers `init.d/homeproxy` leans on: the known-good copy, the fallback when generation produced nothing, the rollback, and the health probe. Pure shell, runs anywhere. |
|
||||
| `tests/runtime/test_dns_snippets.sh` | The dnsmasq snippet writer's incremental behaviour (review L6). Counts `init.d/dnsmasq restart` calls through a stub and asserts that an identical second write does *not* restart (a restart flushes every client's DNS cache), while a changed resource list, routing mode, DNS port or `ipv6_support` setting does. Also pins that a mode change drops the snippet the previous mode produced, and that removing a resource list removes its snippet so the next call is stable again. Pure shell, runs anywhere. |
|
||||
| `tests/runtime/test_health_probe.sh` | The functional health probes (`runtime/health.sh`): `hp_probe_dns` / `hp_probe_tcp` answer 0 / 1 / 2 and keep "cannot probe here" (no tool, no port) distinct from "the probe failed"; `hp_run_probes` *reports* by default and only fails the gate when `health_probe_strict` is on, and never fails it for a probe that could not run; `hp_config_port_for_tag` reads the probe ports out of the running configuration in both the generator's pretty shape and the compact one. `nslookup` and `nc` are stubbed, so it is pure shell and runs anywhere. |
|
||||
| `tests/runtime/test_crontab_perms.sh` | `hp_crontab_drop()` (runtime/service.sh) edits `/etc/crontabs/root` through a temporary file, and `mv` replaces the file rather than its contents - so the new one inherited the shell's umask (0644) while procd creates the root crontab as 0600. The test drives the function against a sandboxed crontab and pins: the entry is dropped, other jobs survive, the mode is 0600 (including a file an older version had already loosened), a missing crontab is reported as a failure and is not created. Pure shell, runs anywhere. |
|
||||
| `tests/runtime/test_resource_update.sh` | The content-verification path of `scripts/update_resources.sh`: the download URL is pinned to a commit, and the file is installed only when its git blob id matches the one GitHub reports for that path and commit. Drives all four outcomes (match, mismatch, no digest from the API, no local digest) with `wget` / `jsonfilter` / `ucode` / `uci` / `flock` stubbed and the script's absolute paths rewritten into a sandbox, asserting in each failure case that the installed list and its `.ver` are left untouched - the point of the check is that it refuses, so a driver that only walked the happy path would keep passing with the check deleted. Pure shell, runs anywhere. |
|
||||
| `tests/runtime/test_runtime_extraction.sh` | Drives `init.d/homeproxy` through a stubbed environment (fake `ip` / `nft` / `fw4` / `ucode` / `sing-box` / `uci` / `netstat` / `nslookup` / `nc`, fake procd and jsonfilter state, fixture UCI) across five scenarios and diffs the resulting command + file trace against a baseline. Two baselines exist and are captured with the SAME harness, so the diff between them is exactly the intentional change: `tests/fixtures/runtime/trace.pre-pr05.txt` (the 517-line init script before PHASE 7 moved the plumbing out — the record that the extraction was behaviour-preserving) and `tests/fixtures/runtime/trace.golden.txt` (after the health-gate fix). Scenario D is the P0 regression: a candidate whose `mixed_port` is already taken must be rejected by the health gate, the previous known-good must survive, and the reload must roll back onto it. The generated fixture configuration is pretty-printed like the generator's own `%.J` output, because the runtime reads that file (`hp_config_ports`, `hp_config_port_for_tag`) and a compact fixture would test a shape production never writes. Pure shell, no ucode needed. Regenerate with `HP_UPDATE_GOLDEN=1` (optionally `HP_GOLDEN=` / `HP_INITD=` to target another baseline or revision). |
|
||||
|
||||
**`HP_UPDATE_GOLDEN=1` is a baseline-update mode, not a test.** It copies the
|
||||
trace the run just produced over the baseline and exits 0 *before* the golden
|
||||
comparison and every behavioural assertion after it
|
||||
(`tests/runtime/test_runtime_extraction.sh:682-687`), so the exit status of a
|
||||
regenerating run says nothing about the diff it wrote. That is the "test bakes a
|
||||
wrong output into a snapshot" risk the review named: the scenarios did run, but
|
||||
only the review of the written file
|
||||
(`git diff tests/fixtures/runtime/trace.golden.txt`) can tell whether the new
|
||||
baseline is correct. `tests/ucode/test_golden_outbounds.sh` is arranged the other
|
||||
way round on purpose - it asserts on the generated JSON first and only then
|
||||
honours `HP_UPDATE_SNAPSHOTS=1` - which `test_runtime_extraction.sh` cannot do,
|
||||
because its assertions all run after the trace is built.
|
||||
|
||||
### What the LuCI form snapshots cover
|
||||
|
||||
A snapshot records, for every option the form builds: kind, name, title,
|
||||
description, dependency set, the `ListValue` value list, and the non-function
|
||||
properties that were set on it. Options are nested, so a `SectionValue`
|
||||
option's nested form is included.
|
||||
|
||||
That last part was missing until it was fixed: `Option.toJSON()` skipped the
|
||||
`subsection` key, which is where `SectionValue` keeps the nested form, so the
|
||||
node form's entire body and client.js's rule sections were invisible. The
|
||||
measured effect of the fix:
|
||||
|
||||
| Target | Options dumped | Snapshot size |
|
||||
|---|---|---|
|
||||
| `node.json` | 13 → **117** | 3.5 KB → 34.6 KB |
|
||||
| `client.json` | 29 → **206** | 8.6 KB → 56.3 KB |
|
||||
| `server.json` | 95 → 95 | 25.9 KB → 27.8 KB |
|
||||
|
||||
Before that fix, a change to the protocol picker - exactly the kind of change
|
||||
PHASE 8 makes - was visible in `server.json` only. That is why
|
||||
`tests/frontend-protocol-inventory.js` also exists: it asserts the *meaning* of
|
||||
the protocol table against the backend, which a structural snapshot cannot.
|
||||
|
||||
What the snapshots still cannot tell you: whether the form is *usable*. They
|
||||
prove a change to the option tree is deliberate and reviewable; they say nothing
|
||||
about whether the resulting page works in a browser.
|
||||
|
||||
`tests/ucode/mocks/homeproxy.uc` is a test double for the real module: only
|
||||
`validation()` is stubbed (the real one runs `/sbin/validate_data`, which does
|
||||
not exist outside OpenWrt), everything else is a copy of the production code.
|
||||
|
||||
Three further mocks exist because a test needs a narrower seam than the full
|
||||
module:
|
||||
|
||||
* `tests/ucode/mocks/homeproxy_fetcher.uc` — replaces `wGETVerbose` with a
|
||||
stub that reads the canned response from a global, so the fetcher test never
|
||||
shells out to `wget`.
|
||||
* `tests/ucode/mocks/homeproxy_firewall.uc` — `isEmpty` / `validation` plus a
|
||||
`RUN_DIR` read from a global, so `firewall_pre.uc` writes its nft fragments
|
||||
into a scratch dir.
|
||||
* `tests/ucode/test_migrate_config.sh` rewrites the staged copy of
|
||||
`migrate_config.uc` so its cursor points at the sandbox. Whenever a test
|
||||
rewrites a staged copy, it must abort when the rewrite anchor stops
|
||||
matching — otherwise the sed no-ops and the script under test operates on
|
||||
the live `/etc/config`. Both `test_migrate_config.sh` and
|
||||
`test_firewall_pre.sh` do this.
|
||||
|
||||
### Files that still have no direct test
|
||||
|
||||
Deliberately, because a meaningful test would need more than the off-target
|
||||
harness provides:
|
||||
|
||||
| Path | Why |
|
||||
| --- | --- |
|
||||
| `scripts/update_resources.sh` | The version query and the download are a GitHub API round-trip plus a jsdelivr fetch, so the verification path is driven by `tests/runtime/test_resource_update.sh` with `wget` / `jsonfilter` / `ucode` / `uci` / `flock` stubbed and the script's absolute paths rewritten into a sandbox: matching digest installs, a mismatch or a missing digest refuses and leaves the installed list and its `.ver` alone. The `*)` usage branch is covered by the shell syntax check. |
|
||||
| `scripts/update_crond.sh` | A fixed list of invocations against hard-coded `/etc/homeproxy/scripts` paths. Covered by the shell syntax check. |
|
||||
| `scripts/clean_log.sh` | `while true; do sleep 180; …` — testing the rotation needs the loop made injectable first. Covered by the shell syntax check. |
|
||||
| `htdocs/.../view/homeproxy/status.js` | A LuCI view; its behaviour is only observable in a browser. |
|
||||
| `init.d/homeproxy` | procd semantics need a real procd, and `service_started` only behaves correctly when procd really starts the instances — the offline harness models that, but it is a model. PR-05 shrank the file from 517 to 253 lines by moving the dnsmasq / fw4 / tproxy-TUN / service-plumbing into `scripts/runtime/{service,dns,firewall,net}.sh`; the trace test proves the move kept the command-and-file behaviour identical, and the shell syntax check covers every file. The health gate itself was verified on the ImmortalWrt test machine (see the plan's 2.14.8). |
|
||||
|
||||
## Architecture regression coverage
|
||||
|
||||
The old `test_demo_architecture.sh` compared the production `OutboundFactory`
|
||||
against itself (via `generate_outbound()`, which delegates to it) and could
|
||||
therefore never fail; it is gone, along with the `/* HP_TEST_HOOK */` marker it
|
||||
relied on. Two tests replaced it:
|
||||
|
||||
* `tests/ucode/test_golden_outbounds.sh` pins the actual emitted JSON per
|
||||
protocol in `tests/snapshots/generator/outbounds.json`; PR-04 added the
|
||||
matching `tests/ucode/test_golden_inbounds.sh` for the server inbounds
|
||||
(`tests/snapshots/generator/inbounds.json`).
|
||||
* `tests/ucode/test_protocol_inventory.sh` asserts that the parser, the model,
|
||||
the loader option table, the adapter tables and the golden snapshot all name
|
||||
the same set of protocols.
|
||||
|
||||
`root/etc/init.d/homeproxy` is exercised for what can be checked off-target:
|
||||
`tests/ucode/run.sh` syntax-checks it and the `runtime/` helpers,
|
||||
`tests/runtime/test_config_transaction.sh` covers the transaction semantics, and
|
||||
`tests/runtime/test_runtime_extraction.sh` pins the orchestration order
|
||||
(generate before teardown, known-good before firewall, cron before
|
||||
`config_load`, early return before `mkdir`) against the pre-PR-05 trace.
|
||||
|
||||
procd itself is still only exercised on a target. **The paragraph below is a
|
||||
historical record, not a target you can use**: `root@192.168.1.102` is the
|
||||
author's former test machine, an address that is no longer part of the test
|
||||
setup and that nothing in this tree defaults to. `tests/run.sh` reads
|
||||
`$HP_TEST_HOST`, skips the layer when it is unset, and arch-guard 13 keeps it
|
||||
that way - so treat what follows as "what was observed on 2026-09", not as
|
||||
reproduction instructions.
|
||||
|
||||
PR-05 was verified on the ImmortalWrt test machine (`root@192.168.1.102`):
|
||||
`start` brought up both
|
||||
`sing-box-c` and `log-cleaner` instances (proving that a procd instance
|
||||
registered from a *sourced module* works), `reload` passed the health gate and
|
||||
refreshed known-good, and `stop` removed the instances, the TUN device, the ip
|
||||
rules, the dnsmasq snippets and the live configuration while keeping the
|
||||
known-good copy. That run is a manual step, not a CI job — see the plan's
|
||||
PHASE 9 row for the on-target CI gap.
|
||||
|
||||
## Adding cases
|
||||
|
||||
* New share link → add an `expect_fields(...)` case to `test_parse_uri.uc`.
|
||||
* New protocol option that reaches the generator → extend the fixtures in
|
||||
`tests/fixtures/generators/`, keeping values schema-valid so `sing-box check`
|
||||
still passes.
|
||||
* Intentional LuCI form change → regenerate the snapshots:
|
||||
|
||||
```sh
|
||||
node tests/luci-form-snapshot.js . node > tests/snapshots/node.json
|
||||
node tests/luci-form-snapshot.js . client > tests/snapshots/client.json
|
||||
node tests/luci-form-snapshot.js . server > tests/snapshots/server.json
|
||||
```
|
||||
+2075
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,170 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Regression fixture for generate_client.uc: a node of every supported
|
||||
# protocol reachable through a single urltest main node, so one run exercises
|
||||
# all outbound builders (TLS, transport, reality, mux, obfuscation).
|
||||
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option proxy_mode 'tun'
|
||||
option ipv6_support '0'
|
||||
option log_level 'error'
|
||||
option main_node 'urltest'
|
||||
option main_udp_node 'nil'
|
||||
option dns_server 'https://cloudflare-dns.com/dns-query'
|
||||
list main_urltest_nodes 'n_anytls'
|
||||
list main_urltest_nodes 'n_vless_reality_ws'
|
||||
list main_urltest_nodes 'n_vmess_http'
|
||||
list main_urltest_nodes 'n_trojan_grpc'
|
||||
list main_urltest_nodes 'n_ss'
|
||||
list main_urltest_nodes 'n_hy2'
|
||||
list main_urltest_nodes 'n_snell'
|
||||
list main_urltest_nodes 'n_http'
|
||||
list main_urltest_nodes 'n_socks'
|
||||
list main_urltest_nodes 'n_shadowtls'
|
||||
|
||||
config homeproxy 'infra'
|
||||
option mixed_port '5330'
|
||||
option dns_port '5333'
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option self_mark '100'
|
||||
option tun_mark '102'
|
||||
option table_mark '100'
|
||||
option ntp_server 'nil'
|
||||
|
||||
config node 'n_anytls'
|
||||
option label 'anytls'
|
||||
option type 'anytls'
|
||||
option address 'a.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option tls '1'
|
||||
option tls_sni 'a.example.com'
|
||||
option tls_insecure '0'
|
||||
option anytls_idle_session_check_interval '30'
|
||||
option anytls_idle_session_timeout '30'
|
||||
option anytls_min_idle_session '0'
|
||||
|
||||
config node 'n_vless_reality_ws'
|
||||
option label 'vless-reality-ws'
|
||||
option type 'vless'
|
||||
option address 'b.example.com'
|
||||
option port '443'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option transport 'ws'
|
||||
option ws_host 'b.example.com'
|
||||
option ws_path '/ws?ed=2048'
|
||||
option tls '1'
|
||||
option tls_sni 'b.example.com'
|
||||
option tls_reality '1'
|
||||
option tls_reality_public_key '4UAg690QziXeelpUJoAmXiim0gfQESSiJkNfvF-o8Uw'
|
||||
option tls_reality_short_id 'abcd'
|
||||
option tls_utls 'chrome'
|
||||
option vless_flow 'xtls-rprx-vision'
|
||||
option packet_encoding 'xudp'
|
||||
|
||||
config node 'n_vmess_http'
|
||||
option label 'vmess-http'
|
||||
option type 'vmess'
|
||||
option address 'c.example.com'
|
||||
option port '80'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option transport 'http'
|
||||
option http_host 'c.example.com'
|
||||
option http_path '/path'
|
||||
option http_method 'GET'
|
||||
option vmess_alterid '0'
|
||||
option vmess_encrypt 'auto'
|
||||
option vmess_global_padding '1'
|
||||
option tls '0'
|
||||
option packet_encoding 'packetaddr'
|
||||
|
||||
config node 'n_trojan_grpc'
|
||||
option label 'trojan-grpc'
|
||||
option type 'trojan'
|
||||
option address 'd.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option transport 'grpc'
|
||||
option grpc_servicename 'gs'
|
||||
option grpc_permit_without_stream '1'
|
||||
option tls '1'
|
||||
option tls_sni 'd.example.com'
|
||||
option tls_handshake_timeout '15'
|
||||
option tls_min_version '1.2'
|
||||
option tls_max_version '1.3'
|
||||
option tls_cipher_suites 'TLS_AES_128_GCM_SHA256'
|
||||
|
||||
config node 'n_ss'
|
||||
option label 'ss'
|
||||
option type 'shadowsocks'
|
||||
option address 'e.example.com'
|
||||
option port '8388'
|
||||
option shadowsocks_encrypt_method 'aes-256-gcm'
|
||||
option password 'secret'
|
||||
option multiplex '1'
|
||||
option multiplex_protocol 'h2mux'
|
||||
option multiplex_padding '1'
|
||||
option multiplex_max_connections '4'
|
||||
option udp_over_tcp '1'
|
||||
option udp_over_tcp_version '2'
|
||||
|
||||
config node 'n_hy2'
|
||||
option label 'hy2'
|
||||
option type 'hysteria2'
|
||||
option address 'f.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option tls '1'
|
||||
option tls_sni 'f.example.com'
|
||||
option hysteria_obfs_type 'salamander'
|
||||
option hysteria_obfs_password 'obfspass'
|
||||
option hysteria_up_mbps '100'
|
||||
option hysteria_down_mbps '500'
|
||||
option hysteria_hopping_port '20000:30000'
|
||||
option hysteria_hop_interval '30'
|
||||
option hysteria_bbr_profile 'conservative'
|
||||
|
||||
config node 'n_snell'
|
||||
option label 'snell'
|
||||
option type 'snell'
|
||||
option address 'g.example.com'
|
||||
option port '443'
|
||||
option password 'psk123456789012'
|
||||
option snell_version '4'
|
||||
option snell_obfs_mode 'http'
|
||||
option snell_obfs_host 'bing.com'
|
||||
option snell_reuse '1'
|
||||
option snell_userkey 'ukey'
|
||||
|
||||
config node 'n_http'
|
||||
option label 'http'
|
||||
option type 'http'
|
||||
option address 'h.example.com'
|
||||
option port '8080'
|
||||
option username 'u'
|
||||
option password 'p'
|
||||
option tls '0'
|
||||
|
||||
config node 'n_socks'
|
||||
option label 'socks'
|
||||
option type 'socks'
|
||||
option address 'i.example.com'
|
||||
option port '1080'
|
||||
option username 'u'
|
||||
option password 'p'
|
||||
option socks_version '5'
|
||||
|
||||
config node 'n_shadowtls'
|
||||
option label 'shadowtls'
|
||||
option type 'shadowtls'
|
||||
option address 'j.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option shadowtls_version '3'
|
||||
option tls '1'
|
||||
option tls_sni 'j.example.com'
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Regression fixture for routing_mode='custom'. It reproduces two crashes
|
||||
# that only the custom path could hit:
|
||||
#
|
||||
# * a direct node with an override forces generate_outbound() to write into
|
||||
# direct_overrides, and the routing rule below forces get_direct_override()
|
||||
# to read it -- so the table has to be declared before both helpers;
|
||||
# * a local rule-set must not carry the legacy download_detour field, which
|
||||
# sing-box 1.14 rejects on anything but a remote rule-set.
|
||||
#
|
||||
# __RULESET_DIR__ is substituted by tests/ucode/test_generators.sh with a
|
||||
# directory holding a real compiled .srs file.
|
||||
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'custom'
|
||||
option proxy_mode 'redirect_tproxy'
|
||||
option ipv6_support '0'
|
||||
option log_level 'error'
|
||||
|
||||
config homeproxy 'routing'
|
||||
option default_outbound 'direct-out'
|
||||
option default_outbound_dns 'default-dns'
|
||||
|
||||
config homeproxy 'infra'
|
||||
option mixed_port '5330'
|
||||
option dns_port '5333'
|
||||
option redirect_port '5331'
|
||||
option self_mark '100'
|
||||
|
||||
# direct node: exercises the direct_overrides write path
|
||||
config node 'n_direct'
|
||||
option label 'direct-node'
|
||||
option type 'direct'
|
||||
option override_address '1.2.3.4'
|
||||
option override_port '8443'
|
||||
|
||||
# routing node + rule: exercises get_direct_override() reading direct_overrides
|
||||
config routing_node 'rn_direct'
|
||||
option enabled '1'
|
||||
option label 'rn-direct'
|
||||
option node 'n_direct'
|
||||
# domain_resolver is what makes the generator call get_resolver(). No
|
||||
# fixture exercised it, so generator/outbound.uc using the function without
|
||||
# importing it (from ./common.uc) went unnoticed: ucode raises "access to
|
||||
# undeclared variable" and the whole generation dies, but only for a custom
|
||||
# routing_node that has this option set - which the UI offers.
|
||||
option domain_resolver 'default-dns'
|
||||
option domain_strategy 'prefer_ipv4'
|
||||
|
||||
config routing_rule 'rr_direct'
|
||||
option enabled '1'
|
||||
option label 'rr-direct'
|
||||
option outbound 'rn_direct'
|
||||
option action 'route'
|
||||
|
||||
# local rule-set: must never carry download_detour
|
||||
config ruleset 'rs_local'
|
||||
option enabled '1'
|
||||
option label 'local-rs'
|
||||
option type 'local'
|
||||
option format 'binary'
|
||||
option path '__RULESET_DIR__/test.srs'
|
||||
@@ -0,0 +1,45 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Regression fixture: a direct node as the main node.
|
||||
#
|
||||
# generate_outbound() turns a direct node's override_address / override_port
|
||||
# into a route action rather than outbound fields, so main-out ends up as a
|
||||
# bare {"type":"direct","tag":"main-out"}. main-dns used to hard-code
|
||||
# `detour: 'main-out'`, and the rule-set http_client inherited it; sing-box
|
||||
# refuses to detour into an empty direct outbound:
|
||||
#
|
||||
# FATAL start service: start dns/tcp[main-dns]: detour to an empty direct
|
||||
# outbound makes no sense
|
||||
# FATAL start service: initialize rule-set[0]: ... detour to an empty direct
|
||||
# outbound makes no sense
|
||||
#
|
||||
# and the service never started. `sing-box check` accepts such a config, so
|
||||
# test_generators.sh inspects the generated JSON instead of trusting check.
|
||||
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option proxy_mode 'tun'
|
||||
option ipv6_support '0'
|
||||
option log_level 'error'
|
||||
option main_node 'n_direct'
|
||||
option main_udp_node 'nil'
|
||||
option dns_server '223.5.5.5'
|
||||
|
||||
config homeproxy 'infra'
|
||||
option mixed_port '5330'
|
||||
option dns_port '5333'
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option self_mark '100'
|
||||
option tun_mark '102'
|
||||
option table_mark '100'
|
||||
option ntp_server 'nil'
|
||||
|
||||
config node 'n_direct'
|
||||
option label 'direct-main'
|
||||
option type 'direct'
|
||||
option override_address '127.0.0.1'
|
||||
option override_port '8080'
|
||||
option enabled '1'
|
||||
@@ -0,0 +1,167 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Fixture for the domain model skeleton test (Stage A1.2+). It exercises
|
||||
# every UCI section the Config Loader is supposed to read, and is *not*
|
||||
# used by the generator regression suite (tests/fixtures/generators/
|
||||
# client.uci and custom.uci are). Sections in this file:
|
||||
#
|
||||
# config general routing_mode, main_node, etc.
|
||||
# infra ports and marks
|
||||
# dns DNS settings (default_strategy, cache knobs)
|
||||
# dns_server one UDP server
|
||||
# dns_rule one hijack rule
|
||||
# routing default_outbound, default_outbound_dns
|
||||
# routing_node one urltest-style selector
|
||||
# routing_rule one sniffer route
|
||||
# ruleset one remote rule-set
|
||||
# control bind_interface, lan_proxy_mode, wan_proxy_*_ips
|
||||
# subscription auto_update, filter_keywords, etc.
|
||||
# server one inbound (for the self-host side; client ignores it)
|
||||
# node two nodes (anytls + snell) so the existing node-shape
|
||||
# assertions keep working
|
||||
#
|
||||
# A domain-model test that loads this fixture can assert every Loader
|
||||
# sub-object without depending on the generator's particular routing
|
||||
# mode or proxy mode.
|
||||
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option proxy_mode 'redirect_tproxy'
|
||||
option ipv6_support '0'
|
||||
option log_level 'error'
|
||||
option main_node 'urltest'
|
||||
option main_udp_node 'nil'
|
||||
option main_urltest_nodes 'n_anytls'
|
||||
option main_urltest_nodes 'n_snell'
|
||||
option dns_server '8.8.8.8'
|
||||
option china_dns_server '223.5.5.5'
|
||||
option routing_port 'common'
|
||||
option github_token ''
|
||||
option cn_ip_fallback '0'
|
||||
|
||||
config homeproxy 'infra'
|
||||
option mixed_port '5330'
|
||||
option dns_port '5333'
|
||||
option redirect_port '5331'
|
||||
option tproxy_port '5332'
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option self_mark '100'
|
||||
option tun_mark '102'
|
||||
option table_mark '100'
|
||||
option tproxy_mark '101'
|
||||
option ntp_server 'time.apple.com'
|
||||
option common_port '22,53,80,443'
|
||||
option dns_redirect '1'
|
||||
option udp_timeout ''
|
||||
|
||||
config homeproxy 'dns'
|
||||
option default_strategy 'prefer_ipv4'
|
||||
option default_server 'default-dns'
|
||||
option disable_cache '0'
|
||||
option disable_cache_expire '0'
|
||||
option optimistic_cache '0'
|
||||
option cache_file_store_dns '0'
|
||||
option dns_timeout '5s'
|
||||
option optimistic_timeout '3s'
|
||||
|
||||
config dns_server 'ds_main'
|
||||
option label 'main-dns'
|
||||
option type 'udp'
|
||||
option server '8.8.8.8'
|
||||
option server_port '53'
|
||||
option detour 'direct-out'
|
||||
|
||||
config dns_rule 'dr_hijack'
|
||||
option enabled '1'
|
||||
option label 'hijack-private'
|
||||
option mode 'default'
|
||||
option action 'hijack-dns'
|
||||
|
||||
config homeproxy 'routing'
|
||||
option default_outbound 'direct-out'
|
||||
option default_outbound_dns 'default-dns'
|
||||
option domain_strategy ''
|
||||
option find_neighbor '0'
|
||||
option udp_timeout '5m'
|
||||
option tcpip_stack 'system'
|
||||
option endpoint_independent_nat ''
|
||||
|
||||
config routing_node 'rn_main'
|
||||
option enabled '1'
|
||||
option label 'main'
|
||||
option node 'urltest'
|
||||
option urltest_nodes 'n_anytls'
|
||||
option urltest_nodes 'n_snell'
|
||||
option urltest_interval '3m'
|
||||
option urltest_tolerance '50'
|
||||
|
||||
config routing_rule 'rr_sniff'
|
||||
option enabled '1'
|
||||
option label 'sniff-route'
|
||||
option outbound 'main-out'
|
||||
option action 'route'
|
||||
|
||||
config ruleset 'rs_cn'
|
||||
option enabled '1'
|
||||
option label 'china-list'
|
||||
option type 'remote'
|
||||
option format 'source'
|
||||
option url 'https://example.com/cn.list'
|
||||
option download_detour 'main-out'
|
||||
option update_interval '24h'
|
||||
|
||||
config homeproxy 'control'
|
||||
option bind_interface ''
|
||||
option lan_proxy_mode 'disabled'
|
||||
list wan_proxy_ipv4_ips '91.108.4.0/22'
|
||||
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
|
||||
|
||||
config homeproxy 'subscription'
|
||||
option auto_update '0'
|
||||
option allow_insecure '0'
|
||||
option packet_encoding 'xudp'
|
||||
option update_via_proxy '0'
|
||||
option filter_nodes 'blacklist'
|
||||
list filter_keywords '重置|到期'
|
||||
list filter_keywords 'Expiration'
|
||||
option user_agent 'HomeProxy/1.0'
|
||||
list subscription_url 'https://example.com/sub'
|
||||
|
||||
config homeproxy 'server'
|
||||
option enabled '1'
|
||||
option log_level 'warn'
|
||||
|
||||
config server 's_vless'
|
||||
option enabled '1'
|
||||
option label 'server-vless'
|
||||
option type 'vless'
|
||||
option address '::'
|
||||
option port '443'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option tls '1'
|
||||
option tls_sni 'server.example.com'
|
||||
|
||||
config homeproxy 'migration'
|
||||
option crontab '1'
|
||||
|
||||
config node 'n_anytls'
|
||||
option label 'anytls'
|
||||
option type 'anytls'
|
||||
option address 'a.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option tls '1'
|
||||
option tls_sni 'a.example.com'
|
||||
option tls_insecure '0'
|
||||
|
||||
config node 'n_snell'
|
||||
option label 'snell'
|
||||
option type 'snell'
|
||||
option address 'g.example.com'
|
||||
option port '443'
|
||||
option password 'psk123456789012'
|
||||
option snell_version '4'
|
||||
option snell_userkey 'ukey'
|
||||
@@ -0,0 +1,248 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Golden-snapshot fixture for the protocol adapter: one node per protocol the
|
||||
# adapter can build an outbound for, with a value on each option that has a
|
||||
# mapping. tests/ucode/test_golden_outbounds.sh turns this into
|
||||
# tests/snapshots/generator/outbounds.json and diffs it on every run.
|
||||
#
|
||||
# It replaces test_demo_architecture.sh, which compared the production
|
||||
# OutboundFactory against itself and therefore could never fail. The
|
||||
# snapshot pins the actual emitted JSON, so adding, removing or re-mapping a
|
||||
# field for any protocol shows up as a reviewable diff.
|
||||
#
|
||||
# WireGuard is absent on purpose: it is emitted as a sing-box endpoint, not an
|
||||
# outbound, and is covered by wireguard.uci.
|
||||
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option proxy_mode 'tun'
|
||||
option ipv6_support '0'
|
||||
option main_node 'n_vless_reality_ws'
|
||||
option main_udp_node 'nil'
|
||||
|
||||
config homeproxy 'infra'
|
||||
option mixed_port '5330'
|
||||
option dns_port '5333'
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option self_mark '100'
|
||||
option tun_mark '102'
|
||||
option table_mark '100'
|
||||
option ntp_server 'nil'
|
||||
|
||||
config node 'n_vless_reality_ws'
|
||||
option label 'vless-reality-ws'
|
||||
option type 'vless'
|
||||
option address 'b.example.com'
|
||||
option port '443'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option transport 'ws'
|
||||
option ws_host 'b.example.com'
|
||||
option ws_path '/ws?ed=2048'
|
||||
option websocket_early_data_header 'Sec-WebSocket-Protocol'
|
||||
option websocket_early_data '2048'
|
||||
option tls '1'
|
||||
option tls_sni 'b.example.com'
|
||||
option tls_alpn 'h2,http/1.1'
|
||||
option tls_min_version '1.2'
|
||||
option tls_max_version '1.3'
|
||||
option tls_handshake_timeout '10'
|
||||
option tls_cipher_suites 'TLS_AES_128_GCM_SHA256'
|
||||
option tls_reality '1'
|
||||
option tls_reality_public_key '4UAg690QziXeelpUJoAmXiim0gfQESSiJkNfvF-o8Uw'
|
||||
option tls_reality_short_id 'abcd'
|
||||
option tls_utls 'chrome'
|
||||
option vless_flow 'xtls-rprx-vision'
|
||||
option packet_encoding 'xudp'
|
||||
# Stale option left behind by switching this node's type from
|
||||
# shadowsocks: sing-box rejects udp_over_tcp on a vless outbound, so the
|
||||
# adapter must ignore it. See OPTION_FIELDS.vless.
|
||||
option udp_over_tcp '1'
|
||||
option udp_over_tcp_version '2'
|
||||
option multiplex '1'
|
||||
option multiplex_protocol 'h2mux'
|
||||
option multiplex_max_connections '4'
|
||||
option multiplex_min_streams '2'
|
||||
option multiplex_max_streams '8'
|
||||
option multiplex_padding '1'
|
||||
option tcp_fast_open '1'
|
||||
option tcp_multi_path '0'
|
||||
option udp_fragment '1'
|
||||
|
||||
config node 'n_vmess_http'
|
||||
option label 'vmess-http'
|
||||
option type 'vmess'
|
||||
option address 'l.example.com'
|
||||
option port '443'
|
||||
option uuid '66666666-7777-8888-9999-aaaaaaaaaaaa'
|
||||
option transport 'http'
|
||||
option http_host 'l.example.com'
|
||||
option http_path '/path'
|
||||
option http_method 'GET'
|
||||
option http_idle_timeout '30'
|
||||
option http_ping_timeout '15'
|
||||
option vmess_alterid '0'
|
||||
option vmess_encrypt 'auto'
|
||||
option vmess_global_padding '1'
|
||||
option packet_encoding 'packetaddr'
|
||||
option tls '1'
|
||||
option tls_sni 'l.example.com'
|
||||
option tls_insecure '1'
|
||||
option tls_utls 'firefox'
|
||||
|
||||
config node 'n_trojan_grpc'
|
||||
option label 'trojan-grpc'
|
||||
option type 'trojan'
|
||||
option address 'h.example.com'
|
||||
option port '443'
|
||||
option password 'trojan-pwd'
|
||||
option transport 'grpc'
|
||||
option grpc_servicename 'gs'
|
||||
option grpc_permit_without_stream '1'
|
||||
option tls '1'
|
||||
option tls_sni 'h.example.com'
|
||||
|
||||
config node 'n_hysteria'
|
||||
option label 'hysteria'
|
||||
option type 'hysteria'
|
||||
option address 'm.example.com'
|
||||
option port '443'
|
||||
option hysteria_auth_type 'string'
|
||||
option hysteria_auth_payload 'hy1-auth'
|
||||
option hysteria_up_mbps '100'
|
||||
option hysteria_down_mbps '500'
|
||||
option hysteria_obfs_type 'xplus'
|
||||
option hysteria_obfs_password 'hy1-obfs'
|
||||
option hysteria_hop_interval '30'
|
||||
option tls '1'
|
||||
option tls_sni 'm.example.com'
|
||||
|
||||
config node 'n_hy2'
|
||||
option label 'hy2'
|
||||
option type 'hysteria2'
|
||||
option address 'k.example.com'
|
||||
option port '443'
|
||||
option password 'hy2-pwd'
|
||||
option hysteria_obfs_type 'salamander'
|
||||
option hysteria_obfs_password 'hy2-obfs'
|
||||
option hysteria_up_mbps '100'
|
||||
option hysteria_down_mbps '500'
|
||||
option hysteria_bbr_profile 'conservative'
|
||||
option hysteria_hopping_port '20000:30000'
|
||||
option hysteria_hop_interval '30'
|
||||
option hysteria_hop_interval_max '60'
|
||||
option hysteria_disable_chrome_parrot '1'
|
||||
option tls '1'
|
||||
option tls_sni 'k.example.com'
|
||||
|
||||
config node 'n_tuic'
|
||||
option label 'tuic'
|
||||
option type 'tuic'
|
||||
option address 'j.example.com'
|
||||
option port '443'
|
||||
option uuid '11111111-2222-3333-4444-555555555555'
|
||||
option password 'tuic-pwd'
|
||||
option tuic_congestion_control 'cubic'
|
||||
option tuic_udp_relay_mode 'native'
|
||||
option tuic_enable_zero_rtt '1'
|
||||
option tuic_udp_over_stream '0'
|
||||
option tuic_heartbeat '3s'
|
||||
option tls '1'
|
||||
option tls_sni 'j.example.com'
|
||||
option tls_alpn 'h3'
|
||||
|
||||
config node 'n_shadowsocks'
|
||||
option label 'ss'
|
||||
option type 'shadowsocks'
|
||||
option address 'd.example.com'
|
||||
option port '8389'
|
||||
option shadowsocks_encrypt_method 'aes-256-gcm'
|
||||
option password 'ss-password'
|
||||
option shadowsocks_plugin 'obfs-local'
|
||||
option shadowsocks_plugin_opts 'obfs=http;obfs-host=d.example.com'
|
||||
option udp_over_tcp '1'
|
||||
option udp_over_tcp_version '2'
|
||||
|
||||
config node 'n_snell'
|
||||
option label 'snell'
|
||||
option type 'snell'
|
||||
option address 'c.example.com'
|
||||
option port '8388'
|
||||
option password 'snell-psk'
|
||||
option snell_version '4'
|
||||
option snell_userkey 'snell-userkey'
|
||||
option snell_reuse '1'
|
||||
option snell_obfs_mode 'http'
|
||||
option snell_obfs_host 'c.example.com'
|
||||
|
||||
config node 'n_socks'
|
||||
option label 'socks'
|
||||
option type 'socks'
|
||||
option address 'g.example.com'
|
||||
option port '1080'
|
||||
option username 'bob'
|
||||
option password 'socks-pwd'
|
||||
option socks_version '5'
|
||||
option udp_over_tcp '1'
|
||||
option udp_over_tcp_version '2'
|
||||
|
||||
config node 'n_http'
|
||||
option label 'http'
|
||||
option type 'http'
|
||||
option address 'f.example.com'
|
||||
option port '8080'
|
||||
option username 'alice'
|
||||
option password 'http-pwd'
|
||||
option tls '1'
|
||||
option tls_sni 'f.example.com'
|
||||
|
||||
config node 'n_shadowtls'
|
||||
option label 'shadowtls'
|
||||
option type 'shadowtls'
|
||||
option address 'i.example.com'
|
||||
option port '443'
|
||||
option password 'shadowtls-pwd'
|
||||
option shadowtls_version '3'
|
||||
option tls '1'
|
||||
option tls_sni 'i.example.com'
|
||||
|
||||
config node 'n_anytls'
|
||||
option label 'anytls'
|
||||
option type 'anytls'
|
||||
option address 'e.example.com'
|
||||
option port '443'
|
||||
option password 'anytls-pwd'
|
||||
option anytls_idle_session_check_interval '30'
|
||||
option anytls_idle_session_timeout '90'
|
||||
option anytls_min_idle_session '2'
|
||||
option tls '1'
|
||||
option tls_sni 'e.example.com'
|
||||
|
||||
config node 'n_ssh'
|
||||
option label 'ssh'
|
||||
option type 'ssh'
|
||||
option address 'n.example.com'
|
||||
option port '22'
|
||||
option username 'root'
|
||||
# The node form stores the private key as a DynamicList, so UCI holds one
|
||||
# list entry per line and the adapter has to re-join them into the single
|
||||
# string sing-box expects. Throwaway test key.
|
||||
list ssh_priv_key '-----BEGIN OPENSSH PRIVATE KEY-----'
|
||||
list ssh_priv_key 'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW'
|
||||
list ssh_priv_key 'QyNTUxOQAAACAY0ADv5F4CV2TlGkNg2c3z3zBfeCDjLEusnrtYyxjNpAAAAJgit4/AIreP'
|
||||
list ssh_priv_key 'wAAAAAtzc2gtZWQyNTUxOQAAACAY0ADv5F4CV2TlGkNg2c3z3zBfeCDjLEusnrtYyxjNpA'
|
||||
list ssh_priv_key 'AAAEC4rRAsuFAQRHkpIDid/Cj0n1PS6o88D3xsDoYkDUVx0xjQAO/kXgJXZOUaQ2DZzfPf'
|
||||
list ssh_priv_key 'MF94IOMsS6yeu1jLGM2kAAAADmhvbWVwcm94eS10ZXN0AQIDBAUGBw=='
|
||||
list ssh_priv_key '-----END OPENSSH PRIVATE KEY-----'
|
||||
option ssh_client_version 'SSH-2.0-OpenSSH_8.9p1'
|
||||
list ssh_host_key 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBjQAO/kXgJXZOUaQ2DZzfPfMF94IOMsS6yeu1jLGM2k homeproxy-test'
|
||||
list ssh_host_key_algo 'ssh-ed25519'
|
||||
|
||||
config node 'n_direct'
|
||||
option label 'direct'
|
||||
option type 'direct'
|
||||
option override_address '1.1.1.1'
|
||||
option override_port '443'
|
||||
option tcp_fast_open '1'
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Regression fixture for P0-5: a broken node inside an urltest group must not
|
||||
# abort the whole configuration.
|
||||
#
|
||||
# The Adapter's create() calls die() on validation problems, and the generator
|
||||
# built every urltest member through it, so ONE node missing its uuid made
|
||||
# generate_client.uc exit non-zero. init.d then had no configuration at all
|
||||
# and the service could not start - even though the rest of the group was
|
||||
# fine. urltest candidates are now pruned (with a warning) instead, while
|
||||
# nodes that routing rules actually resolve through stay fatal.
|
||||
#
|
||||
# n_broken is a vless node with no uuid on purpose; the test asserts that
|
||||
# generation succeeds and that n_broken is absent from the emitted group.
|
||||
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option proxy_mode 'tun'
|
||||
option ipv6_support '0'
|
||||
option log_level 'error'
|
||||
option main_node 'urltest'
|
||||
option main_udp_node 'nil'
|
||||
list main_urltest_nodes 'n_ok'
|
||||
list main_urltest_nodes 'n_broken'
|
||||
option dns_server 'https://cloudflare-dns.com/dns-query'
|
||||
|
||||
config homeproxy 'infra'
|
||||
option mixed_port '5330'
|
||||
option dns_port '5333'
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option self_mark '100'
|
||||
option tun_mark '102'
|
||||
option table_mark '100'
|
||||
option ntp_server 'nil'
|
||||
|
||||
config node 'n_ok'
|
||||
option label 'ok'
|
||||
option type 'vless'
|
||||
option address 'ok.example.com'
|
||||
option port '443'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option tls '1'
|
||||
option tls_sni 'ok.example.com'
|
||||
|
||||
config node 'n_broken'
|
||||
option label 'broken'
|
||||
option type 'vless'
|
||||
option address 'broken.example.com'
|
||||
option port '443'
|
||||
option tls '1'
|
||||
option tls_sni 'broken.example.com'
|
||||
@@ -0,0 +1,177 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# The same node set as client.uci (a node of every supported protocol behind
|
||||
# one urltest main node) in the *product default* proxy mode.
|
||||
#
|
||||
# client.uci uses `proxy_mode 'tun'`, which is the mode that needs no
|
||||
# routing_mark - so for as long as that was the only end-to-end fixture, the
|
||||
# mark path was never generated and a regression that deleted it (the
|
||||
# adapter's COMMON_FIELDS listed it as null and overwrote the literal) could
|
||||
# not be seen by any test. This fixture is the one that exercises
|
||||
# redirect-in/tproxy-in, the dns detours that are gated on self_mark, and the
|
||||
# per-outbound routing_mark itself.
|
||||
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option proxy_mode 'redirect_tproxy'
|
||||
option ipv6_support '0'
|
||||
option log_level 'error'
|
||||
option main_node 'urltest'
|
||||
option main_udp_node 'nil'
|
||||
option dns_server 'https://cloudflare-dns.com/dns-query'
|
||||
list main_urltest_nodes 'n_anytls'
|
||||
list main_urltest_nodes 'n_vless_reality_ws'
|
||||
list main_urltest_nodes 'n_vmess_http'
|
||||
list main_urltest_nodes 'n_trojan_grpc'
|
||||
list main_urltest_nodes 'n_ss'
|
||||
list main_urltest_nodes 'n_hy2'
|
||||
list main_urltest_nodes 'n_snell'
|
||||
list main_urltest_nodes 'n_http'
|
||||
list main_urltest_nodes 'n_socks'
|
||||
list main_urltest_nodes 'n_shadowtls'
|
||||
|
||||
config homeproxy 'infra'
|
||||
option mixed_port '5330'
|
||||
option dns_port '5333'
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option self_mark '100'
|
||||
option tun_mark '102'
|
||||
option table_mark '100'
|
||||
option ntp_server 'nil'
|
||||
|
||||
config node 'n_anytls'
|
||||
option label 'anytls'
|
||||
option type 'anytls'
|
||||
option address 'a.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option tls '1'
|
||||
option tls_sni 'a.example.com'
|
||||
option tls_insecure '0'
|
||||
option anytls_idle_session_check_interval '30'
|
||||
option anytls_idle_session_timeout '30'
|
||||
option anytls_min_idle_session '0'
|
||||
|
||||
config node 'n_vless_reality_ws'
|
||||
option label 'vless-reality-ws'
|
||||
option type 'vless'
|
||||
option address 'b.example.com'
|
||||
option port '443'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option transport 'ws'
|
||||
option ws_host 'b.example.com'
|
||||
option ws_path '/ws?ed=2048'
|
||||
option tls '1'
|
||||
option tls_sni 'b.example.com'
|
||||
option tls_reality '1'
|
||||
option tls_reality_public_key '4UAg690QziXeelpUJoAmXiim0gfQESSiJkNfvF-o8Uw'
|
||||
option tls_reality_short_id 'abcd'
|
||||
option tls_utls 'chrome'
|
||||
option vless_flow 'xtls-rprx-vision'
|
||||
option packet_encoding 'xudp'
|
||||
|
||||
config node 'n_vmess_http'
|
||||
option label 'vmess-http'
|
||||
option type 'vmess'
|
||||
option address 'c.example.com'
|
||||
option port '80'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option transport 'http'
|
||||
option http_host 'c.example.com'
|
||||
option http_path '/path'
|
||||
option http_method 'GET'
|
||||
option vmess_alterid '0'
|
||||
option vmess_encrypt 'auto'
|
||||
option vmess_global_padding '1'
|
||||
option tls '0'
|
||||
option packet_encoding 'packetaddr'
|
||||
|
||||
config node 'n_trojan_grpc'
|
||||
option label 'trojan-grpc'
|
||||
option type 'trojan'
|
||||
option address 'd.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option transport 'grpc'
|
||||
option grpc_servicename 'gs'
|
||||
option grpc_permit_without_stream '1'
|
||||
option tls '1'
|
||||
option tls_sni 'd.example.com'
|
||||
option tls_handshake_timeout '15'
|
||||
option tls_min_version '1.2'
|
||||
option tls_max_version '1.3'
|
||||
option tls_cipher_suites 'TLS_AES_128_GCM_SHA256'
|
||||
|
||||
config node 'n_ss'
|
||||
option label 'ss'
|
||||
option type 'shadowsocks'
|
||||
option address 'e.example.com'
|
||||
option port '8388'
|
||||
option shadowsocks_encrypt_method 'aes-256-gcm'
|
||||
option password 'secret'
|
||||
option multiplex '1'
|
||||
option multiplex_protocol 'h2mux'
|
||||
option multiplex_padding '1'
|
||||
option multiplex_max_connections '4'
|
||||
option udp_over_tcp '1'
|
||||
option udp_over_tcp_version '2'
|
||||
|
||||
config node 'n_hy2'
|
||||
option label 'hy2'
|
||||
option type 'hysteria2'
|
||||
option address 'f.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option tls '1'
|
||||
option tls_sni 'f.example.com'
|
||||
option hysteria_obfs_type 'salamander'
|
||||
option hysteria_obfs_password 'obfspass'
|
||||
option hysteria_up_mbps '100'
|
||||
option hysteria_down_mbps '500'
|
||||
option hysteria_hopping_port '20000:30000'
|
||||
option hysteria_hop_interval '30'
|
||||
option hysteria_bbr_profile 'conservative'
|
||||
|
||||
config node 'n_snell'
|
||||
option label 'snell'
|
||||
option type 'snell'
|
||||
option address 'g.example.com'
|
||||
option port '443'
|
||||
option password 'psk123456789012'
|
||||
option snell_version '4'
|
||||
option snell_obfs_mode 'http'
|
||||
option snell_obfs_host 'bing.com'
|
||||
option snell_reuse '1'
|
||||
option snell_userkey 'ukey'
|
||||
|
||||
config node 'n_http'
|
||||
option label 'http'
|
||||
option type 'http'
|
||||
option address 'h.example.com'
|
||||
option port '8080'
|
||||
option username 'u'
|
||||
option password 'p'
|
||||
option tls '0'
|
||||
|
||||
config node 'n_socks'
|
||||
option label 'socks'
|
||||
option type 'socks'
|
||||
option address 'i.example.com'
|
||||
option port '1080'
|
||||
option username 'u'
|
||||
option password 'p'
|
||||
option socks_version '5'
|
||||
|
||||
config node 'n_shadowtls'
|
||||
option label 'shadowtls'
|
||||
option type 'shadowtls'
|
||||
option address 'j.example.com'
|
||||
option port '443'
|
||||
option password 'secret'
|
||||
option shadowtls_version '3'
|
||||
option tls '1'
|
||||
option tls_sni 'j.example.com'
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Regression fixture for generate_server.uc: one inbound per protocol family,
|
||||
# covering the shared TLS/transport builders (ACME provider, ECH key, REALITY,
|
||||
# WebSocket/gRPC/HTTP transports) on the server side.
|
||||
|
||||
config homeproxy 'server'
|
||||
option enabled '1'
|
||||
option log_level 'warn'
|
||||
|
||||
config server 's_vless_ws'
|
||||
option enabled '1'
|
||||
option type 'vless'
|
||||
option address '10.0.0.1'
|
||||
option port '8443'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option transport 'ws'
|
||||
option ws_host 'a.example.com'
|
||||
option ws_path '/ws'
|
||||
option websocket_early_data '2048'
|
||||
option tls '1'
|
||||
option tls_sni 'a.example.com'
|
||||
option tls_acme '1'
|
||||
option tls_acme_domain 'a.example.com'
|
||||
option tls_acme_provider 'letsencrypt'
|
||||
option tls_acme_email 'admin@example.com'
|
||||
option tls_acme_dsn 'a.example.com'
|
||||
option tls_acme_key_type 'p256'
|
||||
option tls_alpn 'h2'
|
||||
option tls_min_version '1.2'
|
||||
|
||||
config server 's_anytls'
|
||||
option enabled '1'
|
||||
option type 'anytls'
|
||||
option address '10.0.0.1'
|
||||
option port '8444'
|
||||
option password 'secret'
|
||||
option anytls_padding_scheme 'stop=8'
|
||||
option tls '1'
|
||||
option tls_sni 'b.example.com'
|
||||
option tls_acme '1'
|
||||
option tls_acme_domain 'b.example.com'
|
||||
option tls_acme_provider 'zerossl'
|
||||
option tls_acme_email 'admin@example.com'
|
||||
option tls_acme_dsn 'b.example.com'
|
||||
|
||||
config server 's_hy2'
|
||||
option enabled '1'
|
||||
option type 'hysteria2'
|
||||
option address '10.0.0.1'
|
||||
option port '8445'
|
||||
option password 'secret'
|
||||
option hysteria_up_mbps '100'
|
||||
option hysteria_down_mbps '500'
|
||||
option hysteria_obfs_type 'salamander'
|
||||
option hysteria_obfs_password 'obfspass'
|
||||
option hysteria_masquerade 'https://example.com'
|
||||
option tls '1'
|
||||
option tls_sni 'c.example.com'
|
||||
option tls_acme '1'
|
||||
option tls_acme_domain 'c.example.com'
|
||||
option tls_acme_provider 'letsencrypt'
|
||||
option tls_acme_email 'admin@example.com'
|
||||
option tls_acme_dsn 'c.example.com'
|
||||
|
||||
config server 's_trojan_grpc'
|
||||
option enabled '1'
|
||||
option type 'trojan'
|
||||
option address '10.0.0.1'
|
||||
option port '8446'
|
||||
option password 'secret'
|
||||
option transport 'grpc'
|
||||
option grpc_servicename 'gs'
|
||||
option multiplex '1'
|
||||
option multiplex_padding '1'
|
||||
option tls '1'
|
||||
option tls_sni 'd.example.com'
|
||||
option tls_acme '1'
|
||||
option tls_acme_domain 'd.example.com'
|
||||
option tls_acme_provider 'letsencrypt'
|
||||
option tls_acme_email 'admin@example.com'
|
||||
option tls_acme_dsn 'd.example.com'
|
||||
|
||||
config server 's_ss'
|
||||
option enabled '1'
|
||||
option type 'shadowsocks'
|
||||
option address '10.0.0.1'
|
||||
option port '8447'
|
||||
option network 'tcp'
|
||||
option shadowsocks_encrypt_method 'aes-256-gcm'
|
||||
option password 'secret'
|
||||
option udp_timeout '300'
|
||||
|
||||
config server 's_snell'
|
||||
option enabled '1'
|
||||
option type 'snell'
|
||||
option port '8448'
|
||||
option password 'psk123456789012'
|
||||
option snell_version '5'
|
||||
option snell_obfs_mode 'http'
|
||||
|
||||
config server 's_tuic'
|
||||
option enabled '1'
|
||||
option type 'tuic'
|
||||
option address '10.0.0.1'
|
||||
option port '8449'
|
||||
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
|
||||
option password 'secret'
|
||||
option tuic_congestion_control 'bbr'
|
||||
option tuic_auth_timeout '3'
|
||||
option tuic_heartbeat '10'
|
||||
option tls '1'
|
||||
option tls_sni 'e.example.com'
|
||||
option tls_acme '1'
|
||||
option tls_acme_domain 'e.example.com'
|
||||
option tls_acme_provider 'letsencrypt'
|
||||
option tls_acme_email 'admin@example.com'
|
||||
option tls_acme_dsn 'e.example.com'
|
||||
@@ -0,0 +1,50 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Regression fixture for the WireGuard endpoint path.
|
||||
#
|
||||
# WireGuard is the one protocol generate_client.uc emits as a sing-box
|
||||
# *endpoint* instead of an outbound, through generate_endpoint(). A2/A3
|
||||
# converted every call site to pass a Node but left that function reading the
|
||||
# flat UCI option names (node.wireguard_*, node.tcp_fast_open, ...), which no
|
||||
# Node carries - so the endpoint came out with a null private key and a peer
|
||||
# with no public key, and `sing-box check` rejected the whole configuration.
|
||||
# The unit test asserts the key material survives, not just that sing-box
|
||||
# accepts the file.
|
||||
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option proxy_mode 'tun'
|
||||
option ipv6_support '0'
|
||||
option log_level 'error'
|
||||
option main_node 'n_wg'
|
||||
option main_udp_node 'nil'
|
||||
option dns_server 'https://cloudflare-dns.com/dns-query'
|
||||
|
||||
config homeproxy 'infra'
|
||||
option mixed_port '5330'
|
||||
option dns_port '5333'
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option self_mark '100'
|
||||
option tun_mark '102'
|
||||
option table_mark '100'
|
||||
option ntp_server 'nil'
|
||||
|
||||
config node 'n_wg'
|
||||
option label 'wireguard'
|
||||
option type 'wireguard'
|
||||
option address 'wg.example.com'
|
||||
option port '51820'
|
||||
option wireguard_private_key 'iKaNuoWRQTFPD5V3OoMNdMshsMgU9t7rolJNpgNx+UM='
|
||||
option wireguard_peer_public_key 'DDcdTHUv0Q6XYDf9l93jzwwuoY/G1TC+g74QH0A9HmM='
|
||||
list wireguard_local_address '172.16.0.2/32'
|
||||
list wireguard_local_address 'fd00::2/128'
|
||||
option wireguard_mtu '1420'
|
||||
option wireguard_persistent_keepalive_interval '25'
|
||||
list wireguard_reserved '1'
|
||||
list wireguard_reserved '2'
|
||||
list wireguard_reserved '3'
|
||||
option tcp_fast_open '1'
|
||||
option udp_fragment '1'
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,578 @@
|
||||
|
||||
===== scenario: A-tun-bypass-client-only (start) =====
|
||||
uci -q show dhcp.@dnsmasq[0]
|
||||
config_load homeproxy
|
||||
sing-box version -n
|
||||
uname -r
|
||||
ip route show default
|
||||
ip -6 route show default
|
||||
ifstatus wan
|
||||
ucode -S etc/homeproxy/scripts/generate_client.uc
|
||||
log Warning: failed to restart dnsmasq, DNS-based routing may be stale.
|
||||
ip tuntap add mode tun user root name singtun0
|
||||
sleep 1s
|
||||
ip link set singtun0 up
|
||||
ip route replace default dev singtun0 table 100
|
||||
ip rule add fwmark 102 lookup 100
|
||||
procd_open_instance sing-box-c
|
||||
procd_set_param command /usr/bin/sing-box
|
||||
procd_append_param command run --config var/run/homeproxy/sing-box-c.json
|
||||
procd_add_jail sing-box-c log procfs
|
||||
procd_add_jail_mount var/run/homeproxy/sing-box-c.json
|
||||
procd_add_jail_mount_rw var/run/homeproxy/sing-box-c.log
|
||||
procd_add_jail_mount_rw etc/homeproxy/cache.db
|
||||
procd_add_jail_mount etc/homeproxy/certs/
|
||||
procd_add_jail_mount /etc/ssl/
|
||||
procd_add_jail_mount /etc/localtime
|
||||
procd_add_jail_mount /etc/TZ
|
||||
procd_set_param capabilities /etc/capabilities/homeproxy.json
|
||||
procd_set_param no_new_privs 1
|
||||
procd_set_param user sing-box
|
||||
procd_set_param group sing-box
|
||||
procd_set_param limits core=unlimited
|
||||
procd_set_param limits nofile=1000000 1000000
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
procd_open_instance log-cleaner
|
||||
procd_set_param command etc/homeproxy/scripts/clean_log.sh
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
chown sing-box:sing-box var/run/homeproxy/sing-box-c.json var/run/homeproxy/sing-box-c.log etc/homeproxy/cache.db
|
||||
ucode etc/homeproxy/scripts/firewall_pre.uc
|
||||
utpl -S etc/homeproxy/scripts/firewall_post.ut
|
||||
fw4 reload
|
||||
sing-box version -n
|
||||
log sing-box 1.14.0 started.
|
||||
start rc=127
|
||||
log Warning: failed to drop the auto-update cron entry.
|
||||
log Warning: failed to restart cron.
|
||||
config_load homeproxy
|
||||
ip rule del fwmark 101 table 100
|
||||
ip route del local 0.0.0.0/0 dev lo table 100
|
||||
ip -6 rule del fwmark 101 table 100
|
||||
ip -6 route del local ::/0 dev lo table 100
|
||||
ip route del default dev singtun0 table 100
|
||||
ip rule del fwmark 102 table 100
|
||||
ip -6 route del default dev singtun0 table 100
|
||||
ip -6 rule del fwmark 102 table 100
|
||||
nft flush chain inet fw4 homeproxy_output_redir
|
||||
nft delete chain inet fw4 homeproxy_output_redir
|
||||
nft flush chain inet fw4 homeproxy_redirect
|
||||
nft delete chain inet fw4 homeproxy_redirect
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft flush chain inet fw4 homeproxy_redirect_lanac
|
||||
nft delete chain inet fw4 homeproxy_redirect_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft delete chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft flush chain inet fw4 homeproxy_mangle_output
|
||||
nft delete chain inet fw4 homeproxy_mangle_output
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft flush chain inet fw4 homeproxy_mangle_lanac
|
||||
nft delete chain inet fw4 homeproxy_mangle_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_mark
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft flush set inet fw4 homeproxy_local_addr_v4
|
||||
nft delete set inet fw4 homeproxy_local_addr_v4
|
||||
nft flush set inet fw4 homeproxy_local_addr_v6
|
||||
nft delete set inet fw4 homeproxy_local_addr_v6
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v4
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v4
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v6
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v6
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft flush set inet fw4 homeproxy_routing_port
|
||||
nft delete set inet fw4 homeproxy_routing_port
|
||||
fw4 reload
|
||||
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
|
||||
log Service stopped.
|
||||
config_load homeproxy
|
||||
ip link set singtun0 down
|
||||
ip tuntap del mode tun name singtun0
|
||||
stop rc=0
|
||||
file var/run/homeproxy/fw4_forward.nft:
|
||||
file var/run/homeproxy/fw4_input.nft:
|
||||
file var/run/homeproxy/fw4_post.nft:
|
||||
file var/run/homeproxy/homeproxy.log:
|
||||
file var/run/homeproxy/known-good/sing-box-c.json: {"log":{},"inbounds":[{"tag":"dns-in","listen_port":5333},{"tag":"mixed-in","listen_port":5330}],"outbounds":[]}|
|
||||
file etc/homeproxy/cache.db:
|
||||
|
||||
===== scenario: B-tproxy-gfwlist-ipv6-with-server (start) =====
|
||||
uci -q show dhcp.@dnsmasq[0]
|
||||
config_load homeproxy
|
||||
sing-box version -n
|
||||
uname -r
|
||||
ip route show default
|
||||
ip -6 route show default
|
||||
ifstatus wan
|
||||
ucode -S etc/homeproxy/scripts/generate_client.uc
|
||||
log Warning: failed to drop the previous auto-update cron entry.
|
||||
log Warning: failed to install the auto-update cron entry.
|
||||
log Warning: failed to restart cron.
|
||||
log Warning: failed to restart dnsmasq, DNS-based routing may be stale.
|
||||
ip rule add fwmark 101 table 100
|
||||
ip route add local 0.0.0.0/0 dev lo table 100
|
||||
ip -6 rule add fwmark 101 table 100
|
||||
ip -6 route add local ::/0 dev lo table 100
|
||||
procd_open_instance sing-box-c
|
||||
procd_set_param command /usr/bin/sing-box
|
||||
procd_append_param command run --config var/run/homeproxy/sing-box-c.json
|
||||
procd_add_jail sing-box-c log procfs
|
||||
procd_add_jail_mount var/run/homeproxy/sing-box-c.json
|
||||
procd_add_jail_mount_rw var/run/homeproxy/sing-box-c.log
|
||||
procd_add_jail_mount etc/homeproxy/certs/
|
||||
procd_add_jail_mount /etc/ssl/
|
||||
procd_add_jail_mount /etc/localtime
|
||||
procd_add_jail_mount /etc/TZ
|
||||
procd_set_param capabilities /etc/capabilities/homeproxy.json
|
||||
procd_set_param no_new_privs 1
|
||||
procd_set_param user sing-box
|
||||
procd_set_param group sing-box
|
||||
procd_set_param limits core=unlimited
|
||||
procd_set_param limits nofile=1000000 1000000
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
procd_open_instance log-cleaner
|
||||
procd_set_param command etc/homeproxy/scripts/clean_log.sh
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
chown sing-box:sing-box var/run/homeproxy/sing-box-c.json var/run/homeproxy/sing-box-c.log etc/homeproxy/cache.db
|
||||
ucode etc/homeproxy/scripts/firewall_pre.uc
|
||||
utpl -S etc/homeproxy/scripts/firewall_post.ut
|
||||
fw4 reload
|
||||
sing-box version -n
|
||||
log sing-box 1.14.0 started.
|
||||
start rc=127
|
||||
log Warning: failed to drop the auto-update cron entry.
|
||||
log Warning: failed to restart cron.
|
||||
config_load homeproxy
|
||||
ip rule del fwmark 101 table 100
|
||||
ip route del local 0.0.0.0/0 dev lo table 100
|
||||
ip -6 rule del fwmark 101 table 100
|
||||
ip -6 route del local ::/0 dev lo table 100
|
||||
ip route del default dev singtun0 table 100
|
||||
ip rule del fwmark 102 table 100
|
||||
ip -6 route del default dev singtun0 table 100
|
||||
ip -6 rule del fwmark 102 table 100
|
||||
nft flush chain inet fw4 homeproxy_output_redir
|
||||
nft delete chain inet fw4 homeproxy_output_redir
|
||||
nft flush chain inet fw4 homeproxy_redirect
|
||||
nft delete chain inet fw4 homeproxy_redirect
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft flush chain inet fw4 homeproxy_redirect_lanac
|
||||
nft delete chain inet fw4 homeproxy_redirect_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft delete chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft flush chain inet fw4 homeproxy_mangle_output
|
||||
nft delete chain inet fw4 homeproxy_mangle_output
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft flush chain inet fw4 homeproxy_mangle_lanac
|
||||
nft delete chain inet fw4 homeproxy_mangle_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_mark
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft flush set inet fw4 homeproxy_local_addr_v4
|
||||
nft delete set inet fw4 homeproxy_local_addr_v4
|
||||
nft flush set inet fw4 homeproxy_local_addr_v6
|
||||
nft delete set inet fw4 homeproxy_local_addr_v6
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v4
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v4
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v6
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v6
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft flush set inet fw4 homeproxy_routing_port
|
||||
nft delete set inet fw4 homeproxy_routing_port
|
||||
fw4 reload
|
||||
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
|
||||
log Service stopped.
|
||||
config_load homeproxy
|
||||
ip link set singtun0 down
|
||||
ip tuntap del mode tun name singtun0
|
||||
stop rc=0
|
||||
file var/run/homeproxy/fw4_forward.nft:
|
||||
file var/run/homeproxy/fw4_input.nft:
|
||||
file var/run/homeproxy/fw4_post.nft:
|
||||
file var/run/homeproxy/homeproxy.log:
|
||||
file var/run/homeproxy/known-good/sing-box-c.json: {"log":{},"inbounds":[{"tag":"dns-in","listen_port":5333},{"tag":"mixed-in","listen_port":5330}],"outbounds":[]}|
|
||||
|
||||
===== scenario: C-nothing-configured (start) =====
|
||||
uci -q show dhcp.@dnsmasq[0]
|
||||
config_load homeproxy
|
||||
sing-box version -n
|
||||
log Neither client nor server is configured. Service not started.
|
||||
start rc=127
|
||||
log Warning: failed to drop the auto-update cron entry.
|
||||
log Warning: failed to restart cron.
|
||||
config_load homeproxy
|
||||
ip rule del fwmark 101 table 100
|
||||
ip route del local 0.0.0.0/0 dev lo table 100
|
||||
ip -6 rule del fwmark 101 table 100
|
||||
ip -6 route del local ::/0 dev lo table 100
|
||||
ip route del default dev singtun0 table 100
|
||||
ip rule del fwmark 102 table 100
|
||||
ip -6 route del default dev singtun0 table 100
|
||||
ip -6 rule del fwmark 102 table 100
|
||||
nft flush chain inet fw4 homeproxy_output_redir
|
||||
nft delete chain inet fw4 homeproxy_output_redir
|
||||
nft flush chain inet fw4 homeproxy_redirect
|
||||
nft delete chain inet fw4 homeproxy_redirect
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft flush chain inet fw4 homeproxy_redirect_lanac
|
||||
nft delete chain inet fw4 homeproxy_redirect_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft delete chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft flush chain inet fw4 homeproxy_mangle_output
|
||||
nft delete chain inet fw4 homeproxy_mangle_output
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft flush chain inet fw4 homeproxy_mangle_lanac
|
||||
nft delete chain inet fw4 homeproxy_mangle_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_mark
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft flush set inet fw4 homeproxy_local_addr_v4
|
||||
nft delete set inet fw4 homeproxy_local_addr_v4
|
||||
nft flush set inet fw4 homeproxy_local_addr_v6
|
||||
nft delete set inet fw4 homeproxy_local_addr_v6
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v4
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v4
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v6
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v6
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft flush set inet fw4 homeproxy_routing_port
|
||||
nft delete set inet fw4 homeproxy_routing_port
|
||||
fw4 reload
|
||||
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
|
||||
log Service stopped.
|
||||
config_load homeproxy
|
||||
ip link set singtun0 down
|
||||
ip tuntap del mode tun name singtun0
|
||||
stop rc=0
|
||||
file var/run/homeproxy/fw4_forward.nft:
|
||||
file var/run/homeproxy/fw4_input.nft:
|
||||
file var/run/homeproxy/fw4_post.nft:
|
||||
|
||||
===== scenario: D-health-gate-rollback (reload) =====
|
||||
uci -q show dhcp.@dnsmasq[0]
|
||||
config_load homeproxy
|
||||
sing-box version -n
|
||||
uname -r
|
||||
ip route show default
|
||||
ip -6 route show default
|
||||
ifstatus wan
|
||||
ucode -S etc/homeproxy/scripts/generate_client.uc
|
||||
log Warning: failed to restart dnsmasq, DNS-based routing may be stale.
|
||||
ip tuntap add mode tun user root name singtun0
|
||||
sleep 1s
|
||||
ip link set singtun0 up
|
||||
ip route replace default dev singtun0 table 100
|
||||
ip rule add fwmark 102 lookup 100
|
||||
procd_open_instance sing-box-c
|
||||
procd_set_param command /usr/bin/sing-box
|
||||
procd_append_param command run --config var/run/homeproxy/sing-box-c.json
|
||||
procd_add_jail sing-box-c log procfs
|
||||
procd_add_jail_mount var/run/homeproxy/sing-box-c.json
|
||||
procd_add_jail_mount_rw var/run/homeproxy/sing-box-c.log
|
||||
procd_add_jail_mount_rw etc/homeproxy/cache.db
|
||||
procd_add_jail_mount etc/homeproxy/certs/
|
||||
procd_add_jail_mount /etc/ssl/
|
||||
procd_add_jail_mount /etc/localtime
|
||||
procd_add_jail_mount /etc/TZ
|
||||
procd_set_param capabilities /etc/capabilities/homeproxy.json
|
||||
procd_set_param no_new_privs 1
|
||||
procd_set_param user sing-box
|
||||
procd_set_param group sing-box
|
||||
procd_set_param limits core=unlimited
|
||||
procd_set_param limits nofile=1000000 1000000
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
procd_open_instance log-cleaner
|
||||
procd_set_param command etc/homeproxy/scripts/clean_log.sh
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
chown sing-box:sing-box var/run/homeproxy/sing-box-c.json var/run/homeproxy/sing-box-c.log etc/homeproxy/cache.db
|
||||
ucode etc/homeproxy/scripts/firewall_pre.uc
|
||||
utpl -S etc/homeproxy/scripts/firewall_post.ut
|
||||
fw4 reload
|
||||
sing-box version -n
|
||||
log sing-box 1.14.0 started.
|
||||
prime start rc=127
|
||||
log Reloading service...
|
||||
config_load homeproxy
|
||||
ucode -S etc/homeproxy/scripts/generate_client.uc
|
||||
log Warning: failed to drop the auto-update cron entry.
|
||||
log Warning: failed to restart cron.
|
||||
config_load homeproxy
|
||||
ip rule del fwmark 101 table 100
|
||||
ip route del local 0.0.0.0/0 dev lo table 100
|
||||
ip -6 rule del fwmark 101 table 100
|
||||
ip -6 route del local ::/0 dev lo table 100
|
||||
ip route del default dev singtun0 table 100
|
||||
ip rule del fwmark 102 table 100
|
||||
ip -6 route del default dev singtun0 table 100
|
||||
ip -6 rule del fwmark 102 table 100
|
||||
nft flush chain inet fw4 homeproxy_output_redir
|
||||
nft delete chain inet fw4 homeproxy_output_redir
|
||||
nft flush chain inet fw4 homeproxy_redirect
|
||||
nft delete chain inet fw4 homeproxy_redirect
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft flush chain inet fw4 homeproxy_redirect_lanac
|
||||
nft delete chain inet fw4 homeproxy_redirect_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft delete chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft flush chain inet fw4 homeproxy_mangle_output
|
||||
nft delete chain inet fw4 homeproxy_mangle_output
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft flush chain inet fw4 homeproxy_mangle_lanac
|
||||
nft delete chain inet fw4 homeproxy_mangle_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_mark
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft flush set inet fw4 homeproxy_local_addr_v4
|
||||
nft delete set inet fw4 homeproxy_local_addr_v4
|
||||
nft flush set inet fw4 homeproxy_local_addr_v6
|
||||
nft delete set inet fw4 homeproxy_local_addr_v6
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v4
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v4
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v6
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v6
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft flush set inet fw4 homeproxy_routing_port
|
||||
nft delete set inet fw4 homeproxy_routing_port
|
||||
fw4 reload
|
||||
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
|
||||
log Service stopped.
|
||||
config_load homeproxy
|
||||
ip link set singtun0 down
|
||||
ip tuntap del mode tun name singtun0
|
||||
config_load homeproxy
|
||||
sing-box version -n
|
||||
uname -r
|
||||
ip route show default
|
||||
ip -6 route show default
|
||||
ifstatus wan
|
||||
ucode -S etc/homeproxy/scripts/generate_client.uc
|
||||
log Warning: failed to restart dnsmasq, DNS-based routing may be stale.
|
||||
ip tuntap add mode tun user root name singtun0
|
||||
sleep 1s
|
||||
ip link set singtun0 up
|
||||
ip route replace default dev singtun0 table 100
|
||||
ip rule add fwmark 102 lookup 100
|
||||
procd_open_instance sing-box-c
|
||||
procd_set_param command /usr/bin/sing-box
|
||||
procd_append_param command run --config var/run/homeproxy/sing-box-c.json
|
||||
procd_add_jail sing-box-c log procfs
|
||||
procd_add_jail_mount var/run/homeproxy/sing-box-c.json
|
||||
procd_add_jail_mount_rw var/run/homeproxy/sing-box-c.log
|
||||
procd_add_jail_mount_rw etc/homeproxy/cache.db
|
||||
procd_add_jail_mount etc/homeproxy/certs/
|
||||
procd_add_jail_mount /etc/ssl/
|
||||
procd_add_jail_mount /etc/localtime
|
||||
procd_add_jail_mount /etc/TZ
|
||||
procd_set_param capabilities /etc/capabilities/homeproxy.json
|
||||
procd_set_param no_new_privs 1
|
||||
procd_set_param user sing-box
|
||||
procd_set_param group sing-box
|
||||
procd_set_param limits core=unlimited
|
||||
procd_set_param limits nofile=1000000 1000000
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
procd_open_instance log-cleaner
|
||||
procd_set_param command etc/homeproxy/scripts/clean_log.sh
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
chown sing-box:sing-box var/run/homeproxy/sing-box-c.json var/run/homeproxy/sing-box-c.log etc/homeproxy/cache.db
|
||||
ucode etc/homeproxy/scripts/firewall_pre.uc
|
||||
utpl -S etc/homeproxy/scripts/firewall_post.ut
|
||||
fw4 reload
|
||||
sing-box version -n
|
||||
log sing-box 1.14.0 started.
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
jsonfilter -e @['homeproxy'].instances['sing-box-c']
|
||||
jsonfilter -e @.running
|
||||
sleep 1
|
||||
log Error: sing-box-c did not come up with the new configuration, rolling back.
|
||||
log Error: the failing configuration is already the known-good one; nothing to roll back to.
|
||||
log Error: rollback was not possible; the service may be down. Check var/run/homeproxy/homeproxy.log.
|
||||
reload_service rc=1
|
||||
log Warning: failed to drop the auto-update cron entry.
|
||||
log Warning: failed to restart cron.
|
||||
config_load homeproxy
|
||||
ip rule del fwmark 101 table 100
|
||||
ip route del local 0.0.0.0/0 dev lo table 100
|
||||
ip -6 rule del fwmark 101 table 100
|
||||
ip -6 route del local ::/0 dev lo table 100
|
||||
ip route del default dev singtun0 table 100
|
||||
ip rule del fwmark 102 table 100
|
||||
ip -6 route del default dev singtun0 table 100
|
||||
ip -6 rule del fwmark 102 table 100
|
||||
nft flush chain inet fw4 homeproxy_output_redir
|
||||
nft delete chain inet fw4 homeproxy_output_redir
|
||||
nft flush chain inet fw4 homeproxy_redirect
|
||||
nft delete chain inet fw4 homeproxy_redirect
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy
|
||||
nft flush chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft delete chain inet fw4 homeproxy_redirect_proxy_port
|
||||
nft flush chain inet fw4 homeproxy_redirect_lanac
|
||||
nft delete chain inet fw4 homeproxy_redirect_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft delete chain inet fw4 homeproxy_mangle_prerouting
|
||||
nft flush chain inet fw4 homeproxy_mangle_output
|
||||
nft delete chain inet fw4 homeproxy_mangle_output
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy
|
||||
nft flush chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft delete chain inet fw4 homeproxy_mangle_tproxy_port
|
||||
nft flush chain inet fw4 homeproxy_mangle_lanac
|
||||
nft delete chain inet fw4 homeproxy_mangle_lanac
|
||||
nft flush chain inet fw4 homeproxy_mangle_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_mark
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun
|
||||
nft flush chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft delete chain inet fw4 homeproxy_mangle_tun_mark
|
||||
nft flush set inet fw4 homeproxy_local_addr_v4
|
||||
nft delete set inet fw4 homeproxy_local_addr_v4
|
||||
nft flush set inet fw4 homeproxy_local_addr_v6
|
||||
nft delete set inet fw4 homeproxy_local_addr_v6
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v4
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v4
|
||||
nft flush set inet fw4 homeproxy_gfw_list_v6
|
||||
nft delete set inet fw4 homeproxy_gfw_list_v6
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v4
|
||||
nft flush set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft delete set inet fw4 homeproxy_mainland_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_proxy_addr_v6
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v4
|
||||
nft flush set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft delete set inet fw4 homeproxy_wan_direct_addr_v6
|
||||
nft flush set inet fw4 homeproxy_routing_port
|
||||
nft delete set inet fw4 homeproxy_routing_port
|
||||
fw4 reload
|
||||
log Warning: failed to restart dnsmasq after removing the homeproxy snippets.
|
||||
log Service stopped.
|
||||
config_load homeproxy
|
||||
ip link set singtun0 down
|
||||
ip tuntap del mode tun name singtun0
|
||||
stop rc=0
|
||||
file var/run/homeproxy/fw4_forward.nft:
|
||||
file var/run/homeproxy/fw4_input.nft:
|
||||
file var/run/homeproxy/fw4_post.nft:
|
||||
file var/run/homeproxy/homeproxy.log:
|
||||
file var/run/homeproxy/known-good/sing-box-c.json: {"log":{},"inbounds":[{"tag":"dns-in","listen_port":5333},{"tag":"mixed-in","listen_port":5399}],"outbounds":[]}|
|
||||
file etc/homeproxy/cache.db:
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* The certificate upload buttons: which staging path do they actually write?
|
||||
*
|
||||
* `hp.uploadCertificate` derives its staging path from the *second* argument
|
||||
* the call site binds - the UCI option name:
|
||||
*
|
||||
* const tmpPath = '/tmp/homeproxy_cert_' + filename + '.tmp';
|
||||
*
|
||||
* L.bind() prepends the bound arguments, and LuCI's form.Button calls
|
||||
* `onclick(ev, section_id)` (form.js renderWidget), so the event lands on the
|
||||
* handler's *first* parameter. A handler that declares a leading parameter no
|
||||
* caller passes therefore shifts everything by one: filename becomes the event
|
||||
* object, the path becomes /tmp/homeproxy_cert_[object Event].tmp, and both the
|
||||
* ACL whitelist (acl.d/luci-app-homeproxy.json) and certificate_write
|
||||
* (luci.homeproxy, which reads /tmp/homeproxy_cert_${filename}.tmp) stop
|
||||
* matching. Every upload fails and the UI only shows a generic error.
|
||||
*
|
||||
* The unit under test is the *argument alignment*, not the handler body, so
|
||||
* this test wires each real call site's arguments exactly as the view does and
|
||||
* checks the path and the RPC filename that come out.
|
||||
*
|
||||
* Usage: node tests/frontend-certificate-upload.js <repo-root>
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadLuciModule } = require('./lib/luci-module.js');
|
||||
|
||||
const root = path.resolve(process.argv[2] || '.');
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function check(what, ok, detail) {
|
||||
checks++;
|
||||
if (ok) return;
|
||||
failures++;
|
||||
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
||||
}
|
||||
|
||||
/* --- the call sites, read from the views --------------------------------- *
|
||||
* Parsed rather than hardcoded so a renamed button, a changed UCI option name
|
||||
* or a different binding shape is exercised as it is today. */
|
||||
const VIEWS = [ 'node.js', 'server.js' ];
|
||||
const calls = [];
|
||||
for (const view of VIEWS) {
|
||||
const file = path.join(root, 'htdocs/luci-static/resources/view/homeproxy', view);
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
const re = /L\.bind\(hp\.uploadCertificate[^;]*\)/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null)
|
||||
calls.push({ view, text: m[0] });
|
||||
}
|
||||
|
||||
check('the certificate upload buttons were found', calls.length === 4,
|
||||
`expected 4 L.bind(hp.uploadCertificate ...) call sites, found ${calls.length}`);
|
||||
|
||||
/* Turn `L.bind(hp.uploadCertificate, hp, _('certificate'), 'client_ca')` into
|
||||
* ['certificate', 'client_ca']: the arguments the view binds, in order. */
|
||||
function boundArgs(text) {
|
||||
const inner = text.replace(/^L\.bind\(hp\.uploadCertificate\s*,\s*hp\s*,?/, '').replace(/\)$/, '');
|
||||
const args = [];
|
||||
const re = /_\(\s*'([^']*)'\s*\)|'([^']*)'|"([^"]*)"|([A-Za-z_$][\w$]*)|(\d+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(inner)) !== null) {
|
||||
if (m[1] !== undefined || m[2] !== undefined || m[3] !== undefined)
|
||||
args.push(m[1] ?? m[2] ?? m[3]);
|
||||
else
|
||||
args.push(m[4] !== undefined ? { identifier: m[4] } : Number(m[5]));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const parsed = calls.map((c) => ({ ...c, args: boundArgs(c.text) }));
|
||||
|
||||
/* --- load homeproxy.js the way LuCI does ---------------------------------- */
|
||||
const uploads = [];
|
||||
const rpcCalls = [];
|
||||
|
||||
const L = {
|
||||
bind(fn, self, ...bound) {
|
||||
return fn.bind(self, ...bound);
|
||||
}
|
||||
};
|
||||
|
||||
/* The recording ui stub goes in the deps record: that is the object the
|
||||
* module's own `'require ui'` resolves to (the loader's runtime argument only
|
||||
* supplies the _ / E / L globals). */
|
||||
const uiStub = {
|
||||
addNotification: () => {},
|
||||
showModal: () => {},
|
||||
hideModal: () => {},
|
||||
uploadFile: (target, fileEl) => {
|
||||
uploads.push({ target, fileEl });
|
||||
return Promise.resolve({ size: 1 });
|
||||
},
|
||||
createHandlerFn: (self, fn) => fn
|
||||
};
|
||||
|
||||
const hp = loadLuciModule(
|
||||
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
|
||||
{
|
||||
baseclass: { extend: (o) => o },
|
||||
form: { DynamicList: { extend: (o) => o } },
|
||||
fs: {}, rpc: {}, uci: {}, ui: uiStub
|
||||
},
|
||||
{ L, E: () => null }
|
||||
);
|
||||
|
||||
check('homeproxy.js exposes uploadCertificate', typeof hp.uploadCertificate === 'function',
|
||||
typeof hp.uploadCertificate);
|
||||
|
||||
/* --- drive each call site the way form.js does ---------------------------- */
|
||||
async function run() {
|
||||
for (const call of parsed) {
|
||||
const label = `${call.view}: ${call.text}`;
|
||||
const handler = L.bind(hp.uploadCertificate, Object.assign({}, hp, {
|
||||
rpcCall: (method, params) => {
|
||||
rpcCalls.push({ method, params });
|
||||
return Promise.resolve({ result: true });
|
||||
}
|
||||
}), ...call.args);
|
||||
|
||||
uploads.length = 0;
|
||||
rpcCalls.length = 0;
|
||||
|
||||
/* form.js: ui.createHandlerFn(this, (section_id, ev) => this.onclick(ev, section_id), section_id)
|
||||
* L.bind prepends, so the handler is invoked with (event, section_id). */
|
||||
const fakeFile = { name: 'cert.pem' };
|
||||
const ev = { target: { files: [ fakeFile ], value: 'C:\\fakepath\\cert.pem' } };
|
||||
await handler(ev, 'cfg01');
|
||||
|
||||
const want = call.args[1];
|
||||
const got = uploads.length === 1 ? uploads[0].target : null;
|
||||
check(`${label}: uploads to /tmp/homeproxy_cert_${want}.tmp`,
|
||||
got === `/tmp/homeproxy_cert_${want}.tmp`, String(got));
|
||||
|
||||
const rpc = rpcCalls.length === 1 ? rpcCalls[0] : null;
|
||||
check(`${label}: certificate_write receives the same name`,
|
||||
rpc && rpc.method === 'certificate_write' && rpc.params && rpc.params[0] === want,
|
||||
rpc ? JSON.stringify(rpc) : 'no rpcCall');
|
||||
}
|
||||
}
|
||||
|
||||
run().then(() => {
|
||||
console.log(`${checks - failures}/${checks} checks passed`);
|
||||
if (failures) {
|
||||
console.error('FRONTEND CERTIFICATE UPLOAD TEST FAILED');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('FRONTEND CERTIFICATE UPLOAD TEST PASSED');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* homeproxy.js calcStringMD5() must be *the* MD5 of RFC 1321, because its
|
||||
* output is compared against a value another language computed.
|
||||
*
|
||||
* The contract, end to end:
|
||||
*
|
||||
* update_subscriptions.uc url = replace(url, /#.*$/, ''); md5(url)
|
||||
* -> written to UCI as the node's `grouphash`
|
||||
* repository.uc md5(node.grouphash + label)
|
||||
* -> the UCI section name of a subscription node
|
||||
* view/homeproxy/node.js hp.calcStringMD5(suburl.replace(/#.*$/, ''))
|
||||
* -> matched against `grouphash` to decide which
|
||||
* subscription tab a section belongs to, and
|
||||
* whether it is a subscription node at all
|
||||
*
|
||||
* So the browser's digest and ucode's `digest.md5()` have to agree byte for
|
||||
* byte. Nothing verified that before this file: the suite had no MD5 vector
|
||||
* anywhere, and the implementation it was checking was a minified snippet that
|
||||
* did two non-standard things - it normalised CRLF to LF before hashing, and
|
||||
* it encoded UTF-8 one UTF-16 code unit at a time, so any astral character
|
||||
* (emoji) produced invalid UTF-8. Either one silently mismatches ucode, and
|
||||
* the failure mode is quiet: the subscription's nodes stop matching
|
||||
* `grouphash`, so they fall out of their tab and reappear as user nodes
|
||||
* instead of erroring.
|
||||
*
|
||||
* Why this is hand-rolled at all is explained at the method. Briefly:
|
||||
* crypto.subtle.digest() has no MD5 (the Web Crypto spec lists only SHA-1,
|
||||
* SHA-256, SHA-384, SHA-512) and the algorithm cannot be changed without
|
||||
* invalidating every existing install's `grouphash`.
|
||||
*
|
||||
* Two independent references are used, so a shared mistake is unlikely to pass
|
||||
* both:
|
||||
*
|
||||
* 1. the seven test vectors of RFC 1321 appendix A.5, hardcoded here;
|
||||
* 2. Node's crypto.createHash('md5').update(s, 'utf8') over a wider corpus,
|
||||
* including the padding boundaries of section 3.1 (55/56, 63/64, 119/120
|
||||
* bytes) and the UTF-8 cases above.
|
||||
*
|
||||
* tests/ucode/test_subscription_repository.uc pins the ucode half to the same
|
||||
* RFC 1321 vectors, which is what turns "each side is standard" into "the two
|
||||
* sides are equal".
|
||||
*
|
||||
* Usage: node tests/frontend-md5.js <repo-root>
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { loadLuciModule } = require('./lib/luci-module.js');
|
||||
|
||||
const root = path.resolve(process.argv[2] || '.');
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function check(what, ok, detail) {
|
||||
checks++;
|
||||
if (ok) return;
|
||||
failures++;
|
||||
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
||||
}
|
||||
|
||||
/* calcStringMD5() only needs the module's own class; the deps are the same
|
||||
* inert set tests/frontend-validators.js uses, since the method touches none
|
||||
* of them. */
|
||||
const hp = loadLuciModule(
|
||||
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
|
||||
{
|
||||
baseclass: { extend: (o) => o },
|
||||
form: { DynamicList: { extend: (o) => o } },
|
||||
fs: {}, rpc: {}, uci: {}, ui: {}
|
||||
});
|
||||
|
||||
/* An independent MD5, from the platform, as the second opinion. */
|
||||
const reference = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex');
|
||||
|
||||
/* --- 1. RFC 1321 appendix A.5, verbatim -------------------------------- */
|
||||
|
||||
const RFC1321 = [
|
||||
[ '', 'd41d8cd98f00b204e9800998ecf8427e' ],
|
||||
[ 'a', '0cc175b9c0f1b6a831c399e269772661' ],
|
||||
[ 'abc', '900150983cd24fb0d6963f7d28e17f72' ],
|
||||
[ 'message digest', 'f96b697d7cb7938d525a2f31aaf161d0' ],
|
||||
[ 'abcdefghijklmnopqrstuvwxyz', 'c3fcd3d76192e4007dfb496cca67e13b' ],
|
||||
[ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
|
||||
'd174ab98d277d9f5a5611c2c9f419d9f' ],
|
||||
[ '12345678901234567890123456789012345678901234567890123456789012345678901234567890',
|
||||
'57edf4a22be3c955ac49da2e2107b67a' ]
|
||||
];
|
||||
|
||||
for (const [ input, expected ] of RFC1321) {
|
||||
const got = hp.calcStringMD5(input);
|
||||
check(`RFC 1321 A.5 ${JSON.stringify(input.slice(0, 24))}`, got === expected,
|
||||
`got ${got}, expected ${expected}`);
|
||||
}
|
||||
|
||||
/* --- 2. shape ---------------------------------------------------------- */
|
||||
|
||||
check('output is lowercase hex', /^[0-9a-f]{32}$/.test(hp.calcStringMD5('anything')));
|
||||
check('output is 32 characters', hp.calcStringMD5('anything').length === 32,
|
||||
String(hp.calcStringMD5('anything').length));
|
||||
|
||||
/* --- 3. the two divergences the old body had --------------------------- */
|
||||
|
||||
/* It called `e.replace(/\r\n/g, '\n')` first, so these two inputs hashed
|
||||
* alike. RFC 1321 hashes the bytes it is given, and so does ucode. */
|
||||
check('CRLF is not normalised to LF',
|
||||
hp.calcStringMD5('\r\n') === reference('\r\n') &&
|
||||
hp.calcStringMD5('\r\n') !== hp.calcStringMD5('\n'),
|
||||
`CRLF=${hp.calcStringMD5('\r\n')} LF=${hp.calcStringMD5('\n')}`);
|
||||
|
||||
/* It encoded one UTF-16 code unit at a time, so a surrogate pair became two
|
||||
* orphaned three-byte sequences instead of one four-byte sequence. */
|
||||
check('an astral character uses real UTF-8',
|
||||
hp.calcStringMD5('\u{1F600}') === reference('\u{1F600}'),
|
||||
`got ${hp.calcStringMD5('\u{1F600}')}, expected ${reference('\u{1F600}')}`);
|
||||
|
||||
check('CJK uses real UTF-8',
|
||||
hp.calcStringMD5('\u4e2d\u6587') === reference('\u4e2d\u6587'),
|
||||
`got ${hp.calcStringMD5('\u4e2d\u6587')}, expected ${reference('\u4e2d\u6587')}`);
|
||||
|
||||
/* --- 4. cross-check against the platform MD5 --------------------------- */
|
||||
|
||||
const corpus = [
|
||||
'',
|
||||
' ',
|
||||
'https://example.com/sub?token=abc',
|
||||
'https://example.com/sub?token=abc#Node%20Name',
|
||||
'https://sub.example.com/api/v1/client/subscribe?token=deadbeef',
|
||||
'\u4e2d\u6587',
|
||||
'\u{1F600}',
|
||||
'\u{1F1E8}\u{1F1F3} flag pair',
|
||||
'mixed \u4e2d\u6587 and \u{1F600} emoji',
|
||||
'\t\n\r\n\u0000',
|
||||
'y'.repeat(54), 'y'.repeat(55), 'y'.repeat(56), 'y'.repeat(57),
|
||||
'y'.repeat(62), 'y'.repeat(63), 'y'.repeat(64), 'y'.repeat(65),
|
||||
'y'.repeat(118), 'y'.repeat(119), 'y'.repeat(120), 'y'.repeat(121),
|
||||
'y'.repeat(1000),
|
||||
'\u00e9'.repeat(40),
|
||||
'\u{1F600}'.repeat(20)
|
||||
];
|
||||
|
||||
for (const input of corpus) {
|
||||
const got = hp.calcStringMD5(input);
|
||||
const expected = reference(input);
|
||||
check(`cross-check ${input.length} bytes ${JSON.stringify(input.slice(0, 16))}`,
|
||||
got === expected, `got ${got}, expected ${expected}`);
|
||||
}
|
||||
|
||||
/* --- 5. the grouphash shape, exactly as both sides build it ------------ */
|
||||
|
||||
/* node.js hashes the subscription URL with its fragment stripped; the
|
||||
* fragment carries the label a user appended and is deliberately excluded, so
|
||||
* appending one must not move the digest. */
|
||||
const base = 'https://example.com/sub?token=abc';
|
||||
check('stripping the fragment is what makes the two forms agree',
|
||||
hp.calcStringMD5(base) === hp.calcStringMD5(base + '#Some%20Label'.replace(/#.*$/, '')),
|
||||
`${hp.calcStringMD5(base)} vs ${hp.calcStringMD5(base + '#Some%20Label'.replace(/#.*$/, ''))}`);
|
||||
|
||||
/* --- 6. non-strings fail loudly ---------------------------------------- */
|
||||
|
||||
/* The old body threw here only by accident (`null.replace` is a TypeError).
|
||||
* TextEncoder would encode the text "null" and return a digest, which would
|
||||
* silently name a UCI section after a string nobody asked for - so the check
|
||||
* is explicit and this test keeps it that way. */
|
||||
for (const bad of [ null, undefined, 42, {}, [], true ]) {
|
||||
const label = bad === null ? 'null' : (Array.isArray(bad) ? 'array' : typeof bad);
|
||||
let threw = null;
|
||||
try {
|
||||
hp.calcStringMD5(bad);
|
||||
}
|
||||
catch (e) {
|
||||
threw = e;
|
||||
}
|
||||
check(`calcStringMD5 rejects ${label}`, threw instanceof TypeError,
|
||||
threw ? String(threw) : 'returned a digest instead of throwing');
|
||||
}
|
||||
|
||||
console.log(`frontend md5: ${checks} checks, ${failures} failures`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Frontend protocol inventory.
|
||||
*
|
||||
* homeproxy.js carries one ordered `protocols` table and both forms render
|
||||
* their type picker from it. Before that table existed each form had its own
|
||||
* hand-written value list, and they had drifted in ways nothing could catch:
|
||||
* `snell` had a complete form block, a CREDENTIALS row and a golden outbound
|
||||
* but was not in the node form's list, so it could not be selected at all; the
|
||||
* node form offered `chacha20`, which sing-box rejects outright.
|
||||
*
|
||||
* The form snapshots cannot catch this class of bug - they record an option's
|
||||
* dependency and default, and the node form's picker lives in a subsection the
|
||||
* dump does not reach - so this test reads the table directly and compares it
|
||||
* with the backend tables that decide what the generators can actually build.
|
||||
*
|
||||
* Checks:
|
||||
* 1. every offered type is modelled by the backend (parser/mapping.uc
|
||||
* PROTOCOL_TO_UCI) - the table that makes a protocol reachable from UCI;
|
||||
* 2. every protocol the outbound adapter can build (OPTION_FIELDS,
|
||||
* REQUIRED_CREDENTIALS) is offered by the CLIENT form - "the backend
|
||||
* supports it and the UI cannot select it" is the snell bug;
|
||||
* 3. every protocol offered by the SERVER form has inbound credentials
|
||||
* (model.uc INBOUND_CREDENTIALS) - the reverse direction;
|
||||
* 4. the client and server lists match their expected order, because the
|
||||
* order is part of the UI and filtering one table has to reproduce it;
|
||||
* 5. protocols the backend models but the UI deliberately does not offer are
|
||||
* listed explicitly, so an accidental omission has to be added here (and
|
||||
* this file is reviewed) rather than passing silently.
|
||||
*
|
||||
* Usage: node tests/frontend-protocol-inventory.js <repo-root>
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { loadLuciModule } = require('./lib/luci-module.js');
|
||||
|
||||
const root = path.resolve(process.argv[2] || '.');
|
||||
const scripts = path.join(root, 'root/etc/homeproxy/scripts');
|
||||
|
||||
/* The registry is data plus the renderer; loading it through the whole LuCI
|
||||
* mock would test the mock. `protocols` only needs the module to evaluate its
|
||||
* top-level statements, so the imports are stubbed just far enough for that. */
|
||||
function loadHomeproxy() {
|
||||
return loadLuciModule(
|
||||
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
|
||||
{
|
||||
baseclass: { extend: (o) => o },
|
||||
form: { DynamicList: { extend: (o) => o } },
|
||||
fs: {}, rpc: {}, uci: {}, ui: {}
|
||||
});
|
||||
}
|
||||
|
||||
/* Read the keys of one `export const NAME = { ... }` table. Only depth-1 keys
|
||||
* count, so nested per-option maps and the `};` terminator are ignored. */
|
||||
function tableKeys(file, name) {
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||
const head = new RegExp('^export const ' + name + '\\s*=\\s*\\{');
|
||||
let start = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (head.test(lines[i])) { start = i; break; }
|
||||
}
|
||||
if (start === -1)
|
||||
throw new Error(`table ${name} not found in ${file}`);
|
||||
|
||||
const keys = [];
|
||||
let depth = 1;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (depth === 1) {
|
||||
const m = /^\t([A-Za-z0-9_]+)\s*:/.exec(line);
|
||||
if (m) keys.push(m[1]);
|
||||
}
|
||||
for (const ch of line) {
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
}
|
||||
if (depth <= 0) break;
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function check(what, ok, detail) {
|
||||
checks++;
|
||||
if (ok) return;
|
||||
failures++;
|
||||
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
||||
}
|
||||
|
||||
const hp = loadHomeproxy();
|
||||
const protocols = hp.protocols;
|
||||
check('homeproxy.js exports a protocols table', Array.isArray(protocols) && protocols.length > 0);
|
||||
if (!Array.isArray(protocols)) {
|
||||
console.error(`\n${checks} checks, ${failures} failures`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mapped = tableKeys(path.join(scripts, 'parser/mapping.uc'), 'PROTOCOL_TO_UCI');
|
||||
const optionFields = tableKeys(path.join(scripts, 'config/adapter.uc'), 'OPTION_FIELDS');
|
||||
const required = tableKeys(path.join(scripts, 'config/adapter.uc'), 'REQUIRED_CREDENTIALS');
|
||||
const inboundCredentials = tableKeys(path.join(scripts, 'config/model.uc'), 'INBOUND_CREDENTIALS');
|
||||
const credentials = tableKeys(path.join(scripts, 'config/model.uc'), 'CREDENTIALS');
|
||||
|
||||
const clientList = protocols.filter((p) => p.sides.includes('client')).map((p) => p.type);
|
||||
const serverList = protocols.filter((p) => p.sides.includes('server')).map((p) => p.type);
|
||||
|
||||
check('mapping.uc PROTOCOL_TO_UCI parsed', mapped.length > 0, `${mapped.length} keys`);
|
||||
check('adapter.uc OPTION_FIELDS parsed', optionFields.length > 0, `${optionFields.length} keys`);
|
||||
check('adapter.uc REQUIRED_CREDENTIALS parsed', required.length > 0, `${required.length} keys`);
|
||||
check('model.uc INBOUND_CREDENTIALS parsed', inboundCredentials.length > 0, `${inboundCredentials.length} keys`);
|
||||
|
||||
/* 1. Each side is checked against the table that makes it reachable there.
|
||||
* The client side is the outbound half: PROTOCOL_TO_UCI is what the Loader
|
||||
* reads options through. The server side is the inbound half, which lives
|
||||
* in model.uc's INBOUND_CREDENTIALS - server-only protocols such as naive
|
||||
* and mixed legitimately have no outbound mapping at all. */
|
||||
for (const p of protocols) {
|
||||
check(`'${p.type}' declares at least one side`, p.sides.length > 0);
|
||||
/* A label is now wrapped in _() at the table, so it is a translated
|
||||
* String object rather than a primitive when a translation exists. The
|
||||
* property under test is that it is present and non-empty. */
|
||||
check(`'${p.type}' has a label`, p.label != null && String(p.label).length > 0);
|
||||
|
||||
if (p.sides.includes('client'))
|
||||
check(`client '${p.type}' is modelled by the backend (PROTOCOL_TO_UCI)`, mapped.includes(p.type));
|
||||
|
||||
if (p.sides.includes('server'))
|
||||
check(`server '${p.type}' has inbound credentials (INBOUND_CREDENTIALS)`, inboundCredentials.includes(p.type));
|
||||
}
|
||||
|
||||
/* 2. Outbound-capable in the backend => selectable in the client form. This
|
||||
* is the direction that catches "the backend supports it and the UI cannot
|
||||
* select it", which is how snell was lost. */
|
||||
const outboundCapable = [...new Set([...optionFields, ...required])].sort();
|
||||
for (const type of outboundCapable) {
|
||||
check(`outbound-capable '${type}' can be selected in the node form`, clientList.includes(type));
|
||||
}
|
||||
|
||||
/* 4. The rendered order is part of the UI. Pinned here because the form
|
||||
* snapshots do not reach the node form's picker. */
|
||||
const expectedClient = [
|
||||
'direct', 'anytls', 'http', 'hysteria', 'hysteria2',
|
||||
'shadowsocks', 'shadowtls', 'snell', 'socks', 'ssh',
|
||||
'trojan', 'tuic', 'wireguard', 'vless', 'vmess'
|
||||
];
|
||||
const expectedServer = [
|
||||
'anytls', 'http', 'hysteria', 'hysteria2', 'naive', 'mixed',
|
||||
'shadowsocks', 'snell', 'socks', 'trojan', 'tuic', 'vless', 'vmess'
|
||||
];
|
||||
check('client protocol order', JSON.stringify(clientList) === JSON.stringify(expectedClient),
|
||||
`got ${JSON.stringify(clientList)}`);
|
||||
check('server protocol order', JSON.stringify(serverList) === JSON.stringify(expectedServer),
|
||||
`got ${JSON.stringify(serverList)}`);
|
||||
|
||||
/* 5. Deliberate omissions, spelled out. A protocol the backend models but no
|
||||
* form offers has to appear here; adding one to this list is a reviewable
|
||||
* decision instead of a silent gap. */
|
||||
const DELIBERATE = {
|
||||
/* The backend models the inbound side (INBOUND_CREDENTIALS,
|
||||
* REQUIRED_INBOUND_CREDENTIALS, INBOUND_CLAIM_FIELDS) but the inbound
|
||||
* generation path has no fixture/golden coverage, so the server form must
|
||||
* not offer it until that exists. The client form does offer it. */
|
||||
shadowtls: 'server side has no inbound fixture/golden coverage yet',
|
||||
/* Endpoint protocols: modelled and buildable, but the client form gates
|
||||
* them behind runtime features (wireguard on with_wireguard+gvisor,
|
||||
* direct is always offered). Listed here because they are not part of
|
||||
* the adapter's per-protocol OPTION_FIELDS tables. */
|
||||
wireguard: 'endpoint, gated on with_wireguard + with_gvisor',
|
||||
direct: 'endpoint, always offered by the client form'
|
||||
};
|
||||
|
||||
const unmapped = [...new Set([...mapped, ...credentials, ...inboundCredentials])]
|
||||
.filter((t) => !clientList.includes(t) && !serverList.includes(t))
|
||||
.sort();
|
||||
for (const type of unmapped) {
|
||||
check(`unoffered protocol '${type}' is a documented decision`, type in DELIBERATE,
|
||||
'models it in the backend but no form offers it');
|
||||
}
|
||||
|
||||
console.log(`frontend protocol inventory: ${checks} checks, ${failures} failures`);
|
||||
console.log(` client: ${clientList.join(' ')}`);
|
||||
console.log(` server: ${serverList.join(' ')}`);
|
||||
if (unmapped.length)
|
||||
console.log(` deliberately unoffered: ${unmapped.map((t) => `${t} (${DELIBERATE[t] || 'UNDOCUMENTED'})`).join(', ')}`);
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* What the UI does when an RPC does not answer. Two paths, both of which
|
||||
* used to state something false rather than admit ignorance.
|
||||
*
|
||||
* getBuiltinFeatures() goes through rpcCall(), which resolves to `{}` when the
|
||||
* call does not come back. The protocol list used to be filtered against that
|
||||
* map unconditionally, so an empty map hid every feature-gated protocol
|
||||
* (hysteria, hysteria2, naive, tuic, wireguard). A node already saved as one
|
||||
* of them then rendered with no matching Select option, and LuCI's
|
||||
* AbstractValue.parse() writes whenever formvalue != cfgvalue - so the next
|
||||
* Save & Apply silently rewrote the node's `type` to whichever protocol came
|
||||
* first. Silent protocol change, from a failed status RPC.
|
||||
*
|
||||
* No test could see it: luci-form-snapshot.js always passes the fully
|
||||
* populated feature map (and its rpc stub always resolves), so the empty-map
|
||||
* path was never taken.
|
||||
*
|
||||
* Usage: node tests/frontend-feature-fallback.js <repo-root>
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const { loadLuciModule } = require('./lib/luci-module.js');
|
||||
|
||||
const root = path.resolve(process.argv[2] || '.');
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function check(what, ok, detail) {
|
||||
checks++;
|
||||
if (ok) return;
|
||||
failures++;
|
||||
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
||||
}
|
||||
|
||||
const hp = loadLuciModule(
|
||||
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
|
||||
{ baseclass: { extend: (o) => o }, form: { DynamicList: { extend: (o) => o } },
|
||||
fs: {}, rpc: {}, uci: {}, ui: {} });
|
||||
|
||||
function types(side, features) {
|
||||
return hp.protocolChoices(side, features).map((p) => p.type);
|
||||
}
|
||||
|
||||
const FULL = { with_quic: true, with_wireguard: true, with_gvisor: true };
|
||||
const GATED_CLIENT = ['hysteria', 'hysteria2', 'tuic', 'wireguard'];
|
||||
const GATED_SERVER = ['hysteria', 'hysteria2', 'naive', 'tuic'];
|
||||
|
||||
/* --- the failure path: an empty map means "unknown", not "none" ---------- */
|
||||
|
||||
for (const [side, gated] of [['client', GATED_CLIENT], ['server', GATED_SERVER]]) {
|
||||
const unknown = types(side, {});
|
||||
const full = types(side, FULL);
|
||||
|
||||
for (const g of gated) {
|
||||
check(`${side}: ${g} is still offered when the feature call failed`,
|
||||
unknown.indexOf(g) !== -1,
|
||||
`offered: ${unknown.join(',')}`);
|
||||
}
|
||||
|
||||
/* The property that actually matters: an unanswered call must not offer
|
||||
* FEWER protocols than a successful one. */
|
||||
check(`${side}: an unknown feature map offers at least as many as a full one`,
|
||||
unknown.length >= full.length,
|
||||
`unknown=${unknown.length} full=${full.length}`);
|
||||
|
||||
check(`${side}: the unknown list contains everything the full list does`,
|
||||
full.every((t) => unknown.indexOf(t) !== -1),
|
||||
`missing: ${full.filter((t) => unknown.indexOf(t) === -1).join(',')}`);
|
||||
}
|
||||
|
||||
/* --- and the filter still works when the features ARE known -------------- */
|
||||
|
||||
const noQuic = types('client', { with_quic: false, with_wireguard: true, with_gvisor: true });
|
||||
for (const g of ['hysteria', 'hysteria2', 'tuic'])
|
||||
check(`client: ${g} is filtered out when with_quic is false`, noQuic.indexOf(g) === -1,
|
||||
`offered: ${noQuic.join(',')}`);
|
||||
check('client: wireguard survives with_quic=false', noQuic.indexOf('wireguard') !== -1);
|
||||
|
||||
/* wireguard needs BOTH of its features; one missing must be enough to hide it. */
|
||||
const partialWg = types('client', { with_quic: true, with_wireguard: true, with_gvisor: false });
|
||||
check('client: wireguard is hidden when only one of its features is present',
|
||||
partialWg.indexOf('wireguard') === -1, `offered: ${partialWg.join(',')}`);
|
||||
|
||||
/* --- renderProtocolOptions must use the same rule ------------------------ */
|
||||
|
||||
function driveRenderProtocolOptions(features, side) {
|
||||
const offered = [];
|
||||
const fakeOption = {
|
||||
value: (type) => { offered.push(type); },
|
||||
rmempty: true
|
||||
};
|
||||
const fakeSection = { option: () => fakeOption };
|
||||
|
||||
hp.renderProtocolOptions(fakeSection, { features: features, side: side });
|
||||
return offered;
|
||||
}
|
||||
|
||||
for (const [side, gated] of [['client', GATED_CLIENT], ['server', GATED_SERVER]]) {
|
||||
const offered = driveRenderProtocolOptions({}, side);
|
||||
for (const g of gated)
|
||||
check(`renderProtocolOptions(${side}, {}) still offers ${g}`,
|
||||
offered.indexOf(g) !== -1, `offered: ${offered.join(',')}`);
|
||||
}
|
||||
|
||||
const renderedFull = driveRenderProtocolOptions(FULL, 'client');
|
||||
check('renderProtocolOptions offers every client protocol with full features',
|
||||
renderedFull.length === types('client', FULL).length,
|
||||
`${renderedFull.length} vs ${types('client', FULL).length}`);
|
||||
|
||||
/* --- the status bar: "the query failed" is not "the service is down" ----- */
|
||||
|
||||
/* getServiceStatus() asks rpcCall() for a `null` fallback so it can tell the
|
||||
* two apart; the default `{}` made the try/catch report a failed query as
|
||||
* NOT RUNNING, which reads as "your proxy is down". */
|
||||
const running = hp.statusLabel(true);
|
||||
const stopped = hp.statusLabel(false);
|
||||
const unknown = hp.statusLabel(null);
|
||||
|
||||
check('a running service is green', String(running.color) === 'green', String(running.color));
|
||||
check('a stopped service is red', String(stopped.color) === 'red', String(stopped.color));
|
||||
check('an unanswered query is neither green nor red',
|
||||
String(unknown.color) !== 'green' && String(unknown.color) !== 'red',
|
||||
String(unknown.color));
|
||||
|
||||
check('an unanswered query does not say NOT RUNNING',
|
||||
String(unknown.text) !== String(stopped.text),
|
||||
`unknown=${String(unknown.text)} stopped=${String(stopped.text)}`);
|
||||
check('an unanswered query does not say RUNNING either',
|
||||
String(unknown.text) !== String(running.text),
|
||||
`unknown=${String(unknown.text)} running=${String(running.text)}`);
|
||||
check('a missing status argument is treated as unknown',
|
||||
String(hp.statusLabel(undefined).text) === String(unknown.text));
|
||||
check('the three states are three distinct texts',
|
||||
new Set([String(running.text), String(stopped.text), String(unknown.text)]).size === 3);
|
||||
|
||||
/* --- the status poll registers exactly once ----------------------------- */
|
||||
|
||||
/* The flag that makes this true used to live in client.js and server.js as two
|
||||
* separate booleans. Every re-render that got past it added another poll
|
||||
* handler, and LuCI keeps them all. The form snapshots never reach this code,
|
||||
* so nothing could fail until the state moved somewhere testable. */
|
||||
function makePoll() {
|
||||
const added = [];
|
||||
return {
|
||||
added: added,
|
||||
poll: { add: (fn) => { added.push(fn); return added.length; } }
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const h = makePoll();
|
||||
let reads = 0;
|
||||
const register = hp.statusPoller({
|
||||
poll: h.poll,
|
||||
read: () => { reads++; return Promise.resolve('v'); },
|
||||
paint: () => {}
|
||||
});
|
||||
|
||||
check('the first registration reports that it registered', register() === true);
|
||||
check('a second registration reports that it did not', register() === false);
|
||||
check('a third registration reports that it did not', register() === false);
|
||||
check('exactly one poll handler was added', h.added.length === 1, `${h.added.length} added`);
|
||||
|
||||
/* The handler has to actually do something - a registrar that registered an
|
||||
* empty function would satisfy the count above. read() is called
|
||||
* synchronously inside the handler, so this needs no awaiting (and a
|
||||
* top-level `return` is not allowed in a CommonJS module anyway - the first
|
||||
* version of this used one and the whole file failed to load, printing
|
||||
* nothing at all). */
|
||||
h.added[0]();
|
||||
check('the registered handler calls read() when invoked', reads === 1, `${reads} reads`);
|
||||
}
|
||||
|
||||
console.log(`frontend rpc fallbacks: ${checks} checks, ${failures} failures`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Frontend RPC boundary.
|
||||
*
|
||||
* Every backend call goes through `homeproxy.js`'s `rpcCall()`, which is the
|
||||
* one place that knows how to declare an RPC and what to do when the call does
|
||||
* not come back. Before that, nine call sites used
|
||||
* `L.resolveDefault(call(), {})` - which does NOT catch a rejection, it only
|
||||
* substitutes for a null/undefined *result* - so an unreachable rpcd left the
|
||||
* caller's `.then()` never running and said nothing: a status bar that silently
|
||||
* stopped updating, a capability list that quietly became empty, and an
|
||||
* unhandled rejection in the console.
|
||||
*
|
||||
* This is a source-level invariant rather than a behavioural test because the
|
||||
* behaviour needs a browser; what it can pin down is that nobody reintroduces a
|
||||
* second declaration site or the swallowing pattern.
|
||||
*
|
||||
* Usage: node tests/frontend-rpc-inventory.js <repo-root>
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(process.argv[2] || '.');
|
||||
const res = path.join(root, 'htdocs/luci-static/resources');
|
||||
const files = [
|
||||
'homeproxy.js',
|
||||
'view/homeproxy/node.js',
|
||||
'view/homeproxy/client.js',
|
||||
'view/homeproxy/server.js',
|
||||
'view/homeproxy/status.js'
|
||||
];
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function check(what, ok, detail) {
|
||||
checks++;
|
||||
if (ok) return;
|
||||
failures++;
|
||||
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
||||
}
|
||||
|
||||
/* Comments have to go before the pattern checks. A naive grep matched the doc
|
||||
* comment in homeproxy.js that quotes the old `L.resolveDefault(call(), {})`
|
||||
* idiom - which is exactly the trap the plan records for the Architecture
|
||||
* Guard: the check has to talk about code, not about prose describing code.
|
||||
* Only block comments are stripped; a `//` stripper would cut real code after a
|
||||
* URL in a string literal, and hiding a violation is worse than a false alarm. */
|
||||
function stripComments(src) {
|
||||
return src.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
}
|
||||
|
||||
const sources = {};
|
||||
for (const f of files)
|
||||
sources[f] = stripComments(fs.readFileSync(path.join(res, f), 'utf8'));
|
||||
|
||||
/* 1. One declaration site. */
|
||||
for (const f of files) {
|
||||
const n = (sources[f].match(/\brpc\.declare\s*\(/g) || []).length;
|
||||
if (f === 'homeproxy.js')
|
||||
check('homeproxy.js declares the RPCs', n === 1, `${n} rpc.declare calls, expected 1 (inside rpcCall)`);
|
||||
else
|
||||
check(`${f} declares no RPC of its own`, n === 0, `${n} rpc.declare call(s) outside the shared helper`);
|
||||
}
|
||||
|
||||
/* 2. The swallowing pattern: a call wrapped in resolveDefault, which reads like
|
||||
* error handling and is not.
|
||||
*
|
||||
* The pattern used to be `L\.resolveDefault\(\s*call[A-Za-z0-9_]*\s*\(`, which only
|
||||
* matched a callee whose name begins with `call` - the spelling the old code
|
||||
* happened to use. `L.resolveDefault(hp.rpcCall(...))` passed it, so the check
|
||||
* was almost vacuous, and my own reverse verification missed that because it
|
||||
* restored the very spelling the regex hardcodes (a circular proof: the
|
||||
* pattern was tested against the only form it recognises).
|
||||
*
|
||||
* Any resolveDefault wrapping is now flagged, and the pattern is self-tested
|
||||
* below so it cannot quietly stop matching. */
|
||||
const SWALLOW = /L\.resolveDefault\s*\(/g;
|
||||
|
||||
check('the resolveDefault pattern matches the real-world spelling',
|
||||
'L.resolveDefault(hp.rpcCall("list")).then()'.match(SWALLOW)?.length === 1);
|
||||
check('the resolveDefault pattern matches the historical spelling',
|
||||
'L.resolveDefault(call()).then()'.match(SWALLOW)?.length === 1);
|
||||
|
||||
for (const f of files) {
|
||||
const m = sources[f].match(SWALLOW) || [];
|
||||
check(`${f} does not wrap an RPC call in L.resolveDefault`, m.length === 0, m.join(', '));
|
||||
}
|
||||
|
||||
/* 3. rpcCall itself has to catch, and has to have a fallback. */
|
||||
const hp = sources['homeproxy.js'];
|
||||
const helper = /rpcCall\s*\(method,\s*args,\s*options\)\s*\{([\s\S]*?)\n\t\}/.exec(hp);
|
||||
check('homeproxy.js defines rpcCall', !!helper);
|
||||
if (helper) {
|
||||
check('rpcCall catches a failed call', /\.catch\(/.test(helper[1]));
|
||||
check('rpcCall has a fallback for a failed call', /fallback/.test(helper[1]));
|
||||
check('rpcCall reports the failure to the user', /addNotification/.test(helper[1]));
|
||||
}
|
||||
|
||||
/* 4. A view that calls rpcCall must import the module that provides it. */
|
||||
for (const f of files) {
|
||||
if (f === 'homeproxy.js') continue;
|
||||
if (!/\bhp\.rpcCall\s*\(/.test(sources[f])) continue;
|
||||
check(`${f} imports homeproxy for hp.rpcCall`, /'require homeproxy as hp';/.test(sources[f]));
|
||||
}
|
||||
|
||||
/* 5. No file keeps an unused `require rpc` after the conversion. */
|
||||
for (const f of files) {
|
||||
if (f === 'homeproxy.js') continue;
|
||||
const requiresRpc = /'require rpc';/.test(sources[f]);
|
||||
const usesRpc = /\brpc\./.test(sources[f]);
|
||||
check(`${f} has no unused 'require rpc'`, !requiresRpc || usesRpc);
|
||||
}
|
||||
|
||||
console.log(`frontend rpc boundary: ${checks} checks, ${failures} failures`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Title escaping and subscription-URL parsing.
|
||||
*
|
||||
* Two sinks with different properties, which is the whole point of this test:
|
||||
*
|
||||
* - A form *tab* title is a bare string child of E('a', ...), and LuCI's
|
||||
* dom.append() assigns a bare string to innerHTML. One decode, so one
|
||||
* level of escaping is both necessary and sufficient.
|
||||
*
|
||||
* - A GridSection *modal* title goes through form.titleFn(), which runs the
|
||||
* value through stripTags(). stripTags' documented contract is "HTML tags
|
||||
* removed, and HTML entities DECODED" - it parses `<div>${s}</div>` and
|
||||
* returns textContent - and the decoded result is then handed to the same
|
||||
* innerHTML sink. Escaping is therefore not enough there, and is in fact
|
||||
* worse than nothing: `<img>` would be decoded back into markup.
|
||||
*
|
||||
* The test drives the same two decodes the framework performs, so a value that
|
||||
* would become markup in the browser fails here instead.
|
||||
*
|
||||
* Usage: node tests/frontend-title-escaping.js <repo-root>
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const { loadLuciModule } = require('./lib/luci-module.js');
|
||||
|
||||
const root = path.resolve(process.argv[2] || '.');
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function check(what, ok, detail) {
|
||||
checks++;
|
||||
if (ok) return;
|
||||
failures++;
|
||||
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
||||
}
|
||||
|
||||
/* uci.get is what loadModalTitle reads the label through; keep it mutable so
|
||||
* the test can present a hostile label. */
|
||||
let label = null;
|
||||
const hp = loadLuciModule(
|
||||
path.join(root, 'htdocs/luci-static/resources/homeproxy.js'),
|
||||
{ baseclass: { extend: (o) => o }, form: { DynamicList: { extend: (o) => o } },
|
||||
fs: {}, rpc: {}, ui: {},
|
||||
uci: { get: (_c, _s, o) => (o === 'label' ? label : null) } });
|
||||
|
||||
/* Models what form.stripTags() does to a string: parse it as the innerHTML of
|
||||
* a <div>, then read the text back. For the entities that matter here that is
|
||||
* exactly one level of entity decoding - which is why it turns '<' into the
|
||||
* character '<'. Only the five named entities and numeric character
|
||||
* references are modelled; that is enough, because those are the only ways to
|
||||
* spell '<' without writing it. */
|
||||
function stripTags(s) {
|
||||
if (typeof s == 'string' && !/[<>&]/.test(s))
|
||||
return s;
|
||||
|
||||
/* A raw tag is dropped by the parse, which is what makes a *raw* '<...>'
|
||||
* label safe today; only the text outside tags survives. */
|
||||
const withoutTags = s.replace(/<[^>]*>/g, '');
|
||||
|
||||
return decodeEntitiesOnce(withoutTags);
|
||||
}
|
||||
|
||||
function decodeEntitiesOnce(s) {
|
||||
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
|
||||
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
|
||||
if (body[0] === '#') {
|
||||
const code = body[1] === 'x' || body[1] === 'X'
|
||||
? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(named, body) ? named[body] : m;
|
||||
});
|
||||
}
|
||||
|
||||
/* The property that decides whether assigning a string to innerHTML can start
|
||||
* a tag: no literal '<' may survive stripTags' single decode.
|
||||
*
|
||||
* Two callers, deliberately kept apart, because conflating them hides bugs:
|
||||
* rendersAsMarkupAfterEscape() takes a RAW value and applies the sanitizer
|
||||
* first, so it tests the sanitizer; titleBecomesMarkup() takes a value that
|
||||
* has already been sanitized (the output of loadModalTitle) and must NOT
|
||||
* sanitize it again - doing so would mask a call site that forgot to. */
|
||||
function titleBecomesMarkup(alreadyEscaped) {
|
||||
return stripTags(alreadyEscaped).includes('<');
|
||||
}
|
||||
|
||||
function rendersAsMarkupAfterEscape(raw) {
|
||||
return titleBecomesMarkup(hp.escapeTitleText(raw));
|
||||
}
|
||||
|
||||
const PAYLOADS = [
|
||||
'<img src=x onerror=alert(1)>', // the reported vector
|
||||
'<img src=x onerror=alert(1)>', // raw tag: safe before, must stay safe
|
||||
'&lt;script&gt;alert(1)&lt;/script&gt;',
|
||||
'<script>alert(1)</script>',
|
||||
'<script>',
|
||||
'"><script>alert(1)</script>',
|
||||
"'><svg onload=alert(1)>",
|
||||
'&',
|
||||
'&<<',
|
||||
'a < b > c'
|
||||
];
|
||||
|
||||
for (const p of PAYLOADS) {
|
||||
check(`escapeTitleText leaves no live markup for ${JSON.stringify(p)}`,
|
||||
!rendersAsMarkupAfterEscape(p),
|
||||
`stripTags(escapeTitleText(x)) = ${JSON.stringify(stripTags(hp.escapeTitleText(p)))}`);
|
||||
}
|
||||
|
||||
/* And it must not mangle ordinary names. */
|
||||
for (const name of ['Tokyo-01', '香港 01', 'a & b', 'node (backup)', 'im?possible', '100%']) {
|
||||
check(`escapeTitleText keeps ${JSON.stringify(name)} readable`,
|
||||
stripTags(hp.escapeTitleText(name)) === name,
|
||||
`got ${JSON.stringify(stripTags(hp.escapeTitleText(name)))}`);
|
||||
}
|
||||
|
||||
/* escapeHtml is the one-decode sink: it must not emit a literal '<' at all,
|
||||
* and exactly one decode must give the original back - no more (the value is
|
||||
* fully escaped) and no less (it is escaped deeply enough that a single decode
|
||||
* cannot re-introduce markup).
|
||||
*
|
||||
* This check used to read `!decodeEntitiesOnce(escaped).includes('<') || true`,
|
||||
* which is unconditionally true, so it never checked anything: for the raw-tag
|
||||
* payloads one decode *does* contain '<' (that is what a one-level escape
|
||||
* means), and the `|| true` was papering over the wrong property. */
|
||||
for (const p of PAYLOADS) {
|
||||
const escaped = hp.escapeHtml(p);
|
||||
check(`escapeHtml emits no literal '<' for ${JSON.stringify(p)}`,
|
||||
!escaped.includes('<') && !escaped.includes('>'),
|
||||
JSON.stringify(escaped));
|
||||
const decoded = decodeEntitiesOnce(escaped);
|
||||
check(`escapeHtml survives exactly one decode for ${JSON.stringify(p)}`,
|
||||
decoded === p,
|
||||
`one decode gave ${JSON.stringify(decoded)}, expected ${JSON.stringify(p)}`);
|
||||
}
|
||||
|
||||
/* The reason escapeTitleText exists at all: on the *raw* tag case, plain
|
||||
* escaping hands stripTags something it decodes straight back into markup.
|
||||
* This is not a defect in escapeHtml - it is the wrong tool for this sink. */
|
||||
check('escapeHtml would be unsafe for the modal title (raw tag decoded back)',
|
||||
stripTags(hp.escapeHtml('<img src=x onerror=alert(1)>')).includes('<'),
|
||||
'if this ever stops being true the comment above is stale');
|
||||
|
||||
/* loadModalTitle must use the stripTags-aware escape on the label. */
|
||||
for (const p of ['<img src=x onerror=alert(1)>', '<img src=x onerror=alert(1)>']) {
|
||||
label = p;
|
||||
const title = hp.loadModalTitle('Node', 'Add', 'homeproxy', 'n1');
|
||||
check(`loadModalTitle escapes the label ${JSON.stringify(p)}`,
|
||||
!titleBecomesMarkup(title),
|
||||
`title = ${JSON.stringify(title)} -> ${JSON.stringify(stripTags(title))}`);
|
||||
}
|
||||
label = null;
|
||||
check('loadModalTitle falls back to addtitle without a label',
|
||||
hp.loadModalTitle('Node', 'Add', 'homeproxy', 'n1') === 'Add');
|
||||
|
||||
/* --- subscriptionInfo: the crash and the tab-title sink ------------------ */
|
||||
|
||||
/* A fragment of '#%' or '#100%' is accepted by the form validator (it only
|
||||
* needs new URL() to succeed and a hostname to exist) but makes
|
||||
* decodeURIComponent throw. That threw out of render() before m.render(),
|
||||
* so the whole Node Settings page failed to render. */
|
||||
for (const u of ['https://h/p#%', 'https://h/p#100%', 'https://h/p#%zz', 'https://h/p#a%']) {
|
||||
let info = null, threw = null;
|
||||
try { info = hp.subscriptionInfo(u); } catch (e) { threw = e; }
|
||||
check(`subscriptionInfo does not throw on ${u}`, threw === null, threw && threw.message);
|
||||
check(`subscriptionInfo still returns a hash for ${u}`,
|
||||
info && typeof info.hash === 'string' && info.hash.length === 32,
|
||||
info && JSON.stringify(info.hash));
|
||||
/* The fallback has to be the raw fragment, not the whole URL: a bare
|
||||
* decodeURIComponent() would be caught by the outer guard and display
|
||||
* 'https://h/p#100%' in the tab, which reads as a bug to the user. */
|
||||
check(`subscriptionInfo shows the raw fragment for ${u}`,
|
||||
info && info.title === u.slice(u.indexOf('#') + 1),
|
||||
info && JSON.stringify(info.title));
|
||||
}
|
||||
|
||||
/* A value UCI may hold whatever another package wrote. */
|
||||
for (const junk of ['not a url', '', null, undefined, 42, {}]) {
|
||||
let threw = null;
|
||||
try { hp.subscriptionInfo(junk); } catch (e) { threw = e; }
|
||||
check(`subscriptionInfo tolerates ${JSON.stringify(junk)}`, threw === null, threw && threw.message);
|
||||
}
|
||||
check('subscriptionInfo returns null for an empty string', hp.subscriptionInfo('') === null);
|
||||
check('subscriptionInfo returns null for a non-string', hp.subscriptionInfo(42) === null);
|
||||
|
||||
/* The tab-title path renders the fragment through innerHTML. Wrapped so a
|
||||
* regression fails with a named check instead of a stack trace. */
|
||||
function info(label, url) {
|
||||
try {
|
||||
return hp.subscriptionInfo(url);
|
||||
}
|
||||
catch (e) {
|
||||
check(`subscriptionInfo must not throw for ${label}`, false, `${e.name}: ${e.message}`);
|
||||
return { hash: '', title: '' };
|
||||
}
|
||||
}
|
||||
|
||||
const hostile = info('a raw-tag fragment', 'https://e.com/#<img src=x onerror=alert(1)>');
|
||||
check('subscriptionInfo escapes a raw-tag fragment',
|
||||
!hostile.title.includes('<') && !hostile.title.includes('>'),
|
||||
JSON.stringify(hostile.title));
|
||||
|
||||
const encoded = info('an entity-encoded fragment', 'https://e.com/#<img src=x onerror=alert(1)>');
|
||||
check('subscriptionInfo escapes an entity-encoded fragment',
|
||||
!encoded.title.includes('<') && !encoded.title.includes('>'),
|
||||
JSON.stringify(encoded.title));
|
||||
|
||||
const named = info('a percent-encoded fragment', 'https://e.com/#Tokyo%20%26%20Osaka');
|
||||
check('subscriptionInfo keeps %26 as & (one decode, then escape)',
|
||||
named.title === 'Tokyo & Osaka', JSON.stringify(named.title));
|
||||
|
||||
const plain = info('a plain URL', 'https://sub.example.com/path');
|
||||
check('subscriptionInfo falls back to the hostname without a fragment',
|
||||
plain.title === 'sub.example.com', JSON.stringify(plain.title));
|
||||
|
||||
/* The hash must ignore the fragment, so two URLs that differ only by fragment
|
||||
* map to the same subscription. */
|
||||
check('subscriptionInfo hashes the URL without its fragment',
|
||||
info('a fragment x', 'https://e.com/a#x').hash === info('a fragment y', 'https://e.com/a#y').hash);
|
||||
|
||||
console.log(`frontend title escaping: ${checks} checks, ${failures} failures`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user