diff --git a/UA-Mask/.gitattributes b/UA-Mask/.gitattributes
new file mode 100644
index 000000000..3953fb35b
--- /dev/null
+++ b/UA-Mask/.gitattributes
@@ -0,0 +1 @@
+files/UAmask.init linguist-vendored
\ No newline at end of file
diff --git a/UA-Mask/.gitignore b/UA-Mask/.gitignore
new file mode 100644
index 000000000..3120bae19
--- /dev/null
+++ b/UA-Mask/.gitignore
@@ -0,0 +1,5 @@
+/.claude/
+/.codegraph/
+/.cache/
+/dev/
+/dist/
diff --git a/UA-Mask/LICENSE b/UA-Mask/LICENSE
new file mode 100644
index 000000000..f288702d2
--- /dev/null
+++ b/UA-Mask/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ 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
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ 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 3 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, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+ .
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/UA-Mask/Makefile b/UA-Mask/Makefile
new file mode 100644
index 000000000..244d4055d
--- /dev/null
+++ b/UA-Mask/Makefile
@@ -0,0 +1 @@
+include $(CURDIR)/openwrt/package/Makefile
diff --git a/UA-Mask/README.md b/UA-Mask/README.md
new file mode 100644
index 000000000..bc15dac75
--- /dev/null
+++ b/UA-Mask/README.md
@@ -0,0 +1,141 @@
+# UA-Mask
+
+
+[![GitHub Release][release-shield]][release-url]
+[![MIT License][license-shield]][license-url]
+
+
+
+
+
+
UA-Mask
+
+
+ 一个为 OpenWrt 设计的高性能、低内存 User-Agent 修改工具。
+
+ 主要用于解决校园网环境对多设备共享上网的检测问题。
+
+
+ 查看完整教程 »
+ ·
+ 报告Bug
+ ·
+ Discussions
+
+
+
+## 关于 UA-Mask
+
+`UA-Mask` 是一个为 OpenWrt 设计的精简、高性能、一站式的 User-Agent 修改工具。我们只专注做一件事:以极致的性能实现 UA Masking。
+
+我们的优化目标是:
+* **硬路由 (受限设备)**: 在 MIPS 等设备上稳定运行,优化热路径性能,消峰填谷保证稳定体验。
+* **软路由 (高性能设备)**: 在 ARM/x86 等设备上实现高效、高吞吐。
+
+> [!IMPORTANT]
+> **`v0.4.2` 版本现已完成流量卸载!**
+>
+> 通过智能统计分析,UA-Mask 能将非 HTTP 流量(如 P2P、游戏、视频流等)动态卸载给内核处理,在不影响 UA 伪装效果的前提下,**极大降低 CPU 负载,实现接近“零感知”的性能体验**。
+
+## 流量卸载
+
+`流量卸载`是 UA-Mask 的核心性能优化功能。它通过智能分析,将纯粹的非 HTTP 流量(如 P2P、WebSocket、QUIC、加密DNS等)从处理流程中剥离,直接交由系统内核转发。
+
+- **显著降低负载**:对于P2P、Steam、加密代理等重型流量,开启此功能可降低 80% 以上的 CPU 负载。
+- **智能与安全**:我们通过统计分析确保只有纯粹的非 HTTP 连接被卸载,避免了 UA 泄露的风险。
+- **白名单配合**:配合 `UA 关键词白名单` 使用效果更佳。例如,将 `Steam` 加入白名单,其 HTTP 下载流量在通过 UA 检测后即可被卸载,从而兼顾伪装效果与性能。
+
+
+## 架构
+`UA-Mask` 极大地优化了流量处理路径,无需依赖 OpenClash 即可独立工作,也能与之完美配合实现分流。
+- 对steam,P2P等重流量,我们可以直接用流量卸载绕过,不再经过UA-Mask,只需要处理一些小流量的api请求。
+- 和openclash完美分流无需冗杂配置,只需要打开openclash的`代理本机`和`绕过大陆`
+
+
+## 仓库结构
+
+- `core/`:Go 核心代理程序,包含 `cmd/` 入口和 `internal/` 内部实现。
+- `openwrt/package/`:OpenWrt 包定义与构建入口。
+- `openwrt/root/`:会安装到路由器根文件系统的脚本和默认配置。
+- `openwrt/luci/`:LuCI 页面代码。
+- `docs/`:教程、说明文档和图片资源。
+
+## ✨ 特性
+
+* **一键启用**: 自动配置 `nftables` 或 `iptables` 防火墙,开箱即用。
+* **高性能 & 低GC**: 采用 Redirect 架构,开销极低;使用 bufio pool 和 worker pool,GC 极低。
+* **高效 UA 缓存**: 90% 以上请求命中 LRU 缓存,极大减少重复匹配开销。
+* **流量卸载**: 支持使用 `ipset`/`nfset` 动态绕过非 HTTP 流量及白名单目标,极大提升性能。
+* **多种匹配模式**: 支持关键词、正则表达式,全部覆盖。
+* **零泄露**: 正确处理 HTTP、非 HTTP 及混合流量中每个请求的 UA。
+
+## 安装
+
+### 使用预编译包
+
+1. 前往 [Releases 页面](https://github.com/Zesuy/UA-Mask/releases)。
+
+2. 根据路由器架构下载对应的 `.ipk` 包:
+
+3. 安装:
+ ```bash
+ # 根据实际名称安装即可
+ opkg update
+ opkg install UAmask_*.ipk
+ # 对于iptables用户,若需要使用ipset功能,请安装ipset
+ opkg install ipset
+ ```
+
+### 源码编译
+
+1. 将本项目 `clone` 到您的 OpenWrt 编译环境的 `package/luci` 目录下。
+2. 推荐在编译前 `make download` 和 `make j8`,完成一次固件编译。
+3. 完成后再编译本软件包:
+
+ ```bash
+ make clean
+ make package/UA-Mask/compile
+ ```
+ 编译完成后将在 `$(rootdir)/bin/packages/$(targetdir)/base/` 中生成 `UAmask_xxx.ipk` 和 `UAmask-ipt_xxx.ipk`
+
+ 如果需要打包进固件,请在 `network/Web Servers/Proxies/UAmask`选择一个`*`。
+
+## 使用方法
+
+安装后,你只需要:
+
+1. 在 LuCI 界面中找到 "服务" -\> "UA-Mask"。
+2. 勾选 "启用"。
+3. 点击 "保存并应用"。
+
+插件会自动为你配置好所有防火墙转发规则。你也可以在界面中自定义各项高级设置,例如运行模式、匹配规则、绕过端口等。更详细的设置请见 [完整教程](https://github.com/Zesuy/UA-Mask/blob/main/docs/tutorial.md)
+
+## Q&A
+
+项目与 UA3F 的关系?
+- 本项目最初源于 [UA3F](https://github.com/SunBK201/UA3F) 的代码,并在此基础上进行了较大幅度的架构与实现调整。我们尊重并感谢 UA3F 的工作,项目沿用原许可证并在文档中保留致谢。
+
+硬路由能用吗?性能如何?
+- 在 mt7920 等受限设备上,核心应用的转发吞吐(iperf3)可达到上行约 97 Mbps、下行约 78 Mbps,HTTP hey 压测接近;启用“流量卸载”后,对 P2P/Steam/加密代理等重型流量,CPU 负载可显著下降。实际体验取决于设备与流量结构,建议配合 UA 关键词白名单以最大化卸载效果。
+- UA-Mask不会干扰 `Turbo ACC`以及`软件流卸载`,因此在没有其他干扰服务的情况下也推荐启用此设置以在硬路由上获得最大优化。
+
+是否会进行更底层(内核态)的实现?
+- 目前阶段,我们优先保证“零泄露”与“可运维”的平衡,采用 L7 分析 + L4 绕过的策略。更底层的实现需要权衡复杂度、兼容性与维护成本,后续将根据需求与社区反馈评估。
+
+后续计划?
+- 暂无明确大版本规划,欢迎在 Discussion 中提交需求或在 Issue 中反馈问题;对事实性更正与对比测试结果亦欢迎指正。我们会持续跟踪上游项目动态,并保持良性技术交流。
+
+## 致谢与来源
+
+- 本项目最初基于上游项目 UA3F 的实现开展开发,在遵循其开源许可证的前提下进行了大量架构与行为层面的改造;在此致谢 UA3F 及其贡献者。
+- 上游项目
+ - 仓库:
+ - 许可证:
+- 本项目在连接/请求处理路径、缓存策略、GC 优化与“流量卸载(ipset/nfset)”等方面进行了系统性重构,形成独立项目;
+- 若本仓库中存在归属标注不全之处,欢迎通过 Issue 或 PR 指正,我们会及时修正。
+
+
+[release-shield]: https://img.shields.io/github/v/release/Zesuy/UA-Mask?style=flat
+[release-url]: https://github.com/Zesuy/UA-Mask/releases
+[license-shield]: https://img.shields.io/github/license/Zesuy/UA-Mask.svg?style=flat
+[license-url]: https://github.com/Zesuy/UA-Mask/blob/main/LICENSE
diff --git a/UA-Mask/VERSION b/UA-Mask/VERSION
new file mode 100644
index 000000000..17b2ccd9b
--- /dev/null
+++ b/UA-Mask/VERSION
@@ -0,0 +1 @@
+0.4.3
diff --git a/UA-Mask/core/AGENTS.md b/UA-Mask/core/AGENTS.md
new file mode 100644
index 000000000..a63986ffc
--- /dev/null
+++ b/UA-Mask/core/AGENTS.md
@@ -0,0 +1,131 @@
+# AGENTS.md
+
+Guidance for agents working inside `core/`.
+
+## Core Architecture Rules
+
+The core traffic path is layered. Keep these responsibilities separate:
+
+- `ingress` adapts the entry mechanism and emits `model.AcceptedSession`.
+- `session` owns the current connection lifecycle and short-lived `SessionState`.
+- `state` stores and aggregates observed facts into `TargetProfile` and read-only `ProfileSnapshot`.
+- `policy` reads `SessionState` and `ProfileSnapshot`, then emits `PolicyDecision` or `OffloadSignal`.
+- `offload` executes explicit offload signals and reports factual success or failure events.
+
+Do not move long-lived target profile state into `session`. `session` may track only current-connection facts such as protocol, consecutive rewrite/no-rewrite counters, and flow offload status.
+
+## Event And Signal Boundaries
+
+`SessionEvent` is a fact: something observed in the current connection or by an executor.
+
+Examples:
+
+- `SessionOpened`
+- `ProtocolClassified`
+- `HTTPRequestObserved`
+- `RewriteApplied`
+- `RewriteSkipped`
+- `NonHTTPObserved`
+- `SessionClosed`
+- `FlowOffloadSucceeded`
+- `FlowOffloadFailed`
+
+`SessionEvent` must not mean "please do this". Avoid event names or payload fields such as `RequestTargetOffload`, `TryBypass`, or `ShouldPromote`.
+
+`OffloadSignal` is an explicit action candidate produced by policy:
+
+- `TryFlowOffload`
+- `TryTargetOffload`
+- `CancelTargetOffload`
+- `NoAction`
+
+Target-level offload must follow this data flow:
+
+```text
+SessionEvent -> StateStore -> ProfileSnapshot -> policy.TargetEvaluator -> OffloadSignal -> offload.Coordinator
+```
+
+Flow-level offload may be decided in the synchronous session path, but it must still enter offload through an explicit `OffloadSignal`.
+
+## Synchronous Vs Async Work
+
+The current connection path must remain synchronous and bounded:
+
+```text
+AcceptedSession -> SessionState -> classify -> ProfileSnapshot -> policy -> forward/rewrite/drop
+```
+
+Async fan-out is for observations and projections:
+
+```text
+SessionEvent -> StateStore aggregation
+SessionEvent -> stats projection
+SessionEvent -> offload observation
+```
+
+Do not make the current connection wait for target profile aggregation unless a specific synchronous snapshot read is required for policy evaluation.
+
+## Package Notes
+
+- `internal/model`: shared vocabulary only. Keep behavior out of this package.
+- `internal/ingress`: entry adapters. Current implementation is redirect; keep the interface open for future tproxy-style adapters.
+- `internal/session`: connection orchestration, protocol classification, forwarding, and event emission.
+- `internal/state`: fact aggregation only. It must not decide rewrite or offload actions.
+- `internal/policy`: decisions only. It should not mutate profile state or execute offload.
+- `internal/offload`: explicit action execution and factual result events.
+- `internal/rewrite`: User-Agent rewrite rules only. It must not directly enqueue firewall or offload actions.
+- `internal/firewall`: system execution boundary for batching ipset/nfset writes. It must not own target profiles or offload policy.
+
+There is one production traffic path. Do not reintroduce a parallel processor or decision engine beside `session -> events/state/policy/offload`.
+
+## Runtime Lifecycle
+
+- `app.Run` owns signal handling and passes cancellation into the runtime.
+- `proxy.Server` must stop ingress first, reject queued sessions, propagate context cancellation to active handlers, and wait for them.
+- `session.DefaultManager` must use context-aware dialing and close both client and upstream connections on cancellation.
+- Shutdown then proceeds through `offload.Stop -> Executor.Flush/Stop -> Dispatcher.WaitIdle/Stop -> stats.Stop -> state.Stop`.
+- Do not stop the dispatcher before executor completions have emitted and projected their result events.
+- Lifecycle tests must synchronize with channels/barriers; real timeouts are hang guards only.
+
+## Testing Expectations
+
+Before finishing a core change, run:
+
+```sh
+make ci
+```
+
+For changes touching architecture boundaries, add or update focused tests:
+
+- `state`: profiles are derived only from factual events.
+- `policy`: decisions are derived from session input and profile snapshots.
+- `offload`: actions happen only from explicit `OffloadSignal`.
+- `session`: connection-local state does not own target profile data.
+
+Async and time-dependent tests must be deterministic:
+
+- Inject `internal/clock.Clock`; use `internal/testkit/manualclock` to advance business time.
+- Use `Dispatcher.WaitIdle` to wait for causal event fan-out and `Executor.Flush` to wait for queued firewall work.
+- Do not use `time.Sleep`, polling, or real timers to advance business state or prove that a decision delay or cooldown elapsed.
+- Real context deadlines and timeout channels are allowed only as hang protection for concurrency and lifecycle tests.
+- Prefer `internal/testkit/scenario` for cross-package target decision flows, preserving the fact -> snapshot -> signal -> action -> result fact boundaries.
+
+Run the deterministic core stress suite with:
+
+```sh
+make stress
+```
+
+Use:
+
+```sh
+make cover
+```
+
+when changing cross-package behavior or preparing a PR that needs coverage output.
+
+## Compatibility
+
+Keep existing CLI flags and user-visible behavior compatible unless the task explicitly asks for a breaking change.
+
+Existing target-level firewall bypass behavior should remain expressible as target offload. Future eBPF flow offload should integrate through `offload.FlowOffloader` without making `session`, `state`, or `rewrite` depend on eBPF implementation details.
diff --git a/UA-Mask/core/Makefile b/UA-Mask/core/Makefile
new file mode 100644
index 000000000..9c1ea1900
--- /dev/null
+++ b/UA-Mask/core/Makefile
@@ -0,0 +1,44 @@
+GO ?= go
+
+.PHONY: fmt-check vet test stress cover build shell-check ci
+
+fmt-check:
+ @test -z "$$(find . -name '*.go' -print0 | xargs -0 gofmt -l)" || \
+ (echo "Go files are not formatted. Run 'gofmt -w' on the files above."; \
+ find . -name '*.go' -print0 | xargs -0 gofmt -l; exit 1)
+
+vet:
+ $(GO) vet ./...
+
+test:
+ $(GO) test ./...
+
+stress:
+ $(GO) test -race -count=20 \
+ ./internal/app \
+ ./internal/ingress \
+ ./internal/proxy \
+ ./internal/session \
+ ./internal/events \
+ ./internal/state \
+ ./internal/policy \
+ ./internal/offload \
+ ./internal/firewall \
+ ./integration
+
+cover:
+ $(GO) test -covermode=atomic -coverprofile=coverage.out ./...
+ $(GO) tool cover -func=coverage.out
+
+build:
+ $(GO) build ./...
+
+shell-check:
+ sh -n ../openwrt/root/etc/init.d/UAmask
+ @test -z "$$(grep -n 'procd_append_param command' ../openwrt/root/etc/init.d/UAmask)" || \
+ (echo "OpenWrt init must launch core through a JSON config file, not business flags."; exit 1)
+ @grep -q 'procd_set_param command "$$PROG" -config "$$CORE_CONFIG_PATH"' ../openwrt/root/etc/init.d/UAmask
+ sh ../openwrt/tests/config-generation-test.sh
+ sh ../openwrt/tests/procd-contract-test.sh
+
+ci: fmt-check vet test build shell-check
diff --git a/UA-Mask/core/UAmask b/UA-Mask/core/UAmask
new file mode 100755
index 000000000..b042027c8
Binary files /dev/null and b/UA-Mask/core/UAmask differ
diff --git a/UA-Mask/core/cmd/UAmask/main.go b/UA-Mask/core/cmd/UAmask/main.go
new file mode 100644
index 000000000..d824e74c2
--- /dev/null
+++ b/UA-Mask/core/cmd/UAmask/main.go
@@ -0,0 +1,18 @@
+package main
+
+import (
+ "UAmask/internal/app"
+
+ "github.com/sirupsen/logrus"
+)
+
+// version is replaced by release builds through -ldflags. Keeping the source
+// default explicit avoids accidentally presenting an unversioned local build
+// as an official release.
+var version = "dev"
+
+func main() {
+ if err := app.Run(version); err != nil {
+ logrus.Fatalf("UA-Mask failed to start: %v", err)
+ }
+}
diff --git a/UA-Mask/core/go.mod b/UA-Mask/core/go.mod
new file mode 100644
index 000000000..cc5b405db
--- /dev/null
+++ b/UA-Mask/core/go.mod
@@ -0,0 +1,15 @@
+module UAmask
+
+go 1.21
+
+require (
+ github.com/sirupsen/logrus v1.9.3
+ golang.org/x/sys v0.30.0
+)
+
+require github.com/hashicorp/golang-lru/v2 v2.0.7
+
+require (
+ github.com/dlclark/regexp2 v1.11.5 // indirect
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+)
diff --git a/UA-Mask/core/go.sum b/UA-Mask/core/go.sum
new file mode 100644
index 000000000..422730c7c
--- /dev/null
+++ b/UA-Mask/core/go.sum
@@ -0,0 +1,19 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/UA-Mask/core/integration/cli_test.go b/UA-Mask/core/integration/cli_test.go
new file mode 100644
index 000000000..3d1da0658
--- /dev/null
+++ b/UA-Mask/core/integration/cli_test.go
@@ -0,0 +1,155 @@
+package integration_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+var (
+ cliBinary string
+ coreRoot string
+)
+
+func TestMain(m *testing.M) {
+ var err error
+ coreRoot, err = filepath.Abs("..")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "resolve core root: %v\n", err)
+ os.Exit(1)
+ }
+
+ tempDir, err := os.MkdirTemp("", "uamask-cli-test-")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "create CLI test directory: %v\n", err)
+ os.Exit(1)
+ }
+ cliBinary = filepath.Join(tempDir, "UAmask")
+ build := exec.Command("go", "build", "-o", cliBinary, "./cmd/UAmask")
+ build.Dir = coreRoot
+ if output, err := build.CombinedOutput(); err != nil {
+ fmt.Fprintf(os.Stderr, "build UAmask CLI: %v\n%s", err, output)
+ _ = os.RemoveAll(tempDir)
+ os.Exit(1)
+ }
+
+ code := m.Run()
+ if err := os.RemoveAll(tempDir); err != nil && code == 0 {
+ fmt.Fprintf(os.Stderr, "remove CLI test directory: %v\n", err)
+ code = 1
+ }
+ os.Exit(code)
+}
+
+func TestCLIContracts(t *testing.T) {
+ t.Run("PROC-VERSION-001/version output", func(t *testing.T) {
+ result := runCLI(t, "-v")
+ if result.exitCode != 0 {
+ t.Fatalf("exit code = %d, stderr=%q", result.exitCode, result.stderr)
+ }
+ if !strings.HasPrefix(result.stdout, "UA-Mask version: ") || !strings.HasSuffix(result.stdout, "\n") {
+ t.Fatalf("unexpected version output %q", result.stdout)
+ }
+ if result.stderr != "" {
+ t.Fatalf("unexpected stderr %q", result.stderr)
+ }
+ })
+
+ t.Run("PROC-CHECK-001/valid config", func(t *testing.T) {
+ path := configFixture(t, "valid", "schema-v1-full.json")
+ result := runCLI(t, "-check-config", "-config", path)
+ if result.exitCode != 0 || result.stdout != "configuration valid\n" || result.stderr != "" {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ })
+
+ t.Run("PROC-CHECK-002/invalid config includes path", func(t *testing.T) {
+ path := configFixture(t, "invalid", "cfg-null-001-null-scalar.json")
+ result := runCLI(t, "-check-config", "-config", path)
+ if result.exitCode == 0 {
+ t.Fatalf("expected failure, got %+v", result)
+ }
+ if !strings.Contains(result.stderr, path) || !strings.Contains(result.stderr, "null is not allowed at $.listen.port") {
+ t.Fatalf("stderr %q does not identify the file and field", result.stderr)
+ }
+ })
+
+ t.Run("PROC-DUMP-001/effective config reloads", func(t *testing.T) {
+ path := configFixture(t, "valid", "schema-v1-full.json")
+ dump := runCLI(t, "-dump-effective-config", "-config", path)
+ if dump.exitCode != 0 || dump.stderr != "" {
+ t.Fatalf("dump failed: %+v", dump)
+ }
+
+ var document map[string]any
+ if err := json.Unmarshal([]byte(dump.stdout), &document); err != nil {
+ t.Fatalf("dump is not JSON: %v\n%s", err, dump.stdout)
+ }
+ if document["schema_version"] != float64(1) {
+ t.Fatalf("schema_version = %v, want 1", document["schema_version"])
+ }
+
+ dumpedPath := filepath.Join(t.TempDir(), "effective.json")
+ if err := os.WriteFile(dumpedPath, []byte(dump.stdout), 0o600); err != nil {
+ t.Fatalf("write dumped config: %v", err)
+ }
+ check := runCLI(t, "-check-config", "-config", dumpedPath)
+ if check.exitCode != 0 || check.stdout != "configuration valid\n" {
+ t.Fatalf("dumped config did not reload: %+v", check)
+ }
+ })
+
+ t.Run("PROC-ARGS-001/unknown flag", func(t *testing.T) {
+ result := runCLI(t, "-not-a-real-flag")
+ if result.exitCode == 0 || !strings.Contains(result.stderr, "flag provided but not defined") {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ })
+
+ t.Run("PROC-ARGS-002/positional argument", func(t *testing.T) {
+ result := runCLI(t, "unexpected")
+ if result.exitCode == 0 || !strings.Contains(result.stderr, "unexpected positional arguments") {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ })
+}
+
+type cliResult struct {
+ exitCode int
+ stdout string
+ stderr string
+}
+
+func runCLI(t *testing.T, args ...string) cliResult {
+ t.Helper()
+ command := exec.Command(cliBinary, args...)
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ command.Stdout = &stdout
+ command.Stderr = &stderr
+ err := command.Run()
+ exitCode := 0
+ if err != nil {
+ var exitError *exec.ExitError
+ if !errors.As(err, &exitError) {
+ t.Fatalf("run CLI: %v", err)
+ }
+ exitCode = exitError.ExitCode()
+ }
+ return cliResult{exitCode: exitCode, stdout: stdout.String(), stderr: stderr.String()}
+}
+
+func configFixture(t *testing.T, category, name string) string {
+ t.Helper()
+ path := filepath.Join(coreRoot, "internal", "config", "testdata", category, name)
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("stat fixture %q: %v", path, err)
+ }
+ return path
+}
diff --git a/UA-Mask/core/integration/deterministic_flow_test.go b/UA-Mask/core/integration/deterministic_flow_test.go
new file mode 100644
index 000000000..649ecf538
--- /dev/null
+++ b/UA-Mask/core/integration/deterministic_flow_test.go
@@ -0,0 +1,232 @@
+package integration
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "UAmask/internal/model"
+ "UAmask/internal/testkit/scenario"
+)
+
+const (
+ decisionDelay = 10 * time.Second
+ cooldown = 30 * time.Second
+)
+
+var target = model.Target{
+ Address: "203.0.113.10:443",
+ IP: "203.0.113.10",
+ Port: 443,
+}
+
+func TestDeterministicTargetFlow(t *testing.T) {
+ t.Run("FLOW-NONHTTP-101 threshold waits for decision delay", func(t *testing.T) {
+ s := newTargetScenario(t)
+ ctx := testContext(t)
+
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ if got := s.Signals.Len(); got != 0 {
+ t.Fatalf("signals before threshold = %d, want 0", got)
+ }
+
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ pendingAt := s.Snapshot(target).PendingTargetOffloadAt
+ if want := s.Clock.Now().Add(decisionDelay); !pendingAt.Equal(want) {
+ t.Fatalf("pending target offload = %v, want %v", pendingAt, want)
+ }
+
+ advance(t, ctx, s, decisionDelay-time.Nanosecond)
+ if got := s.Signals.Len(); got != 0 {
+ t.Fatalf("signals before deadline = %d, want 0", got)
+ }
+ advance(t, ctx, s, time.Nanosecond)
+ assertSignalCount(t, s, 1)
+ })
+
+ t.Run("FLOW-HTTP-101 HTTP cancels pending offload and starts cooldown", func(t *testing.T) {
+ s := newTargetScenario(t)
+ ctx := testContext(t)
+
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ advance(t, ctx, s, decisionDelay/2)
+ emit(t, ctx, s, model.EventHTTPRequestObserved)
+
+ snapshot := s.Snapshot(target)
+ if snapshot.NonHTTPScore != 0 || !snapshot.PendingTargetOffloadAt.IsZero() {
+ t.Fatalf("HTTP snapshot = %+v, want reset score and no pending offload", snapshot)
+ }
+ advance(t, ctx, s, decisionDelay)
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ if got := s.Snapshot(target).NonHTTPScore; got != 0 {
+ t.Fatalf("score during cooldown = %d, want 0", got)
+ }
+ assertSignalCount(t, s, 0)
+ })
+
+ t.Run("FLOW-HTTP-102 cooldown expiry permits offload", func(t *testing.T) {
+ s := newTargetScenario(t)
+ ctx := testContext(t)
+
+ emit(t, ctx, s, model.EventHTTPRequestObserved)
+ advance(t, ctx, s, cooldown)
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ advance(t, ctx, s, decisionDelay)
+
+ assertSignalCount(t, s, 1)
+ })
+
+ t.Run("FLOW-OFFLOAD-101 success projects fact and validity", func(t *testing.T) {
+ s := newTargetScenario(t)
+ ctx := testContext(t)
+ triggerOffload(t, ctx, s)
+
+ complete(t, ctx, s, nil)
+
+ if got := s.Events.Count(model.EventTargetOffloadSucceeded); got != 1 {
+ t.Fatalf("success events = %d, want 1", got)
+ }
+ snapshot := s.Snapshot(target)
+ if snapshot.NonHTTPScore != 0 {
+ t.Fatalf("score after success = %d, want 0", snapshot.NonHTTPScore)
+ }
+ wantUntil := s.Clock.Now().Add(time.Minute)
+ if !snapshot.TargetOffloadUntil.Equal(wantUntil) {
+ t.Fatalf("offload validity = %v, want %v", snapshot.TargetOffloadUntil, wantUntil)
+ }
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ advance(t, ctx, s, decisionDelay)
+ assertSignalCount(t, s, 1)
+ })
+
+ t.Run("FLOW-OFFLOAD-102 failure permits retry", func(t *testing.T) {
+ s := newTargetScenario(t)
+ ctx := testContext(t)
+ triggerOffload(t, ctx, s)
+
+ complete(t, ctx, s, errors.New("apply failed"))
+
+ if got := s.Events.Count(model.EventTargetOffloadFailed); got != 1 {
+ t.Fatalf("failure events = %d, want 1", got)
+ }
+ if until := s.Snapshot(target).TargetOffloadUntil; !until.IsZero() {
+ t.Fatalf("offload validity after failure = %v, want zero", until)
+ }
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ advance(t, ctx, s, decisionDelay)
+ assertSignalCount(t, s, 2)
+ })
+
+ t.Run("FLOW-OFFLOAD-103 in-flight target is deduplicated", func(t *testing.T) {
+ s := newTargetScenario(t)
+ ctx := testContext(t)
+ triggerOffload(t, ctx, s)
+
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ advance(t, ctx, s, decisionDelay)
+
+ assertSignalCount(t, s, 1)
+ complete(t, ctx, s, nil)
+ if got := s.Batches.Len(); got != 1 {
+ t.Fatalf("firewall batches = %d, want 1", got)
+ }
+ })
+
+ t.Run("FLOW-SHUTDOWN-101 coordinator stop cancels decision timer", func(t *testing.T) {
+ s := newTargetScenario(t)
+ ctx := testContext(t)
+
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ s.Coordinator.Stop()
+ advance(t, ctx, s, decisionDelay)
+
+ assertSignalCount(t, s, 0)
+ })
+
+ t.Run("FLOW-SHUTDOWN-102 close projects queued executor result", func(t *testing.T) {
+ s := scenario.NewTarget(testTargetConfig())
+ ctx := testContext(t)
+ triggerOffload(t, ctx, s)
+
+ if err := s.Close(ctx); err != nil {
+ t.Fatalf("close scenario: %v", err)
+ }
+ if got := s.Events.Count(model.EventTargetOffloadSucceeded); got != 1 {
+ t.Fatalf("success events after close = %d, want 1", got)
+ }
+ if got := s.Snapshot(target).NonHTTPScore; got != 0 {
+ t.Fatalf("score after close = %d, want 0", got)
+ }
+ })
+}
+
+func newTargetScenario(t *testing.T) *scenario.Target {
+ t.Helper()
+ s := scenario.NewTarget(testTargetConfig())
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := s.Close(ctx); err != nil {
+ t.Errorf("close scenario: %v", err)
+ }
+ })
+ return s
+}
+
+func testTargetConfig() scenario.TargetConfig {
+ return scenario.TargetConfig{
+ Start: time.Unix(1_700_000_000, 0),
+ NonHTTPThreshold: 2,
+ DecisionDelay: decisionDelay,
+ HTTPCooldownPeriod: cooldown,
+ TargetOffloadTimeout: 60,
+ }
+}
+
+func testContext(t *testing.T) context.Context {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ t.Cleanup(cancel)
+ return ctx
+}
+
+func emit(t *testing.T, ctx context.Context, s *scenario.Target, eventType model.SessionEventType) {
+ t.Helper()
+ if err := s.Emit(ctx, model.SessionEvent{Type: eventType, Target: target}); err != nil {
+ t.Fatalf("emit %s: %v", eventType, err)
+ }
+}
+
+func advance(t *testing.T, ctx context.Context, s *scenario.Target, duration time.Duration) {
+ t.Helper()
+ if err := s.Advance(ctx, duration); err != nil {
+ t.Fatalf("advance %v: %v", duration, err)
+ }
+}
+
+func triggerOffload(t *testing.T, ctx context.Context, s *scenario.Target) {
+ t.Helper()
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ emit(t, ctx, s, model.EventNonHTTPObserved)
+ advance(t, ctx, s, decisionDelay)
+ assertSignalCount(t, s, 1)
+}
+
+func complete(t *testing.T, ctx context.Context, s *scenario.Target, result error) {
+ t.Helper()
+ if err := s.CompleteNext(ctx, result); err != nil {
+ t.Fatalf("complete target offload: %v", err)
+ }
+}
+
+func assertSignalCount(t *testing.T, s *scenario.Target, want int) {
+ t.Helper()
+ if got := s.Signals.Len(); got != want {
+ t.Fatalf("target signals = %d, want %d", got, want)
+ }
+}
diff --git a/UA-Mask/core/internal/app/app.go b/UA-Mask/core/internal/app/app.go
new file mode 100644
index 000000000..888855709
--- /dev/null
+++ b/UA-Mask/core/internal/app/app.go
@@ -0,0 +1,238 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/signal"
+ "runtime/debug"
+ "syscall"
+ "time"
+
+ "UAmask/internal/config"
+ "UAmask/internal/events"
+ "UAmask/internal/firewall"
+ "UAmask/internal/ingress"
+ "UAmask/internal/offload"
+ "UAmask/internal/policy"
+ "UAmask/internal/proxy"
+ "UAmask/internal/rewrite"
+ "UAmask/internal/session"
+ "UAmask/internal/state"
+ "UAmask/internal/stats"
+
+ "github.com/sirupsen/logrus"
+)
+
+const firewallQueueSize = 10000
+const runtimeShutdownTimeout = 15 * time.Second
+
+type startStop interface {
+ Start()
+ Stop()
+}
+
+type executorRuntime interface {
+ startStop
+ Flush(context.Context) error
+}
+
+type dispatcherRuntime interface {
+ startStop
+ WaitIdle(context.Context) error
+}
+
+type offloadRuntime interface {
+ Stop()
+}
+
+type serverRuntime interface {
+ RunContext(context.Context) error
+}
+
+type Runtime struct {
+ config *config.Config
+ version string
+ stats *stats.Stats
+ statsReporter startStop
+ profileStore startStop
+ firewallExecutor executorRuntime
+ dispatcher dispatcherRuntime
+ offload offloadRuntime
+ server serverRuntime
+ shutdownTimeout time.Duration
+}
+
+func Run(version string) error {
+ command, err := config.NewFromFlags()
+ if err != nil {
+ return fmt.Errorf("load config: %w", err)
+ }
+
+ if command.ShowVersion {
+ _, err := fmt.Fprintf(os.Stdout, "UA-Mask version: %s\n", version)
+ return err
+ }
+ if command.DumpEffective {
+ return config.WriteEffective(os.Stdout, command.Config)
+ }
+ if command.CheckConfig {
+ _, err := fmt.Fprintln(os.Stdout, "configuration valid")
+ return err
+ }
+
+ cfg := command.Config
+ if cfg == nil {
+ return fmt.Errorf("configuration is not available")
+ }
+ applyRuntimeTuning(command)
+ setupLogging(cfg.Observe.LogLevel, cfg.Observe.LogFile)
+
+ if command.ConfigPath != "" {
+ logrus.Infof("Loaded configuration from %s", command.ConfigPath)
+ }
+
+ runtime, err := NewRuntime(cfg, version)
+ if err != nil {
+ return err
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ return runtime.RunContext(ctx)
+}
+
+func applyRuntimeTuning(command *config.Command) {
+ if command == nil || command.Config == nil || command.ConfigPath == "" {
+ return
+ }
+ // Legacy flag-only launches historically left GOGC under the Go runtime and
+ // environment's control. JSON configuration makes GC tuning explicit.
+ debug.SetGCPercent(command.Config.Performance.GCPercent)
+}
+
+func NewRuntime(cfg *config.Config, version string) (*Runtime, error) {
+ appStats := stats.New()
+ statsReporter := stats.NewFileReporter(appStats, cfg.Observe.StatsFilePath, cfg.Observe.StatsInterval)
+
+ cache, err := rewrite.NewUACache(cfg.Rewrite.CacheSize)
+ if err != nil {
+ return nil, fmt.Errorf("create UA cache: %w", err)
+ }
+
+ firewallExecutor := firewall.NewExecutor(logrus.StandardLogger(), firewallQueueSize, firewall.NewCommandApplier())
+
+ ruleEngine := rewrite.NewEngine(cfg.Rewrite, cfg.RewriteRuntime, cfg.Firewall)
+ requestRewriter := rewrite.NewRequestRewriter(ruleEngine, cache)
+
+ profileStore := state.NewStore(state.Config{
+ NonHTTPEnabled: cfg.Firewall.EnableBypass,
+ NonHTTPThreshold: cfg.Firewall.NonHTTPThreshold,
+ HTTPCooldownPeriod: cfg.Firewall.HTTPCooldownPeriod,
+ DecisionDelay: cfg.Firewall.DecisionDelay,
+ ProfileCleanupInterval: cfg.Firewall.ProfileCleanupInterval,
+ })
+
+ targetOffloader := offload.NewFirewallTargetOffloader(firewallExecutor, cfg.Firewall.SetName, cfg.Firewall.Backend)
+
+ sessionPolicy := policy.NewSessionPolicy()
+ targetPolicy := policy.NewTargetPolicy(
+ cfg.Firewall.Timeout,
+ cfg.Firewall.ImmediateBypassTimeout,
+ cfg.Firewall.NonHTTPThreshold,
+ cfg.Firewall.EnableBypass,
+ )
+ dispatcher := events.NewDispatcher()
+ offloadCoordinator := offload.NewCoordinator(
+ logrus.StandardLogger(),
+ profileStore,
+ targetPolicy,
+ targetOffloader,
+ offload.NoopFlowOffloader{},
+ dispatcher,
+ )
+ dispatcher.Add(profileStore, appStats, offloadCoordinator)
+
+ sessionManager := session.NewManager(cfg.Listen, sessionPolicy, requestRewriter, profileStore, offloadCoordinator, dispatcher)
+ redirectIngress, err := ingress.NewRedirect(cfg.Listen)
+ if err != nil {
+ return nil, err
+ }
+ server := proxy.NewServer(cfg.Listen.PoolSize, redirectIngress, sessionManager)
+
+ return &Runtime{
+ config: cfg,
+ version: version,
+ stats: appStats,
+ statsReporter: statsReporter,
+ profileStore: profileStore,
+ firewallExecutor: firewallExecutor,
+ dispatcher: dispatcher,
+ offload: offloadCoordinator,
+ server: server,
+ shutdownTimeout: runtimeShutdownTimeout,
+ }, nil
+}
+
+func (r *Runtime) Run() error {
+ return r.RunContext(context.Background())
+}
+
+func (r *Runtime) RunContext(ctx context.Context) error {
+ if err := r.validate(); err != nil {
+ return err
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ r.config.LogConfig(r.version)
+
+ r.firewallExecutor.Start()
+ r.profileStore.Start()
+ r.statsReporter.Start()
+ r.dispatcher.Start()
+
+ runErr := r.server.RunContext(ctx)
+ if ctx.Err() != nil && errors.Is(runErr, ctx.Err()) {
+ runErr = nil
+ }
+ return errors.Join(runErr, r.shutdown())
+}
+
+func (r *Runtime) validate() error {
+ if r == nil || r.config == nil || r.statsReporter == nil || r.profileStore == nil ||
+ r.firewallExecutor == nil || r.dispatcher == nil || r.offload == nil || r.server == nil {
+ return errors.New("runtime dependencies are not configured")
+ }
+ return nil
+}
+
+func (r *Runtime) shutdown() error {
+ timeout := r.shutdownTimeout
+ if timeout <= 0 {
+ timeout = runtimeShutdownTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ r.offload.Stop()
+ flushErr := r.firewallExecutor.Flush(ctx)
+ r.firewallExecutor.Stop()
+ idleErr := r.dispatcher.WaitIdle(ctx)
+ r.dispatcher.Stop()
+ r.statsReporter.Stop()
+ r.profileStore.Stop()
+
+ return errors.Join(
+ wrapShutdownError("flush firewall executor", flushErr),
+ wrapShutdownError("wait for event dispatcher", idleErr),
+ )
+}
+
+func wrapShutdownError(operation string, err error) error {
+ if err == nil {
+ return nil
+ }
+ return fmt.Errorf("%s: %w", operation, err)
+}
diff --git a/UA-Mask/core/internal/app/app_test.go b/UA-Mask/core/internal/app/app_test.go
new file mode 100644
index 000000000..e5788a0f1
--- /dev/null
+++ b/UA-Mask/core/internal/app/app_test.go
@@ -0,0 +1,248 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "runtime/debug"
+ "sync"
+ "testing"
+ "time"
+
+ "UAmask/internal/config"
+ "UAmask/internal/events"
+ "UAmask/internal/firewall"
+ "UAmask/internal/model"
+ "UAmask/internal/proxy"
+ "UAmask/internal/state"
+)
+
+func TestApplyRuntimeTuning(t *testing.T) {
+ original := debug.SetGCPercent(73)
+ defer debug.SetGCPercent(original)
+
+ t.Run("CFG-GOGC-001/flag-only launch inherits runtime setting", func(t *testing.T) {
+ applyRuntimeTuning(&config.Command{Config: configWithGCPercent(25)})
+ if got := currentGCPercent(); got != 73 {
+ t.Fatalf("GC percent = %d, want inherited value 73", got)
+ }
+ })
+
+ t.Run("CFG-GOGC-002/JSON launch applies configured setting", func(t *testing.T) {
+ applyRuntimeTuning(&config.Command{
+ Config: configWithGCPercent(25),
+ ConfigPath: "/tmp/UAmask/config.json",
+ })
+ if got := currentGCPercent(); got != 25 {
+ t.Fatalf("GC percent = %d, want configured value 25", got)
+ }
+ })
+}
+
+func configWithGCPercent(percent int) *config.Config {
+ cfg := config.DefaultConfig()
+ cfg.Performance.GCPercent = percent
+ return cfg
+}
+
+func currentGCPercent() int {
+ current := debug.SetGCPercent(-1)
+ debug.SetGCPercent(current)
+ return current
+}
+
+func TestNewRuntimeWiresEventProjection(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Firewall.EnableBypass = true
+ cfg.Firewall.NonHTTPThreshold = 2
+
+ runtime, err := NewRuntime(cfg, "test")
+ if err != nil {
+ t.Fatalf("RUNTIME-WIRE-201 NewRuntime: %v", err)
+ }
+ dispatcher, ok := runtime.dispatcher.(*events.Dispatcher)
+ if !ok {
+ t.Fatalf("RUNTIME-WIRE-201 dispatcher type = %T", runtime.dispatcher)
+ }
+ profiles, ok := runtime.profileStore.(*state.Store)
+ if !ok {
+ t.Fatalf("RUNTIME-WIRE-201 profile store type = %T", runtime.profileStore)
+ }
+ if _, ok := runtime.firewallExecutor.(*firewall.Executor); !ok {
+ t.Fatalf("RUNTIME-WIRE-201 executor type = %T", runtime.firewallExecutor)
+ }
+ if _, ok := runtime.server.(*proxy.Server); !ok {
+ t.Fatalf("RUNTIME-WIRE-201 server type = %T", runtime.server)
+ }
+
+ target := model.Target{IP: "203.0.113.20", Port: 80}
+ dispatcher.Append(model.SessionEvent{
+ Type: model.EventHTTPRequestObserved,
+ Target: target,
+ Timestamp: time.Unix(100, 0),
+ })
+ if snapshot := profiles.Snapshot(target); !snapshot.Found || snapshot.HTTPCooldownUntil.IsZero() {
+ t.Fatalf("RUNTIME-WIRE-201 profile was not projected: %+v", snapshot)
+ }
+ if got := runtime.stats.Snapshot().HTTPRequests; got != 1 {
+ t.Fatalf("RUNTIME-WIRE-201 HTTP requests = %d, want 1", got)
+ }
+}
+
+type lifecycleLog struct {
+ mu sync.Mutex
+ calls []string
+}
+
+func (log *lifecycleLog) Add(call string) {
+ log.mu.Lock()
+ log.calls = append(log.calls, call)
+ log.mu.Unlock()
+}
+
+func (log *lifecycleLog) Values() []string {
+ log.mu.Lock()
+ defer log.mu.Unlock()
+ return append([]string(nil), log.calls...)
+}
+
+type lifecycleComponent struct {
+ log *lifecycleLog
+ name string
+}
+
+func (component *lifecycleComponent) Start() { component.log.Add(component.name + ".start") }
+func (component *lifecycleComponent) Stop() { component.log.Add(component.name + ".stop") }
+
+type executorComponent struct {
+ *lifecycleComponent
+ flushErr error
+}
+
+func (component *executorComponent) Flush(context.Context) error {
+ component.log.Add(component.name + ".flush")
+ return component.flushErr
+}
+
+type dispatcherComponent struct {
+ *lifecycleComponent
+ idleErr error
+}
+
+func (component *dispatcherComponent) WaitIdle(context.Context) error {
+ component.log.Add(component.name + ".idle")
+ return component.idleErr
+}
+
+type offloadComponent struct {
+ log *lifecycleLog
+}
+
+func (component *offloadComponent) Stop() { component.log.Add("offload.stop") }
+
+type serverComponent struct {
+ log *lifecycleLog
+ started chan struct{}
+ runErr error
+ waitFor bool
+ startOnce sync.Once
+}
+
+func (server *serverComponent) RunContext(ctx context.Context) error {
+ server.log.Add("server.run")
+ server.startOnce.Do(func() { close(server.started) })
+ if server.waitFor {
+ <-ctx.Done()
+ server.log.Add("server.done")
+ return ctx.Err()
+ }
+ server.log.Add("server.done")
+ return server.runErr
+}
+
+func TestRuntimeRunContextUsesCausalShutdownOrder(t *testing.T) {
+ log := &lifecycleLog{}
+ runtime := runtimeWithFakes(log)
+ runtime.server = &serverComponent{log: log, started: make(chan struct{}), waitFor: true}
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- runtime.RunContext(ctx) }()
+
+ waitAppClosed(t, runtime.server.(*serverComponent).started)
+ cancel()
+ if err := waitAppValue(t, done); err != nil {
+ t.Fatalf("LIFE-SHUTDOWN-201 RunContext: %v", err)
+ }
+
+ want := []string{
+ "executor.start", "store.start", "stats.start", "dispatcher.start",
+ "server.run", "server.done", "offload.stop", "executor.flush",
+ "executor.stop", "dispatcher.idle", "dispatcher.stop", "stats.stop", "store.stop",
+ }
+ assertLifecycleCalls(t, log.Values(), want)
+}
+
+func TestRuntimeRunContextReportsErrorsAndCompletesShutdown(t *testing.T) {
+ runFailure := errors.New("server failed")
+ flushFailure := errors.New("flush failed")
+ log := &lifecycleLog{}
+ runtime := runtimeWithFakes(log)
+ runtime.server = &serverComponent{log: log, started: make(chan struct{}), runErr: runFailure}
+ runtime.firewallExecutor.(*executorComponent).flushErr = flushFailure
+
+ err := runtime.RunContext(context.Background())
+ if !errors.Is(err, runFailure) || !errors.Is(err, flushFailure) {
+ t.Fatalf("LIFE-ERROR-201 error = %v, want server and flush failures", err)
+ }
+ wantTail := []string{
+ "offload.stop", "executor.flush", "executor.stop",
+ "dispatcher.idle", "dispatcher.stop", "stats.stop", "store.stop",
+ }
+ calls := log.Values()
+ assertLifecycleCalls(t, calls[len(calls)-len(wantTail):], wantTail)
+}
+
+func runtimeWithFakes(log *lifecycleLog) *Runtime {
+ return &Runtime{
+ config: config.DefaultConfig(),
+ version: "test",
+ statsReporter: &lifecycleComponent{log: log, name: "stats"},
+ profileStore: &lifecycleComponent{log: log, name: "store"},
+ firewallExecutor: &executorComponent{lifecycleComponent: &lifecycleComponent{log: log, name: "executor"}},
+ dispatcher: &dispatcherComponent{lifecycleComponent: &lifecycleComponent{log: log, name: "dispatcher"}},
+ offload: &offloadComponent{log: log},
+ shutdownTimeout: time.Second,
+ }
+}
+
+func assertLifecycleCalls(t *testing.T, got, want []string) {
+ t.Helper()
+ if len(got) != len(want) {
+ t.Fatalf("lifecycle calls = %v, want %v", got, want)
+ }
+ for index := range want {
+ if got[index] != want[index] {
+ t.Fatalf("lifecycle call %d = %q, want %q; all calls: %v", index, got[index], want[index], got)
+ }
+ }
+}
+
+func waitAppClosed(t *testing.T, channel <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-channel:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for runtime start")
+ }
+}
+
+func waitAppValue[T any](t *testing.T, channel <-chan T) T {
+ t.Helper()
+ select {
+ case value := <-channel:
+ return value
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for runtime completion")
+ var zero T
+ return zero
+ }
+}
diff --git a/UA-Mask/core/internal/app/logging.go b/UA-Mask/core/internal/app/logging.go
new file mode 100644
index 000000000..a0b62e00a
--- /dev/null
+++ b/UA-Mask/core/internal/app/logging.go
@@ -0,0 +1,34 @@
+package app
+
+import (
+ "os"
+
+ "github.com/sirupsen/logrus"
+ "gopkg.in/natefinch/lumberjack.v2"
+)
+
+func setupLogging(logLevel, logFile string) {
+ if logFile != "" {
+ logFileRotator := &lumberjack.Logger{
+ Filename: logFile,
+ MaxSize: 1,
+ MaxBackups: 3,
+ MaxAge: 7,
+ Compress: false,
+ }
+ logrus.SetOutput(logFileRotator)
+ } else {
+ logrus.SetOutput(os.Stdout)
+ }
+
+ level, err := logrus.ParseLevel(logLevel)
+ if err != nil {
+ logrus.Warnf("Invalid log level '%s', using 'info'", logLevel)
+ level = logrus.InfoLevel
+ }
+
+ logrus.SetLevel(level)
+ logrus.SetFormatter(&logrus.TextFormatter{
+ FullTimestamp: true,
+ })
+}
diff --git a/UA-Mask/core/internal/clock/clock.go b/UA-Mask/core/internal/clock/clock.go
new file mode 100644
index 000000000..abc757aeb
--- /dev/null
+++ b/UA-Mask/core/internal/clock/clock.go
@@ -0,0 +1,79 @@
+package clock
+
+import "time"
+
+type Timer interface {
+ C() <-chan time.Time
+ Stop() bool
+ Reset(time.Duration) bool
+}
+
+type Ticker interface {
+ C() <-chan time.Time
+ Stop()
+}
+
+type Clock interface {
+ Now() time.Time
+ After(time.Duration) <-chan time.Time
+ AfterFunc(time.Duration, func()) Timer
+ NewTimer(time.Duration) Timer
+ NewTicker(time.Duration) Ticker
+}
+
+type RealClock struct{}
+
+func (RealClock) Now() time.Time {
+ return time.Now()
+}
+
+func (RealClock) After(duration time.Duration) <-chan time.Time {
+ return time.After(duration)
+}
+
+func (RealClock) AfterFunc(duration time.Duration, callback func()) Timer {
+ return realTimer{timer: time.AfterFunc(duration, callback)}
+}
+
+func (RealClock) NewTimer(duration time.Duration) Timer {
+ return realTimer{timer: time.NewTimer(duration)}
+}
+
+func (RealClock) NewTicker(duration time.Duration) Ticker {
+ return realTicker{ticker: time.NewTicker(duration)}
+}
+
+func OrReal(value Clock) Clock {
+ if value == nil {
+ return RealClock{}
+ }
+ return value
+}
+
+type realTimer struct {
+ timer *time.Timer
+}
+
+func (timer realTimer) C() <-chan time.Time {
+ return timer.timer.C
+}
+
+func (timer realTimer) Stop() bool {
+ return timer.timer.Stop()
+}
+
+func (timer realTimer) Reset(duration time.Duration) bool {
+ return timer.timer.Reset(duration)
+}
+
+type realTicker struct {
+ ticker *time.Ticker
+}
+
+func (ticker realTicker) C() <-chan time.Time {
+ return ticker.ticker.C
+}
+
+func (ticker realTicker) Stop() {
+ ticker.ticker.Stop()
+}
diff --git a/UA-Mask/core/internal/config/behavior_test.go b/UA-Mask/core/internal/config/behavior_test.go
new file mode 100644
index 000000000..fd56704a9
--- /dev/null
+++ b/UA-Mask/core/internal/config/behavior_test.go
@@ -0,0 +1,244 @@
+package config
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPerformanceProfileMatrix(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ profile string
+ cacheSize int
+ bufferSize int
+ poolSize int
+ gcPercent int
+ wantError string
+ }{
+ {name: "CFG-PROFILE-001/low", profile: "low", cacheSize: 2000, bufferSize: 8192, poolSize: 200, gcPercent: 100},
+ {name: "CFG-PROFILE-002/medium", profile: "Medium", cacheSize: 3000, bufferSize: 8192, poolSize: 500, gcPercent: 100},
+ {name: "CFG-PROFILE-003/high", profile: "HIGH", cacheSize: 5000, bufferSize: 8192, poolSize: 1000, gcPercent: 100},
+ {name: "CFG-PROFILE-004/high throughput alias", profile: "high_throughput", cacheSize: 5000, bufferSize: 8192, poolSize: 1000, gcPercent: 100},
+ {name: "CFG-PROFILE-005/custom", profile: "custom", cacheSize: 71, bufferSize: 4096, poolSize: 9, gcPercent: 80},
+ {name: "CFG-PROFILE-006/invalid", profile: "turbo", wantError: `invalid performance profile "turbo"`},
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ path := writeConfigFile(t, fmt.Sprintf(`{
+ "schema_version": 1,
+ "performance": {
+ "profile": %q,
+ "cache_size": 71,
+ "buffer_size": 4096,
+ "pool_size": 9,
+ "gc_percent": 80
+ }
+}`, test.profile))
+ cfg, err := LoadFile(path)
+ if test.wantError != "" {
+ if err == nil || !strings.Contains(err.Error(), test.wantError) {
+ t.Fatalf("LoadFile() error = %v, want substring %q", err, test.wantError)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("LoadFile(): %v", err)
+ }
+ if cfg.Rewrite.CacheSize != test.cacheSize || cfg.Listen.BufferSize != test.bufferSize ||
+ cfg.Listen.PoolSize != test.poolSize || cfg.Performance.GCPercent != test.gcPercent {
+ t.Fatalf("profile %q resolved to rewrite=%+v listen=%+v performance=%+v", test.profile, cfg.Rewrite, cfg.Listen, cfg.Performance)
+ }
+ })
+ }
+}
+
+func TestCustomProfileFixture(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join("testdata", "valid", "schema-v1-custom-profile.json")
+ cfg, err := LoadFile(path)
+ if err != nil {
+ t.Fatalf("LoadFile(%q): %v", path, err)
+ }
+ if cfg.Performance.Profile != "custom" || cfg.Rewrite.CacheSize != 42 ||
+ cfg.Listen.BufferSize != 4096 || cfg.Listen.PoolSize != 3 || cfg.Performance.GCPercent != -1 {
+ t.Fatalf("unexpected custom profile: rewrite=%+v listen=%+v performance=%+v", cfg.Rewrite, cfg.Listen, cfg.Performance)
+ }
+}
+
+func TestLegacyFlagOverrideMatrix(t *testing.T) {
+ t.Parallel()
+
+ fixture := filepath.Join("testdata", "valid", "schema-v1-full.json")
+ tests := []struct {
+ name string
+ args []string
+ check func(*testing.T, *Config)
+ }{
+ {name: "CFG-LEGACY-001/user agent", args: []string{"-u", "LegacyUA"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Rewrite.UserAgent, "LegacyUA") }},
+ {name: "CFG-LEGACY-002/port", args: []string{"-port", "18080"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Listen.Port, 18080) }},
+ {name: "CFG-LEGACY-003/log level", args: []string{"-loglevel", "DEBUG"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Observe.LogLevel, "debug") }},
+ {name: "CFG-LEGACY-004/log file", args: []string{"-log", "/tmp/legacy.log"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Observe.LogFile, "/tmp/legacy.log") }},
+ {name: "CFG-LEGACY-005/whitelist", args: []string{"-w", "Client A, Client B"}, check: func(t *testing.T, cfg *Config) { assertStringList(t, cfg.Rewrite.Whitelist, "Client A", "Client B") }},
+ {name: "CFG-LEGACY-006/force", args: []string{"-force"}, check: func(t *testing.T, cfg *Config) {
+ assertEqual(t, cfg.Rewrite.ForceReplace, true)
+ assertEqual(t, cfg.Rewrite.EnableRegex, false)
+ }},
+ {name: "CFG-LEGACY-007/regex", args: []string{"-enable-regex"}, check: func(t *testing.T, cfg *Config) {
+ assertEqual(t, cfg.Rewrite.EnableRegex, true)
+ assertEqual(t, cfg.Rewrite.ForceReplace, false)
+ }},
+ {name: "CFG-LEGACY-008/partial replace", args: []string{"-s"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Rewrite.EnablePartialReplace, true) }},
+ {name: "CFG-LEGACY-009/keywords", args: []string{"-keywords", "Android,Linux"}, check: func(t *testing.T, cfg *Config) { assertStringList(t, cfg.Rewrite.Keywords, "Android", "Linux") }},
+ {name: "CFG-LEGACY-010/pattern", args: []string{"-r", "Android|Linux"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Rewrite.Pattern, "Android|Linux") }},
+ {name: "CFG-LEGACY-011/cache size", args: []string{"-cache-size", "55"}, check: func(t *testing.T, cfg *Config) {
+ assertEqual(t, cfg.Rewrite.CacheSize, 55)
+ assertEqual(t, cfg.Performance.Profile, "custom")
+ }},
+ {name: "CFG-LEGACY-012/buffer size", args: []string{"-buffer-size", "2048"}, check: func(t *testing.T, cfg *Config) {
+ assertEqual(t, cfg.Listen.BufferSize, 2048)
+ assertEqual(t, cfg.Performance.Profile, "custom")
+ }},
+ {name: "CFG-LEGACY-013/pool size", args: []string{"-p", "3"}, check: func(t *testing.T, cfg *Config) {
+ assertEqual(t, cfg.Listen.PoolSize, 3)
+ assertEqual(t, cfg.Performance.Profile, "custom")
+ }},
+ {name: "CFG-LEGACY-014/firewall UA whitelist", args: []string{"-fw-ua-w", "Steam,Valve"}, check: func(t *testing.T, cfg *Config) { assertStringList(t, cfg.Firewall.UAWhitelist, "Steam", "Valve") }},
+ {name: "CFG-LEGACY-015/firewall bypass", args: []string{"-fw-bypass"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Firewall.EnableBypass, true) }},
+ {name: "CFG-LEGACY-016/firewall set name", args: []string{"-fw-set-name", "legacy_set"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Firewall.SetName, "legacy_set") }},
+ {name: "CFG-LEGACY-017/firewall backend", args: []string{"-fw-type", "IPT"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Firewall.Backend, "ipt") }},
+ {name: "CFG-LEGACY-018/firewall drop", args: []string{"-fw-drop"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Firewall.DropOnMatch, true) }},
+ {name: "CFG-LEGACY-019/non-HTTP threshold", args: []string{"-fw-nonhttp-threshold", "9"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Firewall.NonHTTPThreshold, 9) }},
+ {name: "CFG-LEGACY-020/firewall timeout", args: []string{"-fw-timeout", "90"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Firewall.Timeout, 90) }},
+ {name: "CFG-LEGACY-021/decision delay", args: []string{"-fw-decision-delay", "12s"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Firewall.DecisionDelay, 12*time.Second) }},
+ {name: "CFG-LEGACY-022/HTTP cooldown", args: []string{"-fw-http-cooldown", "45m"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Firewall.HTTPCooldownPeriod, 45*time.Minute) }},
+ {name: "CFG-LEGACY-023/stats file", args: []string{"-stats-file", "/tmp/legacy.stats"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Observe.StatsFilePath, "/tmp/legacy.stats") }},
+ {name: "CFG-LEGACY-024/stats interval", args: []string{"-stats-interval", "11s"}, check: func(t *testing.T, cfg *Config) { assertEqual(t, cfg.Observe.StatsInterval, 11*time.Second) }},
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ args := append([]string{"-config", fixture}, test.args...)
+ command, err := LoadFromArgs(args)
+ if err != nil {
+ t.Fatalf("LoadFromArgs(%v): %v", args, err)
+ }
+ test.check(t, command.Config)
+ })
+ }
+}
+
+func TestLegacyBooleanFlagsCanDisableJSONValues(t *testing.T) {
+ t.Parallel()
+
+ path := writeConfigFile(t, `{
+ "schema_version": 1,
+ "rewrite": {"mode": "all", "partial_replace": true},
+ "firewall": {"drop_on_match": true, "non_http": {"enabled": true}}
+}`)
+ command, err := LoadFromArgs([]string{
+ "-config", path,
+ "-force=false",
+ "-s=false",
+ "-fw-bypass=false",
+ "-fw-drop=false",
+ })
+ if err != nil {
+ t.Fatalf("LoadFromArgs(): %v", err)
+ }
+ if command.Config.Rewrite.ForceReplace || command.Config.Rewrite.EnablePartialReplace ||
+ command.Config.Firewall.EnableBypass || command.Config.Firewall.DropOnMatch {
+ t.Fatalf("explicit false flags were not applied: %+v", command.Config)
+ }
+}
+
+func TestLegacyRewriteModePrecedence(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ mode string
+ args []string
+ wantForce bool
+ wantRegex bool
+ }{
+ {name: "CFG-LEGACY-025/force overrides JSON regex", mode: "regex", args: []string{"-force"}, wantForce: true},
+ {name: "CFG-LEGACY-026/regex overrides JSON force", mode: "all", args: []string{"-enable-regex"}, wantRegex: true},
+ {name: "CFG-LEGACY-027/force wins conflicting flags", mode: "keywords", args: []string{"-enable-regex", "-force"}, wantForce: true},
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ path := writeConfigFile(t, fmt.Sprintf(`{"schema_version":1,"rewrite":{"mode":%q}}`, test.mode))
+ command, err := LoadFromArgs(append([]string{"-config", path}, test.args...))
+ if err != nil {
+ t.Fatalf("LoadFromArgs(): %v", err)
+ }
+ if command.Config.Rewrite.ForceReplace != test.wantForce || command.Config.Rewrite.EnableRegex != test.wantRegex {
+ t.Fatalf("rewrite mode = force:%t regex:%t, want force:%t regex:%t", command.Config.Rewrite.ForceReplace, command.Config.Rewrite.EnableRegex, test.wantForce, test.wantRegex)
+ }
+ })
+ }
+}
+
+func TestEffectiveConfigGoldenFixtures(t *testing.T) {
+ t.Parallel()
+
+ fixtures := []string{"schema-v1-minimal", "schema-v1-full"}
+ for _, fixture := range fixtures {
+ fixture := fixture
+ t.Run("CFG-GOLDEN/"+fixture, func(t *testing.T) {
+ t.Parallel()
+ inputPath := filepath.Join("testdata", "valid", fixture+".json")
+ goldenPath := filepath.Join("testdata", "effective", fixture+".golden.json")
+ cfg, err := LoadFile(inputPath)
+ if err != nil {
+ t.Fatalf("LoadFile(%q): %v", inputPath, err)
+ }
+ var output bytes.Buffer
+ if err := WriteEffective(&output, cfg); err != nil {
+ t.Fatalf("WriteEffective(): %v", err)
+ }
+ if os.Getenv("UPDATE_GOLDEN") == "1" {
+ if err := os.WriteFile(goldenPath, output.Bytes(), 0o644); err != nil {
+ t.Fatalf("update golden %q: %v", goldenPath, err)
+ }
+ }
+ want, err := os.ReadFile(goldenPath)
+ if err != nil {
+ t.Fatalf("read golden %q: %v", goldenPath, err)
+ }
+ if !bytes.Equal(output.Bytes(), want) {
+ t.Fatalf("effective config differs from %s\n--- got ---\n%s\n--- want ---\n%s", goldenPath, output.Bytes(), want)
+ }
+ })
+ }
+}
+
+func assertEqual[T comparable](t *testing.T, got, want T) {
+ t.Helper()
+ if got != want {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func assertStringList(t *testing.T, got []string, want ...string) {
+ t.Helper()
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
+ t.Fatalf("got list %q, want %q", got, want)
+ }
+}
diff --git a/UA-Mask/core/internal/config/config.go b/UA-Mask/core/internal/config/config.go
new file mode 100644
index 000000000..577ca2955
--- /dev/null
+++ b/UA-Mask/core/internal/config/config.go
@@ -0,0 +1,102 @@
+package config
+
+import (
+ "regexp"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+const SchemaVersion = 1
+
+type Config struct {
+ Listen ListenConfig
+ Rewrite RewriteConfig
+ RewriteRuntime RewriteRuntimeConfig
+ Firewall FirewallConfig
+ Observe ObserveConfig
+ Performance PerformanceConfig
+}
+
+type ListenConfig struct {
+ Port int
+ PoolSize int
+ BufferSize int
+ DialTimeout time.Duration
+ ClientKeepAlive time.Duration
+ UpstreamKeepAlive time.Duration
+}
+
+type RewriteConfig struct {
+ UserAgent string
+ Whitelist []string
+ ForceReplace bool
+ EnableRegex bool
+ EnablePartialReplace bool
+ Keywords []string
+ Pattern string
+ CacheSize int
+}
+
+type RewriteRuntimeConfig struct {
+ UARegexp *regexp.Regexp
+}
+
+type FirewallConfig struct {
+ UAWhitelist []string
+ EnableBypass bool
+ SetName string
+ Backend string
+ DropOnMatch bool
+ NonHTTPThreshold int
+ Timeout int
+ DecisionDelay time.Duration
+ HTTPCooldownPeriod time.Duration
+ ProfileCleanupInterval time.Duration
+ ImmediateBypassTimeout int
+}
+
+type ObserveConfig struct {
+ LogLevel string
+ LogFile string
+ StatsFilePath string
+ StatsInterval time.Duration
+}
+
+type PerformanceConfig struct {
+ Profile string
+ GCPercent int
+}
+
+func (c *Config) LogConfig(version string) {
+ logrus.Infof("UA-MASK v%s", version)
+ logrus.Infof("Port: %d", c.Listen.Port)
+ logrus.Infof("User-Agent: %s", c.Rewrite.UserAgent)
+ logrus.Infof("Log level: %s", c.Observe.LogLevel)
+ logrus.Infof("User-Agent Whitelist: %v", c.Rewrite.Whitelist)
+ logrus.Infof("Cache Size: %d", c.Rewrite.CacheSize)
+ logrus.Infof("Buffer Size: %d", c.Listen.BufferSize)
+ logrus.Infof("Worker Pool Size: %d", c.Listen.PoolSize)
+ logrus.Infof("Performance Profile: %s", c.Performance.Profile)
+ logrus.Infof("GC Percent: %d", c.Performance.GCPercent)
+ logrus.Infof("Stats File: %s", c.Observe.StatsFilePath)
+ logrus.Infof("Stats Interval: %s", c.Observe.StatsInterval)
+
+ logrus.Infof("Firewall Type: %s", c.Firewall.Backend)
+ logrus.Infof("Firewall IPSet Name: %s", c.Firewall.SetName)
+ logrus.Infof("Firewall UA Whitelist: %v", c.Firewall.UAWhitelist)
+ logrus.Infof("Enable Firewall Non-HTTP Bypass: %v", c.Firewall.EnableBypass)
+ logrus.Infof("Firewall Drop On Match: %v", c.Firewall.DropOnMatch)
+ logrus.Infof("Firewall Non-HTTP Threshold: %d", c.Firewall.NonHTTPThreshold)
+ logrus.Infof("Firewall Rule Timeout (seconds): %d", c.Firewall.Timeout)
+ logrus.Infof("Firewall Decision Delay: %s", c.Firewall.DecisionDelay)
+ logrus.Infof("Firewall HTTP Cooldown Period: %s", c.Firewall.HTTPCooldownPeriod)
+
+ if c.Rewrite.ForceReplace {
+ logrus.Info("Mode: Force Replace (All)")
+ } else if c.Rewrite.EnableRegex {
+ logrus.Infof("Mode: Regex | Pattern: %s | Partial Replace: %v", c.Rewrite.Pattern, c.Rewrite.EnablePartialReplace)
+ } else {
+ logrus.Infof("Mode: Keywords | Keywords: %v", c.Rewrite.Keywords)
+ }
+}
diff --git a/UA-Mask/core/internal/config/config_test.go b/UA-Mask/core/internal/config/config_test.go
new file mode 100644
index 000000000..c386c2ef9
--- /dev/null
+++ b/UA-Mask/core/internal/config/config_test.go
@@ -0,0 +1,216 @@
+package config
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestLoadJSONConfigNormalizesDurationsListsAndProfile(t *testing.T) {
+ t.Parallel()
+
+ path := writeConfigFile(t, `{
+ "schema_version": 1,
+ "listen": {
+ "port": 12032,
+ "dial_timeout": "15s"
+ },
+ "rewrite": {
+ "user_agent": "MaskUA",
+ "mode": "regex",
+ "pattern": "Android|Linux",
+ "partial_replace": true,
+ "whitelist": [" KeepMe ", ""]
+ },
+ "firewall": {
+ "backend": "nft",
+ "ua_whitelist": ["Steam"],
+ "whitelist_timeout": "12h",
+ "non_http": {
+ "enabled": true,
+ "threshold": 7,
+ "decision_delay": "45s",
+ "http_cooldown": "30m",
+ "timeout": "4h"
+ }
+ },
+ "performance": {
+ "profile": "Medium"
+ }
+}`)
+
+ cfg, err := LoadFile(path)
+ if err != nil {
+ t.Fatalf("LoadFile: %v", err)
+ }
+ if cfg.Listen.Port != 12032 || cfg.Listen.DialTimeout != 15*time.Second {
+ t.Fatalf("unexpected listen config: %+v", cfg.Listen)
+ }
+ if !cfg.Rewrite.EnableRegex || cfg.Rewrite.ForceReplace || cfg.RewriteRuntime.UARegexp == nil {
+ t.Fatalf("expected compiled regex mode, got %+v", cfg.Rewrite)
+ }
+ if len(cfg.Rewrite.Whitelist) != 1 || cfg.Rewrite.Whitelist[0] != "KeepMe" {
+ t.Fatalf("unexpected whitelist: %v", cfg.Rewrite.Whitelist)
+ }
+ if cfg.Firewall.Backend != "nft" || cfg.Firewall.Timeout != 4*3600 || cfg.Firewall.ImmediateBypassTimeout != 12*3600 {
+ t.Fatalf("unexpected firewall config: %+v", cfg.Firewall)
+ }
+ if cfg.Performance.Profile != "medium" || cfg.Rewrite.CacheSize != 3000 || cfg.Listen.PoolSize != 500 {
+ t.Fatalf("performance profile was not applied: %+v listen=%+v rewrite=%+v", cfg.Performance, cfg.Listen, cfg.Rewrite)
+ }
+}
+
+func TestLoadJSONConfigRejectsUnknownFieldsAndMissingVersion(t *testing.T) {
+ t.Parallel()
+
+ unknown := writeConfigFile(t, `{"schema_version":1,"listen":{"unknown":true}}`)
+ if _, err := LoadFile(unknown); err == nil || !strings.Contains(err.Error(), "unknown field") {
+ t.Fatalf("expected unknown field error, got %v", err)
+ }
+
+ missingVersion := writeConfigFile(t, `{"listen":{"port":12032}}`)
+ if _, err := LoadFile(missingVersion); err == nil || !strings.Contains(err.Error(), "schema_version") {
+ t.Fatalf("expected schema version error, got %v", err)
+ }
+}
+
+func TestLoadJSONConfigErrorsIncludePath(t *testing.T) {
+ t.Parallel()
+
+ path := writeConfigFile(t, `{"schema_version":1,"listen":{"port":0}}`)
+ if _, err := LoadFile(path); err == nil || !strings.Contains(err.Error(), path) {
+ t.Fatalf("expected config path in error, got %v", err)
+ }
+}
+
+func TestStrictJSONFixtures(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ fixture string
+ wantError string
+ }{
+ {name: "CFG-NULL-001/null scalar", fixture: "cfg-null-001-null-scalar.json", wantError: "null is not allowed at $.listen.port"},
+ {name: "CFG-NULL-002/null object", fixture: "cfg-null-002-null-object.json", wantError: "null is not allowed at $.performance"},
+ {name: "CFG-NULL-003/null array element", fixture: "cfg-null-003-null-array-element.json", wantError: "null is not allowed at $.rewrite.keywords[1]"},
+ {name: "CFG-DUP-001/duplicate key", fixture: "cfg-dup-001-duplicate-key.json", wantError: `duplicate field "port" at $.listen.port`},
+ {name: "CFG-KEY-001/wrong field case", fixture: "cfg-key-001-wrong-case.json", wantError: `unknown field "Listen" at $.Listen`},
+ {name: "CFG-EOF-001/multiple values", fixture: "cfg-eof-001-multiple-values.json", wantError: "multiple JSON values"},
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ path := filepath.Join("testdata", "invalid", test.fixture)
+ if _, err := LoadFile(path); err == nil || !strings.Contains(err.Error(), test.wantError) {
+ t.Fatalf("LoadFile(%q) error = %v, want substring %q", path, err, test.wantError)
+ }
+ })
+ }
+}
+
+func TestMinimalJSONFixtureUsesDefaults(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join("testdata", "valid", "schema-v1-minimal.json")
+ cfg, err := LoadFile(path)
+ if err != nil {
+ t.Fatalf("LoadFile(%q): %v", path, err)
+ }
+ defaults := DefaultConfig()
+ if cfg.Listen != defaults.Listen || cfg.Performance != defaults.Performance {
+ t.Fatalf("minimal fixture did not retain defaults: got=%+v defaults=%+v", cfg, defaults)
+ }
+}
+
+func TestLegacyFlagsOverrideJSONConfig(t *testing.T) {
+ t.Parallel()
+
+ path := writeConfigFile(t, `{
+ "schema_version": 1,
+ "listen": {"port": 12032},
+ "rewrite": {"mode": "regex", "pattern": "Android"},
+ "performance": {"profile": "high"}
+}`)
+ command, err := LoadFromArgs([]string{
+ "-config", path,
+ "-port", "18080",
+ "-force",
+ "-cache-size", "42",
+ "-check-config",
+ })
+ if err != nil {
+ t.Fatalf("LoadFromArgs: %v", err)
+ }
+ if !command.CheckConfig || command.ConfigPath != path {
+ t.Fatalf("unexpected command: %+v", command)
+ }
+ if command.Config.Listen.Port != 18080 || command.Config.Rewrite.CacheSize != 42 {
+ t.Fatalf("legacy overrides were not applied: %+v", command.Config)
+ }
+ if !command.Config.Rewrite.ForceReplace || command.Config.Rewrite.EnableRegex {
+ t.Fatalf("force flag must override JSON rewrite mode: %+v", command.Config.Rewrite)
+ }
+ if command.Config.Performance.Profile != "custom" {
+ t.Fatalf("tuning override must switch to custom profile: %+v", command.Config.Performance)
+ }
+}
+
+func TestWriteEffectiveProducesReloadableJSON(t *testing.T) {
+ t.Parallel()
+
+ cfg, err := NewFromArgs([]string{"-port", "12345", "-keywords", "Android,Linux"})
+ if err != nil {
+ t.Fatalf("NewFromArgs: %v", err)
+ }
+ var output bytes.Buffer
+ if err := WriteEffective(&output, cfg); err != nil {
+ t.Fatalf("WriteEffective: %v", err)
+ }
+ path := filepath.Join(t.TempDir(), "effective.json")
+ if err := os.WriteFile(path, output.Bytes(), 0o600); err != nil {
+ t.Fatalf("write effective config: %v", err)
+ }
+ reloaded, err := LoadFile(path)
+ if err != nil {
+ t.Fatalf("reload effective config: %v\n%s", err, output.String())
+ }
+ if reloaded.Listen.Port != cfg.Listen.Port || strings.Join(reloaded.Rewrite.Keywords, ",") != "Android,Linux" {
+ t.Fatalf("effective config changed after reload: original=%+v reloaded=%+v", cfg, reloaded)
+ }
+}
+
+func TestJSONDurationMustUseWholeSecondsForFirewallTimeout(t *testing.T) {
+ t.Parallel()
+
+ path := writeConfigFile(t, `{
+ "schema_version": 1,
+ "firewall": {"non_http": {"timeout": "1500ms"}}
+}`)
+ if _, err := LoadFile(path); err == nil || !strings.Contains(err.Error(), "whole seconds") {
+ t.Fatalf("expected whole-second validation error, got %v", err)
+ }
+}
+
+func TestDocumentedExampleConfigLoads(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join("..", "..", "..", "docs", "config.example.json")
+ if _, err := LoadFile(path); err != nil {
+ t.Fatalf("documented example config must remain valid: %v", err)
+ }
+}
+
+func writeConfigFile(t *testing.T, content string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("write config file: %v", err)
+ }
+ return path
+}
diff --git a/UA-Mask/core/internal/config/defaults.go b/UA-Mask/core/internal/config/defaults.go
new file mode 100644
index 000000000..dce2b2a1c
--- /dev/null
+++ b/UA-Mask/core/internal/config/defaults.go
@@ -0,0 +1,53 @@
+package config
+
+import "time"
+
+const (
+ defaultPort = 8080
+ defaultBufferSize = 8192
+ defaultStatsFile = "/tmp/UAmask.stats"
+ defaultStatsInterval = 5 * time.Second
+ defaultDialTimeout = 30 * time.Second
+ defaultKeepAlive = 3 * time.Minute
+ defaultProfileCleanup = 10 * time.Minute
+ defaultImmediateBypassExpiry = 24 * 3600
+ defaultPattern = "(iPhone|iPad|Android|Macintosh|Windows|Linux|Apple|Mac OS X|Mobile)"
+)
+
+func DefaultConfig() *Config {
+ return &Config{
+ Listen: ListenConfig{
+ Port: defaultPort,
+ PoolSize: 0,
+ BufferSize: defaultBufferSize,
+ DialTimeout: defaultDialTimeout,
+ ClientKeepAlive: defaultKeepAlive,
+ UpstreamKeepAlive: defaultKeepAlive,
+ },
+ Rewrite: RewriteConfig{
+ UserAgent: "FFF",
+ Keywords: []string{"iPhone", "iPad", "Android", "Macintosh", "Windows"},
+ Pattern: defaultPattern,
+ CacheSize: 1000,
+ },
+ Firewall: FirewallConfig{
+ SetName: "UAmask_bypass_set",
+ Backend: "ipt",
+ NonHTTPThreshold: 5,
+ Timeout: 8 * 3600,
+ DecisionDelay: 60 * time.Second,
+ HTTPCooldownPeriod: time.Hour,
+ ProfileCleanupInterval: defaultProfileCleanup,
+ ImmediateBypassTimeout: defaultImmediateBypassExpiry,
+ },
+ Observe: ObserveConfig{
+ LogLevel: "info",
+ StatsFilePath: defaultStatsFile,
+ StatsInterval: defaultStatsInterval,
+ },
+ Performance: PerformanceConfig{
+ Profile: "custom",
+ GCPercent: 100,
+ },
+ }
+}
diff --git a/UA-Mask/core/internal/config/file.go b/UA-Mask/core/internal/config/file.go
new file mode 100644
index 000000000..ef63cd3d7
--- /dev/null
+++ b/UA-Mask/core/internal/config/file.go
@@ -0,0 +1,378 @@
+package config
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+)
+
+type fileConfig struct {
+ SchemaVersion int `json:"schema_version"`
+ Listen fileListenConfig `json:"listen"`
+ Rewrite fileRewriteConfig `json:"rewrite"`
+ Firewall fileFirewallConfig `json:"firewall"`
+ Observe fileObserveConfig `json:"observe"`
+ Performance filePerformanceConfig `json:"performance"`
+}
+
+type fileListenConfig struct {
+ Port int `json:"port"`
+ DialTimeout string `json:"dial_timeout"`
+ ClientKeepAlive string `json:"client_keep_alive"`
+ UpstreamKeepAlive string `json:"upstream_keep_alive"`
+}
+
+type fileRewriteConfig struct {
+ UserAgent string `json:"user_agent"`
+ Mode string `json:"mode"`
+ Keywords []string `json:"keywords"`
+ Whitelist []string `json:"whitelist"`
+ Pattern string `json:"pattern"`
+ PartialReplace bool `json:"partial_replace"`
+}
+
+type fileFirewallConfig struct {
+ Backend string `json:"backend"`
+ SetName string `json:"set_name"`
+ UAWhitelist []string `json:"ua_whitelist"`
+ DropOnMatch bool `json:"drop_on_match"`
+ WhitelistTimeout string `json:"whitelist_timeout"`
+ NonHTTP fileNonHTTPFirewallConfig `json:"non_http"`
+}
+
+type fileNonHTTPFirewallConfig struct {
+ Enabled bool `json:"enabled"`
+ Threshold int `json:"threshold"`
+ DecisionDelay string `json:"decision_delay"`
+ HTTPCooldown string `json:"http_cooldown"`
+ Timeout string `json:"timeout"`
+ CleanupInterval string `json:"cleanup_interval"`
+}
+
+type fileObserveConfig struct {
+ LogLevel string `json:"log_level"`
+ LogFile string `json:"log_file"`
+ StatsFilePath string `json:"stats_file"`
+ StatsInterval string `json:"stats_interval"`
+}
+
+type filePerformanceConfig struct {
+ Profile string `json:"profile"`
+ CacheSize int `json:"cache_size"`
+ BufferSize int `json:"buffer_size"`
+ PoolSize int `json:"pool_size"`
+ GCPercent int `json:"gc_percent"`
+}
+
+func LoadFile(path string) (*Config, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read config file %q: %w", path, err)
+ }
+ if err := validateJSONDocument(data, fileConfig{}); err != nil {
+ return nil, fmt.Errorf("validate config file %q: %w", path, err)
+ }
+
+ raw := defaultFileConfig()
+ raw.SchemaVersion = 0
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&raw); err != nil {
+ return nil, fmt.Errorf("decode config file %q: %w", path, err)
+ }
+ if err := ensureJSONEOF(decoder); err != nil {
+ return nil, fmt.Errorf("decode config file %q: %w", path, err)
+ }
+ if raw.SchemaVersion != SchemaVersion {
+ return nil, fmt.Errorf("config file %q: unsupported schema_version %d, expected %d", path, raw.SchemaVersion, SchemaVersion)
+ }
+
+ cfg, err := raw.toConfig()
+ if err != nil {
+ return nil, fmt.Errorf("config file %q: %w", path, err)
+ }
+ if err := finalize(cfg); err != nil {
+ return nil, fmt.Errorf("config file %q: %w", path, err)
+ }
+ return cfg, nil
+}
+
+func WriteEffective(w io.Writer, cfg *Config) error {
+ encoder := json.NewEncoder(w)
+ encoder.SetIndent("", " ")
+ encoder.SetEscapeHTML(false)
+ if err := encoder.Encode(effectiveFileConfig(cfg)); err != nil {
+ return fmt.Errorf("encode effective config: %w", err)
+ }
+ return nil
+}
+
+func defaultFileConfig() fileConfig {
+ return effectiveFileConfig(DefaultConfig())
+}
+
+func effectiveFileConfig(cfg *Config) fileConfig {
+ return fileConfig{
+ SchemaVersion: SchemaVersion,
+ Listen: fileListenConfig{
+ Port: cfg.Listen.Port,
+ DialTimeout: cfg.Listen.DialTimeout.String(),
+ ClientKeepAlive: cfg.Listen.ClientKeepAlive.String(),
+ UpstreamKeepAlive: cfg.Listen.UpstreamKeepAlive.String(),
+ },
+ Rewrite: fileRewriteConfig{
+ UserAgent: cfg.Rewrite.UserAgent,
+ Mode: rewriteMode(cfg),
+ Keywords: cloneList(cfg.Rewrite.Keywords),
+ Whitelist: cloneList(cfg.Rewrite.Whitelist),
+ Pattern: cfg.Rewrite.Pattern,
+ PartialReplace: cfg.Rewrite.EnablePartialReplace,
+ },
+ Firewall: fileFirewallConfig{
+ Backend: cfg.Firewall.Backend,
+ SetName: cfg.Firewall.SetName,
+ UAWhitelist: cloneList(cfg.Firewall.UAWhitelist),
+ DropOnMatch: cfg.Firewall.DropOnMatch,
+ WhitelistTimeout: secondsDuration(cfg.Firewall.ImmediateBypassTimeout).String(),
+ NonHTTP: fileNonHTTPFirewallConfig{
+ Enabled: cfg.Firewall.EnableBypass,
+ Threshold: cfg.Firewall.NonHTTPThreshold,
+ DecisionDelay: cfg.Firewall.DecisionDelay.String(),
+ HTTPCooldown: cfg.Firewall.HTTPCooldownPeriod.String(),
+ Timeout: secondsDuration(cfg.Firewall.Timeout).String(),
+ CleanupInterval: cfg.Firewall.ProfileCleanupInterval.String(),
+ },
+ },
+ Observe: fileObserveConfig{
+ LogLevel: cfg.Observe.LogLevel,
+ LogFile: cfg.Observe.LogFile,
+ StatsFilePath: cfg.Observe.StatsFilePath,
+ StatsInterval: cfg.Observe.StatsInterval.String(),
+ },
+ Performance: filePerformanceConfig{
+ Profile: cfg.Performance.Profile,
+ CacheSize: cfg.Rewrite.CacheSize,
+ BufferSize: cfg.Listen.BufferSize,
+ PoolSize: cfg.Listen.PoolSize,
+ GCPercent: cfg.Performance.GCPercent,
+ },
+ }
+}
+
+func (raw fileConfig) toConfig() (*Config, error) {
+ dialTimeout, err := parseDuration("listen.dial_timeout", raw.Listen.DialTimeout)
+ if err != nil {
+ return nil, err
+ }
+ clientKeepAlive, err := parseDuration("listen.client_keep_alive", raw.Listen.ClientKeepAlive)
+ if err != nil {
+ return nil, err
+ }
+ upstreamKeepAlive, err := parseDuration("listen.upstream_keep_alive", raw.Listen.UpstreamKeepAlive)
+ if err != nil {
+ return nil, err
+ }
+ decisionDelay, err := parseDuration("firewall.non_http.decision_delay", raw.Firewall.NonHTTP.DecisionDelay)
+ if err != nil {
+ return nil, err
+ }
+ httpCooldown, err := parseDuration("firewall.non_http.http_cooldown", raw.Firewall.NonHTTP.HTTPCooldown)
+ if err != nil {
+ return nil, err
+ }
+ cleanupInterval, err := parseDuration("firewall.non_http.cleanup_interval", raw.Firewall.NonHTTP.CleanupInterval)
+ if err != nil {
+ return nil, err
+ }
+ statsInterval, err := parseDuration("observe.stats_interval", raw.Observe.StatsInterval)
+ if err != nil {
+ return nil, err
+ }
+ targetTimeout, err := parseSeconds("firewall.non_http.timeout", raw.Firewall.NonHTTP.Timeout)
+ if err != nil {
+ return nil, err
+ }
+ whitelistTimeout, err := parseSeconds("firewall.whitelist_timeout", raw.Firewall.WhitelistTimeout)
+ if err != nil {
+ return nil, err
+ }
+
+ cfg := &Config{
+ Listen: ListenConfig{
+ Port: raw.Listen.Port,
+ DialTimeout: dialTimeout,
+ ClientKeepAlive: clientKeepAlive,
+ UpstreamKeepAlive: upstreamKeepAlive,
+ },
+ Rewrite: RewriteConfig{
+ UserAgent: raw.Rewrite.UserAgent,
+ Whitelist: normalizeList(raw.Rewrite.Whitelist),
+ EnablePartialReplace: raw.Rewrite.PartialReplace,
+ Keywords: normalizeList(raw.Rewrite.Keywords),
+ Pattern: raw.Rewrite.Pattern,
+ },
+ Firewall: FirewallConfig{
+ UAWhitelist: normalizeList(raw.Firewall.UAWhitelist),
+ EnableBypass: raw.Firewall.NonHTTP.Enabled,
+ SetName: raw.Firewall.SetName,
+ Backend: strings.ToLower(raw.Firewall.Backend),
+ DropOnMatch: raw.Firewall.DropOnMatch,
+ NonHTTPThreshold: raw.Firewall.NonHTTP.Threshold,
+ Timeout: targetTimeout,
+ DecisionDelay: decisionDelay,
+ HTTPCooldownPeriod: httpCooldown,
+ ProfileCleanupInterval: cleanupInterval,
+ ImmediateBypassTimeout: whitelistTimeout,
+ },
+ Observe: ObserveConfig{
+ LogLevel: strings.ToLower(raw.Observe.LogLevel),
+ LogFile: raw.Observe.LogFile,
+ StatsFilePath: raw.Observe.StatsFilePath,
+ StatsInterval: statsInterval,
+ },
+ Performance: PerformanceConfig{
+ Profile: normalizeProfile(raw.Performance.Profile),
+ GCPercent: raw.Performance.GCPercent,
+ },
+ }
+
+ if err := applyRewriteMode(cfg, raw.Rewrite.Mode); err != nil {
+ return nil, err
+ }
+ if err := applyPerformanceProfile(cfg, raw.Performance); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
+
+func applyRewriteMode(cfg *Config, mode string) error {
+ switch strings.ToLower(strings.TrimSpace(mode)) {
+ case "keywords":
+ cfg.Rewrite.ForceReplace = false
+ cfg.Rewrite.EnableRegex = false
+ case "regex":
+ cfg.Rewrite.ForceReplace = false
+ cfg.Rewrite.EnableRegex = true
+ case "all":
+ cfg.Rewrite.ForceReplace = true
+ cfg.Rewrite.EnableRegex = false
+ default:
+ return fmt.Errorf("invalid rewrite mode %q", mode)
+ }
+ return nil
+}
+
+func applyPerformanceProfile(cfg *Config, performance filePerformanceConfig) error {
+ profile := normalizeProfile(performance.Profile)
+ cfg.Performance.Profile = profile
+ switch profile {
+ case "low":
+ cfg.Rewrite.CacheSize = 2000
+ cfg.Listen.BufferSize = 8192
+ cfg.Listen.PoolSize = 200
+ cfg.Performance.GCPercent = 100
+ case "medium":
+ cfg.Rewrite.CacheSize = 3000
+ cfg.Listen.BufferSize = 8192
+ cfg.Listen.PoolSize = 500
+ cfg.Performance.GCPercent = 100
+ case "high":
+ cfg.Rewrite.CacheSize = 5000
+ cfg.Listen.BufferSize = 8192
+ cfg.Listen.PoolSize = 1000
+ cfg.Performance.GCPercent = 100
+ case "custom":
+ cfg.Rewrite.CacheSize = performance.CacheSize
+ cfg.Listen.BufferSize = performance.BufferSize
+ cfg.Listen.PoolSize = performance.PoolSize
+ cfg.Performance.GCPercent = performance.GCPercent
+ default:
+ return fmt.Errorf("invalid performance profile %q", performance.Profile)
+ }
+ return nil
+}
+
+func normalizeProfile(profile string) string {
+ switch strings.ToLower(strings.TrimSpace(profile)) {
+ case "low":
+ return "low"
+ case "medium":
+ return "medium"
+ case "high", "high_throughput":
+ return "high"
+ case "custom", "":
+ return "custom"
+ default:
+ return strings.ToLower(strings.TrimSpace(profile))
+ }
+}
+
+func rewriteMode(cfg *Config) string {
+ if cfg.Rewrite.ForceReplace {
+ return "all"
+ }
+ if cfg.Rewrite.EnableRegex {
+ return "regex"
+ }
+ return "keywords"
+}
+
+func parseDuration(field, value string) (time.Duration, error) {
+ duration, err := time.ParseDuration(value)
+ if err != nil {
+ return 0, fmt.Errorf("invalid %s duration %q: %w", field, value, err)
+ }
+ return duration, nil
+}
+
+func parseSeconds(field, value string) (int, error) {
+ duration, err := parseDuration(field, value)
+ if err != nil {
+ return 0, err
+ }
+ if duration%time.Second != 0 {
+ return 0, fmt.Errorf("%s must use whole seconds: %q", field, value)
+ }
+ seconds := duration / time.Second
+ maxInt := int64(^uint(0) >> 1)
+ if int64(seconds) > maxInt || seconds < 0 {
+ return 0, fmt.Errorf("%s is out of range: %q", field, value)
+ }
+ return int(seconds), nil
+}
+
+func secondsDuration(seconds int) time.Duration {
+ return time.Duration(seconds) * time.Second
+}
+
+func normalizeList(values []string) []string {
+ result := make([]string, 0, len(values))
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ result = append(result, value)
+ }
+ }
+ return result
+}
+
+func cloneList(values []string) []string {
+ result := make([]string, len(values))
+ copy(result, values)
+ return result
+}
+
+func ensureJSONEOF(decoder *json.Decoder) error {
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ if err == nil {
+ return fmt.Errorf("config file contains multiple JSON values")
+ }
+ return fmt.Errorf("decode trailing config data: %w", err)
+ }
+ return nil
+}
diff --git a/UA-Mask/core/internal/config/load.go b/UA-Mask/core/internal/config/load.go
new file mode 100644
index 000000000..612a40e05
--- /dev/null
+++ b/UA-Mask/core/internal/config/load.go
@@ -0,0 +1,239 @@
+package config
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+)
+
+type Command struct {
+ Config *Config
+ ConfigPath string
+ ShowVersion bool
+ CheckConfig bool
+ DumpEffective bool
+}
+
+type legacyValues struct {
+ userAgent string
+ port int
+ logLevel string
+ logFile string
+ whitelist string
+ forceReplace bool
+ enableRegex bool
+ partialReplace bool
+ keywords string
+ pattern string
+ cacheSize int
+ bufferSize int
+ poolSize int
+ firewallUAWhitelist string
+ firewallBypass bool
+ firewallSetName string
+ firewallBackend string
+ firewallDropOnMatch bool
+ firewallNonHTTPThreshold int
+ firewallTimeout int
+ firewallDecisionDelay time.Duration
+ firewallHTTPCooldown time.Duration
+ statsFilePath string
+ statsInterval time.Duration
+}
+
+func NewFromFlags() (*Command, error) {
+ return LoadFromArgs(os.Args[1:])
+}
+
+func NewFromArgs(args []string) (*Config, error) {
+ command, err := LoadFromArgs(args)
+ if err != nil {
+ return nil, err
+ }
+ return command.Config, nil
+}
+
+func LoadFromArgs(args []string) (*Command, error) {
+ defaults := DefaultConfig()
+ legacy := legacyValues{
+ userAgent: defaults.Rewrite.UserAgent,
+ port: defaults.Listen.Port,
+ logLevel: defaults.Observe.LogLevel,
+ logFile: defaults.Observe.LogFile,
+ whitelist: strings.Join(defaults.Rewrite.Whitelist, ","),
+ forceReplace: defaults.Rewrite.ForceReplace,
+ enableRegex: defaults.Rewrite.EnableRegex,
+ partialReplace: defaults.Rewrite.EnablePartialReplace,
+ keywords: strings.Join(defaults.Rewrite.Keywords, ","),
+ pattern: defaults.Rewrite.Pattern,
+ cacheSize: defaults.Rewrite.CacheSize,
+ bufferSize: defaults.Listen.BufferSize,
+ poolSize: defaults.Listen.PoolSize,
+ firewallUAWhitelist: strings.Join(defaults.Firewall.UAWhitelist, ","),
+ firewallBypass: defaults.Firewall.EnableBypass,
+ firewallSetName: defaults.Firewall.SetName,
+ firewallBackend: defaults.Firewall.Backend,
+ firewallDropOnMatch: defaults.Firewall.DropOnMatch,
+ firewallNonHTTPThreshold: defaults.Firewall.NonHTTPThreshold,
+ firewallTimeout: defaults.Firewall.Timeout,
+ firewallDecisionDelay: defaults.Firewall.DecisionDelay,
+ firewallHTTPCooldown: defaults.Firewall.HTTPCooldownPeriod,
+ statsFilePath: defaults.Observe.StatsFilePath,
+ statsInterval: defaults.Observe.StatsInterval,
+ }
+
+ command := &Command{}
+ fs := flag.NewFlagSet("UAmask", flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ fs.StringVar(&command.ConfigPath, "config", "", "Path to JSON configuration file")
+ fs.BoolVar(&command.CheckConfig, "check-config", false, "Validate configuration and exit")
+ fs.BoolVar(&command.DumpEffective, "dump-effective-config", false, "Print normalized configuration and exit")
+ fs.BoolVar(&command.ShowVersion, "v", false, "Show version")
+
+ fs.StringVar(&legacy.userAgent, "u", legacy.userAgent, "User-Agent string (deprecated: use -config)")
+ fs.IntVar(&legacy.port, "port", legacy.port, "Listen port (deprecated: use -config)")
+ fs.StringVar(&legacy.logLevel, "loglevel", legacy.logLevel, "Log level (deprecated: use -config)")
+ fs.StringVar(&legacy.logFile, "log", legacy.logFile, "Log file path (deprecated: use -config)")
+ fs.StringVar(&legacy.whitelist, "w", legacy.whitelist, "User-Agent whitelist CSV (deprecated: use -config)")
+ fs.BoolVar(&legacy.forceReplace, "force", legacy.forceReplace, "Force replace User-Agent (deprecated: use -config)")
+ fs.BoolVar(&legacy.enableRegex, "enable-regex", legacy.enableRegex, "Enable regex matching (deprecated: use -config)")
+ fs.BoolVar(&legacy.partialReplace, "s", legacy.partialReplace, "Enable partial replacement (deprecated: use -config)")
+ fs.StringVar(&legacy.keywords, "keywords", legacy.keywords, "User-Agent keyword CSV (deprecated: use -config)")
+ fs.StringVar(&legacy.pattern, "r", legacy.pattern, "User-Agent regex (deprecated: use -config)")
+ fs.IntVar(&legacy.cacheSize, "cache-size", legacy.cacheSize, "LRU cache size (deprecated: use -config)")
+ fs.IntVar(&legacy.bufferSize, "buffer-size", legacy.bufferSize, "I/O buffer size (deprecated: use -config)")
+ fs.IntVar(&legacy.poolSize, "p", legacy.poolSize, "Worker pool size (deprecated: use -config)")
+ fs.StringVar(&legacy.firewallUAWhitelist, "fw-ua-w", legacy.firewallUAWhitelist, "Firewall UA whitelist CSV (deprecated: use -config)")
+ fs.BoolVar(&legacy.firewallBypass, "fw-bypass", legacy.firewallBypass, "Enable non-HTTP bypass (deprecated: use -config)")
+ fs.StringVar(&legacy.firewallSetName, "fw-set-name", legacy.firewallSetName, "Firewall set name (deprecated: use -config)")
+ fs.StringVar(&legacy.firewallBackend, "fw-type", legacy.firewallBackend, "Firewall backend (deprecated: use -config)")
+ fs.BoolVar(&legacy.firewallDropOnMatch, "fw-drop", legacy.firewallDropOnMatch, "Drop firewall whitelist matches (deprecated: use -config)")
+ fs.IntVar(&legacy.firewallNonHTTPThreshold, "fw-nonhttp-threshold", legacy.firewallNonHTTPThreshold, "Non-HTTP threshold (deprecated: use -config)")
+ fs.IntVar(&legacy.firewallTimeout, "fw-timeout", legacy.firewallTimeout, "Firewall timeout seconds (deprecated: use -config)")
+ fs.DurationVar(&legacy.firewallDecisionDelay, "fw-decision-delay", legacy.firewallDecisionDelay, "Firewall decision delay (deprecated: use -config)")
+ fs.DurationVar(&legacy.firewallHTTPCooldown, "fw-http-cooldown", legacy.firewallHTTPCooldown, "HTTP cooldown (deprecated: use -config)")
+ fs.StringVar(&legacy.statsFilePath, "stats-file", legacy.statsFilePath, "Stats file path (deprecated: use -config)")
+ fs.DurationVar(&legacy.statsInterval, "stats-interval", legacy.statsInterval, "Stats interval (deprecated: use -config)")
+
+ if err := fs.Parse(args); err != nil {
+ return nil, err
+ }
+ if fs.NArg() != 0 {
+ return nil, fmt.Errorf("unexpected positional arguments: %v", fs.Args())
+ }
+
+ cfg := defaults
+ var err error
+ if command.ConfigPath != "" {
+ cfg, err = LoadFile(command.ConfigPath)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ visited := make(map[string]bool)
+ fs.Visit(func(value *flag.Flag) {
+ visited[value.Name] = true
+ })
+ applyLegacyOverrides(cfg, legacy, visited)
+ if err := finalize(cfg); err != nil {
+ return nil, err
+ }
+ command.Config = cfg
+ return command, nil
+}
+
+func applyLegacyOverrides(cfg *Config, values legacyValues, visited map[string]bool) {
+ if visited["u"] {
+ cfg.Rewrite.UserAgent = values.userAgent
+ }
+ if visited["port"] {
+ cfg.Listen.Port = values.port
+ }
+ if visited["loglevel"] {
+ cfg.Observe.LogLevel = strings.ToLower(values.logLevel)
+ }
+ if visited["log"] {
+ cfg.Observe.LogFile = values.logFile
+ }
+ if visited["w"] {
+ cfg.Rewrite.Whitelist = splitCSV(values.whitelist)
+ }
+ if visited["force"] {
+ cfg.Rewrite.ForceReplace = values.forceReplace
+ }
+ if visited["enable-regex"] {
+ cfg.Rewrite.EnableRegex = values.enableRegex
+ }
+ if visited["force"] && values.forceReplace {
+ cfg.Rewrite.ForceReplace = true
+ cfg.Rewrite.EnableRegex = false
+ } else if visited["enable-regex"] && values.enableRegex {
+ cfg.Rewrite.ForceReplace = false
+ cfg.Rewrite.EnableRegex = true
+ }
+ if visited["s"] {
+ cfg.Rewrite.EnablePartialReplace = values.partialReplace
+ }
+ if visited["keywords"] {
+ cfg.Rewrite.Keywords = splitCSV(values.keywords)
+ }
+ if visited["r"] {
+ cfg.Rewrite.Pattern = values.pattern
+ }
+ if visited["cache-size"] {
+ cfg.Rewrite.CacheSize = values.cacheSize
+ cfg.Performance.Profile = "custom"
+ }
+ if visited["buffer-size"] {
+ cfg.Listen.BufferSize = values.bufferSize
+ cfg.Performance.Profile = "custom"
+ }
+ if visited["p"] {
+ cfg.Listen.PoolSize = values.poolSize
+ cfg.Performance.Profile = "custom"
+ }
+ if visited["fw-ua-w"] {
+ cfg.Firewall.UAWhitelist = splitCSV(values.firewallUAWhitelist)
+ }
+ if visited["fw-bypass"] {
+ cfg.Firewall.EnableBypass = values.firewallBypass
+ }
+ if visited["fw-set-name"] {
+ cfg.Firewall.SetName = values.firewallSetName
+ }
+ if visited["fw-type"] {
+ cfg.Firewall.Backend = strings.ToLower(values.firewallBackend)
+ }
+ if visited["fw-drop"] {
+ cfg.Firewall.DropOnMatch = values.firewallDropOnMatch
+ }
+ if visited["fw-nonhttp-threshold"] {
+ cfg.Firewall.NonHTTPThreshold = values.firewallNonHTTPThreshold
+ }
+ if visited["fw-timeout"] {
+ cfg.Firewall.Timeout = values.firewallTimeout
+ }
+ if visited["fw-decision-delay"] {
+ cfg.Firewall.DecisionDelay = values.firewallDecisionDelay
+ }
+ if visited["fw-http-cooldown"] {
+ cfg.Firewall.HTTPCooldownPeriod = values.firewallHTTPCooldown
+ }
+ if visited["stats-file"] {
+ cfg.Observe.StatsFilePath = values.statsFilePath
+ }
+ if visited["stats-interval"] {
+ cfg.Observe.StatsInterval = values.statsInterval
+ }
+}
+
+func splitCSV(value string) []string {
+ if value == "" {
+ return []string{}
+ }
+ return normalizeList(strings.Split(value, ","))
+}
diff --git a/UA-Mask/core/internal/config/strict_json.go b/UA-Mask/core/internal/config/strict_json.go
new file mode 100644
index 000000000..65e8fc65f
--- /dev/null
+++ b/UA-Mask/core/internal/config/strict_json.go
@@ -0,0 +1,155 @@
+package config
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "reflect"
+ "strings"
+)
+
+func validateJSONDocument(data []byte, schema any) error {
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.UseNumber()
+ if err := validateJSONValue(decoder, reflect.TypeOf(schema), "$"); err != nil {
+ return err
+ }
+
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ if err == nil {
+ return fmt.Errorf("config file contains multiple JSON values")
+ }
+ return fmt.Errorf("decode trailing config data: %w", err)
+ }
+ return nil
+}
+
+func validateJSONValue(decoder *json.Decoder, schema reflect.Type, path string) error {
+ token, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("decode %s: %w", path, err)
+ }
+ if token == nil {
+ return fmt.Errorf("null is not allowed at %s", path)
+ }
+
+ delimiter, ok := token.(json.Delim)
+ if !ok {
+ return nil
+ }
+
+ switch delimiter {
+ case '{':
+ return validateJSONObject(decoder, schema, path)
+ case '[':
+ return validateJSONArray(decoder, schema, path)
+ default:
+ return fmt.Errorf("unexpected JSON delimiter %q at %s", delimiter, path)
+ }
+}
+
+func validateJSONObject(decoder *json.Decoder, schema reflect.Type, path string) error {
+ fields := jsonFieldTypes(schema)
+ seen := make(map[string]struct{})
+ for decoder.More() {
+ token, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("decode object key at %s: %w", path, err)
+ }
+ name, ok := token.(string)
+ if !ok {
+ return fmt.Errorf("object key at %s must be a string", path)
+ }
+
+ fieldPath := jsonFieldPath(path, name)
+ if _, exists := seen[name]; exists {
+ return fmt.Errorf("duplicate field %q at %s", name, fieldPath)
+ }
+ seen[name] = struct{}{}
+
+ fieldType := reflect.Type(nil)
+ if fields != nil {
+ var exists bool
+ fieldType, exists = fields[name]
+ if !exists {
+ return fmt.Errorf("unknown field %q at %s", name, fieldPath)
+ }
+ }
+ if err := validateJSONValue(decoder, fieldType, fieldPath); err != nil {
+ return err
+ }
+ }
+
+ if token, err := decoder.Token(); err != nil {
+ return fmt.Errorf("close object at %s: %w", path, err)
+ } else if token != json.Delim('}') {
+ return fmt.Errorf("unexpected JSON token %v at %s", token, path)
+ }
+ return nil
+}
+
+func validateJSONArray(decoder *json.Decoder, schema reflect.Type, path string) error {
+ elementType := jsonElementType(schema)
+ index := 0
+ for decoder.More() {
+ if err := validateJSONValue(decoder, elementType, fmt.Sprintf("%s[%d]", path, index)); err != nil {
+ return err
+ }
+ index++
+ }
+
+ if token, err := decoder.Token(); err != nil {
+ return fmt.Errorf("close array at %s: %w", path, err)
+ } else if token != json.Delim(']') {
+ return fmt.Errorf("unexpected JSON token %v at %s", token, path)
+ }
+ return nil
+}
+
+func jsonFieldTypes(schema reflect.Type) map[string]reflect.Type {
+ schema = dereferenceType(schema)
+ if schema == nil || schema.Kind() != reflect.Struct {
+ return nil
+ }
+
+ fields := make(map[string]reflect.Type)
+ for index := 0; index < schema.NumField(); index++ {
+ field := schema.Field(index)
+ if !field.IsExported() {
+ continue
+ }
+ name := strings.Split(field.Tag.Get("json"), ",")[0]
+ if name == "-" {
+ continue
+ }
+ if name == "" {
+ name = field.Name
+ }
+ fields[name] = field.Type
+ }
+ return fields
+}
+
+func jsonElementType(schema reflect.Type) reflect.Type {
+ schema = dereferenceType(schema)
+ if schema == nil || (schema.Kind() != reflect.Array && schema.Kind() != reflect.Slice) {
+ return nil
+ }
+ return schema.Elem()
+}
+
+func dereferenceType(schema reflect.Type) reflect.Type {
+ for schema != nil && schema.Kind() == reflect.Pointer {
+ schema = schema.Elem()
+ }
+ return schema
+}
+
+func jsonFieldPath(parent, field string) string {
+ if field == "" {
+ return parent + "[\"\"]"
+ }
+ return parent + "." + field
+}
diff --git a/UA-Mask/core/internal/config/testdata/effective/schema-v1-full.golden.json b/UA-Mask/core/internal/config/testdata/effective/schema-v1-full.golden.json
new file mode 100644
index 000000000..9c0895374
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/effective/schema-v1-full.golden.json
@@ -0,0 +1,52 @@
+{
+ "schema_version": 1,
+ "listen": {
+ "port": 12032,
+ "dial_timeout": "12s",
+ "client_keep_alive": "2m0s",
+ "upstream_keep_alive": "4m0s"
+ },
+ "rewrite": {
+ "user_agent": "TraceUA",
+ "mode": "keywords",
+ "keywords": [
+ "Android",
+ "Linux"
+ ],
+ "whitelist": [
+ "KeepMe"
+ ],
+ "pattern": "Android|Linux",
+ "partial_replace": false
+ },
+ "firewall": {
+ "backend": "nft",
+ "set_name": "trace_set",
+ "ua_whitelist": [
+ "Steam"
+ ],
+ "drop_on_match": false,
+ "whitelist_timeout": "12h0m0s",
+ "non_http": {
+ "enabled": false,
+ "threshold": 7,
+ "decision_delay": "15s",
+ "http_cooldown": "30m0s",
+ "timeout": "4h0m0s",
+ "cleanup_interval": "2m0s"
+ }
+ },
+ "observe": {
+ "log_level": "warn",
+ "log_file": "/tmp/UAmask/trace.log",
+ "stats_file": "/tmp/UAmask/trace.stats",
+ "stats_interval": "9s"
+ },
+ "performance": {
+ "profile": "custom",
+ "cache_size": 123,
+ "buffer_size": 4096,
+ "pool_size": 7,
+ "gc_percent": 80
+ }
+}
diff --git a/UA-Mask/core/internal/config/testdata/effective/schema-v1-minimal.golden.json b/UA-Mask/core/internal/config/testdata/effective/schema-v1-minimal.golden.json
new file mode 100644
index 000000000..97c50dd9a
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/effective/schema-v1-minimal.golden.json
@@ -0,0 +1,51 @@
+{
+ "schema_version": 1,
+ "listen": {
+ "port": 8080,
+ "dial_timeout": "30s",
+ "client_keep_alive": "3m0s",
+ "upstream_keep_alive": "3m0s"
+ },
+ "rewrite": {
+ "user_agent": "FFF",
+ "mode": "keywords",
+ "keywords": [
+ "iPhone",
+ "iPad",
+ "Android",
+ "Macintosh",
+ "Windows"
+ ],
+ "whitelist": [],
+ "pattern": "(iPhone|iPad|Android|Macintosh|Windows|Linux|Apple|Mac OS X|Mobile)",
+ "partial_replace": false
+ },
+ "firewall": {
+ "backend": "ipt",
+ "set_name": "UAmask_bypass_set",
+ "ua_whitelist": [],
+ "drop_on_match": false,
+ "whitelist_timeout": "24h0m0s",
+ "non_http": {
+ "enabled": false,
+ "threshold": 5,
+ "decision_delay": "1m0s",
+ "http_cooldown": "1h0m0s",
+ "timeout": "8h0m0s",
+ "cleanup_interval": "10m0s"
+ }
+ },
+ "observe": {
+ "log_level": "info",
+ "log_file": "",
+ "stats_file": "/tmp/UAmask.stats",
+ "stats_interval": "5s"
+ },
+ "performance": {
+ "profile": "custom",
+ "cache_size": 1000,
+ "buffer_size": 8192,
+ "pool_size": 0,
+ "gc_percent": 100
+ }
+}
diff --git a/UA-Mask/core/internal/config/testdata/invalid/cfg-dup-001-duplicate-key.json b/UA-Mask/core/internal/config/testdata/invalid/cfg-dup-001-duplicate-key.json
new file mode 100644
index 000000000..e020eb019
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/invalid/cfg-dup-001-duplicate-key.json
@@ -0,0 +1,7 @@
+{
+ "schema_version": 1,
+ "listen": {
+ "port": 12032,
+ "port": 8080
+ }
+}
diff --git a/UA-Mask/core/internal/config/testdata/invalid/cfg-eof-001-multiple-values.json b/UA-Mask/core/internal/config/testdata/invalid/cfg-eof-001-multiple-values.json
new file mode 100644
index 000000000..8de1d558a
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/invalid/cfg-eof-001-multiple-values.json
@@ -0,0 +1,2 @@
+{"schema_version": 1}
+{"schema_version": 1}
diff --git a/UA-Mask/core/internal/config/testdata/invalid/cfg-key-001-wrong-case.json b/UA-Mask/core/internal/config/testdata/invalid/cfg-key-001-wrong-case.json
new file mode 100644
index 000000000..c1acf6c23
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/invalid/cfg-key-001-wrong-case.json
@@ -0,0 +1,6 @@
+{
+ "schema_version": 1,
+ "Listen": {
+ "port": 12032
+ }
+}
diff --git a/UA-Mask/core/internal/config/testdata/invalid/cfg-null-001-null-scalar.json b/UA-Mask/core/internal/config/testdata/invalid/cfg-null-001-null-scalar.json
new file mode 100644
index 000000000..86def8923
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/invalid/cfg-null-001-null-scalar.json
@@ -0,0 +1,6 @@
+{
+ "schema_version": 1,
+ "listen": {
+ "port": null
+ }
+}
diff --git a/UA-Mask/core/internal/config/testdata/invalid/cfg-null-002-null-object.json b/UA-Mask/core/internal/config/testdata/invalid/cfg-null-002-null-object.json
new file mode 100644
index 000000000..550f8178e
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/invalid/cfg-null-002-null-object.json
@@ -0,0 +1,4 @@
+{
+ "schema_version": 1,
+ "performance": null
+}
diff --git a/UA-Mask/core/internal/config/testdata/invalid/cfg-null-003-null-array-element.json b/UA-Mask/core/internal/config/testdata/invalid/cfg-null-003-null-array-element.json
new file mode 100644
index 000000000..9f4d1eb6e
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/invalid/cfg-null-003-null-array-element.json
@@ -0,0 +1,6 @@
+{
+ "schema_version": 1,
+ "rewrite": {
+ "keywords": ["Android", null]
+ }
+}
diff --git a/UA-Mask/core/internal/config/testdata/valid/schema-v1-custom-profile.json b/UA-Mask/core/internal/config/testdata/valid/schema-v1-custom-profile.json
new file mode 100644
index 000000000..4cd4a9ecc
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/valid/schema-v1-custom-profile.json
@@ -0,0 +1,10 @@
+{
+ "schema_version": 1,
+ "performance": {
+ "profile": "custom",
+ "cache_size": 42,
+ "buffer_size": 4096,
+ "pool_size": 3,
+ "gc_percent": -1
+ }
+}
diff --git a/UA-Mask/core/internal/config/testdata/valid/schema-v1-full.json b/UA-Mask/core/internal/config/testdata/valid/schema-v1-full.json
new file mode 100644
index 000000000..eb6a5188f
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/valid/schema-v1-full.json
@@ -0,0 +1,45 @@
+{
+ "schema_version": 1,
+ "listen": {
+ "port": 12032,
+ "dial_timeout": "12s",
+ "client_keep_alive": "2m",
+ "upstream_keep_alive": "4m"
+ },
+ "rewrite": {
+ "user_agent": "TraceUA",
+ "mode": "keywords",
+ "keywords": [" Android ", "", "Linux"],
+ "whitelist": [" KeepMe "],
+ "pattern": "Android|Linux",
+ "partial_replace": false
+ },
+ "firewall": {
+ "backend": "NFT",
+ "set_name": "trace_set",
+ "ua_whitelist": [" Steam "],
+ "drop_on_match": false,
+ "whitelist_timeout": "12h",
+ "non_http": {
+ "enabled": false,
+ "threshold": 7,
+ "decision_delay": "15s",
+ "http_cooldown": "30m",
+ "timeout": "4h",
+ "cleanup_interval": "2m"
+ }
+ },
+ "observe": {
+ "log_level": "WARN",
+ "log_file": "/tmp/UAmask/trace.log",
+ "stats_file": "/tmp/UAmask/trace.stats",
+ "stats_interval": "9s"
+ },
+ "performance": {
+ "profile": "custom",
+ "cache_size": 123,
+ "buffer_size": 4096,
+ "pool_size": 7,
+ "gc_percent": 80
+ }
+}
diff --git a/UA-Mask/core/internal/config/testdata/valid/schema-v1-minimal.json b/UA-Mask/core/internal/config/testdata/valid/schema-v1-minimal.json
new file mode 100644
index 000000000..bf00d9585
--- /dev/null
+++ b/UA-Mask/core/internal/config/testdata/valid/schema-v1-minimal.json
@@ -0,0 +1,3 @@
+{
+ "schema_version": 1
+}
diff --git a/UA-Mask/core/internal/config/validate.go b/UA-Mask/core/internal/config/validate.go
new file mode 100644
index 000000000..f37f96afc
--- /dev/null
+++ b/UA-Mask/core/internal/config/validate.go
@@ -0,0 +1,81 @@
+package config
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/sirupsen/logrus"
+)
+
+var validSetName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
+
+func finalize(cfg *Config) error {
+ cfg.Rewrite.Whitelist = normalizeList(cfg.Rewrite.Whitelist)
+ cfg.Rewrite.Keywords = normalizeList(cfg.Rewrite.Keywords)
+ cfg.Firewall.UAWhitelist = normalizeList(cfg.Firewall.UAWhitelist)
+ if err := validate(cfg); err != nil {
+ return err
+ }
+ cfg.RewriteRuntime = RewriteRuntimeConfig{}
+ if cfg.Rewrite.EnableRegex && !cfg.Rewrite.ForceReplace {
+ compiled, err := regexp.Compile("(?i)" + cfg.Rewrite.Pattern)
+ if err != nil {
+ return fmt.Errorf("invalid User-Agent regex pattern: %w", err)
+ }
+ cfg.RewriteRuntime.UARegexp = compiled
+ }
+ return nil
+}
+
+func validate(cfg *Config) error {
+ if cfg.Listen.Port < 1 || cfg.Listen.Port > 65535 {
+ return fmt.Errorf("invalid port: %d", cfg.Listen.Port)
+ }
+ if cfg.Listen.BufferSize < 1024 || cfg.Listen.BufferSize > 65536 {
+ return fmt.Errorf("invalid buffer size: %d", cfg.Listen.BufferSize)
+ }
+ if cfg.Listen.PoolSize < 0 {
+ return fmt.Errorf("invalid pool size: %d", cfg.Listen.PoolSize)
+ }
+ if cfg.Listen.DialTimeout <= 0 {
+ return fmt.Errorf("invalid dial timeout: %s", cfg.Listen.DialTimeout)
+ }
+ if cfg.Listen.ClientKeepAlive < 0 || cfg.Listen.UpstreamKeepAlive < 0 {
+ return fmt.Errorf("keepalive durations cannot be negative")
+ }
+ if cfg.Rewrite.CacheSize < 0 {
+ return fmt.Errorf("invalid cache size: %d", cfg.Rewrite.CacheSize)
+ }
+ if cfg.Rewrite.EnableRegex && !cfg.Rewrite.ForceReplace && strings.TrimSpace(cfg.Rewrite.Pattern) == "" {
+ return fmt.Errorf("regex mode requires a pattern")
+ }
+ if cfg.Firewall.Backend != "ipt" && cfg.Firewall.Backend != "nft" {
+ return fmt.Errorf("invalid firewall type: %s", cfg.Firewall.Backend)
+ }
+ if !validSetName.MatchString(cfg.Firewall.SetName) {
+ return fmt.Errorf("invalid firewall set name: %s", cfg.Firewall.SetName)
+ }
+ if cfg.Firewall.NonHTTPThreshold < 1 {
+ return fmt.Errorf("invalid non-http threshold: %d", cfg.Firewall.NonHTTPThreshold)
+ }
+ if cfg.Firewall.Timeout <= 0 || cfg.Firewall.ImmediateBypassTimeout <= 0 {
+ return fmt.Errorf("firewall timeouts must be positive")
+ }
+ if cfg.Firewall.DecisionDelay < 0 || cfg.Firewall.HTTPCooldownPeriod < 0 {
+ return fmt.Errorf("firewall decision durations cannot be negative")
+ }
+ if cfg.Firewall.ProfileCleanupInterval <= 0 {
+ return fmt.Errorf("invalid profile cleanup interval: %s", cfg.Firewall.ProfileCleanupInterval)
+ }
+ if cfg.Observe.StatsInterval <= 0 {
+ return fmt.Errorf("invalid stats interval: %s", cfg.Observe.StatsInterval)
+ }
+ if _, err := logrus.ParseLevel(cfg.Observe.LogLevel); err != nil {
+ return fmt.Errorf("invalid log level %q", cfg.Observe.LogLevel)
+ }
+ if cfg.Performance.GCPercent < -1 {
+ return fmt.Errorf("invalid GC percent: %d", cfg.Performance.GCPercent)
+ }
+ return nil
+}
diff --git a/UA-Mask/core/internal/events/dispatcher.go b/UA-Mask/core/internal/events/dispatcher.go
new file mode 100644
index 000000000..41c984092
--- /dev/null
+++ b/UA-Mask/core/internal/events/dispatcher.go
@@ -0,0 +1,228 @@
+package events
+
+import (
+ "context"
+ "sync"
+
+ "UAmask/internal/model"
+)
+
+const defaultQueueCapacity = 10000
+
+type Sink interface {
+ Append(event model.SessionEvent)
+}
+
+type Dispatcher struct {
+ mu sync.Mutex
+ cond *sync.Cond
+ sinks []Sink
+ queue []model.SessionEvent
+ head int
+ size int
+ running bool
+ stopping bool
+ closed bool
+ dropped uint64
+ active int
+ busy bool
+ idle chan struct{}
+ wg sync.WaitGroup
+}
+
+func NewDispatcher(sinks ...Sink) *Dispatcher {
+ return NewDispatcherWithCapacity(defaultQueueCapacity, sinks...)
+}
+
+func NewDispatcherWithCapacity(capacity int, sinks ...Sink) *Dispatcher {
+ if capacity <= 0 {
+ capacity = 1
+ }
+ idle := make(chan struct{})
+ close(idle)
+ dispatcher := &Dispatcher{
+ sinks: sinks,
+ queue: make([]model.SessionEvent, capacity),
+ idle: idle,
+ }
+ dispatcher.cond = sync.NewCond(&dispatcher.mu)
+ return dispatcher
+}
+
+func (d *Dispatcher) Add(sinks ...Sink) {
+ if d == nil {
+ return
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ d.sinks = append(d.sinks, sinks...)
+}
+
+func (d *Dispatcher) Start() {
+ if d == nil {
+ return
+ }
+
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ if d.running || d.closed {
+ return
+ }
+
+ d.running = true
+ d.stopping = false
+ d.wg.Add(1)
+ go d.loop()
+}
+
+func (d *Dispatcher) Stop() {
+ if d == nil {
+ return
+ }
+
+ d.mu.Lock()
+ if !d.running {
+ d.closed = true
+ d.mu.Unlock()
+ return
+ }
+ d.stopping = true
+ d.closed = true
+ d.cond.Signal()
+ d.mu.Unlock()
+
+ d.wg.Wait()
+}
+
+func (d *Dispatcher) Append(event model.SessionEvent) {
+ _ = d.TryAppend(event)
+}
+
+func (d *Dispatcher) TryAppend(event model.SessionEvent) bool {
+ if d == nil {
+ return false
+ }
+
+ d.mu.Lock()
+ if d.closed || d.stopping {
+ d.dropped++
+ d.mu.Unlock()
+ return false
+ }
+ if d.running {
+ if d.size == len(d.queue) {
+ d.dropped++
+ d.mu.Unlock()
+ return false
+ }
+ d.markBusyLocked()
+ tail := (d.head + d.size) % len(d.queue)
+ d.queue[tail] = event
+ d.size++
+ d.cond.Signal()
+ d.mu.Unlock()
+ return true
+ }
+ sinks := append([]Sink(nil), d.sinks...)
+ d.markBusyLocked()
+ d.active++
+ d.mu.Unlock()
+
+ dispatch(event, sinks)
+ d.mu.Lock()
+ d.active--
+ d.markIdleLocked()
+ d.mu.Unlock()
+ return true
+}
+
+func (d *Dispatcher) WaitIdle(ctx context.Context) error {
+ if d == nil {
+ return nil
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ d.mu.Lock()
+ if !d.busy {
+ d.mu.Unlock()
+ return nil
+ }
+ idle := d.idle
+ d.mu.Unlock()
+
+ select {
+ case <-idle:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (d *Dispatcher) Dropped() uint64 {
+ if d == nil {
+ return 0
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return d.dropped
+}
+
+func (d *Dispatcher) loop() {
+ defer d.wg.Done()
+
+ for {
+ d.mu.Lock()
+ for d.size == 0 && !d.stopping {
+ d.cond.Wait()
+ }
+ if d.size == 0 && d.stopping {
+ d.running = false
+ d.markIdleLocked()
+ d.mu.Unlock()
+ return
+ }
+
+ event := d.queue[d.head]
+ d.queue[d.head] = model.SessionEvent{}
+ d.head = (d.head + 1) % len(d.queue)
+ d.size--
+ d.active++
+ sinks := append([]Sink(nil), d.sinks...)
+ d.mu.Unlock()
+
+ dispatch(event, sinks)
+ d.mu.Lock()
+ d.active--
+ d.markIdleLocked()
+ d.mu.Unlock()
+ }
+}
+
+func (d *Dispatcher) markBusyLocked() {
+ if d.busy {
+ return
+ }
+ d.busy = true
+ d.idle = make(chan struct{})
+}
+
+func (d *Dispatcher) markIdleLocked() {
+ if !d.busy || d.size != 0 || d.active != 0 {
+ return
+ }
+ d.busy = false
+ close(d.idle)
+}
+
+func dispatch(event model.SessionEvent, sinks []Sink) {
+ for _, sink := range sinks {
+ if sink == nil {
+ continue
+ }
+ sink.Append(event)
+ }
+}
diff --git a/UA-Mask/core/internal/events/dispatcher_test.go b/UA-Mask/core/internal/events/dispatcher_test.go
new file mode 100644
index 000000000..33d53e934
--- /dev/null
+++ b/UA-Mask/core/internal/events/dispatcher_test.go
@@ -0,0 +1,168 @@
+package events
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "UAmask/internal/model"
+)
+
+type collectingSink struct {
+ mu sync.Mutex
+ events []model.SessionEventType
+}
+
+type blockingSink struct {
+ started chan struct{}
+ release chan struct{}
+ once sync.Once
+}
+
+func (s *blockingSink) Append(model.SessionEvent) {
+ s.once.Do(func() { close(s.started) })
+ <-s.release
+}
+
+func (s *collectingSink) Append(event model.SessionEvent) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.events = append(s.events, event.Type)
+}
+
+func TestDispatcherDropsNewestEventWhenQueueIsFull(t *testing.T) {
+ t.Parallel()
+
+ blocker := &blockingSink{
+ started: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+ dispatcher := NewDispatcherWithCapacity(1, blocker)
+ dispatcher.Start()
+
+ if !dispatcher.TryAppend(model.SessionEvent{Type: model.EventSessionOpened}) {
+ t.Fatal("expected first event to be accepted")
+ }
+ select {
+ case <-blocker.started:
+ case <-time.After(time.Second):
+ t.Fatal("dispatcher did not start processing the first event")
+ }
+
+ if !dispatcher.TryAppend(model.SessionEvent{Type: model.EventHTTPRequestObserved}) {
+ t.Fatal("expected queued event to be accepted")
+ }
+ if dispatcher.TryAppend(model.SessionEvent{Type: model.EventSessionClosed}) {
+ t.Fatal("expected newest event to be dropped when queue is full")
+ }
+ if dispatcher.Dropped() != 1 {
+ t.Fatalf("expected one dropped event, got %d", dispatcher.Dropped())
+ }
+
+ close(blocker.release)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := dispatcher.WaitIdle(ctx); err != nil {
+ t.Fatalf("QUEUE-DISPATCH-002 dispatcher did not become idle: %v", err)
+ }
+ dispatcher.Stop()
+}
+
+func (s *collectingSink) Types() []model.SessionEventType {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]model.SessionEventType(nil), s.events...)
+}
+
+type emittingSink struct {
+ dispatcher *Dispatcher
+}
+
+func (s *emittingSink) Append(event model.SessionEvent) {
+ if event.Type != model.EventSessionOpened {
+ return
+ }
+ s.dispatcher.Append(model.SessionEvent{Type: model.EventSessionClosed})
+}
+
+func TestDispatcherRejectsEventsAfterStop(t *testing.T) {
+ t.Parallel()
+
+ dispatcher := NewDispatcher()
+ collector := &collectingSink{}
+ dispatcher.Add(collector)
+
+ dispatcher.Start()
+ dispatcher.Append(model.SessionEvent{Type: model.EventSessionOpened})
+ dispatcher.Stop()
+ if dispatcher.TryAppend(model.SessionEvent{Type: model.EventSessionClosed}) {
+ t.Fatal("QUEUE-DISPATCH-003 dispatcher accepted an event after Stop")
+ }
+
+ got := collector.Types()
+ want := []model.SessionEventType{
+ model.EventSessionOpened,
+ }
+ if len(got) != len(want) {
+ t.Fatalf("expected %d events, got %d: %v", len(want), len(got), got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("event %d: expected %s, got %s", i, want[i], got[i])
+ }
+ }
+ if dispatcher.Dropped() != 1 {
+ t.Fatalf("QUEUE-DISPATCH-003 dropped events = %d, want 1", dispatcher.Dropped())
+ }
+}
+
+func TestDispatcherWaitIdleIncludesCausalEvents(t *testing.T) {
+ t.Parallel()
+
+ dispatcher := NewDispatcher()
+ collector := &collectingSink{}
+ dispatcher.Add(&emittingSink{dispatcher: dispatcher}, collector)
+ dispatcher.Start()
+ defer dispatcher.Stop()
+
+ dispatcher.Append(model.SessionEvent{Type: model.EventSessionOpened})
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := dispatcher.WaitIdle(ctx); err != nil {
+ t.Fatalf("QUEUE-DISPATCH-001 WaitIdle: %v", err)
+ }
+
+ got := collector.Types()
+ want := []model.SessionEventType{model.EventSessionOpened, model.EventSessionClosed}
+ if len(got) != len(want) {
+ t.Fatalf("QUEUE-DISPATCH-001 expected %d events, got %d: %v", len(want), len(got), got)
+ }
+ for index := range want {
+ if got[index] != want[index] {
+ t.Fatalf("QUEUE-DISPATCH-001 event %d = %s, want %s", index, got[index], want[index])
+ }
+ }
+}
+
+func TestDispatcherWaitIdleHonorsContext(t *testing.T) {
+ t.Parallel()
+
+ blocker := &blockingSink{started: make(chan struct{}), release: make(chan struct{})}
+ dispatcher := NewDispatcher(blocker)
+ dispatcher.Start()
+ dispatcher.Append(model.SessionEvent{Type: model.EventSessionOpened})
+ select {
+ case <-blocker.started:
+ case <-time.After(time.Second):
+ t.Fatal("dispatcher did not start processing blocked event")
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := dispatcher.WaitIdle(ctx); err != context.Canceled {
+ t.Fatalf("WaitIdle error = %v, want context.Canceled", err)
+ }
+ close(blocker.release)
+ dispatcher.Stop()
+}
diff --git a/UA-Mask/core/internal/firewall/applier.go b/UA-Mask/core/internal/firewall/applier.go
new file mode 100644
index 000000000..eadac4dab
--- /dev/null
+++ b/UA-Mask/core/internal/firewall/applier.go
@@ -0,0 +1,74 @@
+package firewall
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+type CommandApplier struct {
+ timeout time.Duration
+}
+
+func NewCommandApplier() *CommandApplier {
+ return &CommandApplier{timeout: 10 * time.Second}
+}
+
+func (a *CommandApplier) ApplyBatch(items []BypassTarget) error {
+ if len(items) == 0 {
+ return nil
+ }
+
+ cmd := buildCommand(items)
+ errChan := make(chan error, 1)
+
+ go func() {
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ err = fmt.Errorf("error: %v, output: %s", err, string(output))
+ }
+ errChan <- err
+ }()
+
+ select {
+ case err := <-errChan:
+ return err
+ case <-time.After(a.timeout):
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ first := items[0]
+ return fmt.Errorf("timeout executing batch for set %s (%s) with %d items", first.SetName, first.Backend, len(items))
+ }
+}
+
+func buildCommand(items []BypassTarget) *exec.Cmd {
+ first := items[0]
+ if first.Backend == "nft" {
+ args := []string{"add", "element", "inet", "fw4", first.SetName, "{"}
+ elements := make([]string, 0, len(items))
+ for _, item := range items {
+ element := fmt.Sprintf("%s . %d", item.IP, item.Port)
+ if item.Timeout > 0 {
+ element += fmt.Sprintf(" timeout %ds", item.Timeout)
+ }
+ elements = append(elements, element)
+ }
+ args = append(args, strings.Join(elements, ", "))
+ args = append(args, "}")
+ return exec.Command("nft", args...)
+ }
+
+ cmd := exec.Command("ipset", "restore")
+ var stdin strings.Builder
+ for _, item := range items {
+ if item.Timeout > 0 {
+ fmt.Fprintf(&stdin, "add %s %s,%d timeout %d -exist\n", item.SetName, item.IP, item.Port, item.Timeout)
+ } else {
+ fmt.Fprintf(&stdin, "add %s %s,%d -exist\n", item.SetName, item.IP, item.Port)
+ }
+ }
+ cmd.Stdin = strings.NewReader(stdin.String())
+ return cmd
+}
diff --git a/UA-Mask/core/internal/firewall/executor.go b/UA-Mask/core/internal/firewall/executor.go
new file mode 100644
index 000000000..2f5d93a74
--- /dev/null
+++ b/UA-Mask/core/internal/firewall/executor.go
@@ -0,0 +1,283 @@
+package firewall
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "regexp"
+ "sync"
+ "time"
+
+ "UAmask/internal/clock"
+
+ "github.com/sirupsen/logrus"
+)
+
+var validSetName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
+var ErrExecutorStopped = errors.New("firewall executor stopped")
+
+type Applier interface {
+ ApplyBatch(items []BypassTarget) error
+}
+
+type completionFunc func(error)
+
+type queuedTarget struct {
+ target BypassTarget
+ completion completionFunc
+ flush chan struct{}
+}
+
+type pendingTarget struct {
+ target BypassTarget
+ completions []completionFunc
+}
+
+type Executor struct {
+ queue chan queuedTarget
+ stopChan chan struct{}
+ stopOnce sync.Once
+ waitGroup sync.WaitGroup
+ lifecycleMu sync.RWMutex
+ stopped bool
+ log *logrus.Logger
+ applier Applier
+ maxBatchSize int
+ maxBatchWait time.Duration
+ clock clock.Clock
+}
+
+func NewExecutor(log *logrus.Logger, queueSize int, applier Applier) *Executor {
+ return NewExecutorWithClock(log, queueSize, applier, clock.RealClock{})
+}
+
+func NewExecutorWithClock(log *logrus.Logger, queueSize int, applier Applier, runtimeClock clock.Clock) *Executor {
+ return &Executor{
+ queue: make(chan queuedTarget, queueSize),
+ stopChan: make(chan struct{}),
+ log: log,
+ applier: applier,
+ maxBatchSize: 200,
+ maxBatchWait: 100 * time.Millisecond,
+ clock: clock.OrReal(runtimeClock),
+ }
+}
+
+func (e *Executor) Start() {
+ e.waitGroup.Add(1)
+ go e.worker()
+ e.log.Info("[FirewallExecutor] worker started")
+}
+
+func (e *Executor) Stop() {
+ e.stopOnce.Do(func() {
+ e.lifecycleMu.Lock()
+ e.stopped = true
+ close(e.stopChan)
+ e.lifecycleMu.Unlock()
+ })
+ e.waitGroup.Wait()
+ e.log.Info("[FirewallExecutor] worker stopped")
+}
+
+func (e *Executor) Enqueue(target BypassTarget) {
+ _ = e.TryEnqueue(target)
+}
+
+func (e *Executor) TryEnqueue(target BypassTarget) bool {
+ return e.TryEnqueueWithResult(target, nil)
+}
+
+func (e *Executor) TryEnqueueWithResult(target BypassTarget, completion func(error)) bool {
+ if !isValidTarget(target) {
+ e.log.Warnf("[FirewallExecutor] Invalid bypass target: %+v", target)
+ return false
+ }
+ e.lifecycleMu.RLock()
+ defer e.lifecycleMu.RUnlock()
+ if e.stopped {
+ return false
+ }
+
+ select {
+ case e.queue <- queuedTarget{target: target, completion: completion}:
+ return true
+ case <-e.clock.After(50 * time.Millisecond):
+ e.log.Warnf("[FirewallExecutor] queue full, dropping target for %s:%d", target.IP, target.Port)
+ return false
+ }
+}
+
+func (e *Executor) Flush(ctx context.Context) error {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ done := make(chan struct{})
+
+ e.lifecycleMu.RLock()
+ if e.stopped {
+ e.lifecycleMu.RUnlock()
+ return ErrExecutorStopped
+ }
+ select {
+ case e.queue <- queuedTarget{flush: done}:
+ e.lifecycleMu.RUnlock()
+ case <-ctx.Done():
+ e.lifecycleMu.RUnlock()
+ return ctx.Err()
+ }
+
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (e *Executor) worker() {
+ defer e.waitGroup.Done()
+
+ batches := make(map[string]map[string]*pendingTarget)
+ batchTimer := e.clock.NewTimer(e.maxBatchWait)
+ stopAndDrainTimer(batchTimer)
+
+ for {
+ select {
+ case queued := <-e.queue:
+ if queued.flush != nil {
+ e.executeBatches(batches)
+ batches = make(map[string]map[string]*pendingTarget)
+ stopAndDrainTimer(batchTimer)
+ close(queued.flush)
+ continue
+ }
+ key := e.addToBatch(batches, queued)
+
+ if len(batches) == 1 && len(batches[key]) == 1 {
+ batchTimer.Reset(e.maxBatchWait)
+ }
+
+ if len(batches[key]) >= e.maxBatchSize {
+ e.executeBatches(batches)
+ batches = make(map[string]map[string]*pendingTarget)
+ stopAndDrainTimer(batchTimer)
+ }
+
+ case <-batchTimer.C():
+ if len(batches) > 0 {
+ e.executeBatches(batches)
+ batches = make(map[string]map[string]*pendingTarget)
+ }
+
+ case <-e.stopChan:
+ batches = e.drainQueue(batches)
+ e.executeBatches(batches)
+ stopAndDrainTimer(batchTimer)
+ return
+ }
+ }
+}
+
+func (e *Executor) drainQueue(batches map[string]map[string]*pendingTarget) map[string]map[string]*pendingTarget {
+ for {
+ select {
+ case queued := <-e.queue:
+ if queued.flush != nil {
+ e.executeBatches(batches)
+ batches = make(map[string]map[string]*pendingTarget)
+ close(queued.flush)
+ continue
+ }
+ e.addToBatch(batches, queued)
+ default:
+ return batches
+ }
+ }
+}
+
+func stopAndDrainTimer(timer clock.Timer) {
+ if timer.Stop() {
+ return
+ }
+ select {
+ case <-timer.C():
+ default:
+ }
+}
+
+func (e *Executor) addToBatch(batches map[string]map[string]*pendingTarget, queued queuedTarget) string {
+ key := batchKey(queued.target)
+ dedupKey := fmt.Sprintf("%s:%d", queued.target.IP, queued.target.Port)
+ if _, ok := batches[key]; !ok {
+ batches[key] = make(map[string]*pendingTarget)
+ }
+ pending, ok := batches[key][dedupKey]
+ if !ok {
+ pending = &pendingTarget{}
+ batches[key][dedupKey] = pending
+ }
+ pending.target = queued.target
+ if queued.completion != nil {
+ pending.completions = append(pending.completions, queued.completion)
+ }
+ return key
+}
+
+func (e *Executor) executeBatches(batches map[string]map[string]*pendingTarget) {
+ if len(batches) == 0 {
+ return
+ }
+
+ for key, itemsMap := range batches {
+ if len(itemsMap) == 0 {
+ continue
+ }
+
+ items := make([]BypassTarget, 0, len(itemsMap))
+ pendingItems := make([]*pendingTarget, 0, len(itemsMap))
+ for _, pending := range itemsMap {
+ items = append(items, pending.target)
+ pendingItems = append(pendingItems, pending)
+ }
+
+ err := e.applier.ApplyBatch(items)
+ if err != nil {
+ first := items[0]
+ e.log.Warnf("[FirewallExecutor] Failed batch %s for set %s (%s): %v", key, first.SetName, first.Backend, err)
+ } else {
+ first := items[0]
+ e.log.Debugf("[FirewallExecutor] Added %d unique targets to set %s (%s)", len(items), first.SetName, first.Backend)
+ }
+
+ for _, pending := range pendingItems {
+ for _, completion := range pending.completions {
+ e.notifyCompletion(completion, err)
+ }
+ }
+ }
+}
+
+func (e *Executor) notifyCompletion(completion completionFunc, err error) {
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ e.log.Errorf("[FirewallExecutor] completion callback panicked: %v", recovered)
+ }
+ }()
+ completion(err)
+}
+
+func batchKey(target BypassTarget) string {
+ return fmt.Sprintf("%s:%s", target.Backend, target.SetName)
+}
+
+func isValidTarget(target BypassTarget) bool {
+ if target.IP == "" || target.SetName == "" {
+ return false
+ }
+ if net.ParseIP(target.IP) == nil {
+ return false
+ }
+ return validSetName.MatchString(target.SetName)
+}
diff --git a/UA-Mask/core/internal/firewall/executor_test.go b/UA-Mask/core/internal/firewall/executor_test.go
new file mode 100644
index 000000000..315ac826b
--- /dev/null
+++ b/UA-Mask/core/internal/firewall/executor_test.go
@@ -0,0 +1,164 @@
+package firewall
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+
+ "github.com/sirupsen/logrus"
+)
+
+type applierStub struct {
+ mu sync.Mutex
+ batches [][]BypassTarget
+ err error
+}
+
+func (a *applierStub) ApplyBatch(items []BypassTarget) error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ copied := make([]BypassTarget, len(items))
+ copy(copied, items)
+ a.batches = append(a.batches, copied)
+ return a.err
+}
+
+func TestExecutorReportsActualApplyResult(t *testing.T) {
+ t.Parallel()
+
+ wantErr := errors.New("apply failed")
+ applier := &applierStub{err: wantErr}
+ executor := NewExecutor(logrus.New(), 8, applier)
+ result := make(chan error, 2)
+
+ executor.Start()
+ if !executor.TryEnqueueWithResult(BypassTarget{
+ IP: "1.1.1.1",
+ Port: 80,
+ SetName: "set",
+ Backend: "ipt",
+ Timeout: 60,
+ }, func(err error) {
+ result <- err
+ }) {
+ t.Fatal("expected target to be queued")
+ }
+ if !executor.TryEnqueueWithResult(BypassTarget{
+ IP: "1.1.1.1",
+ Port: 80,
+ SetName: "set",
+ Backend: "ipt",
+ Timeout: 60,
+ }, func(err error) {
+ result <- err
+ }) {
+ t.Fatal("expected duplicate target completion to be queued")
+ }
+
+ if err := executor.Flush(context.Background()); err != nil {
+ t.Fatalf("QUEUE-EXEC-002 Flush: %v", err)
+ }
+ for index := 0; index < 2; index++ {
+ gotErr := <-result
+ if !errors.Is(gotErr, wantErr) {
+ t.Fatalf("completion %d error = %v, want %v", index, gotErr, wantErr)
+ }
+ }
+ executor.Stop()
+}
+
+func TestExecutorRejectsTargetsAfterStop(t *testing.T) {
+ t.Parallel()
+
+ executor := NewExecutor(logrus.New(), 1, &applierStub{})
+ executor.Start()
+ executor.Stop()
+
+ if executor.TryEnqueue(BypassTarget{
+ IP: "1.1.1.1",
+ Port: 80,
+ SetName: "set",
+ Backend: "ipt",
+ Timeout: 60,
+ }) {
+ t.Fatal("expected stopped executor to reject new targets")
+ }
+}
+
+func (a *applierStub) BatchCount() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return len(a.batches)
+}
+
+func (a *applierStub) FirstBatchSize() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if len(a.batches) == 0 {
+ return 0
+ }
+ return len(a.batches[0])
+}
+
+func TestExecutorDeduplicatesTargets(t *testing.T) {
+ t.Parallel()
+
+ applier := &applierStub{}
+ executor := NewExecutor(logrus.New(), 8, applier)
+ executor.Start()
+ defer executor.Stop()
+
+ target := BypassTarget{IP: "1.1.1.1", Port: 80, SetName: "set", Backend: "ipt", Timeout: 60}
+ executor.Enqueue(target)
+ executor.Enqueue(target)
+ executor.Enqueue(BypassTarget{IP: "2.2.2.2", Port: 80, SetName: "set", Backend: "ipt", Timeout: 60})
+
+ if err := executor.Flush(context.Background()); err != nil {
+ t.Fatalf("QUEUE-EXEC-001 Flush: %v", err)
+ }
+
+ if applier.BatchCount() != 1 {
+ t.Fatalf("expected one batch, got %d", applier.BatchCount())
+ }
+ if applier.FirstBatchSize() != 2 {
+ t.Fatalf("expected deduplicated batch of size 2, got %d", applier.FirstBatchSize())
+ }
+}
+
+func TestExecutorStopDrainsAndRejectsNewWork(t *testing.T) {
+ t.Parallel()
+
+ applier := &applierStub{}
+ executor := NewExecutor(logrus.New(), 4, applier)
+ target := BypassTarget{IP: "1.1.1.1", Port: 80, SetName: "set", Backend: "ipt", Timeout: 60}
+ if !executor.TryEnqueue(target) {
+ t.Fatal("QUEUE-EXEC-003 expected target to be queued before Start")
+ }
+ executor.Start()
+ executor.Stop()
+
+ if applier.BatchCount() != 1 {
+ t.Fatalf("QUEUE-EXEC-003 Stop applied %d batches, want 1", applier.BatchCount())
+ }
+ if executor.TryEnqueue(target) {
+ t.Fatal("QUEUE-EXEC-003 stopped executor accepted target")
+ }
+ if err := executor.Flush(context.Background()); !errors.Is(err, ErrExecutorStopped) {
+ t.Fatalf("QUEUE-EXEC-003 Flush error = %v, want ErrExecutorStopped", err)
+ }
+}
+
+func TestExecutorFlushHonorsContext(t *testing.T) {
+ t.Parallel()
+
+ executor := NewExecutor(logrus.New(), 1, &applierStub{})
+ if !executor.TryEnqueue(BypassTarget{IP: "1.1.1.1", Port: 80, SetName: "set", Backend: "ipt", Timeout: 60}) {
+ t.Fatal("expected queue setup to succeed")
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := executor.Flush(ctx); !errors.Is(err, context.Canceled) {
+ t.Fatalf("Flush error = %v, want context.Canceled", err)
+ }
+}
diff --git a/UA-Mask/core/internal/firewall/types.go b/UA-Mask/core/internal/firewall/types.go
new file mode 100644
index 000000000..00917aca4
--- /dev/null
+++ b/UA-Mask/core/internal/firewall/types.go
@@ -0,0 +1,9 @@
+package firewall
+
+type BypassTarget struct {
+ IP string
+ Port int
+ SetName string
+ Backend string
+ Timeout int
+}
diff --git a/UA-Mask/core/internal/ingress/original_dst_linux.go b/UA-Mask/core/internal/ingress/original_dst_linux.go
new file mode 100644
index 000000000..9ed1a02d3
--- /dev/null
+++ b/UA-Mask/core/internal/ingress/original_dst_linux.go
@@ -0,0 +1,44 @@
+//go:build linux
+// +build linux
+
+package ingress
+
+import (
+ "fmt"
+ "net"
+ "unsafe"
+
+ "golang.org/x/sys/unix"
+)
+
+func getOriginalDst(conn *net.TCPConn) (*net.TCPAddr, error) {
+ file, err := conn.File()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get file descriptor: %w", err)
+ }
+ defer file.Close()
+
+ fd := int(file.Fd())
+
+ const soOriginalDst = 80
+
+ var addr unix.RawSockaddrInet4
+ addrLen := uint32(unsafe.Sizeof(addr))
+
+ _, _, errno := unix.Syscall6(
+ unix.SYS_GETSOCKOPT,
+ uintptr(fd),
+ uintptr(unix.SOL_IP),
+ uintptr(soOriginalDst),
+ uintptr(unsafe.Pointer(&addr)),
+ uintptr(unsafe.Pointer(&addrLen)),
+ 0,
+ )
+ if errno != 0 {
+ return nil, fmt.Errorf("getsockopt SO_ORIGINAL_DST failed: %v", errno)
+ }
+
+ ip := net.IPv4(addr.Addr[0], addr.Addr[1], addr.Addr[2], addr.Addr[3])
+ port := int(addr.Port>>8 | addr.Port<<8)
+ return &net.TCPAddr{IP: ip, Port: port}, nil
+}
diff --git a/UA-Mask/core/internal/ingress/redirect.go b/UA-Mask/core/internal/ingress/redirect.go
new file mode 100644
index 000000000..666572928
--- /dev/null
+++ b/UA-Mask/core/internal/ingress/redirect.go
@@ -0,0 +1,109 @@
+package ingress
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "sync"
+
+ "UAmask/internal/config"
+ "UAmask/internal/model"
+)
+
+type Ingress interface {
+ Accept() (*model.AcceptedSession, error)
+ Close() error
+}
+
+type Redirect struct {
+ mu sync.Mutex
+ listener *net.TCPListener
+ config config.ListenConfig
+ closed bool
+}
+
+type temporaryAcceptError struct {
+ err error
+}
+
+func (e temporaryAcceptError) Error() string { return e.err.Error() }
+func (e temporaryAcceptError) Unwrap() error { return e.err }
+func (e temporaryAcceptError) Temporary() bool { return true }
+
+func NewRedirect(cfg config.ListenConfig) (*Redirect, error) {
+ return &Redirect{
+ config: cfg,
+ }, nil
+}
+
+func (r *Redirect) Accept() (*model.AcceptedSession, error) {
+ listener, err := r.ensureListener()
+ if err != nil {
+ return nil, err
+ }
+
+ clientConn, err := listener.AcceptTCP()
+ if err != nil {
+ return nil, err
+ }
+
+ originalDst, err := getOriginalDst(clientConn)
+ if err != nil {
+ _ = clientConn.Close()
+ return nil, temporaryAcceptError{err: err}
+ }
+
+ _ = clientConn.SetKeepAlive(true)
+ _ = clientConn.SetKeepAlivePeriod(r.config.ClientKeepAlive)
+
+ return &model.AcceptedSession{
+ ClientConn: clientConn,
+ IngressKind: model.IngressKindRedirect,
+ Target: model.Target{
+ Address: originalDst.String(),
+ IP: originalDst.IP.String(),
+ Port: originalDst.Port,
+ },
+ }, nil
+}
+
+func (r *Redirect) Close() error {
+ if r == nil {
+ return nil
+ }
+ r.mu.Lock()
+ if r.closed {
+ r.mu.Unlock()
+ return nil
+ }
+ r.closed = true
+ listener := r.listener
+ r.mu.Unlock()
+
+ if listener == nil {
+ return nil
+ }
+ if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
+ return err
+ }
+ return nil
+}
+
+func (r *Redirect) ensureListener() (*net.TCPListener, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.closed {
+ return nil, net.ErrClosed
+ }
+
+ if r.listener != nil {
+ return r.listener, nil
+ }
+
+ listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(0, 0, 0, 0), Port: r.config.Port})
+ if err != nil {
+ return nil, fmt.Errorf("listen failed: %w", err)
+ }
+ r.listener = listener
+ return listener, nil
+}
diff --git a/UA-Mask/core/internal/ingress/redirect_test.go b/UA-Mask/core/internal/ingress/redirect_test.go
new file mode 100644
index 000000000..cc89ee302
--- /dev/null
+++ b/UA-Mask/core/internal/ingress/redirect_test.go
@@ -0,0 +1,30 @@
+package ingress
+
+import (
+ "errors"
+ "net"
+ "testing"
+
+ "UAmask/internal/config"
+)
+
+func TestRedirectCloseIsIdempotentAndStopsAccept(t *testing.T) {
+ redirect, err := NewRedirect(config.ListenConfig{})
+ if err != nil {
+ t.Fatalf("LIFE-INGRESS-201 NewRedirect: %v", err)
+ }
+ if err := redirect.Close(); err != nil {
+ t.Fatalf("LIFE-INGRESS-201 first Close: %v", err)
+ }
+ if err := redirect.Close(); err != nil {
+ t.Fatalf("LIFE-INGRESS-201 second Close: %v", err)
+ }
+ if _, err := redirect.Accept(); !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("LIFE-INGRESS-201 Accept error = %v, want net.ErrClosed", err)
+ }
+
+ var nilRedirect *Redirect
+ if err := nilRedirect.Close(); err != nil {
+ t.Fatalf("LIFE-INGRESS-201 nil Close: %v", err)
+ }
+}
diff --git a/UA-Mask/core/internal/model/types.go b/UA-Mask/core/internal/model/types.go
new file mode 100644
index 000000000..4e580608a
--- /dev/null
+++ b/UA-Mask/core/internal/model/types.go
@@ -0,0 +1,167 @@
+package model
+
+import (
+ "net"
+ "net/http"
+ "time"
+)
+
+type Target struct {
+ Address string
+ IP string
+ Port int
+}
+
+type IngressKind string
+
+const (
+ IngressKindRedirect IngressKind = "redirect"
+)
+
+type AcceptedSession struct {
+ ClientConn *net.TCPConn
+ Target Target
+ IngressKind IngressKind
+ Metadata map[string]string
+}
+
+type ProtocolKind string
+
+const (
+ ProtocolUnknown ProtocolKind = "unknown"
+ ProtocolHTTP ProtocolKind = "http"
+ ProtocolNonHTTP ProtocolKind = "non_http"
+)
+
+type FlowOffloadStatus string
+
+const (
+ FlowOffloadNone FlowOffloadStatus = "none"
+ FlowOffloadActive FlowOffloadStatus = "active"
+ FlowOffloadFailed FlowOffloadStatus = "failed"
+)
+
+type SessionState struct {
+ ID uint64
+ IngressKind IngressKind
+ ClientAddr string
+ UpstreamAddr string
+ Target Target
+ Protocol ProtocolKind
+ ConsecutiveRewrite int
+ ConsecutiveNoRewrite int
+ FlowOffloadStatus FlowOffloadStatus
+ OpenedAt time.Time
+}
+
+type SessionEventType string
+
+const (
+ EventSessionOpened SessionEventType = "session_opened"
+ EventUpstreamConnected SessionEventType = "upstream_connected"
+ EventProtocolClassified SessionEventType = "protocol_classified"
+ EventHTTPRequestObserved SessionEventType = "http_request_observed"
+ EventRewriteApplied SessionEventType = "rewrite_applied"
+ EventRewriteSkipped SessionEventType = "rewrite_skipped"
+ EventNonHTTPObserved SessionEventType = "non_http_observed"
+ EventSessionForwardFallback SessionEventType = "session_forward_fallback"
+ EventSessionClosed SessionEventType = "session_closed"
+ EventFlowOffloadSucceeded SessionEventType = "flow_offload_succeeded"
+ EventFlowOffloadFailed SessionEventType = "flow_offload_failed"
+ EventTargetOffloadSucceeded SessionEventType = "target_offload_succeeded"
+ EventTargetOffloadFailed SessionEventType = "target_offload_failed"
+)
+
+type SessionEvent struct {
+ Type SessionEventType
+ SessionID uint64
+ Target Target
+ IngressKind IngressKind
+ Protocol ProtocolKind
+ Reason string
+ Modified bool
+ FromCache bool
+ FirewallHit bool
+ Timeout int
+ Timestamp time.Time
+}
+
+type TargetProfile struct {
+ Target Target
+ NonHTTPScore int
+ HTTPCooldownUntil time.Time
+ LastActivity time.Time
+ PendingTargetOffloadAt time.Time
+ TargetOffloadUntil time.Time
+ FirewallWhitelistHits int
+ LastFirewallHit bool
+ LastEventType SessionEventType
+ LastReason string
+}
+
+type ProfileSnapshot struct {
+ Found bool
+ Target Target
+ NonHTTPScore int
+ HTTPCooldownUntil time.Time
+ LastActivity time.Time
+ PendingTargetOffloadAt time.Time
+ TargetOffloadUntil time.Time
+ FirewallWhitelistHits int
+ LastFirewallHit bool
+ LastEventType SessionEventType
+ LastReason string
+}
+
+type SessionInputKind string
+
+const (
+ SessionInputClassification SessionInputKind = "classification"
+ SessionInputRequest SessionInputKind = "request"
+)
+
+type SessionInput struct {
+ Kind SessionInputKind
+ Protocol ProtocolKind
+ Request *http.Request
+ Modified bool
+ FromCache bool
+ Reason string
+ FirewallHit bool
+ DropConnection bool
+}
+
+type ActionKind string
+
+const (
+ ActionContinue ActionKind = "continue"
+ ActionPassthrough ActionKind = "passthrough"
+ ActionDrop ActionKind = "drop"
+)
+
+type OffloadSignalKind string
+
+const (
+ SignalNoAction OffloadSignalKind = "no_action"
+ SignalTryFlowOffload OffloadSignalKind = "try_flow_offload"
+ SignalTryTargetOffload OffloadSignalKind = "try_target_offload"
+ SignalCancelTargetOffload OffloadSignalKind = "cancel_target_offload"
+)
+
+type OffloadSignal struct {
+ Kind OffloadSignalKind
+ SessionID uint64
+ Target Target
+ Timeout int
+ Reason string
+}
+
+type PolicyDecision struct {
+ Protocol ProtocolKind
+ Action ActionKind
+ Modified bool
+ FromCache bool
+ Reason string
+ FirewallHit bool
+ FlowSignal OffloadSignal
+}
diff --git a/UA-Mask/core/internal/offload/coordinator.go b/UA-Mask/core/internal/offload/coordinator.go
new file mode 100644
index 000000000..1341ba3a6
--- /dev/null
+++ b/UA-Mask/core/internal/offload/coordinator.go
@@ -0,0 +1,311 @@
+package offload
+
+import (
+ "fmt"
+ "sync"
+
+ "UAmask/internal/clock"
+ "UAmask/internal/events"
+ "UAmask/internal/model"
+ "UAmask/internal/policy"
+ "UAmask/internal/state"
+
+ "github.com/sirupsen/logrus"
+)
+
+type FlowOffloader interface {
+ Handle(signal model.OffloadSignal) error
+}
+
+type TargetOffloader interface {
+ Handle(signal model.OffloadSignal, completion func(error)) error
+}
+
+type targetTimer struct {
+ timer clock.Timer
+}
+
+type Coordinator struct {
+ log *logrus.Logger
+ profiles state.ProfileStore
+ targetEvaluator policy.TargetEvaluator
+ targetOffloader TargetOffloader
+ flowOffloader FlowOffloader
+ events *events.Dispatcher
+ clock clock.Clock
+
+ mu sync.Mutex
+ targetTimers map[string]*targetTimer
+ inFlight map[string]struct{}
+ stopped bool
+ timerWG sync.WaitGroup
+ activeWG sync.WaitGroup
+}
+
+func NewCoordinator(
+ log *logrus.Logger,
+ profiles state.ProfileStore,
+ targetEvaluator policy.TargetEvaluator,
+ targetOffloader TargetOffloader,
+ flowOffloader FlowOffloader,
+ dispatcher *events.Dispatcher,
+) *Coordinator {
+ return NewCoordinatorWithClock(log, profiles, targetEvaluator, targetOffloader, flowOffloader, dispatcher, clock.RealClock{})
+}
+
+func NewCoordinatorWithClock(
+ log *logrus.Logger,
+ profiles state.ProfileStore,
+ targetEvaluator policy.TargetEvaluator,
+ targetOffloader TargetOffloader,
+ flowOffloader FlowOffloader,
+ dispatcher *events.Dispatcher,
+ runtimeClock clock.Clock,
+) *Coordinator {
+ return &Coordinator{
+ log: log,
+ profiles: profiles,
+ targetEvaluator: targetEvaluator,
+ targetOffloader: targetOffloader,
+ flowOffloader: flowOffloader,
+ events: dispatcher,
+ clock: clock.OrReal(runtimeClock),
+ targetTimers: make(map[string]*targetTimer),
+ inFlight: make(map[string]struct{}),
+ }
+}
+
+func (c *Coordinator) Append(event model.SessionEvent) {
+ switch event.Type {
+ case model.EventNonHTTPObserved, model.EventHTTPRequestObserved, model.EventRewriteSkipped:
+ if c.isStopped() {
+ return
+ }
+ c.observeTargetEvent(event)
+ case model.EventTargetOffloadSucceeded, model.EventTargetOffloadFailed:
+ c.clearTargetInFlight(event.Target)
+ case model.EventSessionClosed, model.EventRewriteApplied,
+ model.EventSessionOpened, model.EventUpstreamConnected, model.EventProtocolClassified,
+ model.EventSessionForwardFallback, model.EventFlowOffloadSucceeded,
+ model.EventFlowOffloadFailed:
+ return
+ }
+}
+
+func (c *Coordinator) Handle(signal model.OffloadSignal) {
+ if !c.beginOperation() {
+ return
+ }
+ defer c.activeWG.Done()
+ switch signal.Kind {
+ case model.SignalTryFlowOffload:
+ c.handleFlowSignal(signal)
+ case model.SignalTryTargetOffload:
+ c.handleTargetSignal(signal)
+ case model.SignalCancelTargetOffload, model.SignalNoAction:
+ return
+ }
+}
+
+func (c *Coordinator) Stop() {
+ c.mu.Lock()
+ if c.stopped {
+ c.mu.Unlock()
+ c.timerWG.Wait()
+ c.activeWG.Wait()
+ return
+ }
+ c.stopped = true
+ for key, scheduled := range c.targetTimers {
+ scheduled.timer.Stop()
+ delete(c.targetTimers, key)
+ }
+ c.mu.Unlock()
+ c.timerWG.Wait()
+ c.activeWG.Wait()
+}
+
+func (c *Coordinator) observeTargetEvent(event model.SessionEvent) {
+ snapshot := c.profiles.Snapshot(event.Target)
+ if !snapshot.Found {
+ return
+ }
+
+ switch event.Type {
+ case model.EventHTTPRequestObserved:
+ c.cancelTargetTimer(event.Target)
+ case model.EventNonHTTPObserved:
+ c.scheduleTargetEvaluation(snapshot)
+ case model.EventRewriteSkipped:
+ signal := c.targetEvaluator.Evaluate(snapshot)
+ c.Handle(signal)
+ }
+}
+
+func (c *Coordinator) scheduleTargetEvaluation(snapshot model.ProfileSnapshot) {
+ if snapshot.PendingTargetOffloadAt.IsZero() {
+ return
+ }
+
+ delay := snapshot.PendingTargetOffloadAt.Sub(c.clock.Now())
+ if delay < 0 {
+ delay = 0
+ }
+
+ key := targetKey(snapshot.Target)
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.stopped {
+ return
+ }
+
+ if scheduled, ok := c.targetTimers[key]; ok {
+ scheduled.timer.Stop()
+ }
+
+ scheduled := &targetTimer{}
+ scheduled.timer = c.clock.AfterFunc(delay, func() {
+ c.mu.Lock()
+ current, ok := c.targetTimers[key]
+ if !ok || current != scheduled || c.stopped {
+ c.mu.Unlock()
+ return
+ }
+ delete(c.targetTimers, key)
+ c.timerWG.Add(1)
+ c.mu.Unlock()
+ defer c.timerWG.Done()
+
+ latest := c.profiles.Snapshot(snapshot.Target)
+ signal := c.targetEvaluator.Evaluate(latest)
+ c.Handle(signal)
+ })
+ c.targetTimers[key] = scheduled
+}
+
+func (c *Coordinator) cancelTargetTimer(target model.Target) {
+ key := targetKey(target)
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if scheduled, ok := c.targetTimers[key]; ok {
+ scheduled.timer.Stop()
+ delete(c.targetTimers, key)
+ }
+}
+
+func (c *Coordinator) handleFlowSignal(signal model.OffloadSignal) {
+ if signal.Kind == model.SignalNoAction || c.flowOffloader == nil {
+ return
+ }
+
+ if err := c.flowOffloader.Handle(signal); err != nil {
+ c.log.Debugf("[Offload] flow signal failed for %s: %v", signal.Target.Address, err)
+ c.emit(model.SessionEvent{
+ Type: model.EventFlowOffloadFailed,
+ SessionID: signal.SessionID,
+ Target: signal.Target,
+ Reason: err.Error(),
+ Timestamp: c.clock.Now(),
+ })
+ return
+ }
+
+ c.emit(model.SessionEvent{
+ Type: model.EventFlowOffloadSucceeded,
+ SessionID: signal.SessionID,
+ Target: signal.Target,
+ Timestamp: c.clock.Now(),
+ })
+}
+
+func (c *Coordinator) handleTargetSignal(signal model.OffloadSignal) {
+ if signal.Kind == model.SignalNoAction || c.targetOffloader == nil {
+ return
+ }
+ if !c.markTargetInFlight(signal.Target) {
+ return
+ }
+
+ var once sync.Once
+ complete := func(err error) {
+ once.Do(func() {
+ c.completeTargetSignal(signal, err)
+ })
+ }
+ if err := c.targetOffloader.Handle(signal, complete); err != nil {
+ complete(err)
+ }
+}
+
+func (c *Coordinator) completeTargetSignal(signal model.OffloadSignal, err error) {
+ event := model.SessionEvent{
+ Type: model.EventTargetOffloadSucceeded,
+ SessionID: signal.SessionID,
+ Target: signal.Target,
+ Reason: signal.Reason,
+ Timeout: signal.Timeout,
+ Timestamp: c.clock.Now(),
+ }
+ if err != nil {
+ c.log.Debugf("[Offload] target signal failed for %s: %v", signal.Target.Address, err)
+ event.Type = model.EventTargetOffloadFailed
+ event.Reason = err.Error()
+ }
+ if !c.emit(event) {
+ c.clearTargetInFlight(signal.Target)
+ }
+}
+
+func (c *Coordinator) markTargetInFlight(target model.Target) bool {
+ key := targetKey(target)
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.stopped {
+ return false
+ }
+ if _, exists := c.inFlight[key]; exists {
+ return false
+ }
+ c.inFlight[key] = struct{}{}
+ return true
+}
+
+func (c *Coordinator) isStopped() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.stopped
+}
+
+func (c *Coordinator) beginOperation() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.stopped {
+ return false
+ }
+ c.activeWG.Add(1)
+ return true
+}
+
+func (c *Coordinator) clearTargetInFlight(target model.Target) {
+ c.mu.Lock()
+ delete(c.inFlight, targetKey(target))
+ c.mu.Unlock()
+}
+
+func (c *Coordinator) emit(event model.SessionEvent) bool {
+ if c.events == nil {
+ return false
+ }
+ if !c.events.TryAppend(event) {
+ c.log.Warnf("[Offload] event queue full, dropping %s for %s", event.Type, event.Target.Address)
+ return false
+ }
+ return true
+}
+
+func targetKey(target model.Target) string {
+ return fmt.Sprintf("%s:%d", target.IP, target.Port)
+}
diff --git a/UA-Mask/core/internal/offload/coordinator_test.go b/UA-Mask/core/internal/offload/coordinator_test.go
new file mode 100644
index 000000000..6ec89dec6
--- /dev/null
+++ b/UA-Mask/core/internal/offload/coordinator_test.go
@@ -0,0 +1,410 @@
+package offload
+
+import (
+ "errors"
+ "io"
+ "sync"
+ "testing"
+ "time"
+
+ "UAmask/internal/events"
+ "UAmask/internal/model"
+ "UAmask/internal/policy"
+ "UAmask/internal/state"
+ "UAmask/internal/testkit/manualclock"
+
+ "github.com/sirupsen/logrus"
+)
+
+type targetOffloaderStub struct {
+ mu sync.Mutex
+ signals []model.OffloadSignal
+ result error
+ handled chan model.OffloadSignal
+ completed chan struct{}
+}
+
+func (s *targetOffloaderStub) Handle(signal model.OffloadSignal, completion func(error)) error {
+ s.mu.Lock()
+ s.signals = append(s.signals, signal)
+ s.mu.Unlock()
+ if s.handled != nil {
+ s.handled <- signal
+ }
+ completion(s.result)
+ if s.completed != nil {
+ s.completed <- struct{}{}
+ }
+ return nil
+}
+
+func (s *targetOffloaderStub) Count() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return len(s.signals)
+}
+
+type collectorSink struct {
+ mu sync.Mutex
+ events []model.SessionEvent
+}
+
+type delayedTargetOffloaderStub struct {
+ mu sync.Mutex
+ count int
+ completion func(error)
+}
+
+type blockingTargetOffloaderStub struct {
+ mu sync.Mutex
+ count int
+ started chan struct{}
+ release chan struct{}
+ once sync.Once
+}
+
+func (s *blockingTargetOffloaderStub) Handle(_ model.OffloadSignal, completion func(error)) error {
+ s.mu.Lock()
+ s.count++
+ s.mu.Unlock()
+ s.once.Do(func() { close(s.started) })
+ <-s.release
+ completion(nil)
+ return nil
+}
+
+func (s *blockingTargetOffloaderStub) Count() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.count
+}
+
+func (s *delayedTargetOffloaderStub) Handle(_ model.OffloadSignal, completion func(error)) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.count++
+ s.completion = completion
+ return nil
+}
+
+func (s *delayedTargetOffloaderStub) Count() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.count
+}
+
+func (s *delayedTargetOffloaderStub) Complete(err error) {
+ s.mu.Lock()
+ completion := s.completion
+ s.mu.Unlock()
+ completion(err)
+}
+
+func (c *collectorSink) Append(event model.SessionEvent) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.events = append(c.events, event)
+}
+
+func (c *collectorSink) HasType(eventType model.SessionEventType) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ for _, event := range c.events {
+ if event.Type == eventType {
+ return true
+ }
+ }
+ return false
+}
+
+func TestCoordinatorUsesSnapshotAndEmitsFacts(t *testing.T) {
+ t.Parallel()
+
+ runtimeClock := manualclock.New(time.Unix(100, 0))
+ profiles := state.NewStoreWithClock(state.Config{
+ NonHTTPEnabled: true,
+ NonHTTPThreshold: 2,
+ HTTPCooldownPeriod: time.Minute,
+ DecisionDelay: 10 * time.Millisecond,
+ ProfileCleanupInterval: time.Minute,
+ }, runtimeClock)
+
+ targetOffloader := &targetOffloaderStub{}
+ collector := &collectorSink{}
+ dispatcher := events.NewDispatcher()
+ coordinator := NewCoordinatorWithClock(
+ nilLogger(),
+ profiles,
+ policy.NewTargetPolicyWithClock(60, 3600, 2, true, runtimeClock),
+ targetOffloader,
+ NoopFlowOffloader{},
+ dispatcher,
+ runtimeClock,
+ )
+ dispatcher.Add(profiles, collector, coordinator)
+
+ target := model.Target{IP: "1.1.1.1", Port: 80}
+ event := model.SessionEvent{
+ Type: model.EventRewriteSkipped,
+ Target: target,
+ Reason: "Hit Firewall UA Whitelist",
+ FirewallHit: true,
+ Timestamp: runtimeClock.Now(),
+ }
+
+ profiles.Append(event)
+ coordinator.Append(event)
+
+ if targetOffloader.Count() != 1 {
+ t.Fatalf("expected one target offload signal, got %d", targetOffloader.Count())
+ }
+ if !collector.HasType(model.EventTargetOffloadSucceeded) {
+ t.Fatalf("expected target offload success fact to be emitted")
+ }
+}
+
+func TestTargetOffloadFlow(t *testing.T) {
+ t.Run("FLOW-NONHTTP-001/facts produce one target action and result fact", func(t *testing.T) {
+ runtimeClock := manualclock.New(time.Unix(100, 0))
+ profiles := state.NewStoreWithClock(state.Config{
+ NonHTTPEnabled: true,
+ NonHTTPThreshold: 2,
+ HTTPCooldownPeriod: time.Minute,
+ DecisionDelay: 10 * time.Second,
+ ProfileCleanupInterval: time.Minute,
+ }, runtimeClock)
+ targetOffloader := &targetOffloaderStub{
+ handled: make(chan model.OffloadSignal, 1),
+ completed: make(chan struct{}, 1),
+ }
+ dispatcher := events.NewDispatcher()
+ coordinator := NewCoordinatorWithClock(
+ nilLogger(),
+ profiles,
+ policy.NewTargetPolicyWithClock(60, 3600, 2, true, runtimeClock),
+ targetOffloader,
+ NoopFlowOffloader{},
+ dispatcher,
+ runtimeClock,
+ )
+ defer coordinator.Stop()
+ dispatcher.Add(profiles, coordinator)
+
+ target := model.Target{IP: "203.0.113.10", Port: 443}
+ first := model.SessionEvent{
+ Type: model.EventNonHTTPObserved,
+ SessionID: 1,
+ Target: target,
+ Timestamp: runtimeClock.Now(),
+ }
+ dispatcher.Append(first)
+ if snapshot := profiles.Snapshot(target); snapshot.NonHTTPScore != 1 || !snapshot.PendingTargetOffloadAt.IsZero() {
+ t.Fatalf("first fact produced unexpected snapshot: %+v", snapshot)
+ }
+
+ second := first
+ second.SessionID = 2
+ second.Timestamp = first.Timestamp.Add(time.Millisecond)
+ dispatcher.Append(second)
+ runtimeClock.Advance(10*time.Second + time.Millisecond)
+
+ var signal model.OffloadSignal
+ select {
+ case signal = <-targetOffloader.handled:
+ default:
+ t.Fatal("target offload signal was not submitted")
+ }
+ if signal.Kind != model.SignalTryTargetOffload || signal.Target != target || signal.Timeout != 60 || signal.Reason != "non-http threshold reached" {
+ t.Fatalf("unexpected target offload signal: %+v", signal)
+ }
+ select {
+ case <-targetOffloader.completed:
+ default:
+ t.Fatal("target offload completion was not projected")
+ }
+
+ snapshot := profiles.Snapshot(target)
+ if snapshot.NonHTTPScore != 0 || snapshot.TargetOffloadUntil.IsZero() || snapshot.LastEventType != model.EventTargetOffloadSucceeded {
+ t.Fatalf("success fact was not projected into state: %+v", snapshot)
+ }
+
+ third := second
+ third.SessionID = 3
+ third.Timestamp = second.Timestamp.Add(time.Millisecond)
+ dispatcher.Append(third)
+ if targetOffloader.Count() != 1 {
+ t.Fatalf("offloaded target was submitted %d times, want 1", targetOffloader.Count())
+ }
+ })
+}
+
+func TestCoordinatorKeepsTargetInFlightUntilResultEventIsProjected(t *testing.T) {
+ t.Parallel()
+
+ profiles := state.NewStore(state.Config{NonHTTPThreshold: 2})
+ targetOffloader := &delayedTargetOffloaderStub{}
+ dispatcher := events.NewDispatcher()
+ coordinator := NewCoordinator(
+ nilLogger(),
+ profiles,
+ policy.NewTargetPolicy(60, 3600, 2, true),
+ targetOffloader,
+ NoopFlowOffloader{},
+ dispatcher,
+ )
+ dispatcher.Add(profiles, coordinator)
+
+ target := model.Target{IP: "1.1.1.1", Port: 80}
+ signal := model.OffloadSignal{
+ Kind: model.SignalTryTargetOffload,
+ Target: target,
+ Timeout: 60,
+ }
+ coordinator.Handle(signal)
+ coordinator.Handle(signal)
+
+ if targetOffloader.Count() != 1 {
+ t.Fatalf("expected duplicate signal to be suppressed, got %d submissions", targetOffloader.Count())
+ }
+ if snapshot := profiles.Snapshot(target); snapshot.Found {
+ t.Fatalf("target must not be projected as offloaded before apply completes: %+v", snapshot)
+ }
+ targetOffloader.Complete(nil)
+ if snapshot := profiles.Snapshot(target); !snapshot.Found || snapshot.TargetOffloadUntil.IsZero() {
+ t.Fatalf("expected successful apply result to update profile, got %+v", snapshot)
+ }
+}
+
+func TestCoordinatorProjectsApplyFailure(t *testing.T) {
+ t.Parallel()
+
+ profiles := state.NewStore(state.Config{})
+ collector := &collectorSink{}
+ targetOffloader := &targetOffloaderStub{result: errors.New("apply failed")}
+ dispatcher := events.NewDispatcher()
+ coordinator := NewCoordinator(
+ nilLogger(),
+ profiles,
+ policy.NewTargetPolicy(60, 3600, 2, true),
+ targetOffloader,
+ NoopFlowOffloader{},
+ dispatcher,
+ )
+ dispatcher.Add(profiles, collector, coordinator)
+ target := model.Target{IP: "1.1.1.1", Port: 80}
+
+ coordinator.Handle(model.OffloadSignal{
+ Kind: model.SignalTryTargetOffload,
+ Target: target,
+ Timeout: 60,
+ })
+
+ if !collector.HasType(model.EventTargetOffloadFailed) {
+ t.Fatal("expected actual apply failure to be emitted")
+ }
+ if snapshot := profiles.Snapshot(target); !snapshot.TargetOffloadUntil.IsZero() {
+ t.Fatalf("failed apply must not suppress retries: %+v", snapshot)
+ }
+}
+
+func TestCoordinatorStopCancelsPendingTargetEvaluation(t *testing.T) {
+ t.Parallel()
+
+ runtimeClock := manualclock.New(time.Unix(100, 0))
+ profiles := state.NewStoreWithClock(state.Config{
+ NonHTTPEnabled: true,
+ NonHTTPThreshold: 1,
+ DecisionDelay: 30 * time.Second,
+ }, runtimeClock)
+ targetOffloader := &delayedTargetOffloaderStub{}
+ dispatcher := events.NewDispatcher()
+ coordinator := NewCoordinatorWithClock(
+ nilLogger(),
+ profiles,
+ policy.NewTargetPolicyWithClock(60, 3600, 1, true, runtimeClock),
+ targetOffloader,
+ NoopFlowOffloader{},
+ dispatcher,
+ runtimeClock,
+ )
+ target := model.Target{IP: "1.1.1.1", Port: 80}
+ event := model.SessionEvent{
+ Type: model.EventNonHTTPObserved,
+ Target: target,
+ Timestamp: runtimeClock.Now(),
+ }
+ profiles.Append(event)
+ coordinator.Append(event)
+
+ coordinator.Stop()
+ runtimeClock.Advance(30 * time.Second)
+ if targetOffloader.Count() != 0 {
+ t.Fatalf("expected stopped coordinator to suppress timer submission, got %d", targetOffloader.Count())
+ }
+}
+
+func TestCoordinatorStopWaitsForActiveSubmissionAndRejectsNewWork(t *testing.T) {
+ t.Parallel()
+
+ targetOffloader := &blockingTargetOffloaderStub{
+ started: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+ coordinator := NewCoordinator(
+ nilLogger(),
+ state.NewStore(state.Config{}),
+ policy.NewTargetPolicy(60, 3600, 2, true),
+ targetOffloader,
+ NoopFlowOffloader{},
+ events.NewDispatcher(),
+ )
+ signal := model.OffloadSignal{
+ Kind: model.SignalTryTargetOffload,
+ Target: model.Target{IP: "1.1.1.1", Port: 80},
+ Timeout: 60,
+ }
+ handleDone := make(chan struct{})
+ go func() {
+ coordinator.Handle(signal)
+ close(handleDone)
+ }()
+
+ select {
+ case <-targetOffloader.started:
+ case <-time.After(time.Second):
+ t.Fatal("target submission did not start")
+ }
+ stopDone := make(chan struct{})
+ go func() {
+ coordinator.Stop()
+ close(stopDone)
+ }()
+ select {
+ case <-stopDone:
+ t.Fatal("Stop returned while target submission was active")
+ case <-time.After(20 * time.Millisecond):
+ }
+
+ close(targetOffloader.release)
+ select {
+ case <-handleDone:
+ case <-time.After(time.Second):
+ t.Fatal("target submission did not finish")
+ }
+ select {
+ case <-stopDone:
+ case <-time.After(time.Second):
+ t.Fatal("Stop did not finish after active submission completed")
+ }
+
+ coordinator.Handle(signal)
+ if targetOffloader.Count() != 1 {
+ t.Fatalf("expected stopped coordinator to reject new work, got %d submissions", targetOffloader.Count())
+ }
+}
+
+func nilLogger() *logrus.Logger {
+ logger := logrus.New()
+ logger.SetOutput(io.Discard)
+ return logger
+}
diff --git a/UA-Mask/core/internal/offload/target.go b/UA-Mask/core/internal/offload/target.go
new file mode 100644
index 000000000..8e3826b4f
--- /dev/null
+++ b/UA-Mask/core/internal/offload/target.go
@@ -0,0 +1,45 @@
+package offload
+
+import (
+ "fmt"
+
+ "UAmask/internal/firewall"
+ "UAmask/internal/model"
+)
+
+type FirewallTargetOffloader struct {
+ executor *firewall.Executor
+ setName string
+ backend string
+}
+
+func NewFirewallTargetOffloader(executor *firewall.Executor, setName, backend string) *FirewallTargetOffloader {
+ return &FirewallTargetOffloader{
+ executor: executor,
+ setName: setName,
+ backend: backend,
+ }
+}
+
+func (o *FirewallTargetOffloader) Handle(signal model.OffloadSignal, completion func(error)) error {
+ if o == nil || o.executor == nil {
+ return fmt.Errorf("target offloader not configured")
+ }
+
+ ok := o.executor.TryEnqueueWithResult(firewall.BypassTarget{
+ IP: signal.Target.IP,
+ Port: signal.Target.Port,
+ SetName: o.setName,
+ Backend: o.backend,
+ Timeout: signal.Timeout,
+ }, completion)
+ if !ok {
+ return fmt.Errorf("enqueue target offload failed")
+ }
+
+ return nil
+}
+
+type NoopFlowOffloader struct{}
+
+func (NoopFlowOffloader) Handle(model.OffloadSignal) error { return nil }
diff --git a/UA-Mask/core/internal/policy/evaluator.go b/UA-Mask/core/internal/policy/evaluator.go
new file mode 100644
index 000000000..c27cb717d
--- /dev/null
+++ b/UA-Mask/core/internal/policy/evaluator.go
@@ -0,0 +1,119 @@
+package policy
+
+import (
+ "UAmask/internal/clock"
+ "UAmask/internal/model"
+)
+
+type SessionEvaluator interface {
+ Evaluate(session *model.SessionState, input model.SessionInput, snapshot model.ProfileSnapshot) model.PolicyDecision
+}
+
+type TargetEvaluator interface {
+ Evaluate(snapshot model.ProfileSnapshot) model.OffloadSignal
+}
+
+type SessionPolicy struct{}
+
+func NewSessionPolicy() *SessionPolicy {
+ return &SessionPolicy{}
+}
+
+func (p *SessionPolicy) Evaluate(session *model.SessionState, input model.SessionInput, _ model.ProfileSnapshot) model.PolicyDecision {
+ switch input.Kind {
+ case model.SessionInputClassification:
+ if input.Protocol == model.ProtocolNonHTTP {
+ return model.PolicyDecision{
+ Protocol: model.ProtocolNonHTTP,
+ Action: model.ActionPassthrough,
+ FlowSignal: model.OffloadSignal{Kind: model.SignalNoAction},
+ }
+ }
+ case model.SessionInputRequest:
+ if input.Request == nil {
+ return model.PolicyDecision{
+ Protocol: model.ProtocolHTTP,
+ Action: model.ActionDrop,
+ Reason: "nil request",
+ FlowSignal: model.OffloadSignal{Kind: model.SignalNoAction},
+ }
+ }
+
+ decision := model.PolicyDecision{
+ Protocol: model.ProtocolHTTP,
+ Action: model.ActionContinue,
+ Modified: input.Modified,
+ FromCache: input.FromCache,
+ Reason: input.Reason,
+ FirewallHit: input.FirewallHit,
+ FlowSignal: model.OffloadSignal{Kind: model.SignalNoAction},
+ }
+ if input.DropConnection {
+ decision.Action = model.ActionDrop
+ }
+
+ return decision
+ }
+
+ return model.PolicyDecision{
+ Action: model.ActionContinue,
+ FlowSignal: model.OffloadSignal{Kind: model.SignalNoAction},
+ }
+}
+
+type TargetPolicy struct {
+ targetOffloadTimeout int
+ whitelistOffloadTimeout int
+ nonHTTPThreshold int
+ nonHTTPEnabled bool
+ clock clock.Clock
+}
+
+func NewTargetPolicy(targetOffloadTimeout int, whitelistOffloadTimeout int, nonHTTPThreshold int, nonHTTPEnabled bool) *TargetPolicy {
+ return NewTargetPolicyWithClock(targetOffloadTimeout, whitelistOffloadTimeout, nonHTTPThreshold, nonHTTPEnabled, clock.RealClock{})
+}
+
+func NewTargetPolicyWithClock(targetOffloadTimeout int, whitelistOffloadTimeout int, nonHTTPThreshold int, nonHTTPEnabled bool, runtimeClock clock.Clock) *TargetPolicy {
+ return &TargetPolicy{
+ targetOffloadTimeout: targetOffloadTimeout,
+ whitelistOffloadTimeout: whitelistOffloadTimeout,
+ nonHTTPThreshold: nonHTTPThreshold,
+ nonHTTPEnabled: nonHTTPEnabled,
+ clock: clock.OrReal(runtimeClock),
+ }
+}
+
+func (p *TargetPolicy) Evaluate(snapshot model.ProfileSnapshot) model.OffloadSignal {
+ if !snapshot.Found {
+ return model.OffloadSignal{Kind: model.SignalNoAction}
+ }
+
+ now := p.clock.Now()
+ if !snapshot.TargetOffloadUntil.IsZero() && now.Before(snapshot.TargetOffloadUntil) {
+ return model.OffloadSignal{Kind: model.SignalNoAction}
+ }
+
+ if snapshot.LastEventType == model.EventRewriteSkipped && snapshot.LastFirewallHit {
+ return model.OffloadSignal{
+ Kind: model.SignalTryTargetOffload,
+ Target: snapshot.Target,
+ Timeout: p.whitelistOffloadTimeout,
+ Reason: snapshot.LastReason,
+ }
+ }
+
+ if p.nonHTTPEnabled &&
+ !snapshot.PendingTargetOffloadAt.IsZero() &&
+ !now.Before(snapshot.PendingTargetOffloadAt) &&
+ (snapshot.HTTPCooldownUntil.IsZero() || !now.Before(snapshot.HTTPCooldownUntil)) &&
+ snapshot.NonHTTPScore >= p.nonHTTPThreshold {
+ return model.OffloadSignal{
+ Kind: model.SignalTryTargetOffload,
+ Target: snapshot.Target,
+ Timeout: p.targetOffloadTimeout,
+ Reason: "non-http threshold reached",
+ }
+ }
+
+ return model.OffloadSignal{Kind: model.SignalNoAction}
+}
diff --git a/UA-Mask/core/internal/policy/evaluator_test.go b/UA-Mask/core/internal/policy/evaluator_test.go
new file mode 100644
index 000000000..8d8e54609
--- /dev/null
+++ b/UA-Mask/core/internal/policy/evaluator_test.go
@@ -0,0 +1,141 @@
+package policy
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "UAmask/internal/model"
+ "UAmask/internal/testkit/manualclock"
+)
+
+func TestEvaluateSessionUsesRewriteInputWithoutSideEffects(t *testing.T) {
+ t.Parallel()
+
+ evaluator := NewSessionPolicy()
+ sessionState := &model.SessionState{Target: model.Target{IP: "1.1.1.1", Port: 80}}
+ req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Header.Set("User-Agent", "Original")
+
+ decision := evaluator.Evaluate(sessionState, model.SessionInput{
+ Kind: model.SessionInputRequest,
+ Protocol: model.ProtocolHTTP,
+ Request: req,
+ Modified: true,
+ FromCache: true,
+ Reason: "rewrite input",
+ FirewallHit: true,
+ DropConnection: false,
+ }, model.ProfileSnapshot{})
+
+ if decision.Action != model.ActionContinue {
+ t.Fatalf("expected continue action, got %s", decision.Action)
+ }
+ if !decision.Modified {
+ t.Fatalf("expected modified request")
+ }
+ if !decision.FromCache {
+ t.Fatalf("expected cache flag to be propagated")
+ }
+ if got := decision.Reason; got != "rewrite input" {
+ t.Fatalf("expected rewrite reason, got %q", got)
+ }
+ if !decision.FirewallHit {
+ t.Fatalf("expected firewall hit flag to be propagated")
+ }
+ if got := req.Header.Get("User-Agent"); got != "Original" {
+ t.Fatalf("policy must not rewrite UA, got %q", got)
+ }
+ if sessionState.ConsecutiveRewrite != 0 || sessionState.ConsecutiveNoRewrite != 0 {
+ t.Fatalf("policy must not mutate session counters: %+v", sessionState)
+ }
+}
+
+func TestEvaluateTargetUsesSnapshotOnly(t *testing.T) {
+ t.Parallel()
+
+ runtimeClock := manualclock.New(time.Unix(100, 0))
+ evaluator := NewTargetPolicyWithClock(60, 3600, 2, true, runtimeClock)
+ target := model.Target{IP: "1.1.1.1", Port: 80}
+
+ whitelistSignal := evaluator.Evaluate(model.ProfileSnapshot{
+ Found: true,
+ Target: target,
+ LastEventType: model.EventRewriteSkipped,
+ LastFirewallHit: true,
+ })
+ if whitelistSignal.Kind != model.SignalTryTargetOffload {
+ t.Fatalf("expected whitelist snapshot to trigger offload, got %s", whitelistSignal.Kind)
+ }
+
+ nonHTTPSignal := evaluator.Evaluate(model.ProfileSnapshot{
+ Found: true,
+ Target: target,
+ NonHTTPScore: 2,
+ PendingTargetOffloadAt: time.Unix(90, 0),
+ })
+ if nonHTTPSignal.Kind != model.SignalTryTargetOffload {
+ t.Fatalf("expected matured non-http snapshot to trigger offload, got %s", nonHTTPSignal.Kind)
+ }
+
+ noAction := evaluator.Evaluate(model.ProfileSnapshot{
+ Found: true,
+ Target: target,
+ NonHTTPScore: 1,
+ PendingTargetOffloadAt: time.Unix(200, 0),
+ })
+ if noAction.Kind != model.SignalNoAction {
+ t.Fatalf("expected no action for immature snapshot, got %s", noAction.Kind)
+ }
+}
+
+func TestEvaluateTargetKeepsWhitelistEnabledWhenNonHTTPBypassIsDisabled(t *testing.T) {
+ t.Parallel()
+
+ runtimeClock := manualclock.New(time.Unix(100, 0))
+ evaluator := NewTargetPolicyWithClock(60, 3600, 2, false, runtimeClock)
+ target := model.Target{IP: "1.1.1.1", Port: 80}
+
+ whitelistSignal := evaluator.Evaluate(model.ProfileSnapshot{
+ Found: true,
+ Target: target,
+ LastEventType: model.EventRewriteSkipped,
+ LastFirewallHit: true,
+ })
+ if whitelistSignal.Kind != model.SignalTryTargetOffload {
+ t.Fatalf("expected whitelist offload to remain enabled, got %s", whitelistSignal.Kind)
+ }
+
+ nonHTTPSignal := evaluator.Evaluate(model.ProfileSnapshot{
+ Found: true,
+ Target: target,
+ NonHTTPScore: 2,
+ PendingTargetOffloadAt: runtimeClock.Now().Add(-time.Second),
+ })
+ if nonHTTPSignal.Kind != model.SignalNoAction {
+ t.Fatalf("expected non-http offload to be disabled, got %s", nonHTTPSignal.Kind)
+ }
+}
+
+func TestEvaluateSessionNonHTTPPassthrough(t *testing.T) {
+ t.Parallel()
+
+ evaluator := NewSessionPolicy()
+ sessionState := &model.SessionState{}
+
+ decision := evaluator.Evaluate(sessionState, model.SessionInput{
+ Kind: model.SessionInputClassification,
+ Protocol: model.ProtocolNonHTTP,
+ }, model.ProfileSnapshot{})
+
+ if decision.Action != model.ActionPassthrough {
+ t.Fatalf("expected passthrough action, got %s", decision.Action)
+ }
+ if strings.TrimSpace(decision.Reason) != "" {
+ t.Fatalf("expected empty reason for non-http passthrough, got %q", decision.Reason)
+ }
+}
diff --git a/UA-Mask/core/internal/proxy/server.go b/UA-Mask/core/internal/proxy/server.go
new file mode 100644
index 000000000..3d8ea88a1
--- /dev/null
+++ b/UA-Mask/core/internal/proxy/server.go
@@ -0,0 +1,253 @@
+package proxy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "UAmask/internal/ingress"
+ "UAmask/internal/model"
+ "UAmask/internal/session"
+
+ "github.com/sirupsen/logrus"
+)
+
+const acceptRetryDelay = 5 * time.Millisecond
+
+type Server struct {
+ poolSize int
+ ingress ingress.Ingress
+ manager session.Manager
+}
+
+func NewServer(poolSize int, ingress ingress.Ingress, manager session.Manager) *Server {
+ return &Server{
+ poolSize: poolSize,
+ ingress: ingress,
+ manager: manager,
+ }
+}
+
+func (s *Server) Run() error {
+ return s.RunContext(context.Background())
+}
+
+func (s *Server) RunContext(ctx context.Context) error {
+ if s.ingress == nil || s.manager == nil {
+ return fmt.Errorf("server dependencies are not configured")
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ sessionCtx, cancelSessions := context.WithCancel(context.WithoutCancel(ctx))
+
+ tracker := newSessionTracker()
+ shutdownStarted := make(chan struct{})
+ var shutdownOnce sync.Once
+ shutdown := func() {
+ shutdownOnce.Do(func() {
+ close(shutdownStarted)
+ _ = s.ingress.Close()
+ cancelSessions()
+ tracker.CloseAll()
+ })
+ }
+ watchDone := make(chan struct{})
+ go func() {
+ select {
+ case <-ctx.Done():
+ shutdown()
+ case <-watchDone:
+ }
+ }()
+
+ logrus.Infof("UA-Mask ingress server ready")
+ var runErr error
+ if s.poolSize > 0 {
+ runErr = s.runWithPool(sessionCtx, tracker, shutdownStarted, shutdown)
+ } else {
+ runErr = s.runDefault(sessionCtx, tracker, shutdownStarted)
+ }
+ shutdown()
+ close(watchDone)
+ tracker.Wait()
+ return runErr
+}
+
+func (s *Server) runWithPool(
+ ctx context.Context,
+ tracker *sessionTracker,
+ shutdownStarted <-chan struct{},
+ shutdown func(),
+) error {
+ logrus.Infof("Starting in Worker Pool Mode (size: %d)", s.poolSize)
+
+ sessionChan := make(chan *model.AcceptedSession, s.poolSize)
+ var workers sync.WaitGroup
+ for i := 0; i < s.poolSize; i++ {
+ workers.Add(1)
+ go func(workerID int) {
+ defer workers.Done()
+ for accepted := range sessionChan {
+ if ctx.Err() != nil {
+ closeAccepted(accepted)
+ tracker.Done(accepted)
+ continue
+ }
+ logrus.Debugf("[server] Worker %d processing connection for %s", workerID, accepted.Target.Address)
+ s.handleSession(ctx, accepted)
+ tracker.Done(accepted)
+ }
+ }(i)
+ }
+
+ err := s.acceptLoop(ctx, shutdownStarted, func(accepted *model.AcceptedSession) bool {
+ if !tracker.Add(accepted) {
+ closeAccepted(accepted)
+ return false
+ }
+ select {
+ case sessionChan <- accepted:
+ return true
+ case <-ctx.Done():
+ closeAccepted(accepted)
+ tracker.Done(accepted)
+ return false
+ }
+ })
+ shutdown()
+ close(sessionChan)
+ workers.Wait()
+ return err
+}
+
+func (s *Server) runDefault(ctx context.Context, tracker *sessionTracker, shutdownStarted <-chan struct{}) error {
+ logrus.Info("Starting in Default Mode (one goroutine per connection)")
+ return s.acceptLoop(ctx, shutdownStarted, func(accepted *model.AcceptedSession) bool {
+ if !tracker.Add(accepted) {
+ closeAccepted(accepted)
+ return false
+ }
+ go func() {
+ defer tracker.Done(accepted)
+ s.handleSession(ctx, accepted)
+ }()
+ return true
+ })
+}
+
+func (s *Server) handleSession(ctx context.Context, accepted *model.AcceptedSession) {
+ if manager, ok := s.manager.(session.ContextManager); ok {
+ manager.HandleContext(ctx, accepted)
+ return
+ }
+ s.manager.Handle(accepted)
+}
+
+func (s *Server) acceptLoop(
+ ctx context.Context,
+ shutdownStarted <-chan struct{},
+ submit func(*model.AcceptedSession) bool,
+) error {
+ for {
+ accepted, err := s.ingress.Accept()
+ if err != nil {
+ if ctx.Err() != nil || channelClosed(shutdownStarted) {
+ return nil
+ }
+ if !isTemporaryAcceptError(err) {
+ return fmt.Errorf("accept session: %w", err)
+ }
+ logrus.Warnf("Accept error: %v; retrying...", err)
+ if !waitForRetry(ctx) {
+ return nil
+ }
+ continue
+ }
+ if accepted == nil {
+ return errors.New("accept session: ingress returned nil session")
+ }
+ if !submit(accepted) {
+ return nil
+ }
+ }
+}
+
+func channelClosed(channel <-chan struct{}) bool {
+ select {
+ case <-channel:
+ return true
+ default:
+ return false
+ }
+}
+
+func isTemporaryAcceptError(err error) bool {
+ var temporary interface{ Temporary() bool }
+ return errors.As(err, &temporary) && temporary.Temporary()
+}
+
+func waitForRetry(ctx context.Context) bool {
+ timer := time.NewTimer(acceptRetryDelay)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ return true
+ case <-ctx.Done():
+ return false
+ }
+}
+
+type sessionTracker struct {
+ mu sync.Mutex
+ active map[*model.AcceptedSession]struct{}
+ closed bool
+ wg sync.WaitGroup
+}
+
+func newSessionTracker() *sessionTracker {
+ return &sessionTracker{active: make(map[*model.AcceptedSession]struct{})}
+}
+
+func (tracker *sessionTracker) Add(accepted *model.AcceptedSession) bool {
+ tracker.mu.Lock()
+ defer tracker.mu.Unlock()
+ if tracker.closed {
+ return false
+ }
+ tracker.active[accepted] = struct{}{}
+ tracker.wg.Add(1)
+ return true
+}
+
+func (tracker *sessionTracker) Done(accepted *model.AcceptedSession) {
+ tracker.mu.Lock()
+ delete(tracker.active, accepted)
+ tracker.mu.Unlock()
+ tracker.wg.Done()
+}
+
+func (tracker *sessionTracker) CloseAll() {
+ tracker.mu.Lock()
+ tracker.closed = true
+ accepted := make([]*model.AcceptedSession, 0, len(tracker.active))
+ for session := range tracker.active {
+ accepted = append(accepted, session)
+ }
+ tracker.mu.Unlock()
+ for _, session := range accepted {
+ closeAccepted(session)
+ }
+}
+
+func (tracker *sessionTracker) Wait() {
+ tracker.wg.Wait()
+}
+
+func closeAccepted(accepted *model.AcceptedSession) {
+ if accepted != nil && accepted.ClientConn != nil {
+ _ = accepted.ClientConn.Close()
+ }
+}
diff --git a/UA-Mask/core/internal/proxy/server_test.go b/UA-Mask/core/internal/proxy/server_test.go
new file mode 100644
index 000000000..a7880fb52
--- /dev/null
+++ b/UA-Mask/core/internal/proxy/server_test.go
@@ -0,0 +1,283 @@
+package proxy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "UAmask/internal/model"
+
+ "github.com/sirupsen/logrus"
+)
+
+func TestMain(main *testing.M) {
+ logrus.SetOutput(io.Discard)
+ os.Exit(main.Run())
+}
+
+type acceptResult struct {
+ session *model.AcceptedSession
+ err error
+}
+
+type ingressStub struct {
+ results chan acceptResult
+ delivered chan *model.AcceptedSession
+ closed chan struct{}
+ once sync.Once
+}
+
+func newIngressStub() *ingressStub {
+ return &ingressStub{
+ results: make(chan acceptResult, 4),
+ delivered: make(chan *model.AcceptedSession, 4),
+ closed: make(chan struct{}),
+ }
+}
+
+func (stub *ingressStub) Accept() (*model.AcceptedSession, error) {
+ select {
+ case result := <-stub.results:
+ select {
+ case stub.delivered <- result.session:
+ default:
+ }
+ return result.session, result.err
+ case <-stub.closed:
+ return nil, context.Canceled
+ }
+}
+
+type contextBlockingManager struct {
+ mu sync.Mutex
+ handled []*model.AcceptedSession
+ started chan *model.AcceptedSession
+}
+
+type orderedContextManager struct {
+ ingress *ingressStub
+ started chan struct{}
+ observed chan bool
+}
+
+func (manager *orderedContextManager) Handle(accepted *model.AcceptedSession) {
+ manager.HandleContext(context.Background(), accepted)
+}
+
+func (manager *orderedContextManager) HandleContext(ctx context.Context, _ *model.AcceptedSession) {
+ close(manager.started)
+ <-ctx.Done()
+ select {
+ case <-manager.ingress.closed:
+ manager.observed <- true
+ default:
+ manager.observed <- false
+ }
+}
+
+func (manager *contextBlockingManager) Handle(accepted *model.AcceptedSession) {
+ manager.HandleContext(context.Background(), accepted)
+}
+
+func (manager *contextBlockingManager) HandleContext(ctx context.Context, accepted *model.AcceptedSession) {
+ manager.mu.Lock()
+ manager.handled = append(manager.handled, accepted)
+ manager.mu.Unlock()
+ manager.started <- accepted
+ <-ctx.Done()
+}
+
+func (manager *contextBlockingManager) Handled() []*model.AcceptedSession {
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ return append([]*model.AcceptedSession(nil), manager.handled...)
+}
+
+func (stub *ingressStub) Close() error {
+ stub.once.Do(func() { close(stub.closed) })
+ return nil
+}
+
+type blockingManager struct {
+ started chan *model.AcceptedSession
+ release chan struct{}
+}
+
+func (manager *blockingManager) Handle(accepted *model.AcceptedSession) {
+ manager.started <- accepted
+ <-manager.release
+}
+
+func TestServerRunContextWaitsForAcceptedSessions(t *testing.T) {
+ for _, poolSize := range []int{0, 2} {
+ poolSize := poolSize
+ t.Run(fmt.Sprintf("LIFE-SHUTDOWN-201/pool-%d", poolSize), func(t *testing.T) {
+ ingress := newIngressStub()
+ manager := &blockingManager{
+ started: make(chan *model.AcceptedSession, 1),
+ release: make(chan struct{}),
+ }
+ server := NewServer(poolSize, ingress, manager)
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- server.RunContext(ctx) }()
+
+ accepted := &model.AcceptedSession{Target: model.Target{Address: "203.0.113.10:443"}}
+ ingress.results <- acceptResult{session: accepted}
+ if got := waitValue(t, manager.started); got != accepted {
+ t.Fatalf("manager received %p, want %p", got, accepted)
+ }
+
+ cancel()
+ waitClosed(t, ingress.closed)
+ select {
+ case err := <-done:
+ t.Fatalf("server returned before accepted session completed: %v", err)
+ default:
+ }
+
+ close(manager.release)
+ if err := waitValue(t, done); err != nil {
+ t.Fatalf("RunContext returned error on cancellation: %v", err)
+ }
+ })
+ }
+}
+
+func TestServerRunContextReturnsFatalAcceptError(t *testing.T) {
+ ingress := newIngressStub()
+ ingress.results <- acceptResult{err: errors.New("listener failed")}
+ server := NewServer(0, ingress, &blockingManager{
+ started: make(chan *model.AcceptedSession, 1),
+ release: make(chan struct{}),
+ })
+
+ err := server.RunContext(context.Background())
+ if err == nil || !strings.Contains(err.Error(), "listener failed") {
+ t.Fatalf("LIFE-ERROR-201 RunContext error = %v, want listener failure", err)
+ }
+ waitClosed(t, ingress.closed)
+}
+
+func TestServerWorkerPoolSkipsQueuedSessionsAfterCancellation(t *testing.T) {
+ ingress := newIngressStub()
+ manager := &contextBlockingManager{started: make(chan *model.AcceptedSession, 2)}
+ server := NewServer(1, ingress, manager)
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- server.RunContext(ctx) }()
+
+ first := &model.AcceptedSession{Target: model.Target{Address: "203.0.113.10:80"}}
+ second := &model.AcceptedSession{Target: model.Target{Address: "203.0.113.11:80"}}
+ ingress.results <- acceptResult{session: first}
+ if got := waitValue(t, manager.started); got != first {
+ t.Fatalf("LIFE-POOL-202 first handled session = %p, want %p", got, first)
+ }
+ if got := waitValue(t, ingress.delivered); got != first {
+ t.Fatalf("LIFE-POOL-202 first delivered session = %p, want %p", got, first)
+ }
+ ingress.results <- acceptResult{session: second}
+ if got := waitValue(t, ingress.delivered); got != second {
+ t.Fatalf("LIFE-POOL-202 delivered session = %p, want %p", got, second)
+ }
+
+ cancel()
+ if err := waitValue(t, done); err != nil {
+ t.Fatalf("LIFE-POOL-202 RunContext: %v", err)
+ }
+ handled := manager.Handled()
+ if len(handled) != 1 || handled[0] != first {
+ t.Fatalf("LIFE-POOL-202 handled sessions = %v, want only first", handled)
+ }
+}
+
+func TestServerFatalAcceptErrorCancelsActiveAndQueuedSessions(t *testing.T) {
+ for _, poolSize := range []int{0, 1} {
+ poolSize := poolSize
+ t.Run(fmt.Sprintf("LIFE-ERROR-203/pool-%d", poolSize), func(t *testing.T) {
+ ingress := newIngressStub()
+ manager := &contextBlockingManager{started: make(chan *model.AcceptedSession, 2)}
+ server := NewServer(poolSize, ingress, manager)
+ done := make(chan error, 1)
+ go func() { done <- server.RunContext(context.Background()) }()
+
+ first := &model.AcceptedSession{Target: model.Target{Address: "203.0.113.20:80"}}
+ ingress.results <- acceptResult{session: first}
+ if got := waitValue(t, manager.started); got != first {
+ t.Fatalf("first handled session = %p, want %p", got, first)
+ }
+ if got := waitValue(t, ingress.delivered); got != first {
+ t.Fatalf("first delivered session = %p, want %p", got, first)
+ }
+
+ var second *model.AcceptedSession
+ if poolSize > 0 {
+ second = &model.AcceptedSession{Target: model.Target{Address: "203.0.113.21:80"}}
+ ingress.results <- acceptResult{session: second}
+ if got := waitValue(t, ingress.delivered); got != second {
+ t.Fatalf("queued delivered session = %p, want %p", got, second)
+ }
+ }
+
+ ingress.results <- acceptResult{err: errors.New("listener failed")}
+ err := waitValue(t, done)
+ if err == nil || !strings.Contains(err.Error(), "listener failed") {
+ t.Fatalf("RunContext error = %v, want listener failure", err)
+ }
+ handled := manager.Handled()
+ if len(handled) != 1 || handled[0] != first {
+ t.Fatalf("handled sessions = %v, want only first; queued=%p", handled, second)
+ }
+ })
+ }
+}
+
+func TestServerClosesIngressBeforeCancellingHandlers(t *testing.T) {
+ ingress := newIngressStub()
+ manager := &orderedContextManager{
+ ingress: ingress,
+ started: make(chan struct{}),
+ observed: make(chan bool, 1),
+ }
+ server := NewServer(0, ingress, manager)
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- server.RunContext(ctx) }()
+
+ ingress.results <- acceptResult{session: &model.AcceptedSession{Target: model.Target{Address: "203.0.113.30:80"}}}
+ waitClosed(t, manager.started)
+ cancel()
+ if observed := waitValue(t, manager.observed); !observed {
+ t.Fatal("LIFE-ORDER-204 handler observed cancellation before ingress was closed")
+ }
+ if err := waitValue(t, done); err != nil {
+ t.Fatalf("LIFE-ORDER-204 RunContext: %v", err)
+ }
+}
+
+func waitClosed(t *testing.T, channel <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-channel:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for channel to close")
+ }
+}
+
+func waitValue[T any](t *testing.T, channel <-chan T) T {
+ t.Helper()
+ select {
+ case value := <-channel:
+ return value
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for channel value")
+ var zero T
+ return zero
+ }
+}
diff --git a/UA-Mask/core/internal/proxy/tproxy_linux.go b/UA-Mask/core/internal/proxy/tproxy_linux.go
new file mode 100644
index 000000000..775578e3d
--- /dev/null
+++ b/UA-Mask/core/internal/proxy/tproxy_linux.go
@@ -0,0 +1,54 @@
+//go:build linux
+// +build linux
+
+package proxy
+
+import (
+ "fmt"
+ "net"
+ "unsafe"
+
+ "github.com/sirupsen/logrus"
+ "golang.org/x/sys/unix"
+)
+
+// getOriginalDst 获取被 REDIRECT 规则重定向前的原始目标地址
+// 使用 SO_ORIGINAL_DST socket 选项,这是 iptables REDIRECT 目标填充的
+func getOriginalDst(conn *net.TCPConn) (*net.TCPAddr, error) {
+ file, err := conn.File()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get file descriptor: %w", err)
+ }
+ defer file.Close()
+
+ fd := int(file.Fd())
+
+ // SO_ORIGINAL_DST = 80
+ const SO_ORIGINAL_DST = 80
+
+ // 使用 sockaddr 结构获取原始目标地址
+ var addr unix.RawSockaddrInet4
+ addrLen := uint32(unsafe.Sizeof(addr))
+
+ _, _, errno := unix.Syscall6(
+ unix.SYS_GETSOCKOPT,
+ uintptr(fd),
+ uintptr(unix.SOL_IP),
+ uintptr(SO_ORIGINAL_DST),
+ uintptr(unsafe.Pointer(&addr)),
+ uintptr(unsafe.Pointer(&addrLen)),
+ 0,
+ )
+
+ if errno != 0 {
+ return nil, fmt.Errorf("getsockopt SO_ORIGINAL_DST failed: %v", errno)
+ }
+
+ ip := net.IPv4(addr.Addr[0], addr.Addr[1], addr.Addr[2], addr.Addr[3])
+ port := int(addr.Port>>8 | addr.Port<<8)
+ logrus.Debugf("[Tproxy] getOriginalDst raw value: %d, parsed port: %d", addr.Port, port)
+ return &net.TCPAddr{
+ IP: ip,
+ Port: port,
+ }, nil
+}
diff --git a/UA-Mask/core/internal/rewrite/cache.go b/UA-Mask/core/internal/rewrite/cache.go
new file mode 100644
index 000000000..3079272cd
--- /dev/null
+++ b/UA-Mask/core/internal/rewrite/cache.go
@@ -0,0 +1,34 @@
+package rewrite
+
+import lru "github.com/hashicorp/golang-lru/v2"
+
+type UACache struct {
+ cache *lru.Cache[string, string]
+}
+
+func NewUACache(size int) (*UACache, error) {
+ if size == 0 {
+ return &UACache{}, nil
+ }
+
+ cache, err := lru.New[string, string](size)
+ if err != nil {
+ return nil, err
+ }
+
+ return &UACache{cache: cache}, nil
+}
+
+func (c *UACache) Get(originUA string) (string, bool) {
+ if c == nil || c.cache == nil {
+ return "", false
+ }
+ return c.cache.Get(originUA)
+}
+
+func (c *UACache) Add(originUA, finalUA string) {
+ if c == nil || c.cache == nil {
+ return
+ }
+ c.cache.Add(originUA, finalUA)
+}
diff --git a/UA-Mask/core/internal/rewrite/decision.go b/UA-Mask/core/internal/rewrite/decision.go
new file mode 100644
index 000000000..beb10883f
--- /dev/null
+++ b/UA-Mask/core/internal/rewrite/decision.go
@@ -0,0 +1,24 @@
+package rewrite
+
+type Reason string
+
+const (
+ ReasonNoUserAgent Reason = "No User-Agent Header"
+ ReasonFirewallWhitelist Reason = "Hit Firewall UA Whitelist"
+ ReasonWhitelist Reason = "Hit User-Agent Whitelist"
+ ReasonForceReplace Reason = "Force Replace Mode"
+ ReasonRegexMatch Reason = "Hit User-Agent Pattern"
+ ReasonRegexMiss Reason = "Not Hit User-Agent Pattern"
+ ReasonKeywordMatch Reason = "Hit User-Agent Keyword"
+ ReasonKeywordMiss Reason = "Not Hit User-Agent Keywords"
+)
+
+type Decision struct {
+ Matched bool
+ Replace bool
+ FinalUA string
+ Reason Reason
+ Cacheable bool
+ FirewallBypassHit bool
+ DropConnection bool
+}
diff --git a/UA-Mask/core/internal/rewrite/engine.go b/UA-Mask/core/internal/rewrite/engine.go
new file mode 100644
index 000000000..03dd1f6f2
--- /dev/null
+++ b/UA-Mask/core/internal/rewrite/engine.go
@@ -0,0 +1,128 @@
+package rewrite
+
+import (
+ "regexp"
+ "strings"
+
+ "UAmask/internal/config"
+)
+
+type Engine struct {
+ rewrite config.RewriteConfig
+ runtime config.RewriteRuntimeConfig
+ firewall config.FirewallConfig
+}
+
+func NewEngine(rewriteConfig config.RewriteConfig, runtimeConfig config.RewriteRuntimeConfig, firewallConfig config.FirewallConfig) *Engine {
+ return &Engine{
+ rewrite: rewriteConfig,
+ runtime: runtimeConfig,
+ firewall: firewallConfig,
+ }
+}
+
+func (e *Engine) Evaluate(originUA string) Decision {
+ if originUA == "" {
+ return Decision{
+ FinalUA: originUA,
+ Reason: ReasonNoUserAgent,
+ Cacheable: false,
+ }
+ }
+
+ if e.containsFirewallWhitelist(originUA) {
+ return Decision{
+ Matched: true,
+ Replace: false,
+ FinalUA: originUA,
+ Reason: ReasonFirewallWhitelist,
+ Cacheable: false,
+ FirewallBypassHit: true,
+ DropConnection: e.firewall.DropOnMatch,
+ }
+ }
+
+ if e.isUserAgentWhitelisted(originUA) {
+ return Decision{
+ Matched: true,
+ Replace: false,
+ FinalUA: originUA,
+ Reason: ReasonWhitelist,
+ Cacheable: true,
+ }
+ }
+
+ if e.rewrite.ForceReplace {
+ return Decision{
+ Matched: true,
+ Replace: true,
+ FinalUA: buildNewUA(originUA, e.rewrite.UserAgent, e.runtime.UARegexp, e.rewrite.EnablePartialReplace),
+ Reason: ReasonForceReplace,
+ Cacheable: true,
+ }
+ }
+
+ if e.rewrite.EnableRegex {
+ if e.runtime.UARegexp != nil && e.runtime.UARegexp.MatchString(originUA) {
+ return Decision{
+ Matched: true,
+ Replace: true,
+ FinalUA: buildNewUA(originUA, e.rewrite.UserAgent, e.runtime.UARegexp, e.rewrite.EnablePartialReplace),
+ Reason: ReasonRegexMatch,
+ Cacheable: true,
+ }
+ }
+ return Decision{
+ Matched: false,
+ Replace: false,
+ FinalUA: originUA,
+ Reason: ReasonRegexMiss,
+ Cacheable: true,
+ }
+ }
+
+ for _, keyword := range e.rewrite.Keywords {
+ if strings.Contains(originUA, keyword) {
+ return Decision{
+ Matched: true,
+ Replace: true,
+ FinalUA: buildNewUA(originUA, e.rewrite.UserAgent, e.runtime.UARegexp, e.rewrite.EnablePartialReplace),
+ Reason: ReasonKeywordMatch,
+ Cacheable: true,
+ }
+ }
+ }
+
+ return Decision{
+ Matched: false,
+ Replace: false,
+ FinalUA: originUA,
+ Reason: ReasonKeywordMiss,
+ Cacheable: true,
+ }
+}
+
+func (e *Engine) containsFirewallWhitelist(originUA string) bool {
+ for _, keyword := range e.firewall.UAWhitelist {
+ if strings.Contains(originUA, keyword) {
+ return true
+ }
+ }
+ return false
+}
+
+func (e *Engine) isUserAgentWhitelisted(originUA string) bool {
+ for _, whitelistUA := range e.rewrite.Whitelist {
+ if whitelistUA == originUA {
+ return true
+ }
+ }
+ return false
+}
+
+func buildNewUA(originUA, replacementUA string, uaRegexp *regexp.Regexp, enablePartialReplace bool) string {
+ if enablePartialReplace && uaRegexp != nil {
+ return uaRegexp.ReplaceAllString(originUA, replacementUA)
+ }
+ return replacementUA
+}
diff --git a/UA-Mask/core/internal/rewrite/engine_test.go b/UA-Mask/core/internal/rewrite/engine_test.go
new file mode 100644
index 000000000..f51e690da
--- /dev/null
+++ b/UA-Mask/core/internal/rewrite/engine_test.go
@@ -0,0 +1,114 @@
+package rewrite
+
+import (
+ "regexp"
+ "testing"
+
+ "UAmask/internal/config"
+)
+
+func TestEngineEvaluate(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ engine *Engine
+ ua string
+ want Decision
+ }{
+ {
+ name: "force replace",
+ engine: NewEngine(
+ config.RewriteConfig{UserAgent: "FFF", ForceReplace: true},
+ config.RewriteRuntimeConfig{},
+ config.FirewallConfig{},
+ ),
+ ua: "Mozilla/5.0",
+ want: Decision{
+ Matched: true,
+ Replace: true,
+ FinalUA: "FFF",
+ Reason: ReasonForceReplace,
+ Cacheable: true,
+ },
+ },
+ {
+ name: "keyword replace",
+ engine: NewEngine(
+ config.RewriteConfig{UserAgent: "FFF", Keywords: []string{"Android"}},
+ config.RewriteRuntimeConfig{},
+ config.FirewallConfig{},
+ ),
+ ua: "Mozilla Android",
+ want: Decision{
+ Matched: true,
+ Replace: true,
+ FinalUA: "FFF",
+ Reason: ReasonKeywordMatch,
+ Cacheable: true,
+ },
+ },
+ {
+ name: "regex partial replace",
+ engine: NewEngine(
+ config.RewriteConfig{UserAgent: "Mask", EnableRegex: true, EnablePartialReplace: true},
+ config.RewriteRuntimeConfig{UARegexp: regexp.MustCompile("Android")},
+ config.FirewallConfig{},
+ ),
+ ua: "Mozilla Android",
+ want: Decision{
+ Matched: true,
+ Replace: true,
+ FinalUA: "Mozilla Mask",
+ Reason: ReasonRegexMatch,
+ Cacheable: true,
+ },
+ },
+ {
+ name: "firewall whitelist has highest priority",
+ engine: NewEngine(
+ config.RewriteConfig{UserAgent: "FFF", ForceReplace: true},
+ config.RewriteRuntimeConfig{},
+ config.FirewallConfig{UAWhitelist: []string{"Steam"}, DropOnMatch: true},
+ ),
+ ua: "Steam Client",
+ want: Decision{
+ Matched: true,
+ Replace: false,
+ FinalUA: "Steam Client",
+ Reason: ReasonFirewallWhitelist,
+ Cacheable: false,
+ FirewallBypassHit: true,
+ DropConnection: true,
+ },
+ },
+ {
+ name: "exact whitelist pass through",
+ engine: NewEngine(
+ config.RewriteConfig{UserAgent: "FFF", Whitelist: []string{"UA-A"}},
+ config.RewriteRuntimeConfig{},
+ config.FirewallConfig{},
+ ),
+ ua: "UA-A",
+ want: Decision{
+ Matched: true,
+ Replace: false,
+ FinalUA: "UA-A",
+ Reason: ReasonWhitelist,
+ Cacheable: true,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := tt.engine.Evaluate(tt.ua)
+ if got != tt.want {
+ t.Fatalf("unexpected decision: got %+v want %+v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/UA-Mask/core/internal/rewrite/request_rewriter.go b/UA-Mask/core/internal/rewrite/request_rewriter.go
new file mode 100644
index 000000000..830646267
--- /dev/null
+++ b/UA-Mask/core/internal/rewrite/request_rewriter.go
@@ -0,0 +1,63 @@
+package rewrite
+
+import "net/http"
+
+type Result struct {
+ Decision Decision
+ FromCache bool
+ Modified bool
+}
+
+type RequestRewriter struct {
+ engine *Engine
+ cache *UACache
+}
+
+func NewRequestRewriter(engine *Engine, cache *UACache) *RequestRewriter {
+ return &RequestRewriter{
+ engine: engine,
+ cache: cache,
+ }
+}
+
+func (r *RequestRewriter) Rewrite(request *http.Request, _ string, _ int) Result {
+ originUA := request.Header.Get("User-Agent")
+ if originUA == "" {
+ return Result{
+ Decision: Decision{
+ FinalUA: originUA,
+ Reason: ReasonNoUserAgent,
+ Cacheable: false,
+ },
+ }
+ }
+
+ if finalUA, ok := r.cache.Get(originUA); ok {
+ request.Header.Set("User-Agent", finalUA)
+ return Result{
+ Decision: Decision{
+ Matched: finalUA != originUA,
+ Replace: finalUA != originUA,
+ FinalUA: finalUA,
+ Cacheable: true,
+ },
+ FromCache: true,
+ Modified: finalUA != originUA,
+ }
+ }
+
+ decision := r.engine.Evaluate(originUA)
+
+ if decision.Replace {
+ request.Header.Set("User-Agent", decision.FinalUA)
+ }
+
+ if decision.Cacheable {
+ r.cache.Add(originUA, decision.FinalUA)
+ }
+
+ return Result{
+ Decision: decision,
+ Modified: decision.Replace,
+ }
+}
diff --git a/UA-Mask/core/internal/session/manager.go b/UA-Mask/core/internal/session/manager.go
new file mode 100644
index 000000000..da2f46bbf
--- /dev/null
+++ b/UA-Mask/core/internal/session/manager.go
@@ -0,0 +1,401 @@
+package session
+
+import (
+ "bufio"
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "UAmask/internal/config"
+ "UAmask/internal/events"
+ "UAmask/internal/model"
+ "UAmask/internal/policy"
+ "UAmask/internal/rewrite"
+ "UAmask/internal/state"
+
+ "github.com/sirupsen/logrus"
+)
+
+var httpMethods = []string{"GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE", "CONNECT", "PATCH"}
+
+type Manager interface {
+ Handle(accepted *model.AcceptedSession)
+}
+
+type ContextManager interface {
+ Manager
+ HandleContext(context.Context, *model.AcceptedSession)
+}
+
+type SignalHandler interface {
+ Handle(signal model.OffloadSignal)
+}
+
+type DefaultManager struct {
+ config config.ListenConfig
+ evaluator policy.SessionEvaluator
+ rewriter *rewrite.RequestRewriter
+ profiles state.ProfileStore
+ offload SignalHandler
+ events *events.Dispatcher
+ readerPool sync.Pool
+ writerPool sync.Pool
+ sessionIDSeq atomic.Uint64
+}
+
+func NewManager(
+ cfg config.ListenConfig,
+ evaluator policy.SessionEvaluator,
+ rewriter *rewrite.RequestRewriter,
+ profiles state.ProfileStore,
+ offload SignalHandler,
+ dispatcher *events.Dispatcher,
+) *DefaultManager {
+ manager := &DefaultManager{
+ config: cfg,
+ evaluator: evaluator,
+ rewriter: rewriter,
+ profiles: profiles,
+ offload: offload,
+ events: dispatcher,
+ }
+
+ manager.readerPool = sync.Pool{
+ New: func() any {
+ return bufio.NewReaderSize(nil, cfg.BufferSize)
+ },
+ }
+ manager.writerPool = sync.Pool{
+ New: func() any {
+ return bufio.NewWriterSize(nil, cfg.BufferSize)
+ },
+ }
+
+ return manager
+}
+
+func (m *DefaultManager) Handle(accepted *model.AcceptedSession) {
+ m.HandleContext(context.Background(), accepted)
+}
+
+func (m *DefaultManager) HandleContext(ctx context.Context, accepted *model.AcceptedSession) {
+ if accepted == nil || accepted.ClientConn == nil {
+ return
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if ctx.Err() != nil {
+ _ = accepted.ClientConn.Close()
+ return
+ }
+
+ sessionState := model.SessionState{
+ ID: m.sessionIDSeq.Add(1),
+ IngressKind: accepted.IngressKind,
+ ClientAddr: accepted.ClientConn.RemoteAddr().String(),
+ Target: accepted.Target,
+ FlowOffloadStatus: model.FlowOffloadNone,
+ OpenedAt: time.Now(),
+ }
+
+ m.emit(model.SessionEvent{
+ Type: model.EventSessionOpened,
+ SessionID: sessionState.ID,
+ Target: accepted.Target,
+ IngressKind: accepted.IngressKind,
+ Timestamp: sessionState.OpenedAt,
+ })
+ defer func() {
+ m.emit(model.SessionEvent{
+ Type: model.EventSessionClosed,
+ SessionID: sessionState.ID,
+ Target: accepted.Target,
+ IngressKind: accepted.IngressKind,
+ Protocol: sessionState.Protocol,
+ Timestamp: time.Now(),
+ })
+ _ = accepted.ClientConn.Close()
+ }()
+
+ dialer := net.Dialer{
+ Timeout: m.config.DialTimeout,
+ KeepAlive: m.config.UpstreamKeepAlive,
+ }
+ upstream, err := dialer.DialContext(ctx, "tcp", accepted.Target.Address)
+ if err != nil {
+ logrus.Debugf("[session] Failed to connect to %s: %v", accepted.Target.Address, err)
+ return
+ }
+ defer upstream.Close()
+
+ upstreamConn, ok := upstream.(*net.TCPConn)
+ if !ok {
+ logrus.Debugf("[session] Unexpected upstream conn type for %s", accepted.Target.Address)
+ return
+ }
+ sessionState.UpstreamAddr = upstreamConn.RemoteAddr().String()
+ connectionDone := make(chan struct{})
+ go func() {
+ select {
+ case <-ctx.Done():
+ _ = accepted.ClientConn.Close()
+ _ = upstreamConn.Close()
+ case <-connectionDone:
+ }
+ }()
+ defer close(connectionDone)
+ m.emit(model.SessionEvent{
+ Type: model.EventUpstreamConnected,
+ SessionID: sessionState.ID,
+ Target: accepted.Target,
+ IngressKind: accepted.IngressKind,
+ Timestamp: time.Now(),
+ })
+
+ done := make(chan struct{}, 2)
+
+ go func() {
+ defer upstreamConn.CloseWrite()
+ m.processClientToUpstream(&sessionState, accepted.ClientConn, upstreamConn)
+ done <- struct{}{}
+ }()
+
+ go func() {
+ defer accepted.ClientConn.CloseWrite()
+ _, _ = io.Copy(accepted.ClientConn, upstreamConn)
+ done <- struct{}{}
+ }()
+
+ <-done
+ <-done
+}
+
+func (m *DefaultManager) processClientToUpstream(sessionState *model.SessionState, src net.Conn, dst net.Conn) {
+ srcReader := m.readerPool.Get().(*bufio.Reader)
+ srcReader.Reset(src)
+ defer m.readerPool.Put(srcReader)
+
+ dstWriter := m.writerPool.Get().(*bufio.Writer)
+ dstWriter.Reset(dst)
+ defer func() {
+ if err := dstWriter.Flush(); err != nil {
+ logrus.Debugf("[session] [%s] final flush error: %v", sessionState.Target.Address, err)
+ }
+ m.writerPool.Put(dstWriter)
+ }()
+
+ for {
+ isHTTP, err := isHTTP(srcReader)
+ if err != nil {
+ m.handleDetectionError(sessionState, dst, srcReader, dstWriter, err)
+ return
+ }
+
+ if !isHTTP {
+ sessionState.Protocol = model.ProtocolNonHTTP
+ m.emit(model.SessionEvent{
+ Type: model.EventProtocolClassified,
+ SessionID: sessionState.ID,
+ Target: sessionState.Target,
+ IngressKind: sessionState.IngressKind,
+ Protocol: model.ProtocolNonHTTP,
+ Timestamp: time.Now(),
+ })
+
+ snapshot := m.profiles.Snapshot(sessionState.Target)
+ decision := m.evaluator.Evaluate(sessionState, model.SessionInput{
+ Kind: model.SessionInputClassification,
+ Protocol: model.ProtocolNonHTTP,
+ }, snapshot)
+
+ m.emit(model.SessionEvent{
+ Type: model.EventNonHTTPObserved,
+ SessionID: sessionState.ID,
+ Target: sessionState.Target,
+ IngressKind: sessionState.IngressKind,
+ Protocol: model.ProtocolNonHTTP,
+ Timestamp: time.Now(),
+ })
+
+ if decision.FlowSignal.Kind != model.SignalNoAction && m.offload != nil {
+ m.offload.Handle(decision.FlowSignal)
+ }
+
+ m.emit(model.SessionEvent{
+ Type: model.EventSessionForwardFallback,
+ SessionID: sessionState.ID,
+ Target: sessionState.Target,
+ IngressKind: sessionState.IngressKind,
+ Protocol: model.ProtocolNonHTTP,
+ Timestamp: time.Now(),
+ })
+ m.flushAndCopy(dst, srcReader, dstWriter, sessionState.Target)
+ return
+ }
+
+ if !m.processRequest(sessionState, srcReader, dstWriter) {
+ return
+ }
+ }
+}
+
+func (m *DefaultManager) processRequest(sessionState *model.SessionState, srcReader *bufio.Reader, dstWriter *bufio.Writer) bool {
+ request, err := http.ReadRequest(srcReader)
+ if err != nil {
+ if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
+ logrus.Debugf("[session] [%s] connection closed", sessionState.Target.Address)
+ } else if strings.Contains(err.Error(), "connection reset by peer") {
+ logrus.Debugf("[session] [%s] connection reset", sessionState.Target.Address)
+ } else {
+ logrus.Debugf("[session] [%s] read request error: %v", sessionState.Target.Address, err)
+ }
+ return false
+ }
+ defer request.Body.Close()
+
+ sessionState.Protocol = model.ProtocolHTTP
+ m.emit(model.SessionEvent{
+ Type: model.EventProtocolClassified,
+ SessionID: sessionState.ID,
+ Target: sessionState.Target,
+ IngressKind: sessionState.IngressKind,
+ Protocol: model.ProtocolHTTP,
+ Timestamp: time.Now(),
+ })
+
+ rewriteResult := m.rewriteRequest(request, sessionState.Target)
+ snapshot := m.profiles.Snapshot(sessionState.Target)
+ decision := m.evaluator.Evaluate(sessionState, model.SessionInput{
+ Kind: model.SessionInputRequest,
+ Protocol: model.ProtocolHTTP,
+ Request: request,
+ Modified: rewriteResult.Modified,
+ FromCache: rewriteResult.FromCache,
+ Reason: string(rewriteResult.Decision.Reason),
+ FirewallHit: rewriteResult.Decision.FirewallBypassHit,
+ DropConnection: rewriteResult.Decision.DropConnection,
+ }, snapshot)
+ m.updateRewriteCounters(sessionState, decision.Modified)
+
+ m.emit(model.SessionEvent{
+ Type: model.EventHTTPRequestObserved,
+ SessionID: sessionState.ID,
+ Target: sessionState.Target,
+ IngressKind: sessionState.IngressKind,
+ Protocol: model.ProtocolHTTP,
+ Timestamp: time.Now(),
+ })
+
+ eventType := model.EventRewriteSkipped
+ if decision.Modified {
+ eventType = model.EventRewriteApplied
+ }
+ m.emit(model.SessionEvent{
+ Type: eventType,
+ SessionID: sessionState.ID,
+ Target: sessionState.Target,
+ IngressKind: sessionState.IngressKind,
+ Protocol: model.ProtocolHTTP,
+ Reason: decision.Reason,
+ Modified: decision.Modified,
+ FromCache: decision.FromCache,
+ FirewallHit: decision.FirewallHit,
+ Timestamp: time.Now(),
+ })
+
+ if decision.FlowSignal.Kind != model.SignalNoAction && m.offload != nil {
+ m.offload.Handle(decision.FlowSignal)
+ }
+
+ if decision.Action == model.ActionDrop {
+ logrus.Debugf("[session] [%s] dropping connection after policy decision", sessionState.Target.Address)
+ return false
+ }
+
+ if err := request.Write(dstWriter); err != nil {
+ logrus.Debugf("[session] [%s] write request error: %v", sessionState.Target.Address, err)
+ return false
+ }
+ if err := dstWriter.Flush(); err != nil {
+ logrus.Debugf("[session] [%s] flush after request failed: %v", sessionState.Target.Address, err)
+ return false
+ }
+
+ return true
+}
+
+func (m *DefaultManager) handleDetectionError(sessionState *model.SessionState, dst net.Conn, srcReader *bufio.Reader, dstWriter *bufio.Writer, err error) {
+ if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
+ logrus.Debugf("[session] [%s] connection closed", sessionState.Target.Address)
+ } else {
+ logrus.Debugf("[session] [%s] HTTP detection error: %v", sessionState.Target.Address, err)
+ }
+
+ m.emit(model.SessionEvent{
+ Type: model.EventSessionForwardFallback,
+ SessionID: sessionState.ID,
+ Target: sessionState.Target,
+ IngressKind: sessionState.IngressKind,
+ Protocol: sessionState.Protocol,
+ Timestamp: time.Now(),
+ })
+ m.flushAndCopy(dst, srcReader, dstWriter, sessionState.Target)
+}
+
+func (m *DefaultManager) emit(event model.SessionEvent) {
+ if m.events == nil {
+ return
+ }
+ m.events.Append(event)
+}
+
+func (m *DefaultManager) rewriteRequest(request *http.Request, target model.Target) rewrite.Result {
+ if m.rewriter == nil {
+ return rewrite.Result{}
+ }
+ return m.rewriter.Rewrite(request, target.IP, target.Port)
+}
+
+func (m *DefaultManager) updateRewriteCounters(sessionState *model.SessionState, modified bool) {
+ if modified {
+ sessionState.ConsecutiveRewrite++
+ sessionState.ConsecutiveNoRewrite = 0
+ return
+ }
+
+ sessionState.ConsecutiveNoRewrite++
+ sessionState.ConsecutiveRewrite = 0
+}
+
+func isHTTP(reader *bufio.Reader) (bool, error) {
+ buf, err := reader.Peek(7)
+ if err != nil {
+ if !strings.Contains(err.Error(), "EOF") {
+ logrus.Debugf("[session] Peek error: %s", err.Error())
+ }
+ return false, err
+ }
+
+ hint := string(buf)
+ for _, method := range httpMethods {
+ if strings.HasPrefix(hint, method) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func (m *DefaultManager) flushAndCopy(dst net.Conn, srcReader *bufio.Reader, dstWriter *bufio.Writer, target model.Target) {
+ if err := dstWriter.Flush(); err != nil {
+ logrus.Debugf("[session] [%s] flush before fallback failed: %v", target.Address, err)
+ }
+ if _, err := io.Copy(dst, srcReader); err != nil && err != io.EOF {
+ logrus.Debugf("[session] [%s] fallback copy error: %v", target.Address, err)
+ }
+}
diff --git a/UA-Mask/core/internal/session/manager_test.go b/UA-Mask/core/internal/session/manager_test.go
new file mode 100644
index 000000000..7cf282d00
--- /dev/null
+++ b/UA-Mask/core/internal/session/manager_test.go
@@ -0,0 +1,299 @@
+package session
+
+import (
+ "bytes"
+ "context"
+ "net"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "UAmask/internal/config"
+ "UAmask/internal/events"
+ "UAmask/internal/model"
+ "UAmask/internal/policy"
+ "UAmask/internal/rewrite"
+ "UAmask/internal/state"
+ "UAmask/internal/stats"
+)
+
+type bufferConn struct {
+ reader *bytes.Buffer
+ writer *bytes.Buffer
+}
+
+func newReadConn(data string) *bufferConn {
+ return &bufferConn{reader: bytes.NewBufferString(data), writer: &bytes.Buffer{}}
+}
+
+func newWriteConn() *bufferConn {
+ return &bufferConn{reader: &bytes.Buffer{}, writer: &bytes.Buffer{}}
+}
+
+func (conn *bufferConn) Read(value []byte) (int, error) { return conn.reader.Read(value) }
+func (conn *bufferConn) Write(value []byte) (int, error) { return conn.writer.Write(value) }
+func (conn *bufferConn) Close() error { return nil }
+func (conn *bufferConn) LocalAddr() net.Addr { return dummyAddr("local") }
+func (conn *bufferConn) RemoteAddr() net.Addr { return dummyAddr("remote") }
+func (conn *bufferConn) SetDeadline(time.Time) error { return nil }
+func (conn *bufferConn) SetReadDeadline(time.Time) error { return nil }
+func (conn *bufferConn) SetWriteDeadline(time.Time) error { return nil }
+
+type dummyAddr string
+
+func (address dummyAddr) Network() string { return "buffer" }
+func (address dummyAddr) String() string { return string(address) }
+
+type eventCollector struct {
+ events []model.SessionEvent
+}
+
+func (collector *eventCollector) Append(event model.SessionEvent) {
+ collector.events = append(collector.events, event)
+}
+
+func (collector *eventCollector) Count(eventType model.SessionEventType) int {
+ count := 0
+ for _, event := range collector.events {
+ if event.Type == eventType {
+ count++
+ }
+ }
+ return count
+}
+
+func TestManagerProcessesActiveTrafficPath(t *testing.T) {
+ t.Run("PATH-HTTP-201 rewrites requests and projects facts", func(t *testing.T) {
+ manager, profiles, metrics, collector := newTrafficHarness(t, config.RewriteConfig{
+ UserAgent: "MaskUA",
+ ForceReplace: true,
+ }, config.FirewallConfig{})
+ sessionState := testSessionState(80)
+ src := newReadConn("GET / HTTP/1.1\r\nHost: example.com\r\nUser-Agent: A\r\n\r\n" +
+ "GET /x HTTP/1.1\r\nHost: example.com\r\nUser-Agent: B\r\n\r\n")
+ dst := newWriteConn()
+
+ manager.processClientToUpstream(&sessionState, src, dst)
+
+ if got := strings.Count(dst.writer.String(), "User-Agent: MaskUA"); got != 2 {
+ t.Fatalf("rewritten User-Agent count = %d, want 2; output: %s", got, dst.writer.String())
+ }
+ if snapshot := metrics.Snapshot(); snapshot.HTTPRequests != 2 || snapshot.ModifiedRequests != 2 {
+ t.Fatalf("stats snapshot = %+v, want 2 requests and modifications", snapshot)
+ }
+ if got := collector.Count(model.EventRewriteApplied); got != 2 {
+ t.Fatalf("rewrite facts = %d, want 2", got)
+ }
+ if snapshot := profiles.Snapshot(sessionState.Target); snapshot.LastEventType != model.EventRewriteApplied {
+ t.Fatalf("profile snapshot = %+v, want rewrite applied", snapshot)
+ }
+ })
+
+ t.Run("PATH-NONHTTP-201 falls back and updates target profile", func(t *testing.T) {
+ manager, profiles, metrics, collector := newTrafficHarness(t, config.RewriteConfig{
+ UserAgent: "MaskUA",
+ ForceReplace: true,
+ }, config.FirewallConfig{EnableBypass: true, NonHTTPThreshold: 2})
+ sessionState := testSessionState(22)
+ src := newReadConn("SSH-2.0-dropbear\r\n")
+ dst := newWriteConn()
+
+ manager.processClientToUpstream(&sessionState, src, dst)
+
+ if got := dst.writer.String(); got != "SSH-2.0-dropbear\r\n" {
+ t.Fatalf("fallback payload = %q", got)
+ }
+ if got := profiles.Snapshot(sessionState.Target).NonHTTPScore; got != 1 {
+ t.Fatalf("non-HTTP score = %d, want 1", got)
+ }
+ if got := collector.Count(model.EventNonHTTPObserved); got != 1 {
+ t.Fatalf("non-HTTP facts = %d, want 1", got)
+ }
+ if snapshot := metrics.Snapshot(); snapshot.HTTPRequests != 0 {
+ t.Fatalf("HTTP requests = %d, want 0", snapshot.HTTPRequests)
+ }
+ })
+
+ t.Run("PATH-DROP-201 firewall whitelist stops forwarding", func(t *testing.T) {
+ manager, profiles, _, collector := newTrafficHarness(t, config.RewriteConfig{
+ UserAgent: "MaskUA",
+ }, config.FirewallConfig{
+ UAWhitelist: []string{"BypassMe"},
+ DropOnMatch: true,
+ })
+ sessionState := testSessionState(443)
+ src := newReadConn("GET / HTTP/1.1\r\nHost: example.com\r\nUser-Agent: BypassMe/1.0\r\n\r\n")
+ dst := newWriteConn()
+
+ manager.processClientToUpstream(&sessionState, src, dst)
+
+ if got := dst.writer.String(); got != "" {
+ t.Fatalf("dropped request was forwarded: %q", got)
+ }
+ if got := collector.Count(model.EventRewriteSkipped); got != 1 {
+ t.Fatalf("rewrite skipped facts = %d, want 1", got)
+ }
+ snapshot := profiles.Snapshot(sessionState.Target)
+ if !snapshot.LastFirewallHit || snapshot.FirewallWhitelistHits != 1 {
+ t.Fatalf("firewall profile = %+v, want recorded whitelist hit", snapshot)
+ }
+ })
+
+ t.Run("PATH-FALLBACK-201 short protocol hint is preserved", func(t *testing.T) {
+ manager, profiles, _, collector := newTrafficHarness(t, config.RewriteConfig{}, config.FirewallConfig{})
+ sessionState := testSessionState(9000)
+ src := newReadConn("abc")
+ dst := newWriteConn()
+
+ manager.processClientToUpstream(&sessionState, src, dst)
+
+ if got := dst.writer.String(); got != "abc" {
+ t.Fatalf("fallback payload = %q, want abc", got)
+ }
+ if got := collector.Count(model.EventSessionForwardFallback); got != 1 {
+ t.Fatalf("fallback facts = %d, want 1", got)
+ }
+ if snapshot := profiles.Snapshot(sessionState.Target); snapshot.Found {
+ t.Fatalf("fallback-only event unexpectedly created profile: %+v", snapshot)
+ }
+ })
+}
+
+func TestManagerHandleContextClosesSilentUpstream(t *testing.T) {
+ clientPeer, acceptedConn := newTCPPair(t)
+ defer clientPeer.Close()
+ defer acceptedConn.Close()
+
+ upstreamListener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")})
+ if err != nil {
+ t.Fatalf("LIFE-SESSION-202 listen upstream: %v", err)
+ }
+ defer upstreamListener.Close()
+ upstreamAccepted := make(chan *net.TCPConn, 1)
+ upstreamAcceptErr := make(chan error, 1)
+ go func() {
+ conn, acceptErr := upstreamListener.AcceptTCP()
+ if acceptErr != nil {
+ upstreamAcceptErr <- acceptErr
+ return
+ }
+ upstreamAccepted <- conn
+ }()
+
+ manager, _, _, collector := newTrafficHarness(t, config.RewriteConfig{}, config.FirewallConfig{})
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ manager.HandleContext(ctx, &model.AcceptedSession{
+ ClientConn: acceptedConn,
+ Target: model.Target{
+ Address: upstreamListener.Addr().String(),
+ IP: "127.0.0.1",
+ Port: upstreamListener.Addr().(*net.TCPAddr).Port,
+ },
+ IngressKind: model.IngressKindRedirect,
+ })
+ close(done)
+ }()
+
+ var upstreamPeer *net.TCPConn
+ select {
+ case upstreamPeer = <-upstreamAccepted:
+ case acceptErr := <-upstreamAcceptErr:
+ t.Fatalf("LIFE-SESSION-202 accept upstream: %v", acceptErr)
+ case <-time.After(time.Second):
+ t.Fatal("LIFE-SESSION-202 upstream connection was not established")
+ }
+ defer upstreamPeer.Close()
+
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("LIFE-SESSION-202 manager did not stop after context cancellation")
+ }
+ if got := collector.Count(model.EventSessionClosed); got != 1 {
+ t.Fatalf("LIFE-SESSION-202 closed facts = %d, want 1", got)
+ }
+}
+
+func newTrafficHarness(
+ t *testing.T,
+ rewriteConfig config.RewriteConfig,
+ firewallConfig config.FirewallConfig,
+) (*DefaultManager, *state.Store, *stats.Stats, *eventCollector) {
+ t.Helper()
+ cache, err := rewrite.NewUACache(16)
+ if err != nil {
+ t.Fatalf("new UA cache: %v", err)
+ }
+ profiles := state.NewStore(state.Config{
+ NonHTTPEnabled: firewallConfig.EnableBypass,
+ NonHTTPThreshold: firewallConfig.NonHTTPThreshold,
+ HTTPCooldownPeriod: time.Minute,
+ DecisionDelay: time.Second,
+ ProfileCleanupInterval: time.Hour,
+ })
+ metrics := stats.New()
+ collector := &eventCollector{}
+ dispatcher := events.NewDispatcher(profiles, metrics, collector)
+ manager := NewManager(
+ config.ListenConfig{BufferSize: 4096},
+ policy.NewSessionPolicy(),
+ rewrite.NewRequestRewriter(rewrite.NewEngine(rewriteConfig, config.RewriteRuntimeConfig{}, firewallConfig), cache),
+ profiles,
+ nil,
+ dispatcher,
+ )
+ return manager, profiles, metrics, collector
+}
+
+func testSessionState(port int) model.SessionState {
+ return model.SessionState{
+ ID: 1,
+ IngressKind: model.IngressKindRedirect,
+ Target: model.Target{
+ Address: "203.0.113.30:" + strconv.Itoa(port),
+ IP: "203.0.113.30",
+ Port: port,
+ },
+ }
+}
+
+func newTCPPair(t *testing.T) (*net.TCPConn, *net.TCPConn) {
+ t.Helper()
+ listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")})
+ if err != nil {
+ t.Fatalf("listen TCP pair: %v", err)
+ }
+ accepted := make(chan *net.TCPConn, 1)
+ acceptErr := make(chan error, 1)
+ go func() {
+ conn, err := listener.AcceptTCP()
+ if err != nil {
+ acceptErr <- err
+ return
+ }
+ accepted <- conn
+ }()
+ peer, err := net.DialTCP("tcp", nil, listener.Addr().(*net.TCPAddr))
+ if err != nil {
+ _ = listener.Close()
+ t.Fatalf("dial TCP pair: %v", err)
+ }
+ var server *net.TCPConn
+ select {
+ case server = <-accepted:
+ case err := <-acceptErr:
+ _ = peer.Close()
+ _ = listener.Close()
+ t.Fatalf("accept TCP pair: %v", err)
+ case <-time.After(time.Second):
+ _ = peer.Close()
+ _ = listener.Close()
+ t.Fatal("timed out accepting TCP pair")
+ }
+ _ = listener.Close()
+ return peer, server
+}
diff --git a/UA-Mask/core/internal/state/store.go b/UA-Mask/core/internal/state/store.go
new file mode 100644
index 000000000..5dcd1653e
--- /dev/null
+++ b/UA-Mask/core/internal/state/store.go
@@ -0,0 +1,234 @@
+package state
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "UAmask/internal/clock"
+ "UAmask/internal/model"
+)
+
+type EventSink interface {
+ Append(event model.SessionEvent)
+}
+
+type ProfileStore interface {
+ Snapshot(target model.Target) model.ProfileSnapshot
+}
+
+type Config struct {
+ NonHTTPEnabled bool
+ NonHTTPThreshold int
+ HTTPCooldownPeriod time.Duration
+ DecisionDelay time.Duration
+ ProfileCleanupInterval time.Duration
+}
+
+type Store struct {
+ config Config
+ clock clock.Clock
+
+ mu sync.RWMutex
+ profiles map[string]*model.TargetProfile
+
+ stopChan chan struct{}
+ stopOnce sync.Once
+ waitGroup sync.WaitGroup
+}
+
+func NewStore(cfg Config) *Store {
+ return NewStoreWithClock(cfg, clock.RealClock{})
+}
+
+func NewStoreWithClock(cfg Config, runtimeClock clock.Clock) *Store {
+ if cfg.ProfileCleanupInterval <= 0 {
+ cfg.ProfileCleanupInterval = 10 * time.Minute
+ }
+
+ return &Store{
+ config: cfg,
+ clock: clock.OrReal(runtimeClock),
+ profiles: make(map[string]*model.TargetProfile),
+ stopChan: make(chan struct{}),
+ }
+}
+
+func (s *Store) Start() {
+ s.waitGroup.Add(1)
+ go s.cleanupLoop()
+}
+
+func (s *Store) Stop() {
+ s.stopOnce.Do(func() {
+ close(s.stopChan)
+ })
+ s.waitGroup.Wait()
+}
+
+func (s *Store) Append(event model.SessionEvent) {
+ if event.Target.IP == "" || event.Target.Port == 0 {
+ return
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ now := event.Timestamp
+ if now.IsZero() {
+ now = s.clock.Now()
+ }
+
+ switch event.Type {
+ case model.EventHTTPRequestObserved:
+ profile := s.ensureProfileLocked(event.Target)
+ profile.LastEventType = event.Type
+ profile.LastReason = event.Reason
+ s.applyHTTPObservation(profile, now)
+ case model.EventNonHTTPObserved:
+ profile := s.ensureProfileLocked(event.Target)
+ profile.LastEventType = event.Type
+ profile.LastReason = event.Reason
+ s.applyNonHTTPObservation(profile, now)
+ case model.EventRewriteSkipped:
+ profile := s.ensureProfileLocked(event.Target)
+ profile.LastEventType = event.Type
+ profile.LastReason = event.Reason
+ profile.LastFirewallHit = event.FirewallHit
+ profile.LastActivity = now
+ if event.FirewallHit {
+ profile.FirewallWhitelistHits++
+ }
+ case model.EventRewriteApplied:
+ profile := s.ensureProfileLocked(event.Target)
+ profile.LastEventType = event.Type
+ profile.LastReason = event.Reason
+ profile.LastFirewallHit = event.FirewallHit
+ profile.LastActivity = now
+ case model.EventTargetOffloadSucceeded:
+ profile := s.ensureProfileLocked(event.Target)
+ profile.LastEventType = event.Type
+ profile.LastReason = event.Reason
+ profile.LastActivity = now
+ profile.NonHTTPScore = 0
+ profile.PendingTargetOffloadAt = time.Time{}
+ profile.TargetOffloadUntil = now.Add(time.Duration(event.Timeout) * time.Second)
+ profile.FirewallWhitelistHits = 0
+ case model.EventTargetOffloadFailed:
+ profile := s.ensureProfileLocked(event.Target)
+ profile.LastEventType = event.Type
+ profile.LastReason = event.Reason
+ profile.LastActivity = now
+ profile.PendingTargetOffloadAt = time.Time{}
+ case model.EventSessionOpened, model.EventUpstreamConnected, model.EventProtocolClassified,
+ model.EventSessionForwardFallback, model.EventSessionClosed,
+ model.EventFlowOffloadSucceeded, model.EventFlowOffloadFailed:
+ return
+ }
+}
+
+func (s *Store) Snapshot(target model.Target) model.ProfileSnapshot {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ profile, ok := s.profiles[profileKey(target)]
+ if !ok {
+ return model.ProfileSnapshot{Target: target}
+ }
+
+ return model.ProfileSnapshot{
+ Found: true,
+ Target: profile.Target,
+ NonHTTPScore: profile.NonHTTPScore,
+ HTTPCooldownUntil: profile.HTTPCooldownUntil,
+ LastActivity: profile.LastActivity,
+ PendingTargetOffloadAt: profile.PendingTargetOffloadAt,
+ TargetOffloadUntil: profile.TargetOffloadUntil,
+ FirewallWhitelistHits: profile.FirewallWhitelistHits,
+ LastFirewallHit: profile.LastFirewallHit,
+ LastEventType: profile.LastEventType,
+ LastReason: profile.LastReason,
+ }
+}
+
+func (s *Store) cleanupLoop() {
+ defer s.waitGroup.Done()
+
+ ticker := s.clock.NewTicker(s.config.ProfileCleanupInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C():
+ s.cleanup()
+ case <-s.stopChan:
+ return
+ }
+ }
+}
+
+func (s *Store) cleanup() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ now := s.clock.Now()
+ for key, profile := range s.profiles {
+ if !profile.TargetOffloadUntil.IsZero() && now.Before(profile.TargetOffloadUntil) {
+ continue
+ }
+ if !profile.PendingTargetOffloadAt.IsZero() && now.Before(profile.PendingTargetOffloadAt) {
+ continue
+ }
+ if !profile.HTTPCooldownUntil.IsZero() && now.Before(profile.HTTPCooldownUntil) {
+ continue
+ }
+ if !profile.LastActivity.IsZero() && now.Sub(profile.LastActivity) <= s.config.ProfileCleanupInterval {
+ continue
+ }
+ delete(s.profiles, key)
+ }
+}
+
+func (s *Store) applyHTTPObservation(profile *model.TargetProfile, now time.Time) {
+ if now.Before(profile.HTTPCooldownUntil) {
+ return
+ }
+
+ profile.NonHTTPScore = 0
+ profile.HTTPCooldownUntil = now.Add(s.config.HTTPCooldownPeriod)
+ profile.PendingTargetOffloadAt = time.Time{}
+ profile.LastActivity = now
+}
+
+func (s *Store) applyNonHTTPObservation(profile *model.TargetProfile, now time.Time) {
+ if !s.config.NonHTTPEnabled {
+ return
+ }
+ if now.Before(profile.HTTPCooldownUntil) {
+ return
+ }
+ if !profile.TargetOffloadUntil.IsZero() && now.Before(profile.TargetOffloadUntil) {
+ return
+ }
+
+ profile.NonHTTPScore++
+ profile.LastActivity = now
+ if profile.NonHTTPScore >= s.config.NonHTTPThreshold && profile.PendingTargetOffloadAt.IsZero() {
+ profile.PendingTargetOffloadAt = now.Add(s.config.DecisionDelay)
+ }
+}
+
+func (s *Store) ensureProfileLocked(target model.Target) *model.TargetProfile {
+ key := profileKey(target)
+ if profile, ok := s.profiles[key]; ok {
+ return profile
+ }
+
+ profile := &model.TargetProfile{Target: target}
+ s.profiles[key] = profile
+ return profile
+}
+
+func profileKey(target model.Target) string {
+ return fmt.Sprintf("%s:%d", target.IP, target.Port)
+}
diff --git a/UA-Mask/core/internal/state/store_test.go b/UA-Mask/core/internal/state/store_test.go
new file mode 100644
index 000000000..dbac3637d
--- /dev/null
+++ b/UA-Mask/core/internal/state/store_test.go
@@ -0,0 +1,90 @@
+package state
+
+import (
+ "testing"
+ "time"
+
+ "UAmask/internal/model"
+ "UAmask/internal/testkit/manualclock"
+)
+
+func TestStoreAggregatesFactsIntoProfile(t *testing.T) {
+ t.Parallel()
+
+ runtimeClock := manualclock.New(time.Unix(100, 0))
+ store := NewStoreWithClock(Config{
+ NonHTTPEnabled: true,
+ NonHTTPThreshold: 2,
+ HTTPCooldownPeriod: 30 * time.Second,
+ DecisionDelay: 5 * time.Second,
+ ProfileCleanupInterval: time.Minute,
+ }, runtimeClock)
+
+ target := model.Target{IP: "1.1.1.1", Port: 80}
+ now := runtimeClock.Now()
+
+ store.Append(model.SessionEvent{Type: model.EventNonHTTPObserved, Target: target, Timestamp: now})
+ first := store.Snapshot(target)
+ if first.NonHTTPScore != 1 {
+ t.Fatalf("expected non-http score 1, got %d", first.NonHTTPScore)
+ }
+ if !first.PendingTargetOffloadAt.IsZero() {
+ t.Fatalf("expected no pending offload after first observation")
+ }
+
+ store.Append(model.SessionEvent{Type: model.EventNonHTTPObserved, Target: target, Timestamp: now.Add(time.Second)})
+ second := store.Snapshot(target)
+ if second.NonHTTPScore != 2 {
+ t.Fatalf("expected non-http score 2, got %d", second.NonHTTPScore)
+ }
+ if second.PendingTargetOffloadAt.IsZero() {
+ t.Fatalf("expected pending offload after threshold is reached")
+ }
+
+ store.Append(model.SessionEvent{
+ Type: model.EventRewriteSkipped,
+ Target: target,
+ Reason: "Hit Firewall UA Whitelist",
+ FirewallHit: true,
+ Timestamp: now.Add(2 * time.Second),
+ })
+ third := store.Snapshot(target)
+ if third.FirewallWhitelistHits != 1 {
+ t.Fatalf("expected one firewall whitelist hit, got %d", third.FirewallWhitelistHits)
+ }
+
+ store.Append(model.SessionEvent{Type: model.EventHTTPRequestObserved, Target: target, Timestamp: now.Add(3 * time.Second)})
+ afterHTTP := store.Snapshot(target)
+ if afterHTTP.NonHTTPScore != 0 {
+ t.Fatalf("expected non-http score reset, got %d", afterHTTP.NonHTTPScore)
+ }
+ if afterHTTP.PendingTargetOffloadAt != (time.Time{}) {
+ t.Fatalf("expected pending offload cleared by HTTP observation")
+ }
+ if afterHTTP.HTTPCooldownUntil.IsZero() {
+ t.Fatalf("expected HTTP cooldown to be set")
+ }
+}
+
+func TestStoreDoesNotScheduleNonHTTPWhenFeatureIsDisabled(t *testing.T) {
+ t.Parallel()
+
+ runtimeClock := manualclock.New(time.Unix(100, 0))
+ store := NewStoreWithClock(Config{
+ NonHTTPEnabled: false,
+ NonHTTPThreshold: 1,
+ DecisionDelay: time.Second,
+ ProfileCleanupInterval: time.Minute,
+ }, runtimeClock)
+ target := model.Target{IP: "1.1.1.1", Port: 80}
+ store.Append(model.SessionEvent{
+ Type: model.EventNonHTTPObserved,
+ Target: target,
+ Timestamp: runtimeClock.Now(),
+ })
+
+ snapshot := store.Snapshot(target)
+ if snapshot.NonHTTPScore != 0 || !snapshot.PendingTargetOffloadAt.IsZero() {
+ t.Fatalf("expected disabled non-http aggregation to stay idle, got %+v", snapshot)
+ }
+}
diff --git a/UA-Mask/core/internal/stats/reporter.go b/UA-Mask/core/internal/stats/reporter.go
new file mode 100644
index 000000000..c75d82bf6
--- /dev/null
+++ b/UA-Mask/core/internal/stats/reporter.go
@@ -0,0 +1,119 @@
+package stats
+
+import (
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+type FileReporter struct {
+ stats *Stats
+ filePath string
+ interval time.Duration
+ stopChan chan struct{}
+ stopOnce sync.Once
+ waitGroup sync.WaitGroup
+}
+
+func NewFileReporter(stats *Stats, filePath string, interval time.Duration) *FileReporter {
+ return &FileReporter{
+ stats: stats,
+ filePath: filePath,
+ interval: interval,
+ stopChan: make(chan struct{}),
+ }
+}
+
+func (r *FileReporter) Start() {
+ if r.filePath == "" || r.interval <= 0 {
+ return
+ }
+
+ r.waitGroup.Add(1)
+ go r.loop()
+}
+
+func (r *FileReporter) Stop() {
+ r.stopOnce.Do(func() {
+ close(r.stopChan)
+ })
+ r.waitGroup.Wait()
+}
+
+func (r *FileReporter) loop() {
+ defer r.waitGroup.Done()
+
+ ticker := time.NewTicker(r.interval)
+ defer ticker.Stop()
+
+ var lastRequests uint64
+ lastCheckTime := time.Now()
+
+ for {
+ select {
+ case <-ticker.C:
+ snapshot := r.stats.Snapshot()
+ content := renderSnapshot(snapshot, &lastRequests, &lastCheckTime)
+ if err := os.WriteFile(r.filePath, []byte(content), 0644); err != nil {
+ logrus.Warnf("Failed to write stats file: %v", err)
+ }
+ case <-r.stopChan:
+ return
+ }
+ }
+}
+
+func renderSnapshot(snapshot Snapshot, lastRequests *uint64, lastCheckTime *time.Time) string {
+ now := time.Now()
+ intervalSeconds := now.Sub(*lastCheckTime).Seconds()
+
+ var rps float64
+ if intervalSeconds > 0 {
+ requestsSinceLast := snapshot.HTTPRequests - *lastRequests
+ rps = float64(requestsSinceLast) / intervalSeconds
+ }
+
+ *lastRequests = snapshot.HTTPRequests
+ *lastCheckTime = now
+
+ totalCacheHits := snapshot.CacheHits + snapshot.CacheHitNoModify
+
+ var ruleProcessing uint64
+ if snapshot.HTTPRequests > totalCacheHits {
+ ruleProcessing = snapshot.HTTPRequests - totalCacheHits
+ }
+
+ var directPass uint64
+ if snapshot.HTTPRequests > snapshot.ModifiedRequests {
+ directPass = snapshot.HTTPRequests - snapshot.ModifiedRequests
+ }
+
+ var totalCacheRatio float64
+ if snapshot.HTTPRequests > 0 {
+ totalCacheRatio = (float64(totalCacheHits) * 100) / float64(snapshot.HTTPRequests)
+ }
+
+ return fmt.Sprintf(
+ "current_connections:%d\n"+
+ "total_requests:%d\n"+
+ "rps:%.2f\n"+
+ "successful_modifications:%d\n"+
+ "direct_passthrough:%d\n"+
+ "rule_processing:%d\n"+
+ "cache_hit_modify:%d\n"+
+ "cache_hit_pass:%d\n"+
+ "total_cache_ratio:%.2f\n",
+ snapshot.ActiveConnections,
+ snapshot.HTTPRequests,
+ rps,
+ snapshot.ModifiedRequests,
+ directPass,
+ ruleProcessing,
+ snapshot.CacheHits,
+ snapshot.CacheHitNoModify,
+ totalCacheRatio,
+ )
+}
diff --git a/UA-Mask/core/internal/stats/stats.go b/UA-Mask/core/internal/stats/stats.go
new file mode 100644
index 000000000..eafacd8dc
--- /dev/null
+++ b/UA-Mask/core/internal/stats/stats.go
@@ -0,0 +1,81 @@
+package stats
+
+import (
+ "sync/atomic"
+
+ "UAmask/internal/model"
+)
+
+type Snapshot struct {
+ ActiveConnections uint64
+ HTTPRequests uint64
+ ModifiedRequests uint64
+ CacheHits uint64
+ CacheHitNoModify uint64
+}
+
+type Stats struct {
+ activeConnections atomic.Uint64
+ httpRequests atomic.Uint64
+ modifiedRequests atomic.Uint64
+ cacheHits atomic.Uint64
+ cacheHitNoModify atomic.Uint64
+}
+
+func New() *Stats {
+ return &Stats{}
+}
+
+func (s *Stats) ConnectionOpened() {
+ s.activeConnections.Add(1)
+}
+
+func (s *Stats) ConnectionClosed() {
+ s.activeConnections.Add(^uint64(0))
+}
+
+func (s *Stats) RecordHTTPRequest() {
+ s.httpRequests.Add(1)
+}
+
+func (s *Stats) RecordModifiedRequest() {
+ s.modifiedRequests.Add(1)
+}
+
+func (s *Stats) RecordCacheHitModified() {
+ s.cacheHits.Add(1)
+}
+
+func (s *Stats) RecordCacheHitPassthrough() {
+ s.cacheHitNoModify.Add(1)
+}
+
+func (s *Stats) Snapshot() Snapshot {
+ return Snapshot{
+ ActiveConnections: s.activeConnections.Load(),
+ HTTPRequests: s.httpRequests.Load(),
+ ModifiedRequests: s.modifiedRequests.Load(),
+ CacheHits: s.cacheHits.Load(),
+ CacheHitNoModify: s.cacheHitNoModify.Load(),
+ }
+}
+
+func (s *Stats) Append(event model.SessionEvent) {
+ switch event.Type {
+ case model.EventSessionOpened:
+ s.ConnectionOpened()
+ case model.EventSessionClosed:
+ s.ConnectionClosed()
+ case model.EventHTTPRequestObserved:
+ s.RecordHTTPRequest()
+ case model.EventRewriteApplied:
+ s.RecordModifiedRequest()
+ if event.FromCache {
+ s.RecordCacheHitModified()
+ }
+ case model.EventRewriteSkipped:
+ if event.FromCache {
+ s.RecordCacheHitPassthrough()
+ }
+ }
+}
diff --git a/UA-Mask/core/internal/testkit/manualclock/manualclock.go b/UA-Mask/core/internal/testkit/manualclock/manualclock.go
new file mode 100644
index 000000000..0c9a35e05
--- /dev/null
+++ b/UA-Mask/core/internal/testkit/manualclock/manualclock.go
@@ -0,0 +1,216 @@
+package manualclock
+
+import (
+ "sync"
+ "time"
+
+ runtimeclock "UAmask/internal/clock"
+)
+
+type Clock struct {
+ advanceMu sync.Mutex
+ mu sync.Mutex
+ now time.Time
+ nextID uint64
+ timers map[*scheduled]struct{}
+}
+
+type scheduled struct {
+ owner *Clock
+ id uint64
+ due time.Time
+ interval time.Duration
+ channel chan time.Time
+ callback func()
+ active bool
+ ticker bool
+}
+
+type Timer struct {
+ scheduled *scheduled
+}
+
+type Ticker struct {
+ scheduled *scheduled
+}
+
+var _ runtimeclock.Clock = (*Clock)(nil)
+var _ runtimeclock.Timer = (*Timer)(nil)
+var _ runtimeclock.Ticker = (*Ticker)(nil)
+
+func New(start time.Time) *Clock {
+ return &Clock{
+ now: start,
+ timers: make(map[*scheduled]struct{}),
+ }
+}
+
+func (clock *Clock) Now() time.Time {
+ clock.mu.Lock()
+ defer clock.mu.Unlock()
+ return clock.now
+}
+
+func (clock *Clock) After(duration time.Duration) <-chan time.Time {
+ return clock.NewTimer(duration).C()
+}
+
+func (clock *Clock) AfterFunc(duration time.Duration, callback func()) runtimeclock.Timer {
+ return &Timer{scheduled: clock.schedule(duration, 0, nil, callback, false)}
+}
+
+func (clock *Clock) NewTimer(duration time.Duration) runtimeclock.Timer {
+ return &Timer{scheduled: clock.schedule(duration, 0, make(chan time.Time, 1), nil, false)}
+}
+
+func (clock *Clock) NewTicker(duration time.Duration) runtimeclock.Ticker {
+ if duration <= 0 {
+ panic("non-positive interval for NewTicker")
+ }
+ return &Ticker{scheduled: clock.schedule(duration, duration, make(chan time.Time, 1), nil, true)}
+}
+
+func (clock *Clock) Advance(duration time.Duration) {
+ if duration < 0 {
+ panic("manual clock cannot advance backwards")
+ }
+
+ clock.advanceMu.Lock()
+ defer clock.advanceMu.Unlock()
+
+ clock.mu.Lock()
+ target := clock.now.Add(duration)
+ clock.mu.Unlock()
+
+ for {
+ clock.mu.Lock()
+ next := clock.nextDueLocked(target)
+ if next == nil {
+ clock.now = target
+ clock.mu.Unlock()
+ return
+ }
+
+ firedAt := next.due
+ clock.now = firedAt
+ if next.ticker {
+ next.due = next.due.Add(next.interval)
+ next.id = clock.allocateIDLocked()
+ } else {
+ next.active = false
+ }
+ channel := next.channel
+ callback := next.callback
+ clock.mu.Unlock()
+
+ if channel != nil {
+ select {
+ case channel <- firedAt:
+ default:
+ }
+ }
+ if callback != nil {
+ callback()
+ }
+ }
+}
+
+func (clock *Clock) Pending() int {
+ clock.mu.Lock()
+ defer clock.mu.Unlock()
+ count := 0
+ for timer := range clock.timers {
+ if timer.active {
+ count++
+ }
+ }
+ return count
+}
+
+func (clock *Clock) Due() int {
+ clock.mu.Lock()
+ defer clock.mu.Unlock()
+ count := 0
+ for timer := range clock.timers {
+ if timer.active && !timer.due.After(clock.now) {
+ count++
+ }
+ }
+ return count
+}
+
+func (clock *Clock) schedule(duration, interval time.Duration, channel chan time.Time, callback func(), ticker bool) *scheduled {
+ clock.mu.Lock()
+ defer clock.mu.Unlock()
+ if duration < 0 {
+ duration = 0
+ }
+ timer := &scheduled{
+ owner: clock,
+ id: clock.allocateIDLocked(),
+ due: clock.now.Add(duration),
+ interval: interval,
+ channel: channel,
+ callback: callback,
+ active: true,
+ ticker: ticker,
+ }
+ clock.timers[timer] = struct{}{}
+ return timer
+}
+
+func (clock *Clock) allocateIDLocked() uint64 {
+ clock.nextID++
+ return clock.nextID
+}
+
+func (clock *Clock) nextDueLocked(target time.Time) *scheduled {
+ var next *scheduled
+ for timer := range clock.timers {
+ if !timer.active || timer.due.After(target) {
+ continue
+ }
+ if next == nil || timer.due.Before(next.due) || (timer.due.Equal(next.due) && timer.id < next.id) {
+ next = timer
+ }
+ }
+ return next
+}
+
+func (timer *Timer) C() <-chan time.Time {
+ return timer.scheduled.channel
+}
+
+func (timer *Timer) Stop() bool {
+ return timer.scheduled.stop()
+}
+
+func (timer *Timer) Reset(duration time.Duration) bool {
+ scheduled := timer.scheduled
+ scheduled.owner.mu.Lock()
+ defer scheduled.owner.mu.Unlock()
+ if duration < 0 {
+ duration = 0
+ }
+ wasActive := scheduled.active
+ scheduled.active = true
+ scheduled.due = scheduled.owner.now.Add(duration)
+ scheduled.id = scheduled.owner.allocateIDLocked()
+ return wasActive
+}
+
+func (ticker *Ticker) C() <-chan time.Time {
+ return ticker.scheduled.channel
+}
+
+func (ticker *Ticker) Stop() {
+ _ = ticker.scheduled.stop()
+}
+
+func (timer *scheduled) stop() bool {
+ timer.owner.mu.Lock()
+ defer timer.owner.mu.Unlock()
+ wasActive := timer.active
+ timer.active = false
+ return wasActive
+}
diff --git a/UA-Mask/core/internal/testkit/manualclock/manualclock_test.go b/UA-Mask/core/internal/testkit/manualclock/manualclock_test.go
new file mode 100644
index 000000000..535c1fa9a
--- /dev/null
+++ b/UA-Mask/core/internal/testkit/manualclock/manualclock_test.go
@@ -0,0 +1,103 @@
+package manualclock
+
+import (
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestTimerFiresAtDeadline(t *testing.T) {
+ clock := New(time.Unix(100, 0))
+ timer := clock.NewTimer(10 * time.Second)
+
+ clock.Advance(9 * time.Second)
+ select {
+ case value := <-timer.C():
+ t.Fatalf("TIME-CLOCK-001 timer fired early at %s", value)
+ default:
+ }
+
+ clock.Advance(time.Second)
+ select {
+ case value := <-timer.C():
+ if want := time.Unix(110, 0); !value.Equal(want) {
+ t.Fatalf("TIME-CLOCK-001 fired at %s, want %s", value, want)
+ }
+ default:
+ t.Fatal("TIME-CLOCK-001 timer did not fire at deadline")
+ }
+}
+
+func TestCallbacksAtSameDeadlineKeepRegistrationOrder(t *testing.T) {
+ clock := New(time.Unix(100, 0))
+ order := make([]int, 0, 3)
+ clock.AfterFunc(time.Second, func() { order = append(order, 1) })
+ clock.AfterFunc(time.Second, func() { order = append(order, 2) })
+ clock.AfterFunc(time.Second, func() { order = append(order, 3) })
+
+ clock.Advance(time.Second)
+ if want := []int{1, 2, 3}; !reflect.DeepEqual(order, want) {
+ t.Fatalf("TIME-CLOCK-002 order = %v, want %v", order, want)
+ }
+}
+
+func TestTimerStopResetAndReentrantSchedule(t *testing.T) {
+ clock := New(time.Unix(100, 0))
+ order := make([]string, 0, 2)
+ timer := clock.AfterFunc(10*time.Second, func() {
+ order = append(order, "outer")
+ clock.AfterFunc(0, func() { order = append(order, "inner") })
+ })
+
+ if !timer.Stop() {
+ t.Fatal("TIME-CLOCK-003 active timer Stop returned false")
+ }
+ clock.Advance(10 * time.Second)
+ if len(order) != 0 {
+ t.Fatalf("TIME-CLOCK-003 stopped timer fired: %v", order)
+ }
+ if timer.Reset(5 * time.Second) {
+ t.Fatal("TIME-CLOCK-003 reset stopped timer reported active")
+ }
+ clock.Advance(5 * time.Second)
+ if want := []string{"outer", "inner"}; !reflect.DeepEqual(order, want) {
+ t.Fatalf("TIME-CLOCK-003 order = %v, want %v", order, want)
+ }
+}
+
+func TestTickerDropsUnreadTicksAndStops(t *testing.T) {
+ clock := New(time.Unix(100, 0))
+ ticker := clock.NewTicker(2 * time.Second)
+
+ clock.Advance(2 * time.Second)
+ if got := <-ticker.C(); !got.Equal(time.Unix(102, 0)) {
+ t.Fatalf("TIME-CLOCK-004 first tick = %s", got)
+ }
+
+ clock.Advance(6 * time.Second)
+ if got := <-ticker.C(); !got.Equal(time.Unix(104, 0)) {
+ t.Fatalf("TIME-CLOCK-004 retained tick = %s, want first unread tick", got)
+ }
+ select {
+ case got := <-ticker.C():
+ t.Fatalf("TIME-CLOCK-004 ticker retained extra tick %s", got)
+ default:
+ }
+
+ ticker.Stop()
+ clock.Advance(2 * time.Second)
+ select {
+ case got := <-ticker.C():
+ t.Fatalf("TIME-CLOCK-004 stopped ticker fired at %s", got)
+ default:
+ }
+}
+
+func TestAdvanceRejectsNegativeDuration(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("negative Advance did not panic")
+ }
+ }()
+ New(time.Unix(100, 0)).Advance(-time.Second)
+}
diff --git a/UA-Mask/core/internal/testkit/record/record.go b/UA-Mask/core/internal/testkit/record/record.go
new file mode 100644
index 000000000..0c93287bb
--- /dev/null
+++ b/UA-Mask/core/internal/testkit/record/record.go
@@ -0,0 +1,66 @@
+package record
+
+import (
+ "sync"
+
+ "UAmask/internal/firewall"
+ "UAmask/internal/model"
+)
+
+type Recorder[T any] struct {
+ mu sync.Mutex
+ values []T
+}
+
+func (recorder *Recorder[T]) Add(value T) {
+ recorder.mu.Lock()
+ recorder.values = append(recorder.values, value)
+ recorder.mu.Unlock()
+}
+
+func (recorder *Recorder[T]) Values() []T {
+ recorder.mu.Lock()
+ defer recorder.mu.Unlock()
+ return append([]T(nil), recorder.values...)
+}
+
+func (recorder *Recorder[T]) Len() int {
+ recorder.mu.Lock()
+ defer recorder.mu.Unlock()
+ return len(recorder.values)
+}
+
+type Events struct {
+ Recorder[model.SessionEvent]
+}
+
+func (recorder *Events) Append(event model.SessionEvent) {
+ recorder.Add(event)
+}
+
+func (recorder *Events) Count(eventType model.SessionEventType) int {
+ count := 0
+ for _, event := range recorder.Values() {
+ if event.Type == eventType {
+ count++
+ }
+ }
+ return count
+}
+
+type Signals struct {
+ Recorder[model.OffloadSignal]
+}
+
+type Snapshots struct {
+ Recorder[model.ProfileSnapshot]
+}
+
+type Batches struct {
+ Recorder[[]firewall.BypassTarget]
+}
+
+func (recorder *Batches) AddBatch(items []firewall.BypassTarget) {
+ copied := append([]firewall.BypassTarget(nil), items...)
+ recorder.Add(copied)
+}
diff --git a/UA-Mask/core/internal/testkit/scenario/target.go b/UA-Mask/core/internal/testkit/scenario/target.go
new file mode 100644
index 000000000..dd1f58e8b
--- /dev/null
+++ b/UA-Mask/core/internal/testkit/scenario/target.go
@@ -0,0 +1,258 @@
+package scenario
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+ "time"
+
+ "UAmask/internal/events"
+ "UAmask/internal/firewall"
+ "UAmask/internal/model"
+ "UAmask/internal/offload"
+ "UAmask/internal/policy"
+ "UAmask/internal/state"
+ "UAmask/internal/testkit/manualclock"
+ "UAmask/internal/testkit/record"
+
+ "github.com/sirupsen/logrus"
+)
+
+type TargetConfig struct {
+ Start time.Time
+ NonHTTPThreshold int
+ DecisionDelay time.Duration
+ HTTPCooldownPeriod time.Duration
+ ProfileCleanupInterval time.Duration
+ TargetOffloadTimeout int
+ WhitelistOffloadTimeout int
+ ExecutorQueueSize int
+}
+
+type Target struct {
+ Clock *manualclock.Clock
+ Dispatcher *events.Dispatcher
+ Store *state.Store
+ Policy *policy.TargetPolicy
+ Coordinator *offload.Coordinator
+ Executor *firewall.Executor
+ Events *record.Events
+ Signals *record.Signals
+ Snapshots *record.Snapshots
+ Batches *record.Batches
+
+ applier *controlledApplier
+ completedSignals int
+ closeOnce sync.Once
+ closeErr error
+}
+
+func NewTarget(config TargetConfig) *Target {
+ config = targetDefaults(config)
+ runtimeClock := manualclock.New(config.Start)
+ profiles := state.NewStoreWithClock(state.Config{
+ NonHTTPEnabled: true,
+ NonHTTPThreshold: config.NonHTTPThreshold,
+ HTTPCooldownPeriod: config.HTTPCooldownPeriod,
+ DecisionDelay: config.DecisionDelay,
+ ProfileCleanupInterval: config.ProfileCleanupInterval,
+ }, runtimeClock)
+ eventsRecorder := &record.Events{}
+ signalsRecorder := &record.Signals{}
+ snapshotsRecorder := &record.Snapshots{}
+ batchesRecorder := &record.Batches{}
+ applier := &controlledApplier{
+ results: make(chan error, config.ExecutorQueueSize),
+ batches: batchesRecorder,
+ }
+ executor := firewall.NewExecutorWithClock(testLogger(), config.ExecutorQueueSize, applier, runtimeClock)
+ targetOffloader := &recordingTargetOffloader{
+ signals: signalsRecorder,
+ delegate: offload.NewFirewallTargetOffloader(executor, "test_bypass_set", "ipt"),
+ }
+ flowOffloader := &recordingFlowOffloader{signals: signalsRecorder}
+ targetPolicy := policy.NewTargetPolicyWithClock(
+ config.TargetOffloadTimeout,
+ config.WhitelistOffloadTimeout,
+ config.NonHTTPThreshold,
+ true,
+ runtimeClock,
+ )
+ dispatcher := events.NewDispatcher()
+ coordinator := offload.NewCoordinatorWithClock(
+ testLogger(),
+ profiles,
+ targetPolicy,
+ targetOffloader,
+ flowOffloader,
+ dispatcher,
+ runtimeClock,
+ )
+ snapshotObserver := &snapshotObserver{profiles: profiles, snapshots: snapshotsRecorder}
+ dispatcher.Add(profiles, snapshotObserver, eventsRecorder, coordinator)
+
+ scenario := &Target{
+ Clock: runtimeClock,
+ Dispatcher: dispatcher,
+ Store: profiles,
+ Policy: targetPolicy,
+ Coordinator: coordinator,
+ Executor: executor,
+ Events: eventsRecorder,
+ Signals: signalsRecorder,
+ Snapshots: snapshotsRecorder,
+ Batches: batchesRecorder,
+ applier: applier,
+ }
+ profiles.Start()
+ executor.Start()
+ dispatcher.Start()
+ return scenario
+}
+
+func (scenario *Target) Emit(ctx context.Context, event model.SessionEvent) error {
+ if event.Timestamp.IsZero() {
+ event.Timestamp = scenario.Clock.Now()
+ }
+ if !scenario.Dispatcher.TryAppend(event) {
+ return fmt.Errorf("append event %s", event.Type)
+ }
+ return scenario.settle(ctx)
+}
+
+func (scenario *Target) Advance(ctx context.Context, duration time.Duration) error {
+ scenario.Clock.Advance(duration)
+ return scenario.settle(ctx)
+}
+
+func (scenario *Target) CompleteNext(ctx context.Context, result error) error {
+ if scenario.Signals.Len() <= scenario.completedSignals {
+ return errors.New("no pending target offload signal")
+ }
+ scenario.applier.results <- result
+ if err := scenario.Executor.Flush(ctx); err != nil {
+ return err
+ }
+ scenario.completedSignals = scenario.Signals.Len()
+ return scenario.settle(ctx)
+}
+
+func (scenario *Target) Snapshot(target model.Target) model.ProfileSnapshot {
+ return scenario.Store.Snapshot(target)
+}
+
+func (scenario *Target) Close(ctx context.Context) error {
+ scenario.closeOnce.Do(func() {
+ scenario.Coordinator.Stop()
+ if scenario.Signals.Len() > scenario.completedSignals {
+ scenario.applier.results <- nil
+ }
+ if err := scenario.Executor.Flush(ctx); err != nil && !errors.Is(err, firewall.ErrExecutorStopped) {
+ scenario.closeErr = err
+ }
+ scenario.Executor.Stop()
+ if err := scenario.Dispatcher.WaitIdle(ctx); err != nil && scenario.closeErr == nil {
+ scenario.closeErr = err
+ }
+ scenario.Dispatcher.Stop()
+ scenario.Store.Stop()
+ })
+ return scenario.closeErr
+}
+
+func (scenario *Target) settle(ctx context.Context) error {
+ for {
+ if err := scenario.Dispatcher.WaitIdle(ctx); err != nil {
+ return err
+ }
+ if scenario.Clock.Due() == 0 {
+ return nil
+ }
+ scenario.Clock.Advance(0)
+ }
+}
+
+func targetDefaults(config TargetConfig) TargetConfig {
+ if config.Start.IsZero() {
+ config.Start = time.Unix(1_000, 0)
+ }
+ if config.NonHTTPThreshold <= 0 {
+ config.NonHTTPThreshold = 2
+ }
+ if config.DecisionDelay <= 0 {
+ config.DecisionDelay = 30 * time.Second
+ }
+ if config.HTTPCooldownPeriod <= 0 {
+ config.HTTPCooldownPeriod = time.Minute
+ }
+ if config.ProfileCleanupInterval <= 0 {
+ config.ProfileCleanupInterval = 24 * time.Hour
+ }
+ if config.TargetOffloadTimeout <= 0 {
+ config.TargetOffloadTimeout = 60
+ }
+ if config.WhitelistOffloadTimeout <= 0 {
+ config.WhitelistOffloadTimeout = 3600
+ }
+ if config.ExecutorQueueSize <= 0 {
+ config.ExecutorQueueSize = 16
+ }
+ return config
+}
+
+type snapshotObserver struct {
+ profiles state.ProfileStore
+ snapshots *record.Snapshots
+}
+
+func (observer *snapshotObserver) Append(event model.SessionEvent) {
+ if event.Target.IP == "" || event.Target.Port == 0 {
+ return
+ }
+ observer.snapshots.Add(observer.profiles.Snapshot(event.Target))
+}
+
+type targetOffloader interface {
+ Handle(model.OffloadSignal, func(error)) error
+}
+
+type recordingTargetOffloader struct {
+ signals *record.Signals
+ delegate targetOffloader
+}
+
+func (offloader *recordingTargetOffloader) Handle(signal model.OffloadSignal, completion func(error)) error {
+ offloader.signals.Add(signal)
+ return offloader.delegate.Handle(signal, completion)
+}
+
+type recordingFlowOffloader struct {
+ signals *record.Signals
+ mu sync.Mutex
+ err error
+}
+
+func (offloader *recordingFlowOffloader) Handle(signal model.OffloadSignal) error {
+ offloader.signals.Add(signal)
+ offloader.mu.Lock()
+ defer offloader.mu.Unlock()
+ return offloader.err
+}
+
+type controlledApplier struct {
+ results chan error
+ batches *record.Batches
+}
+
+func (applier *controlledApplier) ApplyBatch(items []firewall.BypassTarget) error {
+ applier.batches.AddBatch(items)
+ return <-applier.results
+}
+
+func testLogger() *logrus.Logger {
+ logger := logrus.New()
+ logger.SetOutput(io.Discard)
+ return logger
+}
diff --git a/UA-Mask/docs/architecture.md b/UA-Mask/docs/architecture.md
new file mode 100644
index 000000000..50fba17f7
--- /dev/null
+++ b/UA-Mask/docs/architecture.md
@@ -0,0 +1,679 @@
+# UA-Mask 架构说明
+
+本文描述当前 `UA-Mask` 代码库的实际运行架构,重点覆盖:
+
+- OpenWrt 侧如何把流量导入 UA-Mask
+- Go daemon 的启动装配与生命周期
+- TCP 会话、HTTP 探测、UA 改写的热路径
+- 事件、目标画像、策略、流量卸载之间的关系
+- 统计、LuCI 与防火墙 set 的集成方式
+- SIGINT/SIGTERM 下的有序停止语义
+
+当前架构只有一条生产流量路径:`proxy` 只负责接收与调度连接,业务热路径位于 `session`;目标级防火墙绕过统一通过 `events -> state -> policy -> offload -> firewall.Executor` 管线完成。旧的 `proxy.Processor` 与 `firewall.DecisionEngine` 已删除。
+
+## 1. 总览
+
+`UA-Mask` 是一个 OpenWrt REDIRECT 透明代理。OpenWrt 防火墙先把指定 TCP 流量重定向到本地监听端口,Go core 恢复原始目标地址并连接上游。
+
+进入 core 后:
+
+- HTTP 请求会读取 `User-Agent`,根据规则决定是否改写。
+- 非 HTTP 流量会 fallback 成普通 TCP 转发,同时被记录为目标画像的一部分。
+- 命中 UA 防火墙白名单,或多次表现为纯非 HTTP 的 `IP:Port`,会被动态加入 ipset/nfset。
+- 被加入 set 的目标后续会在 OpenWrt 防火墙层直接 RETURN,不再进入 UA-Mask。
+
+```mermaid
+flowchart LR
+ Client[客户端] --> FW[OpenWrt REDIRECT 规则]
+ FW --> Ingress[core/internal/ingress]
+ Ingress --> Server[core/internal/proxy.Server]
+ Server --> Session[core/internal/session.Manager]
+ Session --> Rewrite[core/internal/rewrite]
+ Session --> Events[core/internal/events.Dispatcher]
+ Events --> State[core/internal/state.Store]
+ Events --> Stats[core/internal/stats.Stats]
+ Events --> Offload[core/internal/offload.Coordinator]
+ State --> Offload
+ Offload --> Firewall[core/internal/firewall.Executor]
+ Firewall --> Set[ipset / nfset]
+ Session --> Upstream[目标服务器]
+ Stats --> StatsFile["/tmp/UAmask.stats"]
+ StatsFile --> LuCI[openwrt/luci]
+ Set --> FW
+```
+
+## 2. 目录与职责
+
+```text
+UA-Mask/
+├── core/
+│ ├── cmd/UAmask/ # 进程入口,只调用 app.Run(version)
+│ └── internal/
+│ ├── app/ # 运行时装配与生命周期
+│ ├── config/ # JSON/legacy flags 加载、默认值、校验与运行时预处理
+│ ├── ingress/ # REDIRECT 监听与原始目标地址恢复
+│ ├── proxy/ # Accept 循环与 worker pool 调度
+│ ├── session/ # 当前热路径中心:协议探测、转发、改写、事件发射
+│ ├── rewrite/ # UA 判定、缓存、请求头改写
+│ ├── events/ # 会话事件分发
+│ ├── state/ # 按目标维护画像与冷却状态
+│ ├── policy/ # 会话策略与目标卸载策略
+│ ├── offload/ # 把策略信号转换为卸载动作
+│ ├── firewall/ # 批量写 ipset/nfset
+│ ├── stats/ # 事件计数与统计落盘
+│ └── model/ # 跨模块共享的数据模型
+├── openwrt/
+│ ├── package/ # OpenWrt 包构建入口
+│ ├── root/ # init.d 与默认 UCI 配置
+│ └── luci/ # LuCI 页面
+└── docs/
+```
+
+可以按三条线理解:
+
+- **启动装配线**:`cmd -> app -> config -> runtime components`
+- **请求热路径线**:`ingress -> proxy.Server -> session.Manager -> rewrite -> upstream`
+- **卸载控制线**:`session events -> state/profile -> policy -> offload -> firewall set`
+
+## 3. 启动与运行时装配
+
+`core/cmd/UAmask/main.go` 是很薄的进程入口,只保存版本号并调用 `app.Run(version)`。
+
+`app.Run()` 负责:
+
+1. 调用 `config.NewFromFlags()` 解析进程参数,并按需加载版本化 JSON。
+2. 按 `defaults -> JSON -> 显式 legacy flags -> normalize -> validate -> compile` 生成最终配置。
+3. 处理 `-v`、`-check-config`、`-dump-effective-config` 等进程动作。
+4. 应用 GC 参数并初始化 logrus。
+5. 调用 `NewRuntime(cfg, version)` 装配所有运行时组件。
+6. 监听 SIGINT/SIGTERM,并用对应 context 执行 `runtime.RunContext()`。
+
+```mermaid
+sequenceDiagram
+ participant Main as cmd/UAmask
+ participant App as app.Run
+ participant Config as config loader
+ participant Runtime as app.NewRuntime
+ participant Server as proxy.Server
+
+ Main->>App: Run(version)
+ App->>Config: 解析进程参数 / 加载 JSON
+ Config->>Config: defaults → normalize → validate → compile
+ Config-->>App: Command + Config
+ App->>App: setupLogging()
+ App->>Runtime: NewRuntime(cfg, version)
+ Runtime-->>App: Runtime
+ App->>Runtime: Runtime.RunContext(ctx)
+ Runtime->>Server: server.RunContext(ctx)
+```
+
+`app.NewRuntime()` 是当前最重要的代码图谱入口。它会创建:
+
+- `stats.Stats` 与 `stats.FileReporter`
+- `rewrite.UACache`
+- `firewall.Executor`
+- `rewrite.Engine` 与 `rewrite.RequestRewriter`
+- `state.Store`
+- `offload.FirewallTargetOffloader`
+- `policy.SessionPolicy` 与 `policy.TargetPolicy`
+- `events.Dispatcher`
+- `offload.Coordinator`
+- `session.DefaultManager`
+- `ingress.Redirect`
+- `proxy.Server`
+
+装配关系如下:
+
+```mermaid
+flowchart TB
+ Config[config.Config]
+
+ Config --> Stats[stats.Stats]
+ Config --> Reporter[stats.FileReporter]
+ Config --> Cache[rewrite.UACache]
+ Config --> RewriteEngine[rewrite.Engine]
+ Config --> Rewriter[rewrite.RequestRewriter]
+ Config --> ProfileStore[state.Store]
+ Config --> SessionPolicy[policy.SessionPolicy]
+ Config --> TargetPolicy[policy.TargetPolicy]
+ Config --> Ingress[ingress.Redirect]
+ Config --> Server[proxy.Server]
+ Config --> FirewallExecutor[firewall.Executor]
+
+ RewriteEngine --> Rewriter
+ Cache --> Rewriter
+
+ Dispatcher[events.Dispatcher] --> ProfileStore
+ Dispatcher --> Stats
+ Dispatcher --> Offload[offload.Coordinator]
+
+ ProfileStore --> Offload
+ TargetPolicy --> Offload
+ FirewallExecutor --> TargetOffloader[offload.FirewallTargetOffloader]
+ TargetOffloader --> Offload
+
+ SessionPolicy --> Session[session.Manager]
+ Rewriter --> Session
+ ProfileStore --> Session
+ Offload --> Session
+ Dispatcher --> Session
+
+ Ingress --> Server
+ Session --> Server
+```
+
+`Runtime.RunContext()` 的启动顺序是:
+
+1. 打印配置。
+2. 启动 `firewall.Executor` worker。
+3. 启动 `state.Store` 清理 worker。
+4. 启动 `stats.FileReporter` 落盘 worker。
+5. 启动 `events.Dispatcher` 事件 worker。
+6. 最后进入 `proxy.Server.RunContext()` 的 Accept 循环。
+
+context 取消或 server 返回错误时:
+
+1. `proxy.Server` 关闭 ingress,停止接收新连接,并丢弃 worker pool 中尚未开始处理的 session。
+2. context 传入 `session.Manager`;拨号使用 `DialContext`,取消时同时关闭 client 和 upstream,再等待已开始的 handler 返回。
+3. `offload.Coordinator.Stop()` 取消待决策 timer 并拒绝新动作。
+4. `firewall.Executor.Flush()` 完成此前入队的批次与 completion,然后停止 worker。
+5. `events.Dispatcher.WaitIdle()` 完成 result event 的状态和统计投影,然后停止分发器。
+6. 最后停止 stats reporter 和 profile store。
+
+这个顺序保证“停止接收流量”先于“停止控制面”,并且防火墙执行结果不会因为关闭顺序而丢失。
+
+## 4. 配置模型
+
+`config.Config` 已经按职责拆成几个子配置:
+
+```mermaid
+classDiagram
+ class Config {
+ +Listen ListenConfig
+ +Rewrite RewriteConfig
+ +RewriteRuntime RewriteRuntimeConfig
+ +Firewall FirewallConfig
+ +Observe ObserveConfig
+ +Performance PerformanceConfig
+ }
+
+ class ListenConfig {
+ +Port int
+ +PoolSize int
+ +BufferSize int
+ +DialTimeout duration
+ +ClientKeepAlive duration
+ +UpstreamKeepAlive duration
+ }
+
+ class RewriteConfig {
+ +UserAgent string
+ +Whitelist []string
+ +ForceReplace bool
+ +EnableRegex bool
+ +EnablePartialReplace bool
+ +Keywords []string
+ +Pattern string
+ +CacheSize int
+ }
+
+ class RewriteRuntimeConfig {
+ +UARegexp *regexp.Regexp
+ }
+
+ class FirewallConfig {
+ +UAWhitelist []string
+ +EnableBypass bool
+ +SetName string
+ +Backend string
+ +DropOnMatch bool
+ +NonHTTPThreshold int
+ +Timeout int
+ +DecisionDelay duration
+ +HTTPCooldownPeriod duration
+ +ProfileCleanupInterval duration
+ +ImmediateBypassTimeout int
+ }
+
+ class ObserveConfig {
+ +LogLevel string
+ +LogFile string
+ +StatsFilePath string
+ +StatsInterval duration
+ }
+
+ class PerformanceConfig {
+ +Profile string
+ +GCPercent int
+ }
+
+ Config --> ListenConfig
+ Config --> RewriteConfig
+ Config --> RewriteRuntimeConfig
+ Config --> FirewallConfig
+ Config --> ObserveConfig
+ Config --> PerformanceConfig
+```
+
+`RewriteConfig.Pattern` 保存用户输入语义,`RewriteRuntimeConfig.UARegexp` 保存编译后的正则。这样业务代码只消费已经预处理好的运行时配置。
+
+OpenWrt LuCI/UCI 仍然是用户配置的唯一真相源。`openwrt/root/etc/init.d/UAmask` 使用 `jshn.sh` 将 core 所需字段写入 `/var/run/UAmask/config.json`,调用 `UAmask -check-config` 校验后原子替换,再通过以下命令启动:
+
+```sh
+/usr/bin/UAmask -config /var/run/UAmask/config.json
+```
+
+JSON 使用 `schema_version` 做显式版本管理,未知字段会导致加载失败。duration 使用 `30s`、`10m`、`8h` 等字符串,列表使用 JSON 数组。完整示例见 `docs/config.example.json`。
+
+独立运行时支持以下进程级参数:
+
+- `-config `
+- `-check-config`
+- `-dump-effective-config`
+- `-v`
+
+旧业务 flags 暂时作为兼容覆盖层保留;优先级为默认值、JSON、显式 legacy flag。
+
+## 5. 接入层:ingress 与 proxy
+
+`ingress.Redirect` 负责本地监听和原始目标地址恢复。
+
+```mermaid
+sequenceDiagram
+ participant FW as OpenWrt REDIRECT
+ participant Ingress as ingress.Redirect
+ participant Server as proxy.Server
+ participant Manager as session.Manager
+
+ FW->>Ingress: TCP 连接进入监听端口
+ Ingress->>Ingress: AcceptTCP()
+ Ingress->>Ingress: getOriginalDst()
+ Ingress-->>Server: AcceptedSession
+ Server->>Manager: Handle(accepted)
+```
+
+`proxy.Server` 当前只负责连接调度:
+
+- 持有 `ingress.Ingress`
+- 持有 `session.Manager`
+- 根据 `ListenConfig.PoolSize` 选择 worker pool 或一连接一 goroutine
+- 对每个 `AcceptedSession` 调用 `manager.Handle()`
+
+这意味着 `proxy` 不再理解 UA 规则、状态画像或防火墙策略。
+
+## 6. 热路径中心:session.Manager
+
+`session.DefaultManager` 是当前最重要的热路径模块。它负责:
+
+- 为每条连接创建 `SessionState`
+- 连接原始目标上游
+- 启动双向转发
+- 在客户端到上游方向做 HTTP 探测
+- 对 HTTP 请求调用 `rewrite.RequestRewriter`
+- 对非 HTTP 流量 fallback 到 `io.Copy`
+- 向 `events.Dispatcher` 发出会话事件
+- 调用 `policy.SessionPolicy` 得到连接动作
+- 把即时 offload signal 交给 `offload.Coordinator`
+
+整体流程如下:
+
+```mermaid
+sequenceDiagram
+ participant Client as 客户端
+ participant Manager as session.Manager
+ participant Upstream as 目标服务器
+ participant Rewriter as rewrite.RequestRewriter
+ participant Policy as policy.SessionPolicy
+ participant Events as events.Dispatcher
+ participant Offload as offload.Coordinator
+
+ Client->>Manager: AcceptedSession
+ Manager->>Events: EventSessionOpened
+ Manager->>Upstream: Dial(target.Address)
+ Manager->>Events: EventUpstreamConnected
+ par client -> upstream
+ Manager->>Manager: Peek(7) 判断 HTTP
+ alt 非 HTTP
+ Manager->>Events: EventProtocolClassified(non_http)
+ Manager->>Policy: Evaluate(classification)
+ Manager->>Events: EventNonHTTPObserved
+ Manager->>Offload: Handle(decision.FlowSignal)
+ Manager->>Events: EventSessionForwardFallback
+ Manager->>Upstream: io.Copy fallback
+ else HTTP
+ Manager->>Manager: http.ReadRequest()
+ Manager->>Events: EventProtocolClassified(http)
+ Manager->>Rewriter: Rewrite(request)
+ Rewriter-->>Manager: rewrite.Result
+ Manager->>Policy: Evaluate(request)
+ Manager->>Events: EventHTTPRequestObserved
+ Manager->>Events: EventRewriteApplied 或 EventRewriteSkipped
+ Manager->>Offload: Handle(decision.FlowSignal)
+ Manager->>Upstream: request.Write()
+ end
+ and upstream -> client
+ Upstream->>Manager: response bytes
+ Manager->>Client: io.Copy
+ end
+ Manager->>Events: EventSessionClosed
+```
+
+协议探测非常轻量:只 `Peek(7)` 并判断是否以常见 HTTP 方法开头。判断为非 HTTP 后,不再尝试解析应用层协议,而是记录事件并直接转发剩余 TCP 字节。
+
+## 7. Rewrite 层
+
+`rewrite` 层拆成三个对象:
+
+- `Engine`:只根据 UA 和配置做纯决策。
+- `UACache`:缓存 `originUA -> finalUA`。
+- `RequestRewriter`:面向 `http.Request` 的封装,负责读写 Header 和串联缓存/决策。
+
+```mermaid
+flowchart LR
+ Request[http.Request] --> Rewriter[RequestRewriter]
+ Rewriter --> Cache[UACache]
+ Rewriter --> Engine[Engine]
+ Engine --> Decision[Decision]
+ Decision --> Rewriter
+ Rewriter --> Mutated[可能被改写的 Request]
+```
+
+UA 决策顺序:
+
+```mermaid
+flowchart TD
+ Start([读取 originUA]) --> Empty{UA 为空?}
+ Empty -- 是 --> NoUA[NoUserAgent: 不缓存、不改写]
+ Empty -- 否 --> FWUA{命中防火墙 UA 白名单?}
+ FWUA -- 是 --> FWDecision[FirewallBypassHit=true 可选 DropConnection]
+ FWUA -- 否 --> ExactWL{命中精确 UA 白名单?}
+ ExactWL -- 是 --> Whitelist[不改写,可缓存]
+ ExactWL -- 否 --> Force{ForceReplace?}
+ Force -- 是 --> ForceReplace[直接替换,可缓存]
+ Force -- 否 --> RegexMode{EnableRegex?}
+ RegexMode -- 是 --> RegexHit{正则命中?}
+ RegexHit -- 是 --> RegexReplace[完整或部分替换,可缓存]
+ RegexHit -- 否 --> RegexMiss[不改写,可缓存]
+ RegexMode -- 否 --> KeywordHit{命中关键词?}
+ KeywordHit -- 是 --> KeywordReplace[替换,可缓存]
+ KeywordHit -- 否 --> KeywordMiss[不改写,可缓存]
+```
+
+`Decision` 字段表达了后续模块需要知道的结果:
+
+- `Matched`
+- `Replace`
+- `FinalUA`
+- `Reason`
+- `Cacheable`
+- `FirewallBypassHit`
+- `DropConnection`
+
+`FirewallBypassHit` 不直接写防火墙,而是通过会话事件和目标策略进入卸载管线。
+
+## 8. 事件模型
+
+当前代码用 `model.SessionEvent` 作为模块之间的主要事实记录。
+
+核心事件包括:
+
+- `EventSessionOpened`
+- `EventUpstreamConnected`
+- `EventProtocolClassified`
+- `EventHTTPRequestObserved`
+- `EventRewriteApplied`
+- `EventRewriteSkipped`
+- `EventNonHTTPObserved`
+- `EventSessionForwardFallback`
+- `EventSessionClosed`
+- `EventFlowOffloadSucceeded`
+- `EventFlowOffloadFailed`
+- `EventTargetOffloadSucceeded`
+- `EventTargetOffloadFailed`
+
+`events.Dispatcher` 是一个简单的异步 fan-out:
+
+```mermaid
+flowchart LR
+ Session[session.Manager] --> Dispatcher[events.Dispatcher]
+ Dispatcher --> State[state.Store]
+ Dispatcher --> Stats[stats.Stats]
+ Dispatcher --> Offload[offload.Coordinator]
+```
+
+运行中 `Dispatcher.Append(event)` 会把事件放入固定容量的环形队列,由后台 worker 依次分发给 sink。队列满时拒绝最新事件并累计 `Dropped()` 计数,防止慢 sink 导致内存无限增长;`Stop()` 只排空调用时已经入队的事件,停止边界之后产生的新事件会被拒绝,确保关闭过程有限;未启动时则同步分发,便于测试。
+
+## 9. 状态画像与策略
+
+`state.Store` 按目标 `IP:Port` 维护 `TargetProfile`:
+
+- `NonHTTPScore`
+- `HTTPCooldownUntil`
+- `LastActivity`
+- `PendingTargetOffloadAt`
+- `TargetOffloadUntil`
+- `FirewallWhitelistHits`
+- `LastFirewallHit`
+- `LastEventType`
+- `LastReason`
+
+它消费事件并更新画像:
+
+```mermaid
+flowchart TD
+ Event[SessionEvent] --> Kind{事件类型}
+ Kind -- HTTPRequestObserved --> HTTP[清零 NonHTTPScore 设置 HTTP cooldown 取消 pending target offload]
+ Kind -- NonHTTPObserved --> NonHTTP[如果不在 cooldown NonHTTPScore++]
+ NonHTTP --> Threshold{达到阈值?}
+ Threshold -- 是 --> Pending[设置 PendingTargetOffloadAt]
+ Threshold -- 否 --> Wait[继续观察]
+ Kind -- RewriteSkipped 且 FirewallHit --> FWHit[记录防火墙白名单命中]
+ Kind -- TargetOffloadSucceeded --> Offloaded[记录 TargetOffloadUntil 清理分数和 pending]
+```
+
+`policy` 有两类策略:
+
+- `SessionPolicy`:根据一次会话输入决定继续、透传或断开。
+- `TargetPolicy`:根据目标画像决定是否发出 `SignalTryTargetOffload`。
+
+`TargetPolicy` 的两个主要触发条件是:
+
+- HTTP 请求命中防火墙 UA 白名单,并产生 `EventRewriteSkipped` + `FirewallHit=true`。
+- 非 HTTP 目标达到阈值,且经过 `DecisionDelay` 后仍不处于 HTTP cooldown。
+
+两条路径的开关彼此独立:UA 防火墙白名单始终可产生目标卸载信号,`-fw-bypass` 只控制非 HTTP 画像与策略。
+
+## 10. 流量卸载管线
+
+`offload.Coordinator` 同时扮演两个角色:
+
+- 作为 `events.Dispatcher` 的 sink,观察目标事件。
+- 作为 `session.Manager` 的 signal handler,处理策略产生的卸载信号。
+
+目标卸载流程如下:
+
+```mermaid
+sequenceDiagram
+ participant Session as session.Manager
+ participant Events as events.Dispatcher
+ participant Store as state.Store
+ participant Coord as offload.Coordinator
+ participant Policy as policy.TargetPolicy
+ participant Executor as firewall.Executor
+ participant FW as ipset/nfset
+
+ Session->>Events: EventNonHTTPObserved 或 EventRewriteSkipped
+ Events->>Store: 更新 TargetProfile
+ Events->>Coord: observeTargetEvent(event)
+ Coord->>Store: Snapshot(target)
+
+ alt 非 HTTP 达到阈值
+ Coord->>Coord: scheduleTargetEvaluation(delay)
+ Coord->>Store: 到期后重新 Snapshot(target)
+ Coord->>Policy: Evaluate(snapshot)
+ else UA 防火墙白名单命中
+ Coord->>Policy: Evaluate(snapshot)
+ end
+
+ Policy-->>Coord: SignalTryTargetOffload
+ Coord->>Executor: TryEnqueue(BypassTarget, completion)
+ Executor->>FW: 批量写入 set
+ FW-->>Executor: 实际执行结果
+ Executor-->>Coord: completion(error)
+ Coord->>Events: EventTargetOffloadSucceeded 或 Failed
+```
+
+`offload.FirewallTargetOffloader` 把 `model.OffloadSignal` 转成 `firewall.BypassTarget` 并投递给 `firewall.Executor`。Coordinator 只在 Executor 的批处理命令实际完成后发出成功或失败事实;单纯进入队列不会再被视为卸载成功。
+
+`NoopFlowOffloader` 当前是空实现,说明代码模型里预留了“单连接级 flow offload”的概念,但当前实际起作用的是目标级 `IP:Port` offload。
+
+## 11. Firewall 执行层
+
+`firewall.Executor` 是异步批处理执行器,不负责策略判断。
+
+它的职责是:
+
+- 校验 `BypassTarget`
+- 接收待写入 set 的目标
+- 按 `backend:setName` 分桶
+- 对同一 `IP:Port` 去重
+- 达到批次大小或最大等待时间后执行
+- 停止时 drain 队列并执行剩余 batch
+
+```mermaid
+flowchart LR
+ Offload[offload.FirewallTargetOffloader] --> Executor[firewall.Executor]
+ Executor --> Queue[queue]
+ Queue --> Batch[按 backend:setName 分桶 按 IP:Port 去重]
+ Batch --> Applier[firewall.CommandApplier]
+ Applier -->|nft add element| NFT[nfset]
+ Applier -->|ipset restore| IPT[ipset]
+```
+
+`firewall.CommandApplier` 根据 backend 选择命令:
+
+- `nft`:执行 `nft add element inet fw4 { ip . port timeout Ns }`
+- `ipt`:执行 `ipset restore`
+
+基础 REDIRECT 规则和 set 创建不在 Go 里完成,而是在 OpenWrt init 脚本里完成。Go 进程只动态追加需要绕过的目标。
+
+## 12. Stats 与 LuCI
+
+`stats.Stats` 是事件 sink,使用原子计数器记录:
+
+- 当前连接数
+- HTTP 请求总数
+- UA 修改次数
+- 缓存命中并修改次数
+- 缓存命中但不修改次数
+
+`stats.FileReporter` 周期性读取快照并写入 `/tmp/UAmask.stats`。
+
+```mermaid
+flowchart LR
+ Events[events.Dispatcher] --> Stats[stats.Stats]
+ Stats --> Reporter[stats.FileReporter]
+ Reporter --> File["/tmp/UAmask.stats"]
+ File --> LuCI[openwrt/luci/model/cbi/UAmask.lua]
+```
+
+当前落盘指标包括:
+
+- `current_connections`
+- `total_requests`
+- `rps`
+- `successful_modifications`
+- `direct_passthrough`
+- `rule_processing`
+- `cache_hit_modify`
+- `cache_hit_pass`
+- `total_cache_ratio`
+
+LuCI 页面读取这个文件并展示运行状态、请求数、RPS、修改数和缓存效率。
+
+## 13. OpenWrt 集成
+
+OpenWrt 侧不是单纯打包壳,而是运行架构的一部分。
+
+主要文件:
+
+- `openwrt/package/Makefile`:构建并安装 Go daemon、init 脚本、默认配置、LuCI 文件。
+- `openwrt/root/etc/config/UAmask`:默认 UCI 配置。
+- `openwrt/root/etc/init.d/UAmask`:procd 生命周期、flags 转换、防火墙规则装配与清理。
+- `openwrt/luci/model/cbi/UAmask.lua`:配置页面与统计展示。
+- `openwrt/luci/controller/UAmask.lua`:LuCI 菜单入口。
+
+服务启动关系:
+
+```mermaid
+sequenceDiagram
+ participant LuCI as LuCI / UCI
+ participant Init as /etc/init.d/UAmask
+ participant Core as /usr/bin/UAmask
+ participant FW as nftables / iptables
+
+ LuCI->>Init: start / reload / stop
+ Init->>Init: detect_firewall()
+ Init->>Init: 从 UCI 读取配置
+ Init->>Init: 从 UCI 生成并校验 config.json
+ Init->>Core: procd 启动并传 -config
+ Init->>FW: 创建 REDIRECT 规则和 bypass set
+ Core->>FW: 运行中动态追加绕过目标
+ Core->>LuCI: 写 /tmp/UAmask.stats
+```
+
+`init.d` 会根据是否存在 `fw4` 选择 nftables 或 iptables:
+
+- nftables 路径:生成 `/tmp/UAmask_rules.nft`,注册为 firewall include,并创建 `inet fw4 UAmask_bypass_set`。
+- iptables 路径:创建 `UAmask_prerouting`、`UAmask_output` 链,并可创建 `ipset hash:ip,port`。
+
+当启用 `proxy_host` 时,init 脚本还会用 GID 规则避免 UA-Mask 自身流量和 OpenClash 流量再次被重定向。
+
+## 14. 唯一生产路径与阅读顺序
+
+旧的 `proxy.Processor`、`firewall.DecisionEngine` 和直接写防火墙的 sink 已删除。业务行为不得再绕过 `session -> event/state/policy/offload` 边界增加第二条路径。
+
+`proxy.Server` 只负责 Accept、worker pool、活跃 session 跟踪和 context 关闭;HTTP 检测、rewrite、drop、fallback 和事件发射全部由 `session.Manager` 负责。`firewall` 只保留系统执行边界,目标状态与决策不放回该包。
+
+建立代码图谱时按以下顺序阅读:
+
+阅读代码时,应优先从这些入口建立图谱:
+
+1. `core/cmd/UAmask/main.go`
+2. `core/internal/app/app.go`
+3. `core/internal/session/manager.go`
+4. `core/internal/rewrite/engine.go`
+5. `core/internal/events/dispatcher.go`
+6. `core/internal/state/store.go`
+7. `core/internal/policy/evaluator.go`
+8. `core/internal/offload/coordinator.go`
+9. `core/internal/firewall/executor.go`
+
+## 15. 扩展点
+
+当前代码最自然的扩展点在这些位置:
+
+- 新增 UA 匹配模式:扩展 `rewrite.Engine`。
+- 新增请求级行为:扩展 `policy.SessionPolicy` 与 `model.PolicyDecision`。
+- 调整非 HTTP 卸载策略:扩展 `state.Store` 的画像字段或 `policy.TargetPolicy`。
+- 支持新的防火墙后端:实现新的 `firewall.Applier` 或调整 `CommandApplier`。
+- 增加观测方式:给 `events.Dispatcher` 添加新的 sink,或替换/扩展 `stats.FileReporter`。
+- 实现真正的 flow-level offload:替换当前 `offload.NoopFlowOffloader`。
+
+## 16. 一句话总结
+
+当前 `UA-Mask` 可以理解成一条清晰的双管线:
+
+```mermaid
+flowchart LR
+ Conn[连接接入] --> Session[session 热路径]
+ Session --> Rewrite[HTTP UA 改写]
+ Rewrite --> Forward[转发上游]
+ Session --> Event[事件流]
+ Event --> Profile[目标画像]
+ Profile --> Policy[卸载策略]
+ Policy --> Firewall[动态写 set]
+ Firewall --> Bypass[后续流量绕过]
+```
+
+也就是:热路径负责尽快识别、改写、转发;控制路径异步观察流量特征,并把确定可以绕过的目标交给内核防火墙处理。
diff --git a/UA-Mask/docs/config.example.json b/UA-Mask/docs/config.example.json
new file mode 100644
index 000000000..e908a4145
--- /dev/null
+++ b/UA-Mask/docs/config.example.json
@@ -0,0 +1,53 @@
+{
+ "schema_version": 1,
+ "listen": {
+ "port": 12032,
+ "dial_timeout": "30s",
+ "client_keep_alive": "3m",
+ "upstream_keep_alive": "3m"
+ },
+ "rewrite": {
+ "user_agent": "FFF",
+ "mode": "keywords",
+ "keywords": [
+ "Windows",
+ "Linux",
+ "Android",
+ "iPhone",
+ "Macintosh",
+ "iPad",
+ "OpenHarmony"
+ ],
+ "whitelist": [],
+ "pattern": "(iPhone|iPad|Android|Macintosh|Windows|Linux)",
+ "partial_replace": false
+ },
+ "firewall": {
+ "backend": "nft",
+ "set_name": "UAmask_bypass_set",
+ "ua_whitelist": [],
+ "drop_on_match": false,
+ "whitelist_timeout": "24h",
+ "non_http": {
+ "enabled": false,
+ "threshold": 5,
+ "decision_delay": "60s",
+ "http_cooldown": "1h",
+ "timeout": "8h",
+ "cleanup_interval": "10m"
+ }
+ },
+ "observe": {
+ "log_level": "info",
+ "log_file": "/tmp/UAmask/UAmask.log",
+ "stats_file": "/tmp/UAmask.stats",
+ "stats_interval": "5s"
+ },
+ "performance": {
+ "profile": "medium",
+ "cache_size": 3000,
+ "buffer_size": 8192,
+ "pool_size": 500,
+ "gc_percent": 100
+ }
+}
diff --git a/UA-Mask/docs/img/after-spark-1.png b/UA-Mask/docs/img/after-spark-1.png
new file mode 100644
index 000000000..38bcbb79b
Binary files /dev/null and b/UA-Mask/docs/img/after-spark-1.png differ
diff --git a/UA-Mask/docs/img/after-spark-clash.png b/UA-Mask/docs/img/after-spark-clash.png
new file mode 100644
index 000000000..a581f71d9
Binary files /dev/null and b/UA-Mask/docs/img/after-spark-clash.png differ
diff --git a/UA-Mask/docs/img/after-think-clash.png b/UA-Mask/docs/img/after-think-clash.png
new file mode 100644
index 000000000..819dcc3f9
Binary files /dev/null and b/UA-Mask/docs/img/after-think-clash.png differ
diff --git a/UA-Mask/docs/img/before-bad.png b/UA-Mask/docs/img/before-bad.png
new file mode 100644
index 000000000..88ac5075f
Binary files /dev/null and b/UA-Mask/docs/img/before-bad.png differ
diff --git a/UA-Mask/docs/img/before-think.png b/UA-Mask/docs/img/before-think.png
new file mode 100644
index 000000000..ec8b01b7d
Binary files /dev/null and b/UA-Mask/docs/img/before-think.png differ
diff --git a/UA-Mask/docs/img/ipset-love.png b/UA-Mask/docs/img/ipset-love.png
new file mode 100644
index 000000000..dcc421857
Binary files /dev/null and b/UA-Mask/docs/img/ipset-love.png differ
diff --git a/UA-Mask/docs/img/ipset-spark.png b/UA-Mask/docs/img/ipset-spark.png
new file mode 100644
index 000000000..d1c709ca1
Binary files /dev/null and b/UA-Mask/docs/img/ipset-spark.png differ
diff --git a/UA-Mask/docs/img/ipset-think.png b/UA-Mask/docs/img/ipset-think.png
new file mode 100644
index 000000000..64709f927
Binary files /dev/null and b/UA-Mask/docs/img/ipset-think.png differ
diff --git a/UA-Mask/docs/img/screemshot_general.jpg b/UA-Mask/docs/img/screemshot_general.jpg
new file mode 100644
index 000000000..d35d0557b
Binary files /dev/null and b/UA-Mask/docs/img/screemshot_general.jpg differ
diff --git a/UA-Mask/docs/img/screemshot_general_1.jpg b/UA-Mask/docs/img/screemshot_general_1.jpg
new file mode 100644
index 000000000..c92ce63c1
Binary files /dev/null and b/UA-Mask/docs/img/screemshot_general_1.jpg differ
diff --git a/UA-Mask/docs/img/screenshot_network.jpg b/UA-Mask/docs/img/screenshot_network.jpg
new file mode 100644
index 000000000..cf1788cb2
Binary files /dev/null and b/UA-Mask/docs/img/screenshot_network.jpg differ
diff --git a/UA-Mask/docs/img/structure-all.png b/UA-Mask/docs/img/structure-all.png
new file mode 100644
index 000000000..af45059b7
Binary files /dev/null and b/UA-Mask/docs/img/structure-all.png differ
diff --git a/UA-Mask/docs/tutorial.md b/UA-Mask/docs/tutorial.md
new file mode 100644
index 000000000..76ceddd92
--- /dev/null
+++ b/UA-Mask/docs/tutorial.md
@@ -0,0 +1,452 @@
+# UA-Mask 使用教程
+
+面向 OpenWrt 用户的一站式配置与排错指南,帮助你从 0 到 1 搭好 UA-Mask 并与 OpenClash 等共存。
+
+## 目录
+
+- [快速开始(5 分钟)](#快速开始-5-分钟)
+- [界面字段详解(与 LuCI 一一对应)](#界面字段详解与-luci-一一对应)
+- [与 OpenClash 共存](#与-openclash-共存)
+- [运行统计详解(页面“状态”卡片)](#运行统计详解页面状态卡片)
+- [流量卸载(基于 ipset/nfset)](#流量卸载基于-ipsetnfset)
+- [防检测策略与推荐组合](#防检测策略与推荐组合)
+- [匹配规则编写示例](#匹配规则编写示例)
+- [验证与排错(必看)](#验证与排错必看)
+ - [抓包定位 UA 泄露(tcpdump + Wireshark)](#抓包定位-ua-泄露tcpdump--wireshark)
+- [工作模式(性能预设)](#工作模式性能预设)
+- [安装与编译](#安装与编译)
+- [附录:UCI 配置参考(/etc/config/UAmask)](#附录uci-配置参考etcconfiguAmask)
+- [关于更名与项目理念(摘要)](#关于更名与项目理念摘要)
+- [项目发展历史](#项目演进心路历程)
+## 快速开始(5 分钟)
+
+1) 安装(任选其一)
+
+- 预编译包:到 Releases 下载与你设备架构匹配的 `.ipk`,然后在路由器上安装。
+ - iptables 用户若要启用“流量卸载”,请先安装 ipset。
+
+ ```sh
+ opkg update
+ opkg install UAmask_*.ipk
+ # 仅 iptables 用户且需要 set 绕过时
+ opkg install ipset
+ ```
+
+2) 打开 Web 界面:系统 -> 服务 -> UA-Mask,勾选“启用”,先别急着保存。
+
+3) 常规设置(General)
+
+- 性能预设:默认 Medium(大多设备够用)。
+- User-Agent 标识(ua):填一个“通用浏览器 UA”,例如:
+ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119 Safari/537.36
+- 匹配规则:保留“基于关键词(推荐)”。关键词默认已覆盖常见系统(Windows/Android/iPhone…)。
+
+4) 网络与防火墙(Network)
+
+- 监听端口:12032(默认)。
+- 监听接口:br-lan(默认)。
+- 代理主机流量:如果你的路由“本机”也需要被修改 UA,才开启。与 OpenClash 共存时建议遵循下文“完美分流方案”。
+- 绕过目标端口:默认 22 443。
+ - 若你“安全优先”,建议移除 443(原因见下文“安全优先方案”)。
+- 启用流量卸载(可选,高性能):iptables 用户务必先装 ipset。
+
+5) 保存并应用。运行状态显示“运行中”。
+
+6) 验证
+
+- 从 LAN 内的一台设备访问[ua检验](http://ua.233996.xyz/),应看到 UA 已变为你设定的值。
+
+提示:UA-Mask 仅在“明文 HTTP”中可见效果;HTTPS 流量不会修改 UA(TLS 加密后 UA 不可见)。
+
+---
+
+## 界面字段详解(与 LuCI 一一对应)
+
+### 1. 常规设置(General)
+
+- 性能预设(operating_profile)
+ - Low:200 并发,2000 LRU,8K 缓冲,内存占用最低。
+ - Medium:500 并发,3000 LRU,8K 缓冲,均衡(默认)。
+ - High:1000 并发,5000 LRU,8K 缓冲,高吞吐。
+ - 自定义(custom):自行设定缓冲/池/缓存/GOGC。
+- I/O 缓冲区大小(buffer_size):每连接读写缓冲,越大吞吐越高但更占内存。默认 8192。
+- 工作协程池大小(pool_size):限制最大并发、降低 GC;为 0 则每连接独立协程。
+- LRU 缓存大小(cache_size):提升命中率;约每 1000 条 ~300KB RAM。
+- GOGC:Go GC 比例,使用协程池时建议保持 100。
+- User-Agent 标识(ua):实际替换成的 UA 字符串。
+- 匹配规则(match_mode)
+ - 基于关键词:UA 包含任一关键词即整串替换(性能最佳)。
+ - 基于正则:配合“替换方式”使用(完整替换/部分替换)。
+ - 修改所有流量:强制全改(一般不必)。
+- 关键词列表(keywords):逗号分隔,命中即整串替换。
+- 正则表达式(ua_regex):例如 (iPhone|Android|Windows)。
+- 替换方式(replace_method):正则命中后“完整替换”或“部分替换”。
+- User-Agent 白名单(whitelist):这些 UA 命中后直接放行(逗号分隔)。
+
+### 2. 网络与防火墙(Network)
+
+- 监听端口(port):默认 12032。
+- 监听接口(iface):默认 br-lan,可多接口空格分隔。
+- 启用流量卸载(enable_firewall_set):创建 ipset/nfset 以“跳过”特定 ip:port,提高性能(见下节“流量卸载”)。
+- UA 关键词白名单(Firewall_ua_whitelist):命中这些 UA 关键词的连接,将目标 ip:port 动态加入 set 卸载(典型如 Steam)。
+- 绕过非http流量(Firewall_ua_bypass):当识别为“非 HTTP”后,交由决策器评估并暂时卸载该 ip:port,降低负载。
+- 匹配时断开连接(Firewall_drop_on_match):命中“UA 关键词白名单”时立即断开,强制新连接走卸载路径,加速生效。
+- 代理主机流量(proxy_host):是否也代理路由器自身的流量;为避免与其他代理回环冲突,若不需要可关闭。
+- 绕过 GID(bypass_gid):默认 65533,用于豁免自身流量;脚本同时豁免 OpenClash 的 65534,防循环。
+- 绕过目标端口(bypass_ports):默认“22 443”。
+- 绕过目标 IP(bypass_ips):默认局域网与保留网段。
+
+### 3. 高级设置(决策器)
+
+- 决策器设置(firewall_advanced_settings):开启后可自定义卸载决策逻辑参数。
+- 非 HTTP 判定阈值(firewall_nonhttp_threshold):将某 ip:port 判定为“非 HTTP”前需累计的非 HTTP 事件次数(默认 5)。
+- 决策延迟时间(秒)(firewall_decision_delay):达到阈值后,延迟多久再做卸载决策,避免误判(默认 60s)。
+- 防火墙规则超时(秒)(firewall_timeout):加入 set 的元素超时(默认 28800 秒=8 小时)。
+
+说明:
+- 决策过程中如出现 HTTP 活动,会触发“HTTP 豁免期”,对该 ip:port 的卸载判定被一票否决并延后,进一步降低误判与 UA 泄露风险。
+
+### 4. 应用日志(Softlog)
+
+- 日志等级(log_level):debug/info/warn/error 等。
+- 应用日志路径(log_file):建议放 /tmp,避免刷写闪存。
+- 查看/清空日志:页面内置“查看最近 200 行”与“一键清空”。
+
+---
+
+## 与 OpenClash 共存
+
+UA-Mask 已对 Clash 做了共存优化:即使开启了“代理本机”,也会自动豁免 Clash 的流量(GID 65534),避免循环。
+
+### 完美分流方案(推荐)
+
+- 配置:UA-Mask(代理本机:关闭)+ OpenClash(代理本机:开启,绕过大陆:开启)
+- 效果:非 22/443 端口的 HTTP 流量先由 UA-Mask 修改 UA,再交给 OpenClash;OpenClash 将国内直连、国外走代理。
+- 备注:若关闭 OpenClash 的“绕过大陆”,则所有 HTTP/HTTPS 都会先过 UA-Mask 再过 OpenClash。
+
+```mermaid
+graph LR
+ A[LAN 流量] --> B{防火墙规则}
+ B -->|22 443 端口| C{OpenClash规则}
+ B -->|其他端口| D[UA-Mask]
+
+ C -->|国内流量| E[OUTPUT 直连]
+ C -->|需要代理| F[Clash 核心]
+
+ D --"本机代理开启"--> C{OpenClash规则}
+```
+
+### 最大兼容方案(不推荐)
+
+- 配置:UA-Mask(代理本机:关闭)+ OpenClash(代理本机:关闭,绕过大陆:开启)
+- 效果:OpenClash 仅代理 UA-Mask 绕过的端口(默认 22/443)。
+- 缺陷:其他端口(如 80)的流量在被 UA-Mask 修改后全部直连,不走 OpenClash。
+
+```mermaid
+graph LR
+ A[LAN 流量] --> B{防火墙规则}
+ B -->|端口 22, 443| C{openclash规则}
+ B -->|非 22, 443 端口| D[UA-Mask]
+
+ C -->|国内流量| E[OUTPUT 直连]
+ C -->|需要代理| F[Clash 核心]
+
+ D --> E[OUTPUT 直连]
+```
+
+验证共存:
+
+- iptables 机型:确保 UA-Mask 的 PREROUTING 规则在链首(服务重启会 -I 插入到最前)。
+ ```sh
+ iptables -t nat -nvL PREROUTING --line-numbers | sed -n '1,30p'
+ iptables -t nat -S UAmask_prerouting
+ ```
+- nftables 机型:
+ ```sh
+ fw4 print ruleset | grep -n UAmask -n
+ nft list set inet fw4 UAmask_bypass_set 2>/dev/null || true
+ ```
+
+---
+
+## 运行统计详解(页面“状态”卡片)
+
+- 当前连接:实时并发连接数。
+- 请求总数:累计 HTTP 请求数。
+- 处理速率:RPS((当前总数-上次总数)/刷新间隔)。
+- 成功修改:完成 UA 修改的请求数。
+- 直接放行:因白名单/不匹配而放行的请求数。
+- 规则处理:真正执行关键词/正则匹配(未命中缓存)的请求数。
+- 缓存(修改):缓存命中且判定“修改”的次数。
+- 缓存(放行):缓存命中且判定“放行”的次数。
+- 总缓存率:(缓存(修改)+缓存(放行)) / HTTP 请求数。
+
+---
+
+## 流量卸载(基于 ipset/nfset)
+
+为降低 CPU 负载、提升高并发性能,引入“流量卸载(set 绕过)”。启用路径:服务 -> UA-Mask -> 网络与防火墙 -> 勾选“启用流量卸载”。
+
+iptables 用户:请先安装 ipset。
+
+包含两种卸载:
+
+1) 绕过非 http 流量(Firewall_ua_bypass)
+
+- 原理:识别到某 ip:port 多次出现“非 HTTP”连接后,进入“决策延迟”观察期;若期间无 HTTP 活动,一次性加入 UAmask_bypass_set,超时后自动移除(默认 8 小时,可在“防火墙规则超时(秒)”调整)。
+- 安全:若观察期内出现 HTTP 活动,会触发“HTTP 豁免期”,之前的非 HTTP 累积分数被一票否决,避免误判导致 UA 泄露。
+- 效果:该 ip:port 的后续新连接被防火墙直接放行,不再进入 UA-Mask 检测,显著减负。
+
+2) UA 关键词白名单(Firewall_ua_whitelist)
+
+- 原理:若某连接的 UA 命中你配置的“UA 关键词”,立即把该目标 ip:port 加入 set 卸载。
+- 适用:高带宽、长连接但 UA“无敏感标识”的流量(如 Steam 下载)。
+- 示例关键词:Valve/Steam, steam, steamworks, 360pcdn, ByteDancePcdn(按需取舍)。
+- 可选:匹配时断开连接(Firewall_drop_on_match)。命中后立刻断开现有连接,促使后续连接直接走卸载路径。可能会出现应用端“需重试”的提示,非苛求性能时可不启用。
+
+重要提醒(性能 vs 风险)
+
+- 被加入卸载 set 的 ip:port 在超时时间内会“完全绕过 UA-Mask”。这有可能导致“真实 UA 泄露”。请按你的环境权衡使用,尽量结合“非 HTTP 决策器”与谨慎的白名单关键词。
+
+---
+
+## 防检测策略与推荐组合
+
+方案一:性能优先(大多数环境)
+
+- 匹配规则:正则 + 部分替换(兼容性好)。
+- 启用流量卸载;同时开启“绕过非 http 流量”和“UA 关键词白名单”(加入 Steam 等关键词)。
+
+方案二:安全优先(严格环境)
+
+- 关闭流量卸载与 UA 关键词白名单。
+- 检查“绕过目标端口”中移除 443,以防极少数明文 HTTP 走 443 导致 UA 泄露。
+
+---
+
+## 匹配规则编写示例
+
+- 关键词模式(整串替换)
+ - 例:Windows,Linux,Android,iPhone,Macintosh,iPad,OpenHarmony
+- 正则模式
+ - 完整替换:ua_regex = (Windows NT|Android|iPhone|iPad|Macintosh)
+ - 部分替换:把 UA 中的“Android [0-9.]+”替换为“Android 10”
+ - ua_regex: Android [0-9.]+
+ - 替换方式:部分替换
+- 修改所有流量
+ - 在 HTTPS 上也不会生效(因为 UA 位于明文 HTTP 头部)。
+
+---
+
+## 验证与排错(必看)
+
+基础检查
+
+- 进程:
+ ```sh
+ pidof UAmask || echo not running
+ ```
+- 日志:
+ ```sh
+ logread -e UAmask | tail -n 50
+ tail -n 200 /tmp/UAmask/UAmask.log 2>/dev/null
+ ```
+
+防火墙规则
+
+- iptables:
+ ```sh
+ iptables -t nat -S UAmask_prerouting
+ iptables -t nat -S UAmask_output
+ ipset list UAmask_bypass_set 2>/dev/null || true
+ ```
+- nftables:
+ ```sh
+ fw4 print ruleset | sed -n '/UAmask_/,+15p'
+ nft list set inet fw4 UAmask_bypass_set 2>/dev/null || true
+ ```
+
+常见问题
+
+- “HTTPS 看不到效果”:属正常,UA 仅在 HTTP 明文里;用 http://httpbin.org/user-agent 验证。
+- “访问变慢/CPU 高”:尽量使用“关键词模式”;或改为 Medium/Low 预设;减少正则复杂度。
+- “与 OpenClash 冲突/循环”:按本文“完美分流方案”设置;必要时重启 UA-Mask 使其规则位于更前面;保留绕过 GID 65534。
+- “Steam 下载 UA 泄露担忧”:别启用“UA 关键词白名单”或仅在下载时短时启用;或不用 set 卸载。
+- “运行统计不更新”:服务需运行一段时间才生成 /tmp/UAmask.stats;先确认进程与日志。
+- “启动失败”:确认 /usr/bin/UAmask 存在;若启用流量卸载,iptables 机型需安装 ipset。
+- “配置校验失败”:OpenWrt 会从 UCI 生成 `/var/run/UAmask/config.json`,可执行 `/usr/bin/UAmask -check-config -config /var/run/UAmask/config.json` 查看校验结果。
+- 查看 Go core 最终生效配置:`/usr/bin/UAmask -dump-effective-config -config /var/run/UAmask/config.json`。该 JSON 是运行时生成文件,请修改 UCI/LuCI,而不是直接编辑它。
+
+---
+
+### 抓包定位 UA 泄露(tcpdump + Wireshark)
+
+目标:在 WAN 侧抓包并用 Wireshark 检查是否存在“未被 UA-Mask 修改的明文 HTTP 请求”。
+
+1) 确认 WAN 接口名(可能是 pppoe-wan/wan/eth0.2 等)
+```sh
+WAN="$(uci -q get network.wan.device || uci -q get network.wan.ifname || ifstatus wan | jsonfilter -e '@.device')"
+echo "$WAN"
+```
+
+2) 抓取样本到路由器 /tmp/capture.pcap
+- 轻量(常见明文 HTTP 端口)
+```sh
+tcpdump -i "$WAN" -s 0 -U -w /tmp/capture.pcap 'tcp port 80 or tcp port 8080 or tcp port 8000'
+```
+- 全量(30 秒,流量大时慎用)
+```sh
+timeout 30s tcpdump -i "$WAN" -s 0 -U -w /tmp/capture.pcap tcp
+# 无 timeout 命令时,按 Ctrl+C 结束
+```
+
+3) 在 LAN 侧复现访问场景,然后停止抓包
+
+4) 将抓包文件拉到本机
+- Windows PowerShell(启用 OpenSSH 的情况下)
+```powershell
+scp root@192.168.1.1:/tmp/capture.pcap .
+```
+- 或使用 WinSCP/pscp.exe 图形或命令行工具
+
+5) Wireshark 分析 UA
+- 打开 capture.pcap,输入显示过滤器:
+```
+http.request and http.user_agent
+```
+- 将 http.user_agent 设置为列:在任意包的“http.user_agent”字段上右键 -> Apply as Column
+- 若 HTTP 使用的不是 80 端口:
+ - 方式 A:右键该端口的一个 TCP 包 -> Decode As... -> 选择 HTTP
+ - 方式 B:Preferences -> Protocols -> HTTP -> TCP Ports,追加 8080,8000,8880,3128 等
+- 判断是否存在“未被替换”的 UA(与 UA-Mask 配置值不一致的、或带有设备真实标识的 UA)
+
+6) 若确认存在 UA 泄露,定位与修复
+- 检查是否被加入卸载 set(会直接跳过 UA-Mask):
+```sh
+# iptables 机型(ipset)
+ipset list UAmask_bypass_set 2>/dev/null | sed -n '1,100p'
+# nftables 机型
+nft list set inet fw4 UAmask_bypass_set 2>/dev/null
+```
+- 临时移除可疑 ip:port:
+```sh
+# iptables 机型(hash:ip,port)
+ipset del UAmask_bypass_set ,
+# nftables 机型(ip . port 二元组)
+nft delete element inet fw4 UAmask_bypass_set { . }
+```
+- 调整配置后复测:
+ - 关闭“绕过非 http 流量”
+ - 清空或精简“UA 关键词白名单”(避免把高带宽下载目标加到卸载)
+ - 安全优先时,在“绕过目标端口”移除 443
+ - 确认 UA-Mask 规则优先级在前(见“与 OpenClash 共存/验证共存”命令)
+ - 尽量使用“关键词模式”或“正则+部分替换”提升命中率与兼容性
+
+## 工作模式(性能预设)
+
+- Low:2000 LRU、8K 缓冲,协程池 200 —— 内存低。
+- Medium:3000 LRU、8K 缓冲,协程池 500 —— 平衡(默认)。
+- High:5000 LRU、8K 缓冲,协程池 1000 —— 高吞吐。
+- 自定义:按需设定 cache_size、buffer_size、pool_size、GOGC。
+
+---
+
+## 安装与编译
+
+### 预编译包
+
+1. 前往 Releases。
+2. 下载匹配架构的 `.ipk`。
+3. 在路由器上安装(见“快速开始”)。
+
+### 源码编译
+
+1. 将本项目克隆到 OpenWrt 源码的 package/luci 目录。
+2. 先完整编译过一次固件更稳(make download;并发编译如 make -j8)。
+3. 编译本包:
+
+```sh
+make clean
+make package/UA-Mask/compile
+```
+
+生成位置:$(rootdir)/bin/packages/$(targetdir)/base/ 下的 UAmask_xxx.ipk
+
+固件内置:menuconfig 中 network/Web Servers/Proxies/UAmask 选中 *。
+
+---
+
+## 附录:UCI 配置参考(/etc/config/UAmask)
+
+示例(默认值可能随版本略有调整):
+
+```conf
+config 'UAmask' 'enabled'
+ option enabled '0'
+
+config 'UAmask' 'main'
+ option port '12032'
+ option ua 'FFF'
+ option log_level 'info'
+ option iface 'br-lan'
+ option proxy_host '0'
+ option bypass_gid '65533'
+ option bypass_ports '22 443'
+ option bypass_ips '172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 169.254.0.0/16'
+ option ua_regex '(iPhone|iPad|Android|Macintosh|Windows|Linux|Apple|Mac OS X|Mobile)'
+ option whitelist ''
+ option log_file '/tmp/UAmask/UAmask.log'
+
+ # 运行参数(预设/自定义)
+ # option operating_profile 'Medium|Low|High|custom'
+ # option cache_size '3000'
+ # option buffer_size '8192'
+ # option pool_size '500'
+ # option gogc_value '100'
+ # option match_mode 'keywords|regex|all'
+ # option keywords 'Windows,Linux,Android,iPhone,Macintosh,iPad,OpenHarmony'
+ # option replace_method 'full|partial'
+
+ # 流量卸载
+ # option enable_firewall_set '0|1'
+ # option Firewall_ua_whitelist ''
+ # option Firewall_ua_bypass '0|1'
+ # option Firewall_drop_on_match '0|1'
+
+ # 决策器(高级设置开启后生效)
+ # option firewall_advanced_settings '0|1'
+ # option firewall_nonhttp_threshold '5'
+ # option firewall_decision_delay '60'
+ # option firewall_timeout '28800'
+```
+
+---
+
+## 关于更名与项目理念
+
+
+- 更名:UA3F-tproxy 演进为 UA-Mask,突出“精简、高性能、专注 UA 修改”。
+- 目标:硬路由可用、软路由高吞吐;热路径优化、零拷贝、缓存与池化,兼顾稳定与性能。
+
+我们会持续打磨性能与易用性,让每台路由器都能一键获得更安全、稳定的 UA Mask 能力。
+
+## 项目演进心路历程
+
+1. **此前方案**:最开始的方案是 `防火墙 -> OpenClash -> SOCKS5 -> UA3F`。这个方案虽然可行,但流量链路过长,导致 CPU 负载轻易达到瓶颈,直到后来萌生了自行实现的想法。
+ 
+ 
+
+2. **第一次优化**:为了解决性能问题,我们想到绕开 OpenClash,直接通过防火墙将流量转发给 UA-Mask(彼时还叫ua3f-tproxy)。这大大提升了处理效率。
+ 
+
+3. **与 OpenClash 共存**:但新问题随之而来——如何与 OpenClash 共存?我们设计了新的方案:在 PREROUTING 链将流量先交给 UA-Mask,处理后再由 OUTPUT 链的 OpenClash 进行分流,并利用“绕过大陆”功能避免不必要的流量进入 Clash 核心。
+ 
+ 
+
+4. **引入 ipset/nfset 卸载**:我们发现,超过 90% 的流量(如 Steam 下载)并不需要修改 UA。因此,引入 `ipset/nfset`,将这些高带宽流量动态加入卸载列表,使其直接通过内核转发,不再消耗 UA-Mask 的资源,实现了性能的飞跃。
+ 
+ 
+ 
+
+5. **最终架构**:结合以上所有优化,形成了当前稳定、高效、兼容性强的最终架构。
+ 
diff --git a/UA-Mask/go.work b/UA-Mask/go.work
new file mode 100644
index 000000000..2e4a9e8ca
--- /dev/null
+++ b/UA-Mask/go.work
@@ -0,0 +1,3 @@
+go 1.21
+
+use ./core
diff --git a/UA-Mask/openwrt/README.md b/UA-Mask/openwrt/README.md
new file mode 100644
index 000000000..c7804c7a4
--- /dev/null
+++ b/UA-Mask/openwrt/README.md
@@ -0,0 +1,38 @@
+# OpenWrt Layout
+
+- `package/`:OpenWrt `Makefile`,负责把 `core/` 编译为可安装包。
+- `root/`:打包时直接铺到目标系统根目录的文件,例如 `etc/init.d/` 和 `etc/config/`。
+- `luci/`:LuCI 控制器与 CBI 页面。
+
+运行时仍以 `/etc/config/UAmask` 为用户配置真相源;`init.d` 会将 core 所需字段转换成 `/var/run/UAmask/config.json`,校验通过后使用 `UAmask -config ` 启动。
+
+## 构建模式
+
+OpenWrt 包保留两种构建模式:
+
+- 默认模式由 OpenWrt 的 Go package infrastructure 从 `core/` 编译。
+- `UAMASK_PREBUILT=/absolute/path/to/UAmask` 使用已经交叉编译的静态二进制,SDK 只负责生成与目标架构匹配的 IPK/APK 元数据和安装包。
+
+发布和 Canary 构建统一从仓库根目录执行:
+
+```sh
+./scripts/build-release.sh \
+ --platform openwrt \
+ --arch arm64 \
+ --sdk-dir /path/to/openwrt-sdk \
+ --openwrt-version 23.05.5 \
+ --target armsr \
+ --subtarget armv8 \
+ --package-arch aarch64_generic \
+ --package-format ipk \
+ --version 0.4.3-canary.example \
+ --output-dir dist
+```
+
+脚本会同时生成包、`.sha256` 和 `.metadata.json`。SDK 必须事先下载并校验;仓库不会在构建过程中隐式下载未固定的工具链。
+
+为避免 artifact 与 commit 对不上,脚本默认拒绝 dirty worktree。`--allow-dirty` 只用于本地调试,并会在 metadata 中明确记录 `"dirty": true`;可交付 Canary 和 release 不得使用该参数。
+
+已核验的 SDK URL、SHA256、target/subtarget、Go 架构和包格式记录在 `.github/openwrt-sdk-matrix.json`。矩阵以 OpenWrt 23.05.5 IPK 通道覆盖本次已验证的设备代际,以 25.12.5 APK 通道覆盖新包管理器代际;每个条目都只接受官方固定 release SDK。
+
+Canary 和 PR build 的二进制版本会包含提交标识。APK 的上游版本格式只接受数值点分版本加 `-rN` release,因此非 release APK 的包版本保持为 `VERSION`;其完整二进制版本、commit、SDK target 和 SHA256 都写入同名 `.metadata.json`,且不会被发布到 GitHub Release。
diff --git a/UA-Mask/openwrt/luci/controller/UAmask.lua b/UA-Mask/openwrt/luci/controller/UAmask.lua
new file mode 100644
index 000000000..a8c3f17b9
--- /dev/null
+++ b/UA-Mask/openwrt/luci/controller/UAmask.lua
@@ -0,0 +1,5 @@
+module("luci.controller.UAmask", package.seeall)
+
+function index()
+ entry({"admin", "services", "UAmask"}, cbi("UAmask"), "UA MASK", 1)
+end
\ No newline at end of file
diff --git a/UA-Mask/openwrt/luci/model/cbi/UAmask.lua b/UA-Mask/openwrt/luci/model/cbi/UAmask.lua
new file mode 100644
index 000000000..46f2b49a3
--- /dev/null
+++ b/UA-Mask/openwrt/luci/model/cbi/UAmask.lua
@@ -0,0 +1,303 @@
+local uci = require("luci.model.uci").cursor()
+local nixio = require("nixio")
+local luci_sys = require("luci.sys")
+
+local stats_cache = nil
+local stats_file = "/tmp/UAmask.stats"
+
+local function get_stats()
+ if stats_cache then
+ return stats_cache
+ end
+
+ local f = io.open(stats_file, "r")
+ if not f then
+ return {}
+ end
+
+ stats_cache = {}
+ for line in f:lines() do
+ local key, val = line:match("([^:]+):(.*)")
+ if key and val then
+ stats_cache[key] = val
+ end
+ end
+ f:close()
+ return stats_cache
+end
+
+-- 辅助函数,用于从缓存中获取特定值
+local function get_stat_value(key)
+ local stats = get_stats()
+ return stats[key] or "0"
+end
+
+UAmask = Map("UAmask",
+ "UA-MASK",
+ [[
+
+ 版本:0.4.3
+
+ 用于修改 User-Agent 的透明代理,使用 REDIRECT 技术实现。
+
+ ]]
+)
+
+enable = UAmask:section(NamedSection, "enabled", "UAmask", "状态")
+main = UAmask:section(NamedSection, "main", "UAmask", "设置")
+
+enable:option(Flag, "enabled", "启用")
+status = enable:option(DummyValue, "status", "运行状态")
+status.rawhtml = true
+status.cfgvalue = function(self, section)
+ local pid = luci_sys.exec("pidof UAmask")
+ if pid == "" then
+ return "" .. "未运行" .. " "
+ else
+ return "" .. "运行中" .. " "
+ end
+end
+stats_display = enable:option(DummyValue, "stats_display", "运行统计")
+stats_display.rawhtml = true
+stats_display.cfgvalue = function(self, section)
+ local pid = luci_sys.exec("pidof UAmask")
+ if pid == "" then
+ return "(服务未运行时不统计) "
+ end
+
+ local stats = get_stats()
+
+ -- 第一行:负载与性能
+ local connections = stats["current_connections"] or "0"
+ local total_reqs = stats["total_requests"] or "0"
+ local rps = stats["rps"] or "0.00"
+
+ -- 第二行:流量分类
+ local modified = stats["successful_modifications"] or "0"
+ local passthrough = stats["direct_passthrough"] or "0"
+ local rule_proc = stats["rule_processing"] or "0"
+
+ -- 第三行:缓存效率
+ local cache_mod = stats["cache_hit_modify"] or "0"
+ local cache_pass = stats["cache_hit_pass"] or "0"
+ local cache_ratio = stats["total_cache_ratio"] or "0.00"
+
+ return string.format(
+ "当前连接: %s | 请求总数: %s | 处理速率: %s RPS " ..
+ "成功修改: %s | 直接放行: %s | 规则处理: %s " ..
+ "缓存(修改): %s | 缓存(放行): %s | 总缓存率: %s%%",
+ connections, total_reqs, rps,
+ modified, passthrough, rule_proc,
+ cache_mod, cache_pass, cache_ratio
+ )
+end
+
+main:tab("general", "常规设置")
+main:tab("network", "网络与防火墙")
+main:tab("advanced", "高级设置")
+main:tab("softlog", "应用日志")
+
+-- === Tab 1: 常规设置(UA 相关)===
+-- 运行模式
+operating_profile = main:taboption("general", ListValue, "operating_profile", "性能预设",
+ "选择性能预设。 " ..
+ "Low: 适合 128MB 路由器,支持并发 200 连接 "..
+ "Medium: 适合 256MB-512MB 路由器,支持并发 500 连接 "..
+ "High: 适合软路由或 1GB 以上路由器,支持并发 1000 连接 "..
+ "注意: 超过限制的连接将等待,这可以用来防止突发的连接压垮路由器 "..
+ "我们尽可能避免内存 GC,确保高性能与降低峰值,缺点是内存占用会稍高。"
+)
+operating_profile:value("Low", "低(Low)")
+operating_profile:value("Medium", "中(Medium)")
+operating_profile:value("High", "高(High)")
+operating_profile:value("custom", "自定义")
+operating_profile.default = "Medium"
+
+buffer_size = main:taboption("general", Value, "buffer_size", "I/O 缓冲区大小(字节)")
+buffer_size:depends("operating_profile", "custom")
+buffer_size.datatype = "uinteger"
+buffer_size.default = "8192"
+buffer_size.description = "每个连接使用的缓冲区大小,单位为字节。较大的缓冲区有助于提升吞吐性能。"
+
+pool_size = main:taboption("general", Value, "pool_size", "工作协程池大小")
+pool_size:depends("operating_profile", "custom")
+pool_size.datatype = "uinteger"
+pool_size.default = "0"
+pool_size.description = "工作协程池的大小。设为 0 则每个连接创建协程,使用协程池会限制最大并发,但能减少 GC。 最大 RAM 估计:pool_size*(2*buffer_size) + cache_ram。 若 pool_size 设为 0,则最大 RAM 估计:连接数*(2*buffer_size) + cache_ram。"
+
+cache_size = main:taboption("general", Value, "cache_size", "LRU 缓存大小")
+cache_size:depends("operating_profile", "custom")
+cache_size.datatype = "uinteger"
+cache_size.default = "1000"
+cache_size.description = "LRU 缓存大小。缓存更大命中率更高,预估每 1000 条占用约 300KB 内存。"
+
+gogc_value=main:taboption("general", Value, "gogc_value", "Go 垃圾回收参数(GOGC)")
+gogc_value:depends("operating_profile", "custom")
+gogc_value.datatype = "uinteger"
+gogc_value.default = "100"
+gogc_value.description = "Go 语言的垃圾回收参数(默认 100)。注意:当使用协程池,GC 压力已经很低,强烈建议保持 100 默认值。 "
+
+ua = main:taboption("general", Value, "ua", "User-Agent 标识")
+ua.default = "FFF"
+ua.description = "用于替换的 User-Agent 字符串。"
+
+-- 重构:匹配规则
+match_mode = main:taboption("general", ListValue, "match_mode", "匹配规则",
+ "定义如何确定哪些流量需要被修改。")
+match_mode:value("keywords", "基于关键词(最快,推荐)")
+match_mode:value("regex", "基于正则表达式(灵活)")
+match_mode:value("all", "修改所有流量(强制)")
+match_mode.default = "keywords"
+
+-- 仅在 keywords 模式下显示
+keywords = main:taboption("general", Value, "keywords", "关键词列表")
+keywords:depends("match_mode", "keywords")
+keywords.default = "Windows,Linux,Android,iPhone,Macintosh,iPad,OpenHarmony"
+keywords.description = "当 UA 包含列表中的任意关键词时,替换整个 UA 为目标值。用逗号分隔。"
+
+-- 仅在 regex 模式下显示
+ua_regex = main:taboption("general", Value, "ua_regex", "正则表达式")
+ua_regex:depends("match_mode", "regex")
+ua_regex.default = "(iPhone|iPad|Android|Macintosh|Windows|Linux)"
+ua_regex.description = "用于匹配 User-Agent 的正则表达式。"
+
+-- 仅在 regex 模式下显示
+replace_method = main:taboption("general", ListValue, "replace_method", "替换方式")
+replace_method:depends("match_mode", "regex")
+replace_method:value("full", "完整替换")
+replace_method:value("partial", "部分替换(仅替换匹配内容)")
+replace_method.default = "full"
+replace_method.description = "完整替换: 将整个 UA 替换为新值。部分替换: 仅将 UA 中被正则匹配到的部分替换为新值。"
+
+whitelist = main:taboption("general", Value, "whitelist", "User-Agent 白名单")
+whitelist.placeholder = ""
+whitelist.description = "指定不进行替换的 User-Agent,用逗号分隔(如:MicroMessenger Client,ByteDancePcdn)。"
+
+-- === Tab 2: 网络与防火墙(网络、日志等级、防火墙相关)===
+
+port = main:taboption("network", Value, "port", "监听端口")
+port.default = "12032"
+port.datatype = "port"
+
+iface = main:taboption("network", Value, "iface", "监听接口")
+iface.default = "br-lan"
+iface.description = "指定监听的 LAN 口。"
+
+enable_firewall_set = main:taboption("network", Flag, "enable_firewall_set", "启用流量卸载")
+enable_firewall_set.default = 0
+enable_firewall_set.description = "启用后,将动态绕过特定目标 IP 和端口的组合,不会再进入 UAmask ,这将大幅提升性能,实现内核级优化。 如果您使用 iptables,请确保安装 ipset 软件包。"
+
+Firewall_ua_bypass=main:taboption("network", Flag, "Firewall_ua_bypass", "绕过非http流量")
+Firewall_ua_bypass:depends("enable_firewall_set", "1")
+Firewall_ua_bypass.description = "启用后,绕过使用非 HTTP 流量的 IP 和端口,使用了决策器以避免泄露。"
+
+Firewall_ua_whitelist= main:taboption("network", Value, "Firewall_ua_whitelist", "UA 关键词白名单")
+Firewall_ua_whitelist:depends("enable_firewall_set", "1")
+Firewall_ua_whitelist.placeholder = ""
+Firewall_ua_whitelist.description = "指定不通过 UAmask 代理的 UA 关键词(流量卸载),用逗号分隔(如:Valve/Steam,360pcdn)。"
+
+Firewall_drop_on_match=main:taboption("network", Flag, "Firewall_drop_on_match", "匹配时断开连接")
+Firewall_drop_on_match:depends("enable_firewall_set", "1")
+Firewall_drop_on_match.description = "启用后,当流量匹配 UA 白名单规则时,将直接断开连接,强制其重新建立连接绕过 UAmask。"
+
+proxy_host = main:taboption("network", Flag, "proxy_host", "代理主机流量")
+proxy_host.description = "启用后将代理主机自身的流量。如果需要尽量避免和其他代理冲突,请禁用此选项。"
+
+bypass_gid = main:taboption("network", Value, "bypass_gid", "绕过 GID")
+bypass_gid:depends("proxy_host", "1")
+bypass_gid.default = "65533"
+bypass_gid.datatype = "uinteger"
+bypass_gid.description = "用于绕过 TPROXY 自身流量的 GID。"
+
+bypass_ports = main:taboption("network", Value, "bypass_ports", "绕过目标端口")
+bypass_ports.placeholder = "22 443"
+bypass_ports.description = "豁免的目标端口,用空格分隔(如:22 443)。"
+
+bypass_ips = main:taboption("network", Value, "bypass_ips", "绕过目标 IP")
+bypass_ips.default = "172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 169.254.0.0/16"
+bypass_ips.description = "豁免的目标 IP/CIDR 列表,用空格分隔。"
+
+-- === Tab 3: 高级设置(防火墙高级设置)===
+firewall_advanced_settings = main:taboption("advanced", Flag, "firewall_advanced_settings", "决策器设置")
+firewall_advanced_settings.description = "启用后,您可以自定义流量卸载中决策器的参数"
+
+firewall_nonhttp_threshold = main:taboption("advanced", Value, "firewall_nonhttp_threshold", "非 HTTP 判定阈值")
+firewall_nonhttp_threshold:depends("firewall_advanced_settings", "1")
+firewall_nonhttp_threshold.datatype = "uinteger"
+firewall_nonhttp_threshold.default = 5
+firewall_nonhttp_threshold.description = "在将一个 IP+端口 确认为非 HTTP 流量之前,需要连续检测到的非 HTTP 连接次数。"
+
+firewall_decision_delay = main:taboption("advanced", Value, "firewall_decision_delay", "决策延迟时间(秒)")
+firewall_decision_delay:depends("firewall_advanced_settings", "1")
+firewall_decision_delay.datatype = "uinteger"
+firewall_decision_delay.default = 60
+firewall_decision_delay.description = "达到验证阈值后,观察多久才做出绕过决策。单位为秒。"
+
+firewall_timeout = main:taboption("advanced", Value, "firewall_timeout", "防火墙规则超时(秒)")
+firewall_timeout:depends("firewall_advanced_settings", "1")
+firewall_timeout.datatype = "uinteger"
+firewall_timeout.default = 28800
+firewall_timeout.description = "添加到 ipset/nfset 中的规则的超时时间。单位为秒(默认8*3600)。"
+
+
+-- === Tab 4: 应用日志 ===
+
+
+log_level = main:taboption("softlog", ListValue, "log_level", "日志等级")
+log_level.default = "info"
+log_level:value("debug", "调试(debug)")
+log_level:value("info", "信息(info)")
+log_level:value("warn", "警告(warn)")
+log_level:value("error", "错误(error)")
+log_level:value("fatal", "致命(fatal)")
+log_level:value("panic", "崩溃(panic)")
+
+log_file = main:taboption("softlog", Value, "log_file", "应用日志路径")
+log_file.placeholder = "/tmp/UAmask/UAmask.log"
+log_file.description = "指定 Go 程序运行时日志的输出文件路径。留空将禁用文件日志。"
+
+softlog = main:taboption("softlog", TextValue, "log_display","")
+softlog.readonly = true
+softlog.rows = 30
+softlog.cfgvalue = function(self, section)
+ local log_file_path = self.map:get("main", "log_file")
+ if not log_file_path or log_file_path == "" then
+ return "(未配置应用日志文件路径)"
+ end
+ return luci.sys.exec("tail -n 200 \"" .. log_file_path .. "\" 2>/dev/null")
+end
+
+local clear_btn = main:taboption("softlog", Button, "clear_log", "清空应用日志")
+clear_btn.inputstyle = "reset"
+clear_btn.write = function(self, section)
+ local log_file_path = self.map:get(section, "log_file")
+ if log_file_path and log_file_path ~= "" and nixio.fs.access(log_file_path) then
+ luci.sys.exec("> \"" .. log_file_path .. "\"")
+ end
+end
+
+-- === Apply/Restart 逻辑(保持不变)===
+
+local apply = luci.http.formvalue("cbi.apply")
+if apply then
+ local enabled_form_value = luci.http.formvalue("cbid.UAmask.enabled.enabled")
+
+ local pid = luci_sys.exec("pidof UAmask")
+ local is_running = (pid ~= "" and pid ~= nil)
+
+ if enabled_form_value == "1" then
+ if is_running then
+ luci.sys.call("/etc/init.d/UAmask reload >/dev/null 2>&1")
+ else
+ luci.sys.call("/etc/init.d/UAmask start >/dev/null 2>&1")
+ end
+ else
+ luci.sys.call("/etc/init.d/UAmask stop >/dev/null 2>&1")
+ end
+end
+
+return UAmask
\ No newline at end of file
diff --git a/UA-Mask/openwrt/package/Makefile b/UA-Mask/openwrt/package/Makefile
new file mode 100644
index 000000000..0917e0622
--- /dev/null
+++ b/UA-Mask/openwrt/package/Makefile
@@ -0,0 +1,108 @@
+include $(TOPDIR)/rules.mk
+
+PACKAGE_DIR:=$(abspath $(dir $(lastword $(MAKEFILE_LIST))))
+OPENWRT_DIR:=$(abspath $(PACKAGE_DIR)/..)
+REPO_ROOT:=$(abspath $(OPENWRT_DIR)/..)
+UAMASK_SOURCE_DIR?=$(REPO_ROOT)
+UAMASK_VERSION?=$(strip $(shell cat $(UAMASK_SOURCE_DIR)/VERSION))
+
+PKG_NAME:=UAmask
+PKG_VERSION:=$(UAMASK_VERSION)
+PKG_RELEASE:=1
+
+PKG_MAINTAINER:=Zesuy
+PKG_LICENSE:=GPL-3.0-only
+PKG_LICENSE_FILES:=LICENSE
+
+PKG_BUILD_PARALLEL:=1
+PKG_BUILD_FLAGS:=no-mips16
+
+ifeq ($(strip $(UAMASK_PREBUILT)),)
+PKG_BUILD_DEPENDS:=golang/host
+endif
+
+include $(INCLUDE_DIR)/package.mk
+
+ifeq ($(strip $(UAMASK_PREBUILT)),)
+GO_PKG:=UAmask
+GO_PKG_BUILD_PKG:=$(GO_PKG)/cmd/UAmask
+GO_PKG_LDFLAGS_X:=main.version=$(PKG_VERSION)
+include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
+UAMASK_ARCH_DEPENDS:=$(GO_ARCH_DEPENDS)
+endif
+
+# 3. nftables 包定义
+define Package/UAmask
+ SECTION:=net
+ CATEGORY:=Network
+ SUBMENU:=Web Servers/Proxies
+ TITLE:=A transparent proxy for changing User-Agent (nftables)
+ URL:=https://github.com/Zesuy/UA-Mask
+ DEPENDS:=$(UAMASK_ARCH_DEPENDS) +luci-compat +libubox
+ CONFLICTS:=ua3f-tproxy ua3f-tproxy-ipt UAmask-ipt
+endef
+
+define Package/UAmask/description
+ A transparent proxy (TPROXY) for modifying HTTP User-Agent.
+ This is the default version using nftables (firewall4).
+endef
+
+ifeq ($(strip $(UAMASK_PREBUILT)),)
+define Build/Prepare
+ $(call Build/Prepare/Default)
+ $(CP) -r $(UAMASK_SOURCE_DIR)/core/cmd $(PKG_BUILD_DIR)/
+ $(CP) -r $(UAMASK_SOURCE_DIR)/core/internal $(PKG_BUILD_DIR)/
+ $(CP) $(UAMASK_SOURCE_DIR)/core/go.mod $(PKG_BUILD_DIR)/
+ $(CP) $(UAMASK_SOURCE_DIR)/core/go.sum $(PKG_BUILD_DIR)/
+ $(CP) $(UAMASK_SOURCE_DIR)/LICENSE $(PKG_BUILD_DIR)/
+endef
+else
+define Build/Prepare
+ $(call Build/Prepare/Default)
+ test -x $(UAMASK_PREBUILT)
+ $(CP) $(UAMASK_PREBUILT) $(PKG_BUILD_DIR)/UAmask
+ $(CP) $(UAMASK_SOURCE_DIR)/LICENSE $(PKG_BUILD_DIR)/
+endef
+
+define Build/Compile
+ true
+endef
+
+# The release builder has already stripped the static Go binary with
+# -ldflags "-s -w". OpenWrt's sstrip corrupts its section-header string table
+# when applied a second time, so prebuilt mode must preserve the ELF verbatim.
+RSTRIP:=:
+endif
+
+# 4. nftables 包 conffiles
+define Package/UAmask/conffiles
+/etc/config/UAmask
+endef
+
+define Package/UAmask/install/assets
+ $(INSTALL_DIR) $(1)/etc/init.d/
+ $(INSTALL_BIN) $(UAMASK_SOURCE_DIR)/openwrt/root/etc/init.d/UAmask $(1)/etc/init.d/UAmask
+ $(INSTALL_DIR) $(1)/etc/config/
+ $(INSTALL_CONF) $(UAMASK_SOURCE_DIR)/openwrt/root/etc/config/UAmask $(1)/etc/config/UAmask
+ $(INSTALL_DIR) $(1)/usr/lib/lua/luci/model/cbi/
+ $(INSTALL_DATA) $(UAMASK_SOURCE_DIR)/openwrt/luci/model/cbi/UAmask.lua $(1)/usr/lib/lua/luci/model/cbi/UAmask.lua
+ $(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller/
+ $(INSTALL_DATA) $(UAMASK_SOURCE_DIR)/openwrt/luci/controller/UAmask.lua $(1)/usr/lib/lua/luci/controller/UAmask.lua
+endef
+
+ifeq ($(strip $(UAMASK_PREBUILT)),)
+define Package/UAmask/install
+ $(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
+ $(INSTALL_DIR) $(1)/usr/bin/
+ $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/UAmask $(1)/usr/bin/UAmask
+ $(call Package/UAmask/install/assets,$(1))
+endef
+$(eval $(call GoBinPackage,UAmask))
+else
+define Package/UAmask/install
+ $(INSTALL_DIR) $(1)/usr/bin/
+ $(INSTALL_BIN) $(PKG_BUILD_DIR)/UAmask $(1)/usr/bin/UAmask
+ $(call Package/UAmask/install/assets,$(1))
+endef
+endif
+$(eval $(call BuildPackage,UAmask))
diff --git a/UA-Mask/openwrt/root/etc/config/UAmask b/UA-Mask/openwrt/root/etc/config/UAmask
new file mode 100644
index 000000000..a2b95d291
--- /dev/null
+++ b/UA-Mask/openwrt/root/etc/config/UAmask
@@ -0,0 +1,15 @@
+config 'UAmask' 'enabled'
+ option enabled '0'
+
+config 'UAmask' 'main'
+ option port '12032'
+ option ua 'FFF'
+ option log_level 'info'
+ option iface 'br-lan'
+ option bypass_gid '65533'
+ option bypass_ports '22 443'
+ option bypass_ips '172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 169.254.0.0/16'
+ option ua_regex '(iPhone|iPad|Android|Macintosh|Windows|Linux|Apple|Mac OS X|Mobile)'
+ option proxy_host '0'
+ option whitelist ''
+ option log_file '/tmp/UAmask/UAmask.log'
\ No newline at end of file
diff --git a/UA-Mask/openwrt/root/etc/init.d/UAmask b/UA-Mask/openwrt/root/etc/init.d/UAmask
new file mode 100644
index 000000000..3d27f1b14
--- /dev/null
+++ b/UA-Mask/openwrt/root/etc/init.d/UAmask
@@ -0,0 +1,543 @@
+#!/bin/sh /etc/rc.common
+
+USE_PROCD=1
+START=99
+STOP=90 # 确保在防火墙重载前停止
+
+NAME="${NAME:-UAmask}"
+PROG="${PROG:-/usr/bin/$NAME}"
+PID_FILE="/var/run/$NAME.pid"
+CORE_CONFIG_DIR="${CORE_CONFIG_DIR:-/var/run/$NAME}"
+CORE_CONFIG_PATH="$CORE_CONFIG_DIR/config.json"
+JSHN_LIB="${JSHN_LIB:-/usr/share/libubox/jshn.sh}"
+
+# --- NFT (fw4) 变量 ---
+FW_CONFIG_NAME="UAmask" # 防火墙 UCI 规则的名称
+NFT_PATH="/tmp/UAmask_rules.nft" # .nft 文件的路径 (使用 /tmp/)
+
+# --- IPT (fw3) 变量 ---
+IPT="iptables"
+CHAIN_PREROUTING="UAmask_prerouting"
+CHAIN_OUTPUT="UAmask_output"
+
+IPSET_NAME="UAmask_bypass_set"
+
+# --- 防火墙检测 ---
+FW_TYPE=""
+
+detect_firewall() {
+ if command -v fw4 >/dev/null 2>&1; then
+ FW_TYPE="nft"
+ else
+ FW_TYPE="ipt"
+ fi
+}
+
+# 脚本加载时执行防火墙检测
+detect_firewall
+
+# NFTABLES (fw4) 函数
+
+# 仅清理规则和 uci,不重载 (NFT helper)
+unset_firewall_rules_nft() {
+ rm -f "${NFT_PATH}" # 清理生成的规则文件
+ uci -q delete firewall.${FW_CONFIG_NAME}
+ uci commit firewall
+}
+
+# 清理防火墙规则并重载 (NFT)
+unset_firewall_nft() {
+ logger -t "$NAME" "Removing firewall rules (nft)..."
+ unset_firewall_rules_nft
+ nft delete chain inet fw4 UAmask_prerouting_before 2>/dev/null || true
+ nft delete chain inet fw4 UAmask_output_after 2>/dev/null || true
+ nft delete set inet fw4 ${IPSET_NAME} 2>/dev/null || true
+ fw4 reload >/dev/null 2>&1
+ logger -t "$NAME" "Firewall rules removed (nft)."
+}
+
+# 自动管理防火墙规则 (NFT)
+set_firewall_nft() {
+ # 1. 确保旧规则被彻底清除
+ unset_firewall_rules_nft
+
+ # 2. 从 UCI 读取配置
+ config_load "$NAME"
+ local port
+ local iface_list
+ local bypass_gid
+ local bypass_ports_list
+ local bypass_ips_list
+ local force_replace
+ local proxy_host
+ local enable_firewall_set
+
+ config_get port "main" "port" "12032"
+ config_get iface_list "main" "iface" "br-lan"
+ config_get bypass_gid "main" "bypass_gid" "65533"
+ config_get bypass_ports_list "main" "bypass_ports" ""
+ config_get bypass_ips_list "main" "bypass_ips" ""
+ config_get_bool proxy_host "main" "proxy_host" ""
+ config_get_bool enable_firewall_set "main" "enable_firewall_set" "0"
+
+ # 3. 格式化接口
+ local nft_ifaces
+ set -- $iface_list
+ if [ "$#" -eq 1 ]; then
+ nft_ifaces="\"$1\"" # 单个接口
+ else
+ nft_ifaces="{ $(echo "$iface_list" | sed -e 's/ /", "/g' -e 's/^/"/' -e 's/$/"/') }" # 多个接口
+ fi
+
+ # 4. 格式化豁免 IPs
+ local nft_ips_rule
+ if [ -n "$bypass_ips_list" ]; then
+ local nft_ips
+ nft_ips="{ $(echo "$bypass_ips_list" | sed -e 's/ /, /g') }"
+ nft_ips_rule="ip daddr != $nft_ips \\"
+ else
+ nft_ips_rule="# No bypass IPs configured"
+ fi
+
+ # 5. 格式化豁免 Ports
+ local nft_ports_rule
+ if [ -n "$bypass_ports_list" ]; then
+ local nft_ports
+ nft_ports="{ $(echo "$bypass_ports_list" | sed -e 's/ /, /g') }"
+ nft_ports_rule="tcp dport != $nft_ports \\"
+ else
+ nft_ports_rule="# No bypass ports configured"
+ fi
+
+ > "${NFT_PATH}"
+ if [ "$enable_firewall_set" = "1" ] && ! command -v nft >/dev/null 2>&1; then
+ logger -t "$NAME" "Error: 'nft' command (nftables) is not installed. Domain bypass disabled."
+ enable_firewall_set="0"
+ fi
+
+ if [ "$enable_firewall_set" = "1" ]; then
+ logger -t "$NAME" "Ensuring nftables set '${IPSET_NAME}' exists..."
+ nft add set inet fw4 ${IPSET_NAME} "{ type ipv4_addr . inet_service ; timeout 10m ;}"
+ if [ $? -ne 0 ]; then
+ logger -t "$NAME" "Error: Failed to create nftables set '${IPSET_NAME}'. Domain bypass disabled."
+ enable_firewall_set="0"
+ fi
+ fi
+
+ # 6. 动态写入 .nft 文件
+cat > "${NFT_PATH}" << EOF
+chain UAmask_prerouting_before {
+ type nat hook prerouting priority dstnat - 1;
+
+ $( [ "$enable_firewall_set" = "1" ] && echo "iifname $nft_ifaces ip protocol tcp ip daddr . tcp dport @$IPSET_NAME return" )
+
+ iifname $nft_ifaces ip protocol tcp \\
+ $nft_ips_rule
+ $nft_ports_rule
+ redirect to :$port
+}
+
+EOF
+
+ if [ "$proxy_host" = "1" ]; then
+cat >> "${NFT_PATH}" << EOF
+
+chain UAmask_output_after {
+ type nat hook output priority -100;
+
+ $( [ "$enable_firewall_set" = "1" ] && echo "ip protocol tcp ip daddr . tcp dport @$IPSET_NAME return" )
+
+ ip protocol tcp \\
+ # 豁免局域网、环回、保留地址等
+ $nft_ips_rule
+ # 豁免端口
+ $nft_ports_rule
+ # 豁免 UAmask 自己的流量
+ # 同时豁免 OpenClash 的流量 (GID 65534),防止循环
+ meta skgid != { $bypass_gid, 65534 } \\
+ redirect to :$port
+}
+
+EOF
+ logger -t "$NAME" "proxy_host is enabled. Added UAmask_output_after chain."
+ else
+ logger -t "$NAME" "proxy_host is disabled. Skipping UAmask_output_after chain."
+ fi
+ logger -t "$NAME" "Generated nftables rules at ${NFT_PATH}"
+ # 7. 注册防火墙规则
+ uci set firewall.${FW_CONFIG_NAME}="include"
+ uci set firewall.${FW_CONFIG_NAME}.type="nftables"
+ uci set firewall.${FW_CONFIG_NAME}.path="${NFT_PATH}"
+ uci set firewall.${FW_CONFIG_NAME}.enabled="1"
+ uci commit firewall
+ nft delete chain inet fw4 UAmask_prerouting_before 2>/dev/null || true
+ nft delete chain inet fw4 UAmask_output_after 2>/dev/null || true
+ fw4 reload >/dev/null 2>&1
+ logger -t "$NAME" "Firewall rules applied (nft)."
+}
+
+# IPTABLES (fw3) 函数
+
+# 清理防火墙规则并重载 (IPT)
+unset_firewall_ipt() {
+ logger -t "$NAME" "Removing firewall rules (iptables)..."
+
+ # 循环尝试删除跳转规则,直到成功或失败
+ while $IPT -t nat -D PREROUTING -j $CHAIN_PREROUTING 2>/dev/null; do :; done
+ while $IPT -t nat -D OUTPUT -j $CHAIN_OUTPUT 2>/dev/null; do :; done
+
+
+ $IPT -t nat -F $CHAIN_PREROUTING 2>/dev/null || true
+ $IPT -t nat -X $CHAIN_PREROUTING 2>/dev/null || true
+
+ $IPT -t nat -F $CHAIN_OUTPUT 2>/dev/null || true
+ $IPT -t nat -X $CHAIN_OUTPUT 2>/dev/null || true
+
+ ipset destroy "$IPSET_NAME" 2>/dev/null || true
+
+ /etc/init.d/firewall reload >/dev/null 2>&1
+ logger -t "$NAME" "Firewall rules removed (iptables)."
+}
+
+# 自动管理防火墙规则 (IPT)
+set_firewall_ipt() {
+ # 1. 确保旧规则被彻底清除
+ unset_firewall_ipt
+
+ # 2. 从 UCI 读取配置 (此部分不变)
+ config_load "$NAME"
+ local port
+ local iface_list
+ local bypass_gid
+ local bypass_ports_list
+ local bypass_ips_list
+ local proxy_host
+ local enable_firewall_set
+
+ config_get port "main" "port" "12032"
+ config_get iface_list "main" "iface" "br-lan"
+ config_get bypass_gid "main" "bypass_gid" "65533"
+ config_get bypass_ports_list "main" "bypass_ports" ""
+ config_get bypass_ips_list "main" "bypass_ips" ""
+ config_get_bool proxy_host "main" "proxy_host" ""
+
+ config_get_bool enable_firewall_set "main" "enable_firewall_set" "0"
+ if [ "$enable_firewall_set" = "1" ]; then
+ if ! command -v ipset >/dev/null 2>&1; then
+ logger -t "$NAME" "Error: 'ipset' package is not installed. Domain bypass disabled."
+ enable_firewall_set="0"
+ else
+ logger -t "$NAME" "Creating ipset '$IPSET_NAME' for domain bypass..."
+ ipset create "$IPSET_NAME" hash:ip,port timeout 600 -exist
+ fi
+ fi
+ # --- IPTABLES 规则设置 ---
+
+ # 3. 创建自定义链
+ $IPT -t nat -N $CHAIN_PREROUTING
+ $IPT -t nat -N $CHAIN_OUTPUT
+
+ if [ "$enable_firewall_set" = "1" ]; then
+ $IPT -t nat -A $CHAIN_PREROUTING -m set --match-set "$IPSET_NAME" dst,dst -j RETURN
+ fi
+
+ # 4. 填充 PREROUTING 链
+ for iface in $iface_list; do
+ for ip in $bypass_ips_list; do
+ $IPT -t nat -A $CHAIN_PREROUTING -i "$iface" -p tcp -d "$ip" -j RETURN
+ done
+ if [ -n "$bypass_ports_list" ]; then
+ local ipt_ports=$(echo "$bypass_ports_list" | sed 's/ /,/g')
+ $IPT -t nat -A $CHAIN_PREROUTING -i "$iface" -p tcp -m multiport --dports "$ipt_ports" -j RETURN
+ fi
+ # 将剩余的 TCP 流量重定向到代理端口
+ $IPT -t nat -A $CHAIN_PREROUTING -i "$iface" -p tcp -j REDIRECT --to-port "$port"
+ done
+
+ # 5. OUTPUT 链处理本机流量
+ if [ "$proxy_host" = "1" ]; then
+ # 域名绕过规则
+ if [ "$enable_firewall_set" = "1" ]; then
+ $IPT -t nat -A $CHAIN_OUTPUT -m set --match-set "$IPSET_NAME" dst -j RETURN
+ fi
+ # 豁免 UAmask 自己的流量 (通过 GID)
+ $IPT -t nat -A $CHAIN_OUTPUT -p tcp -m owner --gid-owner "$bypass_gid" -j RETURN
+ # 同时豁免 OpenClash 的流量 (GID 65534),防止循环
+ $IPT -t nat -A $CHAIN_OUTPUT -p tcp -m owner --gid-owner "65534" -j RETURN
+
+ # 豁免指定的目标 IP
+ for ip in $bypass_ips_list; do
+ $IPT -t nat -A $CHAIN_OUTPUT -p tcp -d "$ip" -j RETURN
+ done
+
+ # 豁免指定的端口
+ if [ -n "$bypass_ports_list" ]; then
+ local ipt_ports=$(echo "$bypass_ports_list" | sed 's/ /,/g')
+ $IPT -t nat -A $CHAIN_OUTPUT -p tcp -m multiport --dports "$ipt_ports" -j RETURN
+ fi
+
+ # 将剩余的 TCP 流量重定向到代理端口
+ $IPT -t nat -A $CHAIN_OUTPUT -p tcp -j REDIRECT --to-port "$port"
+
+ # 将自定义 OUTPUT 链挂载到内置的 OUTPUT 链
+ $IPT -t nat -I OUTPUT 1 -j $CHAIN_OUTPUT
+ logger -t "$NAME" "proxy_host is enabled. Added iptables rules for OUTPUT chain."
+ else
+ logger -t "$NAME" "proxy_host is disabled. Skipping OUTPUT chain rules."
+ fi
+
+ # 6. 将自定义 PREROUTING 链挂载到内置的 PREROUTING 链
+ $IPT -t nat -I PREROUTING 1 -j $CHAIN_PREROUTING
+
+ logger -t "$NAME" "Firewall rules (iptables) applied."
+}
+
+
+# 通用封装函数
+
+set_firewall() {
+ if [ "$FW_TYPE" = "nft" ]; then
+ set_firewall_nft
+ else
+ set_firewall_ipt
+ fi
+}
+
+unset_firewall() {
+ if [ "$FW_TYPE" = "nft" ]; then
+ unset_firewall_nft
+ else
+ unset_firewall_ipt
+ fi
+}
+
+# 通用服务函数 (启动、停止、组设置)
+
+# 检查并创建专用组
+setup_group() {
+ config_load "$NAME"
+ local bypass_gid
+ config_get bypass_gid "main" "bypass_gid" "65533"
+
+ if ! grep -q ":$bypass_gid:" /etc/group; then
+ echo "UAmask:x:$bypass_gid:" >> /etc/group
+ fi
+}
+
+
+trim_config_value() {
+ local value="$1"
+ value="${value#"${value%%[![:space:]]*}"}"
+ value="${value%"${value##*[![:space:]]}"}"
+ printf '%s' "$value"
+}
+
+json_add_csv_array() {
+ local name="$1"
+ local remaining="$2"
+ local item
+
+ json_add_array "$name"
+ while [ -n "$remaining" ]; do
+ case "$remaining" in
+ *,*)
+ item="${remaining%%,*}"
+ remaining="${remaining#*,}"
+ ;;
+ *)
+ item="$remaining"
+ remaining=""
+ ;;
+ esac
+ item="$(trim_config_value "$item")"
+ [ -n "$item" ] && json_add_string "" "$item"
+ done
+ json_close_array
+}
+
+generate_core_config() {
+ local tmp_config="$CORE_CONFIG_PATH.tmp.$$"
+ local validation_output
+ local port ua log_level log_file whitelist
+ local match_mode keywords ua_regex replace_method partial_replace
+ local enable_firewall_set firewall_ua_whitelist firewall_ua_bypass firewall_drop_on_match
+ local firewall_advanced_settings firewall_nonhttp_threshold firewall_timeout firewall_decision_delay
+ local operating_profile cache_size buffer_size pool_size gogc_value
+
+ if [ ! -r "$JSHN_LIB" ]; then
+ logger -t "$NAME" "Cannot generate core config: jshn.sh is unavailable."
+ return 1
+ fi
+ . "$JSHN_LIB"
+
+ config_get port "main" "port" "12032"
+ config_get ua "main" "ua" "FFF"
+ config_get log_level "main" "log_level" "info"
+ config_get log_file "main" "log_file" "/tmp/UAmask/UAmask.log"
+ config_get whitelist "main" "whitelist" ""
+
+ config_get match_mode "main" "match_mode" "keywords"
+ config_get keywords "main" "keywords" "iPhone,iPad,Android,Macintosh,Windows"
+ config_get ua_regex "main" "ua_regex" "(iPhone|iPad|Android|Macintosh|Windows|Linux)"
+ config_get replace_method "main" "replace_method" "full"
+ partial_replace=0
+ [ "$replace_method" = "partial" ] && partial_replace=1
+
+ config_get_bool enable_firewall_set "main" "enable_firewall_set" "0"
+ config_get firewall_ua_whitelist "main" "Firewall_ua_whitelist" ""
+ config_get_bool firewall_ua_bypass "main" "Firewall_ua_bypass" "0"
+ config_get_bool firewall_drop_on_match "main" "Firewall_drop_on_match" "0"
+ config_get_bool firewall_advanced_settings "main" "firewall_advanced_settings" "0"
+ config_get firewall_nonhttp_threshold "main" "firewall_nonhttp_threshold" "5"
+ config_get firewall_timeout "main" "firewall_timeout" "28800"
+ config_get firewall_decision_delay "main" "firewall_decision_delay" "60"
+
+ if [ "$enable_firewall_set" != "1" ]; then
+ firewall_ua_whitelist=""
+ firewall_ua_bypass=0
+ firewall_drop_on_match=0
+ fi
+ if [ "$firewall_advanced_settings" != "1" ]; then
+ firewall_nonhttp_threshold=5
+ firewall_timeout=28800
+ firewall_decision_delay=60
+ else
+ # Preserve the safety floors used by the legacy flag-based launcher.
+ [ "$firewall_nonhttp_threshold" -ge 1 ] 2>/dev/null || firewall_nonhttp_threshold=5
+ [ "$firewall_timeout" -ge 60 ] 2>/dev/null || firewall_timeout=28800
+ [ "$firewall_decision_delay" -ge 10 ] 2>/dev/null || firewall_decision_delay=60
+ fi
+
+ config_get operating_profile "main" "operating_profile" "Medium"
+ config_get cache_size "main" "cache_size" "2000"
+ config_get buffer_size "main" "buffer_size" "8192"
+ config_get pool_size "main" "pool_size" "0"
+ config_get gogc_value "main" "gogc_value" "100"
+
+ umask 077
+ mkdir -p "$CORE_CONFIG_DIR" || return 1
+ mkdir -p "$(dirname "$log_file")" || return 1
+
+ json_init
+ json_add_int "schema_version" 1
+
+ json_add_object "listen"
+ json_add_int "port" "$port"
+ json_add_string "dial_timeout" "30s"
+ json_add_string "client_keep_alive" "3m"
+ json_add_string "upstream_keep_alive" "3m"
+ json_close_object
+
+ json_add_object "rewrite"
+ json_add_string "user_agent" "$ua"
+ json_add_string "mode" "$match_mode"
+ json_add_csv_array "keywords" "$keywords"
+ json_add_csv_array "whitelist" "$whitelist"
+ json_add_string "pattern" "$ua_regex"
+ json_add_boolean "partial_replace" "$partial_replace"
+ json_close_object
+
+ json_add_object "firewall"
+ json_add_string "backend" "$FW_TYPE"
+ json_add_string "set_name" "$IPSET_NAME"
+ json_add_csv_array "ua_whitelist" "$firewall_ua_whitelist"
+ json_add_boolean "drop_on_match" "$firewall_drop_on_match"
+ json_add_string "whitelist_timeout" "24h"
+ json_add_object "non_http"
+ json_add_boolean "enabled" "$firewall_ua_bypass"
+ json_add_int "threshold" "$firewall_nonhttp_threshold"
+ json_add_string "decision_delay" "${firewall_decision_delay}s"
+ json_add_string "http_cooldown" "1h"
+ json_add_string "timeout" "${firewall_timeout}s"
+ json_add_string "cleanup_interval" "10m"
+ json_close_object
+ json_close_object
+
+ json_add_object "observe"
+ json_add_string "log_level" "$log_level"
+ json_add_string "log_file" "$log_file"
+ json_add_string "stats_file" "/tmp/UAmask.stats"
+ json_add_string "stats_interval" "5s"
+ json_close_object
+
+ json_add_object "performance"
+ json_add_string "profile" "$operating_profile"
+ json_add_int "cache_size" "$cache_size"
+ json_add_int "buffer_size" "$buffer_size"
+ json_add_int "pool_size" "$pool_size"
+ json_add_int "gc_percent" "$gogc_value"
+ json_close_object
+
+ if ! json_dump > "$tmp_config"; then
+ logger -t "$NAME" "Failed to serialize core configuration."
+ rm -f "$tmp_config"
+ return 1
+ fi
+
+ validation_output="$("$PROG" -check-config -config "$tmp_config" 2>&1)"
+ if [ $? -ne 0 ]; then
+ logger -t "$NAME" "Core configuration validation failed: $validation_output"
+ rm -f "$tmp_config"
+ return 1
+ fi
+
+ if ! chmod 600 "$tmp_config"; then
+ logger -t "$NAME" "Failed to secure generated core configuration."
+ rm -f "$tmp_config"
+ return 1
+ fi
+ if ! mv -f "$tmp_config" "$CORE_CONFIG_PATH"; then
+ logger -t "$NAME" "Failed to install core configuration."
+ rm -f "$tmp_config"
+ return 1
+ fi
+ return 0
+}
+
+start_service() {
+ logger -t "$NAME" "Starting $NAME with firewall $FW_TYPE..."
+ config_load "$NAME"
+
+ local enabled proxy_host
+ config_get_bool enabled "enabled" "enabled" "0"
+ [ "$enabled" -eq 1 ] || return 1
+ config_get_bool proxy_host "main" "proxy_host" "0"
+
+ if [ "$proxy_host" = "1" ]; then
+ setup_group
+ fi
+ if ! generate_core_config; then
+ logger -t "$NAME" "Refusing to start with invalid core configuration."
+ return 1
+ fi
+
+ procd_open_instance "$NAME"
+ procd_set_param command "$PROG" -config "$CORE_CONFIG_PATH"
+ procd_set_param file "$CORE_CONFIG_PATH"
+ procd_set_param limits nofile="65536 65536"
+
+ if [ "$proxy_host" = "1" ]; then
+ procd_set_param group "UAmask"
+ logger -t "$NAME" "Running as group 'UAmask' for host proxying."
+ else
+ logger -t "$NAME" "Running as default user (proxy_host is disabled)."
+ fi
+
+ procd_set_param respawn
+ procd_set_param stdout 1
+ procd_set_param stderr 1
+ procd_close_instance
+
+ set_firewall
+}
+
+stop_service() {
+ logger -t "$NAME" "Stopping $NAME..."
+ # 停止服务时自动清理防火墙
+ unset_firewall
+ # procd 会自动使用 pidfile 停止 start-stop-daemon
+}
+
+service_triggers() {
+ procd_add_reload_trigger "$NAME"
+}
diff --git a/UA-Mask/openwrt/tests/config-generation-test.sh b/UA-Mask/openwrt/tests/config-generation-test.sh
new file mode 100644
index 000000000..097613cba
--- /dev/null
+++ b/UA-Mask/openwrt/tests/config-generation-test.sh
@@ -0,0 +1,255 @@
+#!/bin/sh
+
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+export JSON_OPS="$TMP_DIR/json.ops"
+export JSON_RENDERER="$ROOT/openwrt/tests/render-json-ops.py"
+export JSHN_LIB="$ROOT/openwrt/tests/fake-jshn.sh"
+export CORE_CONFIG_DIR="$TMP_DIR/run"
+export PROG="$TMP_DIR/UAmask"
+export TEST_LOG_FILE="$TMP_DIR/log/UAmask.log"
+export NAME="UAmask"
+
+TEST_LOG_OUTPUT="$TMP_DIR/init.log"
+TEST_SCENARIO="complete"
+TEST_PROFILE=""
+
+(cd "$ROOT/core" && go build -o "$PROG" ./cmd/UAmask)
+
+preserve_failure_artifacts() {
+ [ -n "${TEST_ARTIFACT_DIR:-}" ] || return 0
+ mkdir -p "$TEST_ARTIFACT_DIR"
+ [ -f "$TEST_LOG_OUTPUT" ] && cp "$TEST_LOG_OUTPUT" "$TEST_ARTIFACT_DIR/config-generation.log"
+ [ -f "$CORE_CONFIG_PATH" ] && cp "$CORE_CONFIG_PATH" "$TEST_ARTIFACT_DIR/config.json"
+ for file in "$CORE_CONFIG_PATH".tmp.*; do
+ [ -f "$file" ] && cp "$file" "$TEST_ARTIFACT_DIR/$(basename "$file")"
+ done
+}
+
+fail() {
+ echo "FAIL $1" >&2
+ preserve_failure_artifacts
+ exit 1
+}
+
+pass() {
+ echo "PASS $1"
+}
+
+logger() {
+ printf '%s\n' "$*" >> "$TEST_LOG_OUTPUT"
+}
+
+config_get() {
+ local variable="$1"
+ local option="$3"
+ local default_value="$4"
+ local value="$default_value"
+
+ case "$option" in
+ log_file) value="$TEST_LOG_FILE" ;;
+ esac
+
+ case "$TEST_SCENARIO:$option" in
+ complete:port) value="13000" ;;
+ complete:ua) value="Mask UA" ;;
+ complete:log_level) value="debug" ;;
+ complete:whitelist) value="Client A, Client B" ;;
+ complete:match_mode) value="regex" ;;
+ complete:keywords) value="Android,Linux" ;;
+ complete:ua_regex) value="Android|Linux" ;;
+ complete:replace_method) value="partial" ;;
+ complete:Firewall_ua_whitelist) value="Steam, Valve" ;;
+ complete:firewall_nonhttp_threshold) value="7" ;;
+ complete:firewall_timeout) value="3600" ;;
+ complete:firewall_decision_delay) value="15" ;;
+ complete:operating_profile) value="custom" ;;
+ complete:cache_size) value="1234" ;;
+ complete:buffer_size) value="4096" ;;
+ complete:pool_size) value="9" ;;
+ complete:gogc_value) value="80" ;;
+ safety:firewall_nonhttp_threshold) value="0" ;;
+ safety:firewall_timeout) value="1" ;;
+ safety:firewall_decision_delay) value="0" ;;
+ profile:operating_profile)
+ [ "$TEST_PROFILE" = "__missing__" ] || value="$TEST_PROFILE"
+ ;;
+ profile:cache_size) value="1234" ;;
+ profile:buffer_size) value="4096" ;;
+ profile:pool_size) value="9" ;;
+ profile:gogc_value) value="80" ;;
+ escape:ua)
+ value='Mask "UA" \ path
+第二行'
+ ;;
+ escape:whitelist) value='Client "A", 路径\B' ;;
+ escape:keywords) value='Android,OpenHarmony' ;;
+ escape:Firewall_ua_whitelist) value='Steam "Deck", 游戏\客户端' ;;
+ invalid:match_mode) value="invalid-mode" ;;
+ esac
+
+ eval "$variable=\$value"
+}
+
+config_get_bool() {
+ local variable="$1"
+ local option="$3"
+ local default_value="$4"
+ local value="$default_value"
+
+ case "$TEST_SCENARIO:$option" in
+ complete:enable_firewall_set|complete:Firewall_ua_bypass|complete:Firewall_drop_on_match|complete:firewall_advanced_settings)
+ value="1"
+ ;;
+ safety:enable_firewall_set|safety:firewall_advanced_settings)
+ value="1"
+ ;;
+ escape:enable_firewall_set)
+ value="1"
+ ;;
+ esac
+ eval "$variable=\$value"
+}
+
+. "$ROOT/openwrt/root/etc/init.d/UAmask"
+FW_TYPE="nft"
+CONFIG_PATH="$CORE_CONFIG_PATH"
+
+TEST_SCENARIO="complete"
+generate_core_config || fail "OWRT-JSON-001 generate complete config"
+"$PROG" -check-config -config "$CONFIG_PATH" >/dev/null || fail "OWRT-JSON-001 validate complete config"
+python3 - "$CONFIG_PATH" <<'PY' || fail "OWRT-JSON-001 verify complete mapping"
+import json
+import sys
+
+with open(sys.argv[1], encoding="utf-8") as config_file:
+ config = json.load(config_file)
+
+assert config == {
+ "schema_version": 1,
+ "listen": {
+ "port": 13000,
+ "dial_timeout": "30s",
+ "client_keep_alive": "3m",
+ "upstream_keep_alive": "3m",
+ },
+ "rewrite": {
+ "user_agent": "Mask UA",
+ "mode": "regex",
+ "keywords": ["Android", "Linux"],
+ "whitelist": ["Client A", "Client B"],
+ "pattern": "Android|Linux",
+ "partial_replace": True,
+ },
+ "firewall": {
+ "backend": "nft",
+ "set_name": "UAmask_bypass_set",
+ "ua_whitelist": ["Steam", "Valve"],
+ "drop_on_match": True,
+ "whitelist_timeout": "24h",
+ "non_http": {
+ "enabled": True,
+ "threshold": 7,
+ "decision_delay": "15s",
+ "http_cooldown": "1h",
+ "timeout": "3600s",
+ "cleanup_interval": "10m",
+ },
+ },
+ "observe": {
+ "log_level": "debug",
+ "log_file": config["observe"]["log_file"],
+ "stats_file": "/tmp/UAmask.stats",
+ "stats_interval": "5s",
+ },
+ "performance": {
+ "profile": "custom",
+ "cache_size": 1234,
+ "buffer_size": 4096,
+ "pool_size": 9,
+ "gc_percent": 80,
+ },
+}
+assert config["observe"]["log_file"].endswith("/log/UAmask.log")
+PY
+pass "OWRT-JSON-001 complete UCI mapping"
+
+TEST_SCENARIO="safety"
+generate_core_config || fail "OWRT-SAFE-001 generate safety config"
+python3 - "$CONFIG_PATH" <<'PY' || fail "OWRT-SAFE-001 verify safety floors"
+import json
+import sys
+
+with open(sys.argv[1], encoding="utf-8") as config_file:
+ non_http = json.load(config_file)["firewall"]["non_http"]
+
+assert non_http["threshold"] == 5
+assert non_http["decision_delay"] == "60s"
+assert non_http["timeout"] == "28800s"
+PY
+pass "OWRT-SAFE-001 legacy safety floors"
+
+TEST_SCENARIO="profile"
+for profile_case in \
+ "Low low 2000 8192 200 100" \
+ "Medium medium 3000 8192 500 100" \
+ "High high 5000 8192 1000 100" \
+ "custom custom 1234 4096 9 80" \
+ "__missing__ medium 3000 8192 500 100"
+do
+ set -- $profile_case
+ TEST_PROFILE="$1"
+ expected_profile="$2"
+ expected_cache="$3"
+ expected_buffer="$4"
+ expected_pool="$5"
+ expected_gc="$6"
+ generate_core_config || fail "OWRT-PROFILE generate $TEST_PROFILE"
+ "$PROG" -dump-effective-config -config "$CONFIG_PATH" > "$TMP_DIR/effective.json" || fail "OWRT-PROFILE dump $TEST_PROFILE"
+ python3 - "$TMP_DIR/effective.json" "$expected_profile" "$expected_cache" "$expected_buffer" "$expected_pool" "$expected_gc" <<'PY' || fail "OWRT-PROFILE verify $TEST_PROFILE"
+import json
+import sys
+
+with open(sys.argv[1], encoding="utf-8") as config_file:
+ config = json.load(config_file)
+
+assert config["performance"]["profile"] == sys.argv[2]
+assert config["performance"]["cache_size"] == int(sys.argv[3])
+assert config["performance"]["buffer_size"] == int(sys.argv[4])
+assert config["performance"]["pool_size"] == int(sys.argv[5])
+assert config["performance"]["gc_percent"] == int(sys.argv[6])
+PY
+done
+pass "OWRT-PROFILE-001 preset and default profiles"
+
+TEST_SCENARIO="escape"
+generate_core_config || fail "OWRT-ESCAPE-001 generate escaped config"
+python3 - "$CONFIG_PATH" <<'PY' || fail "OWRT-ESCAPE-001 verify escaped values"
+import json
+import sys
+
+with open(sys.argv[1], encoding="utf-8") as config_file:
+ config = json.load(config_file)
+
+assert config["rewrite"]["user_agent"] == 'Mask "UA" \\ path\n第二行'
+assert config["rewrite"]["whitelist"] == ['Client "A"', '路径\\B']
+assert config["firewall"]["ua_whitelist"] == ['Steam "Deck"', '游戏\\客户端']
+PY
+pass "OWRT-ESCAPE-001 JSON escaping and Unicode"
+
+mkdir -p "$CORE_CONFIG_DIR"
+cp "$ROOT/core/internal/config/testdata/valid/schema-v1-minimal.json" "$CONFIG_PATH"
+cp "$CONFIG_PATH" "$TMP_DIR/previous-config.json"
+TEST_SCENARIO="invalid"
+if generate_core_config; then
+ fail "OWRT-ATOMIC-001 invalid config was accepted"
+fi
+cmp "$TMP_DIR/previous-config.json" "$CONFIG_PATH" >/dev/null || fail "OWRT-ATOMIC-001 previous config was replaced"
+if find "$CORE_CONFIG_DIR" -name 'config.json.tmp.*' -type f | grep -q .; then
+ fail "OWRT-ATOMIC-001 temporary config was not removed"
+fi
+pass "OWRT-ATOMIC-001 invalid generation preserves previous config"
diff --git a/UA-Mask/openwrt/tests/fake-jshn.sh b/UA-Mask/openwrt/tests/fake-jshn.sh
new file mode 100644
index 000000000..f6f4a9023
--- /dev/null
+++ b/UA-Mask/openwrt/tests/fake-jshn.sh
@@ -0,0 +1,48 @@
+#!/bin/sh
+
+json_encode_field() {
+ printf '%s' "$1" | base64 | tr -d '\n'
+}
+
+json_record() {
+ local kind="$1"
+ local name="$2"
+ local value="$3"
+ printf '%s\t%s\t%s\n' "$kind" "$(json_encode_field "$name")" "$(json_encode_field "$value")" >> "$JSON_OPS"
+}
+
+json_init() {
+ : > "$JSON_OPS"
+}
+
+json_add_object() {
+ json_record object "$1" ""
+}
+
+json_add_array() {
+ json_record array "$1" ""
+}
+
+json_close_object() {
+ json_record close "" ""
+}
+
+json_close_array() {
+ json_record close "" ""
+}
+
+json_add_string() {
+ json_record string "$1" "$2"
+}
+
+json_add_int() {
+ json_record int "$1" "$2"
+}
+
+json_add_boolean() {
+ json_record boolean "$1" "$2"
+}
+
+json_dump() {
+ python3 "$JSON_RENDERER" "$JSON_OPS"
+}
diff --git a/UA-Mask/openwrt/tests/procd-contract-test.sh b/UA-Mask/openwrt/tests/procd-contract-test.sh
new file mode 100644
index 000000000..a58705924
--- /dev/null
+++ b/UA-Mask/openwrt/tests/procd-contract-test.sh
@@ -0,0 +1,140 @@
+#!/bin/sh
+
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+export JSON_OPS="$TMP_DIR/json.ops"
+export JSON_RENDERER="$ROOT/openwrt/tests/render-json-ops.py"
+export JSHN_LIB="$ROOT/openwrt/tests/fake-jshn.sh"
+export CORE_CONFIG_DIR="$TMP_DIR/run"
+export PROG="$TMP_DIR/UAmask"
+export TEST_LOG_FILE="$TMP_DIR/log/UAmask.log"
+export NAME="UAmask"
+
+TEST_LOG_OUTPUT="$TMP_DIR/init.log"
+
+(cd "$ROOT/core" && go build -o "$PROG" ./cmd/UAmask)
+
+logger() {
+ printf '%s\n' "$*" >> "$TEST_LOG_OUTPUT"
+}
+
+preserve_failure_artifacts() {
+ [ -n "${TEST_ARTIFACT_DIR:-}" ] || return 0
+ mkdir -p "$TEST_ARTIFACT_DIR"
+ [ -f "$TEST_LOG_OUTPUT" ] && cp "$TEST_LOG_OUTPUT" "$TEST_ARTIFACT_DIR/procd-contract.log"
+ [ -f "$CORE_CONFIG_PATH" ] && cp "$CORE_CONFIG_PATH" "$TEST_ARTIFACT_DIR/procd-config.json"
+}
+
+fail() {
+ echo "FAIL $1" >&2
+ preserve_failure_artifacts
+ exit 1
+}
+
+config_load() {
+ [ "$1" = "UAmask" ]
+}
+
+config_get() {
+ local variable="$1"
+ local option="$3"
+ local default_value="$4"
+ local value="$default_value"
+ [ "$option" = "log_file" ] && value="$TEST_LOG_FILE"
+ eval "$variable=\$value"
+}
+
+config_get_bool() {
+ local variable="$1"
+ local section="$2"
+ local option="$3"
+ local default_value="$4"
+ local value="$default_value"
+ if [ "$section:$option" = "enabled:enabled" ]; then
+ value="1"
+ fi
+ eval "$variable=\$value"
+}
+
+. "$ROOT/openwrt/root/etc/init.d/UAmask"
+
+OPEN_INSTANCE=""
+CLOSED_INSTANCE=0
+COMMAND_PROGRAM=""
+COMMAND_FLAG=""
+COMMAND_CONFIG=""
+FILE_PARAM=""
+LIMITS_PARAM=""
+RESPAWN_PARAM=0
+STDOUT_PARAM=""
+STDERR_PARAM=""
+FIREWALL_SET=0
+
+procd_open_instance() {
+ OPEN_INSTANCE="$1"
+}
+
+procd_set_param() {
+ case "$1" in
+ command)
+ [ "$#" -eq 4 ] || return 1
+ COMMAND_PROGRAM="$2"
+ COMMAND_FLAG="$3"
+ COMMAND_CONFIG="$4"
+ ;;
+ file)
+ [ "$#" -eq 2 ] || return 1
+ FILE_PARAM="$2"
+ ;;
+ limits)
+ [ "$#" -eq 2 ] || return 1
+ LIMITS_PARAM="$2"
+ ;;
+ respawn)
+ [ "$#" -eq 1 ] || return 1
+ RESPAWN_PARAM=1
+ ;;
+ stdout)
+ [ "$#" -eq 2 ] || return 1
+ STDOUT_PARAM="$2"
+ ;;
+ stderr)
+ [ "$#" -eq 2 ] || return 1
+ STDERR_PARAM="$2"
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+procd_close_instance() {
+ CLOSED_INSTANCE=1
+}
+
+set_firewall() {
+ FIREWALL_SET=1
+}
+
+FW_TYPE="nft"
+start_service
+
+[ "$OPEN_INSTANCE" = "UAmask" ] || fail "OWRT-PROCD-001 instance name"
+[ "$COMMAND_PROGRAM" = "$PROG" ] || fail "OWRT-PROCD-001 program"
+[ "$COMMAND_FLAG" = "-config" ] || fail "OWRT-PROCD-001 config flag"
+[ "$COMMAND_CONFIG" = "$CORE_CONFIG_PATH" ] || fail "OWRT-PROCD-001 config path"
+[ "$FILE_PARAM" = "$CORE_CONFIG_PATH" ] || fail "OWRT-PROCD-001 file param"
+[ "$LIMITS_PARAM" = "nofile=65536 65536" ] || fail "OWRT-PROCD-001 limits"
+[ "$RESPAWN_PARAM" -eq 1 ] || fail "OWRT-PROCD-001 respawn"
+[ "$STDOUT_PARAM" = "1" ] || fail "OWRT-PROCD-001 stdout"
+[ "$STDERR_PARAM" = "1" ] || fail "OWRT-PROCD-001 stderr"
+[ "$CLOSED_INSTANCE" -eq 1 ] || fail "OWRT-PROCD-001 close"
+[ "$FIREWALL_SET" -eq 1 ] || fail "OWRT-PROCD-001 firewall"
+[ -f "$CORE_CONFIG_PATH" ] || fail "OWRT-PROCD-001 generated config"
+
+"$PROG" -check-config -config "$CORE_CONFIG_PATH" >/dev/null || fail "OWRT-PROCD-001 generated config validation"
+echo "PASS OWRT-PROCD-001 start_service contract"
diff --git a/UA-Mask/openwrt/tests/render-json-ops.py b/UA-Mask/openwrt/tests/render-json-ops.py
new file mode 100644
index 000000000..33032b623
--- /dev/null
+++ b/UA-Mask/openwrt/tests/render-json-ops.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+
+import base64
+import json
+import sys
+
+
+def decode(value: str) -> str:
+ return base64.b64decode(value).decode("utf-8") if value else ""
+
+
+def attach(container, name, value):
+ if isinstance(container, list):
+ container.append(value)
+ else:
+ container[name] = value
+
+
+root = {}
+stack = [root]
+
+with open(sys.argv[1], encoding="utf-8") as operations:
+ for raw_line in operations:
+ kind, encoded_name, encoded_value = raw_line.rstrip("\n").split("\t")
+ name = decode(encoded_name)
+ value = decode(encoded_value)
+ if kind == "close":
+ stack.pop()
+ continue
+ if kind == "object":
+ child = {}
+ attach(stack[-1], name, child)
+ stack.append(child)
+ continue
+ if kind == "array":
+ child = []
+ attach(stack[-1], name, child)
+ stack.append(child)
+ continue
+ if kind == "int":
+ value = int(value)
+ elif kind == "boolean":
+ value = value not in ("", "0", "false", "False")
+ attach(stack[-1], name, value)
+
+json.dump(root, sys.stdout, ensure_ascii=False)
+sys.stdout.write("\n")
diff --git a/UA-Mask/scripts/build-release.sh b/UA-Mask/scripts/build-release.sh
new file mode 100755
index 000000000..62cb1f1d3
--- /dev/null
+++ b/UA-Mask/scripts/build-release.sh
@@ -0,0 +1,249 @@
+#!/bin/sh
+set -eu
+umask 022
+
+usage() {
+ cat <<'EOF'
+Usage:
+ build-release.sh --platform linux --arch ARCH [options]
+ build-release.sh --platform openwrt --arch ARCH --sdk-dir DIR [options]
+
+Options:
+ --version VERSION Version injected into the binary (default: VERSION file)
+ --commit SHA Source commit recorded in metadata (default: git HEAD)
+ --output-dir DIR Artifact directory (default: dist)
+ --sdk-dir DIR Prepared OpenWrt SDK directory (required for openwrt)
+ --openwrt-version V OpenWrt version recorded in package metadata
+ --target TARGET OpenWrt target recorded in package metadata
+ --subtarget TARGET OpenWrt subtarget recorded in package metadata
+ --package-arch ARCH OpenWrt package architecture and APK filename suffix
+ --package-format FMT Expected OpenWrt package format: ipk or apk
+ --allow-dirty Permit a dirty tree and record it in metadata
+
+Supported architectures:
+ amd64, 386, arm64, armv5, armv6, armv7,
+ mips-softfloat, mipsle-softfloat, riscv64
+EOF
+}
+
+die() {
+ echo "build-release: $*" >&2
+ exit 1
+}
+
+platform=
+arch=
+version=
+commit=
+output_dir=dist
+sdk_dir=
+openwrt_version=
+target=
+subtarget=
+package_arch=
+package_format=
+allow_dirty=false
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --platform|--arch|--version|--commit|--output-dir|--sdk-dir|--openwrt-version|--target|--subtarget|--package-arch|--package-format)
+ [ "$#" -ge 2 ] || die "missing value for $1"
+ case "$1" in
+ --platform) platform=$2 ;;
+ --arch) arch=$2 ;;
+ --version) version=$2 ;;
+ --commit) commit=$2 ;;
+ --output-dir) output_dir=$2 ;;
+ --sdk-dir) sdk_dir=$2 ;;
+ --openwrt-version) openwrt_version=$2 ;;
+ --target) target=$2 ;;
+ --subtarget) subtarget=$2 ;;
+ --package-arch) package_arch=$2 ;;
+ --package-format) package_format=$2 ;;
+ esac
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ --allow-dirty)
+ allow_dirty=true
+ shift
+ ;;
+ *) die "unknown argument: $1" ;;
+ esac
+done
+
+[ -n "$platform" ] || die "--platform is required"
+[ -n "$arch" ] || die "--arch is required"
+
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
+
+if [ -z "$version" ]; then
+ version=$(tr -d '\r\n' < "$repo_root/VERSION")
+fi
+if [ -z "$commit" ]; then
+ commit=$(git -C "$repo_root" rev-parse HEAD)
+fi
+dirty=false
+if [ -n "$(git -C "$repo_root" status --porcelain --untracked-files=normal)" ]; then
+ dirty=true
+ [ "$allow_dirty" = true ] || die "working tree is dirty; commit the source or use --allow-dirty for a non-release build"
+fi
+
+case "$version" in
+ *[!0-9A-Za-z._+-]*|'') die "version contains unsupported characters: $version" ;;
+esac
+case "$commit" in
+ *[!0-9A-Fa-f]*|'') die "commit must be a hexadecimal Git object ID" ;;
+esac
+
+goarch=
+goarm=
+gomips=
+case "$arch" in
+ amd64|386|arm64|riscv64) goarch=$arch ;;
+ armv5) goarch=arm; goarm=5 ;;
+ armv6) goarch=arm; goarm=6 ;;
+ armv7) goarch=arm; goarm=7 ;;
+ mips-softfloat) goarch=mips; gomips=softfloat ;;
+ mipsle-softfloat) goarch=mipsle; gomips=softfloat ;;
+ *) die "unsupported architecture: $arch" ;;
+esac
+
+case "$platform" in
+ linux|openwrt) ;;
+ *) die "unsupported platform: $platform" ;;
+esac
+if [ "$platform" = openwrt ]; then
+ [ -n "$sdk_dir" ] || die "--sdk-dir is required for openwrt builds"
+ [ -f "$sdk_dir/include/toplevel.mk" ] || die "not an OpenWrt SDK directory: $sdk_dir"
+ [ -n "$openwrt_version" ] || die "--openwrt-version is required for openwrt builds"
+ [ -n "$target" ] || die "--target is required for openwrt builds"
+ [ -n "$subtarget" ] || die "--subtarget is required for openwrt builds"
+ [ -n "$package_arch" ] || die "--package-arch is required for openwrt builds"
+ case "$package_format" in
+ ipk|apk) ;;
+ *) die "--package-format must be ipk or apk for openwrt builds" ;;
+ esac
+ for value in "$openwrt_version" "$target" "$subtarget" "$package_arch"; do
+ case "$value" in
+ *[!0-9A-Za-z._+-]*|'') die "OpenWrt metadata contains unsupported characters: $value" ;;
+ esac
+ done
+fi
+
+mkdir -p "$output_dir"
+output_dir=$(CDPATH= cd -- "$output_dir" && pwd)
+work_dir=$(mktemp -d "${TMPDIR:-/tmp}/uamask-release.XXXXXX")
+package_link=
+cleanup() {
+ if [ -n "$package_link" ]; then
+ rm -f "$package_link"
+ fi
+ rm -rf "$work_dir"
+}
+trap cleanup EXIT HUP INT TERM
+
+binary="$work_dir/UAmask"
+(
+ cd "$repo_root/core"
+ export CGO_ENABLED=0 GOOS=linux GOARCH="$goarch"
+ if [ -n "$goarm" ]; then
+ export GOARM="$goarm"
+ fi
+ if [ -n "$gomips" ]; then
+ export GOMIPS="$gomips"
+ fi
+ go build -trimpath -ldflags "-s -w -X main.version=$version" \
+ -o "$binary" ./cmd/UAmask
+)
+
+# apk-tools accepts only numeric dotted upstream versions followed by its
+# generated "-rN" release suffix. Canary and CI identifiers therefore remain
+# in the binary/artifact metadata, while a non-release APK uses the repository
+# VERSION as its installable package version. Release tags require VERSION and
+# requested version to match, so published APKs remain fully versioned.
+package_version=$version
+if [ "$platform" = openwrt ] && [ "$package_format" = apk ]; then
+ base_version=$(tr -d '\r\n' < "$repo_root/VERSION")
+ if [ "$version" != "$base_version" ]; then
+ package_version=$base_version
+ fi
+fi
+
+write_metadata() {
+ artifact=$1
+ metadata=$2
+ digest=$(sha256sum "$artifact" | awk '{print $1}')
+ go_version=$(go version | sed 's/"/\\"/g')
+ cat > "$metadata" < "$artifact.sha256"
+}
+
+if [ "$platform" = linux ]; then
+ artifact="$output_dir/UAmask-linux-$arch"
+ cp "$binary" "$artifact"
+ chmod 0755 "$artifact"
+ write_metadata "$artifact" "$artifact.metadata.json"
+ printf '%s\n' "$artifact"
+ exit 0
+fi
+
+package_dir="$sdk_dir/package/UAmask"
+[ ! -e "$package_dir" ] || die "SDK package path already exists: $package_dir"
+ln -s "$repo_root/openwrt/package" "$package_dir"
+package_link=$package_dir
+
+make -C "$sdk_dir" defconfig \
+ UAMASK_SOURCE_DIR="$repo_root" \
+ UAMASK_PREBUILT="$binary" \
+ UAMASK_VERSION="$package_version"
+make -C "$sdk_dir" package/UAmask/clean \
+ UAMASK_SOURCE_DIR="$repo_root" \
+ UAMASK_PREBUILT="$binary" \
+ UAMASK_VERSION="$package_version"
+make -C "$sdk_dir" package/UAmask/compile V=s \
+ UAMASK_SOURCE_DIR="$repo_root" \
+ UAMASK_PREBUILT="$binary" \
+ UAMASK_VERSION="$package_version"
+
+packages_file="$work_dir/packages"
+case "$package_format" in
+ ipk) find "$sdk_dir/bin" -type f -name 'UAmask_*.ipk' > "$packages_file" ;;
+ apk) find "$sdk_dir/bin" -type f -name 'UAmask-*.apk' > "$packages_file" ;;
+esac
+[ -s "$packages_file" ] || die "OpenWrt SDK completed without producing a UAmask package"
+
+while IFS= read -r package; do
+ package_name=$(basename "$package")
+ if [ "$package_format" = apk ]; then
+ case "$package_name" in
+ *"_$package_arch.apk") ;;
+ *) package_name=${package_name%.apk}_$package_arch.apk ;;
+ esac
+ fi
+ artifact="$output_dir/$package_name"
+ cp "$package" "$artifact"
+ write_metadata "$artifact" "$artifact.metadata.json"
+ printf '%s\n' "$artifact"
+done < "$packages_file"
diff --git a/luci-app-liteportal/.gitattributes b/luci-app-liteportal/.gitattributes
new file mode 100644
index 000000000..0c2d30b76
--- /dev/null
+++ b/luci-app-liteportal/.gitattributes
@@ -0,0 +1,60 @@
+# 换行与文本/二进制判定。
+#
+# 本仓库此前只有 CRLF 工作区 + 全局 core.autocrlf=true:索引里存的是 LF,
+# 检出后却变成 CRLF。对本项目这不是 cosmetic 问题——
+# * root/etc/init.d/liteportal 变成 CRLF 后,设备上会 `bad interpreter: /bin/sh^M`;
+# * htdocs/ 下的 JS 变成 CRLF 后,LuCI 仍能跑,但 diff 全是噪声。
+# 因此这里显式声明,覆盖全局 autocrlf。
+
+# 默认:文本自动判定,库内与工作区一律 LF
+* text=auto eol=lf
+
+# 脚本必须 LF(设备上由 /bin/sh 解释)
+*.sh text eol=lf
+*.uc text eol=lf
+*.js text eol=lf
+
+# 无扩展名或易被误判的文本文件,显式声明
+LICENSE text eol=lf
+.gitignore text eol=lf
+.gitattributes text eol=lf
+Makefile text eol=lf
+*.json text eol=lf
+*.css text eol=lf
+*.html text eol=lf
+*.po text eol=lf
+*.pot text eol=lf
+
+# Windows 专属文件需要 CRLF(本项目当前没有,留作后续扩展)
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
+
+# 二进制:禁止换行转换与文本 diff
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.webp binary
+*.ico binary
+*.icns binary
+*.pdf binary
+*.woff binary
+*.woff2 binary
+*.ttf binary
+*.otf binary
+*.apk binary
+*.ipk binary
+*.zip binary
+*.gz binary
+*.zst binary
+*.xz binary
+*.tar.gz binary
+*.tar.zst binary
+*.tar.xz binary
+*.exe binary
+*.dll binary
+*.so binary
+*.dylib binary
+*.lib binary
+*.a binary
diff --git a/luci-app-liteportal/.gitignore b/luci-app-liteportal/.gitignore
new file mode 100644
index 000000000..05e6d5993
--- /dev/null
+++ b/luci-app-liteportal/.gitignore
@@ -0,0 +1,46 @@
+# --- Build artefacts (SDK / buildroot output) ---
+/bin/
+/build_dir/
+/staging_dir/
+/tmp/
+/dl/
+/feeds/
+/logs/
+# local scratch for an out-of-tree SDK build and the device test script
+/.build/
+.config
+.config.old
+.config.orig
+*.o
+*.so
+*.a
+# ucode compiles to a binary beside the source when built by hand
+uc.out
+
+# --- Package output ---
+*.apk
+*.ipk
+*.tar.gz
+*.tar.zst
+*.tar.xz
+
+# --- Editor / OS ---
+.vscode/
+.idea/
+*.swp
+*~
+.DS_Store
+Thumbs.db
+
+# --- WorkBuddy / local scratch ---
+.workbuddy/
+# freedesktop trash created by `gio trash` inside the tree
+.Trash-*/
+
+# --- Secrets (never commit) ---
+*.key
+*.pem
+.env
+.env.*
+!.env.example
+!*.env.example
diff --git a/luci-app-liteportal/AGENTS.md b/luci-app-liteportal/AGENTS.md
new file mode 100644
index 000000000..1c9f20bce
--- /dev/null
+++ b/luci-app-liteportal/AGENTS.md
@@ -0,0 +1,150 @@
+# AGENTS.md — 给 AI 编码助手的行为规则
+
+> 本文件供 AI 编码助手(WorkBuddy / Claude Code / Cursor 等)自动读取。
+> **开始任务前先读完本文件;需求含糊时先提问,不要臆造。**
+>
+> 本项目遵循 [ai-template-repository](https://github.com/cocolight/ai-template-repository)
+> 的协作规范;与模板的差异(无编译型测试、以语法级门禁替代单测)在下文各节就地说明。
+
+## 0. 项目
+
+luci-app-liteportal — 给 OpenWrt / ImmortalWrt 用的静态 HTML 导航页:页面由路由器上现有的
+`uhttpd` 以子路径 `/liteportal/` 提供(不起任何独立进程),全部配置通过 LuCI 管理并持久化在 `uci`。
+
+技术栈:LuCI JavaScript 视图 + ucode(rpcd 插件)+ POSIX shell(procd init 脚本)。
+**没有编译型单元测试**,因此质量门禁是「语法级检查 + 交叉引用一致性」,见 §2。
+
+## 1. 上下文入口(按此顺序阅读)
+
+1. `AGENTS.md`(本文件)— 规则与红线
+2. `ROADMAP.md` — 当前任务、已知缺陷与「验收标准」
+3. `README.md` — 定位、安装、uci 参考、排查手册(英文);`README.zh-CN.md` 为中文版
+4. `docs/architecture.md` — 目录 / 分层 / ubus 契约 / 错误处理 / 命名规则
+5. `docs/definition-of-done.md` — 完成定义(DoD)
+6. `docs/adr/` — 已接受的架构决策(改动相关模块前先读)
+7. 与任务相关的源码:`htdocs/luci-static/resources/liteportal/common.js`、
+ `htdocs/luci-static/resources/view/liteportal/*.js`、`root/usr/share/rpcd/ucode/liteportal.uc`、
+ `root/etc/init.d/liteportal`
+
+> 事实以源码为准:用 `read` 看结构、`grep` 查事实,文档只给坐标。
+
+## 2. 常用命令(可直接复制执行)
+
+> **未运行过命令,不得声称「已通过检查」。** 本地无 WSL 时用 `python3 -m json.tool` 代替。
+
+| 目的 | 命令 | 说明 |
+|------|------|------|
+| 依赖安装 | 无 | 纯脚本项目,无包管理器依赖;构建需另配 ImmortalWrt SDK(见 [docs/configuration.md](docs/configuration.md) §6) |
+| JS 语法检查 | `for f in htdocs/luci-static/resources/liteportal/*.js htdocs/luci-static/resources/view/liteportal/*.js root/usr/share/liteportal/www/liteportal.js; do node --check "$f" || exit 1; done` | 5 个 JS 文件;LuCI 视图与门户前端 |
+| shell 检查 | `shellcheck -s sh root/etc/init.d/liteportal` | 唯一的 shell 脚本;`# shellcheck shell=ash` 已声明 |
+| JSON 校验 | `for f in root/usr/share/luci/menu.d/*.json root/usr/share/rpcd/acl.d/*.json; do python3 -m json.tool "$f" >/dev/null || exit 1; done` | 菜单注册与 rpcd ACL |
+| 翻译校验 | `msgfmt --check -o /dev/null po/zh_Hans/liteportal.po` | 需 gettext;CI 用 `sudo apt-get install -y gettext` |
+| 菜单 → 视图 | `sh scripts/check.sh`(含该项) | `menu.d` 的每个视图 `path` 都要有对应的 `view/.js`;漏了是整页空白 |
+| 运行时标识 | `sh scripts/check.sh`(含该项) | 以 `Makefile` 的 `PKG_NAME` 推导期望值,比对 5 个文件里的路径常量、ubus 对象与 uci 包名 |
+| 行尾检查 | `git ls-files --eol \| grep -v 'i/lf.*w/lf' ` | 期望无输出;有输出说明工作区未按 `.gitattributes` 归一化 |
+| 占位符残留 | `sh scripts/check.sh`(含该项) | 期望输出 `none`;模板占位符不得残留 |
+| 本地全量 | `sh scripts/check.sh` | 与 CI 完全一致(CI 调用的就是它);**新增检查只加在这一处** |
+| 构建 .apk | 见 [README.md](README.md) §Building | 需匹配目标固件的 SDK,耗时数分钟,不适合每次改动都跑 |
+
+> `scripts/check.sh` 是 §2 表中除「依赖安装 / 构建 / 行尾检查」外的全部检查,
+> 且与 `.github/workflows/build.yml` 跑同一个脚本 —— **改检查只改一处**,避免本地与 CI 漂移。
+
+## 3. 工作流
+
+- 保护分支 `main`,禁止直接推送。
+- 一个功能 = 一个 `feature/<名称>` 分支 = 一个 PR。
+- **新功能开发前,先在 `ROADMAP.md` 加一行**(状态 `planned`、写清**可判定**的验收标准),
+ 确认后才转 `in-progress` 开干;小改动 / bugfix 无需走计划。
+- 提交:Conventional Commits,type 用英文前缀(`feat`/`fix`/`docs`/`test`/`chore`/`refactor`/`ci`);描述可用中文;标题总长 ≤ 72 字符。
+- 每完成一项,更新 `ROADMAP.md` 对应行状态,并在 `CHANGELOG.md` 的 `[Unreleased]` 对应分类补一条。
+- **本项目特有**:改动任何 ubus 方法时,必须同步核对
+ `root/usr/share/rpcd/acl.d/luci-app-liteportal.json` 的白名单 —— 只改 `.uc` 不改 ACL 的结果是
+ `Access denied`,而这类失败在本地看不出来。
+
+## 4. 红线(违反即回滚)
+
+红线分两级:**阻断级**是底线,触碰即回滚;**提醒级**是默认做法,偏离须在 PR 说明里写明理由。
+
+**阻断级(不可触碰)**
+
+1. 不得删除 / 跳过 / 弱化检查(含从 `scripts/check.sh` 摘掉一项、给它加 `|| true`、放宽断言),不得伪造「检查已通过」。
+2. 大改动先确认:单次改动 > 5 个文件 或 > 200 行,先给方案,等确认再动手。
+3. 不得擅自 `git commit` / `push` / `rebase` / `reset` / `--force`、切分支或改远程;仅在明确要求时执行。
+4. 不得为了让检查「变绿」而修改 CI、hook、lint 配置或门禁。
+5. 不得修改 `docs/adr/` 中「已接受」的记录;变更须新增 ADR。
+6. 绝不提交密钥 / token / 私钥 / `.env`(用环境变量或仓库外文件)。
+7. **本项目特有**:不得引入编译型依赖或构建步骤 —— 本项目是纯脚本包,引入工具链会让
+ 「clone 即可审查」这一性质失效,且 CI 无法在合理时间内验证。
+
+**提醒级(须写明理由)**
+
+8. 含糊需求先提问;同一问题连续 2 次修复失败,停下说明现状并求助。
+9. 只改与任务相关的文件,不做无关重构、不顺手改格式。
+10. **本项目特有**:不得把 `root/etc/init.d/liteportal` 之外的 shell 逻辑塞进 LuCI 视图 ——
+ 浏览器侧无法读取 `/proc/net/tcp[6]`,也拿不到 `uci` 之外的系统状态。
+
+## 5. 完成定义(DoD)
+
+详见 `docs/definition-of-done.md`。任一功能完成须同时满足:
+
+- [ ] `ROADMAP.md` 该行「验收标准」全部满足
+- [ ] `bash scripts/check.sh` 全部通过(输出 `ALL CHECKS PASSED`)
+- [ ] 改过 ubus 方法则 ACL 白名单已同步
+- [ ] 新增的用户可见字符串已进 `po/zh_Hans/liteportal.po` 与 `po/templates/liteportal.pot`
+- [ ] 相关文档(README / AGENTS / architecture / ADR)同步更新
+- [ ] `CHANGELOG.md` 的 `[Unreleased]` 已记录本次改动
+- [ ] `ROADMAP.md` 状态更新为 `done`
+
+## 6. 并发协作(多 AI / 多人)
+
+- 一个分支只由一个执行者写入;动手前先 `git fetch`,避免同文件并发编辑。
+- 遇到冲突立即停止并说明,不要擅自 `--force`。
+
+## 7. 安全
+
+- 绝不提交密钥 / token / 私钥。
+- **`scripts/check.sh` 必须保持可运行**(它是本项目全部自动化门禁的载体)。
+- **本项目特有**:ACL 文件是安全边界。收紧可以(少给权限),放宽必须说明理由 ——
+ `write.ubus` 里的 `generate` / `upload` / `rename` / `remove` 都能改设备上的文件。
+- **本项目特有**:上传文件名白名单(ASCII 字母数字与 `.` `-` `_`)与 2 MiB 上限
+ 在前端与后端**各做一次**,不得因为「前端已经拦了」就删掉后端校验。
+
+## 8. 单一事实源坐标
+
+> 本表只给「去哪查」,不给「查到什么」。事实层(版本号、协议、配置项、数据结构)一律以代码为准。
+> 任何文档都**不得**维护「完整文件清单 / 完整设置项清单」这类每次提交都漂移的枚举;源码是唯一事实源。
+
+| 契约 / 事实 | 唯一事实源在哪 |
+|---|---|
+| uci 配置项与默认值 | `root/etc/config/liteportal`(默认值)+ LuCI 表单 `htdocs/luci-static/resources/view/liteportal/*.js` |
+| ubus 方法契约 | `root/usr/share/rpcd/ucode/liteportal.uc` 的 `const methods = {…}` 表(每行一条);ACL 白名单在 `root/usr/share/rpcd/acl.d/luci-app-liteportal.json` |
+| `links.json` 结构 | 生产者 `liteportal.uc` 的 `generate()`;消费者 `root/usr/share/liteportal/www/liteportal.js` |
+| 门户静态文件的落点与 URL 前缀 | `liteportal.uc` 的 `WWW` / `URL_BASE`、`root/etc/init.d/liteportal` 的 `LITEPORTAL_WWW`、`acl.d` 的文件白名单、`assets.js` 的 `ICON_DIR`/`BG_DIR`/`PORTAL_PATH` —— 同一套值,改一处就是五处一起改(`scripts/check.sh` 的 `runtime identifiers agree` 逐处核对) |
+| 端口 → 服务分类规则 | `liteportal.uc` 的 `classify_port()` |
+| 门户页外观百分比语义 | `liteportal.uc` 的 `percent()` + `root/usr/share/liteportal/www/liteportal.css` 的 CSS 自定义属性 |
+| 版本号 | `Makefile` 的 `PKG_VERSION` / `PKG_RELEASE`;安装示例版本号在 `README.md` / `README.zh-CN.md` |
+| 质量门禁 | `scripts/check.sh`(本地与 CI 同源,`.github/workflows/build.yml` 只调用它) |
+
+## 9. 同改矩阵
+
+> 改了什么,同一回合必须过一遍哪些地方;只改一处导致两份文档打架是禁止的。
+
+| 改了什么 | 同回合必须过一遍 |
+|---|---|
+| `liteportal.uc` 的 `const methods = {…}` | `root/usr/share/rpcd/acl.d/luci-app-liteportal.json` 白名单(漏了 = 运行时 `Access denied`) |
+| 用户可见字符串 | `po/templates/liteportal.pot` + `po/zh_Hans/liteportal.po`(漏了中文界面会漏出英文) |
+| `Makefile` 的 `PKG_VERSION` / `PKG_RELEASE` | `README.md` / `README.zh-CN.md` 安装示例的版本号 |
+| `menu.d` 的视图 `path` | 对应的 `htdocs/luci-static/resources/view/.js` 必须存在(漏了 = 设置页整页空白) |
+| uci 配置项 / 设置键 | 本文件 §8 坐标表对应行 + `README*.md` 的 *uci reference* |
+| 门户静态文件的落点 / 子路径 | 本文件 §8 坐标表该行(`liteportal.uc` 的 `WWW`+`URL_BASE`、`init.d/portal` 的 `LITEPORTAL_WWW`、ACL 文件白名单、`assets.js` 的三个常量)+ `Makefile` 的 postinst / prerm 路径 |
+| 架构 / 分层 / 不变式 | `docs/architecture.md`;需要留痕的追加 `docs/adr/` |
+| 命令或门禁 | 本文件 §2 → `CONTRIBUTING.md`「本地检查」→ `.github/workflows/build.yml`,三者必须一致 |
+| 红线 / DoD | `docs/definition-of-done.md` 同步(不得放宽) |
+| 章节标题 | 全仓扫一遍按「§N」引用本文件的地方 |
+| 决策理由变更 | 新增一条 ADR,`docs/adr/` 旧记录原样保留 |
+| 发版(打 tag / 建 Release) | 对外版本用**纯语义化版本号,不带 `-r`**。 旧约定 `v-r` 已发布的 `v3.0.1-r1` 等**保留不变**,本规则自下一版 `v3.0.2` 起生效。 ① `PKG_VERSION` 变化(semver 升级):tag 与 release title 均为 `v`(如 `v3.0.2`); ② 仅升 `PKG_RELEASE`、不改 `PKG_VERSION` 的纯打包重发:tag 与 release title 为 `v+`(如 `v3.0.1+2`),**不新建 CHANGELOG 段落**,只在对应 `[]` 段下注明「PKG_RELEASE → N(仅打包修正)」并回传 `.apk` 资产; ③ `CHANGELOG.md` 的 `[Unreleased]` 改名为 `[] - 日期`(不带 v、不带 `-r`/`+N`),顶部补空 `[Unreleased]`。 注意:`Makefile` 的 `PKG_VERSION`/`PKG_RELEASE` 仍照常填写 —— OpenWrt 包名恒为 `PKG_VERSION-rPKG_RELEASE`(如 `luci-app-liteportal-4.0.0-r1`),**不受本条 tag 规则影响**。 |
+
+其中「`liteportal.uc` 方法表」「用户可见字符串」「门户静态文件的落点 / 子路径」
+「`menu.d` 的视图 `path`」四行漏掉时 `scripts/check.sh` 会抓到 —— 前两行是逐项断言,
+后两行由 `runtime identifiers agree`(按 `Makefile` 的 `PKG_NAME` 推导期望值后逐处比对)
+与 `menu entries point at views that exist` 两节覆盖;其余几行不会 —— 靠这张表和 review。
diff --git a/luci-app-liteportal/CHANGELOG.md b/luci-app-liteportal/CHANGELOG.md
new file mode 100644
index 000000000..23890e9ea
--- /dev/null
+++ b/luci-app-liteportal/CHANGELOG.md
@@ -0,0 +1,463 @@
+# Changelog
+
+本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 与
+[语义化版本](https://semver.org/lang/zh-CN/)。
+
+## [Unreleased]
+
+### Fixed
+
+- 门户页读取 `links.json` 时跳过浏览器缓存(`fetch('links.json', { cache: 'no-store' })`)。
+ `links.json` 是静态文件,`uhttpd` 只给它 `Last-Modified` 与 `ETag`、没有 `Cache-Control`,
+ 浏览器因此按「文件年龄的 10%」做启发式缓存 —— 一天没动过的门户会被当作两小时新鲜。
+ 后果是改完「背景遮罩强度 / 背景磨砂模糊 / 书签透明度」保存后刷新,页面仍在读旧
+ `links.json`,三个滑块看起来全部失效,只有硬刷新才生效。文件只有几百字节,每次重取
+ 没有可感知代价。`scripts/check.sh` 增加一条断言防止该选项被当成冗余优化删掉。
+- `prerm` 不再整体删除 `/www/liteportal`:只移除包自己发布的四个文件(`index.html`、
+ `liteportal.js`、`liteportal.css`、`links.json`),用户上传的 `bg/` 与 `icons/` 保留 ——
+ 卸载后 `uci` 仍指向这些文件,整目录删除会让重装的页面缺图标与背景,且没有任何提示。
+ 这与 `init.d` 的 `unpublish()` 原则一致:只删发布物,目录用 `rmdir` 只删空的。
+- 清掉 `immortalwrt/luci` 仓库 `eslint.config.mjs` 报出的四个 error,行为不变:
+ `assets.js` 字符类里的 `\-` 与 `liteportal.js` 正则里的 `\/` 在字符类/正则字面量中本来就
+ 无需转义(`no-useless-escape`),`rename_file()` 中 `return ui.showModal(...)` 之后的
+ `return null` 永不执行(`no-unreachable`)。三组正则替换前后在 17 个用例上逐一比对输出
+ 一致。余下 18 条 `'common' is not defined` 是跨文件引入模块变量的既有形态 —— 仓库把
+ `no-undef` 设为 `warn`,上游 `luci-app-firewall` 同样有 11 条,不在本包处理范围。
+
+### Changed
+
+- `Makefile` 按上游 `immortalwrt/luci` 的规范改造,为提交官方源做准备:
+ - 补 `PKG_NAME`,删除 `LUCI_NAME`(仓库目录名已是 `luci-app-liteportal`,
+ `luci.mk` 的 `notdir $(CURDIR)` 可以直接推导);`PKG_PO_VERSION` 保留并改写注释 ——
+ 本仓库自行发布 `.apk` 资产,翻译包版本需与主包对齐;
+ - `PKG_MAINTAINER` 补邮箱,并同时设 `LUCI_MAINTAINER` —— `luci.mk` 用后者填包的
+ `Maintainer` 元数据(前者为 CONTRIBUTING 要求的字段),只写一处会漏;
+ - 删除 `Build/Prepare` 中复制 `LICENSE` 与 `chmod 0755` init 脚本的兜底:`PKG_LICENSE_FILES`
+ 仅作元数据、不读文件,而 init 脚本在 git 里已是 `100755`、`luci.mk` 用 `cp -pR` 保留模式;
+ - `postinst` / `prerm` 里同样的 `chmod` 兜底一并删除,并清掉 `postinst` 中讲 4.0.0 改名与
+ apk 钩子的历史注释(其中「see README, "Upgrading from 3.x"」已是悬空引用);
+ - `include` 改为按存在性选择 `$(TOPDIR)/feeds/luci/luci.mk` 或 `../../luci.mk`,
+ 同一份文件既能在 SDK 的 `package/` 软链下构建,也能在上游 `applications//` 下构建。
+- `scripts/check.sh` 的运行时标识检查改为从 `PKG_NAME` 推导期望值(`LUCI_NAME` 作为回退),
+ 断言强度不变;原先「`prerm` 的 `rm -rf` 等于门户目录」一条随 `prerm` 改写为更强的断言:
+ 禁止出现 `rm -rf`,并逐一核对四个发布文件都在门户目录下。
+ `AGENTS.md` 与 `docs/definition-of-done.md` 的相关表述同步。
+- README(英文与 `zh-CN`)新增两张示意图,以两列表格并排放在开头:左为 LuCI 设置页,
+ 右为门户页(`docs/images/portal-page.png`,替换先前的 jpg 版本)。两张图均为设备实拍
+ 截图,统一缩放至 1200 px 宽、按 `width="400" height="232"` 渲染 —— 原图比例本来接近
+ (1200×690 与 1200×698),固定同一显示盒子后形变不足 1%,远小于「只统一宽度」时
+ 15 px 的高度差在并排表格里造成的错位。
+
+## [4.0.1] - 2026-10-17
+
+### Changed
+
+- 设置页「搜索框」开关的说明文字补充唤出方式:英文源串改为
+ `Show a search box on the portal page; press Space to summon it.`,
+ 中文译为「在门户页上显示搜索框,按空格键唤出。」(门户页的空格键唤出逻辑本就存在,
+ 此前文案未提及)。
+- README(英文与 `zh-CN`)重写为「功能 / 搜索框用法 / 安装 / 原理」四段式:新增
+ *Using the search box* 小节(空格唤出、空框空格收起、`Esc` 收起、回车提交、输入法
+ 优先、非空框内空格即空格),删去 3.x → 4.0.0 的升级与迁移说明及全部「上一版如何」
+ 类历史叙述;同时修正两处失实描述 —— 开头仍写作「独立的 uhttpd 实例/自选端口」,
+ 与现状(主 uhttpd 的 `/liteportal/` 子路径)矛盾;uci 示例的 section 名仍写
+ `config portal`,实际为 `config liteportal`。
+
+## [4.0.0] - 2026-10-17
+
+### Changed
+
+- **运行时标识全量重命名 `portal` → `liteportal`(ROADMAP #32,BREAKING)**:上一版
+ 只改了包名,本次把所有构成对外契约的标识一并对齐 ——
+
+ | 旧 | 新 |
+ |---|---|
+ | uci 配置 `/etc/config/portal`(section 类型 `config portal`) | `/etc/config/liteportal`(`config liteportal`) |
+ | ubus 对象 `luci.portal` | `luci.liteportal` |
+ | 服务 `/etc/init.d/portal` | `/etc/init.d/liteportal` |
+ | 菜单路径 `admin/services/portal` | `admin/services/liteportal` |
+ | 门户页 URL `/portal/` | `/liteportal/` |
+ | 静态目录 `/www/portal` | `/www/liteportal` |
+ | 前端模板 `/usr/share/portal/www` | `/usr/share/liteportal/www` |
+ | 上传暂存 `/tmp/portal_upload.tmp` | `/tmp/liteportal_upload.tmp` |
+ | 视图与资源 `view/portal/`、`resources/portal/` | `view/liteportal/`、`resources/liteportal/` |
+ | 后端插件 `portal.uc` | `liteportal.uc` |
+ | 翻译 `po/zh_Hans/portal.po` / `po/templates/portal.pot` | `liteportal.po` / `liteportal.pot` |
+
+ 门户前端资源文件名一并改为 `liteportal.js` / `liteportal.css`;首页包裹层与样式
+ 作用域类改为 `.liteportal-admin` / `.liteportal-panel` / `.liteportal-bm-thumb`。
+ 仓库更名为 `wrttools/luci-app-liteportal`(旧 URL 由 GitHub 重定向)。
+- **升级不做自动迁移**:4.0.0 改名了全部运行时路径,但本包此前只在维护者的测试
+ 虚拟机上运行过,没有存量设备,`postinst` 不搬运旧配置与旧素材。升级方式为
+ 先 `apk del` 旧包、再装新包(见 README 的「从 3.x 升级」)。
+ **机制上本可行**,值得记下来:apk-tools 同名升级时执行**新版本**的 `post-upgrade`
+ 钩子、**不执行旧版本的** `pre-deinstall`;设备实测(3.0.2-r1 → 4.0.0-r1)
+ `/etc/config/portal` 与 `/www/portal` 连同素材原样留在盘上。因此原地覆盖升级会
+ 留下无主残留,`/portal/` 也会继续应答旧页面,直到手工删掉 `/www/portal`。
+- 日志 tag 与 procd reload trigger 由 `portal` 改为 `liteportal`;init 脚本内的
+ `PORTAL_SRC` / `PORTAL_WWW` 常量更名为 `LITEPORTAL_SRC` / `LITEPORTAL_WWW`。
+
+### Fixed
+
+- **更名漏网的三处值**(首次 `scripts/check.sh` 全绿也没抓到,属「跨文件同值」类错误):
+ - `liteportal.uc` 的 `URL_BASE` 与 `assets.js` 的 `PORTAL_PATH` 仍是 `/portal` ——
+ 后端交给页面的素材 URL 前缀与前端自建的缩略图 URL 都会指到已不存在的路径;
+ - `bookmarks.js` 的 `icon_url_of()` 第三分支仍是 `return '/portal' + value`;
+ - `menu.d` 的三个视图 `path` 仍是 `view/portal/*`,而视图文件已移到
+ `view/liteportal/*` —— 按 luci-base `ui.js` 的
+ `'view.%s'.format(path.replace(/\//g, '.'))`,这一处会让三个设置页整页空白。
+
+### Added
+
+- `scripts/check.sh` 新增两节门禁,把上述两类错误从「靠 review」变成「构建期失败」:
+ - `menu entries point at views that exist` —— 校验 `menu.d` 里每个视图 `path` 都有
+ 对应的 `htdocs/luci-static/resources/view/.js`;
+ - `runtime identifiers agree` —— 以 `Makefile` 的 `LUCI_NAME` 推导期望值,逐处比对
+ ucode / init.d / ACL / 两个视图 / `Makefile` 里的子路径、静态目录、上传暂存路径、
+ uci 包名、ubus 对象名与菜单路径,并扫一遍 `htdocs/`、`root/` 与 `Makefile`
+ (注释行除外)是否还残留旧标识。
+ - 两节都做过反向验证:把上述旧值重新注入后门禁报 6 处 FAIL、退出码 1。
+- `.gitignore` 忽略 `gio trash` 在仓库内建的 `.Trash-*/`。
+
+### Note
+
+- 菜单显示名与页面标题仍为 **Portal** —— 那是面向用户的文案,不是运行时标识;
+ 中文界面与上一版完全一致。
+
+## [3.0.2] - 2026-10-16
+
+### Changed
+
+- **软件包更名 `luci-app-portal` → `luci-app-liteportal`**:apk / ipk 包名与
+ 翻译包(`luci-i18n-liteportal-zh-cn`)随之更名,菜单 / ACL 注册文件更名为
+ `menu.d/luci-app-liteportal.json` 与 `acl.d/luci-app-liteportal.json`(ACL 组名
+ 同步)。**运行时标识全部不变**:uci 配置仍为 `/etc/config/portal`、ubus 对象
+ 仍为 `luci.portal`、服务仍为 `/etc/init.d/portal`、菜单路径仍为 `admin/portal`、
+ 门户页仍发布在 `/portal/`。**从旧包升级**须先卸载旧包:
+ `apk del luci-app-portal luci-i18n-portal-zh-cn`(conffile `/etc/config/portal`
+ 会随旧包删除,卸载前请备份,装新包后还原)。
+- **管理页间距规范化(ROADMAP #26–#30,Argon 主题下实测贴边/偏移的五处)**:
+ 三个视图新增共享样式(`common.admin_css()`,经渲染产物内的 `
+
+
+
+
+ 连接详情
+
+]] .. details_meta .. [[
+
+
+
+]]
+end
+
+s = m:section(NamedSection, "main", "main", "配置")
+s.addremove = false
+s.anonymous = true
+s:tab("basic", "基础设置")
+s:tab("advanced", "进阶设置")
+s:tab("log", "日志")
+
+-- 认证策略选择器;school 为兼容已有配置保留的运行时标识。
+school = s:taboption("basic", ListValue, "school", "认证策略")
+-- "default" 是内置通用深澜运行时:不绑定任何学校,作为出厂默认值。
+school:value("default", "通用深澜认证")
+local school_current = util.trim(tostring(cfg.school or ""))
+local school_listed = school_current == "" or school_current == "default"
+for _, sch in ipairs(schools) do
+ if tostring(sch.short_name or "") ~= "default" then
+ school:value(sch.short_name, sch.name)
+ if tostring(sch.short_name or "") == school_current then
+ school_listed = true
+ end
+ end
+end
+-- schools 列表加载失败或不含当前值时保留原值选项,避免一次保存把 school 悄悄改掉。
+if not school_listed then
+ school:value(school_current, school_current)
+end
+function school.cfgvalue()
+ local value = util.trim(tostring(cfg.school or ""))
+ if value == "" then
+ value = "default"
+ end
+ return value
+end
+function school.write(self, section, value)
+ local next_school = util.trim(value or "")
+ if next_school == "" then
+ next_school = "default"
+ end
+ local current_school = util.trim(tostring(cfg.school or ""))
+ if current_school == "" then
+ current_school = "default"
+ end
+ if next_school ~= current_school then
+ school_changed_during_parse = true
+ cfg[SCHOOL_EXTRA_KEY] = {}
+ changed = true
+ school_extra_dirty = true
+ end
+ set_value("school", next_school)
+end
+school.description = render_school_info_html(schools)
+
+if school_runtime_renderable then
+ for idx, descriptor in ipairs(school_runtime_descriptors) do
+ local option_name = "_school_extra_" .. idx .. "_" .. descriptor.key:gsub("[^%w_]", "_")
+ local label = descriptor.label
+ local description = descriptor.description
+ if descriptor.type == "bool" then
+ local opt = s:taboption("basic", Flag, option_name, label, description)
+ bind_school_extra_flag(opt, descriptor, school_extra_write_blocked)
+ elseif descriptor.type == "enum" then
+ local opt = s:taboption("basic", ListValue, option_name, label, description)
+ for _, choice in ipairs(descriptor.choices or {}) do
+ opt:value(choice, choice)
+ end
+ bind_school_extra_text(opt, descriptor, school_extra_write_blocked, function(raw)
+ if raw == "" and not descriptor.required then
+ return ""
+ end
+ for _, choice in ipairs(descriptor.choices or {}) do
+ if raw == choice then
+ return raw
+ end
+ end
+ return nil
+ end)
+ elseif descriptor.type == "int" then
+ local opt = s:taboption("basic", Value, option_name, label, description)
+ function opt.validate(self, value)
+ local raw = util.trim(value or "")
+ if raw == "" and not descriptor.required then
+ return raw
+ end
+ if raw:match("^-?%d+$") then
+ return raw
+ end
+ return nil, "该字段必须是整数"
+ end
+ bind_school_extra_text(opt, descriptor, school_extra_write_blocked, function(raw)
+ if raw == "" and not descriptor.required then
+ return ""
+ end
+ if raw:match("^-?%d+$") then
+ return tostring(tonumber(raw))
+ end
+ return nil
+ end)
+ else
+ local opt = s:taboption("basic", Value, option_name, label, description)
+ bind_school_extra_text(opt, descriptor, school_extra_write_blocked)
+ end
+ end
+end
+
+-- 一键配置:面向没配过校园网的用户。放在学校预设和手动登录之间——先有能用的
+-- 配置,才谈得上手动登录。
+setup_wizard = s:taboption("basic", DummyValue, "_setup_wizard", "一键配置")
+setup_wizard.rawhtml = true
+function setup_wizard.cfgvalue()
+ return [[
+
+ 开始一键配置
+
+
+]]
+end
+
+manual_login = s:taboption("basic", DummyValue, "_manual_login", "手动登录")
+manual_login.rawhtml = true
+function manual_login.cfgvalue()
+ return [[
+
+ 立即登录
+ 立即登出
+
+
+
+]]
+end
+
+enabled = s:taboption("basic", Flag, "enabled", "启用")
+enabled.description = "自动认证、断线重连与定时切网;不影响手动登录、登出。"
+bind_flag(enabled, "enabled")
+
+multi_wan_enabled = s:taboption("basic", Flag, "multi_wan_enabled", "多 WAN 并行认证")
+multi_wan_enabled.description = "同时维护已勾选“参与并行守护”的有线账号。"
+bind_flag(multi_wan_enabled, "multi_wan_enabled")
+
+quiet_desc = "当前下线/上线时间:" .. tostring(cfg.quiet_start or "00:00") .. " / " .. tostring(cfg.quiet_end or "06:00")
+
+quiet_hours_enabled = s:taboption("basic", Flag, "quiet_hours_enabled", "按时段自动上/下线", quiet_desc)
+bind_flag(quiet_hours_enabled, "quiet_hours_enabled")
+
+quiet_start = s:taboption("basic", Value, "quiet_start", "下线时间(北京时间 HH:MM)")
+quiet_start.default = "00:00"
+function quiet_start.validate(self, value)
+ local t = validate_hhmm(value)
+ if t then
+ return t
+ end
+ return nil, "时间格式应为 HH:MM(24小时制)"
+end
+bind_text(quiet_start, "quiet_start", validate_hhmm)
+
+quiet_end = s:taboption("basic", Value, "quiet_end", "上线时间(北京时间 HH:MM)")
+quiet_end.default = "06:00"
+function quiet_end.validate(self, value)
+ local t = validate_hhmm(value)
+ if t then
+ return t
+ end
+ return nil, "时间格式应为 HH:MM(24小时制)"
+end
+bind_text(quiet_end, "quiet_end", validate_hhmm)
+
+force_logout_in_quiet = s:taboption("basic", Flag, "force_logout_in_quiet", "进入下线时段时强制下线")
+bind_flag(force_logout_in_quiet, "force_logout_in_quiet")
+
+failover_enabled = s:taboption("basic", Flag, "failover_enabled", "登出时自动切换热点上网")
+bind_flag(failover_enabled, "failover_enabled")
+
+switch_test = s:taboption("basic", DummyValue, "_switch_test", "手动切换网络")
+switch_test.rawhtml = true
+function switch_test.cfgvalue()
+ return [[
+
+ 切到热点
+ 切回校园网
+ 强制关闭插件
+
+
+手动切网会停用自动登录服务,如需启用请再次手动开启。
+]]
+end
+
+-- 校园网账号表格 + 热点配置表格 + 弹窗
+tables_html = s:taboption("basic", DummyValue, "_tables", "")
+tables_html.rawhtml = true
+function tables_html.cfgvalue()
+ local campus = cfg.campus_accounts or {}
+ local hotspots = cfg.hotspot_profiles or {}
+ local state = load_state()
+ local active_cid = cfg.active_campus_id or ""
+ local default_cid = cfg.default_campus_id or ""
+ local active_hid = cfg.active_hotspot_id or ""
+ local default_hid = cfg.default_hotspot_id or ""
+ local current_mode = tostring(state.current_mode or "")
+ local online_account_label = tostring(state.online_account_label or "")
+ -- 部分学校(如南昌航空)rad_user_info 返回带后缀的完整账号,
+ -- 比对账号池条目时只取 @ 前主体
+ local online_account_main = online_account_label:match("^([^@]+)") or online_account_label
+ local current_ssid = tostring(state.current_ssid or "")
+ local current_bssid = tostring(state.current_bssid or "")
+ local current_iface = tostring(state.current_iface or "")
+ local current_campus_access_mode = tostring(state.current_campus_access_mode or "")
+ local multi_wan_on = tostring(cfg.multi_wan_enabled or "0") == "1"
+ local wired_sessions = type(state.wired_auth_sessions) == "table" and state.wired_auth_sessions or {}
+
+ local radio_labels = { [""] = "自动" }
+ local radio_options = '自动 '
+ for radio, meta in pairs(RADIO_CHOICES) do
+ local label = tostring(meta.label or radio)
+ radio_labels[radio] = label
+ radio_options = radio_options .. '' .. util.pcdata(label) .. ' '
+ end
+
+ -- 构建校园网账号表格行
+ local campus_rows = ""
+ if type(campus) == "table" then
+ for _, a in ipairs(campus) do
+ local aid = tostring(a.id or "")
+ local campus_user = tostring(a.user_id or "")
+ local campus_ssid = tostring(a.ssid or "")
+ local ap_selection = schema.normalize_ap_selection(a.ap_selection, a.bssid)
+ local campus_bssid = ap_selection == "fixed" and util.trim(tostring(a.bssid or "")):lower() or ""
+ local access_mode = tostring(a.access_mode or "wifi")
+ local wired_iface = util.trim(tostring(a.wired_iface or ""))
+ if wired_iface == "" then wired_iface = util.trim(tostring(a.network_interface or "")) end
+ if wired_iface == "" then wired_iface = "wan" end
+ local auth_enabled = tostring(a.auth_enabled or "0") == "1"
+ local is_managed = multi_wan_on and access_mode == "wired" and auth_enabled
+ local wired_session = type(wired_sessions[aid]) == "table" and wired_sessions[aid] or {}
+ local ssid_display = access_mode == "wired" and ("有线 (" .. wired_iface .. ")") or tostring(a.ssid or "")
+ local is_active = (aid == active_cid)
+ local is_default = (aid == default_cid)
+ local wifi_match = current_mode == "campus"
+ and current_campus_access_mode == "wifi"
+ and campus_ssid ~= ""
+ and current_ssid == campus_ssid
+ and ((campus_bssid == "") or (current_bssid == campus_bssid))
+ local wired_match = current_mode == "campus"
+ and current_campus_access_mode == "wired"
+ and access_mode == "wired"
+ and current_iface == wired_iface
+ local identity_match = campus_user ~= "" and online_account_main == campus_user
+ local is_connected = false
+ if access_mode == "wired" then
+ is_connected = wired_match and identity_match
+ else
+ is_connected = wifi_match and identity_match
+ end
+ if is_managed and next(wired_session) ~= nil then
+ is_connected = wired_session.online == true
+ end
+ local badge_parts = {}
+ if is_connected then
+ badge_parts[#badge_parts + 1] = '已认证 '
+ elseif is_managed then
+ local session_labels = {
+ retry_wait = "等待重试",
+ account_mismatch = "账号冲突",
+ config_error = "配置错误",
+ error = "认证失败",
+ paused = "夜间暂停",
+ }
+ local session_status = tostring(wired_session.status or "")
+ local session_label = session_labels[session_status] or "等待检测"
+ local session_color = session_status == "paused" and "#6b7280" or "#dc2626"
+ badge_parts[#badge_parts + 1] = '' .. util.pcdata(session_label) .. ' '
+ end
+ if is_default then
+ if is_active and not is_connected then
+ badge_parts[#badge_parts + 1] = '默认 '
+ elseif not is_active then
+ badge_parts[#badge_parts + 1] = '待生效 '
+ end
+ else
+ badge_parts[#badge_parts + 1] = ("设默认 "):format(util.pcdata(aid))
+ end
+ local badge = table.concat(badge_parts, ' ')
+ campus_rows = campus_rows .. ''
+ .. '' .. badge .. ' '
+ .. '' .. util.pcdata(tostring(a.label or "")) .. ' '
+ .. '' .. util.pcdata(tostring(a.base_url or "")) .. ' '
+ .. '' .. util.pcdata(tostring(a.ac_id or "1")) .. ' '
+ .. '' .. util.pcdata(tostring(a.user_id or "")) .. ' '
+ .. '' .. util.pcdata(tostring(a.operator_suffix or "")) .. ' '
+ .. '' .. util.pcdata(ssid_display) .. ' '
+ .. '' .. (auth_enabled and (multi_wan_on and '参与 ' or '待开启全局开关 ') or '关闭 ') .. ' '
+ .. '' .. (access_mode == "wired" and '—' or util.pcdata(schema.ap_selection_label(ap_selection)) .. (campus_bssid ~= "" and (' ' .. util.pcdata(campus_bssid)) or '')) .. ' '
+ .. '' .. util.pcdata(radio_labels[tostring(a.radio or "")] or tostring(a.radio or "自动")) .. ' '
+ .. ''
+ .. ("编辑 "):format(util.pcdata(aid))
+ .. ("删除 "):format(util.pcdata(aid))
+ .. '
\n'
+ end
+ end
+ if campus_rows == "" then
+ campus_rows = '暂无账号,请点击"新增"添加 '
+ end
+
+ -- 构建热点配置表格行
+ local hotspot_rows = ""
+ if type(hotspots) == "table" then
+ for _, h in ipairs(hotspots) do
+ local hid = tostring(h.id or "")
+ local is_active = (hid == active_hid)
+ local is_default = (hid == default_hid)
+ local hotspot_ssid = tostring(h.ssid or "")
+ local is_connected = current_mode == "hotspot" and hotspot_ssid ~= "" and current_ssid == hotspot_ssid
+ local badge_parts = {}
+ if is_connected then
+ badge_parts[#badge_parts + 1] = '已连接 '
+ end
+ if is_default then
+ if is_active and not is_connected then
+ badge_parts[#badge_parts + 1] = '默认 '
+ elseif not is_active then
+ badge_parts[#badge_parts + 1] = '待生效 '
+ end
+ else
+ badge_parts[#badge_parts + 1] = ("设默认 "):format(util.pcdata(hid))
+ end
+ local badge = table.concat(badge_parts, ' ')
+ hotspot_rows = hotspot_rows .. ''
+ .. '' .. badge .. ' '
+ .. '' .. util.pcdata(tostring(h.label or "")) .. ' '
+ .. '' .. util.pcdata(tostring(h.ssid or "")) .. ' '
+ .. '' .. util.pcdata(tostring(h.encryption or "psk2")) .. ' '
+ .. '' .. util.pcdata(radio_labels[tostring(h.radio or "")] or tostring(h.radio or "自动")) .. ' '
+ .. ''
+ .. ("编辑 "):format(util.pcdata(hid))
+ .. ("删除 "):format(util.pcdata(hid))
+ .. '
\n'
+ end
+ end
+ if hotspot_rows == "" then
+ hotspot_rows = '暂无热点,请点击"新增"添加 '
+ end
+
+ -- 将数据嵌入到前端
+ local campus_json = jsonc.stringify(campus) or "[]"
+ local hotspot_json = jsonc.stringify(hotspots) or "[]"
+ local school_presets_json = jsonc.stringify(school_presets or {}) or "[]"
+ -- 用户自定义预设/运营商由守护进程持有;读不到时按空存储渲染,
+ -- 页面不会拿一份空表去覆盖对方的文件(保存另有 CAS 把关)。
+ local user_presets_raw = "{}"
+ local user_presets = rpc.call("user_presets.get", nil)
+ if type(user_presets) == "table" and type(user_presets.document) == "table" then
+ user_presets_raw = jsonc.stringify(user_presets.document) or "{}"
+ end
+
+ return [[
+
+
+
+
校园网账号
+
+ 状态 标签 认证地址 ACID 学工号 运营商后缀 接入口 并行守护 AP 选择 / 固定 BSSID 频段 操作
+ ]] .. campus_rows .. [[
+
+
+ 新增
+
+
+
+
+
热点配置
+
+ 状态 标签 SSID 加密方式 频段 操作
+ ]] .. hotspot_rows .. [[
+
+
+ 新增
+
+
+
+
+
+
+
+
+]]
+end
+
+local preset_auto_update = s:taboption("advanced", Flag, "preset_auto_update_enabled", "自动更新学校预设")
+preset_auto_update.description = "每天检查一次;关闭后仍可手动更新。"
+bind_flag(preset_auto_update, "preset_auto_update_enabled")
+
+local preset_update_time = s:taboption("advanced", ListValue, "preset_update_time", "每天更新时间(北京时间,24 小时制)")
+for hour = 0, 23 do
+ local value = string.format("%02d:00", hour)
+ preset_update_time:value(value, value)
+end
+-- Keep a precise time configured through the CLI selectable in the form.
+local saved_preset_time = validate_hhmm(cfg.preset_update_time or "")
+if saved_preset_time and saved_preset_time:sub(4) ~= "00" then
+ preset_update_time:value(saved_preset_time, saved_preset_time)
+end
+preset_update_time:depends("preset_auto_update_enabled", "1")
+bind_text(preset_update_time, "preset_update_time", validate_hhmm)
+-- CBI calls remove for an unmet dependency. Keep the chosen time when the
+-- checkbox is off, including in the page rendered immediately after saving.
+function preset_update_time.remove() end
+preset_update_time.description = "到点后检查;错过时间会补查一次。更新失败时继续使用本地预设。"
+
+local preset_refresh = s:taboption("advanced", DummyValue, "_preset_refresh", "学校预设")
+preset_refresh.rawhtml = true
+function preset_refresh.cfgvalue()
+ return [[立即更新 ]]
+end
+
+backoff_enable = s:taboption("advanced", Flag, "backoff_enable", "登录失败时启用退避重试")
+bind_flag(backoff_enable, "backoff_enable")
+
+backoff_max_retries = s:taboption("advanced", Value, "backoff_max_retries", "最大重试次数(0 为无限)")
+backoff_max_retries.datatype = "uinteger"
+bind_text(backoff_max_retries, "backoff_max_retries")
+
+retry_cooldown_seconds = s:taboption("advanced", Value, "retry_cooldown_seconds", "失败后首次重试等待(秒)")
+function retry_cooldown_seconds.validate(self, value)
+ if validate_non_negative_number(value) then
+ return value
+ end
+ return nil, "首次重试等待时长必须是大于等于 0 的数字"
+end
+retry_cooldown_seconds.placeholder = "10"
+bind_text(retry_cooldown_seconds, "retry_cooldown_seconds", validate_non_negative_number)
+
+retry_max_cooldown_seconds = s:taboption("advanced", Value, "retry_max_cooldown_seconds", "退避等待上限(秒)")
+function retry_max_cooldown_seconds.validate(self, value)
+ if validate_non_negative_number(value) then
+ return value
+ end
+ return nil, "退避等待上限必须是大于等于 0 的数字"
+end
+retry_max_cooldown_seconds.placeholder = "60"
+bind_text(retry_max_cooldown_seconds, "retry_max_cooldown_seconds", validate_non_negative_number)
+
+switch_ready_timeout_seconds = s:taboption("advanced", Value, "switch_ready_timeout_seconds", "切网完成等待时间(秒)",
+ "切换后等待获取 IPv4 的最长时间;部分学校 DHCP 较慢(如出现“未获取到IPv4地址”可调大)")
+switch_ready_timeout_seconds.datatype = "uinteger"
+switch_ready_timeout_seconds.placeholder = "30"
+bind_text(switch_ready_timeout_seconds, "switch_ready_timeout_seconds")
+
+manual_terminal_check_max_attempts = s:taboption("advanced", Value, "manual_terminal_check_max_attempts", "手动登录/登出终态最大检查次数")
+function manual_terminal_check_max_attempts.validate(self, value)
+ if validate_non_negative_number(value) then
+ return value
+ end
+ return nil, "手动终态检查次数必须是大于等于 0 的数字"
+end
+manual_terminal_check_max_attempts.placeholder = "5"
+bind_text(manual_terminal_check_max_attempts, "manual_terminal_check_max_attempts", validate_non_negative_number)
+
+manual_terminal_check_interval_seconds = s:taboption("advanced", Value, "manual_terminal_check_interval_seconds", "手动终态校验间隔(秒)")
+manual_terminal_check_interval_seconds.datatype = "uinteger"
+manual_terminal_check_interval_seconds.placeholder = "2"
+bind_text(manual_terminal_check_interval_seconds, "manual_terminal_check_interval_seconds")
+
+hotspot_failback_enabled = s:taboption("advanced", Flag, "hotspot_failback_enabled", "热点切换失败时自动回切校园网")
+bind_flag(hotspot_failback_enabled, "hotspot_failback_enabled")
+
+connectivity_check_mode = s:taboption("advanced", ListValue, "connectivity_check_mode", "在线判定方式")
+connectivity_check_mode:value("internet", "互联网可达")
+connectivity_check_mode:value("portal", "认证网关可达即可")
+connectivity_check_mode:value("ssid", "仅关联到目标 SSID")
+connectivity_check_mode.rmempty = false
+bind_text(connectivity_check_mode, "connectivity_check_mode")
+
+interval = s:taboption("advanced", Value, "interval", "检测间隔(秒)")
+interval.datatype = "uinteger"
+
+local config_backup = s:taboption("advanced", DummyValue, "_config_backup", "配置备份")
+config_backup.rawhtml = true
+function config_backup.cfgvalue()
+ return [[
+
导出配置
+
选择备份文件
+
+
确认导入
+
备份含插件设置、校园账号和热点密码,请妥善保管。自建学校预设需单独备份。导入会替换配置并关闭自动守护,请核对账号和网口后再启用。
+
]]
+end
+bind_text(interval, "interval")
+
+log_level = s:taboption("log", ListValue, "log_level", "日志等级",
+ "ALL = 全部;DEBUG = 含调试细节;INFO = 默认;WARN/ERROR 仅记录警告与错误。")
+log_level:value("ALL", "ALL(全部)")
+log_level:value("DEBUG", "DEBUG(调试)")
+log_level:value("INFO", "INFO(信息,默认)")
+log_level:value("WARN", "WARN(仅警告与错误)")
+log_level:value("ERROR", "ERROR(仅错误)")
+log_level.rmempty = false
+log_level.default = "INFO"
+bind_text(log_level, "log_level")
+
+local log_file_omit_info = s:taboption("log", Flag, "log_file_omit_info", "日志文件不记录 INFO 信息",
+ "默认开启。INFO 信息仍显示在下方运行日志中,但不写入日志文件(下载的日志也不含这些信息);" ..
+ "警告、错误以及服务启动、停止、保存配置、清空日志的记录照常写入。日志等级为 DEBUG 或 ALL 时不生效。")
+log_file_omit_info.default = "1"
+bind_flag(log_file_omit_info, "log_file_omit_info")
+
+log_text = s:taboption("log", DummyValue, "_log_text", "运行日志")
+log_text.rawhtml = true
+function log_text.cfgvalue(self, section)
+ -- 首屏的最近 100 行;之后由页面自己按游标增量轮询。
+ local page = rpc.call("log.tail", { lines = 100 })
+ local t = type(page) == "table" and type(page.lines) == "table"
+ and table.concat(page.lines, "\n") or ""
+ if t ~= "" then
+ t = log_controller.friendly_log_text(t)
+ end
+ if t == "" then
+ t = "暂无日志"
+ end
+
+ local escaped = util.pcdata and util.pcdata(t) or t
+ return [[
+
+
+]]
+end
+
+function m.parse(self, ...)
+ changed = false
+ dirty_scalar_keys = {}
+ school_extra_dirty = false
+ local state = Map.parse(self, ...)
+ if not changed or self.save == false then
+ return state
+ end
+ -- 保存失败必须说出来。旧版无论如何都写“已保存”,于是一次被拒绝的提交
+ -- 看起来和成功一模一样。
+ local saved, failure = save_cfg(cfg)
+ local note = saved and "配置已保存" or ("配置未保存:" .. tostring(failure))
+ if saved then
+ m.uci:set("smart_srun", "main", "_stamp", tostring(os.time()))
+ http.redirect(page_url)
+ end
+ m.message = (m.message and (m.message .. ";") or "") .. note
+ return state
+end
+
+-- 没有 on_before_commit。配置提交后由守护进程自行重新读取和观测,
+-- 页面不再重启服务:服务生命周期只经固定 helper(规范 02)。
+
+return m
diff --git a/smart-srun/root/usr/lib/lua/luci/smart_srun/bridge.lua b/smart-srun/root/usr/lib/lua/luci/smart_srun/bridge.lua
new file mode 100644
index 000000000..0eb2e0873
--- /dev/null
+++ b/smart-srun/root/usr/lib/lua/luci/smart_srun/bridge.lua
@@ -0,0 +1,944 @@
+-- Translation between the frozen interface and the Go configuration contract.
+--
+-- The page keeps the baseline's flat, UCI-style field names; the daemon owns a
+-- typed config v2. One of them has to convert, and it is this file, in one
+-- place, using the destinations recorded in the M00 field mapping. Doing it in
+-- the controller and the CBI page separately is how two halves of one form end
+-- up disagreeing about what "quiet_hours_enabled" means.
+--
+-- Everything above `-- calls` is pure: tables in, tables out. That is
+-- deliberate, so the mapping can be exercised without a socket or a daemon.
+
+local rpc = require "luci.smart_srun.rpc"
+
+local M = {}
+
+function M.config_revision(value)
+ if type(value) == "string" then
+ if value ~= "0" and not value:match("^[1-9]%d*$") then return nil end
+ value = tonumber(value)
+ end
+ if type(value) ~= "number" or value < 0 or value > 9007199254740991
+ or value ~= math.floor(value) then return nil end
+ return value
+end
+
+-- Read the local catalogue completely. Rendering and task polling never fetch
+-- remote data; presets.refresh is the separate bounded network operation.
+function M.presets()
+ local schools, offset, revision = {}, 0, nil
+ for page_number = 1, 128 do
+ local page, err = rpc.call("presets.list", { include_inactive = false, offset = offset, limit = 100 })
+ if not page then return nil, err end
+ if revision and revision ~= page.revision then
+ return nil, { code = "Conflict", message = "预设读取期间发生编辑,请刷新页面" }
+ end
+ revision = page.revision
+ for _, group in ipairs({ page.public or {}, page.user or {} }) do
+ for _, item in ipairs(group) do schools[#schools + 1] = item end
+ end
+ if page.next_offset == nil then return schools end
+ local next_offset = tonumber(page.next_offset)
+ if not next_offset or next_offset <= offset then
+ return nil, { code = "ProtocolInvalid", message = "预设分页响应无效" }
+ end
+ offset = next_offset
+ end
+ return nil, { code = "ProtocolInvalid", message = "预设超过分页上限" }
+end
+
+-- Baseline scalar -> config v2 destination, from
+-- .codex/go-loop/baseline/field-mapping.json. Keys the mapping records as
+-- dropped (developer_mode, the three backoff factors, the two superseded
+-- durations) are absent on purpose: they have no v2 destination, and inventing
+-- one here would resurrect a knob the decisions removed.
+local SCALARS = {
+ enabled = { path = { "enabled" }, kind = "bool" },
+ multi_wan_enabled = { path = { "multi_wan_enabled" }, kind = "bool" },
+ school = { path = { "school" }, kind = "string" },
+ -- The one string the user may legitimately clear: no STA interface named
+ -- means "let the runtime pick", which is a choice, not a missing value.
+ sta_iface = { path = { "sta_iface" }, kind = "string", empty_ok = true },
+ quiet_hours_enabled = { path = { "quiet", "enabled" }, kind = "bool" },
+ quiet_start = { path = { "quiet", "start" }, kind = "string" },
+ quiet_end = { path = { "quiet", "end" }, kind = "string" },
+ preset_auto_update_enabled = { path = { "preset_updates", "enabled" }, kind = "bool" },
+ preset_update_time = { path = { "preset_updates", "time" }, kind = "string" },
+ force_logout_in_quiet = { path = { "quiet", "force_logout" }, kind = "bool" },
+ failover_enabled = { path = { "failover", "enabled" }, kind = "bool" },
+ hotspot_failback_enabled = { path = { "failover", "hotspot_failback_enabled" }, kind = "bool" },
+ backoff_enable = { path = { "retry", "enabled" }, kind = "bool" },
+ backoff_max_retries = { path = { "retry", "max_retries" }, kind = "int" },
+ retry_cooldown_seconds = { path = { "retry", "initial_seconds" }, kind = "number" },
+ retry_max_cooldown_seconds = { path = { "retry", "max_seconds" }, kind = "number" },
+ interval = { path = { "checks", "interval_seconds" }, kind = "int" },
+ connectivity_check_mode = { path = { "checks", "mode" }, kind = "string" },
+ switch_ready_timeout_seconds = { path = { "checks", "switch_timeout_seconds" }, kind = "int" },
+ manual_terminal_check_max_attempts = { path = { "checks", "terminal_attempts" }, kind = "int" },
+ manual_terminal_check_interval_seconds = { path = { "checks", "terminal_interval_seconds" }, kind = "int" },
+ log_level = { path = { "log", "level" }, kind = "string" },
+ log_file_omit_info = { path = { "log", "file_omit_info" }, kind = "bool" },
+ n = { path = { "login_defaults", "n" }, kind = "string" },
+ type = { path = { "login_defaults", "type" }, kind = "string" },
+ enc = { path = { "login_defaults", "enc" }, kind = "string" },
+}
+
+M.SCALARS = SCALARS
+
+local POINTERS = {
+ active_campus_id = "active_campus_id",
+ default_campus_id = "default_campus_id",
+ active_hotspot_id = "active_hotspot_id",
+ default_hotspot_id = "default_hotspot_id",
+}
+
+local function trim(value)
+ return tostring(value or ""):match("^%s*(.-)%s*$")
+end
+
+local function truthy(value)
+ local text = trim(value):lower()
+ return text == "1" or text == "true" or text == "yes" or text == "on"
+end
+
+local function read_path(root, path)
+ local node = root
+ for _, key in ipairs(path) do
+ if type(node) ~= "table" then
+ return nil
+ end
+ node = node[key]
+ end
+ return node
+end
+
+local function write_path(root, path, value)
+ local node = root
+ for index = 1, #path - 1 do
+ local key = path[index]
+ if type(node[key]) ~= "table" then
+ node[key] = {}
+ end
+ node = node[key]
+ end
+ node[path[#path]] = value
+end
+
+-- to_display renders a typed v2 value in the string form the frozen page reads.
+local function to_display(value, kind)
+ if kind == "bool" then
+ return value and "1" or "0"
+ end
+ if value == nil then
+ return ""
+ end
+ -- Lua 5.1 prints an integral double without a fractional part, so a whole
+ -- number of seconds reaches the form as "60" rather than "60.0".
+ return tostring(value)
+end
+
+-- to_typed is the other direction, and refuses rather than guesses: a number
+-- field whose text is not a number keeps its stored value instead of silently
+-- becoming zero, which the daemon would accept as a valid setting.
+local function to_typed(text, kind)
+ if kind == "bool" then
+ return truthy(text)
+ end
+ if kind == "int" then
+ local number = tonumber(trim(text))
+ if not number then return nil end
+ return math.floor(number)
+ end
+ if kind == "number" then
+ return tonumber(trim(text))
+ end
+ return tostring(text or "")
+end
+
+-- account_view renders one v2 account as the baseline item the table, the edit
+-- dialog and the wizard all read.
+--
+-- Secrets are not part of it. config.get redacts them, and the page has no use
+-- for a credential it never displays: the edit dialog submits an empty password
+-- field, and `campus_patch` reads that as "keep the stored one".
+local function account_view(account)
+ local login = type(account.login) == "table" and account.login or {}
+ local double_stack = ""
+ if login.double_stack ~= nil then
+ double_stack = login.double_stack and "1" or "0"
+ end
+ return {
+ id = tostring(account.id or ""),
+ label = tostring(account.label or ""),
+ user_id = tostring(account.user_id or ""),
+ password = "",
+ operator = tostring(account.operator or ""),
+ operator_suffix = tostring(account.operator_suffix or ""),
+ access_mode = tostring(account.access_mode or "wired"),
+ wired_iface = tostring(account.wired_iface or ""),
+ auth_enabled = account.auth_enabled and "1" or "0",
+ base_url = tostring(account.base_url or ""),
+ ac_id = tostring(account.ac_id or ""),
+ ssid = tostring(account.ssid or ""),
+ radio = tostring(account.radio or ""),
+ encryption = tostring(account.encryption or ""),
+ key = "",
+ ap_selection = tostring(account.ap_selection or ""),
+ bssid = tostring(account.bssid or ""),
+ n = tostring(login.n or ""),
+ type = tostring(login.type or ""),
+ enc = tostring(login.enc or ""),
+ info_prefix = tostring(login.info_prefix or ""),
+ double_stack = double_stack,
+ login_os = tostring(login.os or ""),
+ login_name = tostring(login.name or ""),
+ preset_id = tostring(account.preset_id or ""),
+ }
+end
+
+local function hotspot_view(hotspot)
+ return {
+ id = tostring(hotspot.id or ""),
+ label = tostring(hotspot.label or ""),
+ ssid = tostring(hotspot.ssid or ""),
+ encryption = tostring(hotspot.encryption or ""),
+ key = "",
+ radio = tostring(hotspot.radio or ""),
+ }
+end
+
+-- flatten turns one config.get result into the baseline-shaped table the page
+-- has always worked with.
+function M.flatten(config)
+ local flat = {}
+ for key, spec in pairs(SCALARS) do
+ flat[key] = to_display(read_path(config, spec.path), spec.kind)
+ end
+ local selection = type(config.selection) == "table" and config.selection or {}
+ for legacy, field in pairs(POINTERS) do
+ flat[legacy] = tostring(selection[field] or "")
+ end
+
+ flat.campus_accounts = {}
+ for _, account in ipairs(config.campus_accounts or {}) do
+ flat.campus_accounts[#flat.campus_accounts + 1] = account_view(account)
+ end
+ flat.hotspot_profiles = {}
+ for _, hotspot in ipairs(config.hotspot_profiles or {}) do
+ flat.hotspot_profiles[#flat.hotspot_profiles + 1] = hotspot_view(hotspot)
+ end
+ flat.school_extra = type(config.school_extra) == "table" and config.school_extra or {}
+ flat.revision = M.config_revision(config.revision)
+ return flat
+end
+
+-- settings_patch builds the config.apply body for the fields a save touched.
+--
+-- Only the dirty ones. A settings form that sent every field would overwrite
+-- values another page, the CLI or the wizard changed while this one was open,
+-- and it would do it without the user having touched the control.
+function M.settings_patch(flat, dirty)
+ local patch = {}
+ local touched = false
+ for key in pairs(dirty or {}) do
+ local spec = SCALARS[key]
+ if spec then
+ local value = to_typed(flat[key], spec.kind)
+ -- A field the form did not submit arrives here as an empty string,
+ -- because that is how the CBI reports "removed". For everything
+ -- except sta_iface an empty value is not a setting the daemon can
+ -- hold -- a clock with no time, an enum with no choice -- and
+ -- sending it would fail the whole save, including the fields the
+ -- user did change. Absent stays absent instead.
+ if value == "" and not spec.empty_ok then
+ value = nil
+ end
+ if value ~= nil then
+ write_path(patch, spec.path, value)
+ touched = true
+ end
+ end
+ end
+ if dirty and dirty.school_extra then
+ local extra = type(flat.school_extra) == "table" and flat.school_extra or {}
+ -- An empty Lua table encodes as a JSON array, which is not a map. The
+ -- sentinel survives encoding and becomes `{}` on the wire.
+ patch.school_extra = next(extra) == nil and rpc.EMPTY_OBJECT or extra
+ touched = true
+ end
+ if not touched then
+ return nil
+ end
+ return patch
+end
+
+-- campus_patch turns submitted form values into a v2 account patch.
+--
+-- Presence is the whole point: a field the dialog did not submit is left out,
+-- so it keeps its stored value. The password is the case that matters. The
+-- dialog cannot show it, so an empty box means "unchanged" on an edit, and only
+-- a new account stores the empty string it was given.
+function M.campus_patch(form, options)
+ options = options or {}
+ local creating = options.creating and true or false
+ local access_mode = trim(form.access_mode) == "wired" and "wired" or "wifi"
+
+ local patch = {
+ label = tostring(form.label or ""),
+ user_id = tostring(form.user_id or ""),
+ operator = tostring(form.operator or ""),
+ operator_suffix = tostring(form.operator_suffix or ""),
+ access_mode = access_mode,
+ base_url = tostring(form.base_url or ""),
+ ac_id = tostring(form.ac_id or ""),
+ login = {
+ n = tostring(form.n or ""),
+ type = tostring(form.type or ""),
+ enc = tostring(form.enc or ""),
+ info_prefix = tostring(form.info_prefix or ""),
+ os = tostring(form.login_os or ""),
+ name = tostring(form.login_name or ""),
+ },
+ }
+ if not creating then
+ patch.id = tostring(form.id or "")
+ end
+
+ local double_stack = trim(form.double_stack)
+ if double_stack == "" then
+ -- Explicit null, which the account patch accepts on this one path: it
+ -- clears the override so the strategy default applies again. Leaving the
+ -- field out would silently keep the previous override instead.
+ patch.login.double_stack = rpc.NULL
+ else
+ patch.login.double_stack = truthy(double_stack)
+ end
+
+ local password = tostring(form.password or "")
+ if creating or password ~= "" then
+ patch.password = password
+ end
+
+ if access_mode == "wired" then
+ local iface = trim(form.wired_iface)
+ if iface == "" then
+ -- PR #32 called this field network_interface. Read the alias, write
+ -- only wired_iface.
+ iface = trim(form.network_interface)
+ end
+ patch.wired_iface = iface ~= "" and iface or "wan"
+ patch.auth_enabled = truthy(form.auth_enabled)
+ -- The wireless half is cleared rather than left behind, so a stored
+ -- account never carries an effective setting for the mode it is not in.
+ patch.ssid, patch.bssid, patch.radio, patch.ap_selection = "", "", "", "auto"
+ else
+ patch.auth_enabled = false
+ patch.ssid = tostring(form.ssid or "")
+ patch.radio = tostring(form.radio or "")
+ patch.bssid = trim(form.bssid):lower()
+ patch.ap_selection = M.normalize_ap_selection(form.ap_selection, patch.bssid)
+ if options.encryption ~= nil then
+ patch.encryption = tostring(options.encryption)
+ end
+ if options.key ~= nil then
+ patch.key = tostring(options.key)
+ end
+ if creating and options.encryption == nil then
+ -- A new wireless account needs an encryption mode; the campus dialog
+ -- has never had that control, and the validator requires one.
+ patch.encryption = "none"
+ end
+ end
+ return patch
+end
+
+function M.hotspot_patch(form, options)
+ options = options or {}
+ local patch = {
+ label = tostring(form.label or ""),
+ ssid = tostring(form.ssid or ""),
+ encryption = tostring(form.encryption or ""),
+ radio = tostring(form.radio or ""),
+ }
+ if not options.creating then
+ patch.id = tostring(form.id or "")
+ end
+ local key = tostring(form.key or "")
+ if options.creating or key ~= "" then
+ patch.key = key
+ end
+ return patch
+end
+
+-- normalize_ap_selection is the baseline rule, kept here so the controller and
+-- the CBI page share one copy: an unrecognised policy means "fixed" when a
+-- BSSID was given and "auto" otherwise.
+function M.normalize_ap_selection(value, bssid)
+ local policy = trim(value):lower()
+ if policy == "auto" or policy == "strongest" or policy == "fixed" then
+ return policy
+ end
+ return trim(bssid) ~= "" and "fixed" or "auto"
+end
+
+-- default_label reproduces the baseline's fallback naming.
+function M.default_label(user_id, suffix, fallback)
+ local id, tail = trim(user_id), trim(suffix)
+ if id ~= "" and tail ~= "" then
+ return id .. "@" .. tail
+ end
+ if id ~= "" then
+ return id
+ end
+ return fallback
+end
+
+-- utc_seconds converts an RFC 3339 timestamp to a second count.
+--
+-- Its own civil-date arithmetic rather than os.time, which would interpret the
+-- fields in the router's local zone. The absolute value is never displayed:
+-- only differences between two of these are, and a wrong zone would make an
+-- action look minutes old the moment it finished.
+function M.utc_seconds(text)
+ local year, month, day, hour, minute, second =
+ tostring(text or ""):match("^(%d+)-(%d+)-(%d+)[Tt](%d+):(%d+):(%d+)")
+ if not year then
+ return nil
+ end
+ year, month, day = tonumber(year), tonumber(month), tonumber(day)
+ hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second)
+ -- Days from the civil date, shifting the year to start in March so the leap
+ -- day is the last day of it and no month-length table is needed.
+ local y = year - (month <= 2 and 1 or 0)
+ local era = math.floor(y / 400)
+ local year_of_era = y - era * 400
+ local day_of_year = math.floor((153 * (month + (month > 2 and -3 or 9)) + 2) / 5) + day - 1
+ local day_of_era = year_of_era * 365 + math.floor(year_of_era / 4)
+ - math.floor(year_of_era / 100) + day_of_year
+ local days = era * 146097 + day_of_era - 719468
+ return days * 86400 + hour * 3600 + minute * 60 + second
+end
+
+local AUTH_TEXT = {
+ Unknown = "状态未知",
+ Authenticating = "正在认证",
+ Accepted = "已提交认证",
+ VerifiedSelf = "已认证",
+ VerifiedOther = "线路上是其他账号",
+ Rejected = "认证被拒绝",
+ Offline = "未认证",
+}
+
+local LINK_TEXT = {
+ Missing = "接口不存在",
+ LinkDown = "链路未就绪",
+ AddressPending = "等待 IPv4 地址",
+}
+
+local CONNECTIVITY = {
+ InternetReachable = { text = "互联网可达", level = "online" },
+ PortalReachable = { text = "认证网关可达", level = "portal" },
+ Limited = { text = "已连接但受限", level = "limited" },
+ Offline = { text = "未连接", level = "offline" },
+ Unknown = { text = "未探测", level = "offline" },
+}
+
+local ACTION_RESULT = {
+ succeeded = "ok",
+ failed = "error",
+ -- A cancelled or interrupted action is what the frozen page calls "forced":
+ -- the force-stop button cancels, and a stopped service interrupts. Neither
+ -- is a failed attempt, and showing them as one would tell a user their
+ -- password was wrong when they pressed stop.
+ cancelled = "forced",
+ interrupted = "forced",
+}
+
+-- Only explicit login/logout/switch actions own the frozen progress dialog.
+-- Maintenance, preset refresh and wizard jobs expose their results elsewhere.
+local FEEDBACK_ACTION = {
+ manual_login = true, manual_logout = true, relogin = true,
+ switch_campus = true, switch_hotspot = true,
+}
+
+local function account_by_id(config, id)
+ for _, account in ipairs(config.campus_accounts or {}) do
+ if account.id == id then
+ return account
+ end
+ end
+ return nil
+end
+
+-- portal_origin reduces a configured gateway address to a bare HTTP(S)
+-- origin, or "" when it is not one.
+--
+-- 1.6.1's rule, kept: scheme and host (IPv6 in brackets, or letters, digits,
+-- dots and hyphens) with an optional port, nothing else. No userinfo, no path,
+-- no whitespace, control characters or backslashes -- the last because browsers
+-- disagree about what a backslash in an authority means.
+local function portal_origin(value)
+ -- Schemes are case-insensitive; the answer is always written lower-case.
+ local scheme, rest = trim(value):match("^(%a+)://(.*)$")
+ scheme = scheme and scheme:lower()
+ if scheme ~= "http" and scheme ~= "https" then return "" end
+ local authority = rest:match("^([^/%?#]+)")
+ if not authority or authority:find("[%s%c\\@]") then return "" end
+ local host, port = authority:match("^(%[[%x:]+%])(.*)$")
+ if not host then host, port = authority:match("^([%w%.%-]+)(.*)$") end
+ if not host or not (port == "" or port:match("^:%d%d?%d?%d?%d?$")) then return "" end
+ return scheme:lower() .. "://" .. authority
+end
+
+-- failed_login_portal answers the page's "open the school's page" link.
+--
+-- Only after a failed manual login, as in 1.6.1, and only from the account that
+-- action was for -- not whichever account is active by the time the page asks.
+-- The address is the one the user configured. A gateway's response body never
+-- supplies a URL here.
+local function failed_login_portal(action, config)
+ if type(action) ~= "table" or tostring(action.kind or "") ~= "manual_login"
+ or tostring(action.state or "") ~= "failed" then
+ return ""
+ end
+ local account = account_by_id(config or {}, tostring(action.account_id or ""))
+ return account and portal_origin(account.base_url) or ""
+end
+
+M.portal_origin = portal_origin
+
+local function view_by_id(snapshot, id)
+ for _, view in ipairs(snapshot.accounts or {}) do
+ if view.account_id == id then
+ return view
+ end
+ end
+ return nil
+end
+
+-- wired_session_view rebuilds the per-account badge input the account table
+-- reads, from the projection the daemon publishes for every account.
+local function wired_session_view(view)
+ if not view then
+ return { online = false, status = "" }
+ end
+ local auth = tostring(view.auth or "")
+ local status = ""
+ if auth == "VerifiedOther" then
+ status = "account_mismatch"
+ elseif auth == "Rejected" then
+ status = "error"
+ elseif tostring(view.link or "") == "Missing" then
+ status = "config_error"
+ end
+ return { online = auth == "VerifiedSelf", status = status }
+end
+
+-- mode_of decides campus or hotspot from what actually happened.
+--
+-- The last switch that succeeded, not the last one submitted: an attempt that
+-- failed left the uplink where it was, and reporting the requested mode would
+-- describe a network the device is not on.
+local function mode_of(snapshot, account)
+ local wireless = type(snapshot.wireless) == "table" and snapshot.wireless or {}
+ if wireless.state == "associated" then
+ if tostring(wireless.hotspot_id or "") ~= "" then return "hotspot" end
+ if account and account.access_mode ~= "wired"
+ and wireless.radio == account.radio and wireless.ssid == account.ssid then
+ return "campus"
+ end
+ end
+ local mode = "campus"
+ for _, action in ipairs(snapshot.actions or {}) do
+ if action.state == "succeeded" and action.ended_at
+ and (action.kind == "switch_campus" or action.kind == "switch_hotspot") then
+ -- Retained terminals are ordered by retirement, including when
+ -- the router's wall clock moves backwards during a switch.
+ mode = action.kind == "switch_hotspot" and "hotspot" or "campus"
+ end
+ end
+ return mode
+end
+
+local function hotspot_status(wireless)
+ if wireless.state == "disconnected" then
+ return "热点未连接", CONNECTIVITY.Offline
+ elseif wireless.state == "unavailable" or wireless.state == "ambiguous" then
+ return "无法读取热点连接", CONNECTIVITY.Unknown
+ elseif wireless.state ~= "associated" then
+ return "正在读取热点状态", CONNECTIVITY.Unknown
+ elseif tostring(wireless.address or "") == "" then
+ return "热点等待 IP 地址", CONNECTIVITY.Unknown
+ end
+ local level = tostring(wireless.connectivity or "")
+ if level == "InternetReachable" then
+ return "热点已联网", CONNECTIVITY.InternetReachable
+ elseif level == "Limited" then
+ return "热点联网受限", CONNECTIVITY.Limited
+ elseif level == "Offline" then
+ return "热点联网检测失败", { text = "互联网探测未通过", level = "offline" }
+ end
+ return "热点已连接", { text = "等待联网检测", level = "offline" }
+end
+
+-- status_view is the one combined answer the page polls.
+--
+-- Connection details come from the daemon's bounded, passive wireless cache.
+-- Configured SSIDs and BSSIDs are never used as proof of an association.
+function M.status_view(snapshot, config, now)
+ now = now or os.time()
+ local running = tostring(snapshot.service or "") == "running"
+ local selection = type(config.selection) == "table" and config.selection or {}
+ local active_id = tostring(selection.active_campus_id or "")
+ local account = account_by_id(config, active_id)
+ local view = view_by_id(snapshot, active_id)
+ local wireless = type(snapshot.wireless) == "table" and snapshot.wireless or {}
+ local mode = mode_of(snapshot, account)
+
+ local wired = account ~= nil and tostring(account.access_mode or "") == "wired"
+ local mode_label = "未知模式"
+ local access_mode = ""
+ if mode == "hotspot" then
+ mode_label = "热点模式"
+ elseif account then
+ mode_label = wired and "校园网模式(有线)" or "校园网模式"
+ access_mode = wired and "wired" or "wifi"
+ end
+
+ local connectivity = CONNECTIVITY[tostring(view and view.connectivity or "")] or CONNECTIVITY.Unknown
+ local status = "未知"
+ if not running then
+ status = "认证服务已停止"
+ connectivity = CONNECTIVITY.Unknown
+ elseif mode == "hotspot" then
+ status, connectivity = hotspot_status(wireless)
+ elseif not account then
+ status = "尚未配置校园网账号"
+ elseif not view then
+ status = snapshot.enabled and "等待首次检测" or "自动认证已关闭"
+ else
+ status = LINK_TEXT[tostring(view.link or "")] or AUTH_TEXT[tostring(view.auth or "")] or "状态未知"
+ end
+
+ -- Keep background work visible in the overview without letting it replace
+ -- a manual result or hold that action's progress dialog open.
+ local pending, feedback_pending, last, last_ended = "", false, nil, nil
+ for _, action in ipairs(snapshot.actions or {}) do
+ local state = tostring(action.state or "")
+ local feedback = FEEDBACK_ACTION[tostring(action.kind or "")]
+ if state == "queued" or state == "running" then
+ if pending == "" then
+ pending = tostring(action.kind or "")
+ end
+ if feedback then feedback_pending = true end
+ elseif feedback and ACTION_RESULT[state] then
+ last, last_ended = action, M.utc_seconds(action.ended_at)
+ end
+ end
+ -- A new manual request clears the old terminal message until it completes.
+ if feedback_pending then last, last_ended = nil, nil end
+
+ local written = M.utc_seconds(snapshot.written_at)
+ local last_action_ts = 0
+ if last_ended and written then
+ -- Display only. Wall clocks can move backwards; the dialog uses the
+ -- receipt ID and never uses this timestamp as proof of completion.
+ last_action_ts = now - (written - last_ended)
+ end
+
+ local link_ready = view ~= nil and tostring(view.link or "") == "Ready"
+ local line = mode ~= "hotspot" and type(view) == "table" and type(view.line) == "table" and view.line or {}
+ -- Quiet hours suspend a service the user has switched on; the page has
+ -- always shown that separately from the switch itself.
+ local paused = {}
+ for _, reason in ipairs(snapshot.pause or {}) do
+ paused[tostring(reason)] = true
+ end
+ local payload = {
+ status = status,
+ enabled = snapshot.enabled and true or false,
+ service = running and "running" or "stopped",
+ mode = mode,
+ current_mode = mode,
+ mode_label = mode_label,
+ pending_action = pending,
+ current_campus_access_mode = access_mode,
+ campus_account_label = account and tostring(account.label or "") or "",
+ online_account_label = mode ~= "hotspot" and view and tostring(view.identity or "") or "",
+ campus_ssid = account and tostring(account.ssid or "") or "",
+ campus_bssid = account and tostring(account.bssid or "") or "",
+ current_ssid = "",
+ current_bssid = "",
+ current_wireless_ifname = "",
+ -- The line the last attempt actually reached the network on, not the
+ -- one the account is configured for.
+ current_iface = tostring(line.iface or ""),
+ current_ip = tostring(line.address or ""),
+ current_device = tostring(line.device or ""),
+ in_quiet = paused["QuietHours"] and true or false,
+ paused = next(paused) ~= nil,
+ ap_selection_policy = "",
+ ap_selection_reason = "",
+ connectivity = connectivity.text,
+ connectivity_level = connectivity.level,
+ hotspot_profile_label = "",
+ wired_auth_sessions = {},
+ last_action = last and tostring(last.kind or "") or "",
+ last_action_message = last and tostring(last.message or "") or "",
+ last_action_portal_url = failed_login_portal(last, config),
+ action_result = last and ACTION_RESULT[tostring(last.state or "")] or "",
+ last_action_ts = last_action_ts,
+ action_started_at = feedback_pending and now or 0,
+ config_revision = tonumber(snapshot.config_revision) or 0,
+ last_log = "",
+ updated_at = now,
+ ts = now,
+ }
+ if feedback_pending then
+ payload.action_result = "pending"
+ end
+ -- The observed line is preferred; the configured one only stands in while
+ -- the link is ready and nothing has reported a device yet, which is the
+ -- first moment after a save.
+ if mode ~= "hotspot" and payload.current_iface == "" and link_ready and account and wired then
+ payload.current_iface = tostring(account.wired_iface or "")
+ end
+ if running and (mode == "hotspot" or (account and not wired)) then
+ payload.current_wireless_ifname = tostring(wireless.device or "")
+ payload.ap_selection_policy = mode == "hotspot" and "auto"
+ or M.normalize_ap_selection(account.ap_selection, account.bssid)
+ local reasons = {
+ disconnected = "尚未连接无线网络",
+ unavailable = "暂时无法读取无线状态",
+ ambiguous = "检测到多个上联客户端,请检查无线配置",
+ }
+ payload.ap_selection_reason = reasons[wireless.state] or "等待无线状态检测"
+ if wireless.state == "associated" then
+ payload.current_ssid = tostring(wireless.ssid or "")
+ payload.current_bssid = tostring(wireless.bssid or "")
+ payload.current_iface = tostring(wireless.iface or "")
+ payload.current_device = tostring(wireless.device or "")
+ payload.current_ip = tostring(wireless.address or "")
+ local signal, channel = tonumber(wireless.signal), tonumber(wireless.channel)
+ payload.current_signal = signal and signal < 0 and signal >= -127 and signal or nil
+ payload.current_channel = channel and channel > 0 and channel or nil
+ if mode ~= "hotspot" and payload.current_ssid ~= tostring(account.ssid or "") then
+ payload.ap_selection_reason = "当前无线连接与所选校园网配置不同"
+ elseif payload.ap_selection_policy == "fixed" then
+ payload.ap_selection_reason = payload.current_bssid == tostring(account.bssid or ""):lower()
+ and "已连接指定接入点" or "当前接入点与固定 BSSID 不同"
+ elseif payload.ap_selection_policy == "strongest" then
+ payload.ap_selection_reason = "连接时优先信号较强的接入点,保持当前连接"
+ else
+ payload.ap_selection_reason = "由无线系统选择接入点"
+ end
+ end
+ end
+
+ local hotspot_id = tostring(selection.active_hotspot_id or "")
+ if mode == "hotspot" and tostring(wireless.hotspot_id or "") ~= "" then
+ hotspot_id = tostring(wireless.hotspot_id)
+ end
+ for _, hotspot in ipairs(config.hotspot_profiles or {}) do
+ if hotspot.id == hotspot_id then
+ payload.hotspot_profile_label = tostring(hotspot.label or "")
+ end
+ end
+ -- Both gates, as the daemon applies them: an account that opted in while
+ -- the global switch is off is not managed, and saying it is would show a
+ -- parallel-authentication badge for work nothing is doing.
+ for _, item in ipairs(config.multi_wan_enabled and config.campus_accounts or {}) do
+ if item.access_mode == "wired" and item.auth_enabled then
+ payload.wired_auth_sessions[tostring(item.id or "")] =
+ wired_session_view(view_by_id(snapshot, item.id))
+ end
+ end
+ return payload
+end
+
+-- offline_view is the same shape with nothing observed in it.
+--
+-- A stopped service is a state, not a page error: the overview must say so in
+-- its own card rather than fall back to "状态读取失败", which is what the page
+-- shows when it cannot reach LuCI at all.
+function M.offline_view(message, now)
+ now = now or os.time()
+ return {
+ status = message or "认证服务未在运行",
+ enabled = false,
+ service = "stopped",
+ mode = "",
+ current_mode = "",
+ mode_label = "未知模式",
+ pending_action = "",
+ current_campus_access_mode = "",
+ campus_account_label = "",
+ online_account_label = "",
+ campus_ssid = "",
+ campus_bssid = "",
+ current_ssid = "",
+ current_bssid = "",
+ current_wireless_ifname = "",
+ current_iface = "",
+ current_ip = "",
+ current_device = "",
+ in_quiet = false,
+ paused = false,
+ ap_selection_policy = "",
+ ap_selection_reason = "",
+ connectivity = "未连接",
+ connectivity_level = "offline",
+ hotspot_profile_label = "",
+ wired_auth_sessions = {},
+ last_action = "",
+ last_action_message = "",
+ last_action_portal_url = "",
+ action_result = "",
+ last_action_ts = 0,
+ action_started_at = 0,
+ config_revision = 0,
+ last_log = "",
+ updated_at = now,
+ ts = now,
+ }
+end
+
+-- calls: everything below here talks to the daemon.
+
+-- defaults answers the form's flat defaults from the daemon's own schema.
+--
+-- One source of defaults, as spec 02 requires: Go owns types, defaults, bounds
+-- and choices, and the page keeps the labels. Deriving them here through the
+-- same mapping the save path uses means a changed default cannot reach the
+-- form as the old one, and a field nobody mapped cannot acquire a default that
+-- nothing would ever apply.
+-- school_extra_descriptors answers the selected strategy's private controls.
+--
+-- The page used to build these from parse_school_runtime_contract(""), a
+-- literal empty string, so the list was always empty and every control below it
+-- was unreachable whatever a strategy declared. The daemon publishes the
+-- declarations through the same schema.get the defaults above come from; this
+-- translates them into the shape the page's normaliser already expects.
+--
+-- Kind names differ on purpose: Go names them for the configuration contract
+-- (number, select, multi), the page names them for its widgets (int, enum).
+-- Translating here keeps both vocabularies honest. "multi" has no widget yet
+-- and is passed through unmapped, which the page's supported-type filter drops.
+local SCHOOL_EXTRA_KINDS = { string = "string", bool = "bool",
+ number = "int", select = "enum" }
+
+function M.school_extra_descriptors()
+ local schema, err = rpc.call("schema.get", nil)
+ if not schema then
+ return nil, err
+ end
+ local out = {}
+ for _, field in ipairs(schema.school_extra or {}) do
+ if type(field) == "table" then
+ local kind = tostring(field.kind or "string")
+ local choices = {}
+ for _, choice in ipairs(field.choices or {}) do
+ if type(choice) == "table" and choice.value ~= nil then
+ choices[#choices + 1] = tostring(choice.value)
+ end
+ end
+ out[#out + 1] = {
+ key = tostring(field.key or ""),
+ type = SCHOOL_EXTRA_KINDS[kind] or kind,
+ label = tostring(field.label or ""),
+ description = tostring(field.help or ""),
+ choices = choices,
+ }
+ end
+ end
+ return out
+end
+
+function M.defaults()
+ local schema, err = rpc.call("schema.get", nil)
+ if not schema then
+ return nil, err
+ end
+ local by_path = {}
+ for legacy, spec in pairs(SCALARS) do
+ by_path[table.concat(spec.path, ".")] = { key = legacy, kind = spec.kind }
+ end
+
+ local out = {}
+ for _, field in ipairs(schema.global or {}) do
+ local target = by_path[tostring(field.path or "")]
+ -- A false or empty default is omitted on the wire. For a flag that is
+ -- unambiguous; for anything else an absent default is absent, and
+ -- inventing a zero would put a value in the form that nothing chose.
+ if target and (field.default ~= nil or target.kind == "bool") then
+ out[target.key] = to_display(field.default, target.kind)
+ end
+ end
+ return out
+end
+
+-- config reads the stored configuration in the page's own shape.
+function M.config()
+ local result, err = rpc.call("config.get", nil)
+ if not result then
+ return nil, err
+ end
+ return M.flatten(result), nil, result
+end
+
+function M.snapshot()
+ return rpc.call("status.get", nil)
+end
+
+-- status answers the page's poll from two cached reads.
+--
+-- Two, because the projection names accounts by id and the labels live in the
+-- configuration. Both are answered from memory: spec 03 forbids a status read
+-- that authenticates or probes, and this path must stay cheap enough for a
+-- browser tab to hold open.
+function M.status()
+ local snapshot, err = rpc.call("status.get", nil)
+ if not snapshot then
+ return nil, err
+ end
+ local config, config_err = rpc.call("config.get", nil)
+ if not config then return nil, config_err end
+ return M.status_view(snapshot, config, os.time())
+end
+
+-- A dialog reads its own receipt, not the overview's latest manual action.
+-- Do not start/replay work or expose discovery results through this channel.
+function M.action_feedback(action_id)
+ local id = type(action_id) == "string" and action_id or ""
+ local payload = { action_id = id, action_result = "error", last_action = "",
+ last_action_message = "操作记录已失效,请查看当前状态", last_action_portal_url = "" }
+ if #id == 0 or #id > 64 or not id:match("^[%w_-]+$") then
+ payload.action_id = ""
+ payload.last_action_message = "操作编号无效"
+ return payload
+ end
+ -- session is a discovery owner guard, not a generic LuCI auth parameter.
+ -- Access to this controller already requires an authenticated LuCI user.
+ local action, err = rpc.call("action.get", { action_id = id })
+ if not action then
+ if err and err.code ~= "NotFound" then
+ payload.last_action_message = rpc.message(err, payload.last_action_message)
+ end
+ return payload
+ end
+ if action.id ~= id or not FEEDBACK_ACTION[action.kind] then return payload end
+ local result = ACTION_RESULT[action.state]
+ if action.state == "queued" or action.state == "running" then result = "pending" end
+ if not result then return payload end
+ payload.last_action = action.kind
+ payload.action_result = result
+ payload.last_action_message = tostring(action.message or "")
+ -- The configuration is read only for the one outcome that can use it, so
+ -- polling a running action costs no extra call.
+ if action.kind == "manual_login" and action.state == "failed" then
+ payload.last_action_portal_url = failed_login_portal(action, rpc.call("config.get", nil))
+ end
+ return payload
+end
+
+return M
diff --git a/smart-srun/root/usr/lib/lua/luci/smart_srun/rpc.lua b/smart-srun/root/usr/lib/lua/luci/smart_srun/rpc.lua
new file mode 100644
index 000000000..4d42e4cd2
--- /dev/null
+++ b/smart-srun/root/usr/lib/lua/luci/smart_srun/rpc.lua
@@ -0,0 +1,312 @@
+-- The page's half of the local control protocol.
+--
+-- Spec 02 sends every LuCI business request to the Go daemon over its Unix
+-- socket, and spec 03 fixes the framing: one UTF-8 JSON object per line, one
+-- request and one response per connection. This file is the only place in the
+-- interface that knows any of that, so the controller and the CBI page never
+-- assemble a frame, a deadline or an error envelope themselves.
+--
+-- Nothing here builds a shell command out of user input. The single external
+-- program this module may run is the fixed lifecycle helper, with a fixed
+-- argument list and no data from the request in it.
+
+local nixio = require "nixio"
+-- writeall and readall are not nixio methods: nixio.util adds them to the
+-- socket metatable. This module used to rely on something else having loaded
+-- it -- the controller and CBI page pull in nixio.fs, which does -- so it
+-- worked in the page and failed with "attempt to call method 'writeall'" from
+-- any caller that did not happen to load nixio.fs first.
+require "nixio.util"
+local jsonc = require "luci.jsonc"
+
+local M = {}
+
+M.SOCKET_PATH = "/var/run/smart-srun/control.sock"
+M.HELPER = "/usr/bin/srunnet"
+M.RPC_VERSION = 1
+
+-- A response is bounded by the same limit the daemon writes under, so a peer
+-- that never sends a newline cannot make uhttpd buffer without end.
+local MAX_RESPONSE_BYTES = 1024 * 1024
+local READ_CHUNK = 8192
+-- Seconds. Well inside uhttpd's script timeout: a page that hangs for a minute
+-- is indistinguishable from a broken one, and every method reachable from here
+-- answers from memory.
+local IO_TIMEOUT = 8
+-- The helper waits up to five seconds for the socket and then gives up, so this
+-- only has to be longer than that, not open-ended.
+local HELPER_TIMEOUT = 12
+
+-- Two values the protocol needs and a Lua table cannot hold: an explicit JSON
+-- null, which is how an account clears its double_stack override, and an empty
+-- JSON object, which an empty Lua table encodes as an array instead.
+--
+-- They travel as strings built from a control character, because that is what
+-- luci.jsonc escapes as -- a form field cannot contain one, so no value
+-- a user types can be mistaken for a sentinel, and a request that somehow did
+-- carry one would be refused by the daemon's strict decoder rather than saved
+-- as something else.
+M.NULL = "\1null\1"
+M.EMPTY_OBJECT = "\1{}\1"
+
+local SENTINELS = {
+ { '"\\u0001null\\u0001"', "null" },
+ { '"\\u0001{}\\u0001"', "{}" },
+}
+
+local request_counter = 0
+
+local function fail(code, message)
+ return nil, { code = code, message = message, retryable = false }
+end
+
+local function next_request_id()
+ request_counter = request_counter + 1
+ return string.format("luci-%d-%d", nixio.getpid(), request_counter)
+end
+
+-- connect returns a socket already carrying its deadlines.
+--
+-- The timeouts are set before the connect, not after, so a socket that exists
+-- but is never accepted cannot hold the request open either.
+local function connect()
+ local sock = nixio.socket("unix", "stream")
+ if not sock then
+ return fail("Internal", "无法创建本地套接字")
+ end
+ -- Old nixio builds without SO_RCVTIMEO would raise here rather than return;
+ -- pcall keeps that from turning into a 500 page, and the daemon answering
+ -- from memory is what keeps the remaining risk to a blocked read.
+ pcall(function()
+ sock:setopt("socket", "sndtimeo", IO_TIMEOUT)
+ sock:setopt("socket", "rcvtimeo", IO_TIMEOUT)
+ end)
+
+ local ok = sock:connect(M.SOCKET_PATH)
+ if not ok then
+ sock:close()
+ -- Not reaching the socket is the ordinary "the service is not running"
+ -- case and gets the code the rest of the program uses for it, so the
+ -- page can offer to start it instead of showing a transport fault.
+ return nil, {
+ code = "ServiceStopped",
+ message = "认证服务未在运行",
+ retryable = true,
+ }
+ end
+ return sock
+end
+
+local function write_frame(sock, line)
+ local payload = line .. "\n"
+ local sent = sock:writeall(payload)
+ if sent ~= #payload then
+ return fail("ServiceStopped", "请求未能完整发送给认证服务")
+ end
+ return true
+end
+
+-- read_frame reads exactly one newline-terminated response.
+--
+-- A short read is not a frame and neither is an unterminated one: the loop
+-- keeps reading until the newline arrives, and end-of-stream with bytes in hand
+-- is reported as a truncated response rather than parsed as a complete one.
+local function read_frame(sock)
+ local parts, total = {}, 0
+ while true do
+ local chunk = sock:read(READ_CHUNK)
+ if chunk == nil or chunk == "" then
+ if total == 0 then
+ return fail("ServiceStopped", "认证服务没有返回响应")
+ end
+ return fail("ProtocolInvalid", "认证服务的响应在换行之前结束")
+ end
+ total = total + #chunk
+ if total > MAX_RESPONSE_BYTES then
+ return fail("ProtocolInvalid", "认证服务的响应超过大小上限")
+ end
+ parts[#parts + 1] = chunk
+ local joined = table.concat(parts)
+ local newline = joined:find("\n", 1, true)
+ if newline then
+ return joined:sub(1, newline - 1)
+ end
+ parts = { joined }
+ end
+end
+
+-- call makes one request and returns its result.
+--
+-- Two returns, never one: `result, nil` or `nil, err`. A caller that has to
+-- inspect a field of the result to find out whether the call failed eventually
+-- forgets to, which is how a failed save reports success.
+function M.call(method, params)
+ local request = {
+ rpc_version = M.RPC_VERSION,
+ request_id = next_request_id(),
+ method = method,
+ }
+ if params ~= nil then
+ request.params = params
+ end
+ local line = jsonc.stringify(request)
+ if type(line) ~= "string" then
+ return fail("Internal", "无法编码请求参数")
+ end
+ for _, sentinel in ipairs(SENTINELS) do
+ line = line:gsub(sentinel[1], sentinel[2])
+ end
+
+ local sock, err = connect()
+ if not sock then
+ return nil, err
+ end
+
+ local ok, write_err = write_frame(sock, line)
+ if not ok then
+ sock:close()
+ return nil, write_err
+ end
+
+ local response_line, read_err = read_frame(sock)
+ sock:close()
+ if not response_line then
+ return nil, read_err
+ end
+
+ local response = jsonc.parse(response_line)
+ if type(response) ~= "table" then
+ return fail("ProtocolInvalid", "认证服务返回的不是合法 JSON")
+ end
+ if tonumber(response.rpc_version) ~= M.RPC_VERSION then
+ return fail("ProtocolInvalid", "认证服务的协议版本与本页面不一致")
+ end
+ -- A mismatched id means the answer belongs to another call. One request per
+ -- connection makes that impossible on a healthy socket, which is exactly
+ -- why seeing it means the payload must not be trusted. A failure with no id
+ -- at all is the documented exception: some refusals happen before the
+ -- request has been read, so there is nothing to echo.
+ if response.request_id ~= request.request_id
+ and not (tostring(response.request_id or "") == "" and not response.ok) then
+ return fail("ProtocolInvalid", "认证服务的响应与请求不匹配")
+ end
+
+ if not response.ok then
+ local payload = type(response.error) == "table" and response.error or {}
+ return nil, {
+ code = tostring(payload.code or "Internal"),
+ message = tostring(payload.message or "认证服务内部错误"),
+ field = type(payload.details) == "table"
+ and tostring(payload.details.field or "") or "",
+ retryable = payload.retryable and true or false,
+ }
+ end
+ -- Methods that answer with nothing come back as JSON null. An empty table
+ -- keeps every caller's `result.x` lookup from erroring on a nil index.
+ if type(response.result) ~= "table" then
+ return {}
+ end
+ return response.result
+end
+
+function M.service_stopped(err)
+ return type(err) == "table" and err.code == "ServiceStopped"
+end
+
+function M.message(err, fallback)
+ if type(err) == "table" and tostring(err.message or "") ~= "" then
+ return err.message
+ end
+ return fallback or "操作失败"
+end
+
+-- run_helper runs the fixed lifecycle helper with an argument list.
+--
+-- fork and exec rather than a shell: spec 02 requires the page to invoke one
+-- fixed program with safe arguments, and there is no shell here to interpret an
+-- account name, a URL or a password even if a future caller tried to pass one.
+-- The arguments are checked against a whitelist anyway, because "no shell" and
+-- "no arbitrary subcommand" are two different guarantees.
+local ALLOWED_HELPER_ARGS = {
+ ["service ensure-running"] = true,
+ ["service stop"] = true,
+ ["service status"] = true,
+}
+
+local function run_helper(argv)
+ local joined = table.concat(argv, " ")
+ local update_start = #argv == 4 and argv[1] == "update" and argv[2] == "run"
+ and #argv[3] == 64 and argv[3]:match("^[0-9a-f]+$") and argv[4] == "--background"
+ if not ALLOWED_HELPER_ARGS[joined] and not update_start then
+ return fail("InvalidArgument", "不允许的服务命令")
+ end
+
+ local pid = nixio.fork()
+ if not pid then
+ return fail("Internal", "无法启动服务助手")
+ end
+ if pid == 0 then
+ -- Child. Its output belongs in the log the daemon keeps, not in the
+ -- JSON body this request is about to write.
+ local null = nixio.open("/dev/null", "r+")
+ if null then
+ nixio.dup(null, nixio.stdin)
+ nixio.dup(null, nixio.stdout)
+ nixio.dup(null, nixio.stderr)
+ end
+ nixio.exec(M.HELPER, unpack(argv))
+ -- exec only returns when it failed.
+ os.exit(127)
+ end
+
+ -- A helper that never exits must not hold the page open for longer than the
+ -- helper's own budget, so the wait is polled rather than blocking.
+ local waited = 0
+ while waited < HELPER_TIMEOUT * 10 do
+ local done, _, status = nixio.waitpid(pid, "nohang")
+ if done == pid then
+ if status == 0 then
+ return true
+ end
+ -- Not `return ok or fail(...)`: `or` would keep only the first of
+ -- fail's two values and the caller would lose the message.
+ return fail("ServiceStopped", "服务助手失败(退出码 " .. tostring(status) .. ")")
+ end
+ nixio.nanosleep(0, 100000000)
+ waited = waited + 1
+ end
+ return fail("DeadlineExceeded", "等待服务助手超时")
+end
+
+-- ensure_running starts the service for an explicit user action.
+--
+-- Spec 02 allows exactly this: a deliberate save or manual action on a page
+-- whose service is stopped may start it and then submit its own request. It is
+-- not called from status polling, because a page left open in a tab must not
+-- keep restarting a service the user stopped.
+function M.ensure_running()
+ return run_helper({ "service", "ensure-running" })
+end
+
+function M.stop_service()
+ return run_helper({ "service", "stop" })
+end
+
+function M.start_update(plan_id)
+ if type(plan_id) ~= "string" or #plan_id ~= 64 or not plan_id:match("^[0-9a-f]+$") then
+ return fail("InvalidArgument", "更新计划无效,请重新检查更新")
+ end
+ return run_helper({ "update", "run", plan_id, "--background" })
+end
+
+-- call_started is the mutating path: make sure the service is there, then make
+-- the call. Reads deliberately do not use it.
+function M.call_started(method, params)
+ local ok, err = M.ensure_running()
+ if not ok then
+ return nil, err
+ end
+ return M.call(method, params)
+end
+
+return M
diff --git a/smart-srun/root/usr/lib/lua/luci/smart_srun/schema.lua b/smart-srun/root/usr/lib/lua/luci/smart_srun/schema.lua
new file mode 100644
index 000000000..136645045
--- /dev/null
+++ b/smart-srun/root/usr/lib/lua/luci/smart_srun/schema.lua
@@ -0,0 +1,178 @@
+local fs = require "nixio.fs"
+local nixio = require "nixio"
+
+local OPKG_STATUS_FILE = "/usr/lib/opkg/status"
+local APK_STATUS_FILE = "/lib/apk/db/installed"
+local DEFAULT_VERSION = "v0.0.0"
+
+local M = {}
+
+M.POINTER_KEYS = {
+ "active_campus_id", "default_campus_id",
+ "active_hotspot_id", "default_hotspot_id",
+}
+M.LIST_KEYS = { "campus_accounts", "hotspot_profiles" }
+M.SCHOOL_EXTRA_KEY = "school_extra"
+
+function M.normalize_ap_selection(value, bssid)
+ local policy = tostring(value or ""):match("^%s*(.-)%s*$"):lower()
+ if policy == "auto" or policy == "strongest" or policy == "fixed" then
+ return policy
+ end
+ return tostring(bssid or ""):match("^%s*(.-)%s*$") ~= "" and "fixed" or "auto"
+end
+
+function M.is_valid_bssid(value)
+ local address = tostring(value or ""):match("^%s*(.-)%s*$"):lower()
+ if not address:match("^%x%x:%x%x:%x%x:%x%x:%x%x:%x%x$") then return false end
+ return address ~= "00:00:00:00:00:00" and tonumber(address:sub(1, 2), 16) % 2 == 0
+end
+
+function M.ap_selection_label(policy)
+ local labels = { auto = "系统自动", strongest = "连接时信号优先", fixed = "固定 BSSID" }
+ return labels[tostring(policy or "")] or "未知"
+end
+
+local POINTER_KEY_SET = {}
+for _, key in ipairs(M.POINTER_KEYS) do
+ POINTER_KEY_SET[key] = true
+end
+
+local LIST_KEY_SET = {}
+for _, key in ipairs(M.LIST_KEYS) do
+ LIST_KEY_SET[key] = true
+end
+
+-- load_defaults prefers the daemon's own schema.
+--
+-- Spec 02 gives Go the types, defaults, bounds and choices and leaves the page
+-- its labels, so that the two cannot disagree about what a valid value is. The
+-- page reports a stopped/unavailable service when schema cannot be read; it
+-- never falls back to an obsolete 1.x configuration file.
+local function load_defaults()
+ -- One pcall around both the load and the call: on a host without nixio, or
+ -- with the service stopped, this must fall back rather than take the page
+ -- down with it.
+ local ok, parsed = pcall(function()
+ return require("luci.smart_srun.bridge").defaults()
+ end)
+ if not ok or type(parsed) ~= "table" then
+ parsed = {}
+ end
+ if parsed.school == nil then
+ parsed.school = "default"
+ end
+
+ local defaults = {}
+ for key, value in pairs(parsed) do
+ if not POINTER_KEY_SET[key] and not LIST_KEY_SET[key] then
+ defaults[key] = tostring(value or "")
+ end
+ end
+ return defaults
+end
+
+M.SCALAR_DEFAULTS = load_defaults()
+M.GLOBAL_SCALAR_KEYS = {}
+for key, _ in pairs(M.SCALAR_DEFAULTS) do
+ M.GLOBAL_SCALAR_KEYS[#M.GLOBAL_SCALAR_KEYS + 1] = key
+end
+table.sort(M.GLOBAL_SCALAR_KEYS)
+
+function M.global_scalar_key_set()
+ local key_set = {}
+ for _, key in ipairs(M.GLOBAL_SCALAR_KEYS) do
+ key_set[key] = true
+ end
+ return key_set
+end
+
+function M.with_file_lock(path, callback)
+ local oflags = nixio.open_flags("wronly", "creat")
+ local lock, _, msg = nixio.open(tostring(path) .. ".lock", oflags)
+ if not lock then
+ error("Open lock failed: " .. tostring(msg or "unknown"))
+ end
+
+ local ok, _, lock_msg = lock:lock("lock")
+ if not ok then
+ lock:close()
+ error("Lock failed: " .. tostring(lock_msg or "unknown"))
+ end
+
+ local call_ok, a, b, c = pcall(callback)
+ lock:lock("ulock")
+ lock:close()
+ if not call_ok then
+ error(a)
+ end
+ return a, b, c
+end
+
+local function normalize_version_string(raw)
+ local value = tostring(raw or "")
+ -- opkg and APK use different native RC separators; the UI uses the same
+ -- display version as the Go binary and the release tag.
+ value = value:gsub("~rc", "rc"):gsub("_rc", "rc")
+ local version = value:match("^v?([0-9][%w%._%-]*)%-r?%d+$") or value:match("^v?([0-9][%w%._%-]*)$")
+ if version and version ~= "" then
+ version = version:gsub("_", "-")
+ local base, beta = version:match("^(.+)%-beta%.?(%d+)$")
+ if base and beta then
+ version = base .. "-b" .. beta
+ end
+ return string.format("v%s", version)
+ end
+ return DEFAULT_VERSION
+end
+
+local function read_package_status()
+ local text = fs.readfile(OPKG_STATUS_FILE)
+ if text and text ~= "" then
+ return text
+ end
+ return fs.readfile(APK_STATUS_FILE) or ""
+end
+
+local function package_versions()
+ local versions = {}
+ local text = read_package_status() .. "\n\n"
+ for block in text:gmatch("(.-)\n\n") do
+ local package_name = block:match("\n?Package:%s*([^\r\n]+)")
+ or block:match("\n?P:%s*([^\r\n]+)")
+ or block:match("^Package:%s*([^\r\n]+)")
+ or block:match("^P:%s*([^\r\n]+)")
+ if package_name then
+ versions[package_name] =
+ block:match("\n?Version:%s*([^\r\n]+)")
+ or block:match("\n?V:%s*([^\r\n]+)")
+ or block:match("^Version:%s*([^\r\n]+)")
+ or block:match("^V:%s*([^\r\n]+)")
+ or ""
+ end
+ end
+ return versions
+end
+
+function M.installed_package_name()
+ local versions = package_versions()
+ if versions["luci-app-smart-srun-bundle"] then
+ return "luci-app-smart-srun-bundle"
+ end
+ if versions["luci-app-smart-srun"] then
+ return "luci-app-smart-srun"
+ end
+ return "smart-srun"
+end
+
+function M.installed_package_display_text()
+ local versions = package_versions()
+ local package_name = M.installed_package_name()
+ local label = package_name == "luci-app-smart-srun-bundle" and "Bundle 版"
+ or package_name == "luci-app-smart-srun" and "标准版"
+ or "CLI 版"
+ local version = normalize_version_string(versions[package_name] or "")
+ return string.format("%s %s", label, version)
+end
+
+return M
diff --git a/smart-srun/root/usr/share/smart-srun/third-party-licenses.txt b/smart-srun/root/usr/share/smart-srun/third-party-licenses.txt
new file mode 100644
index 000000000..8a1219b32
--- /dev/null
+++ b/smart-srun/root/usr/share/smart-srun/third-party-licenses.txt
@@ -0,0 +1,95 @@
+Go dependencies used for HTML/charset decoding and Linux terminal input.
+Pinned versions and checksums: core/go.mod and core/go.sum.
+
+golang.org/x/net v0.59.0 — BSD-3-Clause
+https://pkg.go.dev/golang.org/x/net@v0.59.0
+
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+golang.org/x/text v0.42.0 — BSD-3-Clause
+https://pkg.go.dev/golang.org/x/text@v0.42.0
+
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+golang.org/x/sys v0.48.0 — BSD-3-Clause
+https://pkg.go.dev/golang.org/x/sys@v0.48.0
+
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/smart-srun/root/www/luci-static/resources/smart_srun.js b/smart-srun/root/www/luci-static/resources/smart_srun.js
new file mode 100644
index 000000000..e76ca7039
--- /dev/null
+++ b/smart-srun/root/www/luci-static/resources/smart_srun.js
@@ -0,0 +1,2797 @@
+(function() {
+ if (window.__smartSrunUiLoaded) return;
+ window.__smartSrunUiLoaded = true;
+
+ var campusData = [];
+ var hotspotData = [];
+ var modalType = '';
+ var modalEditId = '';
+ var modalSaveHandler = null;
+ var RELEASES_PAGE_URL = 'https://github.com/matthewlu070111/smart-srun/releases';
+ var UPDATE_CHECK_URL = '/cgi-bin/luci/admin/services/smart_srun/update_check';
+ var UPDATE_START_URL = '/cgi-bin/luci/admin/services/smart_srun/update_start';
+ var UPDATE_STATUS_URL = '/cgi-bin/luci/admin/services/smart_srun/update_status';
+
+ function readText(id) {
+ var node = document.getElementById(id);
+ if (!node) return '';
+ return node.value || node.textContent || '';
+ }
+
+ function readJson(id, fallbackValue) {
+ try {
+ var text = readText(id);
+ return text ? JSON.parse(text) : fallbackValue;
+ } catch (err) {
+ return fallbackValue;
+ }
+ }
+
+ function escapeHtml(value) {
+ return String(value == null ? '' : value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function logLineLevel(line) {
+ if (line.indexOf('[错误]') !== -1) return 'error';
+ if (line.indexOf('[警告]') !== -1) return 'warn';
+ if (line.indexOf('[调试]') !== -1) return 'debug';
+ return 'info';
+ }
+
+ var LOG_LEVEL_COLORS = {
+ error: '#ff6b6b',
+ warn: '#ffb454',
+ debug: '#6c7a89',
+ info: '#9ef19e'
+ };
+
+ function renderFriendlyLogHtml(text) {
+ var lines = String(text || '').split('\n');
+ var out = [];
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i];
+ if (line === '') {
+ out.push('');
+ continue;
+ }
+ var level = logLineLevel(line);
+ var color = LOG_LEVEL_COLORS[level] || LOG_LEVEL_COLORS.info;
+ var weight = (level === 'error' || level === 'warn') ? '600' : '400';
+ var opacity = (level === 'debug') ? '0.78' : '1';
+ out.push(
+ '' + escapeHtml(line) + ' '
+ );
+ }
+ return out.join('\n');
+ }
+
+ function fetchJson(url, callback) {
+ var xhr = new XMLHttpRequest();
+ var finished = false;
+ function finish(err, data) {
+ if (finished) return;
+ finished = true;
+ callback(err, data);
+ }
+ xhr.open('GET', url, true);
+ xhr.timeout = 30000;
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== 4) return;
+ if (xhr.status !== 200) {
+ finish(new Error('http_' + xhr.status));
+ return;
+ }
+ var data;
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (err) { finish(err); return; }
+ finish(null, data);
+ };
+ xhr.onerror = function() { finish(new Error('network_error')); };
+ xhr.ontimeout = function() { finish(new Error('request_timeout')); };
+ xhr.send(null);
+ }
+
+ function requestToken() {
+ var tokenNode = document.querySelector('input[name="token"]');
+ return (tokenNode ? tokenNode.value : ((window.L && L.env) ? L.env.token : '')) || '';
+ }
+
+ function configRevision() {
+ var input = document.getElementById('smart-srun-config-revision');
+ var revision = input ? input.value : '';
+ return /^(0|[1-9][0-9]*)$/.test(revision) && Number(revision) <= 9007199254740991 ? revision : null;
+ }
+
+ function startUpdate(planId, callback) {
+ var xhr = new XMLHttpRequest();
+ var finished = false;
+ function finish(err, data) {
+ if (finished) return;
+ finished = true;
+ callback(err, data);
+ }
+ xhr.open('POST', UPDATE_START_URL, true);
+ xhr.timeout = 30000;
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== 4) return;
+ if (xhr.status !== 200) { finish(new Error('http_' + xhr.status)); return; }
+ var data;
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (err) { finish(err); return; }
+ finish(null, data);
+ };
+ xhr.onerror = function() { finish(new Error('network_error')); };
+ xhr.ontimeout = function() { finish(new Error('request_timeout')); };
+ xhr.send('plan_id=' + encodeURIComponent(planId || '') + '&token=' + encodeURIComponent(requestToken()));
+ }
+
+ function initConfigBackup() {
+ var root = document.getElementById('smart-srun-config-backup');
+ if (!root || root.getAttribute('data-initialized')) return;
+ root.setAttribute('data-initialized', '1');
+ var file = document.getElementById('smart-srun-config-file');
+ var importButton = document.getElementById('smart-srun-config-import');
+ var exportButton = document.getElementById('smart-srun-config-export');
+ var result = document.getElementById('smart-srun-config-result');
+ var pending = null, generation = 0, importing = false;
+ function post(endpoint, values, done) {
+ var xhr = new XMLHttpRequest(), body = ['token=' + encodeURIComponent(requestToken())];
+ for (var key in values) {
+ if (Object.prototype.hasOwnProperty.call(values, key)) body.push(encodeURIComponent(key) + '=' + encodeURIComponent(values[key]));
+ }
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/' + endpoint, true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+ xhr.timeout = 30000;
+ var finished = false;
+ function finish(data) { if (finished) return; finished = true; done(data); }
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== 4) return;
+ var data = null;
+ if (xhr.status === 200) { try { data = JSON.parse(xhr.responseText); } catch (ignore) {} }
+ finish(data);
+ };
+ xhr.onerror = xhr.ontimeout = function() { finish(null); };
+ xhr.send(body.join('&'));
+ }
+ exportButton.onclick = function() {
+ exportButton.disabled = true;
+ post('config_export', {}, function(data) {
+ exportButton.disabled = false;
+ if (!data || data.format !== 'smart-srun-config') { result.textContent = '导出失败,请重试'; return; }
+ var url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2) + '\n'], {type:'application/json'}));
+ var link = document.createElement('a');
+ link.href = url; link.download = 'smart-srun-config.json';
+ document.body.appendChild(link); link.click(); document.body.removeChild(link);
+ setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
+ result.textContent = '配置备份已下载,文件包含密码,请妥善保管。';
+ });
+ };
+ file.onchange = function() {
+ var selected = file.files && file.files[0], revision = ++generation;
+ pending = null; importButton.disabled = true;
+ if (importing || !selected) return;
+ if (selected.size > 524288) { result.textContent = '备份不能超过 512 KiB'; return; }
+ var reader = new FileReader();
+ result.textContent = '正在校验备份…';
+ reader.onerror = function() { if (generation === revision) result.textContent = '无法读取备份'; };
+ reader.onload = function() {
+ var text = String(reader.result || '');
+ if (generation !== revision) return;
+ post('config_import', {data:text, check:'1'}, function(data) {
+ if (generation !== revision) return;
+ if (!data || data.ok !== true) { result.textContent = (data && data.message) || '备份校验失败'; return; }
+ pending = {data:text, expected_revision:data.expected_revision};
+ importButton.disabled = false;
+ result.textContent = '备份包含 ' + data.campus_accounts + ' 个校园账号、' + data.hotspot_profiles + ' 个热点。确认导入将替换现有配置并关闭自动守护。' + (data.warnings && data.warnings.length ? '\n' + data.warnings.join('\n') : '');
+ });
+ };
+ reader.readAsText(selected);
+ };
+ importButton.onclick = function() {
+ if (!pending || importing) return;
+ importing = true; importButton.disabled = true; file.disabled = true;
+ result.textContent = '正在导入配置…';
+ post('config_import', pending, function(data) {
+ pending = null; importing = false; file.disabled = false; file.value = '';
+ result.textContent = (data && data.message) || '未能确认导入结果,请刷新页面检查配置;不要直接重复导入。';
+ if (data && data.ok === true) setTimeout(function() { window.location.reload(); }, 1500);
+ });
+ };
+ }
+
+ function isPageHidden() {
+ return document.hidden === true || document.webkitHidden === true;
+ }
+
+ function onPageVisible(callback) {
+ function runIfVisible() {
+ if (!isPageHidden()) callback();
+ }
+ document.addEventListener('visibilitychange', runIfVisible, false);
+ document.addEventListener('webkitvisibilitychange', runIfVisible, false);
+ }
+
+ function formatUpdateStatus(data) {
+ data = data || {};
+ var lines = [];
+ lines.push('状态:' + (data.message || data.phase || '未知'));
+ if (data.current_version) lines.push('当前版本:' + data.current_version);
+ if (data.latest_tag || data.latest_version) lines.push('目标版本:' + (data.latest_tag || data.latest_version));
+ if (data.install_mode) lines.push('包型:' + data.install_mode + ' / ' + (data.package_format || ''));
+ if (data.package_name) lines.push('当前包:' + data.package_name);
+ if (data.asset_name) lines.push('下载项:' + data.asset_name);
+ return lines.join('\n');
+ }
+
+ function pollUpdateStatus(outputNode, errorStreak) {
+ errorStreak = errorStreak || 0;
+ fetchJson(UPDATE_STATUS_URL, function(err, data) {
+ if (err || !data) {
+ // 更新末尾会重启 uwsgi,期间状态接口短暂不可用属正常现象,
+ // 容忍几次失败后再放弃,避免误报“读取更新状态失败”。
+ if (errorStreak >= 5) {
+ outputNode.textContent = '读取更新状态失败';
+ return;
+ }
+ outputNode.textContent = '更新进行中,正在等待服务恢复…';
+ setTimeout(function() { pollUpdateStatus(outputNode, errorStreak + 1); }, 2000);
+ return;
+ }
+ outputNode.textContent = formatUpdateStatus(data);
+ if (data.running) {
+ setTimeout(function() { pollUpdateStatus(outputNode, 0); }, 2000);
+ }
+ });
+ }
+
+ function openUpdateModal(plan) {
+ plan = plan || {};
+ var output = E('pre', {
+ 'style': 'max-height:18rem;overflow:auto;margin:0;padding:.75rem;border:1px solid rgba(127,127,127,.28);background:rgba(127,127,127,.08);white-space:pre-wrap;word-break:break-word;'
+ }, formatUpdateStatus(plan));
+ var buttonRow = E('div', { 'class': 'right' });
+ var cancelBtn = E('button', {
+ 'type': 'button',
+ 'class': 'btn cbi-button',
+ 'click': function() { L.hideModal(); }
+ }, '取消');
+ var releaseBtn = E('a', {
+ 'class': 'btn cbi-button',
+ 'href': plan.release_page || RELEASES_PAGE_URL,
+ 'target': '_blank',
+ 'rel': 'noopener noreferrer'
+ }, '发布页');
+ var updateBtn = E('button', {
+ 'type': 'button',
+ 'class': 'btn cbi-button cbi-button-apply important',
+ 'click': function() {
+ var target = plan.latest_tag || plan.latest_version || '最新版本';
+ if (!confirm('确认自动更新到 ' + target + '?更新过程中请不要刷新或断电。')) return;
+ updateBtn.disabled = true;
+ output.textContent = '正在提交后台更新任务...';
+ startUpdate(plan.plan_id, function(err, data) {
+ if (err || !data) {
+ output.textContent = '提交更新失败';
+ updateBtn.disabled = false;
+ return;
+ }
+ output.textContent = formatUpdateStatus(data);
+ if (!data.running) { updateBtn.disabled = false; return; }
+ pollUpdateStatus(output);
+ });
+ }
+ }, '自动更新');
+ buttonRow.appendChild(cancelBtn);
+ buttonRow.appendChild(document.createTextNode(' '));
+ buttonRow.appendChild(releaseBtn);
+ buttonRow.appendChild(document.createTextNode(' '));
+ buttonRow.appendChild(updateBtn);
+ L.showModal('SMART SRun 更新', [output, buttonRow], 'cbi-modal');
+ }
+
+ function initVersionNotice() {
+ var container = document.getElementById('smart-srun-version-info');
+ var link = document.getElementById('smart-srun-version-link');
+ var dot = document.getElementById('smart-srun-update-dot');
+ if (!container || !link || !dot || window.__smartSrunVersionInit) return;
+ window.__smartSrunVersionInit = true;
+
+ link.href = RELEASES_PAGE_URL;
+ var updatePlan = null, retry = false, checking = false;
+ var message = document.createElement('span');
+ message.setAttribute('aria-live', 'polite');
+ container.appendChild(message);
+ link.addEventListener('click', function(ev) {
+ if (retry) {
+ ev.preventDefault();
+ if (!checking) checkVersion();
+ return;
+ }
+ if (!updatePlan || !updatePlan.update_available) return;
+ ev.preventDefault();
+ openUpdateModal(updatePlan);
+ });
+
+ function checked(err, data) {
+ if (!err && data && data.running && data.job_id) {
+ setTimeout(function() {
+ fetchJson(UPDATE_STATUS_URL + '?job_id=' + encodeURIComponent(data.job_id), checked);
+ }, 1000);
+ return;
+ }
+ checking = false;
+ if (err || !data || !data.ok || (!data.update_available && data.code)) {
+ retry = true;
+ var reason = (data && data.message) || '检查更新失败,请检查网络';
+ message.textContent = '(' + reason + ';点击版本号重试)';
+ link.title = reason + ';点击重试';
+ return;
+ }
+ retry = false;
+ message.textContent = '';
+ if (!data.update_available) { link.title = data.message || '当前没有兼容更新'; return; }
+ updatePlan = data;
+ dot.style.display = 'inline-block';
+ link.title = '发现新版本:' + (data.latest_tag || data.latest_version || '');
+ }
+ function checkVersion() {
+ checking = true;
+ updatePlan = null;
+ dot.style.display = 'none';
+ message.textContent = '(正在检查更新)';
+ link.title = '正在检查更新';
+ fetchJson(UPDATE_CHECK_URL, checked);
+ }
+ checkVersion();
+ }
+
+ window.smartFetchJson = fetchJson;
+
+ function renderPortalGuidance(container, url) {
+ if (!container) return;
+ container.textContent = '';
+ container.style.display = 'none';
+ url = String(url || '');
+ // Only open a configured HTTP(S) school page on an explicit user click.
+ if (!/^https?:\/\//i.test(url) || /[\s\\]/.test(url)) return;
+ var link = document.createElement('a');
+ link.href = url;
+ if ((link.protocol !== 'http:' && link.protocol !== 'https:') || !link.host) return;
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.textContent = '打开学校认证页';
+ container.appendChild(link);
+ container.appendChild(document.createTextNode(',确认网络状态后可返回重试。'));
+ container.style.display = '';
+ }
+
+ function openBlockingFeedback(action, requestedAt, actionId) {
+ var result = document.getElementById('smart-srun-manual-result') || document.getElementById('smart-srun-switch-result');
+ var resultPortal = document.getElementById('smart-srun-manual-portal');
+ renderPortalGuidance(resultPortal, '');
+ var logBox = E('pre', {
+ 'style': 'max-height:18rem;overflow:auto;margin:0;padding:.75rem;border:1px solid rgba(127,127,127,.28);background:rgba(127,127,127,.08);white-space:pre-wrap;word-break:break-word;'
+ }, '等待后端反馈...');
+ var titles = {
+ manual_login: '正在登录',
+ manual_logout: '正在登出',
+ switch_hotspot: '正在切到热点',
+ switch_campus: '正在切回校园网'
+ };
+ var tips = {
+ manual_login: '正在执行登录流程,请勿关闭页面。',
+ manual_logout: '正在执行登出流程,请稍候。',
+ switch_hotspot: '正在切换到热点网络,请稍候。',
+ switch_campus: '正在切换回校园网,请稍候。'
+ };
+ var tip = E('p', { 'style': 'margin:.5rem 0 1rem 0;' }, tips[action] || '正在执行网络动作,请稍候。');
+ var portalHelp = E('div', { 'style': 'display:none;margin:.75rem 0;' });
+ var footer = E('div', { 'class': 'right' });
+ var closed = false;
+ var timer = null;
+ var progressButton = E('button', {
+ 'class': 'btn cbi-button',
+ 'disabled': 'disabled'
+ }, '进行中');
+ var forceButton = E('button', {
+ 'class': 'btn cbi-button cbi-button-remove',
+ 'click': function(ev) {
+ ev.preventDefault();
+ if (closed || forceButton.disabled) return;
+ forceButton.disabled = true;
+ if (result) result.textContent = '正在强制停止...';
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/enqueue', true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== 4 || closed) return;
+ var stopped = false;
+ var text = '强制停止失败,请重试';
+ if (xhr.status === 200) {
+ try {
+ var data = JSON.parse(xhr.responseText || '{}');
+ stopped = data.ok === true;
+ if (stopped) text = '已触发强制停止';
+ if (typeof data.message === 'string' && data.message !== '')
+ text = data.message;
+ } catch (e) {}
+ }
+ if (stopped) {
+ unlock(text, false);
+ } else {
+ forceButton.disabled = false;
+ tip.textContent = text;
+ if (result) result.textContent = text;
+ }
+ };
+ xhr.send('action=' + encodeURIComponent('force_stop') + '&token=' + encodeURIComponent(requestToken()));
+ }
+ }, '强制停止');
+
+ progressButton.addEventListener('click', function(ev) {
+ if (progressButton.disabled) {
+ ev.preventDefault();
+ return;
+ }
+ L.hideModal();
+ location.reload();
+ });
+
+ footer.appendChild(progressButton);
+ footer.appendChild(forceButton);
+
+ function setTerminalFooter() {
+ progressButton.disabled = false;
+ progressButton.textContent = '关闭返回';
+ forceButton.disabled = true;
+ }
+
+ function unlock(text, success, portalUrl) {
+ if (closed) return;
+ closed = true;
+ if (timer) window.clearInterval(timer);
+ setTerminalFooter();
+ tip.textContent = text || (success ? '操作完成' : '执行失败');
+ renderPortalGuidance(portalHelp, success ? '' : portalUrl);
+ renderPortalGuidance(resultPortal, success ? '' : portalUrl);
+ if (result && text) result.textContent = text + (success ? ' 🎉' : ' ⚠');
+ }
+
+ function checkTerminal(statusData) {
+ if (!statusData) return false;
+ if (statusData.action_id !== actionId) return false;
+ if (statusData.last_action && statusData.last_action !== action) return false;
+ if (statusData.action_result === 'forced') {
+ unlock(statusData.last_action_message || statusData.status || '已强制停止', false);
+ return true;
+ }
+ if (statusData.action_result === 'error') {
+ unlock(statusData.last_action_message || statusData.status || '执行失败', false, statusData.last_action_portal_url);
+ return true;
+ }
+ if (statusData.action_result === 'ok') {
+ unlock(statusData.last_action_message || statusData.status || '操作完成', true);
+ return true;
+ }
+ return false;
+ }
+
+ var pendingReads = 0;
+ function poll() {
+ if (closed || pendingReads) return;
+ pendingReads = 2;
+ fetchJson('/cgi-bin/luci/admin/services/smart_srun/log_tail?lines=200&format=friendly&since=' + encodeURIComponent(requestedAt) + '&_=' + Date.now(), function(err, logData) {
+ pendingReads--;
+ if (!closed && !err && logData && typeof logData.log === 'string' && !logData.empty) {
+ logBox.innerHTML = renderFriendlyLogHtml(logData.log);
+ logBox.scrollTop = logBox.scrollHeight;
+ }
+ });
+
+ fetchJson('/cgi-bin/luci/admin/services/smart_srun/status?action_id=' + encodeURIComponent(actionId) + '&_=' + Date.now(), function(err, statusData) {
+ pendingReads--;
+ if (closed || err) return;
+ checkTerminal(statusData);
+ });
+ }
+
+ L.showModal(titles[action] || '正在执行动作', [ tip, logBox, portalHelp, footer ], 'cbi-modal');
+ if (typeof actionId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(actionId)) {
+ unlock('无法读取操作回执,请查看当前状态', false);
+ return;
+ }
+ timer = window.setInterval(poll, 1000);
+ poll();
+ }
+
+ window.smartOpenBlockingFeedback = openBlockingFeedback;
+
+ function getFieldValue(id) {
+ var node = document.getElementById('widget.' + id) || document.getElementById(id);
+ return node ? node.value : '';
+ }
+
+ function renderPasswordField(containerId, fieldId, value) {
+ var container = document.getElementById(containerId);
+ if (!container) return;
+ L.require('ui').then(function(ui) {
+ var widget = new ui.Textfield(value || '', {
+ id: fieldId,
+ password: true,
+ optional: true
+ });
+ return Promise.resolve(widget.render()).then(function(node) {
+ container.innerHTML = '';
+ container.appendChild(node);
+ });
+ });
+ }
+
+ function setRowDisabled(rowId, inputId, disabled) {
+ var row = document.getElementById(rowId);
+ var input = document.getElementById(inputId);
+ if (!row || !input) return;
+ input.disabled = !!disabled;
+ row.style.opacity = disabled ? '0.55' : '1';
+ }
+
+ function updateCampusAccessModeUI() {
+ var mode = document.getElementById('jm-access_mode');
+ if (!mode) return;
+ var wired = mode.value === 'wired';
+ var policy = document.getElementById('jm-ap_selection');
+ setRowDisabled('jm-wired-iface-row', 'jm-wired_iface', !wired);
+ setRowDisabled('jm-auth-enabled-row', 'jm-auth_enabled', !wired);
+ setRowDisabled('jm-ssid-row', 'jm-ssid', wired);
+ setRowDisabled('jm-ap-selection-row', 'jm-ap_selection', wired);
+ setRowDisabled('jm-bssid-row', 'jm-bssid', wired || !policy || policy.value !== 'fixed');
+ setRowDisabled('jm-radio-row', 'jm-radio', wired);
+ }
+
+ function normalizeApSelection(value, bssid) {
+ var policy = String(value || '').replace(/^\s+|\s+$/g, '').toLowerCase();
+ if (policy === 'auto' || policy === 'strongest' || policy === 'fixed') return policy;
+ return String(bssid || '').replace(/^\s+|\s+$/g, '') ? 'fixed' : 'auto';
+ }
+
+ function isValidBssid(value) {
+ var address = String(value || '').replace(/^\s+|\s+$/g, '').toLowerCase();
+ return /^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(address) &&
+ address !== '00:00:00:00:00:00' && (parseInt(address.slice(0, 2), 16) & 1) === 0;
+ }
+
+ function apSelectionLabel(policy) {
+ var labels = { auto: '系统自动', strongest: '连接时信号优先', fixed: '固定 BSSID' };
+ return labels[policy] || '未知';
+ }
+
+ function overviewField(label, value, mono, unit) {
+ var observed = value === undefined || value === null || value === '' ? '未知' : String(value) + (unit || '');
+ return '
' + escapeHtml(label) + ' ' + escapeHtml(observed) + ' ';
+ }
+
+ function overviewGroup(label, fields) {
+ return '' + escapeHtml(label) + ' ' + fields + ' ';
+ }
+
+ function overviewPrimary(label, value) {
+ return '' + escapeHtml(label) + ' ' + escapeHtml(value) + '
';
+ }
+
+ function wirelessStatusMarkup(data) {
+ if (data.current_campus_access_mode === 'wired' || data.mode_label === '校园网模式(有线)') return '';
+ return overviewGroup('无线连接', overviewField('实际 AP', data.current_bssid, true) +
+ overviewField('无线接口', data.current_wireless_ifname, true) +
+ overviewField('信号', data.current_signal, true, ' dBm') +
+ overviewField('信道', data.current_channel, true));
+ }
+
+ function showNativeModal(title, bodyHtml, afterOpen, onSave) {
+ var body = document.createElement('div');
+ body.innerHTML = bodyHtml;
+
+ var buttonRow = document.createElement('div');
+ buttonRow.className = 'right';
+
+ var cancelBtn = document.createElement('button');
+ cancelBtn.type = 'button';
+ cancelBtn.className = 'btn cbi-button';
+ cancelBtn.textContent = '取消';
+ cancelBtn.onclick = function() { L.hideModal(); };
+
+ var saveBtn = document.createElement('button');
+ saveBtn.type = 'button';
+ saveBtn.className = 'btn cbi-button cbi-button-save important';
+ saveBtn.textContent = '保存';
+ saveBtn.onclick = function() {
+ if (typeof modalSaveHandler === 'function') modalSaveHandler();
+ };
+
+ buttonRow.appendChild(cancelBtn);
+ buttonRow.appendChild(document.createTextNode(' '));
+ buttonRow.appendChild(saveBtn);
+
+ modalSaveHandler = onSave;
+ L.showModal(title, [ body, buttonRow ], 'cbi-modal');
+ if (typeof afterOpen === 'function') afterOpen();
+ }
+
+ function findById(items, id) {
+ for (var i = 0; i < items.length; i++) {
+ if (items[i].id === id) return items[i];
+ }
+ return null;
+ }
+
+ function schoolPresetList() {
+ // 不再内置任何学校的兜底预设:预设列表完全来自后端(远端/缓存/打包 fallback)。
+ var items = readJson('smart-school-preset-data', []);
+ var active = [];
+ for (var i = 0; items && i < items.length; i++) {
+ if (items[i] && items[i].status === 'active') active.push(items[i]);
+ }
+ return active;
+ }
+
+ window.smartRefreshPresets = function() {
+ var node = document.getElementById('smart-school-preset-data');
+ if (!node || window.__smartPresetsRefresh) return;
+ var button = document.getElementById('smart-presets-refresh');
+ var result = document.getElementById('smart-presets-refresh-result');
+ window.__smartPresetsRefresh = true;
+ if (button) button.disabled = true;
+ if (result) result.textContent = '正在更新…';
+ postDiscovery('presets_refresh', {}, function(err, data) {
+ window.__smartPresetsRefresh = false;
+ if (button) button.disabled = false;
+ if (err || !data || !data.ok || !data.schools) {
+ if (result) result.textContent = '更新失败,仍使用本地预设';
+ return;
+ }
+ node.value = JSON.stringify(data.schools);
+ node.textContent = node.value;
+ if (result) result.textContent = '学校预设已更新';
+ });
+ };
+
+ var DEFAULT_LOGIN_SHAPE = {
+ n: '200',
+ type: '1',
+ enc: 'srun_bx1',
+ info_prefix: 'SRBX1',
+ double_stack: '0',
+ os: 'Windows 10',
+ name: 'Windows'
+ };
+
+ function findSchoolPreset(id) {
+ // 无预设(空/哨兵值)或找不到时一律返回 null,不再回落到任何特定学校。
+ // 查找范围包含用户自定义预设(路由器侧 user_presets.json)。
+ var wanted = String(id || '');
+ if (!wanted || wanted === '__none__') return null;
+ var items = schoolPresetList().concat(loadCustomPresets());
+ for (var i = 0; i < items.length; i++) {
+ if (String(items[i].short_name || '') === wanted) return items[i];
+ }
+ return null;
+ }
+
+ // 用户自定义预设/运营商存储:真身在路由器侧 /etc/smart-srun/user-presets.json,
+ // 页面渲染时经 #smart-user-preset-data 注入,增删后整份 POST 回写,跨设备共享。
+ var USER_PRESETS_SET_URL = '/cgi-bin/luci/admin/services/smart_srun/user_presets_set';
+ var userPresetStore = { presets: [], operators: [] };
+
+ function normalizeUserStore(raw) {
+ var store = { presets: [], operators: [] };
+ if (raw && raw.presets && raw.presets.length) store.presets = raw.presets;
+ if (raw && raw.operators && raw.operators.length) store.operators = raw.operators;
+ return store;
+ }
+
+ function initUserPresetStore() {
+ userPresetStore = normalizeUserStore(readJson('smart-user-preset-data', null));
+ }
+
+ function pushUserPresetStore(callback) {
+ var fd = new FormData();
+ fd.append('token', requestToken());
+ fd.append('data', JSON.stringify(userPresetStore));
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', USER_PRESETS_SET_URL, true);
+ xhr.onload = function() {
+ var data = {};
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
+ if (xhr.status === 200 && data.ok) {
+ if (callback) callback(null);
+ } else {
+ if (callback) callback(new Error((data && data.message) ? data.message : ('HTTP ' + xhr.status)));
+ }
+ };
+ xhr.onerror = function() {
+ if (callback) callback(new Error('网络错误'));
+ };
+ xhr.send(fd);
+ }
+
+ // 返回存储内的原数组引用:调用方就地增删后调 pushUserPresetStore 落盘。
+ function loadCustomPresets() {
+ return userPresetStore.presets;
+ }
+
+ function loadCustomOperators() {
+ return userPresetStore.operators;
+ }
+
+ function saveCustomOperators(ops) {
+ userPresetStore.operators = ops || [];
+ pushUserPresetStore(function(err) {
+ if (err) alert('运营商列表已在本页生效,但保存到路由器失败:' + err.message);
+ });
+ }
+
+ function isCustomPresetId(id) {
+ return String(id || '').indexOf('custom-') === 0;
+ }
+
+ function radioOptionsMarkup() {
+ return readText('smart-radio-options');
+ }
+
+ window.smartSetDefault = function(kind, id) {
+ var revision = configRevision();
+ if (revision === null) { alert('配置版本不可用,请刷新页面后重试。'); return; }
+ var fd = new FormData();
+ fd.append('token', requestToken());
+ fd.append('expected_revision', revision);
+ fd.append('action', 'set_default_' + kind);
+ fd.append('id', id);
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/enqueue', true);
+ xhr.onload = function() {
+ var data = {};
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
+ if (xhr.status === 200 && data && data.ok === true) {
+ alert((typeof data.message === 'string' && data.message !== '') ? data.message : '已保存默认配置');
+ location.reload();
+ } else {
+ alert((data && data.message) ? data.message : ('操作失败(HTTP ' + xhr.status + ')'));
+ }
+ };
+ xhr.onerror = function() { alert('操作失败:网络错误,请重试'); };
+ xhr.send(fd);
+ };
+
+ window.smartDelete = function(kind, id) {
+ var revision = configRevision();
+ if (revision === null) { alert('配置版本不可用,请刷新页面后重试。'); return; }
+ if (!confirm('确定要删除此项吗?')) return;
+ var fd = new FormData();
+ fd.append('token', requestToken());
+ fd.append('expected_revision', revision);
+ fd.append('action', 'delete_' + kind);
+ fd.append('id', id);
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/enqueue', true);
+ xhr.onload = function() {
+ var data = {};
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
+ if (xhr.status === 200 && data && data.ok === true) {
+ location.reload();
+ } else {
+ alert((data && data.message) ? data.message : ('删除失败(HTTP ' + xhr.status + ')'));
+ }
+ };
+ xhr.onerror = function() { alert('删除失败:网络错误,请重试'); };
+ xhr.send(fd);
+ };
+
+ window.smartEditCampus = function(id) {
+ modalType = 'campus';
+ modalEditId = id;
+ var item = id ? findById(campusData, id) : {};
+ var presets = schoolPresetList();
+ var customPresets = loadCustomPresets();
+ var NO_PRESET_ID = '__none__';
+ // 默认不选任何学校预设:预设只负责预填写,所有字段都由用户决定。
+ var selectedPresetId = NO_PRESET_ID;
+ var initialValues = {
+ label: item.label || '',
+ user_id: item.user_id || '',
+ operator_suffix: item.operator_suffix || '',
+ access_mode: item.access_mode || 'wifi',
+ wired_iface: String(item.wired_iface || '').replace(/^\s+|\s+$/g, '') || String(item.network_interface || '').replace(/^\s+|\s+$/g, '') || 'wan',
+ auth_enabled: String(item.auth_enabled || '0'),
+ base_url: item.base_url || '',
+ ac_id: item.ac_id || '1',
+ n: item.n || '',
+ type: item.type || '',
+ enc: item.enc || '',
+ info_prefix: item.info_prefix || '',
+ double_stack: item.double_stack || '',
+ login_os: item.login_os || '',
+ login_name: item.login_name || '',
+ ssid: item.ssid || '',
+ bssid: item.bssid || '',
+ ap_selection: normalizeApSelection(item.ap_selection, item.bssid),
+ radio: item.radio || ''
+ };
+
+ // 运营商快捷下拉是一份可编辑列表:学校预设的 operators 只作预填写(可删),
+ // 用户自增的条目落在路由器侧 user_presets.json,跨设备共享。
+ // 字段已从 id 改名为 suffix;仍兼容旧的 id 键。
+ function operatorSuffixOf(op) {
+ if (!op) return '';
+ var v = (op.suffix !== undefined && op.suffix !== null) ? op.suffix : op.id;
+ return String(v || '');
+ }
+
+ var operatorChoices = [];
+
+ function operatorChoiceExists(suffix, label) {
+ for (var i = 0; i < operatorChoices.length; i++) {
+ if (operatorChoices[i].suffix === suffix && operatorChoices[i].label === label) return true;
+ }
+ return false;
+ }
+
+ function addOperatorChoice(suffix, label, custom) {
+ suffix = String(suffix || '');
+ label = String(label || '') || (suffix === '' ? '无后缀' : suffix);
+ if (operatorChoiceExists(suffix, label)) return;
+ operatorChoices.push({ suffix: suffix, label: label, custom: !!custom });
+ }
+
+ function persistCustomOperators() {
+ var out = [];
+ for (var i = 0; i < operatorChoices.length; i++) {
+ if (operatorChoices[i].custom) out.push({ suffix: operatorChoices[i].suffix, label: operatorChoices[i].label });
+ }
+ saveCustomOperators(out);
+ }
+
+ function seedOperatorChoices() {
+ operatorChoices = [];
+ var saved = loadCustomOperators();
+ for (var i = 0; i < saved.length; i++) {
+ addOperatorChoice(saved[i].suffix, saved[i].label, true);
+ }
+ var current = String(initialValues.operator_suffix || '');
+ if (current) addOperatorChoice(current, current, false);
+ }
+
+ function renderOperatorChoices(selectedSuffix) {
+ var opSel = document.getElementById('jm-operator');
+ if (!opSel) return;
+ var out = '';
+ if (!operatorChoices.length) {
+ out = '(暂无选项,点“添加”自建) ';
+ }
+ for (var oi = 0; oi < operatorChoices.length; oi++) {
+ var sfx = operatorChoices[oi].suffix;
+ // suffix 为 "??" 表示该运营商后缀尚未被提供者验证,下拉里标注“未验证”。
+ var text = (sfx === '??') ? (operatorChoices[oi].label + '(未验证)') : operatorChoices[oi].label;
+ out += '' + escapeHtml(text) + ' ';
+ }
+ opSel.innerHTML = out;
+ if (operatorChoices.length && selectedSuffix !== undefined && selectedSuffix !== null) {
+ opSel.value = String(selectedSuffix);
+ }
+ }
+
+ // 应用预设时用该校 operators 整体替换下拉中的「预设预填」项。
+ // 上一所学校留下的 label/suffix(如南航「学生用户」)必须清掉,否则会串到
+ // 下一所学校;用户自建(custom)条目仍保留(与「复位」一致)。
+ function replacePresetOperators(preset) {
+ var kept = [];
+ for (var i = 0; i < operatorChoices.length; i++) {
+ if (operatorChoices[i].custom) kept.push(operatorChoices[i]);
+ }
+ operatorChoices = kept;
+ var ops = (preset && preset.operators && preset.operators.length) ? preset.operators : [];
+ for (var j = 0; j < ops.length; j++) {
+ addOperatorChoice(operatorSuffixOf(ops[j]), String(ops[j].label || ''), false);
+ }
+ return ops;
+ }
+
+ // 下拉选择运营商 -> 填充后缀输入框。"??"(未验证)不直接写入,而是清空并提示用户手填。
+ function applyOperatorPick() {
+ var sfx = document.getElementById('jm-operator_suffix');
+ var opSel = document.getElementById('jm-operator');
+ var hint = document.getElementById('jm-operator-suffix-hint');
+ if (!sfx || !opSel) return;
+ var val = String(opSel.value || '');
+ if (val === '??') {
+ sfx.value = '';
+ if (hint) {
+ hint.textContent = '该运营商后缀尚未被验证,请自行确认后手动填写。';
+ hint.style.display = '';
+ }
+ } else {
+ sfx.value = val;
+ if (hint) {
+ hint.textContent = '';
+ hint.style.display = 'none';
+ }
+ }
+ }
+
+ function addOperatorFromPrompt() {
+ var label = window.prompt('运营商显示名称(例如:学生用户 / 中国移动)', '');
+ if (label === null) return;
+ var sfx = window.prompt('运营商后缀(用户名 @ 后面的部分,留空表示纯账号不带后缀)', '');
+ if (sfx === null) return;
+ label = String(label).replace(/^\s+|\s+$/g, '');
+ sfx = String(sfx).replace(/^\s+|\s+$/g, '').replace(/^@+/, '');
+ addOperatorChoice(sfx, label, true);
+ persistCustomOperators();
+ renderOperatorChoices(sfx);
+ applyOperatorPick();
+ }
+
+ function removeSelectedOperator() {
+ var opSel = document.getElementById('jm-operator');
+ if (!opSel || !operatorChoices.length) return;
+ var idx = opSel.selectedIndex;
+ if (idx < 0 || idx >= operatorChoices.length) return;
+ operatorChoices.splice(idx, 1);
+ persistCustomOperators();
+ var nextIdx = Math.min(idx, operatorChoices.length - 1);
+ renderOperatorChoices(nextIdx >= 0 ? operatorChoices[nextIdx].suffix : undefined);
+ }
+
+ function presetOptionsMarkup() {
+ // 自定义预设置顶:无预设 → 自定义 → 远端/内置。
+ var out = '无预设 ';
+ for (var ci = 0; ci < customPresets.length; ci++) {
+ var customId = String(customPresets[ci].short_name || '');
+ if (!customId) continue;
+ out += '' + escapeHtml(String(customPresets[ci].name || customId)) + '(自定义) ';
+ }
+ for (var pi = 0; pi < presets.length; pi++) {
+ var presetId = String(presets[pi].short_name || '');
+ if (!presetId) continue;
+ out += '' + escapeHtml(presets[pi].name || presetId) + ' ';
+ }
+ return out;
+ }
+
+ function rebuildPresetSelect() {
+ var presetSel = document.getElementById('jm-school_preset');
+ if (presetSel) presetSel.innerHTML = presetOptionsMarkup();
+ }
+
+ function formFieldValue(idKey) {
+ var node = document.getElementById(idKey);
+ return node ? String(node.value || '') : '';
+ }
+
+ // 把当前表单的环境字段存为自定义预设(路由器侧 user_presets.json)。
+ // 只存环境信息(认证地址/AC_ID/SSID/接入方式/登录形态/运营商列表),
+ // 学工号、密码等凭据绝不写入。
+ function saveCurrentFormAsPreset() {
+ var name = window.prompt('预设名称(与已有自定义预设同名会覆盖它)', '');
+ if (name === null) return;
+ name = String(name).replace(/^\s+|\s+$/g, '');
+ if (!name) {
+ alert('预设名称不能为空');
+ return;
+ }
+ var ops = [];
+ for (var i = 0; i < operatorChoices.length; i++) {
+ ops.push({ suffix: operatorChoices[i].suffix, label: operatorChoices[i].label });
+ }
+ var preset = {
+ short_name: 'custom-' + new Date().getTime(),
+ name: name,
+ custom: true,
+ defaults: {
+ base_url: formFieldValue('jm-base_url'),
+ ac_id: formFieldValue('jm-ac_id'),
+ ssid: formFieldValue('jm-ssid'),
+ access_mode: formFieldValue('jm-access_mode'),
+ wired_iface: formFieldValue('jm-wired_iface')
+ },
+ observed_login_shape: {
+ n: formFieldValue('jm-login-n'),
+ type: formFieldValue('jm-login-type'),
+ enc: formFieldValue('jm-login-enc'),
+ info_prefix: formFieldValue('jm-info-prefix'),
+ double_stack: formFieldValue('jm-double-stack'),
+ os: formFieldValue('jm-login-os'),
+ name: formFieldValue('jm-login-name')
+ },
+ operators: ops
+ };
+ for (var pi = 0; pi < customPresets.length; pi++) {
+ if (String(customPresets[pi].name || '') === name) {
+ preset.short_name = String(customPresets[pi].short_name || preset.short_name);
+ customPresets.splice(pi, 1);
+ break;
+ }
+ }
+ customPresets.push(preset);
+ selectedPresetId = preset.short_name;
+ rebuildPresetSelect();
+ pushUserPresetStore(function(err) {
+ if (err) alert('预设已在本页生效,但保存到路由器失败:' + err.message);
+ else alert('已保存自定义预设:' + name);
+ });
+ }
+
+ function deleteSelectedCustomPreset() {
+ var presetSel = document.getElementById('jm-school_preset');
+ var pid = presetSel ? String(presetSel.value || '') : '';
+ if (!isCustomPresetId(pid)) {
+ alert('只能删除自己保存的自定义预设');
+ return;
+ }
+ var targetIdx = -1;
+ for (var i = 0; i < customPresets.length; i++) {
+ if (String(customPresets[i].short_name || '') === pid) {
+ targetIdx = i;
+ break;
+ }
+ }
+ if (targetIdx < 0) {
+ alert('未找到该自定义预设');
+ return;
+ }
+ if (!confirm('确定删除自定义预设「' + String(customPresets[targetIdx].name || pid) + '」?')) return;
+ customPresets.splice(targetIdx, 1);
+ selectedPresetId = NO_PRESET_ID;
+ rebuildPresetSelect();
+ pushUserPresetStore(function(err) {
+ if (err) alert('预设已在本页删除,但保存到路由器失败:' + err.message);
+ });
+ }
+
+ var bodyHtml =
+ '学校预设 ' + presetOptionsMarkup() + ' 应用预设 复位
' +
+ '标签(选填)
' +
+ '学工号
' +
+ '' +
+ '运营商后缀 如何获取? ' +
+ '
' +
+ ' ' +
+ '添加 ' +
+ '删除 ' +
+ ' ' +
+ '
' +
+ '
' +
+ '接入方式 无线 有线(指定接口)
' +
+ '' +
+ '参与并行守护 关闭 启用 需同时开启页面上方“多 WAN 并行认证”;守护进程会按本账号的接口、学工号、密码和后缀独立认证。
' +
+ '' +
+ 'AC_ID 自动探测
' +
+ '校园网 SSID
' +
+ '进阶设置 ' +
+ 'AP 选择 系统自动 连接时信号优先 固定 BSSID
' +
+ '频段 ' + radioOptionsMarkup() + '
' +
+ '固定 BSSID
' +
+ '信号优先仅在连接时扫描同 SSID、同一频段的兼容信道;在线不主动漫游,认证失败不会轮换 AP。dBm 越接近 0,信号越强。固定 BSSID 不可用时不会自动改连其他 AP。
' +
+ 'n
' +
+ 'type
' +
+ 'enc
' +
+ 'info 前缀
' +
+ 'double_stack
' +
+ 'os
' +
+ 'name
' +
+ ' ' +
+ '' +
+ '保存为新预设 ' +
+ '删除预设 ' +
+ '
';
+
+ function applySchoolDefaultsToForm() {
+ var preset = findSchoolPreset(selectedPresetId);
+ if (!preset) {
+ resetSchoolDefaultsForm();
+ return;
+ }
+ var schoolDefaults = preset.defaults || {};
+ var loginShape = preset.observed_login_shape || {};
+ var fieldMap = {
+ base_url: 'jm-base_url',
+ ac_id: 'jm-ac_id',
+ ssid: 'jm-ssid'
+ };
+ for (var key in fieldMap) {
+ var target = document.getElementById(fieldMap[key]);
+ if (!target) continue;
+ target.value = (schoolDefaults[key] !== undefined && schoolDefaults[key] !== null) ? String(schoolDefaults[key]) : '';
+ }
+ var nextOperators = replacePresetOperators(preset);
+ var nextSuffix = nextOperators.length ? operatorSuffixOf(nextOperators[0]) : '';
+ renderOperatorChoices(nextOperators.length ? nextSuffix : undefined);
+ applyLoginShapeToForm(loginShape);
+ // 应用预设时:有运营商则用第一个联动填充后缀;无运营商则清空后缀,避免残留旧校值。
+ if (nextOperators.length) {
+ applyOperatorPick();
+ } else {
+ var emptySuffix = document.getElementById('jm-operator_suffix');
+ if (emptySuffix) emptySuffix.value = '';
+ var emptyHint = document.getElementById('jm-operator-suffix-hint');
+ if (emptyHint) {
+ emptyHint.textContent = '';
+ emptyHint.style.display = 'none';
+ }
+ }
+ if (schoolDefaults.access_mode) {
+ var modeSel = document.getElementById('jm-access_mode');
+ if (modeSel) modeSel.value = String(schoolDefaults.access_mode);
+ }
+ if (schoolDefaults.wired_iface !== undefined && schoolDefaults.wired_iface !== null) {
+ var ifaceInput = document.getElementById('jm-wired_iface');
+ if (ifaceInput) ifaceInput.value = String(schoolDefaults.wired_iface || 'wan');
+ }
+ updateCampusAccessModeUI();
+ }
+
+ function applyLoginShapeToForm(shape) {
+ shape = shape || {};
+ var map = {
+ n: 'jm-login-n',
+ type: 'jm-login-type',
+ enc: 'jm-login-enc',
+ info_prefix: 'jm-info-prefix',
+ double_stack: 'jm-double-stack'
+ };
+ for (var key in map) {
+ var target = document.getElementById(map[key]);
+ if (!target) continue;
+ target.value = (shape[key] !== undefined && shape[key] !== null) ? String(shape[key]) : '';
+ }
+ var osNode = document.getElementById('jm-login-os');
+ if (osNode) osNode.value = (shape.os !== undefined && shape.os !== null) ? String(shape.os) : '';
+ var nameNode = document.getElementById('jm-login-name');
+ if (nameNode) nameNode.value = (shape.name !== undefined && shape.name !== null) ? String(shape.name) : '';
+ }
+
+ function resetSchoolDefaultsForm() {
+ selectedPresetId = NO_PRESET_ID;
+ var presetSel = document.getElementById('jm-school_preset');
+ if (presetSel) presetSel.value = selectedPresetId;
+ // 复位:丢弃预设预填的运营商条目,保留用户自建(路由器侧存储)的条目。
+ var kept = [];
+ for (var ki = 0; ki < operatorChoices.length; ki++) {
+ if (operatorChoices[ki].custom) kept.push(operatorChoices[ki]);
+ }
+ operatorChoices = kept;
+ renderOperatorChoices();
+ var resetHint = document.getElementById('jm-operator-suffix-hint');
+ if (resetHint) { resetHint.textContent = ''; resetHint.style.display = 'none'; }
+ var values = {
+ 'jm-label': initialValues.label,
+ 'jm-user_id': initialValues.user_id,
+ 'jm-operator_suffix': '',
+ 'jm-access_mode': 'wifi',
+ 'jm-wired_iface': initialValues.wired_iface || 'wan',
+ 'jm-auth_enabled': '0',
+ 'jm-base_url': '',
+ 'jm-ac_id': '',
+ 'jm-login-n': DEFAULT_LOGIN_SHAPE.n,
+ 'jm-login-type': DEFAULT_LOGIN_SHAPE.type,
+ 'jm-login-enc': DEFAULT_LOGIN_SHAPE.enc,
+ 'jm-info-prefix': DEFAULT_LOGIN_SHAPE.info_prefix,
+ 'jm-double-stack': DEFAULT_LOGIN_SHAPE.double_stack,
+ 'jm-login-os': DEFAULT_LOGIN_SHAPE.os,
+ 'jm-login-name': DEFAULT_LOGIN_SHAPE.name,
+ 'jm-ssid': '',
+ 'jm-bssid': initialValues.bssid,
+ 'jm-ap_selection': initialValues.ap_selection,
+ 'jm-radio': initialValues.radio
+ };
+ for (var idKey in values) {
+ var node = document.getElementById(idKey);
+ if (node) node.value = values[idKey];
+ }
+ updateCampusAccessModeUI();
+ }
+
+ function fillField(node, value) {
+ if (!node || !value) return false;
+ node.value = value;
+ node.dispatchEvent(new Event('change', { bubbles: true }));
+ return true;
+ }
+
+ function applyDetectResult(data, nodes) {
+ var acid = data.acid || data.ac_id || data.value || '';
+ var filled = [];
+ if (fillField(nodes.base, data.base_url || data.detected_url || '')) {
+ filled.push('认证地址');
+ }
+ if (fillField(nodes.acid, acid)) filled.push('AC_ID ' + acid);
+ var text = filled.length ? ('已填入 ' + filled.join('、'))
+ : (data.message || '未发现可用参数');
+ if (nodes.status) nodes.status.textContent = text;
+ else if (!filled.length) alert(text);
+ }
+
+ function detectAcidForForm() {
+ var nodes = {
+ base: document.getElementById('jm-base_url'),
+ acid: document.getElementById('jm-ac_id'),
+ status: document.getElementById('jm-detect-acid-status')
+ };
+ var button = document.getElementById('jm-detect-acid');
+ var baseUrl = nodes.base ? nodes.base.value : '';
+ // 地址空着不再拦人。填不出认证地址正是这个按钮要解决的问题,所以改成
+ // 反过来问路由器:出口有没有被强制门户拦下来?拦下来的 302 跳转里
+ // 就带着本校认证页地址。
+ var path = baseUrl ? 'detect_acid' : 'detect_env';
+ if (button) button.disabled = true;
+ if (nodes.status) {
+ nodes.status.textContent = baseUrl ? '嗅探中...' : '正在检查出口是否被认证页拦截...';
+ }
+ var mode = getFieldValue('jm-access_mode');
+ postDiscovery(path, {base_url: baseUrl, access_mode: mode, school: selectedPresetId,
+ iface: mode === 'wired' ? getFieldValue('jm-wired_iface') : (readJson('smart-probe-config', {}).sta_iface || 'wwan'),
+ ssid: mode === 'wifi' ? getFieldValue('jm-ssid') : ''}, function(err, data) {
+ if (!nodes.base || !document.body.contains(nodes.base)) return;
+ applyDetectResult(err ? {ok: false, message: err.message} : data, nodes);
+ if (button) button.disabled = false;
+ });
+ }
+
+ showNativeModal(
+ id ? '编辑校园网账号' : '新增校园网账号',
+ bodyHtml,
+ function() {
+ document.getElementById('jm-radio').value = initialValues.radio;
+ document.getElementById('jm-ap_selection').value = initialValues.ap_selection;
+ document.getElementById('jm-school_preset').addEventListener('change', function() {
+ selectedPresetId = this.value || NO_PRESET_ID;
+ });
+ document.getElementById('jm-apply-school-defaults').addEventListener('click', applySchoolDefaultsToForm);
+ document.getElementById('jm-reset-school-defaults').addEventListener('click', resetSchoolDefaultsForm);
+ document.getElementById('jm-save-school-preset').addEventListener('click', saveCurrentFormAsPreset);
+ document.getElementById('jm-delete-school-preset').addEventListener('click', deleteSelectedCustomPreset);
+ document.getElementById('jm-detect-acid').addEventListener('click', detectAcidForForm);
+ document.getElementById('jm-access_mode').addEventListener('change', updateCampusAccessModeUI);
+ document.getElementById('jm-ap_selection').addEventListener('change', updateCampusAccessModeUI);
+ document.getElementById('jm-operator').addEventListener('change', applyOperatorPick);
+ document.getElementById('jm-operator-add').addEventListener('click', addOperatorFromPrompt);
+ document.getElementById('jm-operator-del').addEventListener('click', removeSelectedOperator);
+ seedOperatorChoices();
+ renderOperatorChoices(initialValues.operator_suffix);
+ if (!id) applySchoolDefaultsToForm();
+ updateCampusAccessModeUI();
+ renderPasswordField('jm-password-field', 'jm-password', item.password || '');
+ },
+ function() { window.smartModalSave(); }
+ );
+ };
+
+ window.smartEditHotspot = function(id) {
+ modalType = 'hotspot';
+ modalEditId = id;
+ var item = id ? findById(hotspotData, id) : {};
+ var bodyHtml =
+ '标签(选填)
' +
+ 'SSID
' +
+ '加密方式 开放(none) WPA-PSK WPA2-PSK WPA/WPA2 WPA3-SAE WPA2/WPA3
' +
+ '' +
+ '频段 ' + radioOptionsMarkup() + '
';
+ showNativeModal(
+ id ? '编辑热点配置' : '新增热点配置',
+ bodyHtml,
+ function() {
+ document.getElementById('jm-encryption').value = item.encryption || 'psk2';
+ document.getElementById('jm-radio').value = item.radio || '';
+ renderPasswordField('jm-key-field', 'jm-key', item.key || '');
+ },
+ function() { window.smartModalSave(); }
+ );
+ };
+
+ window.smartModalSave = function() {
+ if (window.__smartModalSaving) return;
+ var revision = configRevision();
+ if (revision === null) { alert('配置版本不可用,请刷新页面后重试。'); return; }
+ if (modalType === 'campus' && getFieldValue('jm-access_mode') !== 'wired' &&
+ getFieldValue('jm-ap_selection') === 'fixed' && !isValidBssid(getFieldValue('jm-bssid'))) {
+ alert('固定 BSSID 需要有效的单播地址,例如 02:11:22:33:44:55');
+ return;
+ }
+ window.__smartModalSaving = true;
+ var fd = new FormData();
+ fd.append('token', requestToken());
+ fd.append('expected_revision', revision);
+ fd.append('action', (modalEditId ? 'edit_' : 'add_') + modalType);
+ if (modalEditId) fd.append('id', modalEditId);
+
+ if (modalType === 'campus') {
+ fd.append('label', document.getElementById('jm-label').value);
+ fd.append('user_id', document.getElementById('jm-user_id').value);
+ fd.append('operator_suffix', document.getElementById('jm-operator_suffix').value);
+ fd.append('access_mode', document.getElementById('jm-access_mode').value);
+ fd.append('wired_iface', document.getElementById('jm-wired_iface').value);
+ fd.append('auth_enabled', document.getElementById('jm-auth_enabled').value);
+ fd.append('password', getFieldValue('jm-password'));
+ fd.append('base_url', document.getElementById('jm-base_url').value);
+ fd.append('ac_id', document.getElementById('jm-ac_id').value);
+ fd.append('n', document.getElementById('jm-login-n').value);
+ fd.append('type', document.getElementById('jm-login-type').value);
+ fd.append('enc', document.getElementById('jm-login-enc').value);
+ fd.append('info_prefix', document.getElementById('jm-info-prefix').value);
+ fd.append('double_stack', document.getElementById('jm-double-stack').value);
+ fd.append('login_os', document.getElementById('jm-login-os').value);
+ fd.append('login_name', document.getElementById('jm-login-name').value);
+ fd.append('ssid', document.getElementById('jm-ssid').value);
+ fd.append('bssid', document.getElementById('jm-bssid').value);
+ fd.append('ap_selection', document.getElementById('jm-ap_selection').value);
+ fd.append('radio', document.getElementById('jm-radio').value);
+ } else {
+ fd.append('label', document.getElementById('jm-label').value);
+ fd.append('ssid', document.getElementById('jm-ssid').value);
+ fd.append('encryption', document.getElementById('jm-encryption').value);
+ fd.append('key', getFieldValue('jm-key'));
+ fd.append('radio', document.getElementById('jm-radio').value);
+ }
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/enqueue', true);
+ xhr.onload = function() {
+ var data = {};
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (e) {}
+ if (xhr.status === 200 && data && data.ok === true) {
+ L.hideModal();
+ location.reload();
+ } else {
+ // 失败时保持弹窗打开,避免静默丢弃用户刚填的整套表单。
+ window.__smartModalSaving = false;
+ alert((data && data.message) ? data.message : ('保存失败(HTTP ' + xhr.status + ')'));
+ }
+ };
+ xhr.onerror = function() {
+ window.__smartModalSaving = false;
+ alert('保存失败:网络错误,请重试');
+ };
+ xhr.send(fd);
+ };
+
+ function initSchoolInfo() {
+ var infoBox = document.getElementById('smart-school-info');
+ var docLinkEl = document.getElementById('smart-school-doc-link');
+ if (!infoBox || !docLinkEl || window.__smartSchoolInfoInit) return;
+ window.__smartSchoolInfoInit = true;
+
+ var DOC_FALLBACK = 'https://srun-doc.guiguisocute.com/development/architecture';
+ function docUrlFor(value) {
+ var items = readJson('smart-auth-strategy-data', []);
+ if (!Array.isArray(items)) items = [];
+ for (var i = 0; i < items.length; i++) {
+ if (items[i] && items[i].short_name === value && /^https?:\/\//i.test(items[i].doc_url || '')) return items[i].doc_url;
+ }
+ if (!value || value === 'default') return 'https://srun-doc.guiguisocute.com/guide/authentication';
+ return DOC_FALLBACK;
+ }
+ var outerDescEl = null;
+ for (var parent = infoBox.parentNode; parent; parent = parent.parentNode) {
+ if (parent.className && String(parent.className).indexOf('cbi-value-description') >= 0) {
+ outerDescEl = parent;
+ break;
+ }
+ }
+
+ function update(value) {
+ infoBox.style.display = 'block';
+ if (outerDescEl) outerDescEl.style.display = 'block';
+ docLinkEl.href = docUrlFor(String(value || ''));
+ }
+
+ // The CBI widget may be created after this script initializes.
+ var sel = document.querySelector('select[name="cbid.smart_srun.main.school"]');
+ if (sel) update(sel.value);
+ document.addEventListener('change', function(event) {
+ var target = event.target;
+ if (target && target.name === 'cbid.smart_srun.main.school') update(target.value);
+ });
+ }
+
+ function initOverview() {
+ var root = document.getElementById('smart-srun-overview');
+ var title = document.getElementById('smart-srun-overview-title');
+ var meta = document.getElementById('smart-srun-overview-meta');
+ var details = document.getElementById('smart-srun-overview-details-content');
+ var pendingLine = document.getElementById('smart-srun-overview-pending');
+ if (!root || !title || !meta || !details || !pendingLine || window.__smartSrunOverviewInit) return;
+ window.__smartSrunOverviewInit = true;
+
+ var palette = {
+ online: { light: '#237a4b', dark: '#79d6a5' },
+ portal: { light: '#ad5b0a', dark: '#f4bd70' },
+ limited: { light: '#b63232', dark: '#f59393' },
+ offline: { light: '#626b78', dark: '#b5bdc9' }
+ };
+
+ function applyTone(level) {
+ var tone = palette[level] || palette.offline;
+ // Follow the surrounding LuCI theme, including themes switched without a reload.
+ var color = window.getComputedStyle && root.parentNode ? window.getComputedStyle(root.parentNode).color.match(/[\d.]+/g) : null;
+ var dark = color && (+color[0] * 0.299 + +color[1] * 0.587 + +color[2] * 0.114 > 160);
+ root.style.color = dark ? '#e2e5eb' : '#303846';
+ root.style.borderLeftColor = title.style.color = dark ? tone.dark : tone.light;
+ }
+
+ var refreshing = false;
+ function refreshOverview() {
+ if (refreshing) return;
+ refreshing = true;
+ fetchJson('/cgi-bin/luci/admin/services/smart_srun/status?_=' + Date.now(), function(err, data) {
+ refreshing = false;
+ if (err || !data || data.ok === false || typeof data.status !== 'string') {
+ applyTone('offline');
+ if (title.textContent !== '状态读取失败') title.textContent = '状态读取失败';
+ meta.textContent = '无法获取连接信息,请稍后重试。';
+ details.textContent = '连接信息暂不可用。';
+ pendingLine.textContent = ''; pendingLine.style.display = 'none';
+ return;
+ }
+ var level = (typeof data.connectivity_level === 'string' && data.connectivity_level !== '') ? data.connectivity_level : 'offline';
+ var status = (typeof data.status === 'string' && data.status !== '') ? data.status : '未知';
+ var ssid = (typeof data.current_ssid === 'string' && data.current_ssid !== '') ? data.current_ssid : '未连接';
+ var mode = (typeof data.mode_label === 'string' && data.mode_label !== '') ? data.mode_label : '未知模式';
+ var conn = (typeof data.connectivity === 'string' && data.connectivity !== '') ? data.connectivity : '未知';
+ var iface = (typeof data.current_iface === 'string' && data.current_iface !== '') ? data.current_iface : '--';
+ var ip = (typeof data.current_ip === 'string' && data.current_ip !== '') ? data.current_ip : '--';
+ var pending = (typeof data.pending_action === 'string' && data.pending_action !== '') ? ('待执行操作:' + data.pending_action) : '';
+ var campusLabel = (typeof data.online_account_label === 'string' && data.online_account_label !== '') ? data.online_account_label : ((typeof data.campus_account_label === 'string' && data.campus_account_label !== '') ? data.campus_account_label : '--');
+ var hotspotLabel = (typeof data.hotspot_profile_label === 'string' && data.hotspot_profile_label !== '') ? data.hotspot_profile_label : '--';
+
+ applyTone(level);
+ var interval = /^在线,下一次检测间隔 (\d+) 秒$/.exec(status);
+ var displayStatus = interval ? '在线' : status;
+ if (title.textContent !== displayStatus) title.textContent = displayStatus;
+ pendingLine.textContent = pending; pendingLine.style.display = pending ? '' : 'none';
+ var wired = data.current_campus_access_mode === 'wired' || mode === '校园网模式(有线)';
+ var hotspot = data.mode === 'hotspot' || data.current_mode === 'hotspot' || mode === '热点模式';
+ var metaHtml = overviewPrimary(wired ? '有线网络' : 'Wi-Fi', wired ? iface : ssid) +
+ overviewPrimary(hotspot ? '热点配置' : '当前账号', hotspot ? hotspotLabel : campusLabel);
+ if (meta.innerHTML !== metaHtml) meta.innerHTML = metaHtml;
+ var runtime = overviewField('模式', mode) +
+ overviewField(interval ? '检测间隔' : '运行状态', interval ? interval[1] + ' 秒' : status);
+ if (!wired && !hotspot) {
+ runtime += overviewField('AP 选择', apSelectionLabel(data.ap_selection_policy)) +
+ overviewField('选择说明', data.ap_selection_reason);
+ }
+ var detailsHtml = overviewGroup('网络连接', overviewField('连通性', conn) +
+ overviewField('网络接口', iface, true) + overviewField('IP 地址', ip, true)) +
+ wirelessStatusMarkup(data) + overviewGroup('运行信息', runtime);
+ if (details.innerHTML !== detailsHtml) details.innerHTML = detailsHtml;
+ });
+ }
+
+ refreshOverview();
+ window.setInterval(function() {
+ if (!isPageHidden()) refreshOverview();
+ }, 1200);
+ onPageVisible(refreshOverview);
+ }
+
+ function initManualActions() {
+ var login = document.getElementById('smart-srun-manual-login');
+ var logout = document.getElementById('smart-srun-manual-logout');
+ var result = document.getElementById('smart-srun-manual-result');
+ if (!login || !logout || !result || window.__smartSrunManualInit) return;
+ window.__smartSrunManualInit = true;
+
+ function submit(action) {
+ result.textContent = '正在提交...';
+ login.disabled = true;
+ logout.disabled = true;
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/enqueue', true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== 4) return;
+ login.disabled = false;
+ logout.disabled = false;
+ if (xhr.status !== 200) {
+ result.textContent = '提交失败';
+ return;
+ }
+ try {
+ var data = JSON.parse(xhr.responseText || '{}');
+ var message = (typeof data.message === 'string' && data.message !== '') ? data.message : '已提交';
+ result.textContent = message;
+ if (data.ok) {
+ openBlockingFeedback(action, parseInt(data.requested_at || 0, 10) || 0, data.action_id);
+ }
+ } catch (e) {
+ result.textContent = '提交失败';
+ }
+ };
+ xhr.send('action=' + encodeURIComponent(action) + '&token=' + encodeURIComponent(requestToken()));
+ }
+
+ login.addEventListener('click', function() { submit('manual_login'); });
+ logout.addEventListener('click', function() { submit('manual_logout'); });
+ }
+
+ function initSwitchActions() {
+ var hotspot = document.getElementById('smart-srun-switch-hotspot');
+ var campus = document.getElementById('smart-srun-switch-campus');
+ var forceClose = document.getElementById('smart-srun-force-close');
+ var result = document.getElementById('smart-srun-switch-result');
+ if (!hotspot || !campus || !forceClose || !result || window.__smartSrunSwitchInit) return;
+ window.__smartSrunSwitchInit = true;
+
+ function enqueue(action) {
+ result.textContent = '正在提交...';
+ hotspot.disabled = true;
+ campus.disabled = true;
+ forceClose.disabled = true;
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/enqueue', true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== 4) return;
+ hotspot.disabled = false;
+ campus.disabled = false;
+ forceClose.disabled = false;
+ if (xhr.status !== 200) {
+ result.textContent = '提交失败';
+ return;
+ }
+ try {
+ var data = JSON.parse(xhr.responseText || '{}');
+ var message = (typeof data.message === 'string' && data.message !== '') ? data.message : '已提交';
+ result.textContent = message;
+ if (data.ok) {
+ openBlockingFeedback(action, parseInt(data.requested_at || 0, 10) || 0, data.action_id);
+ }
+ } catch (e) {
+ result.textContent = '提交失败';
+ }
+ };
+ xhr.send('action=' + encodeURIComponent(action) + '&token=' + encodeURIComponent(requestToken()));
+ }
+
+ function enqueueForceClose() {
+ if (!confirm('这会停止 SMART SRun 服务并终止插件进程,是否继续?')) {
+ return;
+ }
+ result.textContent = '正在强制关闭插件...';
+ hotspot.disabled = true;
+ campus.disabled = true;
+ forceClose.disabled = true;
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/enqueue', true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== 4) return;
+ hotspot.disabled = false;
+ campus.disabled = false;
+ forceClose.disabled = false;
+ if (xhr.status !== 200) {
+ result.textContent = '强制关闭失败';
+ return;
+ }
+ try {
+ var data = JSON.parse(xhr.responseText || '{}');
+ result.textContent = (typeof data.message === 'string' && data.message !== '') ? data.message : '已强制关闭插件';
+ if (data.ok) {
+ location.reload();
+ }
+ } catch (e) {
+ result.textContent = '强制关闭失败';
+ }
+ };
+ xhr.send('action=' + encodeURIComponent('force_stop') + '&token=' + encodeURIComponent(requestToken()));
+ }
+
+ hotspot.addEventListener('click', function() { enqueue('switch_hotspot'); });
+ campus.addEventListener('click', function() { enqueue('switch_campus'); });
+ forceClose.addEventListener('click', enqueueForceClose);
+ }
+
+ function initTables() {
+ if (window.__smartTablesInit) return;
+ if (!document.getElementById('smart-campus-data') || !document.getElementById('smart-hotspot-data')) return;
+ window.__smartTablesInit = true;
+ campusData = readJson('smart-campus-data', []);
+ hotspotData = readJson('smart-hotspot-data', []);
+ initUserPresetStore();
+ }
+
+ var LOG_LEVEL_WEIGHTS = { ALL: 0, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
+ var LOG_LIVE_LINES = 100;
+ var LOG_DOWNLOAD_LINES = 0;
+
+ function logLineWeight(line) {
+ if (line.indexOf('[错误]') !== -1) return 40;
+ if (line.indexOf('[警告]') !== -1) return 30;
+ if (line.indexOf('[信息]') !== -1) return 20;
+ if (line.indexOf('[调试]') !== -1) return 10;
+ return 20;
+ }
+
+ function initLogView() {
+ var box = document.getElementById('smart-srun-log-box');
+ var pre = document.getElementById('smart-srun-log-pre');
+ var startButton = document.getElementById('smart-srun-log-start');
+ var stopButton = document.getElementById('smart-srun-log-stop');
+ var clearButton = document.getElementById('smart-srun-log-clear');
+ var downloadButton = document.getElementById('smart-srun-log-download');
+ var levelFilter = document.getElementById('smart-srun-log-level-filter');
+ if (!box || !pre || !startButton || !stopButton || !clearButton || !downloadButton || window.__smartSrunLogInit) return;
+ window.__smartSrunLogInit = true;
+ var logState = {
+ refreshing: true,
+ timer: null,
+ rawText: pre.textContent || '',
+ displayLevel: levelFilter && levelFilter.value ? String(levelFilter.value).toUpperCase() : 'ALL'
+ };
+ if (!(logState.displayLevel in LOG_LEVEL_WEIGHTS)) logState.displayLevel = 'ALL';
+
+ function atBottom() {
+ return (box.scrollHeight - box.scrollTop - box.clientHeight) < 24;
+ }
+
+ function stickBottom() {
+ box.scrollTop = box.scrollHeight;
+ }
+
+ function filterByLevel(text) {
+ var threshold = LOG_LEVEL_WEIGHTS[logState.displayLevel] || 0;
+ if (threshold <= 0) return text;
+ var lines = String(text || '').split('\n');
+ var kept = [];
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i];
+ if (line === '' || logLineWeight(line) >= threshold) kept.push(line);
+ }
+ return kept.join('\n');
+ }
+
+ function renderFromRaw() {
+ var keepBottom = atBottom();
+ var filtered = filterByLevel(logState.rawText);
+ pre.innerHTML = filtered ? renderFriendlyLogHtml(filtered) : '';
+ if (keepBottom) stickBottom();
+ }
+
+ function setRefreshButtons() {
+ startButton.disabled = !!logState.refreshing;
+ stopButton.disabled = !logState.refreshing;
+ startButton.className = logState.refreshing ? 'cbi-button' : 'cbi-button cbi-button-apply';
+ stopButton.className = logState.refreshing ? 'cbi-button cbi-button-apply' : 'cbi-button';
+ }
+
+ function buildLogUrl(lines, format, download) {
+ return '/cgi-bin/luci/admin/services/smart_srun/log_tail?channel=plugin&lines=' + lines +
+ '&format=' + encodeURIComponent(format || 'friendly') +
+ (download ? '&download=1' : '') + '&_=' + Date.now();
+ }
+
+ function buildDownloadName() {
+ var now = new Date();
+ function pad(value) { return value < 10 ? '0' + value : String(value); }
+ return 'smart_srun_plugin_' + now.getFullYear() +
+ pad(now.getMonth() + 1) + pad(now.getDate()) + '_' +
+ pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds()) + '.log';
+ }
+
+ function refresh() {
+ if (isPageHidden()) return;
+ fetchJson(buildLogUrl(LOG_LIVE_LINES, 'friendly', false), function(err, data) {
+ if (err || !data || typeof data.log !== 'string') return;
+ logState.rawText = data.log;
+ renderFromRaw();
+ });
+ }
+
+ function startLoop() {
+ if (logState.timer) return;
+ logState.timer = setInterval(function() {
+ if (logState.refreshing && !isPageHidden()) refresh();
+ }, 2000);
+ }
+
+ function clearDisplay() {
+ clearButton.disabled = true;
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/log_clear', true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState !== 4) return;
+ clearButton.disabled = false;
+ var data = {};
+ try {
+ data = JSON.parse(xhr.responseText || '{}');
+ } catch (e) {}
+ if (xhr.status === 200 && data.ok) {
+ logState.rawText = '';
+ pre.innerHTML = '';
+ } else {
+ alert(data.message || '清空失败');
+ }
+ };
+ xhr.send('channel=plugin&token=' + encodeURIComponent(requestToken()));
+ }
+
+ function triggerBlobDownload(text) {
+ var urlApi = window.URL || window.webkitURL;
+ if (!urlApi || !urlApi.createObjectURL) return;
+ var blob = new Blob([text || ''], { type: 'text/plain;charset=utf-8' });
+ var objUrl = urlApi.createObjectURL(blob);
+ var link = document.createElement('a');
+ link.href = objUrl;
+ link.download = buildDownloadName();
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ urlApi.revokeObjectURL(objUrl);
+ }
+
+ function downloadCurrentLog() {
+ downloadButton.disabled = true;
+ fetchJson(buildLogUrl(LOG_DOWNLOAD_LINES, 'raw', true), function(err, data) {
+ downloadButton.disabled = false;
+ if (err || !data || typeof data.log !== 'string') {
+ alert('下载失败');
+ return;
+ }
+ triggerBlobDownload(data.log);
+ });
+ }
+
+ startButton.addEventListener('click', function() {
+ if (logState.refreshing) return;
+ logState.refreshing = true;
+ setRefreshButtons();
+ refresh();
+ });
+
+ stopButton.addEventListener('click', function() {
+ if (!logState.refreshing) return;
+ logState.refreshing = false;
+ setRefreshButtons();
+ });
+
+ clearButton.addEventListener('click', clearDisplay);
+ downloadButton.addEventListener('click', downloadCurrentLog);
+
+ function applyDisplayLevel(rawValue) {
+ var next = String(rawValue == null ? '' : rawValue).toUpperCase();
+ if (!(next in LOG_LEVEL_WEIGHTS)) next = 'ALL';
+ if (logState.displayLevel === next) return;
+ logState.displayLevel = next;
+ renderFromRaw();
+ }
+
+ if (levelFilter) {
+ levelFilter.value = logState.displayLevel;
+ levelFilter.addEventListener('change', function() {
+ applyDisplayLevel(levelFilter.value);
+ });
+ }
+
+ setRefreshButtons();
+ if (logState.rawText) {
+ renderFromRaw();
+ stickBottom();
+ }
+ if (logState.refreshing) refresh();
+ onPageVisible(function() {
+ if (logState.refreshing) refresh();
+ });
+ startLoop();
+ }
+
+ // 一键配置向导:所有提示保留在文档流中,避免主题把 alert-message 变成浮动通知。
+ var WIZ_STEPS = ['网络接入', '认证地址', '认证后缀', '账号信息', '确认保存'];
+ var WIZ_SHAPE_KEYS = ['n', 'type', 'enc', 'info_prefix', 'double_stack', 'login_os', 'login_name'];
+ var WIZ_OUTCOME = {
+ hit: '登录成功,已验证', online: '已在线,后缀待确认',
+ credential: '账号或密码错误,后缀待确认', miss: '账号不存在', other: '验证未完成',
+ limited: '认证过于频繁', identity: '在线账号后缀已确认', skipped: '未继续尝试'
+ };
+ var wiz = null;
+
+ function wizEl(tag, cls, text) {
+ var n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined && text !== null) n.textContent = text;
+ return n;
+ }
+
+ function wizStyles() {
+ if (document.getElementById('smart-wizard-style')) return;
+ var style = wizEl('style');
+ style.id = 'smart-wizard-style';
+ style.textContent =
+ '.modal.smart-wizard-dialog{width:920px!important;max-width:calc(100vw - 24px)!important;' +
+ 'max-height:calc(100vh - 32px)!important;box-sizing:border-box;display:flex!important;flex-direction:column;' +
+ 'flex-wrap:nowrap!important;align-items:stretch!important;margin:16px auto!important;min-width:0!important;overflow:hidden;padding:20px!important;}' +
+ '.smart-wizard-dialog>h4{flex:none;margin:0 0 16px!important;padding:0!important;box-shadow:none;}' +
+ '.modal.smart-wizard-dialog>.smart-wizard{flex:1 1 auto;width:100%;align-self:stretch;margin:0;outline:none;}' +
+ '.smart-wizard{display:flex;flex-direction:column;min-height:0;overflow:hidden;line-height:1.6;text-align:left;}' +
+ '.smart-wizard *{box-sizing:border-box;}' +
+ '.smart-wizard-steps{display:flex;flex:none;gap:8px;margin:0 0 16px;padding:0;list-style:none;}' +
+ '.smart-wizard-steps li{flex:1;min-width:0;padding:8px 6px;border-bottom:3px solid rgba(127,127,127,.25);font-size:13px;}' +
+ '.smart-wizard-steps li[aria-current=step]{border-color:#8674d9;font-weight:600;}' +
+ '.smart-wizard-content{min-height:0;overflow:auto;padding:0 4px 6px;overscroll-behavior:contain;}' +
+ '.smart-wizard-note{position:static!important;display:block!important;transform:none!important;' +
+ 'width:auto!important;margin:0 0 16px!important;padding:12px 14px;border:1px solid rgba(127,127,127,.25);' +
+ 'border-left:4px solid #7187bd;border-radius:6px;background:rgba(127,127,127,.08);overflow-wrap:anywhere;}' +
+ '.smart-wizard-note-success{border-left-color:#4b9f76;}.smart-wizard-note-warning{border-left-color:#c99a35;}' +
+ '.smart-wizard-note-danger{border-left-color:#d46a6a;}' +
+ '.smart-wizard-row{display:grid;grid-template-columns:130px minmax(0,1fr);gap:6px 16px;margin:0 0 16px;align-items:start;}' +
+ '.smart-wizard-row>label{padding-top:8px;text-align:right;}.smart-wizard-field{min-width:0;}' +
+ '.smart-wizard-field>input,.smart-wizard-field>select{width:100%!important;max-width:100%!important;min-width:0!important;}' +
+ '.smart-wizard-password{position:relative;}' +
+ '.smart-wizard-password>input{width:100%!important;min-width:0!important;max-width:100%!important;padding-right:48px!important;}' +
+ '.smart-wizard-password>.smart-wizard-password-toggle{position:absolute;right:1px;top:1px;bottom:1px;width:42px!important;min-width:42px!important;' +
+ 'padding:0!important;display:flex;align-items:center;justify-content:center;border:0!important;background:transparent!important;' +
+ 'box-shadow:none!important;color:inherit;opacity:.7;}' +
+ '.smart-wizard-password-toggle:hover,.smart-wizard-password-toggle:focus{opacity:1;}' +
+ '.smart-wizard-password-toggle:focus-visible{outline:2px solid #8674d9;outline-offset:-3px;}' +
+ '.smart-wizard-password-toggle[aria-pressed=false] .smart-wizard-eye-slash{display:none;}' +
+ '.smart-wizard-field>code{display:block;white-space:normal;overflow-wrap:anywhere;}' +
+ '.smart-wizard input::placeholder{color:#888!important;opacity:1!important;font-weight:400!important;}' +
+ '.smart-wizard-help{font-size:12px;opacity:.75;margin-top:5px;overflow-wrap:anywhere;}' +
+ '.smart-wizard-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px;' +
+ 'flex:none;border-top:1px solid rgba(127,127,127,.25);margin-top:12px;padding-top:12px;}' +
+ '.smart-wizard-actions .smart-wizard-help{flex:1 1 100%;margin:0 0 4px;}' +
+ '.smart-wizard button{white-space:normal;height:auto!important;margin:0!important;}' +
+ '.smart-wizard-actions .smart-wizard-close{margin-right:auto!important;}' +
+ '.smart-wizard table{width:100%;table-layout:fixed;border-collapse:collapse;margin:8px 0 16px;}' +
+ '.smart-wizard th,.smart-wizard td{text-align:left!important;padding:9px 8px!important;' +
+ 'overflow-wrap:anywhere;border-bottom:1px solid rgba(127,127,127,.2);font-size:13px;}' +
+ '.smart-wizard td label{cursor:pointer;display:block;}.smart-wizard td input{margin-right:6px;}' +
+ '.smart-wizard-summary td:first-child{width:30%;opacity:.75;}' +
+ '.smart-wizard-inline{display:flex;flex-wrap:wrap;gap:8px;}.smart-wizard-inline input{flex:1;min-width:140px;}' +
+ '.smart-wizard-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:150px;overflow:auto;font-size:12px;}' +
+ '@media(max-width:600px){.modal.smart-wizard-dialog{padding:14px!important;max-height:calc(100vh - 16px)!important;}' +
+ '.smart-wizard-steps{gap:3px;}.smart-wizard-steps li{font-size:11px;padding:6px 2px;}' +
+ '.smart-wizard-step-label{display:block;word-break:keep-all;}' +
+ '.smart-wizard-row{grid-template-columns:minmax(0,1fr);gap:4px;margin-bottom:14px;}' +
+ '.smart-wizard-row>label{text-align:left;padding-top:0;font-weight:600;}' +
+ '.smart-wizard th,.smart-wizard td{padding:8px 4px!important;font-size:12px;}}';
+ document.head.appendChild(style);
+ }
+
+ function wizReset() {
+ if (wiz && wiz.xhr) wiz.xhr.abort();
+ wiz = {
+ step: 0, env: null, busy: '', error: '', addressHint: '',
+ baseUrl: '', portalUrl: '', acId: '', school: '', shape: {}, userId: '', password: '',
+ accessMode: '', wiredIface: 'wan', wifiIface: '', ssid: '',
+ portalOps: [], portalOpsHint: '', portalOpsKey: '',
+ wifiKey: '', wifiEncryption: 'auto', wifiRadio: '', wifiJob: '', wifiState: '', wifiMessage: '', wifiTimer: null,
+ ops: [], selectedSuffix: null, opConfirmed: false, opLog: '', verifyExpanded: false, xhr: null
+ };
+ }
+
+ function wizInvalidate() {
+ wiz.opConfirmed = false;
+ wiz.passwordVerified = false;
+ wiz.opLog = '';
+ for (var i = 0; i < wiz.ops.length; i++) { wiz.ops[i].outcome = ''; wiz.ops[i].message = ''; }
+ if (wiz.root) {
+ var result = wiz.root.querySelector('#wiz-account-result');
+ if (result) result.parentNode.removeChild(result);
+ }
+ }
+
+ function wizRow(title, field, descr) {
+ var row = wizEl('div', 'smart-wizard-row');
+ var label = wizEl('label', '', title);
+ if (field.id) label.htmlFor = field.id;
+ row.appendChild(label);
+ var value = wizEl('div', 'smart-wizard-field');
+ if (field.type === 'password') {
+ var wrap = wizEl('div', 'smart-wizard-password');
+ var toggle = wizEl('button', 'btn cbi-button smart-wizard-password-toggle');
+ toggle.type = 'button';
+ toggle.innerHTML = '' +
+ ' ' +
+ ' ';
+ toggle.title = '显示' + title;
+ toggle.setAttribute('aria-controls', field.id);
+ toggle.setAttribute('aria-pressed', 'false');
+ toggle.setAttribute('aria-label', '显示' + title);
+ toggle.onclick = function() {
+ var visible = field.type === 'password';
+ field.type = visible ? 'text' : 'password';
+ toggle.title = (visible ? '隐藏' : '显示') + title;
+ toggle.setAttribute('aria-pressed', visible ? 'true' : 'false');
+ toggle.setAttribute('aria-label', (visible ? '隐藏' : '显示') + title);
+ };
+ wrap.appendChild(field); wrap.appendChild(toggle); value.appendChild(wrap);
+ } else value.appendChild(field);
+ if (descr) value.appendChild(wizEl('div', 'smart-wizard-help', descr));
+ row.appendChild(value);
+ return row;
+ }
+
+ function wizInput(id, key, type, placeholder) {
+ var input = wizEl('input');
+ input.id = id;
+ input.type = type || 'text';
+ input.value = wiz[key] || '';
+ if (placeholder) input.placeholder = placeholder;
+ input.oninput = function() {
+ wiz[key] = (key === 'password' || key === 'wifiKey' || key === 'ssid') ? input.value : input.value.replace(/^\s+|\s+$/g, '');
+ wizInvalidate();
+ if (key === 'baseUrl') wiz.portalUrl = '';
+ if (key === 'wiredIface' || key === 'wifiIface' || key === 'ssid') {
+ wiz.env = null;
+ var oldResult = document.getElementById('wiz-env-result');
+ if (oldResult) oldResult.textContent = '接入信息已更改,请重新检测所选线路。';
+ var oldDetails = document.getElementById('wiz-env-details');
+ if (oldDetails) oldDetails.parentNode.removeChild(oldDetails);
+ }
+ if (key === 'userId') wizUpdatePreviews();
+ };
+ return input;
+ }
+
+ function wizAlert(kind, text, headline) {
+ var box = wizEl('div', 'smart-wizard-note smart-wizard-note-' + kind);
+ box.setAttribute('role', kind === 'danger' ? 'alert' : 'status');
+ if (headline) box.appendChild(wizEl('strong', '', headline));
+ box.appendChild(wizEl('div', '', text));
+ return box;
+ }
+
+ function wizError(text) {
+ wiz.nextAfterWifi = false;
+ wiz.error = text;
+ wizRender();
+ var box = document.getElementById('wiz-error');
+ if (box) { box.tabIndex = -1; box.focus(); }
+ }
+
+ function wizGo(step) {
+ wiz.error = '';
+ wiz.step = step;
+ wizRender();
+ if (step === 2) wizDiscoverOperators(false);
+ }
+
+ function wizClose() {
+ if (wiz && (wiz.busy === 'operator' || wiz.busy === 'save')) return;
+ var old = wiz;
+ if (old && old.wifiTimer) clearTimeout(old.wifiTimer);
+ if (old && old.wifiJob) {
+ var cancel = new XMLHttpRequest();
+ cancel.open('POST', '/cgi-bin/luci/admin/services/smart_srun/setup_wifi', true);
+ cancel.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+ var tokenNode = document.querySelector('input[name="token"]');
+ var token = tokenNode ? tokenNode.value : ((window.L && L.env) ? L.env.token : '');
+ cancel.send('action=cancel&job=' + encodeURIComponent(old.wifiJob) + '&token=' + encodeURIComponent(token || ''));
+ }
+ wiz = null;
+ if (old && old.xhr) old.xhr.abort();
+ L.hideModal();
+ var trigger = document.getElementById('smart-srun-setup-wizard');
+ if (trigger) trigger.focus();
+ if (old && old.saved) location.reload();
+ }
+
+ function wizActions(note, buttons) {
+ var bar = wizEl('div', 'smart-wizard-actions');
+ if (note) bar.appendChild(wizEl('div', 'smart-wizard-help', note));
+ var close = wizEl('button', 'btn cbi-button smart-wizard-close', wiz.saveComplete ? '完成' :
+ (wiz.wifiJob && wiz.wifiState !== 'connected' ? '关闭并恢复原网络' : '关闭'));
+ close.type = 'button';
+ close.onclick = wizClose;
+ close.disabled = wiz.busy === 'operator' || wiz.busy === 'save';
+ bar.appendChild(close);
+ for (var i = 0; i < buttons.length; i++) {
+ var b = buttons[i];
+ if (b.hidden) continue;
+ var button = wizEl('button', 'btn cbi-button ' + (b.cls || ''), b.label);
+ button.type = 'button';
+ button.disabled = !!wiz.busy || !!b.disabled;
+ button.onclick = b.onclick;
+ bar.appendChild(button);
+ }
+ return bar;
+ }
+
+ // Preserve the wizard's callbacks and presentation while the daemon owns
+ // the bounded job. Polling reads cached action state; it never probes again.
+ function postDiscovery(path, values, done) {
+ var stopped = false, active = null, timer = null, actionId = '';
+ var deadline = Date.now() + (path === 'detect_operator' ? 225000 : (path === 'presets_refresh' ? 75000 : 65000));
+ var tokenNode = document.querySelector('input[name="token"]');
+ var token = tokenNode ? tokenNode.value : ((window.L && L.env) ? L.env.token : '');
+ values.idempotency_key = 'luci-probe-' + Date.now() + '-' + Math.random().toString(16).slice(2);
+ function post(params, callback) {
+ var xhr = new XMLHttpRequest(), encoded = ['token=' + encodeURIComponent(token || '')];
+ active = xhr;
+ for (var key in params) {
+ if (Object.prototype.hasOwnProperty.call(params, key)) encoded.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
+ }
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/' + path, true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+ xhr.timeout = 12000;
+ xhr.onload = function() {
+ if (stopped) return;
+ var data;
+ try { data = JSON.parse(xhr.responseText); } catch (e) {}
+ if (xhr.status !== 200 || !data || data.ok === false) {
+ callback(new Error((data && data.message) || '探测请求失败,请检查 LuCI 登录状态。')); return;
+ }
+ callback(null, data);
+ };
+ xhr.onerror = xhr.ontimeout = function() { if (!stopped) callback(new Error('探测请求超时或连接失败,请稍后再试。')); };
+ xhr.send(encoded.join('&'));
+ }
+ function finish(err, data) {
+ if (stopped) return;
+ stopped = true;
+ if (timer) clearTimeout(timer);
+ done(err, data || {});
+ }
+ function poll(err, data) {
+ if (err) { finish(err); return; }
+ actionId = data.action_id || data.id || actionId;
+ if (!data.action_id && data.state === 'succeeded') { finish(null, data.result || {}); return; }
+ if (!data.action_id && (data.state === 'failed' || data.state === 'cancelled' || data.state === 'interrupted')) {
+ finish(new Error(data.message || '探测未完成')); return;
+ }
+ if (!actionId || Date.now() >= deadline) { cancel(); finish(new Error('探测等待超时,请稍后再试。')); return; }
+ timer = setTimeout(function() { post({action: 'status', action_id: actionId}, poll); }, 400);
+ }
+ function cancel() {
+ if (actionId) post({action: 'cancel', action_id: actionId}, function() {});
+ }
+ post(values, poll);
+ return { abort: function() {
+ if (stopped) return;
+ if (active) active.abort();
+ if (timer) clearTimeout(timer);
+ cancel();
+ stopped = true;
+ }};
+ }
+
+ function wizPost(path, values, done, timeout) {
+ var owner = wiz;
+ if (path === 'detect_acid' || path === 'detect_env' || path === 'discover_operators' || path === 'detect_operator') {
+ var job = postDiscovery(path, values, function(err, data) {
+ if (wiz !== owner || owner.xhr !== job) return;
+ owner.xhr = null;
+ owner.busy = '';
+ if (!owner.root || !document.body.contains(owner.root) ||
+ !document.body.classList.contains('modal-overlay-active')) return;
+ done(err, data || {});
+ });
+ owner.xhr = job;
+ return;
+ }
+ var xhr = new XMLHttpRequest();
+ owner.xhr = xhr;
+ xhr.open('POST', '/cgi-bin/luci/admin/services/smart_srun/' + path, true);
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+ xhr.timeout = timeout || 120000;
+ var tokenNode = document.querySelector('input[name="token"]');
+ var token = tokenNode ? tokenNode.value : ((window.L && L.env) ? L.env.token : '');
+ var encoded = ['token=' + encodeURIComponent(token || '')];
+ for (var key in values) {
+ if (Object.prototype.hasOwnProperty.call(values, key)) encoded.push(encodeURIComponent(key) + '=' + encodeURIComponent(values[key]));
+ }
+ function finish(err, data) {
+ if (wiz !== owner || owner.xhr !== xhr) return;
+ owner.xhr = null;
+ owner.busy = '';
+ // Escape or another LuCI modal may have hidden/replaced this wizard.
+ if (!owner.root || !document.body.contains(owner.root) ||
+ !document.body.classList.contains('modal-overlay-active')) return;
+ done(err, data || {});
+ }
+ xhr.onload = function() {
+ if (xhr.status < 200 || xhr.status >= 300) { finish(new Error('请求失败(HTTP ' + xhr.status + '),请检查 LuCI 登录状态。')); return; }
+ var data;
+ try { data = JSON.parse(xhr.responseText); } catch (e) { finish(new Error('未收到有效结果,请检查 LuCI 登录是否过期。')); return; }
+ if (!data || typeof data !== 'object' || Array.isArray(data)) { finish(new Error('探测结果格式错误')); return; }
+ finish(null, data);
+ };
+ xhr.onerror = function() { finish(new Error('无法连接路由器,请检查连接后重试。')); };
+ xhr.ontimeout = function() { finish(new Error(path === 'enqueue' ?
+ '保存请求超时,账号可能已保存。请关闭并刷新账号列表核对后再操作。' :
+ '请求超时。路由器可能仍在处理,请稍后再试。')); };
+ xhr.send(encoded.join('&'));
+ }
+
+ function wizConnection() {
+ return { access_mode: wiz.accessMode, iface: wiz.accessMode === 'wired' ? wiz.wiredIface : wiz.wifiIface,
+ ssid: wiz.accessMode === 'wifi' ? wiz.ssid : '' };
+ }
+
+ function wizLoginPreview(suffix) {
+ var user = wiz.userId || '账号';
+ return suffix ? user + '@' + suffix : user;
+ }
+
+ function wizUpdatePreviews() {
+ var preview = document.getElementById('wiz-login-preview');
+ if (preview) preview.textContent = wiz.selectedSuffix === null ? '待选择认证后缀' : wizLoginPreview(wiz.selectedSuffix);
+ }
+
+ function wizPresetSelect() {
+ var select = wizEl('select');
+ select.id = 'wiz-preset';
+ var presets = [{ short_name: '', name: '无预设' }].concat(schoolPresetList(), loadCustomPresets());
+ for (var i = 0; i < presets.length; i++) {
+ var option = wizEl('option', '', presets[i].name || presets[i].short_name);
+ option.value = presets[i].short_name || '';
+ option.selected = option.value === wiz.school;
+ select.appendChild(option);
+ }
+ select.onchange = function() {
+ wiz.school = select.value;
+ var preset = findSchoolPreset(wiz.school) || {};
+ var defaults = preset.defaults || {};
+ wiz.baseUrl = defaults.base_url || '';
+ wiz.portalUrl = '';
+ wiz.acId = defaults.ac_id || '';
+ if (!wiz.wifiJob) wiz.ssid = defaults.ssid || '';
+ wiz.shape = {};
+ WIZ_SHAPE_KEYS.forEach(function(key) {
+ var observed = preset.observed_login_shape || {};
+ var observedKey = key === 'login_os' ? 'os' : (key === 'login_name' ? 'name' : key);
+ if (defaults[key] !== undefined) wiz.shape[key] = defaults[key];
+ else if (observed[observedKey] !== undefined) wiz.shape[key] = observed[observedKey];
+ });
+ wiz.ops = [];
+ wiz.portalOps = []; wiz.portalOpsHint = ''; wiz.portalOpsKey = '';
+ wiz.selectedSuffix = null;
+ wiz.env = null;
+ wizInvalidate();
+ wiz.addressHint = '';
+ wizRender();
+ };
+ return select;
+ }
+
+ function wizDetectEnvironment() {
+ if (!wiz.accessMode) { wizError('请选择网络接入方式。'); return; }
+ if (wiz.accessMode === 'wired' && !wiz.wiredIface) { wizError('请填写有线出口接口。'); return; }
+ wiz.busy = 'environment';
+ wiz.error = '';
+ var payload = wizConnection();
+ payload.base_url = wiz.baseUrl;
+ payload.school = wiz.school;
+ wizRender();
+ wizPost('detect_env', payload, function(err, data) {
+ if (err) { wizError(err.message); return; }
+ if (!data.state) { wizError(data.message || '路由器未返回出口状态'); return; }
+ wiz.env = data;
+ if (data.ok && data.base_url) wiz.baseUrl = data.base_url;
+ if (data.ok) wiz.portalUrl = data.portal_url || '';
+ if (data.acid) wiz.acId = data.acid;
+ if (wiz.accessMode === 'wifi' && data.iface) wiz.wifiIface = data.iface;
+ if (wiz.accessMode === 'wifi' && data.ssid && !wiz.ssid) wiz.ssid = data.ssid;
+ wiz.addressHint = data.message || '';
+ if (wiz.nextAfterWifi) { wiz.nextAfterWifi = false; wizGo(1); return; }
+ wizRender();
+ });
+ }
+
+ function wizPollWifi() {
+ var owner = wiz;
+ if (!owner || !owner.wifiJob) return;
+ owner.busy = 'wifi';
+ wizPost('setup_wifi', {action: 'status', job: owner.wifiJob}, function(err, data) {
+ if (wiz !== owner) return;
+ if (err || data.state === 'missing') {
+ if (Date.now() > owner.wifiDeadline) { wizError('暂时无法联系路由器。请恢复连接后重新打开向导;临时网络会在配置超时后恢复。'); return; }
+ owner.wifiMessage = '等待路由器连接恢复…';
+ } else {
+ owner.wifiState = data.state || 'failed'; owner.wifiMessage = data.message || '';
+ if (data.state === 'ready' || data.state === 'connected') {
+ owner.wifiIface = data.iface; owner.wifiRadio = data.radio || ''; owner.wifiKey = '';
+ if (data.state === 'ready') owner.wifiTimer = setTimeout(wizWatchWifi, 10000);
+ wizDetectEnvironment(); return;
+ }
+ if (data.ok === false || data.state === 'done' || data.state === 'cancelled') {
+ owner.wifiJob = ''; wizError(data.message || '无线连接未完成,请重新连接。'); return;
+ }
+ }
+ owner.busy = 'wifi'; wizRender();
+ owner.wifiTimer = setTimeout(wizPollWifi, 2000);
+ }, 10000);
+ }
+
+ function wizWatchWifi() {
+ if (!wiz || !wiz.wifiJob || wiz.wifiState !== 'ready') return;
+ if (wiz.busy || wiz.xhr) { wiz.wifiTimer = setTimeout(wizWatchWifi, 10000); return; }
+ wizPost('setup_wifi', {action: 'status', job: wiz.wifiJob}, function(err, data) {
+ if (!err && data.state !== 'ready') {
+ wiz.wifiJob = ''; wiz.env = null;
+ wizError(data.message || '无线连接已结束,请返回第一步重新连接。'); return;
+ }
+ wiz.wifiTimer = setTimeout(wizWatchWifi, 10000);
+ }, 5000);
+ }
+
+ function wizChangeWifi() {
+ var owner = wiz;
+ if (owner.wifiTimer) clearTimeout(owner.wifiTimer);
+ owner.busy = 'wifi'; owner.error = ''; wizRender();
+ function check() {
+ wizPost('setup_wifi', {action: 'status', job: owner.wifiJob}, function(err, data) {
+ if (!err && ['failed', 'cancelled', 'connected', 'done'].indexOf(data.state) !== -1) {
+ owner.wifiJob = ''; owner.wifiState = ''; owner.wifiMessage = ''; owner.env = null;
+ owner.wifiIface = ''; owner.wifiRadio = ''; wizRender(); return;
+ }
+ owner.busy = 'wifi'; owner.wifiTimer = setTimeout(check, 2000);
+ }, 10000);
+ }
+ wizPost('setup_wifi', {action: 'cancel', job: owner.wifiJob}, function(err, data) {
+ if (err || data.ok === false) { wizError(err ? err.message : data.message); return; }
+ check();
+ }, 10000);
+ }
+
+ function wizConnectWifi() {
+ if (wiz.accessMode !== 'wifi') { wizDetectEnvironment(); return; }
+ if (!wiz.ssid) { wizError('请填写校园网 Wi-Fi 名称,或先选择学校预设。'); return; }
+ if (wiz.wifiJob) { wizDetectEnvironment(); return; }
+ var id = '';
+ for (var i = 0; i < 32; i++) id += Math.floor(Math.random() * 16).toString(16);
+ wiz.wifiJob = id; wiz.wifiState = 'starting'; wiz.busy = 'wifi'; wiz.error = '';
+ wiz.wifiDeadline = Date.now() + 300000;
+ wiz.wifiMessage = '正在查找校园网 Wi-Fi…';
+ var payload = {action: 'start', job: id, ssid: wiz.ssid,
+ key: wiz.wifiEncryption === 'none' ? '' : wiz.wifiKey, encryption: wiz.wifiEncryption,
+ iface: wiz.wifiIface, radio: wiz.wifiRadio};
+ wizRender();
+ wizPost('setup_wifi', payload, function(err, data) {
+ if (!err && data.ok === false) { wiz.wifiJob = ''; wizError(data.message || '无法启动连接'); return; }
+ wizPollWifi();
+ }, 10000);
+ }
+
+ function wizStepEnv(body) {
+ var mode = wizEl('select');
+ mode.id = 'wiz-mode';
+ [['', '请选择接入方式'], ['wired', '有线网络'], ['wifi', '无线网络(Wi-Fi)']].forEach(function(pair) {
+ var option = wizEl('option', '', pair[1]);
+ option.value = pair[0]; option.selected = pair[0] === wiz.accessMode;
+ mode.appendChild(option);
+ });
+ mode.onchange = function() { wiz.accessMode = mode.value; wiz.env = null; wizInvalidate(); wizRender(); };
+ body.appendChild(wizRow('接入方式', mode));
+ if (wiz.accessMode) body.appendChild(wizRow('学校预设(可选)', wizPresetSelect()));
+ if (wiz.accessMode === 'wired') {
+ body.appendChild(wizRow('有线出口接口', wizInput('wiz-iface', 'wiredIface', 'text', 'wan'), '校园网对应的网络接口,可在“网络 → 接口”中查看。'));
+ } else if (wiz.accessMode === 'wifi') {
+ body.appendChild(wizRow('校园网 Wi-Fi 名称', wizInput('wiz-ssid', 'ssid', 'text', '例如 CampusWiFi')));
+ var encryption = wizEl('select'); encryption.id = 'wiz-wifi-encryption';
+ [['auto', '自动识别'], ['none', '开放网络'], ['psk2', 'WPA2-PSK'], ['sae', 'WPA3-SAE'],
+ ['sae-mixed', 'WPA2/WPA3 混合'], ['psk-mixed', 'WPA/WPA2 混合'], ['psk', 'WPA-PSK']].forEach(function(pair) {
+ var option = wizEl('option', '', pair[1]); option.value = pair[0];
+ option.selected = pair[0] === wiz.wifiEncryption; encryption.appendChild(option);
+ });
+ encryption.onchange = function() {
+ wiz.wifiEncryption = encryption.value; wiz.env = null;
+ if (wiz.wifiEncryption === 'none') wiz.wifiKey = '';
+ wizInvalidate(); wizRender();
+ };
+ body.appendChild(wizRow('加密方式', encryption));
+ if (wiz.wifiEncryption !== 'none') {
+ body.appendChild(wizRow('Wi-Fi 密码', wizInput('wiz-wifi-key', 'wifiKey', 'password',
+ wiz.wifiEncryption === 'auto' ? '加密网络需要填写' : '请输入 Wi-Fi 密码')));
+ }
+ var advanced = wizEl('details'); advanced.appendChild(wizEl('summary', '', '高级网络设置'));
+ advanced.appendChild(wizRow('无线出口接口', wizInput('wiz-wifi-iface', 'wifiIface', 'text', '自动选择或创建 wwan'), '多线路时可指定网络接口名,不能填写 LAN 接口。'));
+ advanced.appendChild(wizRow('无线电', wizInput('wiz-wifi-radio', 'wifiRadio', 'text', '自动选择可用无线电'), '需要指定频段时填写 radio0、radio1 等无线电名称。'));
+ body.appendChild(advanced);
+ }
+ if (wiz.wifiMessage) body.appendChild(wizAlert('info', wiz.wifiMessage));
+ if (wiz.busy && wiz.busy !== 'wifi') body.appendChild(wizAlert('info', '正在检测网络与认证地址…'));
+ else if (wiz.env) {
+ var e = wiz.env;
+ var result = wizAlert(e.ok ? 'success' : (e.state === 'down' ? 'warning' : 'info'), e.message || '',
+ e.ok ? '已找到认证地址' : (e.state === 'online' ? '网络已连接' : '检测完成'));
+ result.id = 'wiz-env-result';
+ if (e.ok && e.base_url) result.appendChild(wizEl('div', '', e.base_url + ' · AC_ID:' + (e.acid || '待确认')));
+ body.appendChild(result);
+ var details = wizEl('details');
+ details.id = 'wiz-env-details';
+ details.appendChild(wizEl('summary', '', '查看检测详情'));
+ var table = wizSummary([
+ ['检测出口', e.iface || wizConnection().iface || '自动匹配'],
+ ['外网状态', {online: '可访问外网', portal: '发现网页拦截', down: '暂不可达'}[e.state] || '未知'],
+ ['HTTP 状态码', e.status_code || '—'], ['认证地址', e.base_url || '暂未发现'],
+ ['AC_ID', e.acid || '暂未发现'], ['地址来源', e.address_source || (e.base_url ? '门户跳转' : '—')]
+ ]);
+ details.appendChild(table); body.appendChild(details);
+ }
+ body.appendChild(wizActions('', [
+ { label: wiz.busy ? '检测中…' : (wiz.accessMode === 'wifi' && !wiz.wifiJob ? '连接并检测' : (wiz.env ? '重新检测' : '检测所选线路')), cls: 'cbi-button-neutral', disabled: !wiz.accessMode, onclick: wizConnectWifi },
+ { label: '更换 Wi-Fi', hidden: !wiz.wifiJob, onclick: wizChangeWifi },
+ { label: '下一步', cls: 'cbi-button-action', disabled: !wiz.accessMode, onclick: function() {
+ if (wiz.accessMode === 'wired' && !wiz.wiredIface) { wizError('请填写有线出口接口。'); return; }
+ if (wiz.accessMode === 'wifi' && !wiz.wifiJob) { wiz.nextAfterWifi = true; wizConnectWifi(); return; }
+ wizGo(1);
+ } }
+ ]));
+ }
+
+ function wizNormalizeAddress() {
+ var raw = wiz.baseUrl;
+ if (!/^https?:\/\//i.test(raw)) raw = 'http://' + raw;
+ var parser = document.createElement('a'); parser.href = raw;
+ if (!wiz.baseUrl || !/^https?:$/.test(parser.protocol) || !parser.hostname || /\s|@/.test(parser.host) ||
+ /^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(wiz.baseUrl) || /\s/.test(wiz.baseUrl) || /https?:\/\/[^/]*@/i.test(raw)) return false;
+ var acid = /[?&](?:ac_id|acid)=([^]+)/i.exec(parser.search);
+ if (acid) { try { wiz.acId = decodeURIComponent(acid[1]); } catch (e) { return false; } }
+ if (wiz.acId && !/^[A-Za-z0-9_.-]+$/.test(wiz.acId)) return false;
+ var origin = parser.protocol + '//' + parser.host;
+ if ((parser.pathname && parser.pathname !== '/') || parser.search) wiz.portalUrl = raw;
+ var portal = document.createElement('a'); portal.href = wiz.portalUrl || origin;
+ if (portal.protocol + '//' + portal.host !== origin) wiz.portalUrl = '';
+ wiz.baseUrl = origin;
+ return true;
+ }
+
+ function wizStepAddress(body) {
+ body.appendChild(wizRow('学校预设', wizPresetSelect()));
+ body.appendChild(wizRow('认证地址', wizInput('wiz-base', 'baseUrl', 'text', '校园网登录页地址')));
+ body.appendChild(wizRow('AC_ID', wizInput('wiz-acid', 'acId', 'text', '自动检测'), '未检测到时可手动填写;留空保存使用默认值 1。'));
+ if (wiz.addressHint) body.appendChild(wizAlert('info', wiz.addressHint));
+ body.appendChild(wizActions('', [
+ { label: '上一步', onclick: function() { wizGo(0); } },
+ { label: wiz.busy ? '检测中…' : '检测认证参数', cls: 'cbi-button-neutral', onclick: function() {
+ if (!wiz.baseUrl) { wizDetectEnvironment(); return; }
+ var payload = wizConnection(); payload.base_url = wiz.baseUrl;
+ wiz.busy = 'address'; wiz.error = ''; wizRender();
+ wizPost('detect_acid', payload, function(err, data) {
+ if (err) { wizError(err.message); return; }
+ if (data.ok && data.base_url) wiz.baseUrl = data.base_url;
+ if (data.ok) wiz.portalUrl = data.detected_url || payload.base_url;
+ if (data.acid) wiz.acId = data.acid;
+ wiz.addressHint = data.message || '未发现新的参数,请检查地址或手动填写。';
+ wizRender();
+ });
+ } },
+ { label: '下一步', cls: 'cbi-button-action', onclick: function() {
+ if (!wizNormalizeAddress()) { wizError('请填写有效的 HTTP / HTTPS 认证地址和 AC_ID,或选择学校预设。'); return; }
+ wizGo(2);
+ } }
+ ]));
+ }
+
+ function wizBuildCandidates() {
+ var out = [], seen = {};
+ function push(suffix, label, source) {
+ if (typeof suffix !== 'string') return;
+ var value = suffix.replace(/^\s+|\s+$/g, '');
+ if (value === '??') return;
+ if (Object.prototype.hasOwnProperty.call(seen, value)) return;
+ seen[value] = true;
+ out.push({ suffix: value, label: label || value || '不加后缀', src: source, outcome: '' });
+ }
+ if (wiz.portalOps.length) {
+ wiz.portalOps.forEach(function(op) { push(op.suffix, op.label, '认证页面'); });
+ return out;
+ }
+ var ops = (findSchoolPreset(wiz.school) || {}).operators || [];
+ ops.forEach(function(op) { push(op.suffix !== undefined ? op.suffix : op.id, op.label, '学校预设'); });
+ return out;
+ }
+
+ function wizSetCandidates(ops) {
+ wiz.ops = ops;
+ if (!ops.some(function(op) { return op.suffix === wiz.selectedSuffix; })) {
+ wiz.selectedSuffix = ops.length === 1 ? ops[0].suffix : null;
+ }
+ wizInvalidate();
+ }
+
+ function wizDiscoverOperators(force) {
+ var payload = wizConnection(); payload.base_url = wiz.portalUrl || wiz.baseUrl; payload.ac_id = wiz.acId;
+ var key = JSON.stringify(payload);
+ if (wiz.busy || (!force && wiz.portalOpsKey === key)) return;
+ var manual = wiz.portalOpsKey === key ? wiz.ops.filter(function(op) { return !!op.manual; }) : [];
+ var selected = wiz.portalOpsKey === key ? wiz.selectedSuffix : null;
+ wiz.selectedSuffix = selected;
+ wiz.portalOpsKey = key; wiz.portalOps = [];
+ wizSetCandidates(wizBuildCandidates());
+ wiz.busy = 'discovery'; wiz.portalOpsHint = '正在读取认证后缀…';
+ wizRender();
+ wizPost('discover_operators', payload, function(err, data) {
+ wiz.portalOps = data.ok && Array.isArray(data.operators) ? data.operators : [];
+ wiz.portalOpsHint = err ? err.message : (wiz.portalOps.length ? '已读取 ' + wiz.portalOps.length + ' 个认证后缀。' : (data.message || '未识别到认证后缀。'));
+ var ops = wizBuildCandidates();
+ if (!wiz.portalOps.length && ops.length) wiz.portalOpsHint = '已载入学校预设:' + ops.length + ' 个认证后缀。';
+ manual.forEach(function(op) { if (!ops.some(function(item) { return item.suffix === op.suffix; })) ops.push(op); });
+ wiz.selectedSuffix = selected; wizSetCandidates(ops); wizRender();
+ }, 25000);
+ }
+
+ function wizStepOperator(body) {
+ if (!wiz.ops.length && !wiz.busy) wizSetCandidates(wizBuildCandidates());
+ if (wiz.portalOpsHint) body.appendChild(wizAlert(wiz.portalOps.length ? 'success' : 'info', wiz.portalOpsHint));
+ var table = wizEl('table');
+ var head = wizEl('tr');
+ ['账号类型', '认证后缀'].forEach(function(text) { head.appendChild(wizEl('th', '', text)); });
+ table.appendChild(head);
+ wiz.ops.forEach(function(op) {
+ var row = wizEl('tr');
+ var cell = wizEl('td');
+ var label = wizEl('label');
+ var radio = wizEl('input'); radio.type = 'radio'; radio.name = 'wiz-operator';
+ radio.value = op.suffix; radio.checked = wiz.selectedSuffix === op.suffix;
+ radio.onchange = function() { wiz.selectedSuffix = op.suffix; wizInvalidate(); wizRender(); };
+ label.appendChild(radio); label.appendChild(document.createTextNode(op.label));
+ cell.appendChild(label); cell.appendChild(wizEl('div', 'smart-wizard-help', op.src)); row.appendChild(cell);
+ row.appendChild(wizEl('td', '', op.suffix ? '@' + op.suffix : '不加后缀'));
+ table.appendChild(row);
+ });
+ if (wiz.ops.length) body.appendChild(table);
+ else if (!wiz.busy) body.appendChild(wizEl('p', 'smart-wizard-help', '请选择学校预设或填写认证后缀;无后缀账号请选择“不加后缀”。'));
+ var custom = wizEl('input'); custom.id = 'wiz-op-add'; custom.placeholder = '例如 stu.example.edu.cn'; custom.type = 'text';
+ var add = wizEl('button', 'btn cbi-button', '添加并选择'); add.type = 'button';
+ add.onclick = function() {
+ var value = custom.value.replace(/^[@\s]+|\s+$/g, '');
+ if (!value || /[\s,@]/.test(value) || value === '??') { wizError('请填写实际后缀;无需后缀时选择“不加后缀”。'); return; }
+ var exists = wiz.ops.some(function(op) { return op.suffix === value; });
+ if (!exists) wiz.ops.push({suffix: value, label: value, src: '手动填写', manual: true, outcome: ''});
+ wiz.selectedSuffix = value; wizInvalidate(); wizRender();
+ };
+ var wrap = wizEl('div', 'smart-wizard-inline'); wrap.appendChild(custom); wrap.appendChild(add);
+ var plain = wizEl('button', 'btn cbi-button', '不加后缀'); plain.type = 'button';
+ plain.onclick = function() {
+ if (!wiz.ops.some(function(op) { return op.suffix === ''; })) {
+ wiz.ops.push({suffix: '', label: '不加后缀', src: '手动选择', manual: true, outcome: ''});
+ }
+ wiz.selectedSuffix = ''; wizInvalidate(); wizRender();
+ };
+ wrap.appendChild(plain);
+ body.appendChild(wizRow('自定义后缀', wrap));
+ body.appendChild(wizActions('', [
+ { label: '上一步', onclick: function() { wizGo(1); } },
+ { label: '重新读取认证页', cls: 'cbi-button-neutral', onclick: function() { wizDiscoverOperators(true); } },
+ { label: '下一步', cls: 'cbi-button-action', onclick: function() { wizGo(3); } }
+ ]));
+ }
+
+ function wizSelectedOperator() {
+ for (var i = 0; i < wiz.ops.length; i++) {
+ if (wiz.ops[i].suffix === wiz.selectedSuffix) return wiz.ops[i];
+ }
+ return {label: '尚未选择', suffix: '', src: ''};
+ }
+
+ function wizStepAccount(body) {
+ var select = wizEl('select'); select.id = 'wiz-account-operator';
+ var pending = wizEl('option', '', '请选择账号类型'); pending.value = ''; select.appendChild(pending);
+ var choices = wiz.ops.slice();
+ if (!choices.some(function(op) { return op.suffix === ''; })) {
+ choices.push({suffix: '', label: '不加后缀', src: '手动选择', manual: true, outcome: ''});
+ }
+ choices.forEach(function(op, index) {
+ var option = wizEl('option', '', op.label + (op.suffix ? '(@' + op.suffix + ')' : ''));
+ // Use an index so the empty suffix remains distinct from "not selected".
+ option.value = String(index); select.appendChild(option);
+ if (op.suffix === wiz.selectedSuffix) select.value = option.value;
+ });
+ select.onchange = function() {
+ var chosen = select.value === '' ? null : choices[Number(select.value)];
+ wiz.selectedSuffix = chosen ? chosen.suffix : null;
+ if (chosen && !wiz.ops.some(function(op) { return op.suffix === chosen.suffix; })) wiz.ops.push(chosen);
+ wizInvalidate(); wiz.error = '';
+ var error = document.getElementById('wiz-error');
+ if (error) error.parentNode.removeChild(error);
+ wizUpdatePreviews();
+ };
+ body.appendChild(wizRow('账号类型', select));
+ body.appendChild(wizRow('校园网账号', wizInput('wiz-user', 'userId', 'text', '学工号或校园网用户名'), '账号已包含后缀时,请选择“不加后缀”。'));
+ body.appendChild(wizRow('密码', wizInput('wiz-pass', 'password', 'password')));
+ var preview = wizEl('code', '', wiz.selectedSuffix === null ? '待选择认证后缀' : wizLoginPreview(wiz.selectedSuffix));
+ preview.id = 'wiz-login-preview';
+ body.appendChild(wizRow('登录名预览', preview));
+
+ var verify = wizEl('details'); verify.id = 'wiz-account-verify';
+ verify.open = !!wiz.verifyExpanded;
+ verify.appendChild(wizEl('summary', '', '账号验证(可选)'));
+ verify.appendChild(wizEl('p', 'smart-wizard-help', '在线账号识别无需密码。登录验证需提交账号和密码,最多验证 5 个已知后缀。'));
+ var controls = wizEl('div', 'smart-wizard-inline');
+ var identify = wizEl('button', 'btn cbi-button', '识别在线账号'); identify.type = 'button';
+ identify.onclick = function() { wizRunOperatorProbe(true); }; controls.appendChild(identify);
+ var login = wizEl('button', 'btn cbi-button', '验证登录'); login.type = 'button';
+ login.onclick = function() { wizRunOperatorProbe(false); }; controls.appendChild(login);
+ verify.appendChild(controls);
+ if (wiz.opLog) {
+ var result = wizEl('div'); result.id = 'wiz-account-result';
+ if (wiz.opConfirmed) result.appendChild(wizAlert('success', wiz.passwordVerified ?
+ '账号登录已验证。' : '已从在线账号确认后缀;本次填写的密码尚未验证。'));
+ var attempts = wiz.ops.filter(function(op) { return !!op.outcome; });
+ if (attempts.length) {
+ var table = wizEl('table');
+ attempts.forEach(function(op) {
+ var row = wizEl('tr'); row.appendChild(wizEl('td', '', wizLoginPreview(op.suffix)));
+ var outcome = wizEl('td', 'smart-wizard-outcome', WIZ_OUTCOME[op.outcome] || '验证未完成');
+ if (op.message) outcome.appendChild(wizEl('div', 'smart-wizard-help', op.message));
+ row.appendChild(outcome); table.appendChild(row);
+ });
+ result.appendChild(table);
+ }
+ result.appendChild(wizEl('div', 'smart-wizard-log', wiz.opLog)); verify.appendChild(result);
+ }
+ body.appendChild(verify);
+ body.appendChild(wizActions('', [
+ { label: '上一步', onclick: function() { wizGo(2); } },
+ { label: '下一步', cls: 'cbi-button-action', onclick: function() {
+ if (!wiz.userId || !wiz.password) { wizError('请填写校园网账号和密码。'); return; }
+ if (wiz.selectedSuffix === null) { wizError('请选择账号类型或识别在线账号。'); return; }
+ wizGo(4);
+ } }
+ ]));
+ }
+
+ function wizRunOperatorProbe(readOnly) {
+ if (!wiz.userId) { wizError('请填写校园网账号。'); return; }
+ if (!readOnly && !wiz.password) { wizError('验证登录需要填写密码;已有在线账号可使用“识别在线账号”。'); return; }
+ var suffixes = wiz.selectedSuffix === null ? wiz.ops.slice(0, 5).map(function(op) { return op.suffix; }) : [wiz.selectedSuffix];
+ if (!readOnly && !suffixes.length) { wizError('请选择认证后缀后验证登录。'); return; }
+ var payload = wizConnection();
+ payload.base_url = wiz.baseUrl; payload.ac_id = wiz.acId || '1'; payload.school = wiz.school;
+ payload.user_id = wiz.userId; payload.read_only = readOnly ? '1' : '0';
+ if (!readOnly) {
+ payload.password = wiz.password;
+ payload.max_attempts = suffixes.length; payload.candidates = JSON.stringify(suffixes);
+ }
+ WIZ_SHAPE_KEYS.forEach(function(key) { payload[key] = wiz.shape[key] || ''; });
+ wizInvalidate(); wiz.verifyExpanded = true; wiz.busy = 'operator'; wiz.error = '';
+ wiz.opLog = readOnly ? '正在读取在线账号信息…' : '正在验证登录,请稍候…'; wizRender();
+ wizPost('detect_operator', payload, function(err, data) {
+ if (err) { wizError(err.message); return; }
+ wiz.opLog = '';
+ (data.attempts || []).forEach(function(attempt) {
+ wiz.ops.forEach(function(op) { if (op.suffix === attempt.suffix) { op.outcome = attempt.outcome; op.message = attempt.message || ''; } });
+ wiz.opLog += (attempt.username || '') + ' → ' + (WIZ_OUTCOME[attempt.outcome] || '验证未完成') + '\n';
+ });
+ if (data.confirmed) {
+ wiz.selectedSuffix = data.suffix || ''; wiz.opConfirmed = true; wiz.passwordVerified = data.password_verified !== false;
+ if (!wiz.ops.some(function(op) { return op.suffix === wiz.selectedSuffix; })) {
+ wiz.ops.push({suffix: wiz.selectedSuffix, label: wiz.selectedSuffix || '不加后缀', src: '网关在线账号'});
+ }
+ wiz.ops.forEach(function(op) { if (op.suffix === wiz.selectedSuffix) {
+ op.outcome = data.password_verified === false ? 'identity' : 'hit'; op.message = data.message || '';
+ } });
+ } else {
+ wiz.error = data.message || '本次未能确认后缀,请查看具体结果。';
+ wiz.ops.forEach(function(op) { if (!op.outcome && suffixes.indexOf(op.suffix) !== -1) {
+ op.outcome = 'skipped'; op.message = '前置检查或上一项验证已停止,请先处理上方提示。';
+ } });
+ }
+ wiz.opLog += data.message || '验证结束。'; wizRender();
+ }, 180000);
+ }
+
+ function wizSummary(rows) {
+ var table = wizEl('table', 'smart-wizard-summary');
+ rows.forEach(function(row) {
+ var tr = wizEl('tr'); tr.appendChild(wizEl('td', '', row[0])); tr.appendChild(wizEl('td', '', String(row[1]))); table.appendChild(tr);
+ });
+ return table;
+ }
+
+ function wizStepDone(body) {
+ body.appendChild(wizAlert(wiz.opConfirmed ? 'success' : 'info', wiz.opConfirmed ?
+ (wiz.passwordVerified ? '登录验证通过。' : '认证后缀已确认,密码尚未验证。') :
+ '请确认以下账号与网络信息。'));
+ body.appendChild(wizSummary([
+ ['接入方式', wiz.accessMode === 'wired' ? '有线' : '无线'],
+ ['出口接口', wizConnection().iface || '按已连接 Wi-Fi 自动匹配'],
+ ['校园网 Wi-Fi', wiz.accessMode === 'wifi' ? (wiz.ssid || '尚未填写,请返回第一步补充') : '不适用'],
+ ['学校预设', (findSchoolPreset(wiz.school) || {}).name || '无预设'],
+ ['认证地址', wiz.baseUrl], ['AC_ID', wiz.acId || '1(默认值,尚未确认)'],
+ ['账号类型', wizSelectedOperator().label], ['认证后缀', wiz.selectedSuffix ? '@' + wiz.selectedSuffix : '不加后缀'],
+ ['登录名', wizLoginPreview(wiz.selectedSuffix)], ['密码', '已填写']
+ ]));
+ if (wiz.accessMode === 'wifi' && !wiz.ssid) {
+ body.appendChild(wizAlert('warning', '请返回第一步填写校园网 Wi-Fi 名称,保存后才能自动匹配无线网络。'));
+ }
+ body.appendChild(wizActions(wiz.wifiJob ? '保存后设为默认账号,并启用此 Wi-Fi 的自动认证。' : '保存后可在账号列表中设为默认账号。', [
+ { label: '修改接入方式', onclick: function() { wizGo(0); } },
+ { label: '上一步', onclick: function() { wizGo(3); } },
+ { label: wiz.busy ? '保存中…' : (wiz.wifiJob ? '保存并设为默认账号' : '保存为校园网账号'), cls: 'cbi-button-save', disabled: wiz.saved || (wiz.accessMode === 'wifi' && !wiz.ssid), onclick: wizSave }
+ ]));
+ }
+
+ function wizContributionData() {
+ var preset = findSchoolPreset(wiz.school) || {};
+ var origin = /^(https?:\/\/)([^\/?#]+)/i.exec(wiz.baseUrl || '');
+ var defaults = {
+ base_url: origin ? origin[1] + origin[2].split('@').pop() : '',
+ ac_id: wiz.acId || '', access_mode: wiz.accessMode
+ };
+ if (wiz.accessMode === 'wifi') defaults.ssid = wiz.ssid || '';
+ var operators = [];
+ var source = wiz.portalOps.length ? wiz.portalOps : wiz.ops;
+ source.forEach(function(op) {
+ if (typeof op.suffix !== 'string' || op.suffix === '??') return;
+ if (operators.some(function(item) { return item.suffix === op.suffix; })) return;
+ operators.push({ suffix: op.suffix, label: String(op.label || (op.suffix ? '@' + op.suffix : '无后缀')) });
+ });
+ if (!operators.some(function(op) { return op.suffix === wiz.selectedSuffix; })) {
+ operators.push({ suffix: wiz.selectedSuffix || '', label: wizSelectedOperator().label });
+ }
+ // Export a new allowlisted object, never the wizard/account/config object.
+ var result = { name: String(preset.name || ''), status: 'draft', defaults: defaults, operators: operators };
+ if (preset.short_name) result.short_name = String(preset.short_name);
+ return result;
+ }
+
+ function wizContributionBody() {
+ var selected = wiz.selectedSuffix ? '@' + wiz.selectedSuffix : '无后缀';
+ return '### 学校 / 校区\n\n' + wiz.contribution.name + '\n\n' +
+ '### 插件使用结果\n\n' +
+ '- 配置保存:成功\n' +
+ '- 登录验证:' + (wiz.passwordVerified ? '已通过插件验证' : '未执行或未通过,保存不代表认证成功') + '\n' +
+ '- 已确认校园网可正常使用:' + (wiz.contributionOnline ? '是(用户确认)' : '尚未确认') + '\n' +
+ '- 本次所选后缀:' + selected + '\n' +
+ '- 选项来源:' + (wiz.portalOps.length ? '认证页面' : (wiz.school ? '学校预设及手动补充' : '手动填写')) + '\n\n' +
+ '### 预设参数\n\n```json\n' + JSON.stringify(wiz.contribution, null, 2) + '\n```\n\n' +
+ '由智慧深澜一键配置生成。账号类型列表不表示全部类型均已登录验证;请维护者核对学校标识、验证范围与预设状态。';
+ }
+
+ function wizStepSaved(body) {
+ body.appendChild(wizAlert('success', '配置已保存。'));
+ body.appendChild(wizEl('p', '', '可提交学校预设,帮助其他用户完成配置。'));
+ var name = wizEl('input'); name.id = 'wiz-contribution-name'; name.type = 'text';
+ name.value = wiz.contribution.name; name.placeholder = '学校及校区名称';
+ name.maxLength = 100;
+ name.oninput = function() {
+ wiz.contribution.name = name.value.replace(/^\s+|\s+$/g, '');
+ if (wiz.contribution.name && wiz.error) {
+ wiz.error = '';
+ var error = document.getElementById('wiz-error');
+ if (error && error.parentNode) error.parentNode.removeChild(error);
+ }
+ preview.value = wizContributionBody();
+ };
+ body.appendChild(wizRow('学校 / 校区', name));
+ var confirmed = wizEl('input'); confirmed.type = 'checkbox';
+ confirmed.id = 'wiz-contribution-online'; confirmed.checked = !!wiz.contributionOnline;
+ confirmed.onchange = function() { wiz.contributionOnline = confirmed.checked; preview.value = wizContributionBody(); };
+ var label = wizEl('label'); label.appendChild(confirmed);
+ label.appendChild(document.createTextNode(' 已确认校园网可正常使用'));
+ body.appendChild(label);
+ var review = wizEl('details'); review.style.marginTop = '16px';
+ review.appendChild(wizEl('summary', '', '预览提交内容'));
+ var preview = wizEl('textarea'); preview.id = 'wiz-contribution-preview'; preview.readOnly = true;
+ preview.rows = 12; preview.style.width = '100%'; preview.style.marginTop = '8px';
+ preview.setAttribute('aria-label', 'Issue 草稿内容'); preview.value = wizContributionBody();
+ review.appendChild(preview); body.appendChild(review);
+ body.appendChild(wizEl('p', 'smart-wizard-help', '草稿仅包含学校、认证地址、SSID 与后缀信息,不包含账号、密码或设备标识。将在 GitHub 打开,由你确认后提交。'));
+ body.appendChild(wizActions('', [{ label: '创建预设 Issue', cls: 'cbi-button-action', onclick: function() {
+ if (!wiz.contribution.name) { wizError('请填写学校及校区名称。'); return; }
+ var url = 'https://github.com/matthewlu070111/smart-srun/issues/new?title=' +
+ encodeURIComponent('[School Preset]: ' + wiz.contribution.name) + '&body=' + encodeURIComponent(wizContributionBody());
+ if (url.length > 7500) {
+ review.open = true; preview.focus(); preview.select();
+ body.appendChild(wizEl('p', 'smart-wizard-help', '草稿较长,请复制预览内容并粘贴到新 Issue。'));
+ url = 'https://github.com/matthewlu070111/smart-srun/issues/new';
+ }
+ window.open(url, '_blank', 'noopener,noreferrer');
+ } }]));
+ }
+
+ function wizSave() {
+ if (wiz.busy || wiz.saved) return;
+ var revision = configRevision();
+ if (revision === null) { wizError('配置版本不可用,请刷新页面后重试。'); return; }
+ if (!wiz.userId || !wiz.password || wiz.selectedSuffix === null || !wizNormalizeAddress()) { wizError('请检查账号、密码、认证后缀与认证地址。'); return; }
+ if (!wiz.accessMode || (wiz.accessMode === 'wifi' && !wiz.ssid) || (wiz.accessMode === 'wired' && !wiz.wiredIface)) { wizError('请返回第一步补全接入信息。'); return; }
+ var payload = {
+ expected_revision: revision,
+ action: 'add_campus', label: wizLoginPreview(wiz.selectedSuffix), user_id: wiz.userId,
+ operator_suffix: wiz.selectedSuffix, password: wiz.password, base_url: wiz.baseUrl,
+ ac_id: wiz.acId || '1', access_mode: wiz.accessMode,
+ wired_iface: wiz.wiredIface, ssid: wiz.accessMode === 'wifi' ? wiz.ssid : '', ap_selection: 'auto'
+ };
+ WIZ_SHAPE_KEYS.forEach(function(key) { payload[key] = wiz.shape[key] || ''; });
+ wiz.busy = 'save'; wiz.error = ''; wizRender();
+ payload.setup_job = wiz.wifiJob || '';
+ wizPost('enqueue', payload, function(err, data) {
+ if (data.saved) { wiz.saved = true; wiz.wifiJob = ''; }
+ if (err || data.ok !== true) { wizError(err ? err.message : (data.message || '保存失败')); return; }
+ wiz.wifiJob = '';
+ wiz.saved = true; wiz.saveComplete = true;
+ wiz.contribution = wizContributionData();
+ wiz.userId = ''; wiz.password = ''; wiz.wifiKey = '';
+ wizRender();
+ });
+ }
+
+ function wizRender() {
+ if (!wiz) return;
+ wizStyles();
+ var root = wizEl('div', 'smart-wizard');
+ var steps = wizEl('ol', 'smart-wizard-steps');
+ steps.setAttribute('aria-label', '配置步骤');
+ WIZ_STEPS.forEach(function(text, index) {
+ var item = wizEl('li');
+ item.appendChild(document.createTextNode((index + 1) + '. '));
+ item.appendChild(wizEl('span', 'smart-wizard-step-label', text));
+ if (index === wiz.step) item.setAttribute('aria-current', 'step'); steps.appendChild(item);
+ });
+ root.appendChild(steps);
+ var inner = wizEl('div', 'smart-wizard-content');
+ if (wiz.error) { var error = wizAlert('danger', wiz.error); error.id = 'wiz-error'; inner.appendChild(error); }
+ if (wiz.saveComplete) wizStepSaved(inner);
+ else [wizStepEnv, wizStepAddress, wizStepOperator, wizStepAccount, wizStepDone][wiz.step](inner);
+ root.appendChild(inner);
+ var actions = inner.querySelector('.smart-wizard-actions');
+ if (actions) root.appendChild(actions);
+ if (wiz.busy) {
+ var fields = inner.querySelectorAll('input,select,button');
+ for (var i = 0; i < fields.length; i++) fields[i].disabled = true;
+ }
+ if (wiz.wifiJob && wiz.step === 0) {
+ var connectionFields = inner.querySelectorAll('input,select');
+ for (var k = 0; k < connectionFields.length; k++) connectionFields[k].disabled = true;
+ }
+ wiz.root = root;
+ root.addEventListener('keydown', function(event) {
+ if (event.keyCode === 27) {
+ event.preventDefault(); event.stopPropagation(); wizClose();
+ } else if (event.keyCode === 13 && event.target.tagName === 'INPUT') {
+ // Never submit the underlying CBI form while typing in the wizard.
+ event.preventDefault();
+ } else if (event.keyCode === 9) {
+ var focusable = root.querySelectorAll('input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),summary');
+ if (!focusable.length) { event.preventDefault(); return; }
+ var first = focusable[0], last = focusable[focusable.length - 1];
+ if (event.shiftKey && (event.target === first || event.target === root)) {
+ event.preventDefault(); last.focus();
+ } else if (!event.shiftKey && event.target === last) {
+ event.preventDefault(); first.focus();
+ }
+ }
+ });
+ L.showModal('一键配置校园网', [root], 'smart-wizard-dialog');
+ root.tabIndex = -1;
+ root.focus();
+ }
+
+ function initSetupWizard() {
+ var button = document.getElementById('smart-srun-setup-wizard');
+ if (!button || window.__smartWizardInit) return;
+ window.__smartWizardInit = true;
+ button.addEventListener('click', function() { wizReset(); wizRender(); });
+ }
+
+
+ function initAll() {
+ initConfigBackup();
+ initVersionNotice();
+ initTables();
+ initSchoolInfo();
+ initOverview();
+ initManualActions();
+ initSwitchActions();
+ initSetupWizard();
+ initLogView();
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initAll);
+ } else {
+ initAll();
+ }
+})();
diff --git a/smart-srun/ruff.toml b/smart-srun/ruff.toml
new file mode 100644
index 000000000..d08671382
--- /dev/null
+++ b/smart-srun/ruff.toml
@@ -0,0 +1,12 @@
+# Lint gate for build-host scripts and contract tests (see CI).
+#
+# Keep the accepted rule set explicit and the tool version pinned in CI.
+# Change both deliberately so local and CI runs use a reproducible lint gate.
+#
+# Deliberately not enabled yet, with the reason:
+# UP031 percent formatting -- mechanical style changes are a separate task.
+# BLE001 blind except -- the daemon catches broadly on purpose.
+# I001 import sorting -- test probes import host tools from explicit paths.
+
+[lint]
+select = ["E4", "E7", "E9", "F"]
diff --git a/smart-srun/scripts/AGENTS.md b/smart-srun/scripts/AGENTS.md
new file mode 100644
index 000000000..cfb866563
--- /dev/null
+++ b/smart-srun/scripts/AGENTS.md
@@ -0,0 +1,19 @@
+# 开发主机工具
+
+本目录仅在开发主机运行。按任务读取手册,不把部署流程带入普通文档修改。
+
+| 任务 | 实现入口 | 手册 |
+| --- | --- | --- |
+| SDK 构建与验包 | [build_go_sdk.py](build_go_sdk.py)、[verify_go_sdk.py](verify_go_sdk.py)、[targets.json](../targets.json) | [构建与发布](../doc/development/build-release.md) |
+| 清单与候选草稿 | [make_go_manifest.py](make_go_manifest.py)、[ci_go_release.py](ci_go_release.py) | [构建与发布](../doc/development/build-release.md)、[工作流约束](../.github/AGENTS.md) |
+| 包部署与精确版本恢复 | [hot_update.py](hot_update.py)、[hot_update_go.py](hot_update_go.py) | [部署与恢复](../doc/development/deployment.md) |
+| 文档与变更上下文 | [check_docs.py](check_docs.py)、[doc_context.py](doc_context.py) | [上下文维护](../doc/development/context.md) |
+
+- `--dry-run` 只验证本地输入;`--probe` 只读设备;`--prepare` 会上传并核验,不能混称只读。
+- 热部署需要真实 SDK 包和精确原版本恢复包;bundle/split 不混用,不退回逐文件覆盖。
+- 官方 APK 用维护者密钥签名并原生可信验证;临时预览密钥产物不能晋升为正式身份。
+- 不覆盖同版本产物,不补写猜测的历史记录;验收证据绑定包 SHA256。
+- 缺少工具、验签或验收环境明确失败/未执行,不让门禁返回伪成功。
+- 主机测试使用合成数据;私钥、校园账号、真实备份和设备日志不进入公开测试或报告。
+
+参数先看脚本 `--help`。主机检查与依赖见 [tests/README.md](../tests/README.md)。
diff --git a/smart-srun/scripts/build-go-dev.sh b/smart-srun/scripts/build-go-dev.sh
new file mode 100644
index 000000000..df9e6b77c
--- /dev/null
+++ b/smart-srun/scripts/build-go-dev.sh
@@ -0,0 +1,87 @@
+#!/bin/sh
+# Build a manifest-checked development payload. This is not an SDK package or
+# a signed release: install it only in the isolated development VM.
+set -eu
+
+project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+output_dir=${1:-"$project_dir/.codex/go-loop/build/go-dev"}
+mkdir -p "$output_dir"
+output_dir=$(CDPATH= cd -- "$output_dir" && pwd)
+stage_dir=$(mktemp -d "${TMPDIR:-/tmp}/smart-srun-dev.XXXXXX")
+trap 'rm -rf -- "$stage_dir"' EXIT HUP INT TERM
+
+revision=$(git -C "$project_dir" rev-parse --short=12 HEAD)
+if [ -n "$(git -C "$project_dir" status --porcelain -- core)" ]; then revision="$revision.dirty"; fi
+version="0.0.0-dev.$revision"
+arch=${GOARCH:-$(go env GOARCH)}
+# targets.json owns the payload budget. Read it rather than repeating it here,
+# and refuse to build on anything that is not a plain decimal count of bytes --
+# a silently empty value would turn the check below into a comparison against
+# nothing, which passes.
+payload_limit=$(sed -n 's/.*"development_payload_limit_bytes"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' \
+ "$project_dir/targets.json")
+case "$payload_limit" in
+ ''|*[!0-9]*) echo "Cannot read development_payload_limit_bytes from targets.json" >&2; exit 1 ;;
+esac
+mkdir -p "$stage_dir/usr/bin" "$stage_dir/etc/init.d" \
+ "$stage_dir/usr/share/smart-srun" \
+ "$stage_dir/usr/lib/lua/luci/controller" "$stage_dir/usr/lib/lua/luci/model/cbi" \
+ "$stage_dir/usr/lib/lua/luci/smart_srun" \
+ "$stage_dir/www/luci-static/resources"
+# PREBUILT_SRUNNET exists for a Windows checkout whose POSIX shell has no Go
+# toolchain: the cross build runs outside, this assembles the same payload. It
+# must be the ELF built from this tree at this revision; nothing here can check
+# that, so the build-info version is the only claim made about it.
+if [ -n "${PREBUILT_SRUNNET:-}" ]; then
+ cp "$PREBUILT_SRUNNET" "$stage_dir/usr/bin/srunnet"
+else
+(
+ cd "$project_dir/core"
+ CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath \
+ -ldflags "-s -w -X github.com/matthewlu070111/smart-srun/core/internal/cli.Version=$version" \
+ -o "$stage_dir/usr/bin/srunnet" ./cmd/srunnet
+)
+fi
+# Normalize text resource line endings when building a Windows checkout.
+sed 's/\r$//' "$project_dir/root/etc/init.d/smart_srun" > "$stage_dir/etc/init.d/smart_srun"
+sed 's/\r$//' "$project_dir/root/etc/init.d/smart_srun_update" > "$stage_dir/etc/init.d/smart_srun_update"
+cp "$project_dir/doc/school-presets.json" "$stage_dir/usr/share/smart-srun/school-presets.json"
+# The interface is part of what has to be installed to try anything through it.
+# Same files the package will install; the packaging itself is batch C.
+luci_files="usr/share/smart-srun/third-party-licenses.txt
+usr/lib/lua/luci/controller/smart_srun.lua
+usr/lib/lua/luci/model/cbi/smart_srun.lua
+usr/lib/lua/luci/smart_srun/schema.lua
+usr/lib/lua/luci/smart_srun/rpc.lua
+usr/lib/lua/luci/smart_srun/bridge.lua
+www/luci-static/resources/smart_srun.js"
+for relative in $luci_files; do
+ sed 's/\r$//' "$project_dir/root/$relative" > "$stage_dir/$relative"
+ chmod 644 "$stage_dir/$relative"
+done
+chmod 755 "$stage_dir/usr/bin/srunnet" "$stage_dir/etc/init.d/smart_srun"
+chmod 755 "$stage_dir/etc/init.d/smart_srun_update"
+chmod 644 "$stage_dir/usr/share/smart-srun/school-presets.json"
+
+(
+ cd "$stage_dir"
+ sha256sum usr/bin/srunnet etc/init.d/smart_srun etc/init.d/smart_srun_update \
+ usr/share/smart-srun/school-presets.json $luci_files > manifest.sha256
+ payload_bytes=$(wc -c < usr/bin/srunnet)
+ payload_bytes=$((payload_bytes + $(wc -c < etc/init.d/smart_srun) + $(wc -c < usr/share/smart-srun/school-presets.json)))
+ payload_bytes=$((payload_bytes + $(wc -c < etc/init.d/smart_srun_update)))
+ for relative in $luci_files; do
+ payload_bytes=$((payload_bytes + $(wc -c < "$relative")))
+ done
+ if [ "$payload_bytes" -gt "$payload_limit" ]; then
+ echo "Development payload exceeds the $payload_limit byte budget: $payload_bytes bytes" >&2
+ exit 1
+ fi
+ printf 'version=%s\narch=%s\ninstalled_payload_bytes=%s\nformat=development-tar-not-sdk-package\n' \
+ "$version" "$arch" "$payload_bytes" > build-info.txt
+ tar -czf "$output_dir/smart-srun-dev-$arch.tar.gz" \
+ manifest.sha256 build-info.txt usr/bin/srunnet etc/init.d/smart_srun etc/init.d/smart_srun_update \
+ usr/share/smart-srun/school-presets.json $luci_files
+ cat build-info.txt
+)
+sha256sum "$output_dir/smart-srun-dev-$arch.tar.gz"
diff --git a/smart-srun/scripts/build_go_sdk.py b/smart-srun/scripts/build_go_sdk.py
new file mode 100644
index 000000000..d843b1cf2
--- /dev/null
+++ b/smart-srun/scripts/build_go_sdk.py
@@ -0,0 +1,369 @@
+#!/usr/bin/env python3
+"""Build Go packages with pinned OpenWrt SDK inputs (Linux, Python 3.12+).
+
+Each target owns a separate workspace. Output is a build record, not release
+acceptance: installation, emulation and hardware evidence must be added later.
+APK signing accepts an existing maintainer key; it never creates a release key.
+"""
+
+import argparse
+import hashlib
+import io
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import urllib.request
+
+try:
+ import fcntl
+except ImportError: # Helpers can be tested on Windows; the SDK runs on Linux.
+ fcntl = None
+
+
+REPO = Path(__file__).resolve().parents[1]
+PACKAGES = ("smart-srun", "luci-app-smart-srun", "luci-app-smart-srun-bundle")
+SOURCE_PATHS = (
+ "Makefile", "core/go.mod", "core/go.sum", "core/cmd", "core/internal",
+ "root/etc/init.d/smart_srun", "root/lib/upgrade/keep.d/smart-srun",
+ "root/etc/init.d/smart_srun_update",
+ "root/usr/share/smart-srun/third-party-licenses.txt",
+ "root/usr/lib/lua/luci/controller/smart_srun.lua",
+ "root/usr/lib/lua/luci/model/cbi/smart_srun.lua",
+ "root/usr/lib/lua/luci/smart_srun",
+ "root/www/luci-static/resources/smart_srun.js", "doc/school-presets.json",
+)
+FEED_URLS = {
+ "base": "https://github.com/openwrt/openwrt.git",
+ "packages": "https://github.com/openwrt/packages.git",
+ "luci": "https://github.com/openwrt/luci.git",
+}
+CORE_FILES = {
+ "usr/bin/srunnet", "etc/init.d/smart_srun", "lib/upgrade/keep.d/smart-srun",
+ "etc/init.d/smart_srun_update",
+ "usr/share/smart-srun/school-presets.json", "usr/share/smart-srun/third-party-licenses.txt",
+}
+LUCI_FILES = {
+ "usr/lib/lua/luci/controller/smart_srun.lua", "usr/lib/lua/luci/model/cbi/smart_srun.lua",
+ "usr/lib/lua/luci/smart_srun/rpc.lua", "usr/lib/lua/luci/smart_srun/schema.lua",
+ "usr/lib/lua/luci/smart_srun/bridge.lua", "www/luci-static/resources/smart_srun.js",
+}
+
+
+def validate_payload(package, files, limit):
+ expected = set()
+ if package in ("smart-srun", "luci-app-smart-srun-bundle"):
+ expected |= CORE_FILES
+ if package in ("luci-app-smart-srun", "luci-app-smart-srun-bundle"):
+ expected |= LUCI_FILES
+ metadata = {f"lib/apk/packages/{package}.{suffix}" for suffix in ("list", "conffiles", "conffiles_static")}
+ metadata.add("lib/upgrade/keep.d/" + package)
+ if package not in PACKAGES or not expected <= files.keys() or files.keys() - expected - metadata:
+ raise ValueError("Package payload differs from the explicit runtime file list")
+ if any(not isinstance(size, int) or size < 0 for size in files.values()) or sum(files.values()) > limit:
+ # The limit is reported rather than spelled out: it comes from
+ # targets.json, and a literal here said "10 MiB" for as long as that was
+ # true and would have gone on saying it afterwards.
+ raise ValueError(f"Installed payload exceeds the {limit} byte budget or has an invalid size")
+
+
+def digest(path):
+ with path.open("rb") as source:
+ return hashlib.file_digest(source, "sha256").hexdigest()
+
+
+def command(args, cwd=None, log=None, env=None):
+ result = subprocess.run([str(arg) for arg in args], cwd=cwd, env=env,
+ stdout=log or subprocess.PIPE, stderr=subprocess.STDOUT)
+ if result.returncode:
+ detail = result.stdout.decode(errors="replace")[-4000:] if result.stdout else "See build.log"
+ raise RuntimeError(f"{args[0]} failed ({result.returncode}): {detail}")
+ return result.stdout.decode() if result.stdout is not None else ""
+
+
+def package_version(display, manager):
+ match = re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:rc([1-9]\d*))?", display)
+ if not match or manager not in ("opkg", "apk"):
+ raise ValueError("Expected X.Y.Z or X.Y.ZrcN and an opkg/apk target")
+ base = ".".join(match.group(index) for index in (1, 2, 3))
+ return base + (("~rc" if manager == "opkg" else "_rc") + match[4] if match[4] else "")
+
+
+def load_target(path, target_id):
+ catalog = json.loads(path.read_text())
+ found = [target for target in catalog["targets"] if target["id"] == target_id]
+ if catalog.get("schema_version") != 1 or len(found) != 1:
+ raise ValueError("Target must occur exactly once in targets.json schema 1")
+ target = found[0]
+ if not re.fullmatch(r"[a-zA-Z0-9_.-]+", target_id):
+ raise ValueError("Unsafe target id")
+ for value in [target["sdk_sha256"], catalog["go_source_sha256"]]:
+ if not re.fullmatch(r"[0-9a-f]{64}", value):
+ raise ValueError("Missing immutable download digest")
+ for value in [catalog["golang_framework_commit"], *target["feeds"].values()]:
+ if not re.fullmatch(r"[0-9a-f]{40}", value):
+ raise ValueError("Feed must use a full commit")
+ if not target["sdk_url"].startswith("https://downloads.openwrt.org/releases/"):
+ raise ValueError("SDK must come from the official immutable release directory")
+ return catalog, target
+
+
+def download(url, destination, sha256):
+ if destination.exists():
+ if digest(destination) != sha256:
+ raise ValueError("Cached SDK digest mismatch")
+ return
+ temporary = destination.with_suffix(".part")
+ with urllib.request.urlopen(url, timeout=60) as response, temporary.open("wb") as out:
+ total = 0
+ while chunk := response.read(1024 * 1024):
+ total += len(chunk)
+ if total > 2 * 1024**3:
+ raise ValueError("SDK download exceeds 2 GiB")
+ out.write(chunk)
+ if digest(temporary) != sha256:
+ raise ValueError("Downloaded SDK digest mismatch")
+ temporary.replace(destination)
+
+
+def sdk_archive_filter(root_name):
+ def filter_member(member, destination):
+ path = PurePosixPath(member.name)
+ if path.is_absolute() or ".." in path.parts or not path.parts or path.parts[0] != root_name:
+ raise ValueError("SDK archive member escapes its expected root")
+ # Official SDKs carry links to their original builder's host tools.
+ # Do not extract these external links. OpenWrt's prerequisite checks
+ # recreate them using tools on this host before any feed scan/build.
+ if (member.issym() and PurePosixPath(member.linkname).is_absolute()
+ and len(path.parts) == 5 and path.parts[1:4] == ("staging_dir", "host", "bin")):
+ return None
+ return tarfile.data_filter(member, destination)
+ return filter_member
+
+
+def prepare_sdk(work, catalog, target, log):
+ sdk = work / "sdk"
+ marker = work / "inputs.json"
+ inputs = {"target": target, "go_version": catalog["go_version"],
+ "go_source_sha256": catalog["go_source_sha256"],
+ "golang_framework_commit": catalog["golang_framework_commit"]}
+ if marker.exists():
+ if json.loads(marker.read_text()) != inputs:
+ raise ValueError("Workspace has different pinned inputs; use another work directory")
+ return sdk
+ if sdk.exists():
+ raise ValueError("Incomplete SDK workspace; inspect it and use a fresh work directory")
+ archive = work / Path(target["sdk_url"]).name
+ download(target["sdk_url"], archive, target["sdk_sha256"])
+ with tempfile.TemporaryDirectory(prefix="extract-", dir=work) as temporary:
+ process = subprocess.Popen(["zstd", "-dc", str(archive)], stdout=subprocess.PIPE)
+ try:
+ with tarfile.open(fileobj=process.stdout, mode="r|") as tar:
+ tar.extractall(temporary, filter=sdk_archive_filter(archive.name.removesuffix(".tar.zst")))
+ process.stdout.close()
+ if process.wait() != 0:
+ raise ValueError("SDK decompression failed")
+ finally:
+ process.stdout.close()
+ if process.poll() is None:
+ process.terminate()
+ process.wait()
+ directories = list(Path(temporary).iterdir())
+ if len(directories) != 1 or not (directories[0] / "include/package.mk").is_file():
+ raise ValueError("Unexpected SDK archive layout")
+ directories[0].rename(sdk)
+ # The archive's successful check belongs to its builder, not this host.
+ (sdk / "staging_dir/host/.prereq-build").unlink(missing_ok=True)
+ lines = []
+ for name, url in FEED_URLS.items():
+ option = " --root=package" if name == "base" and target.get("base_feed_subdir") == "package" else ""
+ lines.append(f"src-git{option} {name} {url}^{target['feeds'][name]}")
+ (sdk / "feeds.conf").write_text("\n".join(lines) + "\n")
+ command(["./scripts/feeds", "update", *FEED_URLS], sdk, log)
+ overlay = work / "golang-framework"
+ command(["git", "init", overlay], log=log)
+ command(["git", "fetch", "--depth=1", FEED_URLS["packages"], catalog["golang_framework_commit"]], overlay, log)
+ command(["git", "checkout", "--detach", "FETCH_HEAD"], overlay, log)
+ go_makefile = overlay / f"lang/golang/golang{'.'.join(catalog['go_version'].split('.')[:2])}/Makefile"
+ if catalog["go_source_sha256"] not in go_makefile.read_text():
+ raise ValueError("Go framework source hash does not match targets.json")
+ destination = sdk / "feeds/packages/lang/golang"
+ # This tree was created above from a verified SDK and pinned feeds, and is
+ # owned by this build. Never accept a caller-supplied path for deletion.
+ if not destination.resolve().is_relative_to(work):
+ raise ValueError("Go overlay escapes the build workspace")
+ shutil.rmtree(destination)
+ shutil.copytree(overlay / "lang/golang", destination)
+ command(["./scripts/feeds", "update", "-i", "packages"], sdk, log)
+ go_package = "golang" + ".".join(catalog["go_version"].split(".")[:2])
+ command(["./scripts/feeds", "install", go_package, "luci-base", "luci-compat",
+ "ca-bundle", "uci", "ubus", "procd", "rpcd", "rpcd-mod-iwinfo"], sdk, log)
+ marker.write_text(json.dumps(inputs, indent=2) + "\n")
+ return sdk
+
+
+def copy_source(repo, destination, version):
+ if destination.exists():
+ shutil.rmtree(destination)
+ destination.mkdir(parents=True)
+ files, templates = {}, {}
+ for relative in SOURCE_PATHS:
+ source = repo / relative
+ entries = sorted(source.rglob("*")) if source.is_dir() else [source]
+ for entry in entries:
+ if entry.is_symlink():
+ raise ValueError("Source symlinks are not accepted")
+ if not entry.is_file():
+ continue
+ name = entry.relative_to(repo).as_posix()
+ data = entry.read_bytes().replace(b"\r\n", b"\n")
+ templates[name] = hashlib.sha256(data).hexdigest()
+ if name == "Makefile":
+ if data.count(b"PKG_VERSION:=0.0.0\n") != 1:
+ raise ValueError("Missing Makefile version placeholder")
+ data = data.replace(b"PKG_VERSION:=0.0.0\n", f"PKG_VERSION:={version}\n".encode())
+ output = destination / name
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_bytes(data)
+ files[name] = hashlib.sha256(data).hexdigest()
+ return files, templates
+
+
+def sign_apk(apk, path, private_key, public_key):
+ with tempfile.TemporaryDirectory(prefix="smart-srun-verify-") as temporary:
+ keys = Path(temporary)
+ shutil.copyfile(public_key, keys / "smart-srun.pem")
+ # adbsign 3.0.5 returns zero even when refusing unsigned input. The
+ # input-only exception is confined to signing our SDK output; verify
+ # and package installation always require a trusted signature.
+ output = command([apk, "adbsign", "--allow-untrusted", "--reset-signatures",
+ "--sign-key", private_key, path])
+ if output.strip():
+ raise RuntimeError("APK signer reported an error: " + output)
+ command([apk, "--keys-dir", keys, "verify", path])
+ der = subprocess.check_output(["openssl", "pkey", "-pubin", "-in", str(public_key), "-outform", "DER"])
+ return hashlib.sha256(der).hexdigest()
+
+
+def package_info(path, apk):
+ if path.suffix == ".apk":
+ data = json.loads(command([apk, "adbdump", "--format", "json", path]))
+ info = data["info"]
+ files = {str(Path(directory.get("name", "")) / entry["name"]): entry["size"]
+ for directory in data["paths"] for entry in directory.get("files", [])}
+ return info["name"], info["version"], info["arch"], files
+ with tarfile.open(path) as outer:
+ with tarfile.open(fileobj=io.BytesIO(outer.extractfile("./control.tar.gz").read())) as control:
+ text = control.extractfile("./control").read().decode()
+ fields = dict(line.split(": ", 1) for line in text.splitlines() if ": " in line)
+ with tarfile.open(fileobj=io.BytesIO(outer.extractfile("./data.tar.gz").read())) as data:
+ files = {member.name.removeprefix("./"): member.size for member in data if member.isfile()}
+ return fields["Package"], fields["Version"], fields["Architecture"], files
+
+
+def build(args):
+ if fcntl is None:
+ raise RuntimeError("OpenWrt SDK builds require Linux")
+ catalog, target = load_target(args.targets, args.target)
+ version = package_version(args.version, target["package_manager"])
+ work = (args.work_dir.resolve() / target["id"])
+ if any(char.isspace() for char in str(work)):
+ raise ValueError("OpenWrt SDK work path must not contain whitespace")
+ work.mkdir(parents=True, exist_ok=True)
+ with (work / ".lock").open("w") as lock, (work / "build.log").open("a") as log:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ artifacts = work / "artifacts" / args.version
+ if artifacts.exists():
+ raise ValueError("Artifact directory already exists; never overwrite recorded artifacts")
+ sdk = prepare_sdk(work, catalog, target, log)
+ for name, commit in target["feeds"].items():
+ if command(["git", "rev-parse", "HEAD"], sdk / "feeds" / name).strip() != commit:
+ raise ValueError("Feed checkout changed: " + name)
+ files, templates = copy_source(REPO, sdk / "package/luci-app-smart-srun", version)
+ source_commit = command(["git", "rev-parse", "HEAD"], REPO).strip()
+ dirty = bool(command(["git", "status", "--porcelain"], REPO).strip())
+ epoch = command(["git", "show", "-s", "--format=%ct", "HEAD"], REPO).strip()
+ bootstrap = args.bootstrap.resolve()
+ if not (bootstrap / "bin/go").is_file():
+ raise ValueError("An external Go bootstrap toolchain is required")
+ if not re.fullmatch(r"[/a-zA-Z0-9_.-]+", str(bootstrap)):
+ raise ValueError("Unsafe bootstrap path")
+ board, subtarget = target["target"].split("/")
+ config = [f"CONFIG_TARGET_{board}=y", f"CONFIG_TARGET_{board}_{subtarget}=y",
+ "CONFIG_DEVEL=y", "# CONFIG_ALL is not set", "# CONFIG_ALL_KMODS is not set",
+ "# CONFIG_ALL_NONSHARED is not set", "# CONFIG_GOLANG_BUILD_BOOTSTRAP is not set",
+ f'CONFIG_GOLANG_EXTERNAL_BOOTSTRAP_ROOT="{bootstrap}"']
+ config += [f"CONFIG_PACKAGE_{package}=m" for package in PACKAGES]
+ (sdk / ".config").write_text("\n".join(config) + "\n")
+ output = command(["make", "defconfig"], sdk)
+ log.write(output)
+ log.flush()
+ if "recursive dependency detected" in output or re.search(r"(?:^|\n).*:error:", output):
+ raise ValueError("SDK configuration failed even though make returned zero")
+ env = dict(os.environ, SOURCE_DATE_EPOCH=epoch, GOTOOLCHAIN="local")
+ command(["make", "package/luci-app-smart-srun/clean", "V=s"], sdk, log, env)
+ command(["make", "package/luci-app-smart-srun/compile", f"-j{args.jobs}", "V=s",
+ "SMARTSRUN_DISPLAY_VERSION=" + args.version, "SOURCE_DATE_EPOCH=" + epoch], sdk, log, env)
+ go_root = sdk / "staging_dir/hostpkg/lib" / ("go-" + ".".join(catalog["go_version"].split(".")[:2]))
+ go_actual = command([go_root / "bin/go", "version"], env=env).strip()
+ if f"go{catalog['go_version']} " not in go_actual:
+ raise ValueError("SDK used a different Go compiler: " + go_actual)
+ # A failed collection has no completed record. Keep its temporary
+ # output for diagnosis, but reserve the immutable version name only
+ # after every package passed validation.
+ artifacts.parent.mkdir(parents=True, exist_ok=True)
+ stage = Path(tempfile.mkdtemp(prefix=".collect-", dir=artifacts.parent))
+ apk = sdk / "staging_dir/host/bin/apk"
+ result = []
+ for package in PACKAGES:
+ pattern = f"{package}_{version}-*.ipk" if target["format"] == "ipk" else f"{package}-{version}-*.apk"
+ candidates = list((sdk / "bin").rglob(pattern))
+ if len(candidates) != 1:
+ raise ValueError("Expected exactly one SDK artifact for " + package)
+ path = stage / candidates[0].name
+ shutil.copyfile(candidates[0], path)
+ fingerprint = None
+ if path.suffix == ".apk" and args.sign_key:
+ fingerprint = sign_apk(apk, path, args.sign_key, args.public_key)
+ name, native_version, arch, payload = package_info(path, apk)
+ expected_arch = ("all" if target["format"] == "ipk" else "noarch") if package == "luci-app-smart-srun" else target["openwrt_arch"]
+ if name != package or arch != expected_arch:
+ raise ValueError("Package name or architecture mismatch")
+ validate_payload(package, payload, catalog["development_payload_limit_bytes"])
+ result.append(dict(file=path.name, name=name, package_version=native_version,
+ architecture=arch, bytes=path.stat().st_size, sha256=digest(path),
+ installed_payload_bytes=sum(payload.values()), files=payload,
+ signature_spki_sha256=fingerprint, validation={"build": True}))
+ record = dict(schema_version=1, display_version=args.version, source_commit=source_commit,
+ source_dirty=dirty, source_files=files, source_template_files=templates,
+ source_date_epoch=int(epoch),
+ target=target, go_version=catalog["go_version"], go_compiler=go_actual,
+ golang_framework_commit=catalog["golang_framework_commit"], artifacts=result)
+ (stage / "build-record.json").write_text(json.dumps(record, indent=2) + "\n")
+ (stage / "SHA256SUMS").write_text("".join(f"{asset['sha256']} {asset['file']}\n" for asset in result))
+ stage.rename(artifacts)
+ print(artifacts)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--targets", type=Path, default=REPO / "targets.json")
+ parser.add_argument("--target", required=True)
+ parser.add_argument("--version", required=True)
+ parser.add_argument("--work-dir", type=Path, required=True)
+ parser.add_argument("--bootstrap", type=Path, required=True, help="Existing Go toolchain root")
+ parser.add_argument("--jobs", type=int, choices=range(1, 33), default=2)
+ parser.add_argument("--sign-key", type=Path)
+ parser.add_argument("--public-key", type=Path)
+ args = parser.parse_args()
+ if bool(args.sign_key) != bool(args.public_key):
+ parser.error("APK signing requires both --sign-key and --public-key")
+ build(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/smart-srun/scripts/check_docs.py b/smart-srun/scripts/check_docs.py
new file mode 100644
index 000000000..90e46f356
--- /dev/null
+++ b/smart-srun/scripts/check_docs.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Check local Markdown links and review-context targets without network access."""
+import argparse
+import os
+from pathlib import Path
+import re
+import sys
+from urllib.parse import unquote, urlsplit
+
+from doc_context import CONFIG, ROOT, git, load_rules
+
+EXCLUDED = {".git", ".codex", ".claude", ".worktrees", ".cache", ".pytest_cache",
+ ".ruff_cache", "__pycache__", "node_modules", ".venv"}
+INLINE_LINK = re.compile(r"!?\[[^\]\n]*\]\(\s*(?:<([^>\n]+)>|((?:\\.|[^()\s]|\([^()\n]*\))+))(?:\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*\)")
+REFERENCE_LINK = re.compile(r"^\s{0,3}\[[^\]\n]+\]:\s*(?:<([^>\n]+)>|(\S+))", re.MULTILINE)
+HTML_LINK = re.compile(r"\b(?:href|src)\s*=\s*['\"]([^'\"]+)['\"]", re.IGNORECASE)
+
+
+def markdown_paths(root):
+ files = git(root, "ls-files", "--cached", "--others", "--exclude-standard", "-z")
+ return sorted({Path(os.fsdecode(name)) for name in files.split(b"\0") if name
+ and Path(os.fsdecode(name)).suffix.lower() == ".md"
+ and not (set(Path(os.fsdecode(name)).parts) & EXCLUDED)
+ and (root / os.fsdecode(name)).is_file()})
+
+
+def prose(text):
+ """Ignore examples in fenced and inline code, preserving source line numbers."""
+ lines = []
+ fence = None
+ for line in text.splitlines(keepends=True):
+ marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line.rstrip("\r\n"))
+ if fence:
+ if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence) and not marker[2].strip():
+ fence = None
+ lines.append("\n" if line.endswith("\n") else "")
+ elif marker:
+ fence = marker[1]
+ lines.append("\n" if line.endswith("\n") else "")
+ else:
+ lines.append(re.sub(r"(`+).*?\1", "", line))
+ return "".join(lines)
+
+
+def link_error(root, source, target):
+ target = re.sub(r"\\([\\`*{}\[\]()#+\-.!_> ])", r"\1", target)
+ parsed = urlsplit(target)
+ if parsed.scheme or parsed.netloc or not parsed.path or target.startswith("/"):
+ return None
+ destination = (root / source.parent / unquote(parsed.path)).resolve()
+ if not destination.is_relative_to(root.resolve()):
+ return f"relative link leaves repository: {target}"
+ if not destination.exists():
+ return f"missing relative link target: {target}"
+ return None
+
+
+def check_markdown(root, source):
+ text = prose((root / source).read_text(encoding="utf-8"))
+ links = []
+ for pattern in (INLINE_LINK, REFERENCE_LINK):
+ links.extend((match.start(), match[1] or match[2]) for match in pattern.finditer(text))
+ links.extend((match.start(), match[1]) for match in HTML_LINK.finditer(text))
+ if source.name == "CLAUDE.md":
+ links.extend((match.start(), match[1]) for match in re.finditer(r"^@([^\s]+)\s*$", text, re.MULTILINE))
+ errors = []
+ for position, target in links:
+ error = link_error(root, source, target)
+ if error:
+ line = text.count("\n", 0, position) + 1
+ errors.append(f"{source.as_posix()}:{line}: {error}")
+ return errors
+
+
+def check_rules(root):
+ errors = []
+ for rule in load_rules(root):
+ targets = [(path, True) for key in ("docs", "skills") for path in rule.get(key, [])]
+ targets.extend((path, False) for check in rule.get("checks", []) for path in check["targets"])
+ for path, document in targets:
+ target = (root / path).resolve()
+ if not target.is_relative_to(root.resolve()) or not (target.is_file() if document else target.exists()):
+ errors.append(f"{CONFIG}: {rule['id']}: missing or nonlocal target: {path}")
+ return errors
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, default=ROOT)
+ args = parser.parse_args(argv)
+ try:
+ paths = markdown_paths(args.root)
+ errors = [error for path in paths for error in check_markdown(args.root, path)]
+ errors.extend(check_rules(args.root))
+ if errors:
+ print("\n".join(errors), file=sys.stderr)
+ return 1
+ print(f"Documentation: {len(paths)} Markdown files and review-context targets checked (offline).")
+ return 0
+ except (OSError, ValueError) as error:
+ print(f"check-docs: {error}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/smart-srun/scripts/check_go_source.py b/smart-srun/scripts/check_go_source.py
new file mode 100644
index 000000000..66135a368
--- /dev/null
+++ b/smart-srun/scripts/check_go_source.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+"""Reject legacy device runtimes and accidentally tracked private/build files."""
+from pathlib import Path
+import subprocess
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def inspect(paths):
+ problems = []
+ for name in paths:
+ path = Path(name)
+ if name.startswith(('root/usr/lib/smart_srun/', '.codex/', '.claude/', '.worktrees/')):
+ problems.append(name)
+ elif name.startswith('root/') and path.suffix == '.py':
+ problems.append(name)
+ elif name in ('root/usr/bin/srunnet', 'root/etc/smart-srun/config.json',
+ 'root/etc/smart-srun/user-presets.json'):
+ problems.append(name)
+ elif any(part in ('__pycache__', '.pytest_cache', '.venv', 'node_modules') for part in path.parts):
+ problems.append(name)
+ elif path.suffix.lower() in ('.ipk', '.apk', '.har', '.key', '.pyc') or name.endswith(('coverage.out', '.verify-go-test.log')):
+ problems.append(name)
+ return sorted(set(problems))
+
+
+def main():
+ paths = subprocess.check_output(['git', 'ls-files', '-z'], cwd=ROOT).decode().split('\0')
+ problems = inspect(paths)
+ if problems:
+ raise SystemExit('Unshippable or private tracked files:\n'+'\n'.join(problems))
+ makefile = (ROOT/'Makefile').read_text()
+ for line in makefile.splitlines():
+ if 'DEPENDS' in line and any(word in line.lower() for word in ('python', 'nodejs')):
+ raise SystemExit('Device dependency unexpectedly requires a host runtime')
+ print('Go source layout: no legacy runtime, local credentials or build artifacts tracked')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/smart-srun/scripts/ci_go_release.py b/smart-srun/scripts/ci_go_release.py
new file mode 100644
index 000000000..eac5344d9
--- /dev/null
+++ b/smart-srun/scripts/ci_go_release.py
@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+"""GitHub Actions orchestration for the pinned SDK builder and release exporter.
+
+Python is a build-host tool. None of this file, the tests, or signing keys are
+installed on a router. Publication consumes these exact verified packages.
+"""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tarfile
+from types import SimpleNamespace
+import zipfile
+
+import build_go_sdk as sdk
+import make_go_manifest as manifest
+import verify_go_sdk as verifier
+
+ROOT = Path(__file__).resolve().parents[1]
+QEMU = {"amd64": None, "arm64": "qemu-aarch64", "arm": "qemu-arm",
+ "mips": "qemu-mips", "mipsle": "qemu-mipsel"}
+
+
+def run(args, **kwargs):
+ return subprocess.check_output([str(a) for a in args], text=True, **kwargs).strip()
+
+
+def catalog():
+ return json.loads((ROOT / "targets.json").read_text())
+
+
+def validate_version(version, channel=None):
+ sdk.package_version(version, "opkg")
+ if int(version.split('.')[0]) < 2:
+ raise ValueError("Go releases start at version 2")
+ if channel and ("rc" in version) != (channel == "rc"):
+ raise ValueError("Version must match the selected release channel")
+ return version
+
+
+def metadata(args):
+ validate_version(args.version, args.channel)
+ data = catalog()
+ selected = data['targets']
+ if args.matrix == 'smoke':
+ selected = [t for t in selected if t['openwrt_arch'] == 'x86_64']
+ values = {'matrix': json.dumps({'target': [t['id'] for t in selected]}),
+ 'go_version': data['go_version'], 'version': args.version}
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
+ for key, value in values.items():
+ output.write(key + '=' + value + '\n')
+
+
+def export_apk_tool(apk, output, keys):
+ # Relocatable OpenWrt SDKs wrap bin/apk in a shell script which refers to
+ # ../lib and .apk.bin. The wrapper alone cannot run in the assembly job.
+ # Both jobs use the same Ubuntu image; export the real host ELF and prove
+ # that its verification and metadata commands work outside the SDK tree.
+ binary = apk
+ if binary.read_bytes()[:4] != b'\x7fELF':
+ binary = apk.with_name('.apk.bin')
+ if not binary.is_file() or binary.read_bytes()[:4] != b'\x7fELF':
+ raise ValueError('SDK APK host tool is missing its native executable')
+ exported = output / 'apk-tools'
+ shutil.copyfile(binary, exported)
+ exported.chmod(0o755)
+ env = dict(os.environ)
+ for key in ('LD_LIBRARY_PATH', 'LD_PRELOAD', 'RUNAS_ARG0'):
+ env.pop(key, None)
+ for package in sorted(output.glob('*.apk')):
+ run([exported, '--keys-dir', keys, 'verify', package], cwd=output, env=env, timeout=30)
+ run([exported, 'adbdump', '--format', 'json', package], cwd=output, env=env, timeout=30)
+
+
+def build(args):
+ validate_version(args.version)
+ _, target = sdk.load_target(ROOT / 'targets.json', args.target)
+ # Do not leave the secret in the environment inherited by SDK processes.
+ private_pem = os.environ.pop('SMARTSRUN_APK_SIGNING_KEY', '')
+ public_pem = os.environ.pop('SMARTSRUN_APK_PUBLIC_KEY', '')
+ work = args.work.resolve()
+ output = args.output.resolve()
+ output.mkdir(parents=True, exist_ok=False)
+ private = work / 'signing' / 'private.pem'
+ public = work / 'trusted' / 'smart-srun.pem'
+ signed = target['format'] == 'apk'
+ try:
+ if signed:
+ private.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
+ public.parent.mkdir(parents=True, exist_ok=True)
+ if args.official:
+ if not private_pem or not public_pem:
+ raise ValueError("Release signing requires SMARTSRUN_APK_SIGNING_KEY secret and SMARTSRUN_APK_PUBLIC_KEY variable in the release-signing environment")
+ private.touch(mode=0o600)
+ private.chmod(0o600)
+ private.write_text(private_pem)
+ public.write_text(public_pem)
+ else:
+ # Isolated CI keys cannot become release keys. Only public
+ # keys leave this job; unsigned installation is never used.
+ run(['openssl', 'ecparam', '-name', 'prime256v1', '-genkey', '-noout', '-out', private])
+ private.chmod(0o600)
+ public.write_text(run(['openssl', 'pkey', '-in', private, '-pubout']) + '\n')
+ actual = subprocess.check_output(['openssl', 'pkey', '-in', str(private), '-pubout', '-outform', 'DER'])
+ expected = subprocess.check_output(['openssl', 'pkey', '-pubin', '-in', str(public), '-outform', 'DER'])
+ if actual != expected:
+ raise ValueError("Release public key does not match the signing key")
+ del private_pem
+ sdk.build(SimpleNamespace(targets=ROOT/'targets.json', target=args.target, version=args.version,
+ work_dir=work/'sdk', bootstrap=Path(run(['go', 'env', 'GOROOT'])),
+ jobs=2, sign_key=private if signed else None, public_key=public if signed else None))
+ target_work = work / 'sdk' / args.target
+ artifacts = target_work / 'artifacts' / args.version
+ apk = target_work / 'sdk' / 'staging_dir/host/bin/apk'
+ emulator = QEMU[target['goarch']]
+ qemu = shutil.which(emulator) if emulator else None
+ if emulator and not qemu:
+ raise ValueError('Missing version-smoke emulator: ' + emulator)
+ evidence, inspection = verifier.verify(artifacts/'build-record.json', Path(shutil.which('go')),
+ apk if signed else None, public.parent if signed else None,
+ target['goarch'] == 'amd64', Path(qemu) if qemu else None)
+ for path in artifacts.iterdir():
+ if not path.is_file() or path.is_symlink():
+ raise ValueError('Unexpected SDK artifact')
+ shutil.copyfile(path, output/path.name)
+ (output/'validation.json').write_text(json.dumps(evidence, indent=2)+'\n')
+ (output/'inspection.json').write_text(json.dumps(inspection, indent=2)+'\n')
+ if signed:
+ shutil.copyfile(public, output/'apk-public.pem')
+ # The exporter compares noarch APK metadata with the same native
+ # verifier. This host tool remains an internal workflow artifact.
+ if target['goarch'] == 'amd64':
+ export_apk_tool(apk, output, public.parent)
+ (output/'ci.json').write_text(json.dumps({'official_signing': args.official,
+ 'run_id': os.environ.get('GITHUB_RUN_ID'), 'repository': os.environ.get('GITHUB_REPOSITORY')})+'\n')
+ finally:
+ private.unlink(missing_ok=True)
+
+
+def assemble(args):
+ records = sorted(args.input.rglob('build-record.json'))
+ required = {t['id'] for t in catalog()['targets']}
+ observed, checks, keys, fingerprints = set(), {'schema_version': 1, 'assets': {}}, [], set()
+ expected_source = run(['git', 'rev-parse', 'HEAD'], cwd=ROOT)
+ for path in records:
+ record = json.loads(path.read_text())
+ target_id = record['target']['id']
+ if target_id in observed or target_id not in required:
+ raise ValueError('Duplicate or unexpected SDK target')
+ observed.add(target_id)
+ if record['source_commit'] != expected_source or record['source_dirty']:
+ raise ValueError('Candidate does not come from this clean source commit')
+ if record['display_version'] != args.version:
+ raise ValueError('Candidate version mismatch')
+ provenance = json.loads((path.parent/'ci.json').read_text())
+ if provenance['official_signing'] != args.official:
+ raise ValueError('Preview artifacts cannot be promoted to a release')
+ if provenance['repository'] != os.environ.get('GITHUB_REPOSITORY') or provenance['run_id'] != os.environ.get('GITHUB_RUN_ID'):
+ raise ValueError('Artifacts must come from this same workflow run')
+ evidence = json.loads((path.parent/'validation.json').read_text())
+ merge_validation(checks, evidence)
+ if record['target']['format'] == 'apk':
+ keys.append(path.parent/'apk-public.pem')
+ fingerprints.update(a['signature_spki_sha256'] for a in record['artifacts'])
+ if observed != required:
+ raise ValueError('Release candidate requires the complete pinned SDK matrix')
+ if args.official and (os.environ.get('GITHUB_REPOSITORY') != manifest.REPOSITORY or len(fingerprints) != 1):
+ raise ValueError('Official release requires the upstream repository and one maintainer signing key')
+ trust = args.input / 'trusted-public-keys'
+ trust.mkdir(exist_ok=False)
+ for index, key in enumerate(keys):
+ shutil.copyfile(key, trust/f'{index}.pem')
+ tools = sorted(args.input.rglob('apk-tools'))
+ if len(tools) != 1:
+ raise ValueError('Expected one verified-build APK host tool')
+ tools[0].chmod(0o755)
+ release = manifest.export_release(records, args.output, checks, apk=tools[0], keys=trust)
+ add_release_extras(release, records, keys, fingerprints, args)
+
+
+def merge_validation(checks, evidence):
+ if evidence.get('schema_version') != 1 or not isinstance(evidence.get('assets'), dict):
+ raise ValueError('Invalid validation evidence')
+ # Validate exact-digest installation reports before forwarding them.
+ manifest.firmware_compatibility(evidence)
+ for digest, flags in evidence['assets'].items():
+ if digest in checks['assets'] and checks['assets'][digest] != flags:
+ raise ValueError('Conflicting validation evidence')
+ checks['assets'][digest] = flags
+ compatibility = checks.setdefault('firmware_compatibility', {})
+ for digest, families in evidence.get('firmware_compatibility', {}).items():
+ reports = compatibility.setdefault(digest, {})
+ for family, report in families.items():
+ if family in reports and reports[family] != report:
+ raise ValueError('Conflicting firmware compatibility evidence')
+ reports[family] = dict(report)
+
+
+def add_release_extras(release, records, keys, fingerprints, args):
+ output = args.output
+ assets = release['assets']
+ # Each zip is an exact core + architecture-neutral LuCI combination for
+ # the same package manager and SDK. It is not a second source of metadata.
+ for core in [a for a in assets if a['kind'] == 'core']:
+ matches = [a for a in assets if a['kind'] == 'luci' and a['package_manager'] == core['package_manager']
+ and a['sdk_release'] == core['sdk_release']]
+ if len(matches) != 1:
+ raise ValueError('Missing unambiguous matching LuCI package')
+ name = f"smart-srun-split-{release['release']}-{core['openwrt_arch']}-{core['package_manager']}-{core['sdk_release']}.zip"
+ with zipfile.ZipFile(output/name, 'x', compression=zipfile.ZIP_STORED) as archive:
+ for asset in [core, matches[0]]:
+ file = asset['url'].rsplit('/', 1)[1]
+ archive.write(output/file, arcname=file)
+ with tarfile.open(output/'build-records.tar.gz', 'x:gz') as archive:
+ for path in records:
+ target = json.loads(path.read_text())['target']['id']
+ for filename in ['build-record.json', 'validation.json', 'inspection.json', 'ci.json']:
+ archive.add(path.parent/filename, arcname=target+'/'+filename)
+ if args.official:
+ shutil.copyfile(keys[0], output/'smart-srun-apk.pem')
+ template = ROOT/'.github'/'release-template.md'
+ text = template.read_text(encoding='utf-8')
+ replacements = {'VERSION': release['release'], 'SOURCE_COMMIT': release['source_commit'],
+ 'APK_FINGERPRINT': ', '.join(sorted(fingerprints)),
+ 'ASSET_COUNT': str(len(assets))}
+ for key, value in replacements.items():
+ text = text.replace('${'+key+'}', value)
+ if '${' in text:
+ raise ValueError('Unresolved release template fields')
+ (output/'release-notes.md').write_text(text, encoding='utf-8')
+ # Include manifest, keys, provenance and split archives as well as packages.
+ sums = ''.join(hashlib.sha256(p.read_bytes()).hexdigest()+' '+p.name+'\n'
+ for p in sorted(output.iterdir()) if p.name != 'SHA256SUMS')
+ (output/'SHA256SUMS').write_text(sums, encoding='utf-8')
+
+
+def verify_published(args):
+ """Publication is incomplete until GitHub retains every expected name/byte."""
+ candidate = args.candidate
+ expected = {p.name: (p.stat().st_size, 'sha256:' + hashlib.sha256(p.read_bytes()).hexdigest())
+ for p in candidate.iterdir() if p.is_file()}
+ release = json.loads((candidate / 'release-manifest.json').read_text())
+ published = json.loads(args.metadata.read_text())
+ if (published.get('draft') is not True
+ or published.get('prerelease') != (release['channel'] == 'rc')
+ or published.get('tag_name') != release['release']
+ or published.get('target_commitish') != release['source_commit']):
+ raise ValueError('Published draft identity differs from the verified candidate')
+ assets = published.get('assets', [])
+ actual = {a['name']: (a['size'], a.get('digest')) for a in assets}
+ if len(actual) != len(assets) or actual != expected:
+ raise ValueError('Published asset names, sizes or SHA256 digests differ from the candidate')
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest='command', required=True)
+ meta = sub.add_parser('metadata')
+ meta.add_argument('--matrix', choices=['all', 'smoke'], default='all')
+ meta.add_argument('--channel', choices=['rc', 'stable'])
+ builder = sub.add_parser('build')
+ builder.add_argument('--target', required=True)
+ builder.add_argument('--work', type=Path, required=True)
+ builder.add_argument('--output', type=Path, required=True)
+ exporter = sub.add_parser('assemble')
+ exporter.add_argument('--input', type=Path, required=True)
+ exporter.add_argument('--output', type=Path, required=True)
+ published = sub.add_parser('verify-published')
+ published.add_argument('--candidate', type=Path, required=True)
+ published.add_argument('--metadata', type=Path, required=True)
+ for command in [meta, builder, exporter]:
+ command.add_argument('--version', required=True)
+ for command in [builder, exporter]:
+ command.add_argument('--official', action='store_true')
+ args = parser.parse_args()
+ {'metadata': metadata, 'build': build, 'assemble': assemble,
+ 'verify-published': verify_published}[args.command](args)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/smart-srun/scripts/doc_context.py b/smart-srun/scripts/doc_context.py
new file mode 100644
index 000000000..0a5440571
--- /dev/null
+++ b/smart-srun/scripts/doc_context.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Print local guidance and suggested checks selected by changed paths."""
+import argparse
+import fnmatch
+import json
+import os
+from pathlib import Path, PurePosixPath
+import subprocess
+import sys
+
+ROOT = Path(__file__).resolve().parents[1]
+CONFIG = ".github/review-context.json"
+
+
+def git(root, *args):
+ result = subprocess.run(["git", "-C", str(root), *args], capture_output=True)
+ if result.returncode:
+ raise ValueError(result.stderr.decode(errors="replace").strip() or "Git command failed")
+ return result.stdout
+
+
+def local_path(value):
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError("Expected a nonempty repository-relative path")
+ value = value.replace("\\", "/")
+ path = PurePosixPath(value)
+ if path.is_absolute() or ".." in path.parts or ":" in value:
+ raise ValueError(f"Not a repository-relative path: {value}")
+ return path.as_posix()
+
+
+def string_list(value, label):
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
+ raise ValueError(f"{label} must be a list of nonempty strings")
+ return value
+
+
+def load_rules(root):
+ data = json.loads((root / CONFIG).read_text(encoding="utf-8"))
+ if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("rules"), list):
+ raise ValueError(f"{CONFIG}: expected version 1 and a rules list")
+ seen = set()
+ for rule in data["rules"]:
+ if not isinstance(rule, dict) or set(rule) - {"id", "any", "all", "docs", "skills", "checks"}:
+ raise ValueError(f"{CONFIG}: invalid rule fields")
+ name = rule.get("id")
+ if not isinstance(name, str) or not name.strip() or name in seen:
+ raise ValueError(f"{CONFIG}: rule IDs must be nonempty and unique")
+ seen.add(name)
+ for key in ("any", "all", "docs", "skills"):
+ for value in string_list(rule.get(key, []), f"{name}.{key}"):
+ local_path(value)
+ if not rule.get("any") and not rule.get("all"):
+ raise ValueError(f"{name}: at least one any/all glob is required")
+ checks = rule.get("checks", [])
+ if not isinstance(checks, list):
+ raise ValueError(f"{name}.checks must be a list")
+ for check in checks:
+ if not isinstance(check, dict) or set(check) != {"command", "targets"}:
+ raise ValueError(f"{name}: each check needs command and targets")
+ if not isinstance(check["command"], str) or not check["command"].strip():
+ raise ValueError(f"{name}: empty check command")
+ if not string_list(check["targets"], f"{name}.targets"):
+ raise ValueError(f"{name}: check targets cannot be empty")
+ for target in check["targets"]:
+ local_path(target)
+ return data["rules"]
+
+
+def diff_paths(data):
+ """Git -z emits separate status/path tokens; renames include both paths."""
+ tokens = data.split(b"\0")
+ paths = set()
+ index = 0
+ while index < len(tokens) and tokens[index]:
+ status = tokens[index]
+ count = 2 if status[:1] in (b"R", b"C") else 1
+ index += 1
+ if index + count > len(tokens) or any(not value for value in tokens[index:index + count]):
+ raise ValueError("Incomplete Git name-status output")
+ paths.update(os.fsdecode(value) for value in tokens[index:index + count])
+ index += count
+ return paths
+
+
+def changed_paths(root, base=None):
+ paths = set()
+ if base is not None:
+ # Resolve an argument as a revision, never as a Git option or shell text.
+ commit = git(root, "rev-parse", "--verify", "--end-of-options", base + "^{commit}").decode().strip()
+ ancestor = git(root, "merge-base", commit, "HEAD").decode().strip()
+ paths.update(diff_paths(git(root, "diff", "--name-status", "-z", "--find-renames", ancestor, "HEAD", "--")))
+ for args in (("--cached",), ()):
+ paths.update(diff_paths(git(root, "diff", "--name-status", "-z", "--find-renames", *args, "--")))
+ paths.update(os.fsdecode(value) for value in git(root, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0") if value)
+ return sorted(paths)
+
+
+def matching_rules(rules, paths):
+ def hit(pattern):
+ return any(fnmatch.fnmatchcase(path, pattern) for path in paths)
+
+ return [rule for rule in rules
+ if (not rule.get("any") or any(hit(pattern) for pattern in rule["any"]))
+ and all(hit(pattern) for pattern in rule.get("all", []))]
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, default=ROOT)
+ source = parser.add_mutually_exclusive_group()
+ source.add_argument("--files", nargs="+", help="Explicit repository-relative paths, including deleted paths")
+ source.add_argument("--base", help="Compare merge-base(REF, HEAD)..HEAD and include local changes")
+ args = parser.parse_args(argv)
+ try:
+ rules = load_rules(args.root)
+ paths = sorted({local_path(path) for path in args.files}) if args.files else changed_paths(args.root, args.base)
+ if not paths:
+ print("No changed files; no additional guidance selected.")
+ return 0
+ print("Changed paths:")
+ for path in paths:
+ print(" " + path)
+ selected = matching_rules(rules, paths)
+ if not selected:
+ print("No matching rules; start with AGENTS.md.")
+ return 0
+ print("\nMatching rules: " + ", ".join(rule["id"] for rule in selected))
+ for label, key in (("Read local guidance", "docs"), ("Load skills when needed", "skills")):
+ values = dict.fromkeys(value for rule in selected for value in rule.get(key, []))
+ if values:
+ print("\n" + label + ":")
+ for value in values:
+ print(" " + value)
+ print("\nSuggested checks (printed only; not executed):")
+ for command in dict.fromkeys(check["command"] for rule in selected for check in rule.get("checks", [])):
+ print(" " + command)
+ return 0
+ except (OSError, ValueError) as error:
+ print(f"doc-context: {error}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/smart-srun/scripts/hot_update.py b/smart-srun/scripts/hot_update.py
new file mode 100644
index 000000000..1e3866104
--- /dev/null
+++ b/smart-srun/scripts/hot_update.py
@@ -0,0 +1,6 @@
+#!/usr/bin/env python3
+"""Deploy verified Go SDK packages through the independent update worker."""
+from hot_update_go import main
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/smart-srun/scripts/hot_update_go.py b/smart-srun/scripts/hot_update_go.py
new file mode 100644
index 000000000..e8eefb4d7
--- /dev/null
+++ b/smart-srun/scripts/hot_update_go.py
@@ -0,0 +1,220 @@
+#!/usr/bin/env python3
+"""Deploy measured Go SDK packages through the device's independent worker.
+
+Python and OpenSSH run on the development host only. SSH configuration provides
+authentication and host-key trust; this tool never starts network authentication.
+"""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shlex
+import subprocess
+import sys
+import time
+from urllib.parse import urlsplit
+
+MAX_MANIFEST = 256 * 1024
+MAX_PACKAGE = 16 * 1024**2
+# The installed payload budget, read from the file that owns it so the uploader
+# and the device agree. The device enforces update.MaxPayloadBytes; a stale copy
+# here would refuse a package the router would have accepted.
+MAX_PAYLOAD = json.loads(
+ (Path(__file__).resolve().parents[1] / "targets.json").read_text(encoding="utf-8")
+)["development_payload_limit_bytes"]
+NAMES = {"core": "smart-srun", "luci": "luci-app-smart-srun", "bundle": "luci-app-smart-srun-bundle"}
+STATUS_COMMAND = "if [ -x /var/run/smart-srun/update-worker ]; then /var/run/smart-srun/update-worker update status; else /usr/bin/srunnet update status; fi"
+
+
+def unique_object(pairs):
+ result = {}
+ for key, value in pairs:
+ if key in result:
+ raise ValueError("Duplicate JSON field: " + key)
+ result[key] = value
+ return result
+
+
+def load_manifest(path, directory):
+ path = Path(path)
+ if path.stat().st_size > MAX_MANIFEST:
+ raise ValueError("Manifest exceeds size limit")
+ data = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
+ if data.get("schema_version") != 1 or not isinstance(data.get("assets"), list) or not 0 < len(data["assets"]) <= 256:
+ raise ValueError("Invalid release manifest")
+ root = Path(directory).resolve()
+ inputs, ids = [], set()
+ for asset in data["assets"]:
+ identifier, fmt = asset["id"], asset["format"]
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}", identifier) or identifier in ids:
+ raise ValueError("Invalid or duplicate asset ID")
+ ids.add(identifier)
+ if (asset["package_manager"], fmt) not in (("opkg", "ipk"), ("apk", "apk")) or asset["kind"] not in NAMES:
+ raise ValueError("Invalid package kind or format")
+ url = urlsplit(asset["url"])
+ prefix = "/matthewlu070111/smart-srun/releases/download/" + data["release"] + "/"
+ filename = url.path.removeprefix(prefix)
+ if url.scheme != "https" or url.netloc != "github.com" or url.query or url.fragment or not url.path.startswith(prefix):
+ raise ValueError("Package URL is not an official release asset")
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.~+-]{0,199}", filename) or not filename.endswith("." + fmt):
+ raise ValueError("Unsafe package filename")
+ # Manifests may cover several targets; verify the packages that are
+ # actually present, then require every selected package before upload.
+ file = root / filename
+ if file.exists() or file.is_symlink():
+ verify_local(file, asset)
+ inputs.append((asset, file))
+ return data, inputs
+
+
+def verify_local(file, asset):
+ if file.is_symlink() or not file.is_file() or file.stat().st_size != asset["bytes"] or not 0 < asset["bytes"] <= MAX_PACKAGE:
+ raise ValueError("Missing, oversized or changed package: " + file.name)
+ with file.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ if digest != asset["sha256"]:
+ raise ValueError("Package SHA256 mismatch: " + file.name)
+
+
+def select(inputs, inventory, recovery=False):
+ packages = inventory["packages"]
+ names = set(packages)
+ modes = {frozenset([NAMES["core"]]): ["core"], frozenset([NAMES["core"], NAMES["luci"]]): ["core", "luci"],
+ frozenset([NAMES["bundle"]]): ["bundle"]}
+ if frozenset(names) not in modes or len(set(packages.values())) != 1:
+ raise ValueError("Installed package set is incomplete or inconsistent")
+ chosen = []
+ for kind in modes[frozenset(names)]:
+ arch = inventory["architecture"] if kind != "luci" else ("all" if inventory["package_manager"] == "opkg" else "noarch")
+ matches = [(a, p) for a, p in inputs if a["kind"] == kind and a["package_manager"] == inventory["package_manager"]
+ and a["openwrt_arch"] == arch and inventory["firmware_family"] in a["firmware_compat"]
+ and a["validation"].get("build") is True and (kind == "luci" or a["validation"].get("elf") is True)]
+ if len(matches) != 1:
+ raise ValueError("No unique compatible validated package for " + kind)
+ asset, path = matches[0]
+ if recovery and asset["package_version"] != packages[NAMES[kind]]:
+ raise ValueError("Recovery package is not the exact installed version")
+ verify_local(path, asset)
+ chosen.append((asset, path))
+ if len({a["package_version"] for a, _ in chosen}) != 1 or sum(a["installed_bytes"] for a, _ in chosen) > MAX_PAYLOAD:
+ raise ValueError(f"Split version mismatch or installed payload exceeds {MAX_PAYLOAD} bytes")
+ return chosen
+
+
+class SSH:
+ def __init__(self, args):
+ if not args.host or args.host.startswith("-") or re.search(r"[\s\x00-\x1f]", args.host):
+ raise ValueError("Provide a valid SSH host or configured alias with --host")
+ self.argv = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15"]
+ if args.ssh_config:
+ self.argv += ["-F", str(Path(args.ssh_config).resolve())]
+ if args.port:
+ self.argv += ["-p", str(args.port)]
+ self.argv.append(args.host)
+
+ def run(self, command, data=None):
+ result = subprocess.run(self.argv + [command], input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
+ if result.returncode:
+ # Fixed commands never read configuration or account credentials.
+ raise RuntimeError("Remote operation failed: " + result.stderr.decode("utf-8", "replace")[-2000:])
+ if len(result.stdout) > 256 * 1024:
+ raise RuntimeError("Remote response exceeds size limit")
+ return result.stdout
+
+ def json(self, command, data=None):
+ return json.loads(self.run(command, data), object_pairs_hook=unique_object)
+
+ def upload(self, folder, asset, path):
+ verify_local(path, asset)
+ directory = "/var/run/smart-srun/update-inbox/" + folder
+ destination = directory + "/" + asset["id"] + "." + asset["format"]
+ # Private, exclusive staging prevents a stale symlink being followed.
+ # Only this exact staging file is cleaned up on exit, including errors.
+ command = "umask 077; mkdir -p " + directory + "; chmod 0700 /var/run/smart-srun/update-inbox " + directory + "; "
+ command += "stage=$(mktemp " + directory + "/.upload-XXXXXX) || exit 1; trap 'rm -f -- \"$stage\"' EXIT; "
+ command += 'cat > "$stage" && chmod 0600 "$stage" && mv -f -- "$stage" ' + shlex.quote(destination)
+ self.run(command, path.read_bytes())
+ digest = self.run("sha256sum " + shlex.quote(destination)).split()[0].decode("ascii")
+ if digest != asset["sha256"]:
+ raise ValueError("Uploaded package checksum mismatch")
+
+
+def deploy(args, ssh, manifests):
+ new, old = manifests
+ inventory = ssh.json("/usr/bin/srunnet update inventory")
+ status = ssh.json(STATUS_COMMAND)
+ if status.get("running") or status.get("phase") == "recovery_required":
+ raise RuntimeError("Resolve the existing update task before deploying")
+ if old[0]["release"] != inventory["display_version"]:
+ raise ValueError("Recovery manifest does not match installed version")
+ chosen = [("recovery", select(old[1], inventory, True)), ("new", select(new[1], inventory))]
+ print(json.dumps({"inventory": inventory, "target": new[0]["release"], "packages": [p.name for _, files in chosen for _, p in files]}, ensure_ascii=False))
+ if args.probe:
+ return 0
+ for folder, files in chosen:
+ for asset, path in files:
+ ssh.upload(folder, asset, path)
+ payload = json.dumps({"manifest": new[0], "recovery": old[0]}, separators=(",", ":")).encode()
+ # The device independently selects, validates metadata/signatures and hashes.
+ plan = ssh.json("/usr/bin/srunnet update prepare-local", payload)
+ if plan.get("ok") is not True or not re.fullmatch("[0-9a-f]{64}", plan.get("plan_id", "")):
+ raise RuntimeError("Device did not return a verified update plan")
+ if args.prepare:
+ print(json.dumps(plan, ensure_ascii=False))
+ return 0
+ task = ssh.json("/usr/bin/srunnet update run " + plan["plan_id"] + " --background")
+ if not task.get("running") or not re.fullmatch("[0-9a-f]{32}", task.get("job_id", "")):
+ raise RuntimeError("Worker did not acknowledge the installation")
+ print(json.dumps(task, ensure_ascii=False), flush=True)
+ if args.background:
+ return 0
+ deadline, previous = time.monotonic() + args.wait, None
+ while time.monotonic() < deadline:
+ status = ssh.json(STATUS_COMMAND)
+ if status.get("job_id") != task["job_id"]:
+ raise RuntimeError("Update task identity changed")
+ if status.get("phase") != previous or not status.get("running"):
+ print(json.dumps(status, ensure_ascii=False), flush=True)
+ previous = status.get("phase")
+ if not status.get("running"):
+ return 0 if status.get("ok") is True and status.get("phase") == "completed" else 1
+ time.sleep(1)
+ raise TimeoutError("Stopped waiting; the independent worker continues. Read srunnet update status before taking further action.")
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--host", default=os.environ.get("SMARTSRUN_ROUTER_HOST", ""), help="OpenSSH alias or user@host")
+ parser.add_argument("--port", type=int)
+ parser.add_argument("--ssh-config", type=Path)
+ parser.add_argument("--manifest", type=Path, required=True)
+ parser.add_argument("--assets", type=Path, required=True)
+ parser.add_argument("--recovery-manifest", type=Path, required=True)
+ parser.add_argument("--recovery-assets", type=Path, required=True)
+ mode = parser.add_mutually_exclusive_group()
+ mode.add_argument("--dry-run", action="store_true", help="Verify local bytes without SSH or device changes")
+ mode.add_argument("--probe", action="store_true", help="Read inventory and select packages without device changes")
+ mode.add_argument("--prepare", action="store_true", help="Upload and verify packages without installing")
+ mode.add_argument("--background", action="store_true")
+ parser.add_argument("--wait", type=int, default=900, help="Wait budget; timeout never kills the installer")
+ args = parser.parse_args(argv)
+ try:
+ if not 1 <= args.wait <= 86400 or (args.port is not None and not 1 <= args.port <= 65535):
+ raise ValueError("Invalid wait budget or SSH port")
+ manifests = [load_manifest(args.manifest, args.assets), load_manifest(args.recovery_manifest, args.recovery_assets)]
+ if args.dry_run:
+ present = [p for _, files in manifests for _, p in files if p.is_file()]
+ if not present:
+ raise ValueError("No local packages to verify")
+ print(json.dumps({"local_files_verified": [str(p) for p in present], "device_checked": False}, ensure_ascii=False))
+ return 0
+ return deploy(args, SSH(args), manifests)
+ except (OSError, ValueError, KeyError, TypeError, RuntimeError, subprocess.SubprocessError) as exc:
+ print(str(exc), file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/smart-srun/scripts/make_go_manifest.py b/smart-srun/scripts/make_go_manifest.py
new file mode 100644
index 000000000..acea159b8
--- /dev/null
+++ b/smart-srun/scripts/make_go_manifest.py
@@ -0,0 +1,254 @@
+#!/usr/bin/env python3
+"""Create immutable Go release metadata from measured SDK build records.
+
+Additional validation is accepted only as a report keyed by the package's
+actual SHA256. A successful build never implies installation or campus testing.
+Dirty builds require an explicit internal-test option and are not publishable.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import tempfile
+
+REPOSITORY = "matthewlu070111/smart-srun"
+KINDS = {"smart-srun": "core", "luci-app-smart-srun": "luci", "luci-app-smart-srun-bundle": "bundle"}
+CHECKS = ("build", "elf", "emulated_core", "openwrt_install", "hardware_core", "campus_auth")
+
+
+def firmware_compatibility(evidence):
+ # Forks may keep opkg after upstream moved to APK. Keep the build's SDK
+ # identity intact and accept extra families only from exact-byte native
+ # installation reports, never from a firmware label or CPU/model guess.
+ reports = evidence.get("firmware_compatibility", {})
+ if not isinstance(reports, dict):
+ raise ValueError("Invalid firmware compatibility evidence")
+ result = {}
+ for digest, families in reports.items():
+ if not isinstance(digest, str) or not re.fullmatch("[0-9a-f]{64}", digest) or not isinstance(families, dict) or not 0 < len(families) <= 16:
+ raise ValueError("Invalid firmware compatibility evidence")
+ for family, report in families.items():
+ if not isinstance(family, str) or not re.fullmatch(r"\d{2}\.\d{2}", family) or report != {"openwrt_install": True} or type(report.get("openwrt_install")) is not bool:
+ raise ValueError("Extra firmware families require successful native installation evidence")
+ result[digest] = set(families)
+ return result
+
+
+def release_filename(package, native_version, architecture, sdk, fmt):
+ # Native APK filenames omit architecture. Both formats also omit the SDK,
+ # so never flatten their original names into a multi-target Release.
+ # GitHub rewrites '~' during asset upload. Keep the native package version
+ # intact in its metadata, but use the upload-safe spelling in every URL,
+ # checksum entry and split archive member from the beginning.
+ filename_version = native_version.replace("~", ".")
+ separator = "_" if fmt == "ipk" else "-"
+ name = f"{package}{separator}{filename_version}_{architecture}_openwrt-{sdk}.{fmt}"
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,199}", name):
+ raise ValueError("Release filename is not safe for GitHub asset upload")
+ return name
+
+
+def apk_contents(path, apk, keys, version):
+ """Compare signed APK content without treating randomized signatures as payload."""
+ if not apk or not keys:
+ raise ValueError("Non-identical LuCI APKs require --apk and --keys for native content verification")
+ subprocess.run([str(apk), "--keys-dir", str(keys), "verify", str(path)],
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=True, timeout=30)
+ # Native metadata includes file hashes, attributes, dependencies and scripts.
+ # Keep it bounded and verify the full archive first; matching sizes or source
+ # inputs alone do not prove two generated packages have the same contents.
+ with tempfile.TemporaryFile() as output:
+ subprocess.run([str(apk), "adbdump", "--format", "json", str(path)],
+ stdout=output, stderr=subprocess.PIPE, check=True, timeout=30)
+ output.seek(0)
+ data = output.read((256 << 10) + 1)
+ if len(data) > 256 << 10:
+ raise ValueError("Native LuCI APK metadata exceeds the comparison limit")
+ document = json.loads(data)
+ if not isinstance(document, dict) or not isinstance(document.get("info"), dict) or not document.get("paths"):
+ raise ValueError("Native LuCI APK metadata is incomplete")
+ info = document["info"]
+ if (info.get("name"), info.get("arch"), info.get("version")) != ("luci-app-smart-srun", "noarch", version):
+ raise ValueError("Native LuCI APK identity differs from its build record")
+ return document
+
+
+def generate(records, evidence=None, internal=False, *, package_sources=None, apk=None, keys=None):
+ evidence = evidence or {"schema_version": 1, "assets": {}}
+ if evidence.get("schema_version") != 1 or not isinstance(evidence.get("assets"), dict):
+ raise ValueError("Invalid validation evidence")
+ compatibility = firmware_compatibility(evidence)
+ manifest = None
+ source_files = None
+ selections, names, sources, device_selections = {}, {}, {}, set()
+ for path in records:
+ path = Path(path)
+ record = json.loads(path.read_text(encoding="utf-8"))
+ version = record["display_version"]
+ if not re.fullmatch(r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:rc[1-9]\d*)?", version):
+ raise ValueError("Invalid display version")
+ if int(version.split(".")[0]) < 2 or not re.fullmatch("[0-9a-f]{40}", record["source_commit"]):
+ raise ValueError("Not a Go release source")
+ if record.get("source_dirty") is not False and not internal:
+ raise ValueError("Dirty source is allowed only for explicit internal tests")
+ # Native RC spelling changes Makefile (~rc for opkg, _rc for APK).
+ # Compare the measured inputs before that one deliberate substitution,
+ # while retaining the SDK's exact staged file hashes in the build record.
+ files = record.get("source_template_files")
+ if not isinstance(files, dict) or not 2 <= len(files) <= 4096 or not {"Makefile", "core/go.mod"} <= files.keys():
+ raise ValueError("Missing measured source file identities")
+ for name, digest in files.items():
+ if not isinstance(name, str) or name.startswith("/") or "\\" in name or ":" in name or any(part in ("", ".", "..") for part in name.split("/")) or not isinstance(digest, str) or not re.fullmatch("[0-9a-f]{64}", digest):
+ raise ValueError("Invalid measured source file identity")
+ staged = record.get("source_files")
+ if not isinstance(staged, dict) or staged.keys() != files.keys() or any(
+ not isinstance(digest, str) or not re.fullmatch("[0-9a-f]{64}", digest)
+ or (name != "Makefile" and files[name] != digest) for name, digest in staged.items()):
+ raise ValueError("Unexpected SDK source substitution")
+ if source_files is None:
+ source_files = files
+ elif source_files != files:
+ raise ValueError("SDK builds do not contain the same source files")
+ if manifest is None:
+ manifest = dict(schema_version=1, release=version, channel="rc" if "rc" in version else "stable",
+ source_commit=record["source_commit"], assets=[])
+ if manifest["release"] != version or manifest["source_commit"] != record["source_commit"]:
+ raise ValueError("Build records do not share a release and source commit")
+ target = record["target"]
+ if target.get("goos") != "linux" or not isinstance(target.get("goarch"), str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}", target["goarch"]) or not re.fullmatch(r"[a-z0-9_-]+/[a-z0-9_-]+", target.get("target", "")) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}", target.get("openwrt_arch", "")) or target["openwrt_arch"] in ("all", "noarch"):
+ raise ValueError("Invalid native SDK target identity")
+ manager, fmt = target["package_manager"], target["format"]
+ if (manager, fmt) not in (("opkg", "ipk"), ("apk", "apk")):
+ raise ValueError("Inconsistent package format")
+ sdk = target["sdk_release"]
+ if not re.fullmatch(r"\d{2}\.\d{2}\.\d+", sdk):
+ raise ValueError("Unpinned SDK release")
+ family = sdk.rsplit(".", 1)[0]
+ for artifact in record["artifacts"]:
+ name = artifact["file"]
+ if Path(name).name != name or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.~+-]{0,199}", name):
+ raise ValueError("Unsafe artifact filename")
+ file = path.parent / name
+ if not file.is_file() or file.is_symlink():
+ raise ValueError("Missing regular artifact")
+ with file.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ size = file.stat().st_size
+ if digest != artifact["sha256"] or size != artifact["bytes"] or not 0 < size <= 16 * 1024**2:
+ raise ValueError("Artifact bytes or checksum changed after build")
+ installed = artifact["installed_payload_bytes"]
+ if type(installed) is not int or not isinstance(artifact["files"], dict) or any(type(size) is not int or size < 0 for size in artifact["files"].values()) or sum(artifact["files"].values()) != installed or not 0 < installed <= 10 * 1024**2:
+ raise ValueError("Invalid measured installed payload")
+ kind = KINDS[artifact["name"]]
+ separator = "_" if fmt == "ipk" else "-"
+ if not name.startswith(artifact["name"] + separator) or not name.endswith("." + fmt):
+ raise ValueError("Artifact filename does not match package identity")
+ architecture = artifact["architecture"]
+ expected = ("all" if manager == "opkg" else "noarch") if kind == "luci" else target["openwrt_arch"]
+ if architecture != expected:
+ raise ValueError("Package architecture does not match its target/kind")
+ native = version.replace("rc", "~rc" if manager == "opkg" else "_rc")
+ if not re.fullmatch(re.escape(native) + r"-r[1-9]\d{0,8}", artifact["package_version"]):
+ raise ValueError("Unexpected native package version")
+ expected_name = (f"{artifact['name']}_{artifact['package_version']}_{architecture}.ipk" if fmt == "ipk"
+ else f"{artifact['name']}-{artifact['package_version']}.apk")
+ if name != expected_name:
+ raise ValueError("Filename does not identify the exact native package")
+ if manager == "apk" and not re.fullmatch("[0-9a-f]{64}", artifact.get("signature_spki_sha256") or ""):
+ raise ValueError("APK must have verified signing evidence")
+ if artifact.get("validation", {}).get("build") is not True:
+ raise ValueError("Build did not pass")
+ checked = evidence["assets"].get(digest, {})
+ if any(key not in CHECKS or type(value) is not bool for key, value in checked.items()):
+ raise ValueError("Invalid validation levels")
+ validation = {key: checked.get(key, False) for key in CHECKS}
+ validation["build"] = True
+ identifier = f"{kind}-{manager}-{architecture}-{family}"
+ export_name = release_filename(artifact["name"], artifact["package_version"], architecture, sdk, fmt)
+ if len(export_name) > 200:
+ raise ValueError("Release artifact filename exceeds the consumer limit")
+ asset = dict(id=identifier, kind=kind, package_manager=manager, format=fmt,
+ openwrt_arch=architecture, package_version=artifact["package_version"],
+ sdk_release=sdk, target=target["target"], goos=target["goos"], goarch=target["goarch"],
+ url=f"https://github.com/{REPOSITORY}/releases/download/{version}/{export_name}",
+ sha256=digest, bytes=size, installed_bytes=installed,
+ firmware_compat=sorted({family} | compatibility.get(digest, set())), validation=validation)
+ if identifier in selections:
+ previous = selections[identifier]
+ if kind != "luci" or any(previous[key] != asset[key] for key in
+ ("package_version", "installed_bytes", "validation", "sdk_release", "firmware_compat")):
+ raise ValueError("Ambiguous packages for the same device selection")
+ if previous["sha256"] != digest:
+ previous_name = previous["url"].rsplit("/", 1)[1]
+ native_version = asset["package_version"]
+ if fmt != "apk" or apk_contents(sources[previous_name], apk, keys, native_version) != apk_contents(file, apk, keys, native_version):
+ raise ValueError("Ambiguous packages for the same device selection")
+ # ECDSA signatures differ even for identical noarch payloads.
+ # Keep the FIRST verified archive's exact bytes/hash/evidence;
+ # never transfer acceptance from the discarded signature variant.
+ continue # identical architecture-neutral LuCI from another SDK target
+ compatible = {(kind, manager, architecture, item) for item in asset["firmware_compat"]}
+ if device_selections & compatible:
+ raise ValueError("Ambiguous packages for overlapping firmware compatibility")
+ device_selections.update(compatible)
+ if export_name in names:
+ raise ValueError("Duplicate release asset filename")
+ selections[identifier], names[export_name] = asset, digest
+ sources[export_name] = file
+ manifest["assets"].append(asset)
+ if manifest is None or not manifest["assets"]:
+ raise ValueError("No artifacts")
+ if package_sources is not None:
+ package_sources.update(sources)
+ return manifest, "".join(f"{digest} {name}\n" for name, digest in sorted(names.items()))
+
+
+def export_release(records, output, evidence=None, internal=False, *, apk=None, keys=None):
+ sources = {}
+ manifest, sums = generate(records, evidence, internal, package_sources=sources, apk=apk, keys=keys)
+ output = Path(output)
+ if output.exists() or output.is_symlink():
+ raise ValueError("Refusing to replace an existing release directory")
+ output.parent.mkdir(parents=True, exist_ok=True)
+ document = json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
+ manifest_hash = hashlib.sha256(document.encode()).hexdigest()
+ with tempfile.TemporaryDirectory(prefix=".smart-srun-release-", dir=output.parent) as temporary:
+ stage = Path(temporary)
+ for asset in manifest["assets"]:
+ name = asset["url"].rsplit("/", 1)[1]
+ destination = stage / name
+ shutil.copyfile(sources[name], destination)
+ with destination.open("rb") as stream:
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
+ if digest != asset["sha256"] or destination.stat().st_size != asset["bytes"]:
+ raise ValueError("Artifact changed while staging the release")
+ (stage / "SHA256SUMS").write_text(sums + f"{manifest_hash} release-manifest.json\n", encoding="utf-8", newline="\n")
+ (stage / "release-manifest.json").write_text(document, encoding="utf-8", newline="\n")
+ # Reserve the immutable destination exclusively after all byte checks.
+ # Install the manifest last; an interrupted export has no usable index
+ # and is kept for inspection rather than silently overwritten.
+ output.mkdir()
+ for name in [*sorted(sources), "SHA256SUMS", "release-manifest.json"]:
+ (stage / name).rename(output / name)
+ return manifest
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("records", nargs="+", type=Path)
+ parser.add_argument("--evidence", type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("--internal-test", action="store_true")
+ parser.add_argument("--apk", type=Path, help="Native apk-tools for comparing independently signed noarch packages")
+ parser.add_argument("--keys", type=Path, help="Trusted public keys for native APK comparison")
+ args = parser.parse_args()
+ evidence = json.loads(args.evidence.read_text()) if args.evidence else None
+ manifest = export_release(args.records, args.output, evidence, args.internal_test, apk=args.apk, keys=args.keys)
+ print(f"{manifest['release']}: {len(manifest['assets'])} measured assets -> {args.output}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/smart-srun/scripts/verify-go.sh b/smart-srun/scripts/verify-go.sh
new file mode 100644
index 000000000..4d80e92fd
--- /dev/null
+++ b/smart-srun/scripts/verify-go.sh
@@ -0,0 +1,123 @@
+#!/bin/sh
+# Go gate for smart-srun 2.0: format, vet, unit tests, coverage, race.
+#
+# This is the entry point spec 05 fixes by name. It never reports success for
+# work it did not run: a missing toolchain or a missing core/ tree is a failure,
+# not a skip. Race detection needs cgo, so it is reported as an explicit skip
+# with a reason rather than silently dropped.
+#
+# Usage: scripts/verify-go.sh [--no-race] [packages...]
+set -eu
+
+repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+core_dir="$repo_root/core"
+want_race=1
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --no-race) want_race=0; shift ;;
+ --) shift; break ;;
+ *) break ;;
+ esac
+done
+packages=${*:-./...}
+
+fail() { printf 'verify-go: FAIL: %s\n' "$1" >&2; exit 1; }
+step() { printf '\n=== %s ===\n' "$1"; }
+
+command -v go >/dev/null 2>&1 || fail "go toolchain not on PATH"
+[ -d "$core_dir" ] || fail "core/ does not exist yet; no Go gate can pass (M01 creates it)"
+[ -f "$core_dir/go.mod" ] || fail "core/go.mod missing"
+
+cd "$core_dir"
+printf 'go: %s\n' "$(go version)"
+printf 'core: %s\n' "$core_dir"
+
+step "gofmt"
+unformatted=$(gofmt -l . || true)
+[ -z "$unformatted" ] || fail "gofmt reports unformatted files:
+$unformatted"
+echo "ok"
+
+step "go vet"
+go vet ./...
+
+step "go test (shuffled, coverage)"
+test_log="$core_dir/.verify-go-test.log"
+if ! CGO_ENABLED=0 go test -count=1 -shuffle=on -coverprofile=coverage.out $packages \
+ >"$test_log" 2>&1; then
+ cat "$test_log"
+ fail "unit tests failed"
+fi
+cat "$test_log"
+
+step "coverage summary"
+go tool cover -func=coverage.out | tail -n 1
+
+# Spec 05 line 99 sets floors, and printing a number is not checking it. The
+# independent review of d33528a found this entry point printed the total and
+# enforced nothing, so a package could slide under its floor while the gate
+# still said everything passed.
+step "coverage thresholds (spec 05)"
+module=$(go list -m)
+
+# coverage_of prints the measured percentage for one package, or nothing.
+coverage_of() {
+ awk -v want="$module/$1" '
+ $1 == "ok" && $2 == want {
+ for (i = 1; i <= NF; i++)
+ if ($i == "coverage:") { sub(/%/, "", $(i+1)); print $(i+1) }
+ }' "$test_log"
+}
+
+require_coverage() {
+ pkg_path=$1
+ floor=$2
+ [ -d "$core_dir/$pkg_path" ] || fail "required package $pkg_path is missing"
+ value=$(coverage_of "$pkg_path")
+ [ -n "$value" ] || fail "no coverage was reported for $pkg_path; a floor cannot be met by a package the run did not measure"
+ if ! awk -v v="$value" -v f="$floor" 'BEGIN { exit (v + 0 >= f + 0) ? 0 : 1 }'; then
+ fail "$pkg_path coverage is ${value}%, below the spec 05 floor of ${floor}%"
+ fi
+ printf ' %-30s %s%% (floor %s%%)\n' "$pkg_path" "$value" "$floor"
+}
+
+require_coverage internal/protocol/srun 90
+require_coverage internal/policy 90
+require_coverage internal/config 90
+# Portal discovery/parsing was implemented under portal, not the provisional
+# discovery directory in the architecture plan. A stale path must not skip it.
+require_coverage internal/portal 90
+
+total=$(go tool cover -func=coverage.out |
+ awk '$1 == "total:" { sub(/%/, "", $NF); print $NF }')
+[ -n "$total" ] || fail "no total coverage was produced"
+if ! awk -v v="$total" 'BEGIN { exit (v + 0 >= 80) ? 0 : 1 }'; then
+ fail "total coverage is ${total}%, below the spec 05 floor of 80%"
+fi
+printf ' %-30s %s%% (floor 80%%)\n' "whole core" "$total"
+
+skipped=""
+if [ "$want_race" -eq 0 ]; then
+ step "race"
+ echo "SKIPPED: --no-race requested"
+ skipped="race"
+elif ! command -v cc >/dev/null 2>&1 && ! command -v gcc >/dev/null 2>&1; then
+ step "race"
+ # Spec 05: an unrun gate must say so. Do not let this masquerade as a pass.
+ fail "race gate needs a C compiler for CGO_ENABLED=1; none found"
+else
+ step "go test -race"
+ CGO_ENABLED=1 go test -race -count=1 $packages
+fi
+
+# A run that skipped a required gate does not get to print the acceptance line.
+# The review found --no-race printing SKIPPED and then "all gates passed", which
+# is the one thing this script's own header promises never to do.
+if [ -n "$skipped" ]; then
+ printf '\nverify-go: development check only -- %s did not run.\n' "$skipped"
+ printf 'This is NOT an acceptance result; run without --no-race for that.\n'
+ exit 0
+fi
+
+printf '\nverify-go: all gates passed\n'
diff --git a/smart-srun/scripts/verify_go_sdk.py b/smart-srun/scripts/verify_go_sdk.py
new file mode 100644
index 000000000..4900532ab
--- /dev/null
+++ b/smart-srun/scripts/verify_go_sdk.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+"""Inspect measured SDK packages and optionally execute their version command.
+
+Produces SHA256-keyed ELF evidence, not installation, core or campus acceptance.
+Requires Linux binutils and Go; APK inputs additionally require apk and trusted
+public keys. Explicit --qemu/--execute runs package code only when requested.
+"""
+import argparse
+import io
+import json
+from pathlib import Path
+import re
+import struct
+import subprocess
+import tarfile
+import tempfile
+
+import build_go_sdk as build
+
+
+ELF_TARGETS = {
+ "amd64": (2, 1, 62), "arm64": (2, 1, 183), "arm": (1, 1, 40),
+ "mips": (1, 2, 8), "mipsle": (1, 1, 8),
+}
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def payload_limit():
+ """The installed payload budget, from the one file that owns it.
+
+ This verifier previously carried its own copy of the number. Two copies of a
+ limit is one copy too many: raising it in targets.json while this file kept
+ the old value would fail the release with a message pointing at the build,
+ not at the stale literal here.
+ """
+ limit = json.loads((ROOT / "targets.json").read_text(encoding="utf-8"))["development_payload_limit_bytes"]
+ if not isinstance(limit, int) or limit <= 0:
+ raise ValueError("Invalid development payload limit")
+ return limit
+
+
+def inspect_header(data, goarch):
+ if len(data) < 64 or data[:4] != b"\x7fELF" or data[5] not in (1, 2):
+ raise ValueError("Invalid ELF header")
+ kind, machine = struct.unpack(("<" if data[5] == 1 else ">") + "HH", data[16:20])
+ if kind != 2 or (data[4], data[5], machine) != ELF_TARGETS.get(goarch):
+ raise ValueError("ELF class, endian, machine or executable type differs from target")
+
+
+def run(args):
+ return subprocess.check_output([str(arg) for arg in args], text=True, stderr=subprocess.STDOUT, timeout=60)
+
+
+def inspect_binary(binary, target, go, execute, qemu, version):
+ with binary.open("rb") as stream:
+ inspect_header(stream.read(64), target["goarch"])
+ program_headers = run(["readelf", "--program-headers", "--wide", binary])
+ if re.search(r"^\s*(INTERP|DYNAMIC)\s", program_headers, re.MULTILINE):
+ raise ValueError("ELF requires a dynamic loader or dynamic section")
+ info = run([go, "version", "-m", binary])
+ settings = {}
+ for line in info.splitlines():
+ fields = line.strip().split("\t", 1)
+ if len(fields) == 2 and fields[0] == "build" and "=" in fields[1]:
+ key, value = fields[1].split("=", 1)
+ # go version quotes values containing whitespace; OpenWrt appends
+ # a trailing space when combining its package/compiler flags.
+ settings[key] = json.loads(value) if value.startswith('"') else value
+ expected = {"CGO_ENABLED": "0", "GOOS": "linux", "GOARCH": target["goarch"]}
+ if target["goarch"] == "amd64":
+ expected["GOAMD64"] = "v1"
+ expected["-gcflags"] = "github.com/matthewlu070111/smart-srun/core/...=-l"
+ if target["goarch"] == "arm64":
+ expected["GOARM64"] = "v8.0"
+ if target["goarch"] == "arm" and settings.get("GOARM") not in ("7", "7,hardfloat"):
+ raise ValueError("ARMv7 package requires the SDK's ARMv7 hardware-float baseline")
+ if target["goarch"] in ("mips", "mipsle"):
+ expected.update(GOMIPS="softfloat", **{"-gcflags": "all=-l"})
+ for key, value in expected.items():
+ actual = settings.get(key)
+ if key == "-gcflags" and isinstance(actual, str):
+ actual = actual.strip()
+ if actual != value:
+ raise ValueError("Unexpected Go build setting: " + key)
+ if target["goarch"] not in ("mips", "mipsle", "amd64") and settings.get("-gcflags"):
+ raise ValueError("Build unexpectedly changes inlining")
+ execution = None
+ if execute or qemu:
+ execution = run(([qemu] if qemu else []) + [binary, "version"]).strip()
+ if execution != f"srunnet {version} (config schema v2)":
+ raise ValueError("Executed version differs from the build record")
+ return {"go_build_info": info, "program_headers": program_headers,
+ "version_output": execution, "emulator": str(qemu) if qemu else None}
+
+
+def verify(record_path, go, apk=None, keys=None, execute=False, qemu=None):
+ record_path = Path(record_path)
+ record = json.loads(record_path.read_text(encoding="utf-8"))
+ target = record["target"]
+ if target["goarch"] not in ELF_TARGETS:
+ raise ValueError("Unsupported ELF inspection target")
+ if target["format"] == "apk" and (not apk or not keys):
+ raise ValueError("APK verification requires apk and trusted public keys")
+ limit = payload_limit()
+ evidence, inspections = {"schema_version": 1, "assets": {}}, []
+ for asset in record["artifacts"]:
+ name = asset["file"]
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.~+-]{0,199}", name) or not name.endswith("." + target["format"]):
+ raise ValueError("Unsafe package filename")
+ package = record_path.parent / name
+ if package.is_symlink() or not package.is_file() or package.stat().st_size != asset["bytes"] or build.digest(package) != asset["sha256"]:
+ raise ValueError("Package bytes differ from the build record")
+ if target["format"] == "apk":
+ run([apk, "--keys-dir", keys, "verify", package])
+ actual_name, actual_version, arch, files = build.package_info(package, apk)
+ neutral = "all" if target["format"] == "ipk" else "noarch"
+ expected_arch = neutral if actual_name == "luci-app-smart-srun" else target["openwrt_arch"]
+ if (actual_name != asset["name"] or actual_version != asset["package_version"]
+ or arch != expected_arch or arch != asset["architecture"] or files != asset["files"]
+ or sum(files.values()) != asset["installed_payload_bytes"]):
+ raise ValueError("Native package identity or measured payload changed")
+ build.validate_payload(actual_name, files, limit)
+ detail = {"file": name, "sha256": asset["sha256"], "architecture": arch,
+ "installed_payload_bytes": sum(files.values())}
+ evidence["assets"][asset["sha256"]] = {}
+ if actual_name != "luci-app-smart-srun":
+ with tempfile.TemporaryDirectory(prefix="smart-srun-elf-") as temporary:
+ temp = Path(temporary)
+ if target["format"] == "apk":
+ run([apk, "--keys-dir", keys, "extract", "--no-chown", "--destination", temp, package])
+ binary = temp / "usr/bin/srunnet"
+ else:
+ binary = temp / "srunnet"
+ with tarfile.open(package) as outer:
+ with tarfile.open(fileobj=io.BytesIO(outer.extractfile("./data.tar.gz").read())) as data:
+ candidates = [entry for entry in data if entry.name.removeprefix("./") == "usr/bin/srunnet"]
+ if len(candidates) != 1 or not candidates[0].isfile() or candidates[0].size > limit:
+ raise ValueError("Missing or unsafe packaged ELF")
+ binary.write_bytes(data.extractfile(candidates[0]).read())
+ binary.chmod(0o755)
+ if binary.is_symlink() or not binary.resolve().is_relative_to(temp):
+ raise ValueError("Packaged ELF escapes its temporary directory")
+ detail.update(inspect_binary(binary, target, go, execute, qemu, record["display_version"]))
+ evidence["assets"][asset["sha256"]] = {"elf": True}
+ inspections.append(detail)
+ return evidence, {"display_version": record["display_version"], "target": target,
+ "scope": "ELF/payload and optional version command only; no core/install/hardware/campus acceptance",
+ "artifacts": inspections}
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("record", type=Path)
+ parser.add_argument("--go", type=Path, required=True)
+ parser.add_argument("--apk", type=Path)
+ parser.add_argument("--keys", type=Path)
+ execution = parser.add_mutually_exclusive_group()
+ execution.add_argument("--execute", action="store_true")
+ execution.add_argument("--qemu", type=Path)
+ parser.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+ evidence, details = verify(args.record, args.go, args.apk, args.keys, args.execute, args.qemu)
+ args.output.mkdir(parents=True, exist_ok=False)
+ for name, value in (("validation.json", evidence), ("inspection.json", details)):
+ (args.output / name).write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
+ print(f"Verified {len(details['artifacts'])} packages -> {args.output}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/smart-srun/targets.json b/smart-srun/targets.json
new file mode 100644
index 000000000..5716b68d0
--- /dev/null
+++ b/smart-srun/targets.json
@@ -0,0 +1,255 @@
+{
+ "schema_version": 1,
+ "go_version": "1.27.1",
+ "go_source_sha256": "4e408abae126d916b6164627193f2c54f0e3ca1312d693b86db45f862ab238b1",
+ "golang_framework_commit": "d0106a08c903fb3e02436021917c4d7ce9b19705",
+ "development_payload_limit_bytes": 16777216,
+ "targets": [
+ {
+ "id": "x86_64-opkg-24.10.8",
+ "openwrt_arch": "x86_64",
+ "target": "x86/64",
+ "goos": "linux",
+ "goarch": "amd64",
+ "goamd64": "v1",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/x86/64/openwrt-sdk-24.10.8-x86-64_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "ac4a0405d2eea821b06f93c14ba13ffa90ad0457648903df7dde02570027ab21",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "x86_64-apk-25.12.2",
+ "openwrt_arch": "x86_64",
+ "target": "x86/64",
+ "goos": "linux",
+ "goarch": "amd64",
+ "goamd64": "v1",
+ "package_manager": "apk",
+ "format": "apk",
+ "sdk_release": "25.12.2",
+ "base_feed_subdir": "package",
+ "sdk_url": "https://downloads.openwrt.org/releases/25.12.2/targets/x86/64/openwrt-sdk-25.12.2-x86-64_gcc-14.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "4abb32a6dd05f80c24fc1a487a94b1f5f0a5d4cd560ff3dd8c1bf77793a2d5dc",
+ "feeds": {
+ "base": "d266501ad6188cce279a487b1ce61f4f327cd496",
+ "packages": "268d92d3d46147efe1e81892e3a618f8bbd4806b",
+ "luci": "067535eaf51a59582b775a8b588a9b05810f8030"
+ }
+ },
+ {
+ "id": "aarch64_cortex-a53-opkg-24.10.8",
+ "openwrt_arch": "aarch64_cortex-a53",
+ "target": "mediatek/filogic",
+ "goos": "linux",
+ "goarch": "arm64",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/mediatek/filogic/openwrt-sdk-24.10.8-mediatek-filogic_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "d9fd074f281823a3023e8f6a73aaac598018c898ec9ac4d1c507b596930d7482",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "mips_24kc-opkg-24.10.8",
+ "openwrt_arch": "mips_24kc",
+ "target": "ath79/generic",
+ "goos": "linux",
+ "goarch": "mips",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/ath79/generic/openwrt-sdk-24.10.8-ath79-generic_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "e46c4d0127061486ef4ce681f527f8a3980702011cafbf7801aff965a366dd8d",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "aarch64_cortex-a53-apk-25.12.2",
+ "openwrt_arch": "aarch64_cortex-a53",
+ "target": "mediatek/filogic",
+ "goos": "linux",
+ "goarch": "arm64",
+ "package_manager": "apk",
+ "format": "apk",
+ "sdk_release": "25.12.2",
+ "sdk_url": "https://downloads.openwrt.org/releases/25.12.2/targets/mediatek/filogic/openwrt-sdk-25.12.2-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "56e60bb1bfac3eb379cbabc3fff126ac2b749b746b007268b27aba2df9d31a4f",
+ "feeds": {
+ "base": "d266501ad6188cce279a487b1ce61f4f327cd496",
+ "packages": "268d92d3d46147efe1e81892e3a618f8bbd4806b",
+ "luci": "067535eaf51a59582b775a8b588a9b05810f8030"
+ },
+ "base_feed_subdir": "package"
+ },
+ {
+ "id": "arm_cortex-a7_neon-vfpv4-opkg-24.10.8",
+ "openwrt_arch": "arm_cortex-a7_neon-vfpv4",
+ "target": "ipq40xx/generic",
+ "goos": "linux",
+ "goarch": "arm",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/ipq40xx/generic/openwrt-sdk-24.10.8-ipq40xx-generic_gcc-13.3.0_musl_eabi.Linux-x86_64.tar.zst",
+ "sdk_sha256": "b1b6a4a0d18305431c005af5b3750bedcdc4f5578eb757c53a4ce5956337f9f2",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "aarch64_generic-opkg-24.10.8",
+ "openwrt_arch": "aarch64_generic",
+ "target": "armsr/armv8",
+ "goos": "linux",
+ "goarch": "arm64",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/armsr/armv8/openwrt-sdk-24.10.8-armsr-armv8_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "5f430f5b30c9ea6dc472710356c139abf916b7ebd5de14e108e9cc204f40a2a4",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "aarch64_cortex-a72-opkg-24.10.8",
+ "openwrt_arch": "aarch64_cortex-a72",
+ "target": "bcm27xx/bcm2711",
+ "goos": "linux",
+ "goarch": "arm64",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/bcm27xx/bcm2711/openwrt-sdk-24.10.8-bcm27xx-bcm2711_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "d97df62039b38cfea152de7c4c8981178dd3d078187f0cb00f95c0e86fb07ea0",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "aarch64_cortex-a76-opkg-24.10.8",
+ "openwrt_arch": "aarch64_cortex-a76",
+ "target": "bcm27xx/bcm2712",
+ "goos": "linux",
+ "goarch": "arm64",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/bcm27xx/bcm2712/openwrt-sdk-24.10.8-bcm27xx-bcm2712_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "7b5fd1f72047c09986c1c24191971bb7caebf276406c7ea6281d62a1931593c1",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "arm_cortex-a9_vfpv3-d16-opkg-24.10.8",
+ "openwrt_arch": "arm_cortex-a9_vfpv3-d16",
+ "target": "mvebu/cortexa9",
+ "goos": "linux",
+ "goarch": "arm",
+ "goarm": "7",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/mvebu/cortexa9/openwrt-sdk-24.10.8-mvebu-cortexa9_gcc-13.3.0_musl_eabi.Linux-x86_64.tar.zst",
+ "sdk_sha256": "4a2ce19ffe18f615135849ba5e0a4b0b83aa324b2551984c38e2df782307860e",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "arm_cortex-a15_neon-vfpv4-opkg-24.10.8",
+ "openwrt_arch": "arm_cortex-a15_neon-vfpv4",
+ "target": "ipq806x/generic",
+ "goos": "linux",
+ "goarch": "arm",
+ "goarm": "7",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/ipq806x/generic/openwrt-sdk-24.10.8-ipq806x-generic_gcc-13.3.0_musl_eabi.Linux-x86_64.tar.zst",
+ "sdk_sha256": "ff3f75051f300ac6b846a02ada03afbddd8a677659f7df876f70f7a5e524e60b",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "mipsel_24kc-opkg-24.10.8",
+ "openwrt_arch": "mipsel_24kc",
+ "target": "ramips/mt7621",
+ "goos": "linux",
+ "goarch": "mipsle",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/ramips/mt7621/openwrt-sdk-24.10.8-ramips-mt7621_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "9dd11b0517883b016ad7684f5c408a3483eec5b2455b2ffe004c9edd84063e45",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "mipsel_24kc_24kf-opkg-24.10.8",
+ "openwrt_arch": "mipsel_24kc_24kf",
+ "target": "pistachio/generic",
+ "goos": "linux",
+ "goarch": "mipsle",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/pistachio/generic/openwrt-sdk-24.10.8-pistachio-generic_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "890633ef42b729f4f1c5238592c79f59490409f2f179c2f2caa47732929b2a88",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ },
+ {
+ "id": "mipsel_74kc-opkg-24.10.8",
+ "openwrt_arch": "mipsel_74kc",
+ "target": "bcm47xx/mips74k",
+ "goos": "linux",
+ "goarch": "mipsle",
+ "package_manager": "opkg",
+ "format": "ipk",
+ "sdk_release": "24.10.8",
+ "sdk_url": "https://downloads.openwrt.org/releases/24.10.8/targets/bcm47xx/mips74k/openwrt-sdk-24.10.8-bcm47xx-mips74k_gcc-13.3.0_musl.Linux-x86_64.tar.zst",
+ "sdk_sha256": "251ec0f47f0fda3378db6c3f59f810881d34be3e61c2d32afc1fa75836975edf",
+ "feeds": {
+ "base": "0b795ce79e23b553aa184080c390f9ce92a2b6d4",
+ "packages": "23abaa6f3b0fdfd76b570031107e5718476ff0c8",
+ "luci": "cac97ed67cfbcad90db49f5f3b1245c2c4cbfae5"
+ }
+ }
+ ],
+ "pending_architectures": [],
+ "validation_policy": "Targets pin build inputs only. Per-artifact build, ELF, emulation, installation, hardware and campus authentication evidence belongs in the release manifest. Pending architectures remain required work; they are not published as supported artifacts."
+}
diff --git a/smart-srun/tests/AGENTS.md b/smart-srun/tests/AGENTS.md
new file mode 100644
index 000000000..d1d8806d4
--- /dev/null
+++ b/smart-srun/tests/AGENTS.md
@@ -0,0 +1,10 @@
+# 主机与 LuCI 测试
+
+[README.md](README.md) 是命令、依赖和行为映射入口;设备核心测试在 [core](../core/)。
+
+- 源码级 Lua/JS 契约检查是正常手段,真实交互仍需对应 fixture 或设备证据。
+- fixture 只用合成凭据和环境参数,不复制真实配置、备份或认证响应。
+- 缺少 Lua、Node、Linux 或设备环境时记录缺失项,跳过不能写成通过。
+- 包布局以 [Makefile](../Makefile) 为准;新增文件必须检查实际安装范围。
+- 预设 `doc_url` 可指向旧入口;移动文档前检查 [test_preset_catalogue.py](test_preset_catalogue.py)。
+- 优先验证行为和危险副作用,不用脆弱的全文快照锁死文档措辞。
diff --git a/smart-srun/tests/README.md b/smart-srun/tests/README.md
new file mode 100644
index 000000000..2634ac8bc
--- /dev/null
+++ b/smart-srun/tests/README.md
@@ -0,0 +1,49 @@
+# 测试入口与验收范围
+
+## 开发主机命令
+
+从仓库根运行。Go 全门禁需要 Linux、Go(版本由 [targets.json](../targets.json) 锁定)和 C 编译器;主机契约检查需要 Python、PyYAML、Lua 5.1、Node 与 GNU make。CI 的实际安装步骤在 [ci.yml](../.github/workflows/ci.yml)。
+
+```sh
+bash scripts/verify-go.sh
+python3 -m unittest discover -s tests -v
+ruff check scripts tests
+node --check root/www/luci-static/resources/smart_srun.js
+python3 scripts/check_go_source.py
+python3 scripts/check_docs.py
+```
+
+工作流语法检查使用 CI 固定版本的 actionlint。局部修改先运行对应包或测试文件;文档工具的回归为 `python3 -m unittest discover -s tests -p test_documentation_tools.py -v`。跨层检查可用 `python3 scripts/doc_context.py --base origin/main` 获取建议。
+
+Windows 可运行文档与多数主机测试,不能将缺失 POSIX、Lua 或设备环境的跳过记录写成完整 Linux 门禁通过。
+
+## 行为与证据
+
+The device runtime is Go. Run the complete core gate on Linux with
+`bash scripts/verify-go.sh`; it enforces formatting, vet, shuffled tests,
+coverage floors and race detection. Device tests are explicit opt-ins and must
+not be counted as passed when their environment is absent.
+
+`python3 -m unittest discover -s tests -v` tests the build-host tools, release
+metadata, package contracts, preset data and the dependency-free LuCI bridge.
+Install Lua 5.1, Node and GNU make on the development host to run every shared
+UI and packaging check. Python is not part of the installed package.
+
+The retired Python 1.x runtime and its implementation-specific tests remain in
+Git history and on the `1.6.1` branch. Their behavior is covered in the Go tree:
+
+| Behavior | Current regression location |
+| --- | --- |
+| SRun encoding, hashing and login/logout identity | `core/internal/protocol/srun`, `core/internal/auth` |
+| Configuration, secrets, validation and presets | `core/internal/config`, `core/internal/presets` |
+| Scheduling, backoff, quiet hours and multi-WAN | `core/internal/application`, `core/internal/policy` |
+| Binding, reachability and portal discovery | `core/internal/transport`, `core/internal/openwrt`, `core/internal/portal` |
+| Client detection, AP selection and wireless rollback | `core/internal/wifi`, `core/internal/wireless`, `core/internal/daemon` |
+| CLI, process lifecycle, logs and update recovery | `core/internal/cli`, `core/internal/daemon`, `core/internal/logstore`, `core/internal/update` |
+| LuCI forms, wizard, actions, cancellation and feedback | `tests/lua`, `tests/js`, shared UI tests in this directory |
+| Native SDK packages, manifests and release workflows | `test_go_*`, `test_ci_go_release.py`, `core/tests/packaging` |
+
+Removing the obsolete interpreter tests is not evidence of complete parity or
+hardware acceptance. Keep unexecuted hardware, RF, campus and soak checks
+explicit in release evidence. Test fixtures use synthetic credentials; private
+device captures and signing keys do not belong in this tree.
diff --git a/smart-srun/tests/_portal_urls.py b/smart-srun/tests/_portal_urls.py
new file mode 100644
index 000000000..32c4f235d
--- /dev/null
+++ b/smart-srun/tests/_portal_urls.py
@@ -0,0 +1,85 @@
+"""Shared placeholder portal URLs for tests.
+
+These values are used by source contracts and behavioral tests with mocked
+network operations. Defining a fixture does not initiate a network request.
+
+School maintainers can override the placeholders locally without editing test
+files, for example:
+
+ SMARTSRUN_TEST_PORTAL_ORIGIN=http://portal.example.test
+ SMARTSRUN_TEST_PORTAL_IPV4_ORIGIN=http://198.51.100.10
+ SMARTSRUN_TEST_PORTAL_BARE_HOST=203.0.113.5
+ SMARTSRUN_TEST_WIRED_BIND_IP=192.0.2.20
+
+Default hostnames use the reserved .test suffix; IPv4 defaults use documentation
+ranges (RFC 5737). Keep real campus gateways out of this file: they belong in
+the preset JSON and tests that explicitly verify that catalogue's data.
+"""
+
+import os
+from urllib.parse import urlsplit
+
+
+def _origin(env_name, default):
+ text = os.environ.get(env_name, default).strip() or default
+ if "://" not in text:
+ text = "http://" + text
+ parsed = urlsplit(text)
+ if not parsed.scheme or not parsed.netloc:
+ parsed = urlsplit(default)
+ return "%s://%s" % (parsed.scheme, parsed.netloc)
+
+
+def _host(env_name, default):
+ text = os.environ.get(env_name, default).strip() or default
+ if "://" in text:
+ parsed = urlsplit(text)
+ return parsed.netloc or default
+ return text.split("/", 1)[0]
+
+
+_DEFAULT_PORTAL_ORIGIN = _origin(
+ "SMARTSRUN_TEST_PORTAL_ORIGIN", "http://portal.example.test"
+)
+PORTAL_HOST = urlsplit(_DEFAULT_PORTAL_ORIGIN).netloc
+PORTAL_ORIGIN = "http://" + PORTAL_HOST
+PORTAL_DNS_NAME = urlsplit(PORTAL_ORIGIN).hostname
+PORTAL_PORT = urlsplit(PORTAL_ORIGIN).port or 80
+PORTAL_HTTPS_ORIGIN = _origin(
+ "SMARTSRUN_TEST_PORTAL_HTTPS_ORIGIN", "https://" + PORTAL_HOST
+)
+PORTAL_IPV4_ORIGIN = _origin(
+ "SMARTSRUN_TEST_PORTAL_IPV4_ORIGIN", "http://198.51.100.10"
+)
+PORTAL_IPV4_HOST = urlsplit(PORTAL_IPV4_ORIGIN).hostname
+PORTAL_IPV4_PORT = urlsplit(PORTAL_IPV4_ORIGIN).port or 80
+PORTAL_BARE_HOST = _host("SMARTSRUN_TEST_PORTAL_BARE_HOST", "203.0.113.5")
+PORTAL_BARE_ORIGIN = "http://" + PORTAL_BARE_HOST
+
+CLIENT_IP = os.environ.get("SMARTSRUN_TEST_CLIENT_IP", "192.0.2.8").strip()
+BIND_IP = os.environ.get("SMARTSRUN_TEST_BIND_IP", "192.0.2.9").strip()
+DNS_SERVER_IP = os.environ.get("SMARTSRUN_TEST_DNS_SERVER_IP", "192.0.2.53").strip()
+
+# The address the pinned WAN hands out in multi-WAN tests. Interface names
+# such as "wan.v2" stay inline: they are an OpenWrt VLAN naming shape that the
+# LuCI editor already shows every user, not a campus address.
+WIRED_BIND_IP = os.environ.get("SMARTSRUN_TEST_WIRED_BIND_IP", "192.0.2.20").strip()
+
+
+def portal_page_url(origin=PORTAL_ORIGIN, acid="1", extra=""):
+ query = "ac_id=" + str(acid)
+ if extra:
+ query += "&" + str(extra).lstrip("&")
+ return origin.rstrip("/") + "/srun_portal_pc?" + query
+
+
+PORTAL_ACID1_PAGE_URL = portal_page_url(PORTAL_ORIGIN, "1")
+PORTAL_ACID4_THEME_URL = portal_page_url(PORTAL_ORIGIN, "4", "theme=basic2")
+PORTAL_IPV4_ACID4_THEME_URL = portal_page_url(PORTAL_IPV4_ORIGIN, "4", "theme=basic2")
+PORTAL_BARE_ACID1_PAGE_URL = PORTAL_BARE_HOST + "/srun_portal_pc?ac_id=1"
+PORTAL_HTTPS_LOGIN_PATH_URL = PORTAL_HTTPS_ORIGIN + "/path/to/login"
+PORTAL_ACID9_PAGE_PATH = "/srun_portal_pc?ac_id=9"
+PORTAL_ACID9_PAGE_URL = PORTAL_ORIGIN + PORTAL_ACID9_PAGE_PATH
+PORTAL_PING_URL = PORTAL_ORIGIN + "/ping"
+PORTAL_LOGIN_URL = PORTAL_ORIGIN + "/cgi-bin/srun_portal"
+CONNECTIVITY_PROBE_URL = PORTAL_ORIGIN + "/generate_204"
diff --git a/smart-srun/tests/fixtures/config-backup-v1.json b/smart-srun/tests/fixtures/config-backup-v1.json
new file mode 100644
index 000000000..d51fdf751
--- /dev/null
+++ b/smart-srun/tests/fixtures/config-backup-v1.json
@@ -0,0 +1,93 @@
+{
+ "format": "smart-srun-config",
+ "format_version": 1,
+ "config_schema": 1,
+ "config": {
+ "enabled": "1",
+ "multi_wan_enabled": "0",
+ "quiet_hours_enabled": "1",
+ "quiet_start": "23:45",
+ "quiet_end": "06:00",
+ "force_logout_in_quiet": "1",
+ "failover_enabled": "1",
+ "backoff_enable": "1",
+ "backoff_max_retries": "4",
+ "backoff_initial_duration": "10",
+ "backoff_max_duration": "60",
+ "retry_cooldown_seconds": "2.5",
+ "retry_max_cooldown_seconds": "60",
+ "switch_ready_timeout_seconds": "30",
+ "manual_terminal_check_max_attempts": "5",
+ "manual_terminal_check_interval_seconds": "2",
+ "hotspot_failback_enabled": "1",
+ "connectivity_check_mode": "internet",
+ "backoff_exponent_factor": "1.5",
+ "backoff_inter_const_factor": "0",
+ "backoff_outer_const_factor": "0",
+ "interval": "60",
+ "developer_mode": "0",
+ "log_level": "INFO",
+ "sta_iface": "",
+ "n": "200",
+ "type": "1",
+ "enc": "srun_bx1",
+ "school": "default",
+ "active_campus_id": "campus-wifi",
+ "default_campus_id": "campus-wifi",
+ "active_hotspot_id": "hotspot-1",
+ "default_hotspot_id": "hotspot-1",
+ "campus_accounts": [
+ {
+ "id": "campus-wifi",
+ "label": "Synthetic campus",
+ "user_id": "example-user",
+ "password": " synthetic-secret ",
+ "operator": "custom",
+ "operator_suffix": "",
+ "access_mode": "wifi",
+ "base_url": "http://portal.example.test",
+ "ac_id": "7",
+ "ssid": "Synthetic campus",
+ "encryption": "none",
+ "key": "",
+ "radio": "radio1",
+ "wired_iface": "wan",
+ "auth_enabled": "0",
+ "ap_selection": "auto",
+ "bssid": "",
+ "n": "200",
+ "type": "1",
+ "enc": "srun_bx1",
+ "info_prefix": "SRBX1",
+ "double_stack": "0",
+ "login_os": "Linux",
+ "login_name": "Synthetic"
+ },
+ {
+ "id": "campus-wired",
+ "label": "Synthetic wired",
+ "user_id": "other-user",
+ "password": "synthetic-secret-2",
+ "operator": "custom",
+ "operator_suffix": "arbitrary.example",
+ "access_mode": "wired",
+ "base_url": "https://portal.example.test",
+ "ac_id": "8",
+ "wired_iface": "wan2",
+ "auth_enabled": "1",
+ "ap_selection": ""
+ }
+ ],
+ "hotspot_profiles": [
+ {
+ "id": "hotspot-1",
+ "label": "Synthetic hotspot",
+ "ssid": "Synthetic hotspot",
+ "encryption": "psk2",
+ "key": "synthetic-hotspot-key",
+ "radio": "radio0"
+ }
+ ],
+ "school_extra": {}
+ }
+}
diff --git a/smart-srun/tests/js/acid_job_test.js b/smart-srun/tests/js/acid_job_test.js
new file mode 100644
index 000000000..21fdcb6ab
--- /dev/null
+++ b/smart-srun/tests/js/acid_job_test.js
@@ -0,0 +1,101 @@
+// Run with node tests/js/acid_job_test.js. Exercise the shipped task client;
+// the fake XHR controls network ordering without replacing its polling logic.
+'use strict';
+var assert = require('assert');
+var fs = require('fs');
+var vm = require('vm');
+var path = require('path');
+var source = fs.readFileSync(path.join(__dirname, '../../root/www/luci-static/resources/smart_srun.js'), 'utf8');
+var helper = source.slice(source.indexOf(' function postDiscovery('), source.indexOf(' function wizPost('));
+
+function setup(endpoint) {
+ var requests = [], timers = [], completed = [];
+ function XHR() { requests.push(this); }
+ XHR.prototype.open = function(method, url) { this.method = method; this.url = url; };
+ XHR.prototype.setRequestHeader = function() {};
+ XHR.prototype.send = function(body) { this.body = new URLSearchParams(body); };
+ XHR.prototype.abort = function() { this.aborted = true; };
+ var sandbox = {XMLHttpRequest: XHR, document: {querySelector: function() { return {value: 'csrf-token'}; }}, window: {},
+ setTimeout: function(fn) { timers.push(fn); return timers.length; }, clearTimeout: function(id) { timers[id - 1] = null; }};
+ vm.createContext(sandbox);
+ vm.runInContext(helper, sandbox);
+ var handle = sandbox.postDiscovery(endpoint || 'detect_acid', {base_url: 'http://portal.invalid/path', access_mode: 'wired', iface: 'wan'},
+ function(err, data) { completed.push({err: err, data: data}); });
+ return {requests: requests, timers: timers, completed: completed, handle: handle,
+ reply: function(data) { var req = requests[requests.length - 1]; req.status = 200; req.responseText = JSON.stringify(data); req.onload(); },
+ tick: function() { var fn = timers.filter(Boolean).pop(); assert(fn); fn(); }};
+}
+
+var s = setup();
+assert.strictEqual(s.requests[0].body.get('iface'), 'wan');
+assert.strictEqual(s.requests[0].body.get('token'), 'csrf-token');
+assert(s.requests[0].body.get('idempotency_key'));
+s.reply({ok: true, action_id: 'a1', state: 'queued'});
+s.tick();
+assert.strictEqual(s.requests[1].body.get('action'), 'status');
+assert.strictEqual(s.requests[1].body.get('base_url'), null);
+s.reply({ok: true, id: 'a1', state: 'running'});
+s.tick();
+s.reply({ok: true, id: 'a1', state: 'succeeded', result: {ok: true, ac_id: '007'}});
+assert.strictEqual(s.completed.length, 1);
+assert.strictEqual(s.completed[0].data.ac_id, '007');
+s.handle.abort();
+assert.strictEqual(s.requests.length, 3);
+
+s = setup();
+s.reply({ok: true, action_id: 'a2', state: 'succeeded', duplicate: true});
+assert.strictEqual(s.completed.length, 0, 'a completed receipt is not the result');
+s.tick();
+s.reply({ok: true, id: 'a2', state: 'succeeded', result: {ok: true, ac_id: '8'}});
+assert.strictEqual(s.completed[0].data.ac_id, '8');
+
+s = setup();
+s.reply({ok: true, action_id: 'a3', state: 'running'});
+s.tick();
+s.handle.abort();
+assert(s.requests[1].aborted);
+assert.strictEqual(s.requests[2].body.get('action'), 'cancel');
+assert.strictEqual(s.requests[2].body.get('action_id'), 'a3');
+assert.strictEqual(s.requests[2].body.get('token'), 'csrf-token');
+s.reply({ok: true, id: 'a3', state: 'cancelled'});
+assert.strictEqual(s.completed.length, 0, 'closed wizard must ignore late replies');
+
+s = setup();
+s.reply({ok: true, action_id: 'a4', state: 'queued'});
+s.tick();
+s.reply({ok: true, id: 'a4', state: 'failed', message: 'line changed'});
+assert.strictEqual(s.completed[0].err.message, 'line changed');
+s = setup('detect_env');
+assert(s.requests[0].url.endsWith('/detect_env'));
+s.reply({ok: true, action_id: 'env1', state: 'queued'});
+s.tick();
+assert(s.requests[1].url.endsWith('/detect_env'));
+s.reply({ok: true, id: 'env1', state: 'succeeded', result: {ok: false, state: 'online', message: 'no portal'}});
+assert.strictEqual(s.completed[0].err, null);
+assert.strictEqual(s.completed[0].data.state, 'online', 'online without a portal is a valid discovery result');
+var connection = {wiz: {accessMode: 'wired', wiredIface: 'wan', wifiIface: 'wwan', ssid: 'preset-campus'}};
+vm.createContext(connection);
+vm.runInContext(source.slice(source.indexOf(' function wizConnection('), source.indexOf(' function wizLoginPreview(')), connection);
+assert.strictEqual(connection.wizConnection().ssid, '', 'a preset Wi-Fi name must not invalidate wired discovery');
+connection.wiz.accessMode = 'wifi';
+assert.strictEqual(connection.wizConnection().ssid, 'preset-campus');
+assert.strictEqual(connection.wizConnection().iface, 'wwan');
+console.log('PASS discovery task client: submit, poll, terminal result, completed retry, cancel, failure, environment');
+
+// Execute the shipped wizard handler: reading the online account must not send
+// a password even if the user already typed it in the credentials step.
+var drafts = [];
+var wizard = {wiz: {userId: 'student', password: 'draft-secret', selectedSuffix: '', ops: [], shape: {}, baseUrl: 'http://portal.invalid', acId: '9'},
+ WIZ_SHAPE_KEYS: [], wizConnection: function() { return {access_mode: 'wired', iface: 'wan'}; },
+ wizInvalidate: function() {}, wizRender: function() {}, wizError: function(message) { throw Error(message); },
+ wizPost: function(endpoint, payload) { drafts.push({endpoint: endpoint, payload: payload}); }};
+vm.createContext(wizard);
+vm.runInContext(source.slice(source.indexOf(' function wizRunOperatorProbe('), source.indexOf(' function wizSummary(')), wizard);
+wizard.wizRunOperatorProbe(true);
+assert.strictEqual(drafts[0].payload.read_only, '1');
+assert.strictEqual(drafts[0].payload.password, undefined);
+assert.strictEqual(drafts[0].payload.candidates, undefined);
+wizard.wizRunOperatorProbe(false);
+assert.strictEqual(drafts[1].payload.read_only, '0');
+assert.strictEqual(drafts[1].payload.password, 'draft-secret');
+assert.strictEqual(drafts[1].payload.candidates, '[""]', 'explicit empty suffix is a real candidate');
diff --git a/smart-srun/tests/js/luci_action_transport_test.js b/smart-srun/tests/js/luci_action_transport_test.js
new file mode 100644
index 000000000..2213572eb
--- /dev/null
+++ b/smart-srun/tests/js/luci_action_transport_test.js
@@ -0,0 +1,55 @@
+// Run the shipped event handlers and inspect the requests they actually send.
+const assert = require('assert');
+const fs = require('fs');
+const vm = require('vm');
+const source = fs.readFileSync(process.argv[2], 'utf8');
+
+for (const tokenInForm of [true, false]) {
+ const nodes = {};
+ for (const name of ['manual-login', 'manual-logout', 'manual-result',
+ 'switch-hotspot', 'switch-campus', 'force-close', 'switch-result',
+ 'log-box', 'log-pre', 'log-start', 'log-stop', 'log-clear', 'log-download']) {
+ nodes['smart-srun-' + name] = {
+ style: {}, textContent: '', handlers: {},
+ addEventListener(name, callback) { this.handlers[name] = callback; }
+ };
+ }
+ const sent = [];
+ nodes['smart-srun-config-revision'] = {value: '0'};
+ let init;
+ function XHR() {}
+ XHR.prototype.open = function(method, url) { this.method = method; this.url = url; };
+ XHR.prototype.setRequestHeader = function() {};
+ XHR.prototype.send = function(body) { sent.push({method: this.method, url: this.url, body}); };
+ function FormData() { this.fields = {}; }
+ FormData.prototype.append = function(key, value) { this.fields[key] = value; };
+ const token = 'token with + & characters';
+ const L = {env: {token}};
+ const context = {
+ window: {L, setInterval() { return 1; }}, L,
+ document: {readyState: 'loading', hidden: false,
+ getElementById(id) { return nodes[id] || null; },
+ querySelector() { return tokenInForm ? {value: token} : null; },
+ addEventListener(event, callback) { if (event === 'DOMContentLoaded') init = callback; }
+ },
+ XMLHttpRequest: XHR, FormData, confirm() { return true; },
+ setInterval() { return 1; }, Date, JSON
+ };
+ vm.runInNewContext(source, context);
+ init();
+ for (const name of ['manual-login', 'manual-logout', 'switch-hotspot', 'switch-campus', 'force-close', 'log-clear']) {
+ nodes['smart-srun-' + name].handlers.click({preventDefault() {}});
+ }
+ for (const kind of ['campus', 'hotspot']) {
+ context.window.smartSetDefault(kind, 'fixture');
+ context.window.smartDelete(kind, 'fixture');
+ }
+ const writes = sent.filter(request => request.method === 'POST');
+ assert.strictEqual(writes.length, 10);
+ for (const request of writes) {
+ const actual = typeof request.body === 'string'
+ ? new URLSearchParams(request.body).get('token') : request.body.fields.token;
+ assert.strictEqual(actual, token, request.url);
+ }
+}
+console.log('PASS real LuCI mutation handlers send the form or environment CSRF token');
diff --git a/smart-srun/tests/js/luci_config_state_test.js b/smart-srun/tests/js/luci_config_state_test.js
new file mode 100644
index 000000000..38be9c227
--- /dev/null
+++ b/smart-srun/tests/js/luci_config_state_test.js
@@ -0,0 +1,160 @@
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const source = fs.readFileSync(path.join(__dirname, '../../root/www/luci-static/resources/smart_srun.js'), 'utf8');
+const code = source.slice(source.indexOf(' var campusData'), source.indexOf(' function initAll()'));
+
+function fixture(revision = '17') {
+ const nodes = {}, requests = [], alerts = [], timers = [], listeners = {};
+ let reloads = 0, closed = 0;
+ for (const id of ['label', 'user_id', 'operator_suffix', 'access_mode', 'wired_iface', 'auth_enabled',
+ 'password', 'base_url', 'ac_id', 'login-n', 'login-type', 'login-enc', 'info-prefix',
+ 'double-stack', 'login-os', 'login-name', 'ssid', 'bssid', 'ap_selection', 'radio', 'encryption', 'key']) {
+ nodes['jm-' + id] = {value: 'saved-' + id};
+ }
+ nodes['jm-access_mode'].value = 'wired';
+ if (revision !== null) nodes['smart-srun-config-revision'] = {value: revision};
+ function XHR() { requests.push(this); }
+ XHR.prototype.open = function(method, url) { this.method = method; this.url = url; };
+ XHR.prototype.setRequestHeader = function() {};
+ XHR.prototype.send = function(body) { this.body = body; };
+ function FormData() { this.fields = {}; }
+ FormData.prototype.append = function(key, value) { this.fields[key] = String(value); };
+ const L = {hideModal() { closed++; }, showModal() {}};
+ const context = {
+ window: {L, setInterval(fn) { timers.push(fn); return timers.length; }, clearInterval() {}}, L,
+ document: {hidden: false, getElementById(id) { return nodes[id] || null; },
+ querySelector() { return {value: 'csrf-token'}; },
+ addEventListener(event, callback) { (listeners[event] ||= []).push(callback); },
+ body: {contains() { return true; }, classList: {contains() { return true; }}}
+ },
+ XMLHttpRequest: XHR, FormData, location: {reload() { reloads++; }},
+ alert(message) { alerts.push(message); }, confirm() { return true; },
+ E(tag, attrs, text) { return {style: {}, textContent: text || '', appendChild() {}, addEventListener() {}}; }
+ };
+ vm.createContext(context);
+ vm.runInContext(code, context);
+ return {context, nodes, requests, alerts, timers, listeners,
+ get reloads() { return reloads; }, get closed() { return closed; },
+ reply(text, status = 200) {
+ const request = requests.at(-1);
+ Object.assign(request, {status, responseText: text, readyState: 4});
+ (request.onload || request.onreadystatechange)();
+ },
+ modal(kind = 'campus', id = 'saved-id') {
+ context.modalType = kind; context.modalEditId = id;
+ context.window.smartModalSave();
+ },
+ wizard() {
+ context.wizReset();
+ context.wizRender = () => {};
+ context.wizNormalizeAddress = () => true;
+ Object.assign(context.wiz, {root: {}, userId: 'saved-user', password: 'saved-password',
+ selectedSuffix: '', baseUrl: 'https://portal.example.test', accessMode: 'wired', wiredIface: 'wan'});
+ context.wizSave();
+ }
+ };
+}
+
+const failures = [];
+function check(name, callback) {
+ try { callback(); console.log('PASS ' + name); }
+ catch (error) { failures.push(name + ': ' + error.stack); }
+}
+const mutations = [
+ ['default campus', f => f.context.window.smartSetDefault('campus', 'saved-id')],
+ ['default hotspot', f => f.context.window.smartSetDefault('hotspot', 'saved-id')],
+ ['delete campus', f => f.context.window.smartDelete('campus', 'saved-id')],
+ ['delete hotspot', f => f.context.window.smartDelete('hotspot', 'saved-id')],
+ ['edit campus', f => f.modal()], ['add campus', f => f.modal('campus', '')],
+ ['edit hotspot', f => f.modal('hotspot')], ['add hotspot', f => f.modal('hotspot', '')],
+ ['wizard save', f => f.wizard()]
+];
+
+check('every configuration mutation carries the page revision, including zero', () => {
+ for (const revision of ['0', '17']) for (const [name, mutate] of mutations) {
+ const f = fixture(revision); mutate(f);
+ assert.strictEqual(f.requests.length, 1, name);
+ const body = f.requests[0].body;
+ const sent = typeof body === 'string' ? new URLSearchParams(body).get('expected_revision') : body.fields.expected_revision;
+ assert.strictEqual(sent, revision, name);
+ }
+});
+
+check('missing or invalid page revisions block every configuration mutation', () => {
+ for (const revision of [null, '', '-1', '1.5', '1e2', ' 17 ', '01', 'NaN', '9007199254740992']) for (const [name, mutate] of mutations) {
+ const f = fixture(revision); mutate(f);
+ assert.strictEqual(f.requests.length, 0, name + '/' + revision);
+ assert(f.alerts.length || f.context.wiz.error, 'explain why the save was blocked');
+ }
+});
+
+check('only explicit success closes an editor or refreshes a mutation page', () => {
+ for (const response of ['', '{invalid', '{}', 'null', '[]', '{"ok":false}', '{"ok":1}', '{"ok":"true"}']) {
+ for (const [name, mutate] of mutations) {
+ const f = fixture(); mutate(f); f.reply(response);
+ assert.strictEqual(f.reloads, 0, name + '/' + response);
+ assert.strictEqual(f.closed, 0, name + '/' + response);
+ assert.notStrictEqual(f.context.window.__smartModalSaving, true, name);
+ assert.strictEqual(f.nodes['jm-password'].value, 'saved-password');
+ if (name === 'wizard save') assert.strictEqual(f.context.wiz.password, 'saved-password');
+ else assert(f.alerts.length, name + ' must report failure');
+ }
+ }
+ for (const [name, mutate] of mutations.filter(([name]) => name !== 'wizard save')) {
+ const f = fixture(); mutate(f); f.reply('{"ok":true}');
+ assert.strictEqual(f.reloads, 1, name);
+ }
+ const f = fixture(); f.modal(); f.reply('{"ok":false,"message":"配置已变化,请刷新"}', 409);
+ assert.strictEqual(f.reloads, 0);
+ assert.strictEqual(f.alerts[0], '配置已变化,请刷新');
+ assert.strictEqual(f.nodes['jm-password'].value, 'saved-password');
+});
+
+check('slow status requests never overlap, including visibility events and failures', () => {
+ const f = fixture('17');
+ for (const suffix of ['', '-title', '-meta', '-details-content', '-pending']) {
+ f.nodes['smart-srun-overview' + suffix] = {style: {}, textContent: '', innerHTML: ''};
+ }
+ f.context.initOverview();
+ function tick() { f.timers.forEach(fn => fn()); }
+ function visible() { for (const event of ['visibilitychange', 'webkitvisibilitychange']) f.listeners[event].forEach(fn => fn()); }
+ assert.strictEqual(f.requests.length, 1);
+ tick(); tick(); visible();
+ assert.strictEqual(f.requests.length, 1, 'pending request must block timer and visibility refreshes');
+ f.reply('{"ok":false,"message":"配置读取失败"}');
+ assert.strictEqual(f.nodes['smart-srun-overview-title'].textContent, '状态读取失败');
+ tick(); assert.strictEqual(f.requests.length, 2);
+ f.requests.at(-1).ontimeout();
+ visible(); assert.strictEqual(f.requests.length, 3, 'timeouts must release the request guard');
+ tick(); assert.strictEqual(f.requests.length, 3);
+ f.reply('{"ok":true,"status":"在线","revision":99}');
+ assert.strictEqual(f.nodes['smart-srun-config-revision'].value, '17', 'status cannot rebase stale form contents');
+ assert.strictEqual(f.nodes['jm-password'].value, 'saved-password');
+ f.context.document.hidden = true; tick();
+ assert.strictEqual(f.requests.length, 3);
+ f.context.document.hidden = false; visible();
+ assert.strictEqual(f.requests.length, 4);
+ f.reply('null');
+ assert.strictEqual(f.nodes['smart-srun-overview-title'].textContent, '状态读取失败');
+ tick(); assert.strictEqual(f.requests.length, 5);
+});
+
+check('action feedback waits for both pending reads and stops polling after completion', () => {
+ const f = fixture();
+ f.context.window.smartOpenBlockingFeedback('manual_login', 1, 'action-1');
+ function tick() { f.timers.forEach(fn => fn()); }
+ assert.strictEqual(f.requests.length, 2);
+ tick(); tick();
+ assert.strictEqual(f.requests.length, 2);
+ f.requests[0].onerror(); tick();
+ assert.strictEqual(f.requests.length, 2, 'status is still pending');
+ f.reply('{"action_id":"action-1","action_result":"pending"}');
+ tick(); assert.strictEqual(f.requests.length, 4);
+ f.reply('{"action_id":"action-1","action_result":"ok"}');
+ f.requests[2].onerror(); tick();
+ assert.strictEqual(f.requests.length, 4, 'terminal feedback must never start new reads');
+});
+
+assert.deepStrictEqual(failures, []);
diff --git a/smart-srun/tests/lua/bridge_probe.lua b/smart-srun/tests/lua/bridge_probe.lua
new file mode 100644
index 000000000..267f136e0
--- /dev/null
+++ b/smart-srun/tests/lua/bridge_probe.lua
@@ -0,0 +1,515 @@
+-- Exercises the LuCI bridge's pure mapping against the Go configuration shape.
+--
+-- Run by tests/test_luci_rpc_bridge.py with the repository's own Lua tree on
+-- package.path and nixio/luci.jsonc stubbed: nothing here opens a socket, and
+-- the functions under test are the ones that decide what a save sends.
+
+local failures = 0
+
+local function check(name, condition, detail)
+ if not condition then
+ failures = failures + 1
+ io.stderr:write(string.format("FAIL %s: %s\n", name, tostring(detail or "")))
+ end
+end
+
+local function equal(name, got, want)
+ check(name, got == want, string.format("got %s, want %s", tostring(got), tostring(want)))
+end
+
+local bridge = require "luci.smart_srun.bridge"
+local rpc = require "luci.smart_srun.rpc"
+
+-- rpc calls sock:writeall/readall, which only exist once nixio.util has been
+-- loaded. It must load that itself: in the page nixio.fs happened to do it,
+-- and a caller that did not load nixio.fs first failed on its first request.
+check("rpc.loads_nixio_util", package.loaded["nixio.util"] ~= nil,
+ "luci.smart_srun.rpc did not require nixio.util")
+
+-- One configuration in exactly the shape config.get answers with.
+local CONFIG = {
+ schema_version = 2,
+ revision = 7,
+ enabled = true,
+ multi_wan_enabled = false,
+ school = "default",
+ sta_iface = "",
+ login_defaults = { n = "200", type = "1", enc = "srun_bx1" },
+ selection = {
+ active_campus_id = "c1", default_campus_id = "c2",
+ active_hotspot_id = "h1", default_hotspot_id = "h1",
+ },
+ quiet = { enabled = true, start = "00:00", ["end"] = "06:00", force_logout = true },
+ preset_updates = { enabled = true, time = "09:00" },
+ retry = { enabled = true, max_retries = 4, initial_seconds = 10, max_seconds = 60 },
+ checks = {
+ interval_seconds = 60, mode = "internet", switch_timeout_seconds = 30,
+ terminal_attempts = 5, terminal_interval_seconds = 2,
+ },
+ failover = { enabled = true, hotspot_failback_enabled = true },
+ log = { level = "INFO", file_omit_info = true },
+ campus_accounts = {
+ {
+ id = "c1", label = "宿舍有线", user_id = "2021001", password = "",
+ operator = "中国电信", operator_suffix = "telecom",
+ access_mode = "wired", wired_iface = "wan", auth_enabled = true,
+ base_url = "http://10.0.0.55", ac_id = "1",
+ login = { n = "200", type = "1", info_prefix = "SRBX1", double_stack = false },
+ },
+ {
+ id = "c2", label = "教学楼 Wi-Fi", user_id = "2021002",
+ operator_suffix = "", access_mode = "wifi", ssid = "JXNU",
+ encryption = "none", ap_selection = "fixed", bssid = "02:11:22:33:44:55",
+ base_url = "http://10.0.0.55", ac_id = "8", login = {},
+ },
+ },
+ hotspot_profiles = {
+ { id = "h1", label = "手机热点", ssid = "iPhone", encryption = "psk2", key = "", radio = "radio0" },
+ },
+ school_extra = {},
+}
+
+-- flatten: the page's flat, string-valued view of a typed configuration.
+local flat = bridge.flatten(CONFIG)
+equal("flatten.enabled", flat.enabled, "1")
+equal("flatten.multi_wan", flat.multi_wan_enabled, "0")
+equal("flatten.quiet_enabled", flat.quiet_hours_enabled, "1")
+equal("flatten.quiet_start", flat.quiet_start, "00:00")
+equal("flatten.quiet_end", flat.quiet_end, "06:00")
+equal("flatten.preset_auto_update", flat.preset_auto_update_enabled, "1")
+equal("flatten.preset_time", flat.preset_update_time, "09:00")
+local preset_patch = bridge.settings_patch(
+ { preset_auto_update_enabled = "0", preset_update_time = "23:00" },
+ { preset_auto_update_enabled = true, preset_update_time = true })
+equal("patch.preset_disabled", preset_patch.preset_updates.enabled, false)
+equal("patch.preset_time", preset_patch.preset_updates.time, "23:00")
+equal("flatten.interval", flat.interval, "60")
+equal("flatten.retry_cooldown", flat.retry_cooldown_seconds, "10")
+equal("flatten.max_retries", flat.backoff_max_retries, "4")
+equal("flatten.log_level", flat.log_level, "INFO")
+equal("flatten.log_file_omit_info", flat.log_file_omit_info, "1")
+local omit_off = bridge.settings_patch({ log_file_omit_info = "0", log_level = "WARN" }, { log_file_omit_info = true })
+equal("patch.log_file_omit_info_off", omit_off.log.file_omit_info, false)
+check("patch.log_file_omit_info_keeps_level", omit_off.log.level == nil, "the level must not travel with the flag")
+local omit_on = bridge.settings_patch({ log_file_omit_info = "1" }, { log_file_omit_info = true })
+equal("patch.log_file_omit_info_on", omit_on.log.file_omit_info, true)
+equal("flatten.login_default_n", flat.n, "200")
+equal("flatten.active_campus", flat.active_campus_id, "c1")
+equal("flatten.default_campus", flat.default_campus_id, "c2")
+equal("flatten.revision", flat.revision, 7)
+equal("flatten.accounts", #flat.campus_accounts, 2)
+equal("flatten.account_auth_enabled", flat.campus_accounts[1].auth_enabled, "1")
+equal("flatten.account_double_stack", flat.campus_accounts[1].double_stack, "0")
+equal("flatten.account_unset_double_stack", flat.campus_accounts[2].double_stack, "")
+equal("flatten.account_mode", flat.campus_accounts[2].access_mode, "wifi")
+-- A redacted configuration must stay redacted on its way to the page.
+equal("flatten.no_password", flat.campus_accounts[1].password, "")
+equal("flatten.no_hotspot_key", flat.hotspot_profiles[1].key, "")
+
+-- settings_patch: only what the form touched.
+local patch = bridge.settings_patch(
+ { quiet_hours_enabled = "0", interval = "90", log_level = "DEBUG", enabled = "1" },
+ { quiet_hours_enabled = true, interval = true })
+equal("patch.quiet_enabled", patch.quiet.enabled, false)
+equal("patch.interval", patch.checks.interval_seconds, 90)
+check("patch.log_untouched", patch.log == nil, "log level must not travel unless edited")
+check("patch.enabled_untouched", patch.enabled == nil, "enabled must not travel unless edited")
+check("patch.nothing_dirty", bridge.settings_patch(flat, {}) == nil, "an untouched form sends nothing")
+
+-- A number field that is not a number keeps its stored value rather than
+-- becoming zero, which the daemon would accept as a valid setting.
+local bad = bridge.settings_patch({ interval = "abc" }, { interval = true })
+check("patch.rejects_non_numeric", bad == nil, "non-numeric interval must not be sent")
+
+-- An emptied field is "not submitted", not "set to nothing": a clock with no
+-- time or an enum with no choice would fail the whole save, including the
+-- fields the user did change.
+local cleared = bridge.settings_patch(
+ { quiet_start = "", log_level = "", school = "", interval = "90" },
+ { quiet_start = true, log_level = true, school = true, interval = true })
+check("patch.skips_empty_clock", cleared.quiet == nil, "an empty clock must not be sent")
+check("patch.skips_empty_enum", cleared.log == nil, "an empty enum must not be sent")
+check("patch.skips_empty_required", cleared.school == nil, "an empty school must not be sent")
+equal("patch.keeps_the_edited_field", cleared.checks.interval_seconds, 90)
+-- The one field that may genuinely be empty still can be.
+local sta = bridge.settings_patch({ sta_iface = "" }, { sta_iface = true })
+equal("patch.allows_empty_sta_iface", sta.sta_iface, "")
+
+-- Seconds stay fractional: the baseline parsed them with float().
+local fraction = bridge.settings_patch({ retry_cooldown_seconds = "0.5" }, { retry_cooldown_seconds = true })
+equal("patch.fractional_seconds", fraction.retry.initial_seconds, 0.5)
+
+-- campus_patch: presence decides what a save overwrites.
+local edit = bridge.campus_patch({
+ id = "c1", label = "", user_id = "2021001", operator_suffix = "telecom",
+ password = "", access_mode = "wired", wired_iface = "", network_interface = "wan.v2",
+ auth_enabled = "1", base_url = "http://10.0.0.55", ac_id = "1",
+ ssid = "leftover", bssid = "02:11:22:33:44:55", ap_selection = "fixed",
+ n = "200", type = "1", enc = "srun_bx1", info_prefix = "SRBX1",
+ double_stack = "", login_os = "Windows 10", login_name = "Windows",
+}, { creating = false })
+equal("campus.id", edit.id, "c1")
+check("campus.keeps_password", edit.password == nil,
+ "an empty password box on an edit must not clear the stored credential")
+equal("campus.wired_iface_alias", edit.wired_iface, "wan.v2")
+equal("campus.auth_enabled", edit.auth_enabled, true)
+equal("campus.clears_ssid", edit.ssid, "")
+equal("campus.clears_bssid", edit.bssid, "")
+equal("campus.wired_ap_selection", edit.ap_selection, "auto")
+equal("campus.double_stack_cleared", edit.login.double_stack, rpc.NULL)
+
+local created = bridge.campus_patch({
+ label = "", user_id = "2021003", operator_suffix = "", password = "",
+ access_mode = "wifi", ssid = "JXNU", bssid = "", ap_selection = "",
+ double_stack = "1",
+}, { creating = true })
+check("campus.new_id_absent", created.id == nil, "a new account must not choose its own id")
+equal("campus.new_password_stored", created.password, "")
+equal("campus.wifi_default_encryption", created.encryption, "none")
+equal("campus.ap_selection_auto", created.ap_selection, "auto")
+equal("campus.double_stack_true", created.login.double_stack, true)
+
+local pinned = bridge.campus_patch({
+ access_mode = "wifi", ssid = "JXNU", bssid = "02:11:22:33:44:55",
+ ap_selection = "", double_stack = "0",
+}, { creating = true })
+equal("campus.ap_selection_from_bssid", pinned.ap_selection, "fixed")
+equal("campus.double_stack_false", pinned.login.double_stack, false)
+
+local hotspot = bridge.hotspot_patch(
+ { id = "h1", label = "手机热点", ssid = "iPhone", encryption = "psk2", key = "", radio = "" },
+ { creating = false })
+check("hotspot.keeps_key", hotspot.key == nil, "an empty key box on an edit must not clear the stored key")
+equal("hotspot.id", hotspot.id, "h1")
+
+equal("label.with_suffix", bridge.default_label("2021001", "telecom", "未命名账号"), "2021001@telecom")
+equal("label.plain", bridge.default_label("2021001", "", "未命名账号"), "2021001")
+equal("label.fallback", bridge.default_label("", "", "未命名账号"), "未命名账号")
+
+-- utc_seconds: known instants, and a difference that survives any local zone.
+equal("time.epoch", bridge.utc_seconds("1970-01-01T00:00:00Z"), 0)
+equal("time.known", bridge.utc_seconds("2026-09-18T12:00:00Z"), 1789732800)
+equal("time.leap_day", bridge.utc_seconds("2024-02-29T00:00:00Z"), 1709164800)
+check("time.rejects_garbage", bridge.utc_seconds("not a time") == nil, "a malformed timestamp is not a time")
+
+-- status_view: what the page polls, built from a snapshot plus the config.
+local snapshot = {
+ service = "running", enabled = true, config_revision = 7,
+ written_at = "2026-09-18T12:00:30Z",
+ accounts = {
+ { account_id = "c1", link = "Ready", auth = "VerifiedSelf",
+ connectivity = "InternetReachable", identity = "2021001@telecom",
+ line = { iface = "wan", device = "eth0.2", address = "10.0.0.77" } },
+ },
+ actions = {
+ { id = "a1", kind = "manual_login", state = "succeeded", message = "认证完成",
+ ended_at = "2026-09-18T12:00:00Z" },
+ },
+}
+local view = bridge.status_view(snapshot, CONFIG, 1000)
+equal("status.text", view.status, "已认证")
+equal("status.connectivity", view.connectivity, "互联网可达")
+equal("status.level", view.connectivity_level, "online")
+equal("status.mode_label", view.mode_label, "校园网模式(有线)")
+equal("status.access_mode", view.current_campus_access_mode, "wired")
+equal("status.account_label", view.campus_account_label, "宿舍有线")
+equal("status.identity", view.online_account_label, "2021001@telecom")
+-- The line as observed, not as configured: the account says "wan", the device
+-- that carried the attempt was eth0.2, and the address is one nobody could
+-- have guessed from the configuration.
+equal("status.iface", view.current_iface, "wan")
+equal("status.device", view.current_device, "eth0.2")
+equal("status.ip", view.current_ip, "10.0.0.77")
+equal("status.not_paused", view.paused, false)
+equal("status.not_in_quiet", view.in_quiet, false)
+equal("status.hotspot_label", view.hotspot_profile_label, "手机热点")
+equal("status.last_action", view.last_action, "manual_login")
+equal("status.result", view.action_result, "ok")
+equal("status.message", view.last_action_message, "认证完成")
+-- The action finished 30 seconds before the snapshot was written, so it reads
+-- as 30 seconds ago on this clock, whatever zone either machine is in.
+equal("status.last_ts", view.last_action_ts, 970)
+equal("status.pending", view.pending_action, "")
+-- A managed wired account gets a session row; an unmanaged one does not.
+check("status.no_sessions_without_multiwan", next(view.wired_auth_sessions) == nil,
+ "multi_wan_enabled is off, so no account is managed")
+
+local managed = {}
+for key, value in pairs(CONFIG) do managed[key] = value end
+managed.multi_wan_enabled = true
+local managed_view = bridge.status_view(snapshot, managed, 1000)
+check("status.session_present", managed_view.wired_auth_sessions.c1 ~= nil, "c1 opted in and the switch is on")
+equal("status.session_online", managed_view.wired_auth_sessions.c1.online, true)
+
+-- A queued action is what the progress dialog waits on.
+local busy = {
+ service = "running", enabled = true, written_at = "2026-09-18T12:00:30Z",
+ accounts = {}, actions = {
+ { id = "a2", kind = "switch_campus", state = "running" },
+ { id = "a1", kind = "manual_login", state = "succeeded", ended_at = "2026-09-18T12:00:00Z" },
+ },
+}
+local busy_view = bridge.status_view(busy, CONFIG, 1000)
+equal("status.pending_kind", busy_view.pending_action, "switch_campus")
+equal("status.pending_result", busy_view.action_result, "pending")
+equal("status.pending_clears_last_action", busy_view.last_action, "")
+equal("status.pending_clears_message", busy_view.last_action_message, "")
+equal("status.pending_clears_timestamp", busy_view.last_action_ts, 0)
+
+-- Background work and wizard jobs have their own results. They must not hide
+-- the manual terminal the frozen progress dialog matches by kind and time.
+for _, state in ipairs({ "succeeded", "failed", "cancelled", "interrupted" }) do
+ for _, kind in ipairs({ "presets_refresh", "maintain", "forced_logout",
+ "quiet_hotspot", "quiet_campus", "detect_acid", "detect_verify", "wifi_setup_start" }) do
+ local mixed = {
+ service = "running", written_at = "2026-09-18T12:00:30Z", actions = {
+ { kind = "manual_logout", state = state, message = "manual result",
+ ended_at = "2026-09-18T12:00:10Z" },
+ { kind = kind, state = "succeeded", message = "unrelated result",
+ ended_at = "2026-09-18T12:00:20Z" },
+ { kind = "maintain", state = "running" },
+ },
+ }
+ local result = bridge.status_view(mixed, CONFIG, 1000)
+ local name = "status.manual_" .. state .. "_after_" .. kind
+ equal(name .. ".kind", result.last_action, "manual_logout")
+ equal(name .. ".message", result.last_action_message, "manual result")
+ equal(name .. ".timestamp", result.last_action_ts, 980)
+ equal(name .. ".result", result.action_result,
+ state == "succeeded" and "ok" or state == "failed" and "error" or "forced")
+ equal(name .. ".background_visible", result.pending_action, "maintain")
+ equal(name .. ".no_manual_start", result.action_started_at, 0)
+ end
+end
+
+local background_only = bridge.status_view({ actions = {
+ { kind = "presets_refresh", state = "succeeded", message = "refreshed",
+ ended_at = "2026-09-18T12:00:00Z" },
+ { kind = "maintain", state = "queued" },
+} }, CONFIG, 1000)
+equal("status.background_has_no_manual_result", background_only.action_result, "")
+equal("status.background_has_no_manual_message", background_only.last_action_message, "")
+
+for _, kind in ipairs({ "manual_login", "manual_logout", "relogin", "switch_campus", "switch_hotspot" }) do
+ local queued = bridge.status_view({ actions = {
+ { kind = "maintain", state = "running" },
+ { kind = kind, state = "queued" },
+ { kind = "manual_login", state = "succeeded", message = "old result",
+ ended_at = "2026-09-18T12:00:00Z" },
+ } }, CONFIG, 1000)
+ equal("status.queued_" .. kind, queued.action_result, "pending")
+ equal("status.queued_clears_" .. kind, queued.last_action_message, "")
+end
+
+-- Mode follows the switch that actually succeeded, not the one requested.
+local switched = {
+ service = "running", enabled = true, written_at = "2026-09-18T12:10:00Z", accounts = {},
+ actions = {
+ { id = "a3", kind = "switch_hotspot", state = "succeeded", ended_at = "2026-09-18T12:05:00Z" },
+ { id = "a4", kind = "switch_campus", state = "failed", ended_at = "2026-09-18T12:09:00Z" },
+ },
+}
+local switched_view = bridge.status_view(switched, CONFIG, 1000)
+equal("status.mode_after_switch", switched_view.current_mode, "hotspot")
+equal("status.mode_label_hotspot", switched_view.mode_label, "热点模式")
+equal("status.failed_switch_result", switched_view.action_result, "error")
+
+-- Hotspot connectivity never inherits a campus identity or a campus probe.
+-- The observed profile also survives service restart/action-history expiry.
+local hotspot_snapshot = { service = "running", enabled = false, accounts = snapshot.accounts,
+ actions = {}, wireless = { state = "associated", hotspot_id = "h1", radio = "radio0",
+ ssid = "iPhone", iface = "wwan", device = "phy0-sta0", address = "192.0.2.2" } }
+for _, case in ipairs({
+ { "InternetReachable", "热点已联网", "online", "互联网可达" },
+ { "Limited", "热点联网受限", "limited", "已连接但受限" },
+ { "Offline", "热点联网检测失败", "offline", "互联网探测未通过" },
+ { "Unknown", "热点已连接", "offline", "等待联网检测" },
+}) do
+ hotspot_snapshot.wireless.connectivity = case[1]
+ local result = bridge.status_view(hotspot_snapshot, CONFIG, 1000)
+ equal("hotspot.mode", result.mode, "hotspot")
+ equal("hotspot.status_" .. case[1], result.status, case[2])
+ equal("hotspot.level_" .. case[1], result.connectivity_level, case[3])
+ equal("hotspot.connectivity_" .. case[1], result.connectivity, case[4])
+ equal("hotspot.no_campus_identity", result.online_account_label, "")
+ equal("hotspot.own_interface", result.current_iface, "wwan")
+ equal("hotspot.own_address", result.current_ip, "192.0.2.2")
+end
+hotspot_snapshot.accounts = {}
+hotspot_snapshot.wireless.address = ""
+equal("hotspot.awaiting_lease", bridge.status_view(hotspot_snapshot, CONFIG, 1000).status, "热点等待 IP 地址")
+hotspot_snapshot.actions = switched.actions
+hotspot_snapshot.wireless = { state = "disconnected" }
+local disconnected_hotspot = bridge.status_view(hotspot_snapshot, CONFIG, 1000)
+equal("hotspot.disconnected", disconnected_hotspot.status, "热点未连接")
+equal("hotspot.no_old_ip", disconnected_hotspot.current_ip, "")
+hotspot_snapshot.wireless = nil
+equal("hotspot.expired", bridge.status_view(hotspot_snapshot, CONFIG, 1000).status, "正在读取热点状态")
+hotspot_snapshot.wireless = {state = "associated", hotspot_id = "h1", address = "192.0.2.2", connectivity = "InternetReachable"}
+hotspot_snapshot.service = "stopped"
+equal("hotspot.stopped", bridge.status_view(hotspot_snapshot, CONFIG, 1000).connectivity_level, "offline")
+
+-- A cancelled action is "forced", which is what the dialog's stop button means.
+local cancelled = {
+ service = "running", enabled = true, written_at = "2026-09-18T12:00:10Z", accounts = {},
+ actions = { { id = "a5", kind = "manual_login", state = "cancelled", ended_at = "2026-09-18T12:00:00Z" } },
+}
+equal("status.cancelled", bridge.status_view(cancelled, CONFIG, 1000).action_result, "forced")
+
+-- Quiet hours suspend a service that is still switched on. The page has to be
+-- able to say that without contradicting the switch beside it.
+local quiet = {
+ service = "running", enabled = true, pause = { "QuietHours" },
+ written_at = "2026-09-18T12:00:00Z", accounts = {}, actions = {},
+}
+local quiet_view = bridge.status_view(quiet, CONFIG, 1000)
+equal("status.quiet_paused", quiet_view.paused, true)
+equal("status.quiet_flag", quiet_view.in_quiet, true)
+equal("status.quiet_keeps_the_switch", quiet_view.enabled, true)
+
+local disabled = {
+ service = "running", enabled = false, pause = { "UserDisabled" },
+ written_at = "2026-09-18T12:00:00Z", accounts = {}, actions = {},
+}
+equal("status.disabled_is_not_quiet", bridge.status_view(disabled, CONFIG, 1000).in_quiet, false)
+equal("status.disabled_is_paused", bridge.status_view(disabled, CONFIG, 1000).paused, true)
+
+-- A line nobody has observed yet leaves the address empty rather than
+-- repeating the configuration.
+local unobserved = {
+ service = "running", enabled = true, written_at = "2026-09-18T12:00:00Z",
+ accounts = { { account_id = "c1", link = "Ready", auth = "Unknown",
+ connectivity = "Unknown" } },
+ actions = {},
+}
+local unobserved_view = bridge.status_view(unobserved, CONFIG, 1000)
+equal("status.unobserved_ip", unobserved_view.current_ip, "")
+equal("status.unobserved_device", unobserved_view.current_device, "")
+-- The configured interface still stands in while the link is ready, which is
+-- the first moment after a save and before the first attempt.
+equal("status.unobserved_iface_falls_back", unobserved_view.current_iface, "wan")
+
+-- A stopped service is a state, not a page error.
+local stopped = bridge.status_view({ service = "stopped", enabled = false }, CONFIG, 1000)
+equal("status.stopped", stopped.status, "认证服务已停止")
+equal("status.stopped_level", stopped.connectivity_level, "offline")
+equal("offline.status", bridge.offline_view("认证服务未在运行", 1000).status, "认证服务未在运行")
+equal("offline.ts", bridge.offline_view(nil, 1000).ts, 1000)
+
+-- A link that is not ready is reported as the link problem it is, not as an
+-- authentication result the gateway never gave.
+local pending_link = {
+ service = "running", enabled = true, written_at = "2026-09-18T12:00:00Z",
+ accounts = { { account_id = "c1", link = "AddressPending", auth = "Unknown", connectivity = "Unknown" } },
+ actions = {},
+}
+equal("status.link_problem", bridge.status_view(pending_link, CONFIG, 1000).status, "等待 IPv4 地址")
+equal("status.link_iface_unknown", bridge.status_view(pending_link, CONFIG, 1000).current_iface, "")
+
+-- Actual association data must win over a configured fixed BSSID. No radio
+-- observation may borrow the old address or invent a current AP from config.
+local wifi_config = { selection = {active_campus_id = "wifi"}, campus_accounts = {
+ {id = "wifi", access_mode = "wifi", ssid = "campus", bssid = "02:00:00:00:00:01", ap_selection = "fixed"}
+} }
+local wifi_state = {service = "running", accounts = {{account_id = "wifi", link = "Ready"}}, actions = {},
+ wireless = {state = "associated", device = "phy1-sta0", iface = "wwan", address = "192.0.2.9",
+ ssid = "campus", bssid = "02:00:00:00:00:02", signal = -53, channel = 44}}
+local wifi_view = bridge.status_view(wifi_state, wifi_config, 1000)
+equal("wifi.real_ap", wifi_view.current_bssid, "02:00:00:00:00:02")
+equal("wifi.device", wifi_view.current_wireless_ifname, "phy1-sta0")
+equal("wifi.signal", wifi_view.current_signal, -53)
+equal("wifi.channel", wifi_view.current_channel, 44)
+equal("wifi.policy", wifi_view.ap_selection_policy, "fixed")
+equal("wifi.mismatch", wifi_view.ap_selection_reason, "当前接入点与固定 BSSID 不同")
+wifi_state.wireless = nil
+wifi_view = bridge.status_view(wifi_state, wifi_config, 1000)
+equal("wifi.no_invented_ap", wifi_view.current_bssid, "")
+equal("wifi.no_invented_ssid", wifi_view.current_ssid, "")
+
+-- A failed manual login offers the school's page again, as 1.6.1 did.
+--
+-- The page has always had the link (renderPortalGuidance). The 2.0 bridge sent
+-- "" in both places that feed it, so the path could never fire. The address is
+-- the configured one for the account that failed, reduced to an origin.
+local function failed(kind, account_id, state)
+ return { service = "running", written_at = "2026-09-18T12:00:30Z", actions = {
+ { kind = kind, account_id = account_id, state = state or "failed",
+ message = "认证失败", ended_at = "2026-09-18T12:00:10Z" },
+ } }
+end
+equal("portal.failed_login", bridge.status_view(failed("manual_login", "c1"), CONFIG, 1000)
+ .last_action_portal_url, "http://10.0.0.55")
+equal("portal.succeeded_login", bridge.status_view(failed("manual_login", "c1", "succeeded"), CONFIG, 1000)
+ .last_action_portal_url, "")
+equal("portal.cancelled_login", bridge.status_view(failed("manual_login", "c1", "cancelled"), CONFIG, 1000)
+ .last_action_portal_url, "")
+for _, kind in ipairs({ "manual_logout", "relogin", "switch_campus", "switch_hotspot" }) do
+ equal("portal.not_after_" .. kind, bridge.status_view(failed(kind, "c1"), CONFIG, 1000)
+ .last_action_portal_url, "")
+end
+-- The account the login was for, not the active one: c2 is not active here.
+local elsewhere = {}
+for key, value in pairs(CONFIG) do elsewhere[key] = value end
+elsewhere.campus_accounts = {
+ CONFIG.campus_accounts[1],
+ { id = "c2", base_url = "https://portal.example.edu:8443/srun_portal_pc?ac_id=8" },
+}
+equal("portal.uses_the_failed_account", bridge.status_view(failed("manual_login", "c2"), elsewhere, 1000)
+ .last_action_portal_url, "https://portal.example.edu:8443")
+equal("portal.unknown_account", bridge.status_view(failed("manual_login", "gone"), CONFIG, 1000)
+ .last_action_portal_url, "")
+
+-- Only a bare origin survives. Everything refused here is something a browser
+-- would resolve differently from what the user sees, or would carry
+-- credentials or control characters into a link.
+for value, want in pairs({
+ ["http://10.0.0.55"] = "http://10.0.0.55",
+ ["http://10.0.0.55/"] = "http://10.0.0.55",
+ [" HTTP://gw.example.edu:801/path "] = "http://gw.example.edu:801",
+ ["http://[fe80::1]:8080/x"] = "http://[fe80::1]:8080",
+ ["http://user:pw@10.0.0.55"] = "",
+ ["http://10.0.0.55\\@evil.example"] = "",
+ ["javascript:alert(1)"] = "",
+ ["ftp://10.0.0.55"] = "",
+ ["http://"] = "",
+ ["http://gw example"] = "",
+ ["http://gw.example:port"] = "",
+ ["http://gw.example:123456"] = "",
+ ["10.0.0.55"] = "",
+ [""] = "",
+}) do
+ equal("portal.origin[" .. value .. "]", bridge.portal_origin(value), want)
+end
+
+-- The progress dialog reads its own receipt through action_feedback.
+local real_call = rpc.call
+local answers = {}
+rpc.call = function(method) return answers[method] end
+answers["config.get"] = CONFIG
+answers["action.get"] = { id = "a1", kind = "manual_login", account_id = "c1", state = "failed", message = "认证失败" }
+equal("feedback.failed_login_portal", bridge.action_feedback("a1").last_action_portal_url, "http://10.0.0.55")
+answers["action.get"] = { id = "a1", kind = "manual_login", account_id = "c1", state = "running" }
+answers["config.get"] = nil
+equal("feedback.running_reads_no_config", bridge.action_feedback("a1").last_action_portal_url, "")
+answers["action.get"] = { id = "a1", kind = "manual_logout", account_id = "c1", state = "failed", message = "x" }
+answers["config.get"] = CONFIG
+equal("feedback.logout_has_no_portal", bridge.action_feedback("a1").last_action_portal_url, "")
+answers["action.get"] = { id = "a1", kind = "manual_login", account_id = "c1", state = "failed", message = "x" }
+answers["config.get"] = nil
+equal("feedback.config_unreadable", bridge.action_feedback("a1").last_action_portal_url, "")
+-- The form's default comes from the daemon's schema, so a page rendered with
+-- a configuration that lacks the key still shows the flag checked.
+answers["schema.get"] = { global = {
+ { path = "log.level", kind = "enum", default = "INFO" },
+ { path = "log.file_omit_info", kind = "bool", default = true },
+} }
+local defaults = bridge.defaults()
+equal("defaults.log_file_omit_info", defaults.log_file_omit_info, "1")
+equal("defaults.log_level", defaults.log_level, "INFO")
+rpc.call = real_call
+
+if failures > 0 then
+ io.stderr:write(string.format("%d check(s) failed\n", failures))
+ os.exit(1)
+end
+print("bridge probe: all checks passed")
diff --git a/smart-srun/tests/lua/cbi_config_state.lua b/smart-srun/tests/lua/cbi_config_state.lua
new file mode 100644
index 000000000..76d54b751
--- /dev/null
+++ b/smart-srun/tests/lua/cbi_config_state.lua
@@ -0,0 +1,157 @@
+local repo = ... or "."
+local harness = dofile(repo .. "/tests/lua/controller_harness.lua")(repo)
+local http = require "luci.http"
+local dispatcher = require "luci.dispatcher"
+local util = require "luci.util"
+dispatcher.build_url = function() return "/cgi-bin/luci/admin/services/smart_srun" end
+http.redirect = function(url) harness.redirect = url end
+util.pcdata = function(value)
+ return tostring(value or ""):gsub("&", "&"):gsub("<", "<"):gsub('"', """)
+end
+
+local stored = {
+ revision = 17, school = "saved-school", enabled = true,
+ selection = {active_campus_id = "c1"},
+ campus_accounts = {{id = "c1", label = "saved-campus", user_id = "student", access_mode = "wired"}},
+ hotspot_profiles = {{id = "h1", label = "saved-hotspot", ssid = "saved-ssid"}},
+ school_extra = {saved = "value"},
+}
+local available = true
+harness.responses["config.get"] = function()
+ if available then return stored end
+ return nil, {code = "Unavailable", message = "RPC "}
+end
+harness.responses["schema.get"] = {global = {
+ {path = "school", default = "default"}, {path = "enabled", default = false},
+}}
+harness.responses["status.get"] = {service = "running", enabled = true}
+harness.responses["config.apply"] = function(params)
+ if params.expected_revision ~= stored.revision then
+ return nil, {code = "Conflict", message = "配置已被修改"}
+ end
+ for key, value in pairs(params.settings) do stored[key] = value end
+ stored.revision = stored.revision + 1
+ return {config_revision = stored.revision}
+end
+
+local function new_form(kind)
+ local form = {kind = kind, fields = {}, children = {}, save = true, uci = {}}
+ function form.uci:get() return true end
+ function form.uci:set() end
+ function form:section()
+ local section = {}
+ function section:tab() end
+ function section:option(_, name)
+ local option = {option = name}
+ function option:value() end
+ function option:depends() end
+ form.fields[name] = option
+ return option
+ end
+ function section:taboption(_, class, name) return self:option(class, name) end
+ form.children[#form.children + 1] = section
+ return section
+ end
+ function form:parse() end
+ return form
+end
+
+local Map = setmetatable({}, {__call = function() return new_form("map") end})
+function Map.parse(self)
+ harness.parse_calls = harness.parse_calls + 1
+ if harness.before_parse then harness.before_parse() end
+ if harness.env.REQUEST_METHOD ~= "POST" then return 0 end
+ for _ = 1, harness.form["cbi.apply"] and 2 or 1 do
+ for key, option in pairs(self.fields) do
+ local value = harness.form["cbid.smart_srun.main." .. key]
+ if value ~= nil and option.write then option:write("main", value) end
+ end
+ end
+ if harness.invalid_parse then self.save = false end
+ return 1
+end
+
+local function request(method, form)
+ harness.reset()
+ harness.redirect, harness.parse_calls = nil, 0
+ harness.env = {REQUEST_METHOD = method or "GET"}
+ harness.form = form or {}
+ local env = setmetatable({Map = Map, SimpleForm = function() return new_form("error") end,
+ SimpleSection = {}, NamedSection = {}, DummyValue = {}, Flag = {}, ListValue = {}, Value = {}}, {__index = _G})
+ local chunk = assert(loadfile(repo .. "/root/usr/lib/lua/luci/model/cbi/smart_srun.lua"))
+ setfenv(chunk, env)
+ local page = chunk()
+ page:parse()
+ return page
+end
+
+local function rejected(page)
+ assert(page.kind == "error", "untrusted configuration must render an independent error form")
+ assert(page.embedded == true, "the CBI route already provides the outer form")
+ assert(page.submit == false and page.reset == false)
+ assert(not page.fields.enabled and not page.fields._tables and not page.fields._setup_wizard)
+ assert(harness.parse_calls == 0 and not harness.last_call("config.apply"))
+ local html = page.fields._config_error:cfgvalue()
+ assert(html:find('href="/cgi-bin/luci/admin/services/smart_srun"', 1, true))
+ assert(not html:find('name="expected_revision"', 1, true))
+end
+
+available = false
+local failed = request()
+rejected(failed)
+assert(failed.fields._config_error:cfgvalue():find("<unavailable>", 1, true))
+for _, call in ipairs(harness.calls) do assert(not call.started, "page reads must not start the service") end
+
+available = true
+rejected(request("POST", {["cbi.submit"] = "1", ["cbid.smart_srun.main.enabled"] = "0"}))
+assert(stored.enabled and stored.school == "saved-school")
+for _, revision in ipairs({"16", "18", "", "-1", "1.5", "1e1", "9007199254740992"}) do
+ rejected(request("POST", {["cbi.submit"] = "1", expected_revision = revision,
+ ["cbid.smart_srun.main.school"] = "default"}))
+end
+local recovered = request()
+assert(recovered.fields.school:cfgvalue() == "saved-school" and recovered.fields.enabled:cfgvalue() == "1")
+assert(recovered.fields._tables:cfgvalue():find("saved-campus", 1, true))
+local html = recovered.fields._overview_status:cfgvalue()
+assert(html:find('id="smart-srun-config-revision"', 1, true))
+assert(html:find('name="expected_revision" value="17"', 1, true))
+
+harness.invalid_parse = true
+request("POST", {["cbi.submit"] = "1", expected_revision = "17", ["cbid.smart_srun.main.enabled"] = "0"})
+assert(not harness.last_call("config.apply") and not harness.redirect and stored.enabled)
+harness.invalid_parse = nil
+
+request("POST", {["cbi.submit"] = "1", ["cbi.apply"] = "1", expected_revision = "17",
+ ["cbid.smart_srun.main.enabled"] = "0"})
+assert(harness.last_call("config.apply").params.expected_revision == 17)
+assert(stored.enabled == false and stored.revision == 18)
+local writes = 0
+for _, call in ipairs(harness.calls) do if call.method == "config.apply" then writes = writes + 1 end end
+assert(writes == 1, "CBI Apply reparsing must not issue a second write")
+assert(harness.redirect == dispatcher.build_url(), "successful saves must reload the complete configuration")
+local next_page = request()
+assert(next_page.fields._overview_status:cfgvalue():find('name="expected_revision" value="18"', 1, true))
+
+harness.before_parse = function() stored.revision = 19 end
+local raced = request("POST", {["cbi.submit"] = "1", expected_revision = "18",
+ ["cbid.smart_srun.main.enabled"] = "1"})
+harness.before_parse = nil
+assert(harness.last_call("config.apply").params.expected_revision == 18)
+assert(stored.enabled == false and not harness.redirect)
+assert(raced.message:find("配置未保存", 1, true))
+assert(raced.fields._overview_status:cfgvalue():find('name="expected_revision" value="18"', 1, true))
+
+for _, revision in ipairs({false, "invalid", -1, 1.5, 9007199254740992}) do
+ stored.revision = revision or nil
+ rejected(request())
+end
+
+stored.revision = 0
+request("POST", {["cbi.submit"] = "1", expected_revision = "0", ["cbid.smart_srun.main.enabled"] = "1"})
+assert(harness.last_call("config.apply").params.expected_revision == 0 and stored.enabled == true)
+assert(harness.redirect)
+
+available = false
+local status, err = harness.bridge.status()
+assert(status == nil and err.code == "Unavailable", "config read failure must propagate through status")
+print("CBI unavailable/recovered/stale/racing/initial configuration checks passed")
diff --git a/smart-srun/tests/lua/controller_actions.lua b/smart-srun/tests/lua/controller_actions.lua
new file mode 100644
index 000000000..8c4cc51f3
--- /dev/null
+++ b/smart-srun/tests/lua/controller_actions.lua
@@ -0,0 +1,353 @@
+-- Status, manual actions and account editing through the real controller.
+--
+-- Every one of these used to be a file the page wrote. What is asserted now is
+-- the request that leaves the page and the answer that reaches the browser: the
+-- account the action names, the revision it carries, the credential it does not
+-- send, and the refusal it does not swallow.
+local repo = ... or "."
+local harness = dofile(repo .. "/tests/lua/controller_harness.lua")(repo)
+local controller = harness.controller
+local PORTAL_URL = "http://portal.example.invalid/srun_portal_pc"
+
+local CONFIG = {
+ revision = 11,
+ selection = { active_campus_id = "c1", default_campus_id = "c2",
+ active_hotspot_id = "h1", default_hotspot_id = "h1" },
+ campus_accounts = {
+ { id = "c1", label = "宿舍", user_id = "student", access_mode = "wired",
+ wired_iface = "wan", auth_enabled = false, login = {} },
+ { id = "c2", label = "教学楼", user_id = "student", access_mode = "wifi",
+ ssid = "Campus", login = {} },
+ },
+ hotspot_profiles = { { id = "h1", label = "手机热点", ssid = "iPhone" } },
+}
+
+local RUNNING = {
+ service = "running", enabled = true, config_revision = 11,
+ written_at = "2026-09-18T12:00:05Z",
+ accounts = { { account_id = "c1", link = "Ready", auth = "Rejected",
+ connectivity = "PortalReachable", identity = "" } },
+ actions = { { id = "a1", kind = "manual_login", state = "failed",
+ message = "用户名或密码错误", ended_at = "2026-09-18T12:00:00Z" } },
+}
+
+harness.responses["config.get"] = CONFIG
+harness.responses["status.get"] = RUNNING
+
+harness.reset()
+harness.responses["config.get"] = { error = { code = "Unavailable", message = "配置读取暂时失败" } }
+controller.action_status()
+assert(harness.output.ok == false, "a configuration failure must not appear as an empty configuration")
+assert(harness.output.service == "unknown", "a read failure cannot confirm that the service stopped")
+assert(harness.output.status == "配置读取暂时失败", harness.output.status)
+assert(#harness.calls == 2 and #harness.helpers == 0)
+for _, call in ipairs(harness.calls) do assert(not call.started) end
+harness.responses["config.get"] = CONFIG
+
+-- The explanation of the last action survives routine status polling, which is
+-- the failure the baseline hit: a daemon tick replaced it seconds after it
+-- appeared, while the user was still reading it.
+controller.action_status()
+local status = harness.output
+assert(status.last_action == "manual_login", status.last_action)
+assert(status.action_result == "error", status.action_result)
+assert(status.last_action_message == "用户名或密码错误", status.last_action_message)
+assert(status.last_action_ts == harness.now - 5, status.last_action_ts)
+assert(status.connectivity == "认证网关可达", status.connectivity)
+assert(status.connectivity_level == "portal", status.connectivity_level)
+assert(status.status == "认证被拒绝", status.status)
+-- Portal evidence is not in the daemon's projection yet (batch B): the field is
+-- published empty rather than filled with a guess.
+assert(status.last_action_portal_url == "", status.last_action_portal_url)
+assert(status.campus_account_label == "宿舍", status.campus_account_label)
+assert(status.hotspot_profile_label == "手机热点", status.hotspot_profile_label)
+-- A read must never start the service.
+for _, call in ipairs(harness.calls) do
+ assert(call.started == false, call.method .. " must not start the service")
+end
+
+-- A stopped service is reported as a state, not as a page error.
+harness.reset()
+harness.responses["status.get"] = { error = { code = "ServiceStopped", message = "认证服务未在运行" } }
+controller.action_status()
+assert(harness.output.ok == false, "a failed status read must be explicit")
+assert(harness.output.status == "认证服务未在运行", harness.output.status)
+assert(harness.output.service == "stopped", harness.output.service)
+assert(harness.output.connectivity_level == "offline")
+harness.responses["status.get"] = RUNNING
+
+-- Overview history is in completion order; wall-clock corrections must not
+-- select an older result or uplink. An ID query remains independent of both.
+local rolled = { service = "running", actions = {
+ {id = "old", kind = "switch_hotspot", state = "succeeded", ended_at = "2026-09-18T12:00:00Z"},
+ {id = "new", kind = "switch_campus", state = "succeeded", ended_at = "2026-09-18T11:00:00Z"},
+} }
+local rolled_view = harness.bridge.status_view(rolled, CONFIG)
+assert(rolled_view.last_action == "switch_campus" and rolled_view.mode == "campus")
+
+for _, state in ipairs({ "queued", "running", "succeeded", "failed", "cancelled", "interrupted" }) do
+ harness.reset()
+ harness.form = { action_id = "instance-a1" }
+ harness.responses["action.get"] = {id = "instance-a1", kind = "manual_login", state = state,
+ account_id = "c1", message = "this request", ended_at = "2026-09-18T11:00:00Z",
+ result = { secret = "private" }}
+ harness.responses["config.get"] = CONFIG
+ controller.action_status()
+ -- Polling costs one read. The one outcome that can offer the school's page
+ -- (a failed manual login) costs one more, of the already-redacted
+ -- configuration, and never starts the service.
+ if state == "failed" then
+ assert(#harness.calls == 2 and #harness.helpers == 0, #harness.calls)
+ assert(harness.calls[2].method == "config.get" and not harness.calls[2].started)
+ else
+ assert(#harness.calls == 1 and #harness.helpers == 0, #harness.calls)
+ end
+ local read = harness.calls[1]
+ assert(read.method == "action.get" and not read.started)
+ assert(read.params.action_id == "instance-a1" and read.params.session == nil)
+ assert(harness.output.action_id == "instance-a1" and harness.output.last_action_message == "this request")
+ local expected = ({queued = "pending", running = "pending", succeeded = "ok", failed = "error",
+ cancelled = "forced", interrupted = "forced"})[state]
+ assert(harness.output.action_result == expected)
+ assert(harness.output.result == nil)
+end
+
+for _, response in ipairs({
+ {id = "instance-a2", kind = "manual_login", state = "succeeded", message = "someone else"},
+ {id = "instance-a1", kind = "detect_operators", state = "succeeded", result = { secret = "private" }},
+ {id = "instance-a1", kind = "manual_login", state = "unknown"},
+ {error = {code = "NotFound"}}, {error = {code = "ServiceStopped", message = "认证服务未在运行"}},
+}) do
+ harness.reset()
+ harness.responses["action.get"] = response
+ controller.action_status()
+ assert(harness.output.action_id == "instance-a1" and harness.output.action_result == "error")
+ assert(harness.output.last_action == "" and harness.output.result == nil)
+ assert(#harness.calls == 1 and not harness.calls[1].started and #harness.helpers == 0)
+end
+for _, invalid in ipairs({ "", string.rep("a", 65), "a/1", "a 1", {} }) do
+ harness.reset()
+ harness.form = { action_id = invalid }
+ controller.action_status()
+ assert(harness.output.action_result == "error" and #harness.calls == 0)
+end
+harness.form = {}
+
+-- A manual action names the active account and carries the revision it read.
+-- Rejected method/token checks must run before any read that starts a service,
+-- submission, config write, log clear or lifecycle helper.
+for _, action in ipairs({ "manual_login", "manual_logout", "switch_campus", "switch_hotspot",
+ "force_stop", "add_campus", "edit_campus", "delete_campus", "set_default_campus",
+ "add_hotspot", "edit_hotspot", "delete_hotspot", "set_default_hotspot" }) do
+ harness.reset()
+ harness.security_allowed = false
+ harness.form = { action = action }
+ controller.action_enqueue()
+ assert(#harness.calls == 0 and #harness.helpers == 0, action .. " bypassed security rejection")
+end
+for _, handler in ipairs({controller.action_log_clear, controller.action_user_presets_set}) do
+ harness.reset()
+ harness.env = { REQUEST_METHOD = "POST" }
+ harness.form = { data = "presets" }
+ harness.parsed = { presets = { presets = {}, operators = {} } }
+ handler()
+ assert(#harness.calls == 0 and #harness.helpers == 0, "write endpoint bypassed security rejection")
+end
+harness.security_allowed = true
+
+harness.reset()
+harness.responses["action.submit"] = { action_id = "a2", state = "queued" }
+harness.form = { action = "manual_login" }
+controller.action_enqueue()
+local submit = assert(harness.last_call("action.submit"))
+assert(submit.params.kind == "manual_login", submit.params.kind)
+assert(submit.params.account_id == "c1", submit.params.account_id)
+assert(submit.params.expected_revision == 11, submit.params.expected_revision)
+assert(submit.params.idempotency_key:find("luci-manual_login-", 1, true) == 1,
+ submit.params.idempotency_key)
+assert(harness.output.ok == true, harness.output.message)
+assert(harness.output.requested_at == harness.now, harness.output.requested_at)
+assert(harness.output.message == "已提交手动登录请求", harness.output.message)
+assert(#harness.writes == 0, "no file may be written")
+assert(#harness.commands == 0, "no shell command may run")
+
+-- Two clicks in the same second are one action, not two logins.
+local first = submit.params.idempotency_key
+harness.reset()
+controller.action_enqueue()
+assert(harness.last_call("action.submit").params.idempotency_key == first)
+
+-- Switching back to campus uses the default account; switching to a hotspot
+-- names the hotspot and no account at all.
+harness.reset()
+harness.form = { action = "switch_campus" }
+controller.action_enqueue()
+assert(harness.last_call("action.submit").params.account_id == "c2")
+
+harness.reset()
+harness.form = { action = "switch_hotspot" }
+controller.action_enqueue()
+local hotspot = harness.last_call("action.submit").params
+assert(hotspot.hotspot_id == "h1", tostring(hotspot.hotspot_id))
+assert(hotspot.account_id == nil, "a hotspot switch does not name a campus account")
+
+-- Background work is lower priority in the Go coordinator. A status preflight
+-- must not prevent the user's request from ever reaching that scheduler.
+for _, background in ipairs({ "maintain", "presets_refresh", "forced_logout", "quiet_hotspot", "quiet_campus" }) do
+ for _, state in ipairs({ "queued", "running" }) do
+ for _, manual in ipairs({ "manual_login", "manual_logout", "switch_campus", "switch_hotspot" }) do
+ harness.reset()
+ harness.responses["status.get"] = { actions = { { kind = background, state = state } } }
+ harness.form = { action = manual }
+ controller.action_enqueue()
+ assert(harness.output.ok == true, background .. ": " .. tostring(harness.output.message))
+ assert(harness.last_call("action.submit").params.kind == manual)
+ end
+ end
+end
+
+-- An action already in flight is refused with the frozen wording, and nothing
+-- is submitted behind it.
+harness.reset()
+harness.responses["status.get"] = {
+ service = "running", enabled = true, written_at = "2026-09-18T12:00:05Z", accounts = {},
+ actions = { { id = "a9", kind = "switch_campus", state = "running" } },
+}
+harness.form = { action = "manual_logout" }
+controller.action_enqueue()
+assert(harness.output.ok == false, "a second action must be refused")
+assert(harness.output.message:find("已有动作正在执行", 1, true), harness.output.message)
+assert(harness.output.pending_action == "switch_campus", harness.output.pending_action)
+assert(harness.last_call("action.submit") == nil, "nothing may be submitted")
+
+-- A wizard or an unknown kind remains a conflicting operation, including when
+-- it follows background work in the snapshot. Only known background kinds skip
+-- the preflight; the daemon can still reject a full/frozen queue itself.
+for _, kind in ipairs({ "manual_login", "relogin", "detect_verify", "wifi_setup_start", "future_action" }) do
+ harness.reset()
+ harness.responses["status.get"] = { actions = {
+ { kind = "maintain", state = "running" },
+ { kind = kind, state = "queued" },
+ } }
+ controller.action_enqueue()
+ assert(harness.output.ok == false)
+ assert(harness.output.pending_action == kind)
+ assert(harness.last_call("action.submit") == nil)
+end
+harness.reset()
+harness.responses["status.get"] = { actions = { { kind = "maintain", state = "running" } } }
+harness.responses["action.submit"] = { error = { code = "Busy", message = "队列已满" } }
+controller.action_enqueue()
+assert(harness.output.ok == false and harness.output.message == "队列已满")
+harness.responses["action.submit"] = { action_id = "a2", state = "queued" }
+harness.responses["status.get"] = RUNNING
+
+-- Force stop is the fixed lifecycle helper. No init script, no process hunt,
+-- and the user's automatic-authentication switch is not touched.
+harness.reset()
+harness.form = { action = "force_stop" }
+controller.action_enqueue()
+assert(harness.helpers[1] == "service stop", tostring(harness.helpers[1]))
+assert(harness.output.ok == true, harness.output.message)
+assert(harness.output.message == "已强制关闭插件并停止服务", harness.output.message)
+assert(harness.last_call("config.apply") == nil, "force stop must not write configuration")
+assert(#harness.commands == 0, "force stop must not run a shell command")
+
+harness.reset()
+harness.stop_failure = { code = "Internal", message = "停不下来" }
+controller.action_enqueue()
+assert(harness.output.ok == false and harness.output.message == "停不下来", harness.output.message)
+harness.stop_failure = nil
+
+-- Editing an account: the legacy alias is read, only the canonical field is
+-- written, and an empty password box keeps the stored credential.
+harness.reset()
+harness.responses["campus.upsert"] = { config_revision = 12, id = "c1" }
+harness.form = {
+ action = "edit_campus", id = "c1", expected_revision = "11", access_mode = "wired", wired_iface = " ",
+ network_interface = " wan.test ", auth_enabled = "1", password = "",
+ user_id = "student", operator_suffix = "telecom", double_stack = "",
+}
+controller.action_enqueue()
+local account = harness.last_call("campus.upsert").params.account
+assert(account.wired_iface == "wan.test", account.wired_iface)
+assert(account.network_interface == nil, "the alias must not be written back")
+assert(account.password == nil, "an empty password box must not clear the credential")
+assert(account.auth_enabled == true)
+assert(account.login.double_stack == harness.rpc.NULL, "an empty override clears it explicitly")
+assert(account.label == "student@telecom", account.label)
+assert(harness.output.message == "已更新", harness.output.message)
+
+-- A new account submits what it was given, including an empty password, and
+-- lets the daemon choose the identifier.
+harness.reset()
+harness.responses["campus.upsert"] = { config_revision = 13, id = "c3" }
+harness.form = { action = "add_campus", expected_revision = "11", access_mode = "wired", wired_iface = "wan",
+ user_id = "fresh", password = "", operator_suffix = "" }
+controller.action_enqueue()
+local created = harness.last_call("campus.upsert").params.account
+assert(created.id == nil, "a new account must not choose its own id")
+assert(created.password == "", "a new account stores what the form gave it")
+assert(created.label == "fresh", created.label)
+assert(harness.output.message == "已添加", harness.output.message)
+
+-- A base URL typed as a bare host still reaches the daemon as a URL.
+harness.reset()
+harness.form = { action = "add_campus", expected_revision = "11", access_mode = "wired", wired_iface = "wan",
+ user_id = "fresh", base_url = "10.0.0.55/srun_portal" }
+controller.action_enqueue()
+assert(harness.last_call("campus.upsert").params.account.base_url == "http://10.0.0.55",
+ harness.last_call("campus.upsert").params.account.base_url)
+
+-- Deleting and defaulting are their own operations, each with the revision.
+for _, case in ipairs({
+ { action = "delete_campus", method = "campus.remove", message = "已删除" },
+ { action = "delete_hotspot", method = "hotspot.remove", message = "已删除" },
+ { action = "set_default_campus", method = "campus.set_default", message = "已设为当前默认账号" },
+ { action = "set_default_hotspot", method = "hotspot.set_default", message = "已设为当前默认热点" },
+}) do
+ harness.reset()
+ harness.responses[case.method] = { config_revision = 14 }
+ harness.form = { action = case.action, id = "c1", expected_revision = "11" }
+ controller.action_enqueue()
+ local call = assert(harness.last_call(case.method), case.method .. " was not called")
+ assert(call.params.id == "c1" and call.params.expected_revision == 11)
+ assert(harness.output.message == case.message, harness.output.message)
+end
+
+-- A configuration conflict is reported, not hidden behind a success.
+harness.reset()
+harness.responses["campus.remove"] = {
+ error = { code = "Conflict", message = "配置已变化,请重新读取后再保存" },
+}
+harness.form = { action = "delete_campus", id = "c1", expected_revision = "11" }
+controller.action_enqueue()
+assert(harness.output.ok == false, "a conflict must not report success")
+assert(harness.output.message == "配置已变化,请重新读取后再保存", harness.output.message)
+
+-- A temporary Wi-Fi account goes through the owned wizard transaction. A
+-- missing/rejected job must not fall back to an ordinary account save.
+package.preload["luci.dispatcher"] = function()
+ return { context = { authsession = "browser-session" }, test_post_security = function() return true end }
+end
+harness.reset()
+harness.responses["setup_wifi.account"] = { error = { code = "NotFound", message = "没有当前会话的无线任务" } }
+harness.form = { action = "add_campus", expected_revision = "11", setup_job = string.rep("a", 32), access_mode = "wifi" }
+controller.action_enqueue()
+assert(harness.output.ok == false, "a wizard save must not pretend to work")
+assert(harness.last_call("campus.upsert") == nil)
+
+-- Log translation is unchanged: raw gateway codes never reach the user.
+for _, code in ipairs({ "no_response_data_error", "not_online_error", "portal_intercept_error",
+ "auth_html_response_error", "auth_response_parse_error" }) do
+ local line = controller.friendly_line(
+ "[2026-06-01 22:00:00] WARN srun_login_response error_code=" .. code)
+ assert(not line:find(code, 1, true), line)
+end
+local multi = controller.friendly_line(
+ "[2026-06-01 22:00:00] WARN multi_wan_session account_id=c1 wired_iface=wan.test | offline")
+assert(not multi:find("multi_wan_session", 1, true), multi)
+assert(multi:find("wan.test", 1, true), multi)
+assert(PORTAL_URL ~= nil)
+
+print("controller actions probe: ok")
diff --git a/smart-srun/tests/lua/controller_ap_selection.lua b/smart-srun/tests/lua/controller_ap_selection.lua
new file mode 100644
index 000000000..ec70e6718
--- /dev/null
+++ b/smart-srun/tests/lua/controller_ap_selection.lua
@@ -0,0 +1,115 @@
+-- AP selection through the real controller and the real bridge.
+--
+-- The policy and its pinned BSSID have to survive the trip from the dialog to
+-- the daemon unchanged, the wired half has to be cleared rather than carried,
+-- and a refusal has to reach the user instead of being swallowed into a save
+-- that appears to have worked.
+local repo = ... or "."
+local harness = dofile(repo .. "/tests/lua/controller_harness.lua")(repo)
+local controller = harness.controller
+local schema = require "luci.smart_srun.schema"
+
+assert(schema.normalize_ap_selection(nil, "02:11:22:33:44:55") == "fixed")
+assert(schema.normalize_ap_selection("auto", "02:11:22:33:44:55") == "auto")
+
+local CONFIG = {
+ revision = 3,
+ selection = { active_campus_id = "c1", default_campus_id = "c1" },
+ campus_accounts = { { id = "c1", label = "one", access_mode = "wifi", ssid = "Campus",
+ bssid = "02:11:22:33:44:55", ap_selection = "fixed", login = {} } },
+ hotspot_profiles = {},
+}
+
+harness.responses["config.get"] = CONFIG
+harness.responses["campus.upsert"] = { config_revision = 4, id = "c1" }
+
+for _, policy in ipairs({ "auto", "strongest", "fixed" }) do
+ harness.reset()
+ harness.form = { action = "edit_campus", id = "c1", expected_revision = "3", access_mode = "wifi",
+ ap_selection = policy, bssid = "02:11:22:33:44:55", ssid = "Campus" }
+ controller.action_enqueue()
+ local call = assert(harness.last_call("campus.upsert"), "the save must reach the daemon")
+ assert(call.started, "a save may start the service")
+ assert(call.params.expected_revision == 3, "the save carries the browser revision")
+ local account = call.params.account
+ assert(account.id == "c1", account.id)
+ assert(account.ap_selection == policy, account.ap_selection)
+ assert(account.bssid == "02:11:22:33:44:55", account.bssid)
+ assert(harness.output.ok == true, harness.output.message)
+ assert(harness.output.message == "已更新", harness.output.message)
+ assert(#harness.writes == 0, "the page must not write any file")
+end
+
+-- An unset policy with a pinned address still means "fixed": the frozen dialog
+-- can submit an empty selector, and losing the pin would silently roam.
+harness.reset()
+harness.form = { action = "edit_campus", id = "c1", expected_revision = "3", access_mode = "wifi",
+ ap_selection = "", bssid = "02:11:22:33:44:55", ssid = "Campus" }
+controller.action_enqueue()
+assert(harness.last_call("campus.upsert").params.account.ap_selection == "fixed")
+
+-- The wired half clears the wireless one, so a stored account never carries an
+-- effective setting for the mode it is not in.
+harness.reset()
+harness.form = { action = "edit_campus", id = "c1", expected_revision = "3", access_mode = "wired",
+ wired_iface = "wan", ap_selection = "fixed", bssid = "02:11:22:33:44:55" }
+controller.action_enqueue()
+local wired = harness.last_call("campus.upsert").params.account
+assert(wired.ssid == "" and wired.bssid == "" and wired.radio == "", "wireless half must be cleared")
+assert(wired.ap_selection == "auto", wired.ap_selection)
+assert(wired.wired_iface == "wan", wired.wired_iface)
+
+-- Validation lives in the daemon now. What the page owes the user is the
+-- refusal, in the daemon's own words, with nothing saved.
+harness.reset()
+harness.responses["campus.upsert"] = {
+ error = { code = "InvalidConfig", message = "不是有效的单播 BSSID(形如 aa:bb:cc:dd:ee:ff)",
+ field = "campus_accounts[0].bssid" },
+}
+harness.form = { action = "edit_campus", id = "c1", expected_revision = "3", access_mode = "wifi",
+ ap_selection = "fixed", bssid = "01:11:22:33:44:55", ssid = "Campus" }
+controller.action_enqueue()
+assert(harness.output.ok == false, "an invalid address must not report success")
+assert(harness.output.message:find("单播 BSSID", 1, true), harness.output.message)
+assert(#harness.writes == 0)
+harness.responses["campus.upsert"] = { config_revision = 5, id = "c1" }
+
+-- The status answer reports the wireless detail it actually has. The daemon's
+-- projection does not carry SSID, BSSID, signal or channel yet (batch B), and
+-- the page publishes them empty rather than repeating the configuration as if
+-- it had been observed.
+harness.reset()
+harness.responses["status.get"] = {
+ service = "running", enabled = true, config_revision = 3,
+ written_at = "2026-09-18T12:00:00Z",
+ accounts = { { account_id = "c1", link = "Ready", auth = "VerifiedSelf",
+ connectivity = "InternetReachable", identity = "student" } },
+ actions = {},
+ wireless = { state = "associated", device = "phy1-sta0", ssid = "Campus",
+ bssid = "02:11:22:33:44:55", signal = -52, channel = 44, iface = "wwan" },
+}
+controller.action_status()
+local status = harness.output
+assert(status.current_ssid == "Campus", status.current_ssid)
+assert(status.current_bssid == "02:11:22:33:44:55", status.current_bssid)
+assert(status.current_signal == -52 and status.current_channel == 44)
+assert(status.ap_selection_policy == "fixed" and status.ap_selection_reason == "已连接指定接入点")
+assert(status.connectivity_level == "online", status.connectivity_level)
+
+-- A line that is not ready claims nothing about the air.
+harness.reset()
+harness.responses["status.get"] = {
+ service = "running", enabled = true, written_at = "2026-09-18T12:00:00Z",
+ accounts = { { account_id = "c1", link = "LinkDown", auth = "Unknown", connectivity = "Offline" } },
+ actions = {},
+}
+controller.action_status()
+assert(harness.output.current_ssid == "", harness.output.current_ssid)
+assert(harness.output.campus_ssid == "Campus", "the configured SSID is still shown as configured")
+
+for _, event in ipairs({ "ap_selection", "ap_association" }) do
+ local line = controller.friendly_line("[2026-06-01 22:00:00] INFO " .. event .. " | fixture")
+ assert(not line:find(event, 1, true), line)
+end
+
+print("ap selection probe: ok")
diff --git a/smart-srun/tests/lua/controller_backup.lua b/smart-srun/tests/lua/controller_backup.lua
new file mode 100644
index 000000000..4bdfba318
--- /dev/null
+++ b/smart-srun/tests/lua/controller_backup.lua
@@ -0,0 +1,22 @@
+local h = dofile(arg[1] .. "/tests/lua/controller_harness.lua")(arg[1])
+h.security_allowed = false
+h.controller.action_config_export(); h.controller.action_config_import()
+assert(#h.calls == 0 and #h.helpers == 0)
+h.security_allowed = true
+local exported = '{"format":"smart-srun-config","config":{"school_extra":{},"campus_accounts":[{"login":{}}],"hotspot_profiles":[]}}'
+h.responses["config.export"] = {data=exported}
+h.controller.action_config_export()
+assert(h.calls[1].params.include_secrets == true and not h.calls[1].started)
+assert(h.calls[1].params.as_json == true and h.output == exported)
+assert(h.headers["Cache-Control"] == "no-store")
+h.responses["config.import"] = {ok=true,expected_revision=15,campus_accounts=2,hotspot_profiles=1}
+h.form = {data='{ "duplicate":1,"duplicate":2 }',check="1"}
+h.controller.action_config_import()
+assert(h.calls[2].params.data == h.form.data and h.calls[2].params.check_only == true)
+h.form = {data="synthetic backup",expected_revision="15"}
+h.controller.action_config_import()
+assert(h.calls[3].params.expected_revision == 15 and not h.calls[3].params.check_only)
+assert(not h.calls[3].started and #h.helpers == 0 and #h.writes == 0 and #h.commands == 0)
+h.form.expected_revision="-1";h.controller.action_config_import();assert(#h.calls==3 and h.output.ok==false)
+h.responses["config.export"] = {data={}}
+h.controller.action_config_export();assert(h.output.ok==false)
diff --git a/smart-srun/tests/lua/controller_config_revision.lua b/smart-srun/tests/lua/controller_config_revision.lua
new file mode 100644
index 000000000..373208981
--- /dev/null
+++ b/smart-srun/tests/lua/controller_config_revision.lua
@@ -0,0 +1,74 @@
+local repo = ... or "."
+local h = dofile(repo .. "/tests/lua/controller_harness.lua")(repo)
+
+local cases = {
+ { action = "add_campus", method = "campus.upsert" },
+ { action = "edit_campus", method = "campus.upsert" },
+ { action = "add_hotspot", method = "hotspot.upsert" },
+ { action = "edit_hotspot", method = "hotspot.upsert" },
+ { action = "delete_campus", method = "campus.remove" },
+ { action = "delete_hotspot", method = "hotspot.remove" },
+ { action = "set_default_campus", method = "campus.set_default" },
+ { action = "set_default_hotspot", method = "hotspot.set_default" },
+ { action = "add_campus", method = "setup_wifi.account", setup_job = string.rep("a", 32) },
+}
+
+for _, case in ipairs(cases) do
+ local revision = 11
+ local writes = 0
+ h.responses["config.get"] = { revision = revision }
+ h.responses[case.method] = function(params)
+ if params.expected_revision ~= revision then
+ return nil, { code = "Conflict", message = "配置已变化,请刷新页面后再保存" }
+ end
+ writes = writes + 1
+ revision = revision + 1
+ return { config_revision = revision }
+ end
+
+ h.reset()
+ h.form = { action = case.action, id = "saved-id", expected_revision = "10",
+ setup_job = case.setup_job }
+ h.controller.action_enqueue()
+ assert(h.output.ok == false, case.action .. " accepted a stale browser revision")
+ assert(writes == 0, case.action .. " overwrote a newer configuration")
+ assert(h.last_call(case.method).params.expected_revision == 10)
+ assert(h.last_call("config.get") == nil, case.action .. " replaced the browser revision")
+ assert(#h.calls == 1, case.action .. " retried a rejected write")
+
+ for _, invalid in ipairs({ false, "", "-1", "1.5", "1e1", "0x10", " 11", "11 ",
+ "9007199254740992", {}, "NaN", "Infinity" }) do
+ h.reset()
+ h.form.expected_revision = invalid
+ if invalid == false then h.form.expected_revision = nil end
+ h.controller.action_enqueue()
+ assert(h.output.ok == false, case.action .. " accepted an invalid revision")
+ assert(#h.calls == 0 and #h.helpers == 0, case.action .. " made an RPC with an invalid revision")
+ end
+
+ h.responses["config.get"] = { error = { code = "Unavailable" } }
+ for _, valid in ipairs({ "0", "11", "9007199254740991" }) do
+ h.reset()
+ revision = tonumber(valid)
+ h.form.expected_revision = valid
+ h.controller.action_enqueue()
+ assert(h.output.ok == true, case.action .. ": " .. tostring(h.output.message))
+ assert(h.last_call(case.method).params.expected_revision == tonumber(valid))
+ assert(#h.calls == 1 and h.last_call("config.get") == nil)
+ end
+
+ if not case.setup_job then
+ h.reset()
+ revision = 11
+ h.form.expected_revision = "11"
+ h.controller.action_enqueue()
+ assert(h.output.ok == true)
+ local prior_writes = writes
+ h.reset()
+ h.controller.action_enqueue()
+ assert(h.output.ok == false and writes == prior_writes, case.action .. " accepted a duplicate old edit")
+ assert(#h.calls == 1 and h.last_call(case.method).params.expected_revision == 11)
+ end
+end
+
+print("controller config revision probe: ok")
diff --git a/smart-srun/tests/lua/controller_discovery.lua b/smart-srun/tests/lua/controller_discovery.lua
new file mode 100644
index 000000000..0659828f1
--- /dev/null
+++ b/smart-srun/tests/lua/controller_discovery.lua
@@ -0,0 +1,81 @@
+local root = arg[1]
+local h = dofile(root .. "/tests/lua/controller_harness.lua")(root)
+local allowed = true
+package.preload["luci.dispatcher"] = function()
+ return { context = { authsession = "browser-session" }, test_post_security = function() return allowed end }
+end
+
+h.responses["detect.verify"] = { action_id = "verify1", state = "queued" }
+h.form = { user_id = "student", password = " exact secret ", read_only = "0", candidates = "candidates", iface = "wan", access_mode = "wired" }
+h.parsed = { candidates = { "" } }
+h.controller.action_detect_operator()
+local params = h.last_call("detect.verify").params
+assert(params.password == " exact secret ", "password whitespace changed")
+assert(params.session == "browser-session" and params.candidates[1] == "")
+assert(#h.writes == 0 and #h.commands == 0, "draft used a file or shell")
+
+h.reset()
+h.form.read_only = "1"
+h.responses["detect.identity"] = { action_id = "identity1", state = "queued" }
+h.controller.action_detect_operator()
+params = h.last_call("detect.identity").params
+assert(params.password == nil and params.candidates == nil, "identity got credentials")
+
+h.reset()
+allowed = false
+h.controller.action_detect_operator()
+assert(#h.calls == 0, "CSRF rejection reached RPC")
+allowed = true
+
+h.reset()
+h.form = { iface = "wan", idempotency_key = "refresh1" }
+h.responses["presets.refresh"] = { action_id = "refresh1", state = "queued" }
+h.controller.action_presets_refresh()
+params = h.last_call("presets.refresh").params
+assert(params.iface == "wan" and params.session == "browser-session" and params.base_url == nil)
+
+h.reset()
+h.form = { action = "status", action_id = "refresh1" }
+h.responses["action.get"] = { id = "refresh1", state = "succeeded" }
+h.responses["presets.list"] = function(p)
+ if p.offset == 0 then return { public = {{short_name="first"}}, user = {}, revision=1, next_offset=1 } end
+ assert(p.offset == 1)
+ return { public = {}, user = {{short_name="last"}}, revision=1 }
+end
+h.controller.action_presets_refresh()
+assert(#h.output.result.schools == 2 and h.output.result.schools[2].short_name == "last")
+assert(h.last_call("presets.refresh") == nil, "polling repeated refresh")
+assert(h.last_call("action.get").params.session == "browser-session")
+
+h.responses["presets.list"] = function(p)
+ return { public = {}, user = {}, revision = 1, next_offset = p.offset }
+end
+local result, err = h.bridge.presets()
+assert(result == nil and err.code == "ProtocolInvalid", "non-advancing pagination accepted")
+h.reset()
+h.form = { action = "start", job = string.rep("a", 32), ssid = " exact SSID ", key = " exact wifi key ", encryption = "psk2" }
+h.responses["setup_wifi.start"] = { ok = true, state = "starting" }
+h.controller.action_setup_wifi()
+params = h.last_call("setup_wifi.start").params
+assert(params.ssid == " exact SSID " and params.key == " exact wifi key " and params.session == "browser-session")
+assert(h.last_call("setup_wifi.start").started and #h.writes == 0 and #h.commands == 0)
+h.reset()
+h.form.action = "status"
+h.responses["setup_wifi.status"] = { ok = true, state = "ready" }
+h.controller.action_setup_wifi()
+assert(not h.last_call("setup_wifi.status").started and h.last_call("setup_wifi.status").params.key == nil)
+h.reset()
+allowed = false
+h.form.action = "cancel"
+h.controller.action_setup_wifi()
+assert(#h.calls == 0, "wifi cancel ignored CSRF rejection")
+allowed = true
+h.reset()
+h.form = { action = "add_campus", expected_revision = "7", setup_job = string.rep("a", 32), user_id = "student", password = "secret", access_mode = "wifi", ssid = "draft" }
+h.responses["config.get"] = { revision = 7 }
+h.responses["setup_wifi.account"] = { config_revision = 8, id = "c1" }
+h.controller.action_enqueue()
+params = h.last_call("setup_wifi.account").params
+assert(params.job == string.rep("a", 32) and params.session == "browser-session" and params.expected_revision == 7)
+assert(h.last_call("campus.upsert") == nil and #h.writes == 0 and #h.commands == 0)
+print("PASS discovery controller: secrets, read-only, CSRF, refresh, wifi lifecycle and account save")
diff --git a/smart-srun/tests/lua/controller_harness.lua b/smart-srun/tests/lua/controller_harness.lua
new file mode 100644
index 000000000..3d633a7bb
--- /dev/null
+++ b/smart-srun/tests/lua/controller_harness.lua
@@ -0,0 +1,173 @@
+-- Loads the real LuCI controller against a recording stand-in for the daemon.
+--
+-- The controller's job is now translation: form values in, one RPC out, the
+-- daemon's answer back to the browser. A harness that stubs the socket rather
+-- than the filesystem is what makes that testable -- and it fails loudly if the
+-- page ever goes back to writing a file, because nothing here provides one.
+--
+-- Usage:
+-- local harness = dofile("tests/lua/controller_harness.lua")(repo_root)
+-- harness.responses["config.get"] = { revision = 3, ... }
+-- harness.form = { action = "manual_login" }
+-- harness.controller.action_enqueue()
+-- assert(harness.calls[1].method == "status.get")
+
+return function(repo_root)
+ local harness = {
+ form = {},
+ output = nil,
+ calls = {},
+ responses = {},
+ writes = {},
+ commands = {},
+ helpers = {},
+ now = 1000,
+ security_allowed = true,
+ }
+
+ local function encode(value)
+ -- The controller hands one table to the response writer; keeping the
+ -- table itself is more useful to a test than a string of it.
+ return value
+ end
+
+ package.preload["luci.http"] = function()
+ return {
+ formvalue = function(key) return harness.form[key] end,
+ getenv = function(key) return harness.env and harness.env[key] end,
+ prepare_content = function() end,
+ header = function(key, value) harness.headers = harness.headers or {}; harness.headers[key] = value end,
+ write = function(value) harness.output = value end,
+ }
+ end
+ package.preload["luci.jsonc"] = function()
+ return {
+ parse = function(text) return harness.parsed and harness.parsed[text] or nil end,
+ stringify = encode,
+ }
+ end
+ package.preload["luci.dispatcher"] = function()
+ return { context = { authsession = "browser-session" },
+ test_post_security = function() return harness.security_allowed end }
+ end
+ package.preload["luci.sys"] = function()
+ return {
+ exec = function(command)
+ harness.commands[#harness.commands + 1] = command
+ return ""
+ end,
+ call = function(command)
+ harness.commands[#harness.commands + 1] = command
+ return 0
+ end,
+ }
+ end
+ package.preload["luci.util"] = function()
+ return {
+ trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") end,
+ pcdata = function(value) return tostring(value or "") end,
+ shellquote = function(value) return "'" .. tostring(value or "") .. "'" end,
+ }
+ end
+ package.preload["nixio.fs"] = function()
+ return {
+ readfile = function() return nil end,
+ writefile = function(path, value)
+ harness.writes[#harness.writes + 1] = { path = path, value = value }
+ return true
+ end,
+ access = function() return false end,
+ mkdirr = function() return true end,
+ remove = function() return true end,
+ unlink = function() return true end,
+ rename = function() return true end,
+ stat = function() return nil end,
+ dir = function() return function() return nil end end,
+ }
+ end
+ package.preload["nixio"] = function()
+ return {
+ getpid = function() return 4242 end,
+ open_flags = function() return 0 end,
+ open = function() return { lock = function() return true end, close = function() end } end,
+ }
+ end
+
+ -- The stand-in daemon. Unset methods answer "not implemented" rather than
+ -- an empty success, so a call the test did not plan for cannot pass as one.
+ package.preload["luci.smart_srun.rpc"] = function()
+ local rpc = { NULL = "\1null\1", EMPTY_OBJECT = "\1{}\1" }
+
+ local function record(method, params, started)
+ harness.calls[#harness.calls + 1] =
+ { method = method, params = params, started = started or false }
+ local canned = harness.responses[method]
+ if type(canned) == "function" then
+ return canned(params)
+ end
+ if canned == nil then
+ return nil, { code = "Internal", message = "本次测试没有为 " .. method .. " 准备响应" }
+ end
+ if canned.error then
+ return nil, canned.error
+ end
+ return canned
+ end
+
+ function rpc.call(method, params) return record(method, params, false) end
+ function rpc.call_started(method, params) return record(method, params, true) end
+ function rpc.stop_service()
+ harness.helpers[#harness.helpers + 1] = "service stop"
+ if harness.stop_failure then
+ return nil, harness.stop_failure
+ end
+ return true
+ end
+ function rpc.ensure_running()
+ harness.helpers[#harness.helpers + 1] = "service ensure-running"
+ return true
+ end
+ function rpc.service_stopped(err)
+ return type(err) == "table" and err.code == "ServiceStopped"
+ end
+ function rpc.message(err, fallback)
+ if type(err) == "table" and tostring(err.message or "") ~= "" then
+ return err.message
+ end
+ return fallback or "操作失败"
+ end
+ return rpc
+ end
+
+ package.path = repo_root .. "/root/usr/lib/lua/?.lua;" .. package.path
+ dofile(repo_root .. "/root/usr/lib/lua/luci/controller/smart_srun.lua")
+ harness.controller = package.loaded["luci.controller.smart_srun"]
+ harness.rpc = require "luci.smart_srun.rpc"
+ harness.bridge = require "luci.smart_srun.bridge"
+
+ -- A fixed clock, so an age computed from two timestamps is a stated number
+ -- rather than whatever the machine running the test happens to say.
+ local real_time = os.time
+ os.time = function(parts)
+ if parts then return real_time(parts) end
+ return harness.now
+ end
+
+ function harness.last_call(method)
+ for index = #harness.calls, 1, -1 do
+ if harness.calls[index].method == method then
+ return harness.calls[index]
+ end
+ end
+ return nil
+ end
+
+ function harness.reset()
+ harness.calls = {}
+ harness.output = nil
+ harness.writes = {}
+ harness.helpers = {}
+ end
+
+ return harness
+end
diff --git a/smart-srun/tests/lua/stub_env.lua b/smart-srun/tests/lua/stub_env.lua
new file mode 100644
index 000000000..ab5f49b88
--- /dev/null
+++ b/smart-srun/tests/lua/stub_env.lua
@@ -0,0 +1,24 @@
+-- Stubs for the two LuCI modules the bridge pulls in through rpc.lua.
+--
+-- The bridge's mapping is pure; loading it must not require a router. nixio is
+-- reduced to the one call rpc makes at load time, and jsonc to encode/decode
+-- shapes the probe never exercises -- if a mapping function ever starts using
+-- them, these stubs fail loudly rather than silently returning nothing.
+package.preload["nixio"] = function()
+ return {
+ getpid = function() return 4242 end,
+ socket = function() error("the probe must not open a socket") end,
+ fork = function() error("the probe must not fork") end,
+ }
+end
+
+-- rpc.lua loads nixio.util for the socket's writeall/readall. Loading it adds
+-- methods and returns nothing the probe calls.
+package.preload["nixio.util"] = function() return {} end
+
+package.preload["luci.jsonc"] = function()
+ return {
+ stringify = function() error("the probe must not encode a frame") end,
+ parse = function() error("the probe must not decode a frame") end,
+ }
+end
diff --git a/smart-srun/tests/lua/version_probe.lua b/smart-srun/tests/lua/version_probe.lua
new file mode 100644
index 000000000..bddbded35
--- /dev/null
+++ b/smart-srun/tests/lua/version_probe.lua
@@ -0,0 +1,15 @@
+local root = assert(arg[1])
+package.path = root .. '/root/usr/lib/lua/?.lua;' .. package.path
+local status = ''
+package.preload['nixio.fs'] = function() return { readfile = function(path)
+ return path == '/usr/lib/opkg/status' and status or nil
+end } end
+package.preload['nixio'] = function() return {} end
+package.preload['luci.jsonc'] = function() return {parse = function() return {} end} end
+local schema = require 'luci.smart_srun.schema'
+for native, expected in pairs({ ['2.0.0~rc39-r1'] = 'v2.0.0rc39', ['2.0.0_rc2-r1'] = 'v2.0.0rc2',
+ ['2.0.0-r1'] = 'v2.0.0', ['1.6.0-1'] = 'v1.6.0' }) do
+ status = 'Package: luci-app-smart-srun-bundle\nVersion: ' .. native .. '\nStatus: install ok installed\n\n'
+ assert(schema.installed_package_display_text() == 'Bundle 版 ' .. expected, native)
+end
+print('native version display: passed')
diff --git a/smart-srun/tests/test_ap_selection_ui.py b/smart-srun/tests/test_ap_selection_ui.py
new file mode 100644
index 000000000..bc0f14fcb
--- /dev/null
+++ b/smart-srun/tests/test_ap_selection_ui.py
@@ -0,0 +1,270 @@
+"""Execute AP policy editing and status rendering through shipped LuCI code."""
+
+import json
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+JS = ROOT / "root/www/luci-static/resources/smart_srun.js"
+LUA = ROOT / "root/usr/lib/lua/luci"
+BSSID = "02:11:22:33:44:55"
+
+
+class APSelectionUITests(unittest.TestCase):
+ def run_js(self, scenario):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ script = r"""
+const fs = require('fs'), vm = require('vm');
+const scenario = JSON.parse(process.argv[1]);
+let source = fs.readFileSync(process.argv[2], 'utf8');
+const end = source.lastIndexOf('})();');
+source = source.slice(0, end) + `
+window.apTest = { normalize: normalizeApSelection, update: updateCampusAccessModeUI,
+ overview: initOverview, edit: function() { modalType = 'campus'; modalEditId = 'one'; } };
+` + source.slice(end);
+const nodes = {}, sent = [], alerts = [];
+nodes['smart-srun-config-revision'] = {value: '17'};
+['label', 'user_id', 'operator_suffix', 'access_mode', 'wired_iface', 'auth_enabled',
+ 'password', 'base_url', 'ac_id', 'login-n', 'login-type', 'login-enc', 'info-prefix',
+ 'double-stack', 'login-os', 'login-name', 'ssid', 'bssid', 'radio', 'ap_selection'].forEach(key => {
+ nodes['jm-' + key] = {value: '', style: {}};
+});
+['wired-iface', 'auth-enabled', 'ssid', 'bssid', 'radio', 'ap-selection'].forEach(key => {
+ nodes['jm-' + key + '-row'] = {style: {}};
+});
+['smart-srun-overview', 'smart-srun-overview-title', 'smart-srun-overview-meta',
+ 'smart-srun-overview-details-content', 'smart-srun-overview-pending'].forEach(key => {
+ nodes[key] = {style: {}};
+});
+nodes['jm-access_mode'].value = scenario.mode || 'wifi';
+nodes['jm-ap_selection'].value = scenario.policy || 'auto';
+nodes['jm-bssid'].value = scenario.bssid || '';
+function FormData() { this.fields = {}; }
+FormData.prototype.append = function(key, value) { this.fields[key] = value; };
+function XHR() {}
+XHR.prototype.open = function(method, url) { this.url = url; this.method = method; };
+XHR.prototype.send = function(body) {
+ if (this.method === 'POST') { sent.push(body.fields); return; }
+ this.readyState = 4; this.status = 200;
+ this.responseText = JSON.stringify(Object.assign({status: '未知'}, scenario.status));
+ this.onreadystatechange();
+};
+const context = { window: {setInterval() {}}, document: {
+ readyState: 'loading', addEventListener() {},
+ querySelector(selector) { return selector === 'input[name="token"]' ? {value: 'test-csrf'} : null; },
+ getElementById(id) { return nodes[id] || null; }
+}, XMLHttpRequest: XHR, FormData, alert(value) { alerts.push(value); }, Date, JSON };
+vm.runInNewContext(source, context);
+context.window.apTest.edit();
+context.window.apTest.update();
+context.window.smartModalSave();
+context.window.apTest.overview();
+console.log(JSON.stringify({ sent, alerts, bssidDisabled: nodes['jm-bssid'].disabled,
+ policyDisabled: nodes['jm-ap_selection'].disabled,
+ normalizations: [context.window.apTest.normalize(null, scenario.bssid),
+ context.window.apTest.normalize('auto', scenario.bssid)],
+ overview: nodes['smart-srun-overview-details-content'].innerHTML,
+ summary: nodes['smart-srun-overview-meta'].innerHTML,
+ title: nodes['smart-srun-overview-title'].textContent,
+ pending: nodes['smart-srun-overview-pending'].textContent }));
+"""
+ output = subprocess.run(
+ [node, "-e", script, json.dumps(scenario), str(JS)],
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", timeout=15,
+ )
+ self.assertEqual(output.returncode, 0, output.stderr)
+ result = json.loads(output.stdout)
+ result["fields"] = dict(re.findall(r"(.*?) ]*>(.*?) ", result["overview"]))
+ return result
+
+ def test_auto_and_strongest_disable_fixed_field_but_preserve_remembered_value(self):
+ for policy in ("auto", "strongest"):
+ with self.subTest(policy=policy):
+ output = self.run_js({"policy": policy, "bssid": BSSID})
+ self.assertTrue(output["bssidDisabled"])
+ self.assertFalse(output["policyDisabled"])
+ self.assertEqual(output["sent"][0]["ap_selection"], policy)
+ self.assertEqual(output["sent"][0]["bssid"], BSSID)
+ self.assertEqual(output["normalizations"], ["fixed", "auto"])
+
+ def test_fixed_invalid_does_not_send_or_lock_save_and_wired_ignores_it(self):
+ for address in ("", "bad", "ff:ff:ff:ff:ff:ff", "01:11:22:33:44:55"):
+ with self.subTest(address=address):
+ output = self.run_js({"policy": "fixed", "bssid": address})
+ self.assertFalse(output["bssidDisabled"])
+ self.assertEqual(output["sent"], [])
+ self.assertIn("固定 BSSID", output["alerts"][0])
+ valid = self.run_js({"policy": "fixed", "bssid": BSSID})
+ self.assertEqual(valid["alerts"], [])
+ self.assertEqual(valid["sent"][0]["bssid"], BSSID)
+ wired = self.run_js({"mode": "wired", "policy": "fixed"})
+ self.assertTrue(wired["bssidDisabled"])
+ self.assertTrue(wired["policyDisabled"])
+ self.assertEqual(len(wired["sent"]), 1)
+
+ def test_status_uses_observed_values_escapes_reason_and_keeps_unknown_signal(self):
+ output = self.run_js({"status": {
+ "current_bssid": BSSID, "current_wireless_ifname": "phy0-sta0",
+ "current_signal": -47, "current_channel": 36,
+ "ap_selection_policy": "strongest", "ap_selection_reason": "",
+ }})
+ self.assertIn("-47 dBm", output["overview"])
+ self.assertEqual(output["fields"]["实际 AP"], BSSID)
+ self.assertEqual(output["fields"]["信道"], "36")
+ self.assertIn("<untrusted>", output["overview"])
+ missing = self.run_js({"status": {"campus_bssid": BSSID, "current_signal": None}})
+ self.assertEqual(missing["fields"]["实际 AP"], "未知")
+ self.assertEqual(missing["fields"]["信号"], "未知")
+ self.assertNotIn(BSSID, missing["overview"])
+ self.assertNotIn("0 dBm", missing["overview"])
+
+ def test_wired_hides_ap_fields_and_hotspot_only_shows_observations(self):
+ wired = self.run_js({"status": {"current_campus_access_mode": "wired"}})
+ self.assertNotIn("实际 AP", wired["fields"])
+ self.assertNotIn("信号", wired["fields"])
+ hotspot = self.run_js({"status": {"mode": "hotspot", "current_bssid": BSSID, "current_signal": -55}})
+ self.assertEqual(hotspot["fields"]["实际 AP"], BSSID)
+ self.assertIn("-55 dBm", hotspot["overview"])
+ self.assertNotIn("AP 选择", hotspot["fields"])
+ self.assertNotIn("选择说明", hotspot["fields"])
+
+ def test_overview_summary_hides_diagnostics_but_keeps_actions_and_errors_visible(self):
+ output = self.run_js({"status": {
+ "status": "在线,下一次检测间隔 45 秒", "current_ssid": "Campus",
+ "current_ip": "192.0.2.4", "online_account_label": "student", "current_signal": -47,
+ "current_bssid": BSSID, "pending_action": "login",
+ }})
+ self.assertEqual(output["title"], "在线")
+ self.assertIn("Campus<WiFi>", output["summary"])
+ self.assertIn("student", output["summary"])
+ for technical in (BSSID, "192.0.2.4", "-47", "45 秒"):
+ self.assertNotIn(technical, output["summary"])
+ self.assertIn(technical, output["overview"])
+ self.assertIn("login", output["pending"])
+ failure = self.run_js({"status": {"status": "认证失败:密码错误"}})
+ self.assertEqual(failure["title"], "认证失败:密码错误")
+ wired = self.run_js({"status": {"current_campus_access_mode": "wired", "current_iface": "wan"}})
+ self.assertIn("有线网络", wired["summary"])
+ self.assertIn("wan", wired["summary"])
+ self.assertNotIn("Wi-Fi", wired["summary"])
+
+ def test_advanced_fields_and_ssr_use_policy_and_observed_state(self):
+ source = JS.read_text(encoding="utf-8")
+ advanced = source.split('进阶设置 ', 1)[1].split("''", 1)[0]
+ for field in ("jm-ap_selection", "jm-radio", "jm-bssid"):
+ self.assertIn('id="' + field + '"', advanced)
+ cbi = (LUA / "model/cbi/smart_srun.lua").read_text(encoding="utf-8")
+ overview = cbi.split("function overview_status.cfgvalue()", 1)[1].split("\nend", 1)[0]
+ for field in ("current_bssid", "current_signal", "current_channel", "current_wireless_ifname"):
+ self.assertIn("state." + field, overview)
+ self.assertNotIn("campus_bssid", overview)
+ self.assertIn('', overview)
+ self.assertNotIn(' T27;T39;T51.)
+ """
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ result = subprocess.run(
+ [lua, (ROOT / "tests/lua/controller_ap_selection.lua").as_posix(),
+ ROOT.as_posix()],
+ cwd=str(ROOT), stdin=subprocess.DEVNULL, capture_output=True,
+ text=True, encoding="utf-8", timeout=15,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
+
+
+ def test_account_table_only_displays_remembered_bssid_for_fixed_policy(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ script = r"""
+local fs = {readfile=function() return nil end}
+local jsonc = {parse=function() return nil end, stringify=function() return "[]" end}
+package.preload["nixio.fs"] = function() return fs end
+package.preload["luci.jsonc"] = function() return jsonc end
+package.preload["nixio"] = function() return {} end
+package.preload["nixio.util"] = function() return {} end
+local schema = dofile(SCHEMA_PATH)
+local util = {pcdata=function(value) return tostring(value or "") end,
+ trim=function(value) return tostring(value or ""):match("^%s*(.-)%s*$") end}
+local function load_state() return {current_mode="campus", current_campus_access_mode="wifi",
+ current_ssid="Campus", current_bssid="02:66:77:88:99:aa", online_account_label="student"} end
+local handle = assert(io.open(CBI_PATH, "rb"))
+local source = handle:read("*a"):gsub("\r\n", "\n")
+handle:close()
+local body = assert(source:match("function tables_html%.cfgvalue%(%)\n(.-)\nend"))
+local render = assert(loadstring("return function() " .. body .. " end"))()
+-- The user-preset store now comes from the daemon; a page that cannot reach
+-- it still has to render the account table.
+local rpc = {call=function() return nil end}
+local context = setmetatable({cfg={}, RADIO_CHOICES={}, school_presets={},
+ schema=schema, util=util, fs=fs, jsonc=jsonc, rpc=rpc, load_state=load_state}, {__index=_G})
+setfenv(render, context)
+for _, case in ipairs({
+ {policy="auto"}, {policy="strongest"}, {policy="fixed", fixed=true},
+ {fixed=true}, {policy="fixed", access="wired"}
+}) do
+ context.cfg = {campus_accounts={{id="one", user_id="student", ssid="Campus",
+ bssid="02:11:22:33:44:55", ap_selection=case.policy, access_mode=case.access or "wifi"}}}
+ local rows = render():match("(.-) ")
+ local label = case.access == "wired" and string.char(226, 128, 148)
+ or schema.ap_selection_label(case.policy or "fixed")
+ assert(rows:find(label, 1, true), rows)
+ assert((rows:find("02:11:22:33:44:55", 1, true) ~= nil) == (case.fixed == true), rows)
+end
+""".replace("SCHEMA_PATH", json.dumps((LUA / "smart_srun/schema.lua").as_posix())).replace(
+ "CBI_PATH", json.dumps((LUA / "model/cbi/smart_srun.lua").as_posix())
+ )
+ subprocess.run([lua, "-e", script], check=True, stdin=subprocess.DEVNULL,
+ capture_output=True, text=True, encoding="utf-8", timeout=15)
+
+ def test_asset_url_tracks_js_mtime_and_size_with_unavailable_stat_fallback(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ script = r"""
+local handle = assert(io.open(CBI_PATH, "rb"))
+local source = handle:read("*a"):gsub("\r\n", "\n")
+handle:close()
+local body = assert(source:match("local function render_js_asset_tag%(%)\n(.-)\nend"))
+local render = assert(loadstring("return function() " .. body .. " end"))()
+local path = "/luci-static/resources/smart_srun.js"
+local current
+local fs = {stat=function(actual) assert(actual == "/www" .. path); return current end}
+local context = setmetatable({JS_ASSET_PATH=path, fs=fs,
+ util={pcdata=function(value) return value end}}, {__index=_G})
+setfenv(render, context)
+for _, case in ipairs({
+ {stat={mtime=0, size=72217}, query="?v=0-72217"},
+ {stat={mtime=0, size=72218}, query="?v=0-72218"},
+ {stat={mtime=123, size=72218}, query="?v=123-72218"},
+ {query=""}, {stat={mtime=0}, query=""}, {stat={mtime="invalid", size=10}, query=""}
+}) do
+ current = case.stat
+ assert(render() == '')
+end
+fs.stat = nil
+assert(render() == '')
+""".replace("CBI_PATH", json.dumps((LUA / "model/cbi/smart_srun.lua").as_posix()))
+ subprocess.run([lua, "-e", script], check=True, stdin=subprocess.DEVNULL,
+ capture_output=True, text=True, encoding="utf-8", timeout=15)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_ci_go_release.py b/smart-srun/tests/test_ci_go_release.py
new file mode 100644
index 000000000..558096813
--- /dev/null
+++ b/smart-srun/tests/test_ci_go_release.py
@@ -0,0 +1,225 @@
+"""Release automation must refuse wrong identities and preserve verified bytes."""
+import hashlib
+import json
+import os
+from pathlib import Path
+import sys
+import tempfile
+from types import SimpleNamespace
+import unittest
+from unittest.mock import patch
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'scripts'))
+import ci_go_release as release # noqa: E402
+import check_go_source as source # noqa: E402
+
+
+class ReleaseAutomationTests(unittest.TestCase):
+ def test_assembly_forwards_exact_firmware_install_evidence(self):
+ digest = 'b' * 64
+ evidence = {'schema_version': 1, 'assets': {digest: {'elf': True}},
+ 'firmware_compatibility': {digest: {'25.12': {'openwrt_install': True}}}}
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ folder = root / 'one'
+ folder.mkdir()
+ target = {'id': 'synthetic-target', 'format': 'apk'}
+ (folder / 'build-record.json').write_text(json.dumps({
+ 'source_commit': 'a' * 40, 'source_dirty': False, 'display_version': '2.0.0rc3',
+ 'target': target, 'artifacts': [{'signature_spki_sha256': 'c' * 64}]}))
+ (folder / 'ci.json').write_text(json.dumps({
+ 'official_signing': False, 'repository': 'synthetic/repo', 'run_id': '123'}))
+ (folder / 'validation.json').write_text(json.dumps(evidence))
+ (folder / 'apk-public.pem').write_text('synthetic public key')
+ (folder / 'apk-tools').write_bytes(b'synthetic tool')
+ args = SimpleNamespace(input=root, output=root / 'release', version='2.0.0rc3', official=False)
+ with patch.dict(os.environ, {'GITHUB_REPOSITORY': 'synthetic/repo', 'GITHUB_RUN_ID': '123'}), \
+ patch.object(release, 'catalog', return_value={'targets': [target]}), \
+ patch.object(release, 'run', return_value='a' * 40), \
+ patch.object(release.manifest, 'export_release') as export, \
+ patch.object(release, 'add_release_extras'):
+ release.assemble(args)
+ self.assertEqual(export.call_args.args[2], evidence)
+
+ def test_validation_merge_rejects_conflicts_and_unverified_firmware(self):
+ digest = 'b' * 64
+ checks = {'schema_version': 1, 'assets': {}}
+ evidence = {'schema_version': 1, 'assets': {digest: {'elf': True}},
+ 'firmware_compatibility': {digest: {'25.12': {'openwrt_install': True}}}}
+ release.merge_validation(checks, evidence)
+ release.merge_validation(checks, evidence)
+ self.assertEqual(checks, evidence)
+ conflicting = dict(evidence, assets={digest: {'elf': False}})
+ with self.assertRaisesRegex(ValueError, 'Conflicting validation'):
+ release.merge_validation(checks, conflicting)
+ invalid = dict(evidence, firmware_compatibility={digest: {'25.12': {'openwrt_install': False}}})
+ with self.assertRaises(ValueError):
+ release.merge_validation(checks, invalid)
+ with self.assertRaisesRegex(ValueError, 'Invalid validation'):
+ release.merge_validation(checks, dict(evidence, schema_version=2))
+
+ def test_apk_export_unwraps_sdk_launcher_and_checks_relocated_tool(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ sdk, output = root/'sdk', root/'artifact'
+ sdk.mkdir()
+ output.mkdir()
+ apk = sdk/'apk'
+ apk.write_text('#!/bin/sh\nexec "$0.missing" "$@"\n')
+ native = b'\x7fELF synthetic host executable'
+ (sdk/'.apk.bin').write_bytes(native)
+ package = output/'luci.apk'
+ package.write_bytes(b'synthetic signed archive')
+ loader = {name: 'sdk-only' for name in ('LD_LIBRARY_PATH', 'LD_PRELOAD', 'RUNAS_ARG0')}
+ with patch.dict(os.environ, loader), patch.object(release, 'run') as run:
+ release.export_apk_tool(apk, output, root/'trusted')
+ self.assertEqual((output/'apk-tools').read_bytes(), native)
+ self.assertEqual(run.call_count, 2)
+ self.assertEqual(run.call_args_list[0].args[0],
+ [output/'apk-tools', '--keys-dir', root/'trusted', 'verify', package])
+ for call in run.call_args_list:
+ self.assertEqual(call.kwargs['cwd'], output)
+ self.assertTrue(set(loader).isdisjoint(call.kwargs['env']))
+ with patch.object(release, 'run', side_effect=RuntimeError('cannot verify')):
+ with self.assertRaisesRegex(RuntimeError, 'cannot verify'):
+ release.export_apk_tool(apk, output, root/'trusted')
+ (sdk/'.apk.bin').unlink()
+ with self.assertRaisesRegex(ValueError, 'missing its native executable'):
+ release.export_apk_tool(apk, output, root/'trusted')
+
+ def test_versions_reject_shell_paths_and_wrong_channels(self):
+ for value in ['1.6.0', 'v2.0.0', '2.0.0rc0', '2.0.0;echo bad', '../../2.0.0', '2.0.0\n']:
+ with self.subTest(value=value), self.assertRaises(ValueError):
+ release.validate_version(value)
+ self.assertEqual(release.validate_version('2.0.0rc10', 'rc'), '2.0.0rc10')
+ self.assertEqual(release.validate_version('2.0.0', 'stable'), '2.0.0')
+ with self.assertRaises(ValueError):
+ release.validate_version('2.0.0rc2', 'stable')
+
+ def test_official_apk_without_key_fails_before_build(self):
+ target = next(t for t in release.catalog()['targets'] if t['format'] == 'apk')
+ with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, {}, clear=True), patch.object(release.sdk, 'build') as build:
+ args = SimpleNamespace(version='2.0.0rc1', target=target['id'], official=True,
+ work=Path(temp)/'work', output=Path(temp)/'out')
+ with self.assertRaisesRegex(ValueError, 'requires SMARTSRUN_APK_SIGNING_KEY'):
+ release.build(args)
+ build.assert_not_called()
+ self.assertFalse((args.work/'signing/private.pem').exists())
+
+ def test_missing_matrix_and_preview_promotion_fail_closed(self):
+ with tempfile.TemporaryDirectory() as temp, patch.object(release, 'run', return_value='a'*40):
+ root = Path(temp)
+ args = SimpleNamespace(input=root, output=root/'release', version='2.0.0rc1', official=True)
+ with self.assertRaisesRegex(ValueError, 'complete pinned SDK matrix'):
+ release.assemble(args)
+ folder = root/'one'
+ folder.mkdir()
+ (folder/'build-record.json').write_text(json.dumps({
+ 'source_commit': 'a'*40, 'source_dirty': False, 'display_version': args.version,
+ 'target': release.catalog()['targets'][0]}))
+ (folder/'ci.json').write_text(json.dumps({'official_signing': False}))
+ with self.assertRaisesRegex(ValueError, 'Preview artifacts'):
+ release.assemble(args)
+ self.assertFalse(args.output.exists())
+
+ def test_split_zip_and_all_extra_checksums_preserve_exact_bytes(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ out = root/'out'
+ out.mkdir()
+ records = root/'records'
+ records.mkdir()
+ (records/'build-record.json').write_text(json.dumps({'target': {'id': 'synthetic-target'}}))
+ for name in ['validation.json', 'inspection.json', 'ci.json']:
+ (records/name).write_text('{}')
+ key = root/'public.pem'
+ key.write_text('synthetic public key; not usable for installation')
+ assets = []
+ for kind in ['core', 'luci']:
+ filename = kind+'.ipk'
+ (out/filename).write_bytes((kind+' exact bytes').encode())
+ assets.append({'kind': kind, 'package_manager': 'opkg', 'sdk_release': '24.10.8',
+ 'openwrt_arch': 'x86_64' if kind == 'core' else 'all',
+ 'url': 'https://example.test/'+filename})
+ data = {'release': '2.0.0rc1', 'source_commit': 'a'*40, 'assets': assets}
+ release.add_release_extras(data, [records/'build-record.json'], [key], {'b'*64},
+ SimpleNamespace(output=out, official=True))
+ with zipfile.ZipFile(next(out.glob('*.zip'))) as archive:
+ self.assertEqual(archive.namelist(), ['core.ipk', 'luci.ipk'])
+ self.assertEqual(archive.read('core.ipk'), b'core exact bytes')
+ for line in (out/'SHA256SUMS').read_text().splitlines():
+ digest, name = line.split(' ')
+ self.assertEqual(digest, hashlib.sha256((out/name).read_bytes()).hexdigest())
+ self.assertNotIn('${', (out/'release-notes.md').read_text(encoding='utf-8'))
+
+ def test_source_audit_allows_host_tools_but_rejects_runtime_and_private_files(self):
+ paths = ['scripts/ci_go_release.py', 'tests/test_ci_go_release.py', 'root/usr/lib/smart_srun/cli.py',
+ '.codex/private.json', 'root/etc/smart-srun/config.json', 'signing.key', 'old.apk']
+ self.assertEqual(source.inspect(paths), sorted(paths[2:]))
+
+ def test_published_draft_checks_server_names_and_digests(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ candidate = root / 'candidate'
+ candidate.mkdir()
+ data = {'release': '2.0.0rc1', 'source_commit': 'a' * 40, 'channel': 'rc'}
+ (candidate / 'release-manifest.json').write_text(json.dumps(data))
+ (candidate / 'smart-srun_2.0.0.rc1-r1_x86_64.ipk').write_bytes(b'exact package bytes')
+ assets = [{'name': p.name, 'size': p.stat().st_size,
+ 'digest': 'sha256:' + hashlib.sha256(p.read_bytes()).hexdigest()}
+ for p in candidate.iterdir()]
+ original = dict(draft=True, prerelease=True, tag_name=data['release'],
+ target_commitish=data['source_commit'], assets=assets)
+ path = root / 'github.json'
+ args = SimpleNamespace(candidate=candidate, metadata=path)
+ path.write_text(json.dumps(original))
+ release.verify_published(args)
+ for field, value in [('name', 'github-renamed.ipk'), ('size', 0), ('digest', None)]:
+ with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'asset names'):
+ changed = json.loads(json.dumps(original))
+ changed['assets'][0][field] = value
+ path.write_text(json.dumps(changed))
+ release.verify_published(args)
+ for field, value in [('draft', False), ('prerelease', False), ('tag_name', 'wrong'),
+ ('target_commitish', 'b' * 40)]:
+ with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'draft identity'):
+ changed = dict(original, **{field: value})
+ path.write_text(json.dumps(changed))
+ release.verify_published(args)
+
+ def test_workflows_pin_actions_and_use_verified_candidate(self):
+ import yaml
+ for path in (ROOT/'.github/workflows').glob('*.yml'):
+ workflow = yaml.safe_load(path.read_text())
+ for job in workflow['jobs'].values():
+ for step in job.get('steps', []):
+ uses = step.get('uses', '')
+ if uses and not uses.startswith('./'):
+ self.assertRegex(uses, r'^[\w/-]+@[0-9a-f]{40}$')
+ publish = (ROOT/'.github/workflows/publish-go.yml').read_text()
+ self.assertIn('sha256sum --strict --check', publish)
+ self.assertIn('--draft --latest=false', publish)
+ self.assertNotIn('--clobber', publish)
+ self.assertNotIn('build_go_sdk.py', publish)
+ self.assertIn('verify-published', publish)
+
+ def test_release_callers_forward_signing_context_only_to_builder(self):
+ import yaml
+ workflows = ROOT / '.github/workflows'
+ builder = yaml.safe_load((workflows / 'build-go.yml').read_text())
+ # PyYAML's YAML 1.1 reader treats the GitHub Actions `on` key as true.
+ triggers = builder.get('on', builder.get(True))
+ self.assertIs(triggers['workflow_call']['secrets']['SMARTSRUN_APK_SIGNING_KEY']['required'], False)
+ self.assertIn('release-signing', builder['jobs']['build']['environment'])
+ for name in ('build-release.yml', 'build-prerelease.yml'):
+ with self.subTest(workflow=name):
+ jobs = yaml.safe_load((workflows / name).read_text())['jobs']
+ self.assertEqual(jobs['candidate']['secrets'], 'inherit')
+ self.assertEqual(jobs['candidate']['uses'], './.github/workflows/build-go.yml')
+ self.assertNotIn('secrets', jobs['publish'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/smart-srun/tests/test_config_backup_ui.py b/smart-srun/tests/test_config_backup_ui.py
new file mode 100644
index 000000000..85fde799e
--- /dev/null
+++ b/smart-srun/tests/test_config_backup_ui.py
@@ -0,0 +1,74 @@
+"""Exercise the shared backup UI with delayed replies and uncertain writes."""
+import shutil
+import subprocess
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+class BackupUITests(unittest.TestCase):
+ def test_lua_backup_routes_preserve_raw_input_and_cas(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua unavailable")
+ result = subprocess.run([lua, str(ROOT/"tests/lua/controller_backup.lua"), str(ROOT)], capture_output=True, text=True, timeout=10)
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_preview_races_commit_token_and_download(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node unavailable")
+ script = r"""
+const fs = require('fs'), vm = require('vm'), assert = require('assert');
+let source = fs.readFileSync(process.argv[1], 'utf8');
+const end = source.lastIndexOf('})();');
+source = source.slice(0,end) + '\nwindow.initBackupTest = initConfigBackup;\n' + source.slice(end);
+const nodes={}, requests=[], readers=[], timers=[], downloads=[];
+for (const id of ['backup','file','import','export','result']) {
+ nodes['smart-srun-config-'+id] = {attributes:{}, disabled:false,
+ getAttribute(k){return this.attributes[k];}, setAttribute(k,v){this.attributes[k]=v;}};
+}
+function XHR(){requests.push(this);}
+XHR.prototype.open=function(method,url){this.method=method;this.url=url;};
+XHR.prototype.setRequestHeader=function(){};
+XHR.prototype.send=function(body){this.body=new URLSearchParams(body);};
+XHR.prototype.reply=function(data){this.status=200;this.readyState=4;this.responseText=JSON.stringify(data);this.onreadystatechange();};
+function FileReader(){readers.push(this);}
+FileReader.prototype.readAsText=function(){};
+const doc = {readyState:'loading', addEventListener(){},
+ getElementById(id){return nodes[id]||null;}, querySelector(){return {value:'csrf-token'};},
+ createElement(){return {click(){downloads.push(this.download);}};},
+ body:{appendChild(){},removeChild(){}}};
+const ctx={window:{},document:doc,XMLHttpRequest:XHR,FileReader,URL:{createObjectURL(){return 'blob:backup';},revokeObjectURL(){}},Blob:function(){},setTimeout(fn){timers.push(fn);},JSON};
+vm.runInNewContext(source,ctx);ctx.window.initBackupTest();
+const file=nodes['smart-srun-config-file'], button=nodes['smart-srun-config-import'], result=nodes['smart-srun-config-result'];
+function choose(text){file.files=[{size:text.length}];file.onchange();const reader=readers.at(-1);reader.result=text;reader.onload();return requests.at(-1);}
+const old=choose('old-secret'), fresh=choose('new-secret');
+old.reply({ok:true,expected_revision:'old',campus_accounts:99,hotspot_profiles:99});
+assert.equal(button.disabled,true);assert(!String(result.textContent).includes('99'));
+fresh.reply({ok:true,expected_revision:'15',campus_accounts:2,hotspot_profiles:1,warnings:['synthetic warning']});
+assert.equal(button.disabled,false);assert(!result.textContent.includes('new-secret'));
+button.onclick();const commit=requests.at(-1);
+assert.equal(commit.method,'POST');assert.equal(commit.body.get('token'),'csrf-token');
+assert.equal(commit.body.get('expected_revision'),'15');assert.equal(commit.body.get('data'),'new-secret');
+assert.equal(file.disabled,true);const count=requests.length;button.onclick();assert.equal(requests.length,count);
+commit.ontimeout();assert.equal(button.disabled,true);assert.equal(file.disabled,false);assert(result.textContent.includes('不要直接重复'));
+commit.reply({ok:true,message:'late success'});assert(!result.textContent.includes('late success'));
+nodes['smart-srun-config-export'].onclick();requests.at(-1).reply({format:'smart-srun-config',format_version:1,config:{}});
+assert.deepEqual(downloads,['smart-srun-config.json']);
+assert.equal(requests.at(-1).body.get('token'),'csrf-token');
+file.files=[{size:524289}];file.onchange();assert.equal(button.disabled,true);assert(result.textContent.includes('512'));
+for (const invalid of [null, {}, {ok:1}, {ok:'true'}]) {
+ choose('backup').reply(invalid);
+ assert.equal(button.disabled,true,'preview requires explicit success');
+ choose('backup').reply({ok:true,expected_revision:'16',campus_accounts:1,hotspot_profiles:0});
+ button.onclick();const scheduled=timers.length;
+ requests.at(-1).reply(invalid);
+ assert.equal(timers.length,scheduled,'unconfirmed import must not refresh');
+}
+"""
+ result = subprocess.run([node, "-e", script, str(ROOT/"root/www/luci-static/resources/smart_srun.js")], capture_output=True, text=True, timeout=15)
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_documentation_tools.py b/smart-srun/tests/test_documentation_tools.py
new file mode 100644
index 000000000..ce3c040b3
--- /dev/null
+++ b/smart-srun/tests/test_documentation_tools.py
@@ -0,0 +1,205 @@
+"""Exercise guidance selection and link checks in isolated Git repositories."""
+import contextlib
+import io
+import json
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "scripts"))
+import check_docs # noqa: E402
+import doc_context # noqa: E402
+
+
+class DocumentationTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ self.git("init", "-q")
+ self.write("AGENTS.md", "# Local guidance\n")
+ self.write("CLAUDE.md", "@AGENTS.md\n")
+ self.write("scripts/check.py", "raise SystemExit(0)\n")
+ self.rules = [{"id": "docs", "any": ["*.md"], "docs": ["AGENTS.md"],
+ "checks": [{"command": "python scripts/check.py", "targets": ["scripts/check.py"]}]}]
+ self.save_rules()
+
+ def git(self, *args):
+ return subprocess.check_output(["git", "-C", str(self.root), *args], stderr=subprocess.PIPE)
+
+ def write(self, name, content):
+ path = self.root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+ def save_rules(self):
+ self.write(doc_context.CONFIG, json.dumps({"version": 1, "rules": self.rules}))
+
+ def commit(self):
+ self.git("add", "--all")
+ self.git("-c", "user.name=Synthetic Test", "-c", "user.email=test@example.invalid",
+ "commit", "-q", "-m", "fixture")
+ return self.git("rev-parse", "HEAD").decode().strip()
+
+ def run_main(self, main, *args):
+ output, errors = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
+ status = main(["--root", str(self.root), *args])
+ return status, output.getvalue(), errors.getvalue()
+
+ def test_base_and_dirty_changes_include_both_sides_of_renames_and_deletions(self):
+ self.write(".gitignore", "ignored/\n")
+ for name in ("old committed.txt", "old staged.txt", "staged.txt", "unstaged.txt", "deleted.txt"):
+ self.write(name, name + "\n")
+ base = self.commit()
+ self.git("mv", "old committed.txt", "new committed.txt")
+ self.write("committed.txt", "new commit\n")
+ self.commit()
+ self.git("mv", "old staged.txt", "new staged.txt")
+ self.write("staged.txt", "staged edit\n")
+ self.git("add", "staged.txt")
+ self.write("unstaged.txt", "worktree edit\n")
+ (self.root / "deleted.txt").unlink()
+ self.write("new file.txt", "untracked\n")
+ self.write("ignored/private.txt", "excluded\n")
+ dirty = {"old staged.txt", "new staged.txt", "staged.txt", "unstaged.txt", "deleted.txt", "new file.txt"}
+ self.assertEqual(set(doc_context.changed_paths(self.root)), dirty)
+ self.assertEqual(set(doc_context.changed_paths(self.root, base)),
+ dirty | {"old committed.txt", "new committed.txt", "committed.txt"})
+
+ def test_default_supports_unborn_repository_and_empty_worktree(self):
+ self.git("add", "AGENTS.md")
+ self.assertIn("AGENTS.md", doc_context.changed_paths(self.root))
+ self.commit()
+ status, output, error = self.run_main(doc_context.main)
+ self.assertEqual((status, error), (0, ""))
+ self.assertIn("No changed files", output)
+
+ def test_base_compares_the_merge_base_not_unrelated_base_branch_changes(self):
+ ancestor = self.commit()
+ self.git("checkout", "-q", "-b", "base")
+ self.write("base-only.md", "Base branch change\n")
+ base = self.commit()
+ self.git("checkout", "-q", "-b", "work", ancestor)
+ self.write("work-only.md", "Work branch change\n")
+ self.commit()
+ self.assertEqual(doc_context.changed_paths(self.root, base), ["work-only.md"])
+
+ def test_base_is_resolved_without_treating_it_as_an_option(self):
+ self.commit()
+ status, _, error = self.run_main(doc_context.main, "--base=--help")
+ self.assertEqual(status, 1)
+ self.assertIn("doc-context:", error)
+
+ def test_rules_drive_any_all_selection_and_commands_are_only_printed(self):
+ self.rules.extend([
+ {"id": "core", "any": ["core/**"], "docs": ["AGENTS.md"]},
+ {"id": "cross", "all": ["core/**", "root/**"], "docs": ["AGENTS.md"],
+ "checks": [{"command": "DO NOT EXECUTE THIS COMMAND", "targets": ["scripts/check.py"]}]},
+ ])
+ self.save_rules()
+ rules = doc_context.load_rules(self.root)
+ self.assertEqual([rule["id"] for rule in doc_context.matching_rules(rules, ["core/a.go"])], ["core"])
+ status, output, error = self.run_main(doc_context.main, "--files", "core/a.go", "root/b.lua")
+ self.assertEqual((status, error), (0, ""))
+ self.assertIn("Matching rules: core, cross", output)
+ self.assertIn("DO NOT EXECUTE THIS COMMAND", output)
+ self.assertIn("printed only; not executed", output)
+
+ def test_repository_routes_readme_and_documentation_tools_without_release_guidance(self):
+ rules = doc_context.load_rules(ROOT)
+ selected = doc_context.matching_rules(rules, ["README.md"])
+ self.assertEqual({rule["id"] for rule in selected}, {"documentation"})
+ for path in ("scripts/check_docs.py", "scripts/doc_context.py"):
+ with self.subTest(path=path):
+ selected = doc_context.matching_rules(rules, [path])
+ self.assertEqual({rule["id"] for rule in selected}, {"host-tools", "documentation"})
+ self.assertFalse(any(rule.get("skills") for rule in selected))
+ self.assertIn("scripts/AGENTS.md", [value for rule in selected for value in rule.get("docs", [])])
+
+ def test_repository_go_tests_do_not_request_the_host_test_suite(self):
+ selected = doc_context.matching_rules(doc_context.load_rules(ROOT), ["core/internal/policy/state_test.go"])
+ self.assertEqual({rule["id"] for rule in selected}, {"go-core"})
+ self.assertFalse(any("unittest" in check["command"] for rule in selected for check in rule.get("checks", [])))
+
+ def test_repository_release_routing_retains_deleted_and_renamed_paths(self):
+ self.write("scripts/build_go_sdk.py", "# synthetic build script\n")
+ self.write("targets.json", "{}\n")
+ self.commit()
+ self.git("mv", "scripts/build_go_sdk.py", "scripts/check_docs.py")
+ (self.root / "targets.json").unlink()
+ paths = doc_context.changed_paths(self.root)
+ self.assertEqual(set(paths), {"scripts/build_go_sdk.py", "scripts/check_docs.py", "targets.json"})
+ selected = doc_context.matching_rules(doc_context.load_rules(ROOT), paths)
+ self.assertIn("build-and-release", {rule["id"] for rule in selected})
+
+ def test_markdown_tracks_new_files_and_ignores_private_and_code_examples(self):
+ self.write("guide/space name.md", "# Target\n")
+ self.write("guide/a(b).md", "# Parentheses\n")
+ self.write("README.md", "\n".join([
+ "[Space](guide/space%20name.md#section)",
+ "[Angle]()",
+ "[Paren](guide/a(b).md)",
+ "[Directory](guide/)",
+ "[Reference][guide]", "[guide]: guide/space%20name.md \"title\"",
+ " ",
+ "[Web](https://example.invalid/missing)", "[Anchor](#missing)",
+ "[Absolute site route](/guide/missing)", "[Mail](mailto:test@example.invalid)",
+ "`[Inline example](missing.md)`", "```md", "[Example](missing.md)", "```",
+ ]))
+ self.commit()
+ self.write("new.md", "[New document](AGENTS.md)\n")
+ self.write(".codex/private.md", "[Private](missing.md)\n")
+ self.write("node_modules/cache.md", "[Cache](missing.md)\n")
+ self.write(".gitignore", "ignored/\n")
+ self.write("ignored/notes.md", "[Ignored](missing.md)\n")
+ status, output, errors = self.run_main(check_docs.main)
+ self.assertEqual((status, errors), (0, ""))
+ self.assertIn("6 Markdown files", output)
+ self.assertIn(Path("new.md"), check_docs.markdown_paths(self.root))
+
+ def test_missing_markdown_targets_and_claude_import_fail(self):
+ self.write("README.md", "[Broken](removed.md#section)\n")
+ self.write("CLAUDE.md", "@missing-AGENTS.md\n")
+ status, _, errors = self.run_main(check_docs.main)
+ self.assertEqual(status, 1)
+ self.assertIn("README.md:1: missing relative link target: removed.md#section", errors)
+ self.assertIn("CLAUDE.md:1: missing relative link target: missing-AGENTS.md", errors)
+
+ def test_deleted_tracked_document_breaks_referring_link(self):
+ target = self.write("guide.md", "# Guide\n")
+ self.write("README.md", "[Guide](guide.md)\n")
+ self.commit()
+ target.unlink()
+ status, _, errors = self.run_main(check_docs.main)
+ self.assertEqual(status, 1)
+ self.assertIn("missing relative link target: guide.md", errors)
+
+ def test_missing_rule_docs_skills_and_check_targets_fail(self):
+ self.rules[0]["docs"] = ["removed.md"]
+ self.rules[0]["skills"] = [".agents/skills/missing/SKILL.md"]
+ self.rules[0]["checks"][0]["targets"] = ["scripts/missing.py"]
+ self.save_rules()
+ status, _, errors = self.run_main(check_docs.main)
+ self.assertEqual(status, 1)
+ for name in ("removed.md", ".agents/skills/missing/SKILL.md", "scripts/missing.py"):
+ self.assertIn(name, errors)
+
+ def test_invalid_rule_shape_or_traversal_is_rejected(self):
+ for rule in ({"id": "bad", "any": "*.md"},
+ {"id": "bad", "any": ["*.md"], "docs": ["../outside.md"]},
+ {"id": "bad", "any": ["*.md"], "checks": [{"command": "anything", "targets": []}]}):
+ with self.subTest(rule=rule):
+ self.rules = [rule]
+ self.save_rules()
+ status, _, errors = self.run_main(check_docs.main)
+ self.assertEqual(status, 1)
+ self.assertIn("check-docs:", errors)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_go_hot_update.py b/smart-srun/tests/test_go_hot_update.py
new file mode 100644
index 000000000..e9ea28670
--- /dev/null
+++ b/smart-srun/tests/test_go_hot_update.py
@@ -0,0 +1,110 @@
+"""Host deployment must select exact recovery bytes and never invoke Python remotely."""
+import contextlib
+import hashlib
+import importlib.util
+import io
+import json
+from pathlib import Path
+import tempfile
+from types import SimpleNamespace
+import unittest
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("go_hot_update_test", ROOT / "scripts/hot_update_go.py")
+deploy = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(deploy)
+
+
+class DeployTests(unittest.TestCase):
+ def fixture(self, directory, version="2.0.0rc2", manager="opkg", kind="bundle"):
+ fmt = "ipk" if manager == "opkg" else "apk"
+ filename = "fixture-" + version + "-" + kind + "." + fmt
+ path = Path(directory) / filename
+ path.write_bytes(version.encode())
+ asset = dict(id=version + "-" + kind, format=fmt, kind=kind, package_manager=manager,
+ openwrt_arch="x86_64", firmware_compat=["24.10"], package_version=version + "-r1",
+ url="https://github.com/matthewlu070111/smart-srun/releases/download/" + version + "/" + filename,
+ bytes=path.stat().st_size, sha256=hashlib.sha256(path.read_bytes()).hexdigest(), installed_bytes=100,
+ validation=dict(build=True, elf=True))
+ manifest = dict(schema_version=1, release=version, assets=[asset])
+ manifest_path = Path(directory) / (version + ".json")
+ manifest_path.write_text(json.dumps(manifest))
+ return manifest_path, asset, path
+
+ def inventory(self):
+ return dict(display_version="2.0.0rc1", packages={"luci-app-smart-srun-bundle": "2.0.0rc1-r1"},
+ architecture="x86_64", package_manager="opkg", firmware_family="24.10")
+
+ def test_dry_run_checks_bytes_without_ssh_or_password(self):
+ with tempfile.TemporaryDirectory() as directory:
+ new, _, path = self.fixture(directory)
+ old, _, _ = self.fixture(directory, "2.0.0rc1")
+ args = ["--dry-run", "--manifest", str(new), "--assets", directory,
+ "--recovery-manifest", str(old), "--recovery-assets", directory]
+ with mock.patch.object(deploy, "SSH", side_effect=AssertionError("must not connect")), contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(deploy.main(args), 0)
+ path.write_bytes(b"tampered")
+ with contextlib.redirect_stderr(io.StringIO()):
+ self.assertEqual(deploy.main(args), 1)
+
+ def test_exact_recovery_and_ambiguous_selection_refused(self):
+ with tempfile.TemporaryDirectory() as directory:
+ _, asset, path = self.fixture(directory)
+ inventory = self.inventory()
+ with self.assertRaises(ValueError):
+ deploy.select([(asset, path)], inventory, recovery=True)
+ with self.assertRaises(ValueError):
+ deploy.select([(asset, path), (asset, path)], inventory)
+ inventory["packages"]["smart-srun"] = "2.0.0rc1-r1"
+ with self.assertRaises(ValueError):
+ deploy.select([(asset, path)], inventory)
+
+ def test_probe_does_not_upload_or_start_and_worker_poll_uses_copy(self):
+ with tempfile.TemporaryDirectory() as directory:
+ new, _, _ = self.fixture(directory)
+ old, _, _ = self.fixture(directory, "2.0.0rc1")
+ manifests = [deploy.load_manifest(new, directory), deploy.load_manifest(old, directory)]
+ ssh = mock.Mock()
+ ssh.json.side_effect = [self.inventory(), dict(running=False, phase="idle")]
+ with contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(deploy.deploy(SimpleNamespace(probe=True), ssh, manifests), 0)
+ ssh.upload.assert_not_called()
+ self.assertEqual(ssh.json.call_args_list, [mock.call("/usr/bin/srunnet update inventory"), mock.call(deploy.STATUS_COMMAND)])
+ ssh.reset_mock()
+ ssh.json.side_effect = [self.inventory(), dict(running=False, phase="idle"), dict(ok=True, plan_id="a" * 64),
+ dict(running=True, job_id="b" * 32), dict(running=False, ok=True, phase="completed", job_id="b" * 32)]
+ with contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(deploy.deploy(SimpleNamespace(probe=False, prepare=False, background=False, wait=2), ssh, manifests), 0)
+ self.assertEqual(ssh.upload.call_count, 2)
+ self.assertEqual(ssh.json.call_args, mock.call(deploy.STATUS_COMMAND))
+ for call in ssh.json.call_args_list:
+ self.assertNotIn("python", call.args[0])
+ self.assertNotIn("allow-untrusted", call.args[0])
+
+ def test_unsafe_remote_asset_path_and_duplicate_manifest_fields_refused(self):
+ with tempfile.TemporaryDirectory() as directory:
+ manifest, _, _ = self.fixture(directory)
+ value = json.loads(manifest.read_text())
+ value["assets"][0]["id"] = "../../etc/config/network"
+ manifest.write_text(json.dumps(value))
+ with self.assertRaises(ValueError):
+ deploy.load_manifest(manifest, directory)
+ manifest.write_text('{"schema_version":1,"schema_version":1,"assets":[]}')
+ with self.assertRaises(ValueError):
+ deploy.load_manifest(manifest, directory)
+
+ def test_public_entrypoint_in_go_tree_cannot_run_legacy_uploader(self):
+ spec = importlib.util.spec_from_file_location("hot_update_dispatch_test", ROOT / "scripts/hot_update.py")
+ entry = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(entry)
+ self.assertFalse(hasattr(entry, "legacy_main"))
+ self.assertEqual(entry.main.__module__, "hot_update_go")
+ with contextlib.redirect_stdout(io.StringIO()):
+ with self.assertRaises(SystemExit) as raised:
+ entry.main(["--help"])
+ self.assertEqual(raised.exception.code, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_go_manifest.py b/smart-srun/tests/test_go_manifest.py
new file mode 100644
index 000000000..1ae73c7ea
--- /dev/null
+++ b/smart-srun/tests/test_go_manifest.py
@@ -0,0 +1,252 @@
+"""Release evidence must stay attached to the exact package bytes."""
+import hashlib
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import subprocess
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("go_manifest", ROOT / "scripts/make_go_manifest.py")
+manifest = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(manifest)
+
+
+class ManifestTests(unittest.TestCase):
+ def luci_apk_pair(self, root):
+ records, packages = [], []
+ for name, arch, goarch in (("x86", "x86_64", "amd64"), ("arm", "aarch64_cortex-a53", "arm64")):
+ path, core, record = self.apk_fixture(root / name, arch, goarch)
+ luci = core.with_name("luci-app-smart-srun-2.0.0_rc2-r1.apk")
+ luci.write_bytes(b"same contents with signature " + name.encode())
+ asset = dict(record["artifacts"][0], file=luci.name, name="luci-app-smart-srun", architecture="noarch",
+ bytes=luci.stat().st_size, sha256=hashlib.sha256(luci.read_bytes()).hexdigest())
+ record["artifacts"].insert(0, asset) # Another artifact must still parse after deduplication.
+ path.write_text(json.dumps(record))
+ records.append(path)
+ packages.append(luci)
+ return records, packages
+
+ def test_independently_signed_luci_requires_native_content_equivalence(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ records, packages = self.luci_apk_pair(root)
+ with self.assertRaisesRegex(ValueError, "require --apk and --keys"):
+ manifest.generate(records)
+ # No signature/content check can be waived merely because both
+ # records claim the same inputs, installed size and package version.
+ with patch.object(manifest, "apk_contents", side_effect=[{"scripts": "old"}, {"scripts": "changed"}]):
+ with self.assertRaisesRegex(ValueError, "Ambiguous"):
+ manifest.export_release(records, root / "different")
+ with patch.object(manifest, "apk_contents", return_value={"complete native metadata": "identical"}) as native:
+ out = root / "release"
+ result = manifest.export_release(records, out, apk="apk", keys="trusted")
+ self.assertEqual(native.call_count, 2)
+ self.assertEqual(len(result["assets"]), 3)
+ luci = next(a for a in result["assets"] if a["kind"] == "luci")
+ self.assertEqual(luci["sha256"], hashlib.sha256(packages[0].read_bytes()).hexdigest())
+ self.assertEqual((out / luci["url"].rsplit("/", 1)[1]).read_bytes(), packages[0].read_bytes())
+ self.assertFalse(luci["validation"]["openwrt_install"])
+
+ def test_native_apk_comparison_verifies_trust_before_reading_bounded_metadata(self):
+ with patch.object(manifest.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "apk")) as native:
+ with self.assertRaises(subprocess.CalledProcessError):
+ manifest.apk_contents("package.apk", "apk", "trusted", "2.0.0_rc2-r1")
+ self.assertEqual(native.call_count, 1)
+ self.assertEqual(native.call_args.args[0], ["apk", "--keys-dir", "trusted", "verify", "package.apk"])
+ valid = {"info": {"name": "luci-app-smart-srun", "arch": "noarch", "version": "2.0.0_rc2-r1"}, "paths": [{}]}
+ for body, accepted in ((json.dumps(valid).encode(), True), (b" " * ((256 << 10) + 1), False),
+ (b'{}', False), (json.dumps(dict(valid, info={"name": "smart-srun"})).encode(), False)):
+ def native(args, **kwargs):
+ if "adbdump" in args:
+ kwargs["stdout"].write(body)
+ with self.subTest(accepted=accepted), patch.object(manifest.subprocess, "run", side_effect=native):
+ if accepted:
+ self.assertEqual(manifest.apk_contents("p.apk", "apk", "trusted", "2.0.0_rc2-r1"), valid)
+ else:
+ with self.assertRaises(ValueError):
+ manifest.apk_contents("p.apk", "apk", "trusted", "2.0.0_rc2-r1")
+
+ def fixture(self, directory):
+ package = Path(directory) / "smart-srun_2.0.0~rc2-r1_x86_64.ipk"
+ package.write_bytes(b"measured test fixture")
+ digest = hashlib.sha256(package.read_bytes()).hexdigest()
+ record = dict(schema_version=1, display_version="2.0.0rc2", source_commit="a" * 40, source_dirty=False,
+ source_files={"Makefile": "a" * 64, "core/go.mod": "b" * 64},
+ source_template_files={"Makefile": "a" * 64, "core/go.mod": "b" * 64},
+ target=dict(package_manager="opkg", format="ipk", sdk_release="24.10.8",
+ openwrt_arch="x86_64", target="x86/64", goos="linux", goarch="amd64"),
+ artifacts=[dict(file=package.name, name="smart-srun", package_version="2.0.0~rc2-r1",
+ architecture="x86_64", bytes=package.stat().st_size, sha256=digest,
+ installed_payload_bytes=100, files={"usr/bin/srunnet": 100}, validation={"build": True})])
+ path = Path(directory) / "build-record.json"
+ path.write_text(json.dumps(record))
+ return path, package, record, digest
+
+ def apk_fixture(self, directory, arch, goarch):
+ Path(directory).mkdir()
+ path, old, record, _ = self.fixture(directory)
+ record["target"].update(package_manager="apk", format="apk", sdk_release="25.12.2",
+ openwrt_arch=arch, goarch=goarch)
+ asset = record["artifacts"][0]
+ asset.update(file="smart-srun-2.0.0_rc2-r1.apk", package_version="2.0.0_rc2-r1",
+ architecture=arch, signature_spki_sha256="c" * 64)
+ package = old.with_name(asset["file"])
+ package.write_bytes(b"synthetic signed package " + arch.encode())
+ asset.update(bytes=package.stat().st_size, sha256=hashlib.sha256(package.read_bytes()).hexdigest())
+ old.unlink()
+ path.write_text(json.dumps(record))
+ return path, package, record
+
+ def test_export_distinguishes_same_native_apk_name_and_preserves_bytes(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ first, pkg1, _ = self.apk_fixture(root / "x86", "x86_64", "amd64")
+ second, pkg2, _ = self.apk_fixture(root / "arm", "aarch64_cortex-a53", "arm64")
+ self.assertEqual(pkg1.name, pkg2.name)
+ out = root / "release"
+ result = manifest.export_release([first, second], out)
+ exported = [asset["url"].rsplit("/", 1)[1] for asset in result["assets"]]
+ self.assertEqual(len(set(exported)), 2)
+ for name, package in zip(exported, (pkg1, pkg2)):
+ self.assertIn("_openwrt-25.12.2.apk", name)
+ self.assertEqual((out / name).read_bytes(), package.read_bytes())
+ for line in (out / "SHA256SUMS").read_text().splitlines():
+ digest, name = line.split(" ")
+ self.assertEqual(hashlib.sha256((out / name).read_bytes()).hexdigest(), digest)
+ with self.assertRaisesRegex(ValueError, "existing release directory"):
+ manifest.export_release([first, second], out)
+
+ def test_ipk_prerelease_names_survive_github_upload_without_changing_native_version(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ record, package, _, digest = self.fixture(root)
+ out = root / "release"
+ release = manifest.export_release([record], out)
+ asset = release["assets"][0]
+ name = asset["url"].rsplit("/", 1)[1]
+ self.assertEqual(name, "smart-srun_2.0.0.rc2-r1_x86_64_openwrt-24.10.8.ipk")
+ self.assertEqual(asset["package_version"], "2.0.0~rc2-r1")
+ self.assertEqual((out / name).read_bytes(), package.read_bytes())
+ self.assertIn(digest + " " + name, (out / "SHA256SUMS").read_text())
+
+ def test_export_distinguishes_sdk_families_and_rejects_copy_corruption(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / "first").mkdir()
+ (root / "second").mkdir()
+ first, _, _, _ = self.fixture(root / "first")
+ second, _, record, _ = self.fixture(root / "second")
+ record["target"]["sdk_release"] = "23.05.6"
+ second.write_text(json.dumps(record))
+ result, _ = manifest.generate([first, second])
+ self.assertEqual(len({asset["url"] for asset in result["assets"]}), 2)
+ out = root / "release"
+ with patch.object(manifest.shutil, "copyfile", side_effect=lambda src, dst: dst.write_bytes(b"changed")):
+ with self.assertRaisesRegex(ValueError, "changed while staging"):
+ manifest.export_release([first, second], out)
+ self.assertFalse(out.exists())
+
+ def test_build_never_implies_runtime_acceptance(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path, _, _, digest = self.fixture(directory)
+ result, sums = manifest.generate([path])
+ asset = result["assets"][0]
+ self.assertEqual(asset["validation"], {key: key == "build" for key in manifest.CHECKS})
+ self.assertIn(digest, sums)
+ result, _ = manifest.generate([path], {"schema_version": 1, "assets": {digest: {"elf": True}}})
+ self.assertTrue(result["assets"][0]["validation"]["elf"])
+ self.assertFalse(result["assets"][0]["validation"]["campus_auth"])
+
+ def test_fork_firmware_requires_exact_package_native_install_evidence(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path, _, _, digest = self.fixture(directory)
+ evidence = {"schema_version": 1, "assets": {}, "firmware_compatibility": {
+ digest: {"25.12": {"openwrt_install": True}},
+ "b" * 64: {"23.05": {"openwrt_install": True}}}}
+ result, _ = manifest.generate([path], evidence)
+ asset = result["assets"][0]
+ self.assertEqual(asset["firmware_compat"], ["24.10", "25.12"])
+ self.assertEqual(asset["sdk_release"], "24.10.8")
+ self.assertFalse(asset["validation"]["hardware_core"])
+ self.assertFalse(asset["validation"]["campus_auth"])
+ for value in (False, 1, None, "true"):
+ evidence["firmware_compatibility"][digest]["25.12"]["openwrt_install"] = value
+ with self.subTest(value=value), self.assertRaises(ValueError):
+ manifest.generate([path], evidence)
+ for reports in (["25.12"], {digest: {}}, {digest: {"25.12-SNAPSHOT": {"openwrt_install": True}}},
+ {digest: {"25.12": {"build": True}}}, {"bad": {"25.12": {"openwrt_install": True}}}):
+ evidence["firmware_compatibility"] = reports
+ with self.subTest(reports=reports), self.assertRaises(ValueError):
+ manifest.generate([path], evidence)
+
+ def test_extra_family_cannot_make_two_sdk_packages_match_one_device(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / "first").mkdir()
+ (root / "second").mkdir()
+ first, _, _, digest = self.fixture(root / "first")
+ second, _, record, _ = self.fixture(root / "second")
+ record["target"]["sdk_release"] = "25.12.2"
+ second.write_text(json.dumps(record))
+ evidence = {"schema_version": 1, "assets": {}, "firmware_compatibility": {
+ digest: {"25.12": {"openwrt_install": True}}}}
+ with self.assertRaisesRegex(ValueError, "overlapping firmware"):
+ manifest.generate([first, second], evidence)
+
+ def test_corruption_dirty_build_and_ambiguous_selection_refused(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path, package, record, _ = self.fixture(directory)
+ with self.assertRaises(ValueError):
+ manifest.generate([path, path])
+ record["source_dirty"] = True
+ path.write_text(json.dumps(record))
+ with self.assertRaises(ValueError):
+ manifest.generate([path])
+ manifest.generate([path], internal=True)
+ package.write_bytes(b"modified after validation")
+ with self.assertRaises(ValueError):
+ manifest.generate([path], internal=True)
+
+ def test_same_commit_with_different_worktree_bytes_cannot_mix_sdk_assets(self):
+ with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
+ path, _, _, _ = self.fixture(first)
+ other, _, record, _ = self.fixture(second)
+ record["source_files"]["core/go.mod"] = "c" * 64
+ record["source_template_files"]["core/go.mod"] = "c" * 64
+ other.write_text(json.dumps(record))
+ with self.assertRaisesRegex(ValueError, "same source files"):
+ manifest.generate([path, other])
+
+ def test_only_makefile_may_differ_from_measured_source_template(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path, _, record, _ = self.fixture(directory)
+ record["source_files"]["Makefile"] = "c" * 64
+ path.write_text(json.dumps(record))
+ manifest.generate([path])
+ record["source_files"]["core/go.mod"] = "c" * 64
+ path.write_text(json.dumps(record))
+ with self.assertRaisesRegex(ValueError, "substitution"):
+ manifest.generate([path])
+ del record["source_template_files"]
+ path.write_text(json.dumps(record))
+ with self.assertRaisesRegex(ValueError, "source file identities"):
+ manifest.generate([path])
+
+ def test_negative_file_sizes_and_non_native_targets_refused(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path, _, record, _ = self.fixture(directory)
+ record["artifacts"][0]["files"] = {"usr/bin/srunnet": 110, "other": -10}
+ path.write_text(json.dumps(record))
+ with self.assertRaisesRegex(ValueError, "payload"):
+ manifest.generate([path])
+ record["target"]["openwrt_arch"] = "all"
+ path.write_text(json.dumps(record))
+ with self.assertRaisesRegex(ValueError, "target identity"):
+ manifest.generate([path])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_go_sdk_build.py b/smart-srun/tests/test_go_sdk_build.py
new file mode 100644
index 000000000..4d26fb941
--- /dev/null
+++ b/smart-srun/tests/test_go_sdk_build.py
@@ -0,0 +1,170 @@
+"""Focused checks for release version mapping and failed signing/config input."""
+
+import importlib.util
+import hashlib
+import json
+from pathlib import Path
+import re
+import tempfile
+import tarfile
+import unittest
+from unittest.mock import patch
+
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("build_go_sdk", ROOT / "scripts/build_go_sdk.py")
+build = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(build)
+
+
+class GoSDKBuildTests(unittest.TestCase):
+ def test_sdk_filter_discards_only_builder_host_links(self):
+ filter_member = build.sdk_archive_filter("sdk")
+ with tempfile.TemporaryDirectory() as temporary:
+ for tool in ("gcc", "python3", "git", "xxd"):
+ member = tarfile.TarInfo("sdk/staging_dir/host/bin/" + tool)
+ member.type = tarfile.SYMTYPE
+ member.linkname = "/builder/original/tool"
+ self.assertIsNone(filter_member(member, temporary))
+ member = tarfile.TarInfo("sdk/toolchain/lib64")
+ member.type = tarfile.SYMTYPE
+ member.linkname = "../lib64"
+ self.assertIsNotNone(filter_member(member, temporary))
+ member.linkname = "/outside"
+ with self.assertRaises(tarfile.FilterError):
+ filter_member(member, temporary)
+
+ def test_sdk_filter_preserves_path_and_link_escape_checks(self):
+ filter_member = build.sdk_archive_filter("sdk")
+ with tempfile.TemporaryDirectory() as temporary:
+ for name in ("../outside", "/sdk/absolute", "other/file", "sdk/../outside"):
+ with self.subTest(name=name), self.assertRaises(ValueError):
+ filter_member(tarfile.TarInfo(name), temporary)
+ for kind, target in ((tarfile.SYMTYPE, "../../../outside"),
+ (tarfile.LNKTYPE, "../outside"),
+ (tarfile.LNKTYPE, "/outside")):
+ member = tarfile.TarInfo("sdk/bin/link")
+ member.type, member.linkname = kind, target
+ with self.subTest(kind=kind, target=target), self.assertRaises(tarfile.FilterError):
+ filter_member(member, temporary)
+
+ def test_native_rc_substitution_preserves_common_input_identity(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ repo = root / "repo"
+ (repo / "core").mkdir(parents=True)
+ (repo / "Makefile").write_bytes(b"PKG_VERSION:=0.0.0\r\nOTHER:=unchanged\r\n")
+ (repo / "core/go.mod").write_text("module test\n")
+ with patch.object(build, "SOURCE_PATHS", ("Makefile", "core/go.mod")):
+ ipk, first = build.copy_source(repo, root / "ipk", "2.0.0~rc9")
+ apk, second = build.copy_source(repo, root / "apk", "2.0.0_rc9")
+ self.assertEqual(first, second)
+ self.assertNotEqual(ipk["Makefile"], apk["Makefile"])
+ self.assertEqual(ipk["core/go.mod"], first["core/go.mod"])
+ for name, digest in apk.items():
+ self.assertEqual(hashlib.sha256((root / "apk" / name).read_bytes()).hexdigest(), digest)
+
+ def test_payload_rejects_missing_binary_extra_config_and_legacy_runtime(self):
+ files = dict.fromkeys(build.CORE_FILES | build.LUCI_FILES, 100)
+ build.validate_payload("luci-app-smart-srun-bundle", files, 10 * 1024**2)
+ for unwanted in ("etc/smart-srun/config.json", "usr/lib/smart_srun/client.py", "tests/secrets.json"):
+ with self.subTest(unwanted=unwanted), self.assertRaises(ValueError):
+ build.validate_payload("luci-app-smart-srun-bundle", {**files, unwanted: 1}, 10 * 1024**2)
+ del files["usr/bin/srunnet"]
+ with self.assertRaises(ValueError):
+ build.validate_payload("luci-app-smart-srun-bundle", files, 10 * 1024**2)
+
+ def test_payload_budget_has_one_source(self):
+ """targets.json and the device constant must agree, and nothing may fork them.
+
+ D80 raised the budget from 10 to 16 MiB and found the old number written
+ out in five places. A build-side limit that drifts above the device's
+ produces packages the router refuses to install; one that drifts below
+ refuses a package the router would have accepted. Either way the failure
+ names the build, not the stale literal, so this pins them together.
+ """
+ limit = json.loads((ROOT / "targets.json").read_text(encoding="utf-8"))["development_payload_limit_bytes"]
+ self.assertEqual(limit, 16 * 1024**2)
+
+ source = (ROOT / "core/internal/update/manifest.go").read_text(encoding="utf-8")
+ shift = re.search(r"MaxPayloadBytes\s*=\s*(\d+)\s*<<\s*(\d+)", source)
+ self.assertIsNotNone(shift, "MaxPayloadBytes is no longer a shift literal")
+ self.assertEqual(int(shift.group(1)) << int(shift.group(2)), limit)
+
+ # Every enforcement point must read the budget by name. Checking for the
+ # key rather than banning its value keeps this honest about the
+ # neighbouring constants: hot_update_go.MAX_PACKAGE is the compressed
+ # asset cap and currently happens to be 16 MiB too, which a value-based
+ # check would report as drift it is not.
+ for name in ("scripts/verify_go_sdk.py", "scripts/hot_update_go.py",
+ "scripts/build_go_sdk.py", "scripts/build-go-dev.sh"):
+ text = (ROOT / name).read_text(encoding="utf-8")
+ self.assertIn("development_payload_limit_bytes", text,
+ f"{name} does not read the budget from targets.json")
+ # The superseded value must survive nowhere: unlike 16 MiB it is not
+ # shared with any other limit, so any occurrence is a stale copy.
+ self.assertNotRegex(text, r"10485760|\b10 ?\* ?1024\*\*2",
+ f"{name} still carries the old 10 MiB budget")
+
+ def test_rc_and_stable_native_versions(self):
+ self.assertEqual(build.package_version("2.0.0rc10", "opkg"), "2.0.0~rc10")
+ self.assertEqual(build.package_version("2.0.0rc10", "apk"), "2.0.0_rc10")
+ self.assertEqual(build.package_version("2.0.0", "apk"), "2.0.0")
+ for invalid in ("2.0.0rc0", "2.0.0rc01", "v2.0.0", "2.0.0\n", "2.0.0;touch x", "02.0.0"):
+ with self.subTest(invalid=invalid), self.assertRaises(ValueError):
+ build.package_version(invalid, "opkg")
+
+ def test_catalog_has_no_ambiguous_target_or_unpinned_commit(self):
+ catalog = json.loads((ROOT / "targets.json").read_text())
+ with tempfile.TemporaryDirectory() as temporary:
+ path = Path(temporary) / "targets.json"
+ target = catalog["targets"][0]
+ catalog["targets"].append(target)
+ path.write_text(json.dumps(catalog))
+ with self.assertRaises(ValueError):
+ build.load_target(path, target["id"])
+ catalog["targets"].pop()
+ target["feeds"]["packages"] = "master"
+ path.write_text(json.dumps(catalog))
+ with self.assertRaises(ValueError):
+ build.load_target(path, target["id"])
+
+ def test_cached_archive_is_verified_before_reuse(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ path = Path(temporary) / "sdk.tar.zst"
+ path.write_bytes(b"corrupted")
+ with patch.object(build.urllib.request, "urlopen") as network:
+ with self.assertRaises(ValueError):
+ build.download("https://downloads.openwrt.org/test", path, "0" * 64)
+ network.assert_not_called()
+
+ def test_signer_zero_exit_does_not_hide_reported_failure(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ key = Path(temporary) / "public.pem"
+ key.write_text("test public input")
+ with patch.object(build, "command", return_value="UNTRUSTED signature\n"):
+ with self.assertRaisesRegex(RuntimeError, "signer reported"):
+ build.sign_apk("apk", "test.apk", "private.pem", key)
+
+ def test_signing_requires_separate_trusted_verification(self):
+ calls = []
+
+ def command(args):
+ calls.append(args)
+ if "verify" in args:
+ raise RuntimeError("untrusted signature")
+ return ""
+
+ with tempfile.TemporaryDirectory() as temporary:
+ key = Path(temporary) / "public.pem"
+ key.write_text("test public input")
+ with patch.object(build, "command", side_effect=command):
+ with self.assertRaisesRegex(RuntimeError, "untrusted signature"):
+ build.sign_apk("apk", "test.apk", "private.pem", key)
+ self.assertEqual(len(calls), 2)
+ self.assertIn("--allow-untrusted", calls[0])
+ self.assertNotIn("--allow-untrusted", calls[1])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_go_sdk_verify.py b/smart-srun/tests/test_go_sdk_verify.py
new file mode 100644
index 000000000..c01435445
--- /dev/null
+++ b/smart-srun/tests/test_go_sdk_verify.py
@@ -0,0 +1,79 @@
+"""Release ELF inspection must distinguish ABI/endianness and fail closed."""
+import importlib.util
+from pathlib import Path
+import struct
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "scripts"))
+spec = importlib.util.spec_from_file_location("verify_go_sdk", ROOT / "scripts/verify_go_sdk.py")
+verify = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(verify)
+
+
+def header(arch):
+ elf_class, endian, machine = verify.ELF_TARGETS[arch]
+ data = bytearray(64)
+ data[:6] = b"\x7fELF" + bytes((elf_class, endian))
+ data[16:20] = struct.pack(("<" if endian == 1 else ">") + "HH", 2, machine)
+ return bytes(data)
+
+
+class SDKVerifyTests(unittest.TestCase):
+ def test_quoted_sdk_flags_allow_only_expected_inlining_setting(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ binary = Path(temporary) / "srunnet"
+ binary.write_bytes(header("mips"))
+ info = '\tbuild\tCGO_ENABLED=0\n\tbuild\tGOOS=linux\n\tbuild\tGOARCH=mips\n\tbuild\tGOMIPS=softfloat\n\tbuild\t-gcflags="all=-l "\n'
+ with patch.object(verify, "run", side_effect=[" LOAD 0x000000", info]):
+ result = verify.inspect_binary(binary, {"goarch": "mips"}, "go", False, None, "2.0.0")
+ self.assertIsNone(result["version_output"])
+ with patch.object(verify, "run", side_effect=[" LOAD 0x000000", info.replace('all=-l ', 'all=-l -N')]):
+ with self.assertRaisesRegex(ValueError, "gcflags"):
+ verify.inspect_binary(binary, {"goarch": "mips"}, "go", False, None, "2.0.0")
+
+ def test_wrong_architecture_endian_and_dynamic_executable_are_rejected(self):
+ for arch in verify.ELF_TARGETS:
+ verify.inspect_header(header(arch), arch)
+ for other in verify.ELF_TARGETS:
+ if arch != other:
+ with self.subTest(arch=arch, other=other), self.assertRaises(ValueError):
+ verify.inspect_header(header(arch), other)
+ data = bytearray(header("amd64"))
+ data[16:18] = b"\x03\x00"
+ with self.assertRaises(ValueError):
+ verify.inspect_header(data, "amd64")
+
+ def test_amd64_keeps_standard_library_inlining_and_rejects_debug_flags(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ binary = Path(temporary) / "srunnet"
+ binary.write_bytes(header("amd64"))
+ base = '\tbuild\tCGO_ENABLED=0\n\tbuild\tGOOS=linux\n\tbuild\tGOARCH=amd64\n\tbuild\tGOAMD64=v1\n'
+ flags = 'github.com/matthewlu070111/smart-srun/core/...=-l'
+ for value in (flags, 'all=-l', flags + ' -N', ''):
+ info = base + '\tbuild\t-gcflags="' + value + ' "\n'
+ with self.subTest(flags=value), patch.object(verify, "run", side_effect=[" LOAD 0x000000", info]):
+ if value == flags:
+ verify.inspect_binary(binary, {"goarch": "amd64"}, "go", False, None, "2.0.0")
+ else:
+ with self.assertRaisesRegex(ValueError, "gcflags"):
+ verify.inspect_binary(binary, {"goarch": "amd64"}, "go", False, None, "2.0.0")
+
+ def test_dynamic_loader_and_hard_float_never_pass_static_mips_check(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ binary = Path(temporary) / "srunnet"
+ binary.write_bytes(header("mips"))
+ with patch.object(verify, "run", return_value=" INTERP 0x000000"):
+ with self.assertRaisesRegex(ValueError, "dynamic loader"):
+ verify.inspect_binary(binary, {"goarch": "mips"}, "go", False, None, "2.0.0")
+ info = "\tbuild\tCGO_ENABLED=0\n\tbuild\tGOOS=linux\n\tbuild\tGOARCH=mips\n\tbuild\tGOMIPS=hardfloat\n\tbuild\t-gcflags=all=-l\n"
+ with patch.object(verify, "run", side_effect=[" LOAD 0x000000", info]):
+ with self.assertRaisesRegex(ValueError, "GOMIPS"):
+ verify.inspect_binary(binary, {"goarch": "mips"}, "go", False, None, "2.0.0")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_log_event_translations.py b/smart-srun/tests/test_log_event_translations.py
new file mode 100644
index 000000000..e1d8c3435
--- /dev/null
+++ b/smart-srun/tests/test_log_event_translations.py
@@ -0,0 +1,85 @@
+"""Every event the daemon can log has Chinese wording in the page.
+
+Spec 02 splits the log in two: Go owns the event codes and their fields, LuCI
+owns how they read. That only works if neither half can move without the other,
+and the failure is quiet -- an untranslated event reaches a user as a bare
+`quiet_logout_queued`, which is exactly the raw-code output the friendly
+renderer exists to prevent.
+
+Source-level string checks are the right tool here: this is a dictionary
+contract between two files, not behaviour that could be observed by running
+something.
+"""
+
+import re
+import unittest
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+EVENTS_GO = REPO_ROOT / "core/internal/logstore/events.go"
+CONTROLLER = REPO_ROOT / "root/usr/lib/lua/luci/controller/smart_srun.lua"
+
+
+def go_event_names():
+ """Resolve the catalogue's entries to the strings they are declared with."""
+ source = EVENTS_GO.read_text(encoding="utf-8")
+ constants = dict(
+ re.findall(r'^\t(Event[A-Za-z]+)\s*=\s*"([^"]+)"', source, re.MULTILINE)
+ )
+ catalogue = re.search(r"var catalogue = \[\]Event\{(.*?)\n\}", source, re.S)
+ assert catalogue, "the catalogue is not where this test expects it"
+ used = re.findall(r"\{(Event[A-Za-z]+),", catalogue.group(1))
+ assert used, "no catalogue entries found"
+ return constants, {constants[name] for name in used}
+
+
+def lua_translations():
+ source = CONTROLLER.read_text(encoding="utf-8")
+ table = re.search(r"local event_zh = \{(.*?)\n\}", source, re.S)
+ assert table, "event_zh is not where this test expects it"
+ return dict(
+ re.findall(r"^\s*([a-z_0-9]+)\s*=\s*\"([^\"]*)\"", table.group(1), re.MULTILINE)
+ )
+
+
+class LogEventTranslationTests(unittest.TestCase):
+ def test_every_catalogued_event_has_chinese_wording(self):
+ _, events = go_event_names()
+ translations = lua_translations()
+ missing = sorted(events - set(translations))
+ self.assertEqual(
+ missing, [],
+ "these events would reach a user as raw codes: %s" % missing,
+ )
+ for event in sorted(events):
+ with self.subTest(event=event):
+ wording = translations[event]
+ self.assertNotEqual(wording.strip(), "")
+ self.assertTrue(
+ any(ord(char) > 0x2E80 for char in wording),
+ "%s is translated as %r, which is not Chinese" % (event, wording),
+ )
+
+ def test_every_declared_constant_is_in_the_catalogue(self):
+ """A constant outside the catalogue has no level and no translation check."""
+ constants, events = go_event_names()
+ orphans = sorted(set(constants.values()) - events)
+ self.assertEqual(orphans, [], "declared but not catalogued: %s" % orphans)
+
+ def test_the_page_may_know_wording_the_daemon_does_not_emit_yet(self):
+ """The reverse direction is deliberately not required.
+
+ The Lua table still carries the baseline's events -- the wireless and
+ gateway ones that arrive with M12 and M14 -- and dropping them to make
+ the two sides match would delete wording that is about to be needed.
+ """
+ _, events = go_event_names()
+ translations = lua_translations()
+ self.assertGreater(
+ len(set(translations) - events), 10,
+ "the baseline wording seems to have been deleted rather than kept",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_log_file_omit_info_ui.py b/smart-srun/tests/test_log_file_omit_info_ui.py
new file mode 100644
index 000000000..d871cdd89
--- /dev/null
+++ b/smart-srun/tests/test_log_file_omit_info_ui.py
@@ -0,0 +1,71 @@
+"""log.file_omit_info: a native checkbox in the log tab, mapped through the bridge."""
+
+import json
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+CBI = ROOT / "root/usr/lib/lua/luci/model/cbi/smart_srun.lua"
+BRIDGE = ROOT / "root/usr/lib/lua/luci/smart_srun/bridge.lua"
+
+
+class LogFileOmitInfoUITests(unittest.TestCase):
+ def test_checkbox_is_in_the_log_tab_next_to_the_level(self):
+ source = CBI.read_text(encoding="utf-8")
+ self.assertIn(
+ 's:taboption("log", Flag, "log_file_omit_info", "日志文件不记录 INFO 信息"',
+ source,
+ )
+ self.assertIn('log_file_omit_info.default = "1"', source)
+ self.assertIn('bind_flag(log_file_omit_info, "log_file_omit_info")', source)
+ level = source.index('bind_text(log_level, "log_level")')
+ flag = source.index('"log_file_omit_info"')
+ text = source.index('"_log_text"')
+ self.assertLess(level, flag)
+ self.assertLess(flag, text)
+
+ def test_bridge_maps_the_flag_as_a_bool(self):
+ source = BRIDGE.read_text(encoding="utf-8")
+ self.assertRegex(
+ source,
+ re.compile(r'log_file_omit_info\s*=\s*\{ path = \{ "log", "file_omit_info" \}, kind = "bool" \}'),
+ )
+
+ def test_checkbox_reads_and_writes_the_flat_value(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ script = r'''
+local f = assert(io.open(CBI_PATH, 'rb'))
+local source = f:read('*a'):gsub('\r\n', '\n'); f:close()
+local binders = assert(source:match('(local function bind_flag.-)\nlocal function bind_text'))
+local control = assert(source:match('(local log_file_omit_info = .-)\n\nlog_text ='))
+for _, case in ipairs({{stored='1', shown='1'}, {stored='0', shown='0'}}) do
+ local options = {}
+ local s = {taboption=function(self, tab, kind, name, title, description)
+ assert(tab == 'log' and kind == 'Flag')
+ local opt = {title=title, description=description}
+ options[name] = opt; return opt
+ end}
+ local cfg = {log_file_omit_info=case.stored}
+ local env = setmetatable({cfg=cfg, s=s, Flag='Flag',
+ set_value=function(k, v) cfg[k] = v end}, {__index=_G})
+ local chunk = assert(loadstring(binders .. '\n' .. control)); setfenv(chunk, env); chunk()
+ local flag = options.log_file_omit_info
+ assert(flag.default == '1' and flag.rmempty == false)
+ assert(flag:cfgvalue() == case.shown, 'cfgvalue')
+ flag:write('main', '0'); assert(cfg.log_file_omit_info == '0')
+ flag:write('main', '1'); assert(cfg.log_file_omit_info == '1')
+ flag:remove('main'); assert(cfg.log_file_omit_info == '0')
+end
+'''
+ script = script.replace("CBI_PATH", json.dumps(CBI.as_posix()))
+ result = subprocess.run([lua, "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=15)
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_lua_syntax.py b/smart-srun/tests/test_lua_syntax.py
new file mode 100644
index 000000000..6ef9bb7a4
--- /dev/null
+++ b/smart-srun/tests/test_lua_syntax.py
@@ -0,0 +1,205 @@
+import json
+import shutil
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+from _portal_urls import PORTAL_ORIGIN
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+LUA_ROOT = REPO_ROOT / "root" / "usr" / "lib" / "lua" / "luci"
+CONTROLLER_FILE = LUA_ROOT / "controller" / "smart_srun.lua"
+
+
+def lua_string(value):
+ return json.dumps(str(value).replace("\\", "/"), ensure_ascii=False)
+
+
+class LuaSyntaxSmokeTests(unittest.TestCase):
+ def test_shipped_luci_lua_files_parse_with_luac(self):
+ luac = shutil.which("luac")
+ if not luac:
+ self.skipTest("luac is not installed")
+
+ lua_files = sorted(LUA_ROOT.rglob("*.lua"))
+ self.assertTrue(lua_files, "expected shipped LuCI Lua files")
+
+ for path in lua_files:
+ with self.subTest(path=str(path.relative_to(REPO_ROOT))):
+ with (
+ tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as stdout,
+ tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as stderr,
+ ):
+ result = subprocess.run(
+ [luac, "-p", str(path)],
+ stdin=subprocess.DEVNULL,
+ stdout=stdout,
+ stderr=stderr,
+ text=True,
+ )
+ stdout.seek(0)
+ stderr.seek(0)
+ output = (stderr.read() or stdout.read()).strip()
+ self.assertEqual(
+ result.returncode,
+ 0,
+ output,
+ )
+
+ def test_luci_friendly_line_executes_with_lua(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ stub_root = Path(temp_dir) / "lua"
+ (stub_root / "luci" / "smart_srun").mkdir(parents=True)
+ (stub_root / "nixio").mkdir(parents=True)
+ (stub_root / "luci" / "http.lua").write_text(
+ "\n".join(
+ [
+ "local M = {}",
+ "function M.formvalue() return nil end",
+ "function M.prepare_content() end",
+ "function M.write() end",
+ "function M.status() end",
+ "function M.header() end",
+ "return M",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ (stub_root / "luci" / "jsonc.lua").write_text(
+ "\n".join(
+ [
+ "local M = {}",
+ "function M.parse() return {} end",
+ "function M.stringify() return '{}' end",
+ "return M",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ (stub_root / "luci" / "sys.lua").write_text(
+ "\n".join(
+ [
+ "local M = {}",
+ "function M.exec() return '' end",
+ "function M.call() return 0 end",
+ "return M",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ (stub_root / "luci" / "util.lua").write_text(
+ "\n".join(
+ [
+ "local M = {}",
+ "function M.trim(value) return tostring(value or ''):match('^%s*(.-)%s*$') end",
+ "function M.pcdata(value) return tostring(value or '') end",
+ "return M",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ (stub_root / "nixio" / "fs.lua").write_text(
+ "\n".join(
+ [
+ "local M = {}",
+ "function M.readfile() return '' end",
+ "function M.writefile() return true end",
+ "function M.access() return false end",
+ "function M.mkdirr() return true end",
+ "function M.remove() return true end",
+ "function M.dir() return function() return nil end end",
+ "return M",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ # The controller reaches the daemon through luci.smart_srun.rpc,
+ # which opens a Unix socket. Loading it needs nixio present; this
+ # stub answers the one call made while the module loads and refuses
+ # the rest, so a future top-level socket call fails loudly here.
+ (stub_root / "nixio" / "init.lua").write_text(
+ "\n".join(
+ [
+ "local M = {}",
+ "function M.getpid() return 4242 end",
+ "function M.socket() error('no socket in the syntax probe') end",
+ "function M.fork() error('no fork in the syntax probe') end",
+ "return M",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ # rpc.lua loads nixio.util itself for writeall/readall, rather
+ # than relying on a sibling module having loaded nixio.fs first.
+ (stub_root / "nixio" / "util.lua").write_text("return {}\n", encoding="utf-8")
+ (stub_root / "luci" / "smart_srun" / "schema.lua").write_text(
+ "\n".join(
+ [
+ "local M = {}",
+ "M.POINTER_KEYS = {}",
+ "M.LIST_KEYS = {}",
+ "function M.global_scalar_key_set() return {} end",
+ "function M.with_file_lock(_, callback) return callback() end",
+ "function M.installed_package_display_text() return 'Bundle 版 v0.0.0' end",
+ "return M",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ script = """
+package.path = %s .. "/?.lua;" .. %s .. "/?/init.lua;" .. %s .. "/?.lua;" .. %s .. "/?/init.lua;" .. package.path
+_ = function(value) return value end
+entry = function() return {} end
+call = function(value) return value end
+cbi = function(value) return value end
+dofile(%s)
+local controller = package.loaded["luci.controller.smart_srun"]
+assert(controller and controller.friendly_line)
+local out = controller.friendly_line(%s)
+assert(out:find(%s, 1, true), out)
+assert(out:find("状态码=200", 1, true), out)
+assert(out:find("耗时=123ms", 1, true), out)
+assert(not out:find("password", 1, true), out)
+assert(not out:find("***", 1, true), out)
+""" % (
+ lua_string(stub_root),
+ lua_string(stub_root),
+ lua_string(LUA_ROOT.parent),
+ lua_string(LUA_ROOT.parent),
+ lua_string(CONTROLLER_FILE),
+ lua_string(
+ '[2026-06-01 22:00:00] INFO http_fetch_result url="'
+ + PORTAL_ORIGIN
+ + '/login" status_code=200 duration_ms=123 password=*** | ok'
+ ),
+ lua_string("URL=" + PORTAL_ORIGIN + "/login"),
+ )
+ script_path = Path(temp_dir) / "friendly_probe.lua"
+ script_path.write_text(script, encoding="utf-8")
+
+ with (
+ tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as stdout,
+ tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as stderr,
+ ):
+ result = subprocess.run(
+ [lua, str(script_path)],
+ stdin=subprocess.DEVNULL,
+ stdout=stdout,
+ stderr=stderr,
+ text=True,
+ )
+ stdout.seek(0)
+ stderr.seek(0)
+ output = (stderr.read() or stdout.read()).strip()
+ self.assertEqual(result.returncode, 0, output)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_luci_config_state_ui.py b/smart-srun/tests/test_luci_config_state_ui.py
new file mode 100644
index 000000000..25d5d94b7
--- /dev/null
+++ b/smart-srun/tests/test_luci_config_state_ui.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+import shutil
+import subprocess
+import unittest
+
+
+class LuciConfigStateTests(unittest.TestCase):
+ def test_cbi_configuration_read_and_save_states(self):
+ self.run_lua_probe("cbi_config_state.lua")
+
+ def test_controller_preserves_browser_revisions(self):
+ self.run_lua_probe("controller_config_revision.lua")
+
+ def run_lua_probe(self, name):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ root = Path(__file__).resolve().parents[1]
+ result = subprocess.run([lua, str(root / "tests/lua" / name), str(root)],
+ capture_output=True, text=True, encoding="utf-8", timeout=20)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+
+ def test_config_revisions_response_validation_and_status_polling(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ probe = Path(__file__).resolve().parent / "js/luci_config_state_test.js"
+ result = subprocess.run([node, str(probe)], capture_output=True, text=True,
+ encoding="utf-8", timeout=20)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
diff --git a/smart-srun/tests/test_luci_log_view_refactor.py b/smart-srun/tests/test_luci_log_view_refactor.py
new file mode 100644
index 000000000..9162ea80f
--- /dev/null
+++ b/smart-srun/tests/test_luci_log_view_refactor.py
@@ -0,0 +1,197 @@
+from pathlib import Path
+import unittest
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+CONTROLLER_FILE = (
+ REPO_ROOT / "root" / "usr" / "lib" / "lua" / "luci" / "controller" / "smart_srun.lua"
+)
+CBI_FILE = (
+ REPO_ROOT / "root" / "usr" / "lib" / "lua" / "luci" / "model" / "cbi" / "smart_srun.lua"
+)
+JS_FILE = REPO_ROOT / "root" / "www" / "luci-static" / "resources" / "smart_srun.js"
+
+
+class LuciLogViewRefactorTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.controller_text = CONTROLLER_FILE.read_text(encoding="utf-8")
+ cls.cbi_text = CBI_FILE.read_text(encoding="utf-8")
+ cls.js_text = JS_FILE.read_text(encoding="utf-8")
+
+ def test_controller_declares_network_event_allowlist(self):
+ for event_name in [
+ "bind_ip_resolved",
+ "http_fetch",
+ "http_fetch_result",
+ "connectivity_probe_begin",
+ "connectivity_probe_result",
+ "dns_probe_failed",
+ "srun_challenge",
+ "srun_challenge_result",
+ "srun_login_submit",
+ "srun_login_response",
+ "srun_online_query",
+ "srun_online_result",
+ "ip_wait_progress",
+ "ip_wait_result",
+ "wifi_reload",
+ "sta_section_disabled",
+ "uci_wireless_update",
+ ]:
+ self.assertIn(event_name, self.controller_text)
+
+ def test_controller_uses_channel_parameter_and_info_prefix(self):
+ self.assertIn('local channel = http.formvalue("channel") or "plugin"', self.controller_text)
+ self.assertIn('local download_mode = tostring(http.formvalue("download") or "") == "1"', self.controller_text)
+ self.assertIn('channel = channel == "network" and "network" or "plugin"', self.controller_text)
+ self.assertIn('local lines = tonumber(http.formvalue("lines")) or 1000', self.controller_text)
+ self.assertIn('[信息]', self.controller_text)
+ self.assertIn('if not zh then', self.controller_text)
+ self.assertIn('local suffix = extract_structured_suffix(rest, level, event)', self.controller_text)
+ self.assertIn('parts[#parts + 1] = " " .. suffix', self.controller_text)
+ self.assertIn('local function parse_structured_fields(rest, level, event)', self.controller_text)
+ self.assertIn('local function append_friendly_fields(parts, field_list, field_map, skipped)', self.controller_text)
+ self.assertIn('channel = channel,', self.controller_text)
+ self.assertIn('return read_plugin_full_log_text()', self.controller_text)
+ self.assertIn('local function resolve_network_source_lines(lines, download_mode)', self.controller_text)
+ self.assertIn('local source_lines = requested * 4', self.controller_text)
+ self.assertIn('local plugin_text = read_plugin_log_text(source_lines)', self.controller_text)
+ self.assertIn('local system_text = read_system_log_text(source_lines)', self.controller_text)
+ self.assertIn('local function tail_text(text, lines)', self.controller_text)
+ # The plugin log is the daemon's, read over the socket. tail_text stays
+ # for the system log, which is still `logread` output this page trims.
+ self.assertIn('rpc.call("log.tail"', self.controller_text)
+ self.assertNotIn("/var/log/smart_srun.log", self.controller_text)
+ self.assertIn('"logread -l " .. lines .. " 2>/dev/null"', self.controller_text)
+ self.assertIn('"logread 2>/dev/null"', self.controller_text)
+ self.assertIn('return tail_text(text, lines)', self.controller_text)
+ self.assertNotIn('"logread 2>/dev/null | tail -n " .. lines', self.controller_text)
+
+ def test_friendly_log_translation_preserves_structured_context(self):
+ # Known translated events still need the original structured fields; otherwise
+ # LuCI shows only a friendly description and drops the useful detail.
+ for key, label in [
+ ("url", "URL"),
+ ("status_code", "状态码"),
+ ("duration_ms", "耗时"),
+ ("queue_lag_ms", "排队"),
+ ("bytes_received", "字节"),
+ ("error_code", "错误码"),
+ ("username_reported", "在线账号"),
+ ("bind_ip", "绑定IP"),
+ ]:
+ self.assertIn(f'"{key}"', self.controller_text)
+ self.assertIn(f'{key} = "{label}"', self.controller_text)
+ self.assertIn('append_friendly_fields(parts, field_list, field_map, skip_fields("account", "reason", "attempt"))', self.controller_text)
+ self.assertIn('append_message_detail(parts, rest, has_detail)', self.controller_text)
+ self.assertIn('hidden_friendly_fields', self.controller_text)
+ self.assertIn('sensitive_friendly_key_parts', self.controller_text)
+ self.assertIn('local function is_hidden_friendly_field(key)', self.controller_text)
+ self.assertIn('is_hidden_friendly_field(key)', self.controller_text)
+
+ def test_cbi_log_panel_renders_simple_toolbar(self):
+ # The first screen is the newest hundred records from the daemon's own
+ # log; the page then polls by cursor. It reads no file of its own.
+ self.assertIn('rpc.call("log.tail", { lines = 100 })', self.cbi_text)
+ self.assertNotIn("/var/log/smart_srun.log", self.cbi_text)
+ self.assertNotIn('tail -n 100 /var/log/smart_srun.log', self.cbi_text)
+ self.assertIn('log_controller.friendly_log_text(t)', self.cbi_text)
+ self.assertNotIn('smart-srun-log-channels', self.cbi_text)
+ self.assertNotIn('data-channel="network"', self.cbi_text)
+ for element_id in [
+ 'smart-srun-log-start',
+ 'smart-srun-log-stop',
+ 'smart-srun-log-clear',
+ 'smart-srun-log-download',
+ 'smart-srun-log-level-filter',
+ ]:
+ self.assertIn(element_id, self.cbi_text)
+ self.assertIn('max-height:560px', self.cbi_text)
+
+ def test_js_log_view_tracks_refresh_and_download_state(self):
+ self.assertIn('var logState = {', self.js_text)
+ self.assertIn('refreshing: true', self.js_text)
+ self.assertIn("rawText: pre.textContent || ''", self.js_text)
+ self.assertIn('log_tail?channel=plugin&lines=', self.js_text)
+ self.assertNotIn('logState.channel', self.js_text)
+ self.assertIn('downloadCurrentLog', self.js_text)
+ self.assertIn("'smart_srun_plugin_'", self.js_text)
+ self.assertIn('[信息]', self.js_text)
+ self.assertIn('/cgi-bin/luci/admin/services/smart_srun/log_clear', self.js_text)
+ self.assertIn('levelFilter.addEventListener', self.js_text)
+ self.assertIn('data.ok', self.js_text)
+
+ def test_controller_exposes_plugin_log_clear_endpoint(self):
+ self.assertIn('log_clear', self.controller_text)
+ self.assertIn('function action_log_clear()', self.controller_text)
+ # Clearing is the daemon's own operation on its own log. The page never
+ # truncates a file, and the system log is refused before the call.
+ self.assertIn('rpc.call_started("log.clear", nil)', self.controller_text)
+ self.assertIn("系统网络日志不能由插件清空", self.controller_text)
+ self.assertIn('channel = "plugin"', self.controller_text)
+
+ def test_js_uses_short_live_window_and_full_download_window(self):
+ # Live refresh hits the server with a small line count (perf), while download
+ # uses a dedicated raw/full request path.
+ self.assertIn('var LOG_LIVE_LINES = 100', self.js_text)
+ self.assertIn('var LOG_DOWNLOAD_LINES = 0', self.js_text)
+ self.assertIn("buildLogUrl(LOG_LIVE_LINES, 'friendly', false)", self.js_text)
+ self.assertIn("buildLogUrl(LOG_DOWNLOAD_LINES, 'raw', true)", self.js_text)
+ self.assertIn("'&format=' + encodeURIComponent(format || 'friendly')", self.js_text)
+ self.assertIn("(download ? '&download=1' : '')", self.js_text)
+
+ def test_js_display_level_filter_is_live_and_hooks_log_level_select(self):
+ # Display-side level filter weights are owned by the log toolbar itself.
+ self.assertIn('LOG_LEVEL_WEIGHTS', self.js_text)
+ self.assertIn("ALL: 0", self.js_text)
+ self.assertIn("ERROR: 40", self.js_text)
+ self.assertIn('logLineWeight', self.js_text)
+ self.assertIn('filterByLevel', self.js_text)
+ self.assertNotIn('findLogLevelSelect', self.js_text)
+ self.assertIn('displayLevel', self.js_text)
+ self.assertIn("levelFilter.addEventListener('change'", self.js_text)
+ self.assertIn('applyDisplayLevel', self.js_text)
+
+ def test_js_skips_background_polling_when_page_hidden(self):
+ self.assertIn('function isPageHidden()', self.js_text)
+ self.assertIn('document.hidden === true', self.js_text)
+ self.assertIn('function onPageVisible(callback)', self.js_text)
+ self.assertIn("document.addEventListener('visibilitychange'", self.js_text)
+ self.assertIn('if (isPageHidden()) return;', self.js_text)
+ self.assertIn('onPageVisible(refreshOverview)', self.js_text)
+
+ def test_school_preset_apply_button_is_explicit(self):
+ # Keep a compact source contract for the preset UI; full form wiring is
+ # covered by runtime/config tests rather than brittle string laundry lists.
+ self.assertIn("smart-school-preset-data", self.cbi_text)
+ self.assertIn("presets_refresh", self.controller_text)
+ self.assertIn("window.smartRefreshPresets", self.js_text)
+ self.assertIn("jm-apply-school-defaults", self.js_text)
+ self.assertIn("applySchoolDefaultsToForm", self.js_text)
+ self.assertIn("jm-save-school-preset", self.js_text)
+ self.assertIn("user_presets_set", self.js_text)
+ self.assertIn("action_user_presets_set", self.controller_text)
+ self.assertNotIn("localStorage", self.js_text)
+ self.assertIn("jm-operator-suffix-hint", self.js_text)
+ self.assertIn("operatorSuffixOf", self.js_text)
+ self.assertNotIn("schoolDefaults.operator", self.js_text)
+ self.assertNotIn("opId === 'xn' ? '' : opId", self.js_text)
+ self.assertNotIn("不填则使用纯账号", self.js_text)
+
+ def test_apply_school_preset_replaces_operator_choices_not_merges(self):
+ # Switching school A -> school B must drop A's operators (e.g. nchu
+ # "学生用户") and install B's list; custom entries stay.
+ self.assertIn("function replacePresetOperators(preset)", self.js_text)
+ self.assertIn("var nextOperators = replacePresetOperators(preset)", self.js_text)
+ self.assertNotIn("function mergePresetOperators", self.js_text)
+ self.assertNotIn("mergePresetOperators(preset)", self.js_text)
+ # Non-custom rows are discarded before re-adding the new preset ops.
+ self.assertIn("if (operatorChoices[i].custom) kept.push(operatorChoices[i])", self.js_text)
+ self.assertIn("operatorChoices = kept", self.js_text)
+ # Empty operator list from a preset must clear leftover suffix text.
+ self.assertIn("if (emptySuffix) emptySuffix.value = ''", self.js_text)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_luci_rpc_bridge.py b/smart-srun/tests/test_luci_rpc_bridge.py
new file mode 100644
index 000000000..561b6b218
--- /dev/null
+++ b/smart-srun/tests/test_luci_rpc_bridge.py
@@ -0,0 +1,77 @@
+"""The LuCI bridge's mapping between the frozen form and config v2.
+
+The interface keeps the baseline's flat field names while the daemon owns a
+typed configuration, so a wrong destination here is a setting that silently
+does not take effect -- or, in the password case, a credential a routine edit
+would wipe. The assertions live in Lua next door and run under the same
+interpreter version LuCI uses; this wrapper is what puts them in the suite.
+"""
+
+import shutil
+import subprocess
+import unittest
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+LUA_ROOT = REPO_ROOT / "root" / "usr" / "lib" / "lua"
+PROBE_DIR = Path(__file__).resolve().parent / "lua"
+
+
+class LuciRpcBridgeTests(unittest.TestCase):
+ def test_native_version_display(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ result = subprocess.run([lua, str(PROBE_DIR / "version_probe.lua"), str(REPO_ROOT)],
+ cwd=REPO_ROOT, capture_output=True, text=True)
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
+
+ def test_discovery_and_refresh_controller(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ result = subprocess.run(
+ [lua, str(PROBE_DIR / "controller_discovery.lua"), str(REPO_ROOT)],
+ cwd=str(REPO_ROOT), capture_output=True, text=True,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
+
+ def test_acid_job_client(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ result = subprocess.run(
+ [node, str(PROBE_DIR.parent / "js" / "acid_job_test.js")],
+ cwd=str(REPO_ROOT), capture_output=True, text=True,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
+
+ def test_bridge_mapping_probe_passes(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+
+ probe = PROBE_DIR / "bridge_probe.lua"
+ self.assertTrue(probe.is_file(), probe)
+ result = subprocess.run(
+ [lua, "-e", "require 'stub_env'; dofile([[%s]])" % probe.as_posix()],
+ cwd=str(REPO_ROOT),
+ env={
+ "LUA_PATH": ";".join(
+ [
+ "%s/?.lua" % LUA_ROOT.as_posix(),
+ "%s/?.lua" % PROBE_DIR.as_posix(),
+ "",
+ ]
+ ),
+ "PATH": "/usr/bin:/bin",
+ },
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_package_conflicts.py b/smart-srun/tests/test_package_conflicts.py
new file mode 100644
index 000000000..bf8a11387
--- /dev/null
+++ b/smart-srun/tests/test_package_conflicts.py
@@ -0,0 +1,132 @@
+"""Evaluate package metadata without downloading an OpenWrt SDK.
+
+The SDK captures the build dependency graph inside BuildPackage, then expands
+Package//DEPENDS when writing the package. Keep that boundary in this
+fixture so APK conflicts cannot accidentally become Kconfig dependencies.
+Actual SDK builds additionally validate the generated APK/IPK metadata.
+"""
+
+import re
+import shutil
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+PACKAGES = ("smart-srun", "luci-app-smart-srun", "luci-app-smart-srun-bundle")
+BUILD_DEPENDS = {
+ "smart-srun": {"+ca-bundle", "+uci", "+ubus", "+procd", "+rpcd", "+rpcd-mod-iwinfo"},
+ "luci-app-smart-srun": {"+smart-srun", "+luci-base", "+luci-compat"},
+ "luci-app-smart-srun-bundle": {"+ca-bundle", "+uci", "+ubus", "+procd", "+rpcd", "+rpcd-mod-iwinfo", "+luci-base", "+luci-compat"},
+}
+CONFLICTS = {
+ "smart-srun": {"luci-app-smart-srun-bundle"},
+ "luci-app-smart-srun": {"luci-app-smart-srun-bundle"},
+ "luci-app-smart-srun-bundle": {"smart-srun", "luci-app-smart-srun"},
+}
+
+
+@unittest.skipUnless(shutil.which("make"), "GNU make is required")
+class PackageConflictTests(unittest.TestCase):
+ def evaluate_metadata(self, use_apk, dump=False):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir)
+ (path / "rules.mk").write_text("INCLUDE_DIR := .\n", encoding="utf-8")
+ go_framework = path / "feeds/packages/lang/golang/golang-package.mk"
+ go_framework.parent.mkdir(parents=True)
+ go_framework.write_text("# Dependency graph fixture; real builds use the pinned SDK framework.\n")
+ (path / "package.mk").write_text(
+ "define BuildPackage\n"
+ "$(eval DEPENDS :=)\n"
+ "$(eval CONFLICTS :=)\n"
+ "$(eval EXTRA_DEPENDS :=)\n"
+ "$(eval $(call Package/$(1)))\n"
+ "$(eval BuildDepends/$(1) := $(DEPENDS))\n"
+ "$(eval Conflicts/$(1) := $(CONFLICTS))\n"
+ "$(eval ExactDepends/$(1) := $(EXTRA_DEPENDS))\n"
+ "$(eval Package/$(1)/DEPENDS := $(subst +,,$(DEPENDS)))\n"
+ "endef\n",
+ encoding="utf-8",
+ )
+ makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
+ for package in PACKAGES:
+ for field, variable in (
+ ("build", "BuildDepends/" + package),
+ ("conflicts", "Conflicts/" + package),
+ ("runtime", "Package/" + package + "/DEPENDS"),
+ ("exact", "ExactDepends/" + package),
+ ):
+ makefile += "\n$(info contract:%s:%s:$(%s))" % (
+ package,
+ field,
+ variable,
+ )
+ makefile += "\n.PHONY: contract\ncontract:\n"
+ (path / "Makefile").write_text(makefile, encoding="utf-8")
+ completed = subprocess.run(
+ [
+ shutil.which("make"),
+ "--no-print-directory",
+ "-s",
+ "TOPDIR=.",
+ "CONFIG_USE_APK=" + ("y" if use_apk else ""),
+ "DUMP=" + ("1" if dump else ""),
+ "VERSION=2.0.0~rc1-r1",
+ "contract",
+ ],
+ cwd=path,
+ stdin=subprocess.DEVNULL,
+ text=True,
+ capture_output=True,
+ check=True,
+ )
+ metadata = {}
+ for line in completed.stdout.splitlines():
+ if line.startswith("contract:"):
+ _, package, field, value = line.split(":", 3)
+ metadata.setdefault(package, {})[field] = set(
+ filter(None, re.split(r"[,\s]+", value.strip()))
+ )
+ self.assertEqual(set(metadata), set(PACKAGES))
+ return metadata
+
+ def test_luci_uses_sdk_version_including_its_actual_release_suffix(self):
+ metadata = self.evaluate_metadata(False)
+ self.assertEqual(metadata["luci-app-smart-srun"]["exact"],
+ {"smart-srun", "(=2.0.0~rc1-r1)"})
+
+ def test_dump_omits_runtime_conflicts_from_kconfig(self):
+ metadata = self.evaluate_metadata(False, dump=True)
+ for package in PACKAGES:
+ with self.subTest(package=package):
+ self.assertEqual(metadata[package]["build"], BUILD_DEPENDS[package])
+ self.assertEqual(metadata[package]["conflicts"], set())
+
+ def test_ipk_keeps_conflicts_separate_from_runtime_dependencies(self):
+ metadata = self.evaluate_metadata(False)
+ for package in PACKAGES:
+ with self.subTest(package=package):
+ self.assertEqual(metadata[package]["build"], BUILD_DEPENDS[package])
+ self.assertEqual(metadata[package]["conflicts"], CONFLICTS[package])
+ self.assertEqual(
+ metadata[package]["runtime"],
+ {dep.lstrip("+") for dep in BUILD_DEPENDS[package]},
+ )
+
+ def test_apk_excludes_bundle_without_changing_the_split_build_graph(self):
+ metadata = self.evaluate_metadata(True)
+ for package in PACKAGES:
+ with self.subTest(package=package):
+ self.assertEqual(metadata[package]["build"], BUILD_DEPENDS[package])
+ self.assertEqual(metadata[package]["conflicts"], CONFLICTS[package])
+ self.assertEqual(
+ metadata[package]["runtime"],
+ {dep.lstrip("+") for dep in BUILD_DEPENDS[package]}
+ | {"!" + conflict for conflict in CONFLICTS[package]},
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_portal_fixtures.py b/smart-srun/tests/test_portal_fixtures.py
new file mode 100644
index 000000000..223a73f63
--- /dev/null
+++ b/smart-srun/tests/test_portal_fixtures.py
@@ -0,0 +1,72 @@
+"""Keep behavioral fixtures separate from intentional preset-data assertions."""
+
+import ipaddress
+import os
+import runpy
+import unittest
+from pathlib import Path
+from unittest import mock
+from urllib.parse import urlsplit
+
+
+FIXTURE_FILE = Path(__file__).with_name("_portal_urls.py")
+DOCUMENTATION_NETWORKS = tuple(
+ ipaddress.ip_network(cidr)
+ for cidr in ("192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24")
+)
+
+
+def load_fixtures(**overrides):
+ environment = {
+ key: value
+ for key, value in os.environ.items()
+ if not key.startswith("SMARTSRUN_TEST_")
+ }
+ environment.update(overrides)
+ with mock.patch.dict(os.environ, environment, clear=True):
+ return runpy.run_path(str(FIXTURE_FILE))
+
+
+class PortalFixtureTests(unittest.TestCase):
+ def test_defaults_use_reserved_names_and_documentation_addresses(self):
+ fixtures = load_fixtures()
+ # Real gateways belong in dedicated preset catalogue assertions, not
+ # the default addresses used by socket mocks and behavioral tests.
+ for key in ("PORTAL_ORIGIN", "PORTAL_HTTPS_ORIGIN"):
+ with self.subTest(key=key):
+ self.assertTrue(urlsplit(fixtures[key]).hostname.endswith(".test"))
+
+ addresses = [
+ fixtures["CLIENT_IP"],
+ fixtures["BIND_IP"],
+ fixtures["WIRED_BIND_IP"],
+ fixtures["DNS_SERVER_IP"],
+ fixtures["PORTAL_BARE_HOST"],
+ urlsplit(fixtures["PORTAL_IPV4_ORIGIN"]).hostname,
+ ]
+ for value in addresses:
+ with self.subTest(address=value):
+ self.assertTrue(
+ any(
+ ipaddress.ip_address(value) in net
+ for net in DOCUMENTATION_NETWORKS
+ )
+ )
+
+ def test_local_overrides_keep_origin_and_scheme_contract(self):
+ fixtures = load_fixtures(
+ SMARTSRUN_TEST_PORTAL_ORIGIN="https://custom.example.test:8080/path?ac_id=9",
+ SMARTSRUN_TEST_PORTAL_HTTPS_ORIGIN="https://secure.example.test:8443/path",
+ SMARTSRUN_TEST_PORTAL_BARE_HOST="http://203.0.113.25/page",
+ )
+ self.assertEqual(fixtures["PORTAL_ORIGIN"], "http://custom.example.test:8080")
+ self.assertEqual(fixtures["PORTAL_DNS_NAME"], "custom.example.test")
+ self.assertEqual(fixtures["PORTAL_PORT"], 8080)
+ self.assertEqual(
+ fixtures["PORTAL_HTTPS_ORIGIN"], "https://secure.example.test:8443"
+ )
+ self.assertEqual(fixtures["PORTAL_BARE_ORIGIN"], "http://203.0.113.25")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_portal_luci.py b/smart-srun/tests/test_portal_luci.py
new file mode 100644
index 000000000..40d710a63
--- /dev/null
+++ b/smart-srun/tests/test_portal_luci.py
@@ -0,0 +1,290 @@
+"""Exercise the LuCI action result and portal link without a router/browser."""
+
+import json
+import shutil
+import subprocess
+import unittest
+from pathlib import Path
+
+from _portal_urls import PORTAL_IPV4_ORIGIN
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+JS_FILE = REPO_ROOT / "root/www/luci-static/resources/smart_srun.js"
+CONTROLLER_FILE = REPO_ROOT / "root/usr/lib/lua/luci/controller/smart_srun.lua"
+CBI_FILE = REPO_ROOT / "root/usr/lib/lua/luci/model/cbi/smart_srun.lua"
+
+
+class PortalLuciTests(unittest.TestCase):
+ def test_mutation_handlers_send_csrf_token(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ subprocess.run(
+ [node, str(REPO_ROOT / "tests/js/luci_action_transport_test.js"), str(JS_FILE)],
+ check=True, capture_output=True, text=True, encoding="utf-8", timeout=30,
+ )
+
+ def _run_ui(self, status, receipt="instance-a1", force_response=None):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ # Run the shipped script. A small DOM/XHR surface lets the real modal
+ # consume the same status payload as the controller sends to browsers.
+ script = r"""
+const fs = require('fs');
+const vm = require('vm');
+const payload = JSON.parse(process.argv[1]);
+const forceResponse = JSON.parse(process.argv[4]);
+const source = fs.readFileSync(process.argv[2], 'utf8');
+function element(tag, attrs, text) {
+ const el = {
+ tag, children: [], style: {}, textContent: text || '',
+ appendChild(child) { this.children.push(child); return child; },
+ addEventListener() {}, setAttribute(key, value) { this[key] = value; }
+ };
+ if (tag === 'a') Object.defineProperty(el, 'href', {
+ set(value) {
+ this.url = value;
+ try {
+ const url = new URL(value);
+ this.protocol = url.protocol;
+ this.host = url.host;
+ } catch (_) {}
+ },
+ get() { return this.url; }
+ });
+ Object.assign(el, attrs || {});
+ return el;
+}
+const nodes = {
+ 'smart-srun-manual-result': element('span'),
+ 'smart-srun-manual-portal': element('div')
+};
+const urls = [];
+let modal = null;
+let cleared = 0;
+function XHR() {}
+XHR.prototype.open = function(method, url) { this.url = url; urls.push(url); };
+XHR.prototype.setRequestHeader = function() {};
+XHR.prototype.send = function() {
+ this.readyState = 4;
+ const force = this.url.indexOf('/enqueue') >= 0;
+ this.status = force ? forceResponse.status : 200;
+ this.responseText = force ? forceResponse.body : JSON.stringify(
+ this.url.indexOf('/status?') >= 0 ? payload : {empty: true}
+ );
+ this.onreadystatechange();
+};
+const context = {
+ window: { setInterval() { return 1; }, clearInterval() { cleared += 1; } },
+ document: { readyState: 'loading', addEventListener() {},
+ querySelector() { return {value: 'csrf-token'}; },
+ getElementById(id) { return nodes[id] || null; },
+ createElement(tag) { return element(tag); }, createTextNode(text) { return {textContent: text}; }
+ },
+ E: element, XMLHttpRequest: XHR,
+ L: { showModal(title, contents) { modal = contents; } },
+ Date, JSON
+};
+vm.runInNewContext(source, context);
+context.window.smartOpenBlockingFeedback('manual_login', 100, JSON.parse(process.argv[3]));
+if (forceResponse) modal[3].children[1].click({preventDefault() {}});
+console.log(JSON.stringify({result: nodes['smart-srun-manual-result'].textContent,
+ tip: modal[0].textContent, portal: modal[2].children,
+ resultPortal: nodes['smart-srun-manual-portal'].children, urls, cleared,
+ progressDisabled: !!modal[3].children[0].disabled, forceDisabled: !!modal[3].children[1].disabled}));
+"""
+ result = subprocess.run(
+ [node, "-e", script, json.dumps(status), str(JS_FILE), json.dumps(receipt), json.dumps(force_response)],
+ stdin=subprocess.DEVNULL,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ return json.loads(result.stdout)
+
+ def _status(self, **extra):
+ status = {
+ "action_id": "instance-a1",
+ "last_action": "manual_login",
+ "last_action_ts": 101,
+ "action_result": "error",
+ "status": "后续守护状态",
+ "last_action_message": "认证网关可达,但尚未联网,可尝试网页登录。",
+ "last_action_portal_url": PORTAL_IPV4_ORIGIN,
+ }
+ status.update(extra)
+ return status
+
+ def test_terminal_uses_stable_result_and_explicit_safe_link(self):
+ status = self._status()
+ rendered = self._run_ui(status)
+ self.assertEqual(status["last_action_message"], rendered["tip"])
+ self.assertIn(status["last_action_message"], rendered["result"])
+ self.assertNotIn(status["status"], rendered["result"])
+ for key in ("portal", "resultPortal"):
+ link = rendered[key][0]
+ self.assertEqual(PORTAL_IPV4_ORIGIN, link["url"])
+ self.assertEqual("_blank", link["target"])
+ self.assertEqual("noopener noreferrer", link["rel"])
+ self.assertEqual(1, rendered["cleared"])
+ self.assertTrue(
+ any(
+ "log_tail?lines=200&format=friendly&since=100" in url
+ for url in rendered["urls"]
+ )
+ )
+
+ def test_old_status_payload_remains_compatible(self):
+ rendered = self._run_ui(
+ self._status(last_action_message="", last_action_portal_url="")
+ )
+ self.assertEqual("后续守护状态", rendered["tip"])
+ self.assertEqual([], rendered["portal"])
+
+ def test_another_actions_result_does_not_finish_current_modal(self):
+ rendered = self._run_ui(self._status(action_id="instance-a2"))
+ self.assertEqual(0, rendered["cleared"])
+ self.assertEqual([], rendered["portal"])
+ self.assertIn("正在执行", rendered["tip"])
+
+ def test_clock_rollback_cannot_hide_own_terminal_result(self):
+ rendered = self._run_ui(self._status(last_action_ts=-3600))
+ self.assertEqual(1, rendered["cleared"])
+ self.assertIn("尚未联网", rendered["tip"])
+ self.assertTrue(any("status?action_id=instance-a1&" in url for url in rendered["urls"]))
+
+ def test_lost_history_or_stopped_service_is_a_closable_error(self):
+ rendered = self._run_ui(self._status(last_action="", last_action_message="操作记录已失效"))
+ self.assertEqual("操作记录已失效", rendered["tip"])
+ self.assertEqual(1, rendered["cleared"])
+
+ def test_missing_receipt_does_not_poll_global_state(self):
+ rendered = self._run_ui(self._status(), receipt=None)
+ self.assertIn("无法读取操作回执", rendered["tip"])
+ self.assertEqual([], rendered["urls"])
+
+ def test_rejected_force_stop_does_not_claim_stopped_or_end_polling(self):
+ for response in (
+ {"status": 403, "body": "csrf rejected"},
+ {"status": 0, "body": ""},
+ {"status": 200, "body": "invalid json"},
+ {"status": 200, "body": json.dumps({"ok": False, "message": "停止失败"})},
+ ):
+ with self.subTest(response=response):
+ rendered = self._run_ui(self._status(action_result="pending"), force_response=response)
+ self.assertIn("失败", rendered["tip"])
+ self.assertEqual(0, rendered["cleared"])
+ self.assertTrue(rendered["progressDisabled"])
+ self.assertFalse(rendered["forceDisabled"])
+
+ def test_confirmed_force_stop_unlocks_the_dialog(self):
+ rendered = self._run_ui(self._status(action_result="pending"), force_response={
+ "status": 200, "body": json.dumps({"ok": True, "message": "已停止"})})
+ self.assertEqual("已停止", rendered["tip"])
+ self.assertEqual(1, rendered["cleared"])
+ self.assertFalse(rendered["progressDisabled"])
+ self.assertTrue(rendered["forceDisabled"])
+
+ def test_unsafe_portal_urls_are_never_rendered(self):
+ for url in (
+ "javascript:alert(1)",
+ "data:text/html,hello",
+ "//example.test",
+ "https://example.test\n/",
+ "https://example.test\\other",
+ ):
+ with self.subTest(url=url):
+ rendered = self._run_ui(self._status(last_action_portal_url=url))
+ self.assertEqual([], rendered["portal"])
+ self.assertEqual([], rendered["resultPortal"])
+
+ def test_success_cannot_reuse_stale_portal_guidance(self):
+ rendered = self._run_ui(
+ self._status(action_result="ok", last_action_message="登录成功")
+ )
+ self.assertEqual("登录成功", rendered["tip"])
+ self.assertEqual([], rendered["portal"])
+
+ def test_status_projection_publishes_action_specific_feedback(self):
+ """The result of the last action is part of the status answer.
+
+ It is built from the daemon's action record now, not copied out of a
+ state file the page also wrote. Clearing it when a new action starts is
+ the daemon's job (observe.Store.ActionStarted), which is why the page
+ no longer has a line that blanks it. (M00 ledger:
+ replaced_with_stronger_test -> T39;T24.)
+ """
+ bridge = (REPO_ROOT / "root/usr/lib/lua/luci/smart_srun/bridge.lua").read_text(
+ encoding="utf-8"
+ )
+ for field in ("last_action", "last_action_message", "action_result",
+ "last_action_portal_url", "last_action_ts"):
+ self.assertIn(f"{field} =", bridge)
+ self.assertIn("ACTION_RESULT", bridge)
+ self.assertIn(
+ 'id="smart-srun-manual-portal"', CBI_FILE.read_text(encoding="utf-8")
+ )
+
+ def test_controller_status_enqueue_and_friendly_logs_execute(self):
+ """Drive the shipped controller against a recording stand-in daemon.
+
+ The old version of this test stubbed the filesystem, because the page
+ used to answer from files it also wrote. It now stubs the control
+ protocol instead, which is where the work went: what is asserted is the
+ request that leaves the page, the credential that does not, and the
+ refusal that reaches the browser unchanged. (M00 ledger:
+ ported_behavior -> T39;T24.)
+ """
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ result = subprocess.run(
+ [lua, (REPO_ROOT / "tests/lua/controller_actions.lua").as_posix(),
+ REPO_ROOT.as_posix()],
+ cwd=str(REPO_ROOT),
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=30,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
+
+ def test_luci_account_editor_reads_legacy_interface_but_saves_canonical(self):
+ controller = CONTROLLER_FILE.read_text(encoding="utf-8")
+ bridge = (REPO_ROOT / "root/usr/lib/lua/luci/smart_srun/bridge.lua").read_text(
+ encoding="utf-8"
+ )
+ js = JS_FILE.read_text(encoding="utf-8")
+ cbi = CBI_FILE.read_text(encoding="utf-8")
+ # The alias is read from the form and resolved in one place; the patch
+ # that goes to the daemon carries only the canonical field, so nothing
+ # has to remember to delete the old one afterwards.
+ self.assertIn('network_interface = fv("network_interface")', controller)
+ self.assertIn("trim(form.network_interface)", bridge)
+ self.assertIn("patch.wired_iface", bridge)
+ self.assertNotIn("patch.network_interface", bridge)
+ self.assertIn("String(item.network_interface || '').replace", js)
+ self.assertIn("fd.append('wired_iface'", js)
+ self.assertNotIn("fd.append('network_interface'", js)
+ self.assertIn('util.trim(tostring(a.network_interface or ""))', cbi)
+
+ def test_empty_auth_address_probes_the_environment_instead_of_refusing(self):
+ # 认证地址是小白最填不出来的一项,不能拿它当探测的前置条件。空地址时
+ # 前端要改问 detect_env,由路由器自己从强制门户跳转里捞出地址。
+ controller = CONTROLLER_FILE.read_text(encoding="utf-8")
+ js = JS_FILE.read_text(encoding="utf-8")
+
+ self.assertIn('"detect_env"}, call("action_detect_env")', controller)
+ self.assertIn("function action_detect_env()", controller)
+ self.assertIn('discovery_job("detect.environment")', controller)
+
+ self.assertIn("var path = baseUrl ? 'detect_acid' : 'detect_env';", js)
+ self.assertNotIn("请先填写认证地址", js)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_preset_catalogue.py b/smart-srun/tests/test_preset_catalogue.py
new file mode 100644
index 000000000..838d1baac
--- /dev/null
+++ b/smart-srun/tests/test_preset_catalogue.py
@@ -0,0 +1,51 @@
+"""Validate the single catalogue installed verbatim by the Go SDK package."""
+import json
+from pathlib import Path
+import re
+import unittest
+from urllib.parse import unquote, urlsplit
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+DOC_LINK_RE = re.compile(r"^/[^/]+/[^/]+/blob/[^/]+/doc/(?P.+)$")
+LOGIN_SHAPE_KEYS = frozenset(("n", "type", "enc", "double_stack", "info_prefix", "os", "name", "login_os", "login_name"))
+
+class PresetCatalogueTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.payload = json.loads((REPO_ROOT / "doc/school-presets.json").read_text(encoding="utf-8"))
+ cls.schools = {school["id"]: school for school in cls.payload["schools"]}
+
+ def test_links_to_repository_docs_have_existing_targets(self):
+ for school in self.payload["schools"]:
+ url = urlsplit(school.get("doc_url", ""))
+ link = DOC_LINK_RE.match(url.path) if url.netloc == "github.com" else None
+ if link:
+ with self.subTest(school=school["id"]):
+ target = REPO_ROOT / "doc" / unquote(link.group("path"))
+ self.assertTrue(target.is_file(), school["doc_url"])
+
+ def test_catalogue_ids_and_present_values_are_valid_without_filling_unknowns(self):
+ self.assertEqual(len(self.schools), len(self.payload["schools"]), "school IDs must be unique")
+ for school in self.payload["schools"]:
+ with self.subTest(school=school["id"]):
+ self.assertRegex(school["id"], r"^[a-z0-9][a-z0-9_-]*$")
+ self.assertTrue(school["name"].strip())
+ self.assertIn(school["status"], ("active", "draft"))
+ defaults = school.get("defaults", {})
+ self.assertFalse({"operator", "operator_suffix", "no_suffix_operators"} & set(defaults))
+ self.assertNotIn("no_suffix_operators", school)
+ if "base_url" in defaults:
+ self.assertTrue(re.match(r"^https?://[^\s]+$", defaults["base_url"]))
+ if "ac_id" in defaults:
+ self.assertIsInstance(defaults["ac_id"], str)
+ self.assertTrue(defaults["ac_id"].isdigit())
+ if "access_mode" in defaults:
+ self.assertIn(defaults["access_mode"], ("wired", "wifi"))
+ for operator in school.get("operators", []):
+ self.assertIsInstance(operator.get("suffix"), str)
+ self.assertTrue(operator.get("label", "").strip())
+ self.assertNotIn("id", operator)
+ shape = school.get("observed_login_shape", {})
+ self.assertLessEqual(set(shape), LOGIN_SHAPE_KEYS)
+ for value in shape.values():
+ self.assertIsInstance(value, str)
diff --git a/smart-srun/tests/test_preset_luci.py b/smart-srun/tests/test_preset_luci.py
new file mode 100644
index 000000000..c789b8fe9
--- /dev/null
+++ b/smart-srun/tests/test_preset_luci.py
@@ -0,0 +1,231 @@
+"""Run the shipped LuCI preset functions against a small DOM/XHR surface."""
+
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import unittest
+
+from _portal_urls import PORTAL_ORIGIN
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+JS_FILE = REPO_ROOT / "root/www/luci-static/resources/smart_srun.js"
+
+
+class PresetLuciTests(unittest.TestCase):
+ def test_auth_strategy_selector_and_documentation_are_separate_from_parameter_presets(self):
+ cbi = (REPO_ROOT / "root/usr/lib/lua/luci/model/cbi/smart_srun.lua").read_text(encoding="utf-8")
+ self.assertIn('ListValue, "school", "认证策略"', cbi)
+ self.assertIn('school:value("default", "通用深澜认证")', cbi)
+ self.assertIn('render_school_info_html(schools)', cbi)
+ self.assertIn('set_value("school", next_school)', cbi)
+ self.assertNotIn('点击查看学校预设文档', cbi)
+ source = JS_FILE.read_text(encoding="utf-8")
+ info = source.split('function initSchoolInfo()', 1)[1].split('function initOverview()', 1)[0]
+ self.assertIn("readJson('smart-auth-strategy-data', [])", info)
+ self.assertNotIn('schoolPresetList()', info)
+ self.assertIn('guide/authentication', info)
+ self.assertIn("wizRow('学校预设', wizPresetSelect())", source)
+
+ def test_strategy_documentation_handles_delayed_controls_and_ignores_preset_urls(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ script = r"""
+const fs = require('fs'), vm = require('vm'), assert = require('assert');
+let source = fs.readFileSync(process.argv[1], 'utf8');
+const end = source.lastIndexOf('})();');
+source = source.slice(0, end) + 'window.initStrategy = initSchoolInfo;' + source.slice(end);
+const listeners = {}, link = {href: 'https://example.test/server-rendered'}, nodes = {
+ 'smart-school-info': {style: {}}, 'smart-school-doc-link': link,
+ 'smart-auth-strategy-data': {value: JSON.stringify([
+ {short_name: 'custom', doc_url: 'https://example.test/runtime'},
+ {short_name: 'invalid', doc_url: 'javascript:alert(1)'}
+ ])},
+ 'smart-school-preset-data': {value: JSON.stringify([{short_name:'custom', doc_url:'https://example.test/preset'}])}
+};
+const context = {window: {}, document: {
+ readyState: 'loading', querySelector() { return null; },
+ getElementById(id) { return nodes[id] || null; },
+ addEventListener(name, fn) { (listeners[name] || (listeners[name] = [])).push(fn); }
+}};
+vm.runInNewContext(source, context);
+context.window.initStrategy();
+context.window.initStrategy();
+assert.strictEqual(listeners.change.length, 1);
+assert.strictEqual(link.href, 'https://example.test/server-rendered');
+const change = (value, name='cbid.smart_srun.main.school') => listeners.change[0]({target:{name,value}});
+change('custom', 'wiz-preset');
+assert.strictEqual(link.href, 'https://example.test/server-rendered');
+change('custom');
+assert.strictEqual(link.href, 'https://example.test/runtime');
+change('default');
+assert(link.href.endsWith('/guide/authentication'));
+change('invalid');
+assert(link.href.endsWith('/development/architecture'));
+"""
+ subprocess.run([node, "-e", script, str(JS_FILE)], check=True,
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", timeout=15)
+
+ def _run_ui(self, initial, custom=None, lookups=None, refreshes=None):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ scenario = {
+ "initial": initial,
+ "custom": custom or [],
+ "lookups": lookups or [],
+ "refreshes": refreshes or [],
+ }
+ # Expose the real closure functions only inside this test VM. Keeping
+ # readJson/fetchJson intact exercises both textarea and HTTP handling.
+ script = r"""
+const fs = require('fs');
+const vm = require('vm');
+const scenario = JSON.parse(process.argv[1]);
+let source = fs.readFileSync(process.argv[2], 'utf8');
+const end = source.lastIndexOf('})();');
+if (end < 0) throw new Error('Missing LuCI script closure');
+source = source.slice(0, end) + `
+ window.presetTestApi = {
+ list: schoolPresetList,
+ find: findSchoolPreset,
+ refresh: window.smartRefreshPresets,
+ initCustom: initUserPresetStore
+ };
+` + source.slice(end);
+function textarea(value) {
+ const text = JSON.stringify(value);
+ return {value: text, textContent: text};
+}
+const nodes = {
+ 'smart-school-preset-data': textarea(scenario.initial),
+ 'smart-user-preset-data': textarea({presets: scenario.custom, operators: []})
+};
+const pending = [];
+const timers = [];
+const urls = [];
+function XHR() {}
+XHR.prototype.open = function(method, url, async) {
+ if (method !== 'POST') throw new Error('Refresh and polling must use POST');
+ this.url = url;
+ this.async = async;
+ urls.push(url);
+};
+XHR.prototype.setRequestHeader = function() {};
+XHR.prototype.send = function(body) {
+ if (!body.includes('token=csrf-token')) throw new Error('Missing CSRF');
+ pending.push(this);
+};
+const context = {
+ window: {},
+ document: {
+ readyState: 'loading', addEventListener() {},
+ querySelector() { return {value: 'csrf-token'}; },
+ getElementById(id) { return nodes[id] || null; }
+ },
+ XMLHttpRequest: XHR,
+ setTimeout(fn) { timers.push(fn); return timers.length; }, clearTimeout() {},
+ Date, JSON
+};
+vm.runInNewContext(source, context);
+const api = context.window.presetTestApi;
+api.initCustom();
+function snapshot() {
+ const found = {};
+ scenario.lookups.forEach(id => { found[id] = api.find(id); });
+ return {
+ schools: api.list(), found,
+ value: nodes['smart-school-preset-data'].value,
+ textContent: nodes['smart-school-preset-data'].textContent
+ };
+}
+const states = [snapshot()];
+const beforeResponses = [];
+scenario.refreshes.forEach(payload => {
+ // A completed manual refresh allows a later click without reloading the page.
+ api.refresh();
+ beforeResponses.push(snapshot());
+ if (pending.length !== 1) throw new Error('Expected one asynchronous request');
+ const xhr = pending.shift();
+ if (!xhr.async) throw new Error('Preset request must be asynchronous');
+ xhr.readyState = 4;
+ xhr.status = 200;
+ xhr.responseText = JSON.stringify({ok:true, action_id:'refresh1', state:'queued'});
+ xhr.onload();
+ timers.shift()();
+ const poll = pending.shift();
+ poll.status = 200;
+ poll.responseText = JSON.stringify({ok:true, id:'refresh1', state:'succeeded', result:payload});
+ poll.onload();
+ states.push(snapshot());
+});
+console.log(JSON.stringify({states, beforeResponses, urls}));
+"""
+ result = subprocess.run(
+ [node, "-e", script, json.dumps(scenario), str(JS_FILE)],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=15,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ return json.loads(result.stdout)
+
+ def test_public_presets_require_active_status(self):
+ active = {"short_name": "active-school", "status": "active"}
+ rendered = self._run_ui([
+ active,
+ {"short_name": "draft-school", "status": "draft"},
+ {"short_name": "retired-school", "status": "deprecated"},
+ {"short_name": "unclassified-school"},
+ ])
+ self.assertEqual(rendered["states"][0]["schools"], [active])
+
+ def test_custom_preset_without_status_remains_findable(self):
+ active = {"short_name": "active-school", "status": "active"}
+ custom = {
+ "short_name": "custom-123", "name": "My campus", "custom": True,
+ "defaults": {"base_url": PORTAL_ORIGIN},
+ }
+ rendered = self._run_ui(
+ [active, {"short_name": "draft-school", "status": "draft"}],
+ custom=[custom],
+ lookups=["active-school", "draft-school", "custom-123", "__none__", "missing"],
+ )
+ found = rendered["states"][0]["found"]
+ self.assertEqual(found["active-school"], active)
+ self.assertEqual(found["custom-123"], custom)
+ self.assertIsNone(found["draft-school"])
+ self.assertIsNone(found["__none__"])
+ self.assertIsNone(found["missing"])
+
+ def test_async_empty_refresh_clears_public_presets_and_keeps_custom(self):
+ initial = {"short_name": "old-school", "status": "active"}
+ refreshed = {"short_name": "new-school", "status": "active"}
+ custom = {"short_name": "custom-123", "name": "My campus", "custom": True}
+ rendered = self._run_ui(
+ [initial],
+ custom=[custom],
+ lookups=["old-school", "new-school", "custom-123"],
+ refreshes=[{"ok": True, "schools": [refreshed]}, {"ok": True, "schools": []}],
+ )
+ before = rendered["beforeResponses"]
+ states = rendered["states"]
+ self.assertEqual(before[0]["schools"], [initial])
+ self.assertEqual(states[1]["schools"], [refreshed])
+ self.assertIsNone(states[1]["found"]["old-school"])
+ self.assertEqual(before[1]["schools"], [refreshed])
+ self.assertEqual(states[2]["schools"], [])
+ self.assertEqual(states[2]["value"], "[]")
+ self.assertEqual(states[2]["textContent"], "[]")
+ self.assertIsNone(states[2]["found"]["new-school"])
+ self.assertEqual(states[2]["found"]["custom-123"], custom)
+ self.assertEqual(len(rendered["urls"]), 4)
+ self.assertTrue(all(url.endswith("/presets_refresh") for url in rendered["urls"]))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_preset_schedule_ui.py b/smart-srun/tests/test_preset_schedule_ui.py
new file mode 100644
index 000000000..e633807a8
--- /dev/null
+++ b/smart-srun/tests/test_preset_schedule_ui.py
@@ -0,0 +1,104 @@
+"""Page rendering stays local; only an explicit refresh starts a network job."""
+
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class PresetScheduleUITests(unittest.TestCase):
+ def test_cbi_disabled_dependency_retains_saved_clock(self):
+ lua = shutil.which("lua")
+ if not lua:
+ self.skipTest("lua is not installed")
+ script = r'''
+local f = assert(io.open(arg[1], 'rb'))
+local source = f:read('*a'):gsub('\r\n', '\n'); f:close()
+local binders = assert(source:match('(local function bind_flag.-)\nlocal quiet_desc'))
+local controls = assert(source:match('(local preset_auto_update.-)\nbackoff_enable ='))
+local options = {}
+local s = {taboption=function(self, tab, kind, name)
+ local opt = {values={}}
+ function opt:value(value) self.values[value]=true end
+ function opt:depends() end
+ options[name]=opt; return opt
+end}
+for _, time in ipairs({'09:00', '23:45'}) do
+ local cfg={preset_auto_update_enabled='1', preset_update_time=time}
+ local env=setmetatable({cfg=cfg,s=s,util={trim=function(v) return v end},
+ validate_hhmm=function(v) return v end,
+ set_value=function(k,v) cfg[k]=v end}, {__index=_G})
+ local chunk=assert(loadstring(binders .. controls)); setfenv(chunk,env); chunk()
+ local toggle=options.preset_auto_update_enabled
+ local clock=options.preset_update_time
+ assert(clock.values[time] and clock:cfgvalue()==time)
+ toggle:write('main','0'); clock:remove('main')
+ assert(cfg.preset_auto_update_enabled=='0' and clock:cfgvalue()==time)
+ toggle:write('main','1')
+ assert(clock:cfgvalue()==time)
+ clock:write('main','22:00')
+ assert(cfg.preset_update_time=='22:00')
+end
+'''
+ # Lua 5.1 -e does not provide the remaining argv as arg reliably.
+ script = script.replace("arg[1]", json.dumps((ROOT / "root/usr/lib/lua/luci/model/cbi/smart_srun.lua").as_posix()))
+ result = subprocess.run([lua, "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=15)
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_page_open_does_not_refresh_and_manual_refresh_can_retry(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ script = r"""
+const fs = require('fs'), vm = require('vm');
+let source = fs.readFileSync(process.argv[1], 'utf8');
+const end = source.lastIndexOf('})();');
+source = source.slice(0, end) + `
+window.initForTest = initTables;
+postDiscovery = function(path, args, done) { window.requests.push(path); window.finish = done; };
+` + source.slice(end);
+const nodes = {};
+['smart-campus-data', 'smart-hotspot-data', 'smart-school-preset-data', 'smart-user-preset-data'].forEach(id => {
+ nodes[id] = {value: '[]', textContent: '[]'};
+});
+nodes['smart-presets-refresh'] = {};
+nodes['smart-presets-refresh-result'] = {};
+const context = {window: {requests: []}, document: {readyState: 'loading',
+ addEventListener() {}, getElementById(id) { return nodes[id] || null; }}, JSON, Date};
+vm.runInNewContext(source, context);
+context.window.initForTest();
+const opened = context.window.requests.length;
+context.window.smartRefreshPresets();
+context.window.smartRefreshPresets();
+const pending = context.window.requests.length;
+context.window.finish('failed');
+const retryEnabled = !nodes['smart-presets-refresh'].disabled;
+context.window.smartRefreshPresets();
+context.window.finish(null, {ok: true, schools: [{short_name: 'demo'}]});
+console.log(JSON.stringify({opened, pending, retryEnabled, requests: context.window.requests,
+ schools: JSON.parse(nodes['smart-school-preset-data'].value),
+ result: nodes['smart-presets-refresh-result'].textContent}));
+"""
+ result = subprocess.run(
+ [node, "-e", script, str(ROOT / "root/www/luci-static/resources/smart_srun.js")],
+ capture_output=True, text=True, encoding="utf-8", timeout=15,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ actual = json.loads(result.stdout)
+ self.assertEqual(actual["opened"], 0)
+ self.assertEqual(actual["pending"], 1)
+ self.assertTrue(actual["retryEnabled"])
+ self.assertEqual(actual["requests"], ["presets_refresh", "presets_refresh"])
+ self.assertEqual(actual["schools"], [{"short_name": "demo"}])
+ self.assertEqual(actual["result"], "学校预设已更新")
+
+ def test_daily_selector_depends_on_checkbox(self):
+ source = (ROOT / "root/usr/lib/lua/luci/model/cbi/smart_srun.lua").read_text(encoding="utf-8")
+ self.assertIn('Flag, "preset_auto_update_enabled", "自动更新学校预设"', source)
+ self.assertIn('preset_update_time:depends("preset_auto_update_enabled", "1")', source)
+ self.assertIn('for hour = 0, 23 do', source)
+ self.assertIn('string.format("%02d:00", hour)', source)
+ self.assertIn('onclick="smartRefreshPresets()"', source)
diff --git a/smart-srun/tests/test_runtime_dependencies.py b/smart-srun/tests/test_runtime_dependencies.py
new file mode 100644
index 000000000..e94c96e98
--- /dev/null
+++ b/smart-srun/tests/test_runtime_dependencies.py
@@ -0,0 +1,15 @@
+"""Installed Go runtime dependencies are independent of host Python tooling."""
+import re
+import unittest
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+class RuntimeDependencyTests(unittest.TestCase):
+ def test_go_package_declares_device_tools_and_no_python(self):
+ makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
+ match = re.search(r"^RUNTIME_DEPENDS:=(.*)$", makefile, re.MULTILINE)
+ self.assertIsNotNone(match)
+ packages = {name.lstrip("+") for name in match.group(1).split()}
+ self.assertFalse(any(name.startswith("python") for name in packages))
+ self.assertTrue({"ca-bundle", "uci", "ubus", "procd", "rpcd-mod-iwinfo"} <= packages)
diff --git a/smart-srun/tests/test_setup_wizard_ui.py b/smart-srun/tests/test_setup_wizard_ui.py
new file mode 100644
index 000000000..62995cedb
--- /dev/null
+++ b/smart-srun/tests/test_setup_wizard_ui.py
@@ -0,0 +1,199 @@
+"""Exercise operator selection across the shipped wizard's two account steps."""
+
+from pathlib import Path
+import shutil
+import subprocess
+import unittest
+
+
+JS_FILE = Path(__file__).resolve().parents[1] / "root/www/luci-static/resources/smart_srun.js"
+
+
+class WizardOperatorUiTests(unittest.TestCase):
+ def test_account_step_can_choose_discovered_suffix_without_losing_credentials(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ script = r"""
+const fs = require('fs'), vm = require('vm'), assert = require('assert');
+const source = fs.readFileSync(process.argv[1], 'utf8');
+let root;
+function find(node, predicate) {
+ if (predicate(node)) return node;
+ for (const child of node.children) { const result = find(child, predicate); if (result) return result; }
+ return null;
+}
+function element(tag) {
+ if (tag === 'a') return {set href(value) {
+ const url = new URL(value);
+ for (const key of ['protocol','host','hostname','pathname','search']) this[key] = url[key];
+ }};
+ return {tagName: tag.toUpperCase(), children: [], textContent: '', value: '', style: {},
+ appendChild(child) { this.children.push(child); child.parentNode = this; return child; },
+ removeChild(child) { this.children.splice(this.children.indexOf(child), 1); },
+ setAttribute() {}, focus() {},
+ querySelector(selector) { return find(this, n => n.id === selector.slice(1)); }
+ };
+}
+const context = {document: {
+ createElement: element, createTextNode: text => Object.assign(element('text'), {textContent:text}),
+ getElementById: id => find(root, n => n.id === id)
+}, findSchoolPreset: () => null};
+vm.createContext(context);
+vm.runInContext(source.slice(source.indexOf(' var WIZ_STEPS'), source.indexOf(' function initAll()')), context);
+const discover = context.wizDiscoverOperators;
+context.wizRender = () => {
+ root = element('div'); context.wiz.root = root;
+ if (context.wiz.error) { const error = element('div'); error.id = 'wiz-error'; root.appendChild(error); }
+ if (context.wiz.step === 2) context.wizStepOperator(root);
+ if (context.wiz.step === 3) context.wizStepAccount(root);
+};
+context.wizDiscoverOperators = () => { throw new Error('Account selection must not issue a discovery request'); };
+const byId = id => context.document.getElementById(id);
+const click = text => find(root, n => n.tagName === 'BUTTON' && n.textContent === text).onclick();
+const input = (id, value) => { const field = byId(id); field.value = value; field.oninput(); };
+const choose = value => { const field = byId('wiz-account-operator'); field.value = value; field.onchange(); };
+context.wizReset();
+Object.assign(context.wiz, {step:2, ops:[
+ {suffix:'cmcc', label:'移动', src:'认证页自动发现'},
+ {suffix:'ctcc', label:'电信', src:'认证页自动发现'},
+ {suffix:'custom.realm', label:'自定义运营商', src:'认证页自动发现'}
+]});
+context.wizRender();
+click('下一步');
+assert.strictEqual(context.wiz.step, 3);
+assert.strictEqual(byId('wiz-account-operator').value, '');
+assert.strictEqual(context.wiz.selectedSuffix, null, 'discovery must not guess a personal operator');
+assert.deepStrictEqual(byId('wiz-account-operator').children.map(n => n.textContent),
+ ['请选择账号类型', '移动(@cmcc)', '电信(@ctcc)', '自定义运营商(@custom.realm)', '不加后缀']);
+input('wiz-user', 'fixture-user'); input('wiz-pass', 'fixture-password');
+click('下一步');
+assert.strictEqual(context.wiz.step, 3, 'pending operator must block confirmation');
+assert.ok(byId('wiz-error'));
+choose('1');
+assert.strictEqual(context.wiz.selectedSuffix, 'ctcc');
+assert.strictEqual(byId('wiz-login-preview').textContent, 'fixture-user@ctcc');
+assert.strictEqual(byId('wiz-error'), null);
+assert.strictEqual(byId('wiz-user').value, 'fixture-user');
+assert.strictEqual(byId('wiz-pass').value, 'fixture-password');
+context.wiz.opConfirmed = true; context.wiz.passwordVerified = true; context.wiz.opLog = 'old result';
+context.wizRender();
+assert.ok(byId('wiz-account-result'));
+assert.strictEqual(byId('wiz-pass').type, 'password');
+find(root, n => n.tagName === 'BUTTON' && n.title === '显示密码').onclick();
+assert.strictEqual(byId('wiz-pass').type, 'text');
+assert.strictEqual(byId('wiz-pass').value, 'fixture-password');
+assert.strictEqual(context.wiz.password, 'fixture-password');
+assert.strictEqual(context.wiz.opConfirmed, true, 'visibility is not a credential change');
+assert.ok(byId('wiz-account-result'));
+find(root, n => n.tagName === 'BUTTON' && n.title === '隐藏密码').onclick();
+assert.strictEqual(byId('wiz-pass').type, 'password');
+choose('2');
+assert.strictEqual(context.wiz.selectedSuffix, 'custom.realm');
+assert.strictEqual(byId('wiz-login-preview').textContent, 'fixture-user@custom.realm');
+assert.strictEqual(context.wiz.opConfirmed, false);
+assert.strictEqual(context.wiz.passwordVerified, false);
+assert.strictEqual(byId('wiz-account-result'), null);
+context.wizDiscoverOperators = () => {};
+click('上一步');
+assert.strictEqual(find(root, n => n.tagName === 'INPUT' && n.value === 'custom.realm').checked, true);
+const mobile = find(root, n => n.tagName === 'INPUT' && n.value === 'cmcc'); mobile.onchange();
+click('下一步');
+assert.strictEqual(byId('wiz-account-operator').value, '0', 'step three selection must carry forward');
+assert.strictEqual(byId('wiz-pass').value, 'fixture-password');
+choose('3');
+assert.strictEqual(context.wiz.selectedSuffix, '', 'no suffix must not mean pending');
+assert.ok(context.wiz.ops.some(op => op.suffix === ''));
+input('wiz-user', 'fixture-user@custom.realm');
+assert.strictEqual(byId('wiz-login-preview').textContent, 'fixture-user@custom.realm');
+click('下一步'); assert.strictEqual(context.wiz.step, 4);
+context.wizGo(3);
+assert.strictEqual(byId('wiz-account-operator').children.length, 5, 'no duplicate empty suffix');
+choose('');
+assert.strictEqual(context.wiz.selectedSuffix, null);
+assert.strictEqual(byId('wiz-login-preview').textContent, '待选择认证后缀');
+click('下一步'); assert.strictEqual(context.wiz.step, 3);
+
+// A one-option preset must not gain unrelated carrier choices.
+const singlePreset = {operators:[{suffix:'Students.Campus-2.test',label:'学生用户'}]};
+context.wizReset();
+context.findSchoolPreset = () => singlePreset;
+context.wiz.step = 2; context.wizRender();
+assert.strictEqual(context.wiz.ops.length, 1);
+assert.strictEqual(context.wiz.selectedSuffix, 'Students.Campus-2.test');
+assert.strictEqual(context.wiz.ops[0].label, '学生用户');
+assert.strictEqual(context.wiz.opConfirmed, false, 'one option is not proof of login');
+click('下一步');
+assert.deepStrictEqual(byId('wiz-account-operator').children.map(n => n.textContent),
+ ['请选择账号类型', '学生用户(@Students.Campus-2.test)', '不加后缀']);
+
+// An unsupported page without a preset must not invent even an empty suffix.
+context.findSchoolPreset = () => ({operators:[{suffix:'??',label:'未确认'},{label:'缺失后缀'}]});
+context.wizReset(); context.wiz.step = 2; context.wizRender();
+assert.strictEqual(context.wiz.ops.length, 0);
+assert.strictEqual(context.wiz.selectedSuffix, null);
+click('下一步'); input('wiz-user', 'fixture-user'); input('wiz-pass', 'fixture-password');
+context.wizPost = () => { throw new Error('No known suffix must not attempt login'); };
+context.wizRunOperatorProbe(false);
+assert.strictEqual(context.wiz.error, '请选择认证后缀后验证登录。');
+
+// Refreshing the same portal keeps manual entries; a different portal clears them.
+context.findSchoolPreset = () => singlePreset;
+context.wizReset();
+Object.assign(context.wiz, {step:2,baseUrl:'http://portal.example.test',acId:'1',accessMode:'wired',wiredIface:'wan'});
+let response = {ok:true,operators:[{suffix:'staff.Research-2.edu',label:'研究人员'}]};
+context.wizPost = (path, payload, done) => { context.wiz.busy = ''; done(null, response); };
+context.wizDiscoverOperators = discover;
+discover(false);
+assert.strictEqual(context.wiz.ops.length, 1, 'portal evidence must replace a different preset');
+assert.strictEqual(context.wiz.selectedSuffix, 'staff.Research-2.edu');
+byId('wiz-op-add').value = 'Lab_42'; click('添加并选择');
+discover(true);
+assert.strictEqual(context.wiz.selectedSuffix, 'Lab_42');
+assert.strictEqual(context.wiz.ops.length, 2);
+context.wiz.baseUrl = 'http://different.example.test';
+response = {ok:true,operators:[{suffix:'',label:'访客'}]}; discover(false);
+assert.strictEqual(context.wiz.ops.length, 1);
+assert.strictEqual(context.wiz.selectedSuffix, '');
+assert.strictEqual(context.wiz.ops[0].label, '访客');
+
+// Explicit realm values have no reserved school-specific alias.
+for (const suffix of ['xn', '42', 'Lab_Mixed.Case']) {
+ byId('wiz-op-add').value = suffix; click('添加并选择');
+ click('下一步');
+ input('wiz-user', 'fixture-user');
+ assert.strictEqual(byId('wiz-login-preview').textContent, 'fixture-user@' + suffix);
+ click('上一步');
+}
+
+// The receipt renders and retains the actual school name, not a wizard key.
+context.wiz.contribution = {name:'Example Campus'};
+context.wiz.saveComplete = true;
+root = element('div'); context.wizStepSaved(root);
+assert.strictEqual(byId('wiz-contribution-name').value, 'Example Campus');
+assert.strictEqual(byId('wiz-contribution-name').type, 'text');
+assert.strictEqual(byId('wiz-contribution-name').placeholder, '学校及校区名称');
+byId('wiz-contribution-name').value = 'Edited Campus'; byId('wiz-contribution-name').oninput();
+root = element('div'); context.wizStepSaved(root);
+assert.strictEqual(byId('wiz-contribution-name').value, 'Edited Campus');
+
+// Discovery must keep custom portal paths after the login origin is normalized.
+context.wizReset();
+Object.assign(context.wiz, {step:2,baseUrl:'https://portal.example.test:8443/custom/login?ac_id=42',accessMode:'wired',wiredIface:'wan'});
+assert.strictEqual(context.wizNormalizeAddress(), true);
+assert.strictEqual(context.wiz.baseUrl, 'https://portal.example.test:8443');
+assert.strictEqual(context.wiz.acId, '42');
+assert.strictEqual(context.wizNormalizeAddress(), true);
+context.wizPost = (path, payload, done) => {
+ assert.strictEqual(payload.base_url, 'https://portal.example.test:8443/custom/login?ac_id=42');
+ context.wiz.busy = ''; done(null, {ok:true,operators:[{suffix:'42',label:'访客'}]});
+};
+discover(false);
+assert.strictEqual(context.wiz.selectedSuffix, '42');
+context.wiz.baseUrl = 'https://different.example.test';
+assert.strictEqual(context.wizNormalizeAddress(), true);
+assert.strictEqual(context.wiz.portalUrl, '', 'never reuse a different gateway page');
+"""
+ result = subprocess.run([node, "-e", script, str(JS_FILE)],
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", timeout=15)
+ self.assertEqual(result.returncode, 0, result.stderr)
diff --git a/smart-srun/tests/test_sysupgrade_backup.py b/smart-srun/tests/test_sysupgrade_backup.py
new file mode 100644
index 000000000..96e3943ee
--- /dev/null
+++ b/smart-srun/tests/test_sysupgrade_backup.py
@@ -0,0 +1,64 @@
+"""Keep runtime-created user settings across OpenWrt keep-config upgrades."""
+
+import re
+import unittest
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+KEEP_PATH = "/lib/upgrade/keep.d/smart-srun"
+CONFIG_PATH = "/etc/smart-srun/config.json"
+USER_PRESETS_PATH = "/etc/smart-srun/user-presets.json"
+
+
+def read_source(relative_path):
+ return (REPO_ROOT / relative_path).read_text(encoding="utf-8")
+
+
+class SysupgradeBackupTests(unittest.TestCase):
+ def test_backup_covers_shared_user_config_without_programs_or_runtime_state(self):
+ entries = [
+ line.strip()
+ for line in read_source("root" + KEEP_PATH).splitlines()
+ if line.strip() and not line.lstrip().startswith("#")
+ ]
+ self.assertEqual(entries, [CONFIG_PATH, USER_PRESETS_PATH])
+
+ for relative_path in (
+ "root/usr/lib/lua/luci/controller/smart_srun.lua",
+ "root/usr/lib/lua/luci/model/cbi/smart_srun.lua",
+ ):
+ self.assertNotIn(CONFIG_PATH, read_source(relative_path))
+ self.assertNotIn(USER_PRESETS_PATH, read_source(relative_path))
+ # The package must not ship defaults over restored user data.
+ for path in (CONFIG_PATH, USER_PRESETS_PATH):
+ self.assertFalse((REPO_ROOT / ("root" + path)).exists())
+
+ def test_both_runtime_packages_install_the_backup_rule_as_data(self):
+ makefile = read_source("Makefile")
+ for package in ("smart-srun", "luci-app-smart-srun-bundle"):
+ with self.subTest(package=package):
+ install = re.search(
+ r"^define Package/%s/install\n(.*?)^endef$" % package,
+ makefile, re.MULTILINE | re.DOTALL,
+ )
+ self.assertIsNotNone(install)
+ body = " ".join(install.group(1).replace("\\\n", "").split())
+ self.assertIn("$(call SmartSrun/InstallCore,$(1))", body)
+ shared = re.search(r"^define SmartSrun/InstallCore\n(.*?)^endef$", makefile, re.MULTILINE | re.DOTALL)
+ self.assertIsNotNone(shared)
+ body = " ".join(shared.group(1).replace("\\\n", "").split())
+ self.assertIn("$(1)/lib/upgrade/keep.d", body)
+ self.assertIn(
+ "$(INSTALL_DATA) $(CURDIR)/root%s $(1)%s"
+ % (KEEP_PATH, KEEP_PATH), body,
+ )
+
+ def test_keep_rule_has_unix_line_endings_in_package_checkouts(self):
+ self.assertNotIn(b"\r", (REPO_ROOT / ("root" + KEEP_PATH)).read_bytes())
+ self.assertIn("root%s text eol=lf" % KEEP_PATH, read_source(".gitattributes"))
+
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/smart-srun/tests/test_update_luci.py b/smart-srun/tests/test_update_luci.py
new file mode 100644
index 000000000..46299744c
--- /dev/null
+++ b/smart-srun/tests/test_update_luci.py
@@ -0,0 +1,16 @@
+"""Execute shipped update UI handlers with deterministic DOM/XHR fixtures."""
+from pathlib import Path
+import subprocess
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class UpdateLuciTests(unittest.TestCase):
+ def test_update_retry_and_request_completion(self):
+ subprocess.run(['node', str(ROOT / 'tests/update_luci.js'),
+ str(ROOT / 'root/www/luci-static/resources/smart_srun.js')], check=True)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/smart-srun/tests/test_wizard_contribution_ui.py b/smart-srun/tests/test_wizard_contribution_ui.py
new file mode 100644
index 000000000..4611daa0d
--- /dev/null
+++ b/smart-srun/tests/test_wizard_contribution_ui.py
@@ -0,0 +1,78 @@
+"""Verify the save receipt and the allowlisted, user-submitted preset draft."""
+
+from pathlib import Path
+import shutil
+import subprocess
+import unittest
+
+
+JS_FILE = Path(__file__).resolve().parents[1] / "root/www/luci-static/resources/smart_srun.js"
+
+
+class WizardContributionTests(unittest.TestCase):
+ def test_saved_receipt_and_draft_exclude_credentials(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ script = r"""
+const fs = require('fs'), vm = require('vm'), assert = require('assert');
+const source = fs.readFileSync(process.argv[1], 'utf8');
+let posts = 0, reloads = 0, renderCount = 0, response, callback;
+const context = {findSchoolPreset: () => ({name:'Example Campus',short_name:'example'}),
+ location: {reload() {reloads++;}}, L: {hideModal() {}},
+ document: {getElementById(id) {return id === 'smart-srun-config-revision' ? {value:'0'} : null;}}};
+vm.createContext(context);
+vm.runInContext(source.slice(source.indexOf(' function configRevision()'), source.indexOf(' function startUpdate(')), context);
+vm.runInContext(source.slice(source.indexOf(' var WIZ_STEPS'), source.indexOf(' function initAll()')), context);
+context.wizRender = () => {renderCount++;};
+context.wizNormalizeAddress = () => true;
+context.wizPost = (action, payload, done) => {assert.strictEqual(action, 'enqueue'); posts++; callback=done;};
+context.wizError = error => {context.wiz.busy=false; context.wiz.error=error;};
+function setup() {
+ context.wizReset();
+ Object.assign(context.wiz, {step:4,school:'example',accessMode:'wifi',ssid:'CampusWiFi',
+ acId:'7',baseUrl:'https://url-user:url-password@portal.example.test/login?token=secret-token#session',
+ userId:'private-account',password:'private-password',wifiKey:'private-wifi-password',
+ current_ip:'192.0.2.199',bssid:'02:00:00:00:00:99',selectedSuffix:'realm.example',
+ wifiJob:'private-job',passwordVerified:true,
+ portalOps:[{suffix:'realm.example',label:'Student'},{suffix:'',label:'Guest'},
+ {suffix:'realm.example',label:'Duplicate'},{suffix:'??',label:'Unknown'}],
+ ops:[{suffix:'realm.example',label:'Student'},{suffix:'',label:'Guest'}]});
+}
+setup(); context.wizSave();
+assert.strictEqual(posts,1); context.wizSave(); assert.strictEqual(posts,1,'double save while pending');
+assert.ok(!context.wiz.saveComplete,'cannot show success while pending');
+context.wiz.busy=false; callback(null, {ok:true,saved:true});
+assert.strictEqual(context.wiz.saveComplete,true);
+assert.strictEqual(reloads,0,'must keep the receipt open');
+assert.strictEqual(context.wiz.wifiJob,'');
+assert.strictEqual(context.wiz.userId,''); assert.strictEqual(context.wiz.password,'');
+assert.strictEqual(context.wiz.wifiKey,'');
+assert.strictEqual(context.wiz.contribution.defaults.base_url,'https://portal.example.test');
+assert.deepStrictEqual(Array.from(context.wiz.contribution.operators, op=>op.suffix),['realm.example','']);
+assert.strictEqual(context.wiz.contribution.status,'draft','saving is not proof of school-wide compatibility');
+let body = context.wizContributionBody();
+for(const secret of ['private-account','private-password','private-wifi-password','private-job','secret-token',
+ 'url-user','url-password','192.0.2.199','02:00:00:00:00:99']) assert.ok(!body.includes(secret),secret+' leaked');
+assert.ok(body.includes('已通过插件验证')); assert.ok(body.includes('尚未确认'));
+context.wiz.contributionOnline=true; body=context.wizContributionBody(); assert.ok(body.includes('是(用户确认)'));
+context.wizSave(); assert.strictEqual(posts,1,'cannot save a second account from receipt');
+context.wizClose(); assert.strictEqual(reloads,1); assert.strictEqual(context.wiz,null);
+setup(); context.wizSave(); context.wiz.busy=false; callback(new Error('Request failed'), {});
+assert.ok(!context.wiz.saveComplete); assert.strictEqual(context.wiz.password,'private-password');
+assert.ok(!context.wiz.saved,'failed save must remain retryable');
+setup(); context.wizSave(); context.wiz.busy=false; callback(null, {ok:false,saved:true,message:'Post-save failure'});
+assert.ok(!context.wiz.saveComplete,'partial failure must not claim success');
+assert.strictEqual(context.wiz.saved,true); assert.strictEqual(context.wiz.wifiJob,'');
+const before = posts; context.wizSave(); assert.strictEqual(posts,before,'partial saved result must not duplicate account');
+setup(); context.wiz.portalOps=[]; context.wiz.ops=[]; context.wiz.selectedSuffix='special.school';
+context.wiz.contribution=context.wizContributionData();
+assert.strictEqual(context.wiz.contribution.operators[0].suffix,'special.school');
+context.wiz.selectedSuffix=''; context.wiz.accessMode='wired';
+context.wiz.contribution=context.wizContributionData();
+assert.strictEqual(context.wiz.contribution.operators[0].suffix,'');
+assert.ok(!('ssid' in context.wiz.contribution.defaults));
+"""
+ result = subprocess.run([node, "-e", script, str(JS_FILE)], stdin=subprocess.DEVNULL,
+ capture_output=True, text=True, encoding="utf-8")
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
diff --git a/smart-srun/tests/test_wizard_wifi_encryption_ui.py b/smart-srun/tests/test_wizard_wifi_encryption_ui.py
new file mode 100644
index 000000000..8b10d96f4
--- /dev/null
+++ b/smart-srun/tests/test_wizard_wifi_encryption_ui.py
@@ -0,0 +1,55 @@
+"""The selected wireless security must reach the router without stale passwords."""
+
+from pathlib import Path
+import shutil
+import subprocess
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class WizardWifiEncryptionTests(unittest.TestCase):
+ def test_selection_controls_password_and_connection_payload(self):
+ node = shutil.which("node")
+ if not node:
+ self.skipTest("node is not installed")
+ script = r"""
+const fs=require('fs'),vm=require('vm'),assert=require('assert');
+const source=fs.readFileSync(process.argv[1],'utf8');let root,posted;
+function find(el,predicate){if(predicate(el))return el;for(const child of el.children){const match=find(child,predicate);if(match)return match;}return null;}
+function element(tag){return {tagName:tag.toUpperCase(),children:[],value:'',style:{},textContent:'',
+ appendChild(child){this.children.push(child);child.parentNode=this;return child;},
+ setAttribute(){},removeChild(child){this.children.splice(this.children.indexOf(child),1);},
+ querySelector(selector){return find(this,el=>el.id===selector.slice(1));},focus(){}};}
+const context={document:{createElement:element,createTextNode:text=>Object.assign(element('text'),{textContent:text}),
+ getElementById:id=>find(root,el=>el.id===id)},schoolPresetList:()=>[],loadCustomPresets:()=>[]};
+vm.createContext(context);vm.runInContext(source.slice(source.indexOf(' var WIZ_STEPS'),source.indexOf(' function initAll()')),context);
+context.wizRender=()=>{root=element('div');context.wiz.root=root;context.wizStepEnv(root);};
+context.wizPost=(endpoint,payload)=>{posted={endpoint,payload};};
+context.wizReset();Object.assign(context.wiz,{accessMode:'wifi',ssid:'Campus'});context.wizRender();
+const byId=id=>context.document.getElementById(id);
+const choose=value=>{const select=byId('wiz-wifi-encryption');select.value=value;select.onchange();};
+assert.strictEqual(context.wiz.wifiEncryption,'auto');
+assert.deepStrictEqual(byId('wiz-wifi-encryption').children.map(el=>el.value),['auto','none','psk2','sae','sae-mixed','psk-mixed','psk']);
+assert.strictEqual(byId('wiz-wifi-key').type,'password');
+byId('wiz-wifi-key').value='private-wifi';byId('wiz-wifi-key').oninput();
+choose('sae-mixed');assert.strictEqual(byId('wiz-wifi-key').value,'private-wifi');
+context.wizConnectWifi();assert.strictEqual(posted.endpoint,'setup_wifi');
+assert.strictEqual(posted.payload.encryption,'sae-mixed');assert.strictEqual(posted.payload.key,'private-wifi');
+context.wiz.wifiJob='';context.wiz.busy='';context.wiz.env={state:'online'};
+choose('none');assert.strictEqual(context.wiz.wifiKey,'');assert.strictEqual(byId('wiz-wifi-key'),null);
+assert.strictEqual(context.wiz.env,null);context.wizConnectWifi();
+assert.strictEqual(posted.payload.encryption,'none');assert.strictEqual(posted.payload.key,'');
+context.wiz.wifiJob='';context.wiz.busy='';choose('psk2');
+assert.strictEqual(byId('wiz-wifi-key').value,'','cleared password must not return');
+context.wizGo(1);context.wizGo(0);assert.strictEqual(context.wiz.wifiEncryption,'psk2');
+"""
+ result = subprocess.run([node, "-e", script, str(ROOT / "root/www/luci-static/resources/smart_srun.js")],
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8")
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_luci_passes_encryption_to_private_wifi_payload(self):
+ source = (ROOT / "root/usr/lib/lua/luci/controller/smart_srun.lua").read_text(encoding="utf-8")
+ endpoint = source.split("function action_setup_wifi()", 1)[1].split("function action_discover_operators", 1)[0]
+ self.assertIn('encryption = fv("encryption")', endpoint)
diff --git a/smart-srun/tests/update_luci.js b/smart-srun/tests/update_luci.js
new file mode 100644
index 000000000..7bd86660c
--- /dev/null
+++ b/smart-srun/tests/update_luci.js
@@ -0,0 +1,88 @@
+const fs = require('fs');
+const vm = require('vm');
+const assert = require('assert');
+const source = fs.readFileSync(process.argv[2], 'utf8');
+function section(start, end) {
+ const from = source.indexOf(start), to = source.indexOf(end, from);
+ assert(from >= 0 && to > from, 'shipped handler must exist');
+ return source.slice(from, to);
+}
+const noticeCode = section(' function initVersionNotice()', ' window.smartFetchJson');
+function notice(responses) {
+ const requests = [], timers = [], opened = [];
+ function node() {
+ return {style: {}, children: [], textContent: '', title: '',
+ setAttribute(name, value) { this[name] = value; },
+ appendChild(child) { this.children.push(child); },
+ addEventListener(name, handler) { this[name] = handler; }};
+ }
+ const nodes = Object.fromEntries(['smart-srun-version-info', 'smart-srun-version-link',
+ 'smart-srun-update-dot'].map(id => [id, node()]));
+ const context = {window: {}, document: {getElementById: id => nodes[id], createElement: node},
+ RELEASES_PAGE_URL: 'https://github.com/matthewlu070111/smart-srun/releases',
+ UPDATE_CHECK_URL: '/check', UPDATE_STATUS_URL: '/status',
+ fetchJson(url, callback) {
+ requests.push(url);
+ const next = responses.shift();
+ assert(next, 'unexpected request');
+ callback(next.error || null, next.data);
+ }, setTimeout(fn) { timers.push(fn); }, openUpdateModal(plan) { opened.push(plan); }};
+ vm.runInNewContext(noticeCode + '\ninitVersionNotice();', context);
+ return {context, requests, timers, opened, link: nodes['smart-srun-version-link'],
+ dot: nodes['smart-srun-update-dot'], message: nodes['smart-srun-version-info'].children[0],
+ click() { let prevented = false; this.link.click({preventDefault() { prevented = true; }}); return prevented; }};
+}
+for (const failure of [
+ {data: {ok: false, code: 'DNSFailure', message: '无法解析更新域名'}},
+ {data: {ok: true, update_available: false, code: 'PackageIncompatible', message: '固件 25.12 不兼容'}},
+ {error: new Error('request_timeout')}
+]) {
+ const fixture = notice([failure, {data: {ok: true, update_available: true, plan_id: 'safe-plan', latest_tag: '2.0.0rc3'}}]);
+ assert(fixture.message.textContent.includes('点击版本号重试'));
+ if (failure.data) assert(fixture.message.textContent.includes(failure.data.message));
+ assert.strictEqual(fixture.dot.style.display, 'none');
+ assert(fixture.click());
+ assert.strictEqual(fixture.requests.length, 2);
+ assert.strictEqual(fixture.dot.style.display, 'inline-block');
+ assert.strictEqual(fixture.message.textContent, '');
+ assert(fixture.click());
+ assert.strictEqual(fixture.opened[0].plan_id, 'safe-plan');
+ vm.runInNewContext('initVersionNotice();', fixture.context);
+ assert.strictEqual(fixture.requests.length, 2, 'initialization must not duplicate checks');
+}
+const pending = notice([{data: {ok: true, running: true, job_id: 'job/1'}},
+ {data: {ok: true, update_available: false, message: '没有更新的兼容版本'}}]);
+assert.strictEqual(pending.requests.length, 1);
+pending.timers.shift()();
+assert.strictEqual(pending.requests[1], '/status?job_id=job%2F1');
+assert.strictEqual(pending.message.textContent, '');
+assert.strictEqual(pending.click(), false);
+
+const requestCode = section(' function fetchJson(', ' function initConfigBackup(');
+for (const method of ['fetchJson', 'startUpdate']) {
+ for (const outcome of ['timeout', 'error', 'success', 'http', 'json']) {
+ const requests = [], replies = [];
+ function XHR() { requests.push(this); }
+ XHR.prototype.open = function(method, url) { this.method = method; this.url = url; };
+ XHR.prototype.setRequestHeader = function() {};
+ XHR.prototype.send = function(body) { this.body = body; };
+ const context = {XMLHttpRequest: XHR, UPDATE_START_URL: '/start', window: {},
+ document: {querySelector() { return {value: 'csrf-token'}; }},
+ callback(error, data) { replies.push({error, data}); }};
+ vm.runInNewContext(requestCode + `\n${method}('plan&1', callback);`, context);
+ const xhr = requests[0];
+ assert.strictEqual(xhr.timeout, 30000);
+ if (method === 'startUpdate') assert(xhr.body.includes('plan_id=plan%261'));
+ if (outcome === 'timeout') xhr.ontimeout();
+ if (outcome === 'error') xhr.onerror();
+ xhr.readyState = 4;
+ xhr.status = outcome === 'http' ? 503 : 200;
+ xhr.responseText = outcome === 'json' ? '{invalid' : '{"ok":true}';
+ xhr.onreadystatechange();
+ xhr.onerror();
+ xhr.ontimeout();
+ assert.strictEqual(replies.length, 1, `${method}/${outcome} completes once`);
+ assert.strictEqual(!!replies[0].error, outcome !== 'success');
+ }
+}
+console.log('update UI retry, polling and XHR completion passed');
diff --git a/wwand/Makefile b/wwand/Makefile
index 2a36b7d95..1c0b4bee8 100644
--- a/wwand/Makefile
+++ b/wwand/Makefile
@@ -50,12 +50,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=wwand
-PKG_VERSION:=1.9.0_pre10
-PKG_RELEASE:=89
+PKG_VERSION:=1.9.0_pre11
+PKG_RELEASE:=90
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
-PKG_SOURCE_VERSION:=e9fd1db72db20faced06e323c06e8a1062607bf2
+PKG_SOURCE_VERSION:=7efd9bf9e5b1feb319b2b3471f921910837be577
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only