commit ac86caaf8b8fc743a1dfa6721b759ad2f9a0859c Author: amurcanov Date: Sat May 23 22:18:08 2026 +0300 Initial v1.1.8 Commits diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..396b850 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# ===== Gradle ===== +.gradle/ +build/ +local.properties + +# ===== Android ===== +*.apk +*.aab +*.ap_ +*.dex +*.class +app/build/ +app/release/ + +# ===== Go ===== +go.sum + +# ===== Native Libraries (compiled) ===== +*.so +*.dll +*.dylib +jniLibs/ +app/src/main/jniLibs/ + +# ===== Server binary ===== +app/src/main/assets/server +server + +# ===== Keystore / Secrets ===== +*.keystore +*.jks +*.key +*.pem + +# ===== IDE ===== +.idea/ +*.iml +.vscode/ +*.swp +*.swo +*~ + +# ===== OS ===== +.DS_Store +Thumbs.db +Desktop.ini + +# ===== Misc ===== +*.log +*.tmp +*.bak +msg.txt +*.bat diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/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/README.md b/README.md new file mode 100644 index 0000000..c23c466 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +
+ + # WDTT — WireGuard over TURN Tunnel +
+ Android SDK + Go Version + Kotlin + + Stars + +
+
+ +**WDTT** — это Android-приложение для создания защищённого **WireGuard-туннеля поверх TURN/DTLS**. Клиент поднимает локальный VPN-интерфейс на устройстве, получает WireGuard-конфигурацию от вашего VPS и передаёт транспорт через TURN-серверы VK, маскируя соединение под обычный зашифрованный медиатрафик звонка. + +--- + +MyCollages (2) + +## Возможности Android-версии + +- **Полноценный VPN-режим:** приложение использует `VpnService` и WireGuard GoBackend, поэтому трафик выбранных приложений проходит через системный VPN-интерфейс без ручного импорта конфигов. +- **TURN/DTLS-транспорт:** нативный Go-клиент получает временные TURN-учётные данные VK-звонка и поднимает DTLS-соединения к relay-серверу, через который передаётся трафик до вашего VPS. +- **Деплой с телефона:** вкладка **«Деплой»** подключается к серверу по SSH, загружает `wdtt-server`, создаёт `systemd`-сервис, включает NAT/firewall и открывает рабочие UDP-порты. +- **Парольная модель доступа:** сервер поддерживает главный пароль, одноразовые/срочные пароли, привязку пароля к устройству и управление через Telegram-бота. +- **Исключения приложений:** можно выбрать, какие приложения идут через туннель, а какие работают напрямую. Поддерживаются режимы ЧС и БС, а изменения применяются перезагрузкой WireGuard без полного перенастраивания. +- **Капча VK Smart Captcha:** основной рабочий режим в текущей Android-сборке — `WBV/WebView` с ручным решением или автоматической попыткой для простых сценариев. RJS-логика есть в Go-ядре, но в UI текущей сборки временно отключена. +- **Живой лог-вьюер:** события Go-клиента, DTLS, WireGuard, капчи, деплоя и статистики отображаются в приложении с группировкой одинаковых сообщений и счётчиками повторов. +- **Фоновая устойчивость:** `Foreground Service`, `WakeLock`, `WifiLock`, мониторинг смены сети и watchdog помогают переживать Doze, смену Wi-Fi/LTE и падение нативного процесса. +- **Темы и оформление:** Material 3, Jetpack Compose, Inter, светлая/тёмная тема, Dynamic Colors на Android 12+ и встроенные палитры. +- **Автообновления:** приложение проверяет GitHub releases, показывает диалог обновления и позволяет перейти на страницу актуального релиза. + +## Что нового в версии 1.1.0 + +> [!IMPORTANT] +> После обновления до **1.1.0** необходимо заново выполнить **деплой сервера** из приложения. + +* **Изоляция WDTT:** деплой, удаление и рабочая среда **WDTT** теперь не должны влиять на другие компоненты VPS. Серверная часть изолирована в собственной конфигурации, интерфейсе и правилах firewall/NAT. +* **Автообновление:** помимо ручной проверки, приложение теперь самостоятельно проверяет наличие новых версий и предлагает обновиться до актуального релиза. +* **Ручные порты:** добавлена возможность управлять портами. При включении режима ручных портов можно задать **DTLS** и **WG** порты на сервере, а также локальный VPN-порт в туннеле. Если это не нужно, режим лучше не включать. +* **Капча:** возвращён режим **Авто-WBV** для прохождения капчи **«Я не робот»**. Режим **RJS** временно заблокирован до следующих улучшений. Если с **Авто-WBV** возникают проблемы, используйте ручной режим. +* **Разделение архитектур:** релизы теперь делятся на **arm64-v8a**, **armeabi-v7a** и **x86_64**, чтобы уменьшить размер APK. Если вы не знаете, какой APK выбрать, используйте **Universal** — он содержит все 3 архитектуры, но весит больше. +* **Сборка:** обновлены **AGP**, **Gradle** и **Kotlin** до актуальных версий (`9.0.1`, `9.1.0`, `2.x`), что положительно влияет на стабильность и работу приложения. +* **Багфиксы и стабильность:** удалён **DataSync**, который мог вызывать краши на **Android 14+**; изменено поведение уведомления, чтобы оно не скакало в шторке; улучшен запуск **VPN Service** и передача WireGuard-конфига. +* **Интерфейс и информация:** проведён небольшой редизайн, добавлены тени и орбы на фон. Раздел **«Инфо»** переработан и теперь позволяет собрать отчёт с данными об устройстве для более точного разбора ошибок. +* **В планах:** заменить стандартный протокол **WireGuard** на **AmneziaWG** в версиях `1.5-2.0`, чтобы лучше решать проблемы региональных блокировок. +* **Откат при проблемах:** если после обновления появились ошибки, которых раньше точно не было, можно открыть `issue` и временно откатиться на версию **1.0.6**. + +--- + +## Как это работает + +```text +Android-приложение → VpnService / WireGuard GoBackend → локальный UDP 127.0.0.1:9000 + → Go-клиент WDTT → VK TURN / DTLS → wdtt-server на VPS → интернет +``` + +1. Приложение запускает нативный Go-клиент `libclient.so` и передаёт ему адрес VPS, VK-хеши звонка, пароль туннеля, протокол TURN и количество потоков. +2. Go-клиент получает TURN-учётные данные через VK-звонок, при необходимости решает VK Smart Captcha и устанавливает DTLS-соединения через TURN relay. +3. Первый рабочий канал запрашивает у VPS WireGuard-конфигурацию через `GETCONF`, передавая локальный порт, `device-id` и пароль подключения. +4. Сервер проверяет пароль: главный пароль работает как владелец, сгенерированные пароли могут иметь срок действия и привязываются к первому устройству. +5. Android-часть парсит полученный WireGuard-конфиг, поднимает системный VPN-туннель и применяет исключения приложений. +6. Watchdog следит за Go-процессом, активными воркерами и сетевыми изменениями, перезапуская транспорт при сбоях. + +## Быстрый старт + +1. Скачайте актуальный `APK` со **[страницы релизов](https://github.com/amurcanov/proxy-turn-vk-android/releases)**. +2. Установите приложение на Android-смартфон. +3. Подготовьте VPS с root-доступом или пользователем с `sudo`. +4. В VK создайте или откройте групповой звонок и скопируйте ссылку вида `vk.com/call/join/xxxxxxxxxxx`. +5. Откройте **WDTT** и перейдите во вкладку **«Деплой»**. +6. Введите IP/домен VPS, SSH-логин, пароль и SSH-порт. +7. В **«Секретах»** задайте пароль туннеля. При необходимости добавьте Telegram `admin_id` и `bot_token` для управления паролями. +8. Нажмите **«Установить»** и дождитесь завершения деплоя. +9. Во вкладке **«Туннель»** укажите IP/домен сервера, VK-хеши, пароль туннеля и количество потоков. +10. Нажмите **«Подключить»** и выдайте Android-разрешение на VPN. + +--- + +## Получение VK-хеша + +```text +VK → группа → звонок → ссылка приглашения → код после /join/ +``` + +1. Откройте VK и создайте пустую группу или используйте существующую. +2. Начните групповой звонок. +3. Скопируйте ссылку приглашения. +4. Вставьте в WDTT всю ссылку или только хеш после последнего слэша. +5. Можно использовать до **3 хешей** одновременно для распределения нагрузки и увеличения доступного числа потоков. + +> [!IMPORTANT] +> При выходе из звонка нажимайте **«Просто завершить»**, а не **«Завершить для всех»**. Если закрыть комнату для всех участников, хеш перестанет работать. + +## Деплой VPS + +Серверная часть ставится автоматически из приложения: + +```text +Android → SSH → /tmp/deploy.sh + /tmp/wdtt-server → /usr/local/bin/wdtt-server + → systemd wdtt.service → wdtt0 + NAT + firewall +``` + +По умолчанию используются: + +- `56000/udp` — DTLS-сервер WDTT. +- `56001/udp` — внутренний WireGuard-порт сервера. +- `9000/udp` — локальный порт Android-клиента. +- `10.66.66.0/24` — подсеть WireGuard-устройств. + +Если включить ручное управление портами, эти значения можно изменить в **«Секретах»**. + +## Управление доступом + +WDTT-сервер поддерживает две модели подключения: + +- **Главный пароль:** задаётся при деплое и используется владельцем сервера. +- **Сгенерированные пароли:** создаются через Telegram-бота командой `/new`, имеют срок действия и привязываются к первому устройству. + +Команда `/list` показывает активные пароли и устройства. Через inline-кнопки можно отвязать устройство или удалить пароль. + +--- + +## Дополнительные возможности + +#### Исключения приложений + +Вкладка **«Исключ.»** показывает установленные приложения с поиском. В режиме ЧС выбранные приложения исключаются из VPN, а в режиме БС логика инвертируется: неотмеченные приложения добавляются в туннель. Само приложение WDTT и VK-клиенты исключаются автоматически, чтобы не ломать TURN-соединение. + +#### Логирование + +Вкладка **«Логи»** показывает статус получения VK-учётных данных, решение капчи, DTLS-handshake, готовность WireGuard, статистику активных воркеров и ошибки. Повторяющиеся строки схлопываются в одну запись со счётчиком. + +#### Обновления + +Приложение проверяет **GitHub releases** репозитория [amurcanov/proxy-turn-vk-android](https://github.com/amurcanov/proxy-turn-vk-android), умеет показывать диалог новой версии и открывать страницу релиза в браузере. + +#### Отчёт для issue + +В разделе **«Информация»** есть кнопка **«Собрать отчёт»**. Она копирует версию приложения, Android SDK, ABI, модель устройства, SoC, ROM и fingerprint — эти данные полезны при разборе крашей и проблем с запуском. + +--- + +> [!NOTE] +> ### Отчёты об ошибках +> WDTT зависит от мобильной сети, Android-ограничений фоновой работы, состояния VK-звонка, TURN-квот и настроек VPS. +> +> Если возникла проблема, приложите к `issue` отчёт из раздела **«Информация»**, скриншот вкладки **«Логи»**, версию APK, ABI сборки и описание сети. Мелкие повторяющиеся ошибки в логах не всегда означают поломку, если туннель остаётся активным. + +> [!IMPORTANT] +> ### Назначение проекта +> Приложение является техническим инструментом для защищённого туннелирования собственного трафика через ваш сервер. Автор не призывает использовать WDTT для противоправных целей или нарушения правил сторонних сервисов. + +--- + +## Лицензия + +Этот проект распространяется под лицензией **GNU General Public License v3.0**. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..4684a50 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,143 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.wdtt.client" + compileSdk = 35 + + defaultConfig { + applicationId = "com.wdtt.client" + minSdk = 29 + targetSdk = 35 + versionCode = 118 + versionName = "1.1.8" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + + ndk { + abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a", "x86_64")) + } + } + + splits { + abi { + isEnable = true + reset() + include("arm64-v8a", "armeabi-v7a", "x86_64") + isUniversalApk = true + } + } + + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + + signingConfigs { + create("release") { + val keyFile = localProperties.getProperty("KEYSTORE_FILE") + if (keyFile != null) { + // Резолвим путь: если начинается с "..", берём от корня проекта + val resolvedFile = if (keyFile.startsWith("..")) { + // ../release.keystore -> корень проекта / release.keystore + file(rootDir.resolve(keyFile.substring(3))) + } else { + file(keyFile) + } + if (resolvedFile.exists()) { + storeFile = resolvedFile + storePassword = localProperties.getProperty("KEYSTORE_PASSWORD") + keyAlias = localProperties.getProperty("KEY_ALIAS") + keyPassword = localProperties.getProperty("KEY_PASSWORD") + } else { + println("WARNING: Keystore file not found: $keyFile (resolved: ${resolvedFile.absolutePath})") + } + } + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + val keyFile = localProperties.getProperty("KEYSTORE_FILE") + val resolvedFile = if (keyFile != null && keyFile.startsWith("..")) { + file(rootDir.resolve(keyFile.substring(3))) + } else if (keyFile != null) { + file(keyFile) + } else null + + if (resolvedFile != null && resolvedFile.exists()) { + signingConfig = signingConfigs.getByName("release") + println("✅ Signing config applied: ${resolvedFile.absolutePath}") + } else { + println("⚠️ WARNING: Keystore not found, using debug signing") + println(" Looked for: ${resolvedFile?.absolutePath ?: keyFile}") + } + } + } + + packaging { + jniLibs { + useLegacyPackaging = true + } + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "/META-INF/INDEX.LIST" + excludes += "/META-INF/DEPENDENCIES" + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + lint { + checkReleaseBuilds = false + abortOnError = false + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + jniLibs.setSrcDirs(listOf("src/main/jniLibs")) + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.15.0") + implementation(platform("androidx.compose:compose-bom:2024.12.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("com.wireguard.android:tunnel:1.0.20230706") + implementation("com.github.mwiede:jsch:0.2.16") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..b24c6a2 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,4 @@ +-keep class com.jcraft.jsch.** { *; } +-keep class com.mwiede.jsch.** { *; } +-dontwarn com.jcraft.jsch.** +-dontwarn com.mwiede.jsch.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2952449 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/deploy.sh b/app/src/main/assets/deploy.sh new file mode 100644 index 0000000..ecf78a5 --- /dev/null +++ b/app/src/main/assets/deploy.sh @@ -0,0 +1,585 @@ +#!/bin/bash +# ============================================================================== +# WDTT VPN Server — Универсальный установщик для VPS +# Поддержка: Debian 11+, Ubuntu 20.04+, CentOS/RHEL/Fedora/AlmaLinux/Rocky +# Версия: 3.2 | Дата: 2026-05-13 +# NAT: MASQUERADE через iptables +# WG: порт 56001 (не конфликтует с существующим WG на 51820) +# DTLS: порт 56000 +# ============================================================================== +set -uo pipefail + +readonly SCRIPT_VERSION="3.2" +readonly LOG_FILE="/var/log/wdtt-install.log" +readonly WG_PORT="${WDTT_WG_PORT:-56001}" +readonly DTLS_PORT="${WDTT_DTLS_PORT:-56000}" +readonly SSH_PORT="${WDTT_SSH_PORT:-22}" +readonly WDTT_ARGS="${WDTT_ARGS:-}" +readonly WDTT_IFACE="wdtt0" +readonly WDTT_CONFIG_DIR="/etc/wdtt" +readonly WDTT_ACCESS_DB="passwords.json" +readonly IPT_COMMENT="WDTT_MANAGED" +readonly IPT_MIRROR_COMMENT="WDTT_MIRRORED" + +validate_port() { + local name="$1" value="$2" + case "$value" in + ''|*[!0-9]*) die "$name должен быть числом от 1 до 65535, получено: $value" ;; + esac + if [ "$value" -lt 1 ] || [ "$value" -gt 65535 ]; then + die "$name должен быть в диапазоне 1..65535, получено: $value" + fi +} + +# ─── Цвета ─────────────────────────────────────────────────────────────────── +C_GREEN=''; C_YELLOW=''; C_RED='' +C_CYAN=''; C_BOLD=''; C_NC='' + +log_info() { echo -e "${C_GREEN}[✓]${C_NC} $*" | tee -a "$LOG_FILE"; } +log_warn() { echo -e "${C_YELLOW}[!]${C_NC} $*" | tee -a "$LOG_FILE"; } +log_error() { echo -e "${C_RED}[✗]${C_NC} $*" | tee -a "$LOG_FILE"; } +log_step() { echo -e "${C_CYAN}[►]${C_NC} ${C_BOLD}$*${C_NC}" | tee -a "$LOG_FILE"; } + +die() { log_error "$*"; exit 1; } + +prog() { echo "WDTT_PROGRESS|$1|$2"; } + +# ─── Проверка root ──────────────────────────────────────────────────────────── +check_root() { + if [ "$(id -u)" -ne 0 ]; then + die "Скрипт должен быть запущен от root. Если sudo отсутствует, зайдите под root и запустите: bash $0 $*" + fi +} + +# ─── Определение ОС ────────────────────────────────────────────────────────── +OS_ID="" ; PKG_MGR="" + +detect_os() { + log_step "Определение операционной системы..." + if [ ! -f /etc/os-release ]; then + die "Файл /etc/os-release не найден." + fi + . /etc/os-release + OS_ID="${ID:-unknown}" + case "$OS_ID" in + ubuntu|debian|linuxmint|pop) PKG_MGR="apt" ;; + centos|rhel|rocky|almalinux|oracle) PKG_MGR="yum" + command -v dnf &>/dev/null && PKG_MGR="dnf" ;; + fedora) PKG_MGR="dnf" ;; + arch|manjaro|endeavouros) PKG_MGR="pacman" ;; + *) die "Неподдерживаемый дистрибутив: $OS_ID" ;; + esac + log_info "ОС: ${PRETTY_NAME:-$OS_ID} | PM: $PKG_MGR" +} + +# ─── Пакеты ────────────────────────────────────────────────────────────────── +pkg_update_done=0 + +pkg_update() { + [ "$pkg_update_done" = "1" ] && return 0 + log_step "Обновление индексов пакетов..." + case "$PKG_MGR" in + apt) + export DEBIAN_FRONTEND=noninteractive + apt-get update -y >>"$LOG_FILE" 2>&1 || log_warn "apt update завершился с ошибкой, пробую продолжить" + ;; + dnf) dnf makecache -y >>"$LOG_FILE" 2>&1 || true ;; + yum) yum makecache -y >>"$LOG_FILE" 2>&1 || true ;; + pacman) pacman -Sy --noconfirm >>"$LOG_FILE" 2>&1 || true ;; + esac + pkg_update_done=1 +} + +pkg_install() { + [ "$#" -eq 0 ] && return 0 + case "$PKG_MGR" in + apt) + export DEBIAN_FRONTEND=noninteractive + apt-get install -y -qq "$@" >>"$LOG_FILE" 2>&1 + ;; + dnf) dnf install -y "$@" >>"$LOG_FILE" 2>&1 ;; + yum) yum install -y "$@" >>"$LOG_FILE" 2>&1 ;; + pacman) pacman -S --noconfirm --needed "$@" >>"$LOG_FILE" 2>&1 ;; + esac +} + +install_prerequisites() { + prog 0.08 "Пакеты..." + pkg_update + log_step "Установка базовых зависимостей..." + + case "$PKG_MGR" in + apt) + pkg_install ca-certificates iproute2 iptables nftables procps psmisc || \ + log_warn "Часть apt-пакетов не установилась, продолжаю с доступными утилитами" + ;; + dnf|yum) + pkg_install ca-certificates iproute iptables nftables procps-ng psmisc || \ + log_warn "Часть rpm-пакетов не установилась, продолжаю с доступными утилитами" + ;; + pacman) + pkg_install ca-certificates iproute2 iptables nftables procps-ng psmisc || \ + log_warn "Часть pacman-пакетов не установилась, продолжаю с доступными утилитами" + ;; + esac +} + +require_runtime_tools() { + command -v ip >/dev/null 2>&1 || die "Команда ip не найдена. Установите iproute2/iproute." + command -v systemctl >/dev/null 2>&1 || die "systemctl не найден. Нужен VPS с systemd." +} + +# ─── Автоопределение WAN-интерфейса ────────────────────────────────────────── +detect_wan_interface() { + local iface="" + iface=$(ip route show default 2>/dev/null | head -1 | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}') + [ -z "$iface" ] && iface=$(ip -4 addr show scope global 2>/dev/null | grep -oP '(?<=dev )\S+' | head -1) + [ -z "$iface" ] && iface=$(ls /sys/class/net/ | grep -v lo | head -1) + echo "$iface" +} + +# ─── Firewall helpers ──────────────────────────────────────────────────────── +FW_BACKEND="" + +iptables_add_input() { + local proto="$1" port="$2" comment="$3" + [ "$FW_BACKEND" = "iptables" ] || return 0 + case "$proto:$port" in + tcp:[0-9]*|udp:[0-9]*) ;; + *) return 0 ;; + esac + [ "$port" -ge 1 ] 2>/dev/null && [ "$port" -le 65535 ] 2>/dev/null || return 0 + iptables -C INPUT -p "$proto" --dport "$port" -m comment --comment "$comment" -j ACCEPT 2>/dev/null || \ + iptables -I INPUT -p "$proto" --dport "$port" -m comment --comment "$comment" -j ACCEPT 2>/dev/null || true +} + +mirror_port_to_iptables() { + local proto="$1" port="$2" source="$3" + iptables_add_input "$proto" "$port" "$IPT_MIRROR_COMMENT" + log_info "iptables: сохранён доступ $port/$proto из $source" +} + +mirror_existing_firewall_ports_to_iptables() { + [ "$FW_BACKEND" = "iptables" ] || return 0 + local tmp + tmp="$(mktemp)" + + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi "Status: active"; then + log_info "UFW активен: переношу разрешённые tcp/udp порты в iptables" + ufw status 2>/dev/null | sed -nE 's#^([0-9]{1,5})/(tcp|udp)[[:space:]].*ALLOW IN.*#\2 \1 ufw#p' >> "$tmp" || true + fi + + if command -v nft >/dev/null 2>&1; then + local nft_ports + nft_ports="$(nft -a list ruleset 2>/dev/null | sed -nE 's/.*(tcp|udp) dport ([0-9]{1,5}).*accept.*/\1 \2 nft/p' | sort -u || true)" + if [ -n "$nft_ports" ]; then + log_info "nftables найден: переношу простые accept dport правила в iptables" + printf '%s\n' "$nft_ports" >> "$tmp" + fi + fi + + if [ -s "$tmp" ]; then + sort -u "$tmp" | while read -r proto port source; do + mirror_port_to_iptables "$proto" "$port" "$source" + done + else + log_info "UFW/nftables разрешённых tcp/udp портов для переноса не найдено" + fi + rm -f "$tmp" +} + +detect_firewall() { + if ! command -v iptables &>/dev/null; then + log_warn "iptables не найден. Пытаюсь установить firewall-пакеты..." + pkg_update + pkg_install iptables nftables || true + fi + if command -v iptables &>/dev/null; then + FW_BACKEND="iptables" + log_info "Firewall backend: iptables (принудительно)" + mirror_existing_firewall_ports_to_iptables + else + FW_BACKEND="none" + log_warn "iptables не найден. Установка продолжится, но NAT/firewall нужно настроить вручную." + fi +} + +# ─── Firewall-абстракция ───────────────────────────────────────────────────── +fw_add_input_udp() { + local port="$1" + case "$FW_BACKEND" in + iptables) + iptables -C INPUT -p udp --dport "$port" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || \ + iptables -I INPUT -p udp --dport "$port" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + ;; + nft) + ensure_nft_wdtt + nft add rule inet wdtt input udp dport "$port" accept 2>/dev/null || true + ;; + none) ;; + esac +} + +fw_add_input_tcp() { + local port="$1" + case "$FW_BACKEND" in + iptables) + iptables -C INPUT -p tcp --dport "$port" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || \ + iptables -I INPUT -p tcp --dport "$port" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + ;; + nft) + ensure_nft_wdtt + nft add rule inet wdtt input tcp dport "$port" accept 2>/dev/null || true + ;; + none) ;; + esac +} + +fw_add_input_udp_range() { + local from="$1" to="$2" + case "$FW_BACKEND" in + iptables|nft) log_warn "Пропускаю широкий UDP range $from-$to: это не изолировано и может влиять на чужие сервисы." ;; + none) ;; + esac +} + +fw_add_forward() { + case "$FW_BACKEND" in + iptables) + iptables -C FORWARD -i "$WDTT_IFACE" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || \ + iptables -I FORWARD -i "$WDTT_IFACE" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + iptables -C FORWARD -o "$WDTT_IFACE" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || \ + iptables -I FORWARD -o "$WDTT_IFACE" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + ;; + nft) + ensure_nft_wdtt + nft add rule inet wdtt forward iifname "$WDTT_IFACE" accept 2>/dev/null || true + nft add rule inet wdtt forward oifname "$WDTT_IFACE" accept 2>/dev/null || true + ;; + none) ;; + esac +} + +fw_add_masquerade() { + local iface="$1" subnet="$2" + case "$FW_BACKEND" in + iptables) + iptables -t nat -C POSTROUTING -s "$subnet" -o "$iface" -m comment --comment "$IPT_COMMENT" -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -s "$subnet" -o "$iface" -m comment --comment "$IPT_COMMENT" -j MASQUERADE 2>/dev/null || true + ;; + nft) + nft add table ip wdtt 2>/dev/null || true + nft add chain ip wdtt postrouting '{ type nat hook postrouting priority 100; }' 2>/dev/null || true + nft add rule ip wdtt postrouting ip saddr "$subnet" oifname "$iface" masquerade 2>/dev/null || true + ;; + none) ;; + esac +} + +fw_add_mss_clamping() { + local subnet="$1" + case "$FW_BACKEND" in + iptables) + # Применяем правило ТОЛЬКО к нашей подсети WDTT + iptables -t mangle -C FORWARD -s "$subnet" -p tcp -m tcp --tcp-flags SYN,RST SYN -m comment --comment "$IPT_COMMENT" -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || \ + iptables -t mangle -I FORWARD -s "$subnet" -p tcp -m tcp --tcp-flags SYN,RST SYN -m comment --comment "$IPT_COMMENT" -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true + iptables -t mangle -C FORWARD -d "$subnet" -p tcp -m tcp --tcp-flags SYN,RST SYN -m comment --comment "$IPT_COMMENT" -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || \ + iptables -t mangle -I FORWARD -d "$subnet" -p tcp -m tcp --tcp-flags SYN,RST SYN -m comment --comment "$IPT_COMMENT" -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true + ;; + nft) + nft add table inet wdtt_mangle 2>/dev/null || true + nft add chain inet wdtt_mangle forward '{ type filter hook forward priority -150; policy accept; }' 2>/dev/null || true + nft add rule inet wdtt_mangle forward ip saddr "$subnet" tcp flags syn tcp option maxseg size set rt mtu 2>/dev/null || true + nft add rule inet wdtt_mangle forward ip daddr "$subnet" tcp flags syn tcp option maxseg size set rt mtu 2>/dev/null || true + ;; + none) ;; + esac +} + +fw_add_established() { + return 0 +} + +fw_cleanup_wdtt_rules() { + local iface="$1" + if command -v iptables >/dev/null 2>&1; then + for i in {1..5}; do + local nat_iface + for nat_iface in "$iface" $(ls /sys/class/net 2>/dev/null || true); do + [ -n "$nat_iface" ] && iptables -t nat -D POSTROUTING -s 10.66.66.0/24 -o "$nat_iface" -m comment --comment "$IPT_COMMENT" -j MASQUERADE 2>/dev/null || true + done + iptables -t mangle -D FORWARD -s 10.66.66.0/24 -p tcp -m tcp --tcp-flags SYN,RST SYN -m comment --comment "$IPT_COMMENT" -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true + iptables -t mangle -D FORWARD -d 10.66.66.0/24 -p tcp -m tcp --tcp-flags SYN,RST SYN -m comment --comment "$IPT_COMMENT" -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true + iptables -D INPUT -p udp --dport ${DTLS_PORT} -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + iptables -D INPUT -p udp --dport ${WG_PORT} -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + iptables -D INPUT -p tcp --dport ${SSH_PORT} -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + iptables -D INPUT -p tcp --dport 22 -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + iptables -D FORWARD -i "$WDTT_IFACE" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + iptables -D FORWARD -o "$WDTT_IFACE" -m comment --comment "$IPT_COMMENT" -j ACCEPT 2>/dev/null || true + done + fi + if command -v nft >/dev/null 2>&1; then + nft delete table ip wdtt 2>/dev/null || true + nft delete table inet wdtt 2>/dev/null || true + nft delete table inet wdtt_mangle 2>/dev/null || true + fi +} + +cleanup_config_dir_keep_access_db() { + [ -d "$WDTT_CONFIG_DIR" ] || return 0 + find "$WDTT_CONFIG_DIR" -mindepth 1 -maxdepth 1 ! -name "$WDTT_ACCESS_DB" -exec rm -rf {} + 2>/dev/null || true + [ -f "$WDTT_CONFIG_DIR/$WDTT_ACCESS_DB" ] && chmod 600 "$WDTT_CONFIG_DIR/$WDTT_ACCESS_DB" 2>/dev/null || true +} + +# ══════════════════════════════════════════════════════════════════════════════ +# WDTT VPN SERVER DEPLOYMENT +# ══════════════════════════════════════════════════════════════════════════════ + +# ─── Очистка старого WDTT ───────────────────────────────────────────────────── +wdtt_cleanup() { + prog 0.05 "Очистка..." + echo "🧹 Очистка старой установки WDTT..." + + systemctl unmask wdtt 2>/dev/null || true + systemctl stop wdtt 2>/dev/null || true + systemctl disable wdtt 2>/dev/null || true + rm -f /etc/systemd/system/wdtt.service 2>/dev/null || true + systemctl daemon-reload 2>/dev/null || true + pkill -x wdtt-server 2>/dev/null || killall wdtt-server 2>/dev/null || true + + # Удаляем только собственный интерфейс WDTT. + ip link show "$WDTT_IFACE" >/dev/null 2>&1 && ip link del "$WDTT_IFACE" 2>/dev/null || true + + # Удаляем старые правила NAT для WDTT подсети + fw_cleanup_wdtt_rules "$(detect_wan_interface)" + + rm -f /usr/local/bin/wdtt-server 2>/dev/null || true + cleanup_config_dir_keep_access_db + + echo "✓ Очистка завершена (база доступа сохранена)" +} + +# ─── Sysctl тюнинг ─────────────────────────────────────────────────────────── +setup_sysctl() { + prog 0.20 "Sysctl..." + echo "⚙️ Настройка сетевых параметров..." + + echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true + mkdir -p /etc/sysctl.d + cat > /etc/sysctl.d/99-wdtt.conf << 'SYSEOF' +net.ipv4.ip_forward = 1 +SYSEOF + + sysctl -p /etc/sysctl.d/99-wdtt.conf >/dev/null 2>&1 || true + + echo "✓ Sysctl настроен" +} + +# ─── Настройка NAT + Firewall ───────────────────────────────────────────────── +setup_nat_and_firewall() { + prog 0.40 "NAT + Firewall..." + echo "🛡 Настройка NAT и фаервола..." + + local iface + iface=$(detect_wan_interface) + + if [ -z "$iface" ]; then + log_warn "Не удалось определить WAN-интерфейс!" + log_warn "Настройте NAT вручную для подсети 10.66.66.0/24." + return 0 + fi + + log_info "WAN-интерфейс: $iface" + + # === WDTT порты === + fw_add_input_udp "$DTLS_PORT" # 56000 — DTLS сервер + fw_add_input_udp "$WG_PORT" # 56001 — WireGuard + fw_add_input_tcp "$SSH_PORT" # SSH порт, указанный пользователем в приложении + + # === Forward === + fw_add_forward + + # === NAT: MASQUERADE для подсети WireGuard === + fw_add_masquerade "$iface" "10.66.66.0/24" + + # === MSS Clamping для исправления MTU (DonationAlerts / Cloudflare) === + fw_add_mss_clamping "10.66.66.0/24" + + if [ "$FW_BACKEND" = "none" ]; then + echo "⚠ NAT не настроен автоматически: firewall-бэкенд отсутствует" + else + echo "✓ NAT: MASQUERADE на $iface для 10.66.66.0/24" + fi + echo "✓ Порты: ${DTLS_PORT}/udp(DTLS), ${WG_PORT}/udp(WG), ${SSH_PORT}/tcp(SSH)" + echo "✓ TCP MSS Clamping включен" +} + +# ─── Установка бинарника wdtt-server ────────────────────────────────────────── +setup_wdtt_binary() { + prog 0.60 "Бинарник..." + echo "📦 Установка wdtt-server..." + + if [ -f /tmp/wdtt-server ]; then + chmod +x /tmp/wdtt-server + install -m 0755 /tmp/wdtt-server /usr/local/bin/wdtt-server 2>/dev/null || mv /tmp/wdtt-server /usr/local/bin/wdtt-server + echo "✓ wdtt-server установлен" + elif [ -f /usr/local/bin/wdtt-server ]; then + echo "✓ wdtt-server уже установлен" + else + echo "⚠ wdtt-server не найден в /tmp/ — пропускаем" + echo " Загрузите бинарник вручную в /usr/local/bin/wdtt-server" + fi + + mkdir -p "$WDTT_CONFIG_DIR" +} + +# ─── Systemd-сервис WDTT ───────────────────────────────────────────────────── +setup_wdtt_service() { + prog 0.75 "Сервис..." + echo "🔧 Создание systemd-сервиса WDTT..." + + cat > /etc/systemd/system/wdtt.service << WDTTSVC +[Unit] +Description=WDTT VPN Server +After=network.target network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStartPre=-/usr/bin/env bash -c "ip link show ${WDTT_IFACE} >/dev/null 2>&1 && ip link del ${WDTT_IFACE} 2>/dev/null || true" +ExecStartPre=-/usr/bin/env bash -c "if command -v iptables >/dev/null 2>&1; then iptables -C INPUT -p udp --dport ${DTLS_PORT} -m comment --comment ${IPT_COMMENT} -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport ${DTLS_PORT} -m comment --comment ${IPT_COMMENT} -j ACCEPT; iptables -C INPUT -p udp --dport ${WG_PORT} -m comment --comment ${IPT_COMMENT} -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport ${WG_PORT} -m comment --comment ${IPT_COMMENT} -j ACCEPT; iptables -C INPUT -p tcp --dport ${SSH_PORT} -m comment --comment ${IPT_COMMENT} -j ACCEPT 2>/dev/null || iptables -I INPUT -p tcp --dport ${SSH_PORT} -m comment --comment ${IPT_COMMENT} -j ACCEPT; fi" +ExecStart=/usr/local/bin/wdtt-server -listen 0.0.0.0:${DTLS_PORT} -wg-port ${WG_PORT} -config-dir ${WDTT_CONFIG_DIR} ${WDTT_ARGS} +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +WDTTSVC + + systemctl daemon-reload + systemctl unmask wdtt >/dev/null 2>&1 || true + systemctl enable wdtt >/dev/null 2>&1 || true + echo "✓ Сервис wdtt.service создан и включён" +} + +# ─── Запуск WDTT ───────────────────────────────────────────────────────────── +start_wdtt() { + prog 0.90 "Запуск..." + echo "🚀 Запуск WDTT VPN Server..." + + if [ ! -f /usr/local/bin/wdtt-server ]; then + echo "⚠ wdtt-server не установлен — запуск пропущен" + return 0 + fi + + systemctl restart wdtt + + sleep 2 + local status + status=$(systemctl is-active wdtt 2>/dev/null || echo "unknown") + + prog 1.0 "Готово!" + + echo "" + echo "══════════════════════════════════════════════════════════════" + + if [ "$status" = "active" ]; then + echo "✅ Деплой успешно завершён!" + echo " NAT: MASQUERADE (стандартный)" + echo " DTLS: порт ${DTLS_PORT}" + echo " WG: порт ${WG_PORT}" + echo " SSH: порт ${SSH_PORT}" + else + echo "⚠️ Сервис wdtt не запустился. Статус: $status" + echo " Последние логи:" + journalctl -u wdtt -n 7 --no-pager 2>/dev/null | sed 's/^/ >> /' + fi + + echo " Логи: journalctl -u wdtt -f" + echo " Статус: systemctl status wdtt" + echo "══════════════════════════════════════════════════════════════" + echo "" +} + +# ─── Команда: uninstall ────────────────────────────────────────────────────── +do_uninstall() { + log_step "Удаление WDTT..." + + systemctl stop wdtt 2>/dev/null || true + systemctl disable wdtt 2>/dev/null || true + rm -f /etc/systemd/system/wdtt.service + systemctl daemon-reload + + ip link show "$WDTT_IFACE" >/dev/null 2>&1 && ip link del "$WDTT_IFACE" 2>/dev/null || true + pkill -x wdtt-server 2>/dev/null || true + + fw_cleanup_wdtt_rules "$(detect_wan_interface)" + + rm -f /usr/local/bin/wdtt-server + cleanup_config_dir_keep_access_db + rm -f /etc/sysctl.d/99-wdtt.conf + sysctl --system >/dev/null 2>&1 || true + + log_info "WDTT удалён. База доступа сохранена: ${WDTT_CONFIG_DIR}/${WDTT_ACCESS_DB}" +} + +# ─── Команда: status ───────────────────────────────────────────────────────── +do_status() { + echo "Статус WDTT:" + echo "" + if systemctl is-active wdtt &>/dev/null; then + log_info "Сервис: АКТИВЕН" + else + log_warn "Сервис: НЕ АКТИВЕН" + fi + if [ -f /usr/local/bin/wdtt-server ]; then + log_info "Бинарник: установлен" + else + log_warn "Бинарник: НЕ найден" + fi + if ip link show "$WDTT_IFACE" &>/dev/null; then + log_info "WDTT интерфейс ($WDTT_IFACE): активен" + else + log_warn "WDTT интерфейс ($WDTT_IFACE): не активен" + fi +} + +# ══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ══════════════════════════════════════════════════════════════════════════════ +main() { + echo "╔══════════════════════════════════════════════════════════════╗" + echo "║ WDTT VPN Server — Installer v${SCRIPT_VERSION} ║" + echo "║ DTLS: ${DTLS_PORT} | WG: ${WG_PORT} | SSH: ${SSH_PORT} ║" + echo "╚══════════════════════════════════════════════════════════════╝" + + local action="${1:-install}" + check_root + validate_port "WDTT_DTLS_PORT" "$DTLS_PORT" + validate_port "WDTT_WG_PORT" "$WG_PORT" + validate_port "WDTT_SSH_PORT" "$SSH_PORT" + + mkdir -p "$(dirname "$LOG_FILE")" + echo "=== WDTT Installer v${SCRIPT_VERSION} — $(date) ===" >> "$LOG_FILE" + + detect_os + install_prerequisites + require_runtime_tools + detect_firewall + + case "$action" in + status|--status|-s) do_status ;; + uninstall|--uninstall|-u) do_uninstall ;; + install|--install|-i|*) + wdtt_cleanup + setup_sysctl + setup_nat_and_firewall + setup_wdtt_binary + setup_wdtt_service + start_wdtt + ;; + esac +} + +main "$@" diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..17fc07a Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/wdtt/client/AppUpdate.kt b/app/src/main/java/com/wdtt/client/AppUpdate.kt new file mode 100644 index 0000000..3714e51 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/AppUpdate.kt @@ -0,0 +1,276 @@ +package com.wdtt.client + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +const val UPDATE_CHECK_NEVER = -1 +const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 12 +const val UPDATE_DIALOG_ACTION_POSTPONED = "postponed" +const val UPDATE_DIALOG_ACTION_UPDATE = "update" + +private const val UPDATE_LOG_TAG = "WDTT" +private const val GITHUB_RELEASES_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/releases?per_page=30" +private const val GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/releases/latest" +private const val GITHUB_LATEST_RELEASE_WEB_URL = "https://github.com/amurcanov/proxy-turn-vk-android/releases/latest" +private const val GITHUB_RELEASE_TAG_URL_PREFIX = "https://github.com/amurcanov/proxy-turn-vk-android/releases/tag/" +private const val GITHUB_TAGS_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/tags?per_page=100" +private const val GITHUB_TAG_TREE_URL_PREFIX = "https://github.com/amurcanov/proxy-turn-vk-android/tree/" +private const val GITHUB_API_RATE_LIMIT_FALLBACK_MS = 30L * 60L * 1000L +private val VERSION_NUMBER_REGEX = Regex("\\d+(?:\\.\\d+)*") + +@Volatile +private var githubApiCooldownUntilMs = 0L + +fun updateIntervalHoursToMillis(hours: Int): Long? = when { + hours <= 0 -> null + else -> hours * 60L * 60L * 1000L +} + +data class AppReleaseInfo( + val versionTag: String, + val releaseUrl: String, + val source: RemoteVersionSource +) + +enum class RemoteVersionSource { + Release, + Tag +} + +suspend fun fetchLatestReleaseInfo(localVersion: String? = null): AppReleaseInfo? = withContext(Dispatchers.IO) { + val latestRelease = fetchReleaseFromLatestWebRedirect() + ?: fetchReleaseFromLatestEndpoint() + ?: fetchLatestStableReleaseFromList() + val latestTag = fetchLatestTagFromList() + + when { + latestRelease == null -> latestTag + latestTag == null -> latestRelease + isNewerVersion(latestRelease.versionTag, latestTag.versionTag) -> latestTag + else -> latestRelease + } +} + +fun isNewerVersion(local: String, remote: String): Boolean { + val localParts = versionParts(local) + val remoteParts = versionParts(remote) + if (remoteParts.isEmpty()) return false + + val maxLen = maxOf(localParts.size, remoteParts.size) + for (i in 0 until maxLen) { + val localPart = localParts.getOrElse(i) { 0 } + val remotePart = remoteParts.getOrElse(i) { 0 } + if (remotePart > localPart) return true + if (remotePart < localPart) return false + } + return false +} + +private fun fetchLatestStableReleaseFromList(): AppReleaseInfo? { + val response = fetchGitHubApi(GITHUB_RELEASES_URL) ?: return null + val releases = try { + JSONArray(response) + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse releases list", e) + return null + } + + var bestRelease: AppReleaseInfo? = null + for (i in 0 until releases.length()) { + val json = releases.optJSONObject(i) ?: continue + if (json.optBoolean("draft") || json.optBoolean("prerelease")) continue + val release = json.toAppReleaseInfo() ?: continue + if (bestRelease == null || isNewerVersion(bestRelease.versionTag, release.versionTag)) { + bestRelease = release + } + } + return bestRelease +} + +private fun fetchLatestTagFromList(): AppReleaseInfo? { + val response = fetchGitHubApi(GITHUB_TAGS_URL) ?: return null + val tags = try { + JSONArray(response) + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse tags list", e) + return null + } + + var bestTag: AppReleaseInfo? = null + for (i in 0 until tags.length()) { + val json = tags.optJSONObject(i) ?: continue + val tagName = normalizeVersionTag(json.optString("name")) + if (tagName.isBlank()) continue + val tag = AppReleaseInfo( + versionTag = tagName, + releaseUrl = "$GITHUB_TAG_TREE_URL_PREFIX$tagName", + source = RemoteVersionSource.Tag + ) + if (bestTag == null || isNewerVersion(bestTag.versionTag, tag.versionTag)) { + bestTag = tag + } + } + return bestTag +} + +private fun fetchReleaseFromLatestEndpoint(): AppReleaseInfo? { + val response = fetchGitHubApi(GITHUB_LATEST_RELEASE_URL) ?: return null + val json = try { + JSONObject(response) + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse latest release", e) + return null + } + return json.toAppReleaseInfo() +} + +private fun fetchReleaseFromLatestWebRedirect(): AppReleaseInfo? { + var conn: HttpURLConnection? = null + return try { + conn = URL(GITHUB_LATEST_RELEASE_WEB_URL).openConnection() as HttpURLConnection + applyNoCacheHeaders(conn) + conn.instanceFollowRedirects = false + conn.requestMethod = "GET" + conn.setRequestProperty("Accept", "text/html,*/*") + conn.setRequestProperty("User-Agent", "WDTTAndroid/${BuildConfig.VERSION_NAME}") + conn.connectTimeout = 8_000 + conn.readTimeout = 8_000 + + val responseCode = conn.responseCode + val location = conn.getHeaderField("Location") + if (!location.isNullOrBlank()) { + val releaseUrl = URL(URL(GITHUB_LATEST_RELEASE_WEB_URL), location).toString() + val versionTag = extractTagFromReleaseUrl(releaseUrl) + if (!versionTag.isNullOrBlank()) { + return AppReleaseInfo(versionTag, releaseUrl, RemoteVersionSource.Release) + } + } + + if (responseCode in 200..299) { + val response = conn.inputStream.bufferedReader().use { it.readText() } + val versionTag = Regex("/releases/tag/([^\"?#<]+)").find(response)?.groupValues?.getOrNull(1) + if (!versionTag.isNullOrBlank()) { + return AppReleaseInfo(versionTag, "$GITHUB_RELEASE_TAG_URL_PREFIX$versionTag", RemoteVersionSource.Release) + } + } + + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: GitHub web fallback returned $responseCode") + null + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: GitHub web fallback failed", e) + null + } finally { + conn?.disconnect() + } +} + +private fun fetchGitHubApi(url: String): String? { + val now = System.currentTimeMillis() + if (now < githubApiCooldownUntilMs) return null + return fetchHttpText( + url = url, + sourceLabel = "GitHub API", + accept = "application/vnd.github+json", + isGitHubApi = true + ) +} + +private fun fetchHttpText( + url: String, + sourceLabel: String, + accept: String, + isGitHubApi: Boolean = false +): String? { + var conn: HttpURLConnection? = null + return try { + conn = URL(url).openConnection() as HttpURLConnection + applyNoCacheHeaders(conn) + conn.requestMethod = "GET" + conn.setRequestProperty("Accept", accept) + if (isGitHubApi) { + conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28") + } + conn.setRequestProperty("User-Agent", "WDTTAndroid/${BuildConfig.VERSION_NAME}") + conn.connectTimeout = 8_000 + conn.readTimeout = 8_000 + + val responseCode = conn.responseCode + val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream + val response = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + + if (responseCode in 200..299) { + if (isGitHubApi) githubApiCooldownUntilMs = 0L + response + } else { + if (isGitHubApi) noteGitHubApiCooldown(conn, responseCode, response) + Log.w( + UPDATE_LOG_TAG, + "[WARN] Update check: $sourceLabel returned $responseCode ${response.take(300)}" + ) + null + } + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: $sourceLabel request failed", e) + null + } finally { + conn?.disconnect() + } +} + +private fun applyNoCacheHeaders(conn: HttpURLConnection) { + conn.useCaches = false + conn.setRequestProperty("Cache-Control", "no-cache, no-store, max-age=0") + conn.setRequestProperty("Pragma", "no-cache") + conn.setRequestProperty("Expires", "0") +} + +private fun noteGitHubApiCooldown(conn: HttpURLConnection, responseCode: Int, response: String) { + if (responseCode != HttpURLConnection.HTTP_FORBIDDEN && responseCode != 429) return + val now = System.currentTimeMillis() + val retryAfterUntil = conn.getHeaderField("Retry-After")?.trim()?.toLongOrNull()?.takeIf { it > 0L }?.let { now + it * 1000L } + val rateLimitResetUntil = conn.getHeaderField("X-RateLimit-Reset")?.trim()?.toLongOrNull()?.takeIf { it > 0L }?.let { it * 1000L } + val fallbackUntil = now + if (response.contains("rate limit", ignoreCase = true)) GITHUB_API_RATE_LIMIT_FALLBACK_MS else 5L * 60L * 1000L + val cooldownUntil = listOfNotNull(retryAfterUntil, rateLimitResetUntil).filter { it > now }.minOrNull() ?: fallbackUntil + if (cooldownUntil > githubApiCooldownUntilMs) { + githubApiCooldownUntilMs = cooldownUntil + Log.w( + UPDATE_LOG_TAG, + "[WARN] Update check: GitHub API cooldown ${(cooldownUntil - now) / 1000}s after HTTP $responseCode" + ) + } +} + +private fun JSONObject.toAppReleaseInfo(): AppReleaseInfo? { + val versionTag = normalizeVersionTag(optString("tag_name")) + val releaseUrl = optString("html_url").trim() + if (versionTag.isBlank() || releaseUrl.isBlank()) return null + return AppReleaseInfo(versionTag, releaseUrl, RemoteVersionSource.Release) +} + +private fun versionParts(version: String): List { + val normalized = VERSION_NUMBER_REGEX.find(version.trim())?.value ?: return emptyList() + return normalized.split(".").mapNotNull { it.toIntOrNull() } +} + +private fun normalizeVersionTag(version: String): String { + val trimmed = version.trim() + if (trimmed.isBlank()) return "" + return if (trimmed.startsWith("v", ignoreCase = true)) trimmed else "v$trimmed" +} + +private fun extractTagFromReleaseUrl(releaseUrl: String): String? { + val marker = "/releases/tag/" + val index = releaseUrl.indexOf(marker) + if (index < 0) return null + return releaseUrl.substring(index + marker.length) + .substringBefore("?") + .substringBefore("#") + .substringBefore("/") + .takeIf { it.isNotBlank() } + ?.let(::normalizeVersionTag) +} diff --git a/app/src/main/java/com/wdtt/client/CaptchaWebViewManager.kt b/app/src/main/java/com/wdtt/client/CaptchaWebViewManager.kt new file mode 100644 index 0000000..f1daa31 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/CaptchaWebViewManager.kt @@ -0,0 +1,599 @@ +package com.wdtt.client + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.cancellation.CancellationException +import kotlin.random.Random + +/** + * Управляет «невидимым» WebView для автоматического прохождения VK Smart Captcha. + * + * Один запрос = один свежий WebView: + * 1. Создаёт WebView с рандомизированным fingerprint (UA, viewport) + * 2. Загружает redirect_uri, ждёт короткую паузу загрузки + * 3. Находит чекбокс "Я не робот" (label.vkc__Checkbox-module__Checkbox) + * 4. Кликает в рандомную точку внутри label с коротким human-like таймингом + * 5. JS-interceptor перехватывает captchaNotRobot.check → success_token + * 6. Уничтожает WebView + */ +object CaptchaWebViewManager { + + private const val TAG = "CaptchaWV" + private const val CAPTCHA_TIMEOUT_MS = 10_000L + private const val WV_CREATE_TIMEOUT_MS = 3000L + const val ERROR_SLIDER_DETECTED = "slider_detected" + + // Рандомизируемые параметры viewport (чтобы VK не видел одинаковый size) + private val VIEWPORT_WIDTHS = intArrayOf(356, 358, 360, 362, 364, 366, 368) + private val VIEWPORT_HEIGHTS = intArrayOf(376, 378, 380, 382, 384, 386, 388) + + // Пул Chrome-версий (minor builds) для варьирования + private val CHROME_BUILDS = arrayOf( + "146.0.0.0", "145.0.6422.60", "145.0.6422.53", + "144.0.6367.78", "144.0.6367.61", "143.0.6312.99" + ) + + private val mainHandler = Handler(Looper.getMainLooper()) + private val captchaMutex = Mutex() + + @Volatile + private var isTunnelActive = false + + @Volatile + private var appContext: Context? = null + + private val pendingResult = AtomicReference>?>(null) + private val postClickSliderWatcher = AtomicReference(null) + + @Volatile + private var currentWebView: WebView? = null + + // Interceptor: перехватывает ответ captchaNotRobot.check → достаёт success_token + private val interceptorJSCode = """ + (function() { + if (window.__wdtt_interceptor_installed) return; + window.__wdtt_interceptor_installed = true; + + const origFetch = window.fetch; + window.fetch = async function() { + const args = arguments; + const url = args[0] || ''; + if (typeof url === 'string' && url.includes('captchaNotRobot.check')) { + const response = await origFetch.apply(this, args); + const clone = response.clone(); + try { + const data = await clone.json(); + if (data.response && data.response.success_token) { + window.WdttCaptcha.onSuccess(data.response.success_token); + } else if ( + data.response && + data.response.show_captcha_type === 'slider' + ) { + window.WdttCaptcha.onSliderDetected('check_response'); + } else if (data.error) { + window.WdttCaptcha.onError(JSON.stringify(data.error)); + } + } catch(e) {} + return response; + } + return origFetch.apply(this, args); + }; + + const origXHROpen = XMLHttpRequest.prototype.open; + const origXHRSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this._wdtt_url = url; + return origXHROpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + const xhr = this; + if (xhr._wdtt_url && xhr._wdtt_url.includes('captchaNotRobot.check')) { + xhr.addEventListener('load', function() { + try { + const data = JSON.parse(xhr.responseText); + if (data.response && data.response.success_token) { + window.WdttCaptcha.onSuccess(data.response.success_token); + } else if ( + data.response && + data.response.show_captcha_type === 'slider' + ) { + window.WdttCaptcha.onSliderDetected('check_response'); + } else if (data.error) { + window.WdttCaptcha.onError(JSON.stringify(data.error)); + } + } catch(e) {} + }); + } + return origXHRSend.apply(this, arguments); + }; + })(); + """.trimIndent() + + // ═══════════════════════════════════════════════════════════════ + // Lifecycle + // ═══════════════════════════════════════════════════════════════ + + fun onTunnelStart(context: Context) { + appContext = context.applicationContext + isTunnelActive = true + Log.d(TAG, "Туннель активен") + } + + fun onTunnelStop() { + isTunnelActive = false + cancelPendingResult("tunnel stopped") + destroyCurrentWebView() + appContext = null + Log.d(TAG, "Туннель остановлен") + } + + // ═══════════════════════════════════════════════════════════════ + // Публичный API + // ═══════════════════════════════════════════════════════════════ + + suspend fun solveCaptchaAsync(redirectUri: String, sessionToken: String, onStep: (String) -> Unit = {}): String { + if (!isTunnelActive) throw IllegalStateException("WV не готов — туннель не активен") + val ctx = appContext ?: throw IllegalStateException("WV не готов — контекст null") + + // Используем Mutex вместо AtomicBoolean: если запрашивается вторая капча до закрытия первой, + // она просто подождет в очереди (несколько секунд), вместо того чтобы вылетать с ошибкой. + return captchaMutex.withLock { + try { + withTimeout(CAPTCHA_TIMEOUT_MS) { + doSolveCaptcha(ctx, redirectUri, onStep) + } + } finally { + pendingResult.set(null) + destroyCurrentWebView() + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Внутренняя логика + // ═══════════════════════════════════════════════════════════════ + + private suspend fun doSolveCaptcha(context: Context, redirectUri: String, onStep: (String) -> Unit): String { + val deferred = CompletableDeferred>() + pendingResult.set(deferred) + + val webView = createWebViewSync(context, onStep) + ?: throw IllegalStateException("Не удалось создать WebView") + + Log.d(TAG, "WebView создан ✓") + + // Загружаем страницу капчи + withContext(Dispatchers.Main) { + webView.evaluateJavascript(interceptorJSCode, null) + kotlinx.coroutines.delay(80) + webView.loadUrl(redirectUri) + } + + // Ждём success_token от JS-bridge + try { + val token = deferred.await().getOrThrow() + Log.d(TAG, "Капча решена ✓") + return token + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Ошибка: ${e::class.simpleName} — ${e.message}") + throw e + } + } + + // ═══════════════════════════════════════════════════════════════ + // Создание WebView с рандомизированным fingerprint + // ═══════════════════════════════════════════════════════════════ + + @SuppressLint("SetJavaScriptEnabled") + private fun createWebViewSync(context: Context, onStep: (String) -> Unit): WebView? { + // Рандомизируем параметры для КАЖДОГО запроса + val vw = VIEWPORT_WIDTHS[Random.Default.nextInt(VIEWPORT_WIDTHS.size)] + val vh = VIEWPORT_HEIGHTS[Random.Default.nextInt(VIEWPORT_HEIGHTS.size)] + val chromeBuild = CHROME_BUILDS[Random.Default.nextInt(CHROME_BUILDS.size)] + val ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$chromeBuild Safari/537.36" + + Log.d(TAG, "Fingerprint: ${vw}x${vh}, Chrome/$chromeBuild") + + val latch = CountDownLatch(1) + var webView: WebView? = null + + val createAction = Runnable { + try { + val wv = WebView(context.applicationContext) + wv.apply { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + mediaPlaybackRequiresUserGesture = false + loadWithOverviewMode = true + useWideViewPort = true + blockNetworkLoads = false + cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE + userAgentString = ua + } + + addJavascriptInterface(CaptchaJSBridge(), "WdttCaptcha") + + webViewClient = object : WebViewClient() { + override fun onPageStarted( + view: WebView, url: String?, favicon: android.graphics.Bitmap? + ) { + super.onPageStarted(view, url, favicon) + view.evaluateJavascript(interceptorJSCode, null) + } + + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + + val isCaptchaPage = url?.let { + it.contains("not_robot_captcha") || + it.contains("id.vk.ru/captcha") || + it.contains("not_robot") + } ?: false + + if (isCaptchaPage) { + Log.d(TAG, "Страница капчи загружена") + view.evaluateJavascript(interceptorJSCode, null) + + if (currentWebView === view && isTunnelActive) { + // Быстрый auto-pass: WebView получает такой же короткий темп, как Go v2. + val pageLoadDelay = 650L + Random.Default.nextLong(0, 550) + mainHandler.postDelayed({ + if (currentWebView === view && isTunnelActive) { + solveCaptchaAutomatedSync(view) + } + }, pageLoadDelay) + } + } + } + + override fun shouldInterceptRequest( + view: WebView, request: WebResourceRequest + ): WebResourceResponse? { + return super.shouldInterceptRequest(view, request) + } + + override fun onReceivedSslError( + view: WebView, + handler: android.webkit.SslErrorHandler, + error: android.net.http.SslError + ) { + // Разрешаем только для доверенных доменов VK/OK + val url = error.url ?: "" + if (url.contains("vk.ru") || url.contains("vk.com") || url.contains("okcdn.ru")) { + handler.proceed() + } else { + handler.cancel() + Log.w(TAG, "SSL error rejected for: $url") + } + } + } + + webChromeClient = WebChromeClient() + + measure( + View.MeasureSpec.makeMeasureSpec(vw, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(vh, View.MeasureSpec.EXACTLY) + ) + layout(0, 0, vw, vh) + onResume() + } + webView = wv + currentWebView = wv + } catch (e: Exception) { + Log.e(TAG, "Ошибка создания WebView: ${e.message}") + webView = null + } finally { + latch.countDown() + } + } + + if (Looper.myLooper() == Looper.getMainLooper()) { + createAction.run() + } else { + mainHandler.post(createAction) + } + + val ok = latch.await(WV_CREATE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + if (!ok) { + Log.e(TAG, "Таймаут создания WebView") + return null + } + return webView + } + + private fun destroyCurrentWebView() { + val wv = currentWebView ?: return + currentWebView = null + postClickSliderWatcher.getAndSet(null)?.let { mainHandler.removeCallbacks(it) } + + val destroyAction = Runnable { + try { + wv.stopLoading() + wv.loadUrl("about:blank") + try { wv.removeJavascriptInterface("WdttCaptcha") } catch (_: Exception) {} + wv.webViewClient = WebViewClient() + wv.webChromeClient = null + wv.onPause() + wv.removeAllViews() + wv.destroy() + Log.d(TAG, "WebView уничтожен ✓") + } catch (e: Exception) { + Log.e(TAG, "Ошибка уничтожения: ${e.message}") + } + } + + if (Looper.myLooper() == Looper.getMainLooper()) { + destroyAction.run() + } else { + val latch = CountDownLatch(1) + mainHandler.post { + try { destroyAction.run() } finally { latch.countDown() } + } + latch.await(2000, TimeUnit.MILLISECONDS) + } + } + + // ═══════════════════════════════════════════════════════════════ + // Авто-решение: клик по чекбоксу «Я не робот» + // + // Структура VK капчи (из HTML): + // label.vkc__Checkbox-module__Checkbox ← КЛИКАБЕЛЬНЫЙ label (~200x32px) + // input#not-robot-captcha-checkbox ← скрытый checkbox + // div.vkc__Checkbox-module__Checkbox__iconBlock ← иконка чекбокса + // div.vkc__Checkbox-module__Checkbox__title ← текст "Я не робот" + // + // Стратегия: находим ВЕСЬ label, получаем его размеры, + // кликаем в РАНДОМНУЮ точку внутри него (не в центр). + // ═══════════════════════════════════════════════════════════════ + + private fun solveCaptchaAutomatedSync(webView: WebView) { + if (currentWebView !== webView || !isTunnelActive) return + + // Ищем LABEL целиком (он большой, ~200x32px — как человек кликает). + // Если вместо checkbox открыт slider/kaleidoscope, скрытый WebView сразу отдаёт fallback ручному WV. + val findLabelJS = """ + (function() { + var slider = document.querySelector( + '[class*="SliderCaptcha"], [class*="Kaleidoscope"], ' + + '.vkc__SliderCaptcha-module__description, ' + + '.vkc__KaleidoscopeScreen-module__captchaId' + ); + if (slider) return '${ERROR_SLIDER_DETECTED}'; + + // Приоритет: label обёртка (самый большой кликабельный элемент) + var el = document.querySelector('label.vkc__Checkbox-module__Checkbox'); + // Fallback: прямой поиск по ID + if (!el) el = document.querySelector('label[for="not-robot-captcha-checkbox"]'); + // Fallback: сам чекбокс + if (!el) el = document.getElementById('not-robot-captcha-checkbox'); + if (!el) return 'not_found'; + + var rect = el.getBoundingClientRect(); + var style = window.getComputedStyle(el); + if (rect.width < 5 || rect.height < 5 || + style.display === 'none' || style.visibility === 'hidden') { + return 'not_found'; + } + // Возвращаем left,top,width,height — чтобы кликнуть в РАНДОМНУЮ точку + return rect.left + ',' + rect.top + ',' + rect.width + ',' + rect.height; + })(); + """.trimIndent() + + webView.evaluateJavascript(findLabelJS) { rawValue -> + val result = rawValue?.replace("\"", "") ?: "" + Log.d(TAG, "Label чекбокса: $result") + + if (currentWebView !== webView || !isTunnelActive) return@evaluateJavascript + + if (result == ERROR_SLIDER_DETECTED) { + Log.i(TAG, "Обнаружен слайдер — fallback на ручной WebView") + notifyResult(Result.failure(IllegalStateException(ERROR_SLIDER_DETECTED))) + return@evaluateJavascript + } + + if (result == "not_found" || result.split(",").size < 4) { + // Fallback: JS .click() — не идеально, но лучше чем ничего + Log.w(TAG, "Label не найден — JS-клик (fallback)") + val jsClick = """ + (function() { + var el = document.querySelector('label.vkc__Checkbox-module__Checkbox'); + if (!el) el = document.getElementById('not-robot-captcha-checkbox'); + if (el) { el.click(); return 'clicked'; } + return 'nothing'; + })(); + """.trimIndent() + webView.evaluateJavascript(jsClick) { clickResult -> + if ((clickResult ?: "").replace("\"", "") == "clicked") { + startPostClickSliderWatcher(webView) + } + } + return@evaluateJavascript + } + + val parts = result.split(",") + val left = parts[0].toFloatOrNull() ?: return@evaluateJavascript + val top = parts[1].toFloatOrNull() ?: return@evaluateJavascript + val width = parts[2].toFloatOrNull() ?: return@evaluateJavascript + val height = parts[3].toFloatOrNull() ?: return@evaluateJavascript + + // Рандомная точка внутри label (60-90% ширины, 25-75% высоты) + // Человек кликает не ровно в центр, а примерно туда + val randX = left + width * (0.15f + Random.Default.nextFloat() * 0.7f) + val randY = top + height * (0.25f + Random.Default.nextFloat() * 0.5f) + + Log.d(TAG, "Клик: (${randX.toInt()}, ${randY.toInt()}) в зоне ${width.toInt()}x${height.toInt()}") + + val thinkDelay = 420L + Random.Default.nextLong(0, 260) + + mainHandler.postDelayed({ + if (currentWebView === webView && isTunnelActive) { + simulateHumanTouch(webView, randX, randY) + startPostClickSliderWatcher(webView) + } + }, thinkDelay) + } + } + + private fun startPostClickSliderWatcher(webView: WebView) { + postClickSliderWatcher.getAndSet(null)?.let { mainHandler.removeCallbacks(it) } + + var attemptsLeft = 14 + val watcher = object : Runnable { + override fun run() { + if (currentWebView !== webView || !isTunnelActive) return + + val detectSliderJS = """ + (function() { + var slider = document.querySelector( + '[class*="SliderCaptcha"], [class*="Kaleidoscope"], ' + + '.vkc__SliderCaptcha-module__description, ' + + '.vkc__KaleidoscopeScreen-module__captchaId, ' + + '.vkc__SwipeButton-module__track' + ); + if (slider) return 'slider'; + + var success = document.querySelector( + '[class*="success"], [class*="Success"], [class*="passed"], [class*="Passed"]' + ); + if (success) return 'success_ui'; + + return 'none'; + })(); + """.trimIndent() + + webView.evaluateJavascript(detectSliderJS) { rawValue -> + if (currentWebView !== webView || !isTunnelActive) return@evaluateJavascript + + val result = rawValue?.replace("\"", "") ?: "none" + when (result) { + "slider" -> { + Log.i(TAG, "После checkbox появился слайдер — fallback на ручной WebView") + notifyResult(Result.failure(IllegalStateException(ERROR_SLIDER_DETECTED))) + } + "success_ui" -> { + postClickSliderWatcher.set(null) + } + else -> { + attemptsLeft-- + if (attemptsLeft > 0) { + mainHandler.postDelayed(this, 350L) + } else { + postClickSliderWatcher.set(null) + } + } + } + } + } + } + + postClickSliderWatcher.set(watcher) + mainHandler.postDelayed(watcher, 450L) + } + + /** + * Имитирует нативный тач как от пальца: + * - ACTION_DOWN с рандомным pressure (0.5-0.9) + * - Удержание 80-180мс (как палец на экране) + * - ACTION_UP с лёгким смещением (палец дрожит) + */ + private fun simulateHumanTouch(webView: WebView, cssX: Float, cssY: Float) { + if (currentWebView !== webView) return + + val density = webView.resources.displayMetrics.density + val physX = cssX * density + val physY = cssY * density + val downTime = SystemClock.uptimeMillis() + + // Рандомный pressure — палец нажимает с разной силой + val pressure = 0.5f + Random.Default.nextFloat() * 0.4f + + val downEvent = MotionEvent.obtain( + downTime, downTime, MotionEvent.ACTION_DOWN, physX, physY, pressure, 1f, 0, 1f, 1f, 0, 0 + ) + downEvent.source = android.view.InputDevice.SOURCE_TOUCHSCREEN + webView.dispatchTouchEvent(downEvent) + downEvent.recycle() + + // Удержание пальца: 80-180мс + val holdTime = 80L + Random.Default.nextLong(0, 100) + + mainHandler.postDelayed({ + if (currentWebView === webView) { + // Лёгкое смещение при отпускании (палец не стоит идеально на месте) + val jitterX = physX + (-1f + Random.Default.nextFloat() * 2f) * density + val jitterY = physY + (-0.5f + Random.Default.nextFloat() * 1f) * density + + val upEvent = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, + jitterX, jitterY, 0f, 1f, 0, 1f, 1f, 0, 0 + ) + upEvent.source = android.view.InputDevice.SOURCE_TOUCHSCREEN + webView.dispatchTouchEvent(upEvent) + upEvent.recycle() + } + }, holdTime) + } + + // ═══════════════════════════════════════════════════════════════ + // JS Bridge — вызывается из JavaScript background thread + // ═══════════════════════════════════════════════════════════════ + + private class CaptchaJSBridge { + @JavascriptInterface + fun onSuccess(token: String) { + Log.d(TAG, "JS: success_token получен (${token.length} символов)") + notifyResult(Result.success(token)) + } + + @JavascriptInterface + fun onSliderDetected(source: String) { + Log.i(TAG, "JS: обнаружен slider после auto-step ($source)") + notifyResult(Result.failure(IllegalStateException(ERROR_SLIDER_DETECTED))) + } + + @JavascriptInterface + fun onError(error: String) { + Log.e(TAG, "JS: ошибка — $error") + notifyResult(Result.failure(Exception("VK: $error"))) + } + } + + private fun notifyResult(result: Result) { + val deferred = pendingResult.getAndSet(null) ?: return + if (!deferred.isCompleted) { + deferred.complete(result) + } + } + + private fun cancelPendingResult(reason: String) { + val deferred = pendingResult.getAndSet(null) ?: return + if (!deferred.isCompleted) { + deferred.complete(Result.failure(CancellationException(reason))) + } + } +} diff --git a/app/src/main/java/com/wdtt/client/DeployManager.kt b/app/src/main/java/com/wdtt/client/DeployManager.kt new file mode 100644 index 0000000..5a767b2 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/DeployManager.kt @@ -0,0 +1,88 @@ +package com.wdtt.client + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object DeployManager { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val isDeploying = MutableStateFlow(false) + val deployProgress = MutableStateFlow(0f) + val currentStep = MutableStateFlow("") + val lastResult = MutableStateFlow("") // "success", "error: ...", "" + + @Volatile + var activeSession: com.jcraft.jsch.Session? = null + private var deployStartTime = 0L + private var errorsFile: File? = null + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + /** Вызвать один раз при старте приложения */ + fun init(context: Context) { + val dir = context.getExternalFilesDir(null) ?: context.filesDir + errorsFile = File(dir, "errors.log") + } + + fun getErrorsFile(): File? = errorsFile + + /** Записать ошибку в файл (потокобезопасно) */ + @Synchronized + fun writeError(msg: String) { + val file = errorsFile ?: return + try { + val timestamp = dateFormat.format(Date()) + file.appendText("[$timestamp] $msg\n") + // Ротация: если файл > 500 КБ, обрезаем до последних 200 КБ + if (file.length() > 500_000) { + val text = file.readText() + file.writeText(text.takeLast(200_000)) + } + } catch (_: Exception) { } + } + + fun startDeploy() { + // Автосброс зависшего деплоя > 30 минут + if (isDeploying.value && deployStartTime > 0 && + System.currentTimeMillis() - deployStartTime > 30 * 60 * 1000) { + writeError("Автосброс: предыдущий деплой завис >30 мин") + forceReset() + } + isDeploying.value = true + deployStartTime = System.currentTimeMillis() + deployProgress.value = 0f + currentStep.value = "Инициализация..." + lastResult.value = "" + } + + fun stopDeploy(result: String = "") { + isDeploying.value = false + deployStartTime = 0L + if (result.isNotBlank()) lastResult.value = result + val session = activeSession + activeSession = null + try { session?.disconnect() } catch (_: Exception) {} + } + + /** Принудительный сброс — для восстановления из любого состояния */ + fun forceReset() { + val session = activeSession + activeSession = null + try { session?.disconnect() } catch (_: Exception) {} + isDeploying.value = false + deployStartTime = 0L + deployProgress.value = 0f + currentStep.value = "" + } + + fun updateProgress(progress: Float, step: String) { + deployProgress.value = progress + currentStep.value = step + } +} diff --git a/app/src/main/java/com/wdtt/client/MainActivity.kt b/app/src/main/java/com/wdtt/client/MainActivity.kt new file mode 100644 index 0000000..6c34076 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/MainActivity.kt @@ -0,0 +1,671 @@ +package com.wdtt.client + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.net.VpnService +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import android.view.HapticFeedbackConstants +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.foundation.shape.RoundedCornerShape +import kotlinx.coroutines.launch +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.font.FontWeight +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.platform.LocalContext +import com.wdtt.client.ui.AppUpdateDialog +import com.wdtt.client.ui.FloatingToolbar +import com.wdtt.client.ui.LogsTab +import com.wdtt.client.ui.SettingsTab +import com.wdtt.client.ui.DeployTab +import com.wdtt.client.ui.ExceptionsTab +import com.wdtt.client.ui.InfoTab +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.first +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +class MainActivity : ComponentActivity() { + + private val vpnLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + // VPN permission dialog finished + } + + private val batteryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + checkAndRequestVpn() + } + + private val notificationLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + checkAndRequestBattery() + } + + companion object { + var activeActivities = 0 + var isForeground: Boolean + get() = activeActivities > 0 + set(value) {} + } + + override fun onStart() { + super.onStart() + activeActivities++ + ManlCaptchaWebViewManager.checkAndShowPendingCaptcha(this) + } + + override fun onStop() { + super.onStop() + activeActivities-- + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + checkAndRequestNotifications() + + setContent { + val settingsStore = remember { SettingsStore(this) } + val themeMode by settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = "system") + val isDynamicColor by settingsStore.isDynamicColor.collectAsStateWithLifecycle(initialValue = false) + val themePalette by settingsStore.themePalette.collectAsStateWithLifecycle(initialValue = "indigo") + val scope = rememberCoroutineScope() + + WDTTTheme(themeMode = themeMode, dynamicColor = isDynamicColor, themePalette = themePalette) { + MainScreen( + settingsStore = settingsStore, + themeMode = themeMode, + onThemeChange = { mode -> + scope.launch { + settingsStore.saveThemeMode(mode) + } + }, + isDynamicColor = isDynamicColor, + onDynamicColorChange = { enabled -> + scope.launch { settingsStore.saveDynamicColor(enabled) } + }, + currentPalette = themePalette, + onPaletteChange = { palette -> + scope.launch { settingsStore.saveThemePalette(palette) } + } + ) + } + } + } + + private fun checkAndRequestNotifications() { + if (Build.VERSION.SDK_INT >= 33) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + checkAndRequestBattery() + } + } else { + checkAndRequestBattery() + } + } + + private fun checkAndRequestBattery() { + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + try { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + batteryLauncher.launch(intent) + } catch (e: Exception) { + checkAndRequestVpn() + } + } else { + checkAndRequestVpn() + } + } + + private fun checkAndRequestVpn() { + try { + val vpnIntent = VpnService.prepare(this) + if (vpnIntent != null) { + vpnLauncher.launch(vpnIntent) + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} + +// ═══ Навигация ═══ + +private data class NavItem( + val label: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, +) + +private val navItems = listOf( + NavItem("Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey), + NavItem("Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud), + NavItem("Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList), + NavItem("Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal), + NavItem("Инфо", Icons.Filled.Info, Icons.Outlined.Info), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + settingsStore: SettingsStore, + themeMode: String = "system", + onThemeChange: (String) -> Unit = {}, + isDynamicColor: Boolean = false, + onDynamicColorChange: (Boolean) -> Unit = {}, + currentPalette: String = "indigo", + onPaletteChange: (String) -> Unit = {} +) { + val unreadErrors by TunnelManager.unreadErrorCount.collectAsStateWithLifecycle() + val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle() + val view = LocalView.current + val context = LocalContext.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + var dragTargetIndex by remember { mutableIntStateOf(-1) } + var dragProgress by remember { mutableFloatStateOf(0f) } + val updateCheckIntervalHours by settingsStore.updateCheckIntervalHours.collectAsStateWithLifecycle( + initialValue = DEFAULT_UPDATE_CHECK_INTERVAL_HOURS + ) + var pendingRelease by remember { mutableStateOf(null) } + val currentVersion = remember { "v${BuildConfig.VERSION_NAME.removePrefix("v")}" } + val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() } + val navOverlayReserve = safeBottomInset + 96.dp + + LaunchedEffect(selectedTab) { + if (selectedTab == 3) TunnelManager.clearUnreadErrors() + } + + LaunchedEffect(updateCheckIntervalHours) { + if (updateCheckIntervalHours == UPDATE_CHECK_NEVER) return@LaunchedEffect + + val intervalMillis = updateIntervalHoursToMillis(updateCheckIntervalHours) + ?: updateIntervalHoursToMillis(DEFAULT_UPDATE_CHECK_INTERVAL_HOURS) + ?: 12L * 60L * 60L * 1000L + + suspend fun runUpdateCheck(reason: String) { + val checkedAt = System.currentTimeMillis() + val release = fetchLatestReleaseInfo(currentVersion) + settingsStore.saveUpdateState( + lastCheckAt = checkedAt, + latestVersion = release?.versionTag ?: "", + error = if (release == null) "Не удалось проверить" else "" + ) + + if (release == null) { + Log.w("WDTT", "[WARN] Update check: no release info, local=$currentVersion reason=$reason") + return + } + + val hasUpdate = isNewerVersion(currentVersion, release.versionTag) + val postponeVer = settingsStore.updatePostponeVersion.first() + val postponeUntil = settingsStore.updatePostponeUntil.first() + val isPostponed = postponeVer == release.versionTag && checkedAt < postponeUntil + Log.i( + "WDTT", + "Update check: local=$currentVersion remote=${release.versionTag} newer=$hasUpdate postponed=$isPostponed reason=$reason" + ) + + if (hasUpdate && !isPostponed) { + settingsStore.saveUpdateDialogShown(release.versionTag, checkedAt) + pendingRelease = release + } + } + + runUpdateCheck("startup") + + while (isActive) { + val now = System.currentTimeMillis() + val lastCheck = settingsStore.updateLastCheckAt.first() + val nextCheckAt = lastCheck + intervalMillis + val waitMs = (nextCheckAt - now).coerceAtLeast(intervalMillis) + delay(waitMs) + if (isActive) { + runUpdateCheck("periodic") + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + AppBackdrop(modifier = Modifier.matchParentSize()) + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + containerColor = Color.Transparent, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .pointerInput(selectedTab) { + var totalDrag = 0f + detectHorizontalDragGestures( + onDragStart = { + totalDrag = 0f + dragTargetIndex = -1 + dragProgress = 0f + }, + onDragCancel = { + dragTargetIndex = -1 + dragProgress = 0f + }, + onDragEnd = { + if (dragTargetIndex in navItems.indices && dragProgress >= 0.5f) { + selectedTab = dragTargetIndex + if (selectedTab == 3) TunnelManager.clearUnreadErrors() + } + dragTargetIndex = -1 + dragProgress = 0f + } + ) { change, dragAmount -> + change.consume() + totalDrag += dragAmount + if (abs(totalDrag) < 12f) { + dragTargetIndex = -1 + dragProgress = 0f + return@detectHorizontalDragGestures + } + + val candidate = if (totalDrag < 0f) selectedTab + 1 else selectedTab - 1 + if (candidate !in navItems.indices) { + dragTargetIndex = -1 + dragProgress = 0f + return@detectHorizontalDragGestures + } + + dragTargetIndex = candidate + dragProgress = (abs(totalDrag) / 180f).coerceIn(0f, 1f) + } + } + ) { + AnimatedContent( + targetState = selectedTab, + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(225)) + }, + modifier = Modifier + .fillMaxSize() + .padding(bottom = navOverlayReserve), + label = "tab_content" + ) { tab -> + when (tab) { + 0 -> SettingsTab() + 1 -> DeployTab() + 2 -> ExceptionsTab() + 3 -> LogsTab() + 4 -> InfoTab() + } + } + + ProxyNavigationBar( + navItems = navItems, + selectedTab = selectedTab, + dragTargetIndex = dragTargetIndex, + dragProgress = dragProgress, + unreadErrors = unreadErrors, + tunnelRunning = tunnelRunning, + onTabSelected = { index -> + if (selectedTab != index) { + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + selectedTab = index + if (index == 3) TunnelManager.clearUnreadErrors() + } + dragTargetIndex = -1 + dragProgress = 0f + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } + + // Floating theme toolbar overlay + FloatingToolbar( + currentTheme = themeMode, + onThemeChange = onThemeChange, + isDynamicColor = isDynamicColor, + onDynamicColorChange = onDynamicColorChange, + currentPalette = currentPalette, + onPaletteChange = onPaletteChange + ) + } + + pendingRelease?.let { release -> + AppUpdateDialog( + release = release, + onPostpone = { + pendingRelease = null + Toast.makeText(context, "Обновление отложено на 24 часа.", Toast.LENGTH_SHORT).show() + scope.launch { + val now = System.currentTimeMillis() + settingsStore.saveUpdatePostpone( + version = release.versionTag, + until = now + 24L * 60L * 60L * 1000L + ) + settingsStore.saveUpdateDialogAction( + version = release.versionTag, + action = UPDATE_DIALOG_ACTION_POSTPONED, + actedAt = now + ) + } + }, + onUpdate = { + pendingRelease = null + scope.launch { + settingsStore.saveUpdateDialogAction( + version = release.versionTag, + action = UPDATE_DIALOG_ACTION_UPDATE, + actedAt = System.currentTimeMillis() + ) + openReleaseUrl(context, release.releaseUrl) + } + } + ) + } +} + +@Composable +private fun ProxyNavigationBar( + navItems: List, + selectedTab: Int, + dragTargetIndex: Int, + dragProgress: Float, + unreadErrors: Int, + tunnelRunning: Boolean, + onTabSelected: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + val selectedColor = colors.primary + val unselectedColor = colors.onSurfaceVariant.copy(alpha = 0.55f) + val shellColor = if (isDark) { + colors.surface.copy(alpha = 0.78f) + } else { + lerp(colors.surface, colors.surfaceVariant, 0.48f).copy(alpha = 0.95f) + } + val shellBorder = if (isDark) { + colors.outlineVariant.copy(alpha = 0.42f) + } else { + colors.outline.copy(alpha = 0.16f) + } + val indicatorColor = if (isDark) { + colors.primaryContainer.copy(alpha = 0.84f) + } else { + lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f) + } + val indicatorIndex = remember { Animatable(selectedTab.toFloat()) } + val dragVisualIndex = indicatorIndex.value + + LaunchedEffect(selectedTab) { + if (dragTargetIndex !in navItems.indices) { + indicatorIndex.animateTo( + targetValue = selectedTab.toFloat(), + animationSpec = tween( + durationMillis = 720, + easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f) + ) + ) + } + } + + LaunchedEffect(selectedTab, dragTargetIndex, dragProgress) { + if (dragTargetIndex in navItems.indices) { + val target = selectedTab.toFloat() + (dragTargetIndex - selectedTab) * dragProgress + indicatorIndex.snapTo(target) + } + } + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .padding(horizontal = 22.dp, vertical = 12.dp) + ) { + val trackPadding = 8.dp + val itemWidth = (maxWidth - trackPadding * 2) / navItems.size + val indicatorOffset = trackPadding + itemWidth * dragVisualIndex + + Surface( + shape = RoundedCornerShape(28.dp), + color = shellColor, + border = BorderStroke(1.dp, shellBorder), + tonalElevation = 0.dp, + shadowElevation = if (isDark) 10.dp else 8.dp, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + ) { + Surface( + shape = RoundedCornerShape(22.dp), + color = indicatorColor, + modifier = Modifier + .offset(x = indicatorOffset) + .padding(vertical = 6.dp) + .width(itemWidth) + .fillMaxHeight() + ) {} + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = trackPadding, vertical = 6.dp) + ) { + navItems.forEachIndexed { index, item -> + val emphasis = (1f - abs(index - dragVisualIndex)).coerceIn(0f, 1f) + val iconColor = lerp(unselectedColor, selectedColor, emphasis) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(22.dp)) + .clickable { onTabSelected(index) }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(contentAlignment = Alignment.TopEnd) { + Icon( + imageVector = if (emphasis > 0.55f) item.selectedIcon else item.unselectedIcon, + contentDescription = item.label, + modifier = Modifier.size(22.dp), + tint = iconColor + ) + if (index == 3 && unreadErrors > 0) { + Badge( + containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning, + contentColor = colors.onPrimary, + modifier = Modifier.offset(x = 12.dp, y = (-8).dp) + ) { + Text("$unreadErrors") + } + } + } + Spacer(Modifier.height(4.dp)) + Text( + text = item.label, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (emphasis > 0.55f) FontWeight.SemiBold else FontWeight.Medium, + color = iconColor, + maxLines = 1 + ) + } + } + } + } + } + } +} + +private fun openReleaseUrl(context: Context, url: String) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } + context.startActivity(intent) + } catch (_: Exception) { + Toast.makeText(context, "Не удалось открыть ссылку", Toast.LENGTH_SHORT).show() + } +} + +private fun android16OrbShape(points: Int, innerRatio: Float): Shape = GenericShape { size, _ -> + val centerX = size.width / 2f + val centerY = size.height / 2f + val outerRadius = min(size.width, size.height) / 2f + val innerRadius = outerRadius * innerRatio + + for (i in 0 until points * 2) { + val angle = (-PI / 2.0) + (i * PI / points) + val radius = if (i % 2 == 0) outerRadius else innerRadius + val x = centerX + (radius * cos(angle)).toFloat() + val y = centerY + (radius * sin(angle)).toFloat() + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() +} + +private val Android16OrbLarge: Shape = android16OrbShape(points = 18, innerRatio = 0.90f) +private val Android16OrbMedium: Shape = android16OrbShape(points = 20, innerRatio = 0.92f) +private val Android16OrbSmall: Shape = android16OrbShape(points = 16, innerRatio = 0.88f) + +@Composable +private fun AppBackdrop(modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + val baseBrush = remember(colors.background, colors.surface, colors.surfaceVariant) { + Brush.verticalGradient( + colors = if (isDark) { + listOf( + lerp(colors.background, colors.surface, 0.18f), + colors.background, + lerp(colors.surfaceVariant, colors.background, 0.72f) + ) + } else { + listOf( + lerp(colors.background, colors.surface, 0.78f), + colors.background, + lerp(colors.surfaceVariant, colors.background, 0.30f) + ) + } + ) + } + val topGlow = colors.primary.copy(alpha = if (isDark) 0.055f else 0.09f) + val leftGlow = if (isDark) { + colors.tertiary.copy(alpha = 0.045f) + } else { + lerp(colors.tertiary, colors.secondaryContainer, 0.74f).copy(alpha = 0.24f) + } + val bottomGlow = if (isDark) { + colors.primary.copy(alpha = 0.04f) + } else { + lerp(colors.secondary, colors.primaryContainer, 0.70f).copy(alpha = 0.22f) + } + val lightOrbOutline = colors.outlineVariant.copy(alpha = 0.26f) + val topOrbGlow = if (isDark) { + topGlow + } else { + lerp(colors.primary, colors.primaryContainer, 0.72f).copy(alpha = 0.32f) + } + + Box( + modifier = modifier + .fillMaxSize() + .background(baseBrush) + ) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .offset(x = (-86).dp, y = (-126).dp) + .size(258.dp) + .clip(Android16OrbLarge) + .background(topOrbGlow) + .then( + if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline, Android16OrbLarge) + ) + ) + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset(x = (-44).dp, y = 28.dp) + .size(146.dp) + .clip(Android16OrbSmall) + .background(leftGlow) + .then( + if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline.copy(alpha = 0.22f), Android16OrbSmall) + ) + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 62.dp, y = (-208).dp) + .size(198.dp) + .clip(Android16OrbMedium) + .background(bottomGlow) + .then( + if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline.copy(alpha = 0.20f), Android16OrbMedium) + ) + ) + } +} diff --git a/app/src/main/java/com/wdtt/client/ManlCaptchaWebViewManager.kt b/app/src/main/java/com/wdtt/client/ManlCaptchaWebViewManager.kt new file mode 100644 index 0000000..aa78b12 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ManlCaptchaWebViewManager.kt @@ -0,0 +1,388 @@ +package com.wdtt.client + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.ViewGroup +import android.webkit.* +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.app.NotificationCompat +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicReference +import kotlin.random.Random + +object ManlCaptchaWebViewManager { + private const val TAG = "ManlCaptchaWV" + private const val CAPTCHA_TIMEOUT_MS = 60_000L + + val captchaMutex = Mutex() + val pendingResult = AtomicReference>?>(null) + var activeActivity: ManlCaptchaActivity? = null + var pendingIntentToStart: Intent? = null + var isCaptchaPending = false + + fun checkAndShowPendingCaptcha(context: Context) { + val intent = pendingIntentToStart + if (intent != null && activeActivity == null) { + context.startActivity(intent) + } + } + + fun cancelCaptcha() { + pendingResult.get()?.completeExceptionally(kotlin.coroutines.cancellation.CancellationException("Cancelled by system")) + } + + private const val NOTIFICATION_ID = 9001 + private const val CHANNEL_ID = "captcha_channel" + + private fun showCaptchaNotification(context: Context, redirectUri: String) { + if (MainActivity.isForeground) return // Если юзер уже в приложении — не спамим пушом + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Уведомления защиты (Капча)", + NotificationManager.IMPORTANCE_HIGH + ) + notificationManager.createNotificationChannel(channel) + } + + val openIntent = Intent(context, ManlCaptchaActivity::class.java).apply { + putExtra("redirectUri", redirectUri) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } + + val openPendingIntent = PendingIntent.getActivity( + context, 0, openIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val cancelIntent = Intent(context, CaptchaCancelReceiver::class.java) + val cancelPendingIntent = PendingIntent.getBroadcast( + context, 1, cancelIntent, PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentTitle("Требуется подтверждение капчи") + .setContentText("ВК запросил проверку безопасности. Нажмите для решения.") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(openPendingIntent) + .setAutoCancel(true) + .addAction(0, "Отменить и выключить", cancelPendingIntent) + .build() + + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun clearCaptchaNotification(context: Context) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_ID) + } + + suspend fun solveCaptchaAsync(context: Context, redirectUri: String, sessionToken: String): String { + return captchaMutex.withLock { + isCaptchaPending = true + val deferred = CompletableDeferred>() + // Если предыдущий вызов завис, отменяем его + pendingResult.getAndSet(deferred)?.cancel() + + showCaptchaNotification(context, redirectUri) + + val intent = Intent(context, ManlCaptchaActivity::class.java).apply { + putExtra("redirectUri", redirectUri) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } + pendingIntentToStart = intent + + if (MainActivity.isForeground) { + // Запускаем окно только если интерфейс приложения активен (иначе Android блокирует старт) + context.startActivity(intent) + } + + try { + withTimeout(CAPTCHA_TIMEOUT_MS) { + deferred.await().getOrThrow() + } + } finally { + isCaptchaPending = false + pendingResult.set(null) + pendingIntentToStart = null + clearCaptchaNotification(context) + try { + activeActivity?.finish() + } catch (e: Exception) { + Log.e(TAG, "Error finishing activity: ${e.message}") + } + activeActivity = null + } + } + } + + fun notifyResult(result: Result) { + val deferred = pendingResult.getAndSet(null) ?: return + if (!deferred.isCompleted) { + deferred.complete(result) + } + } +} + +class ManlCaptchaActivity : ComponentActivity() { + private val interceptorJSCode = """ + (function() { + if (window.__wdtt_interceptor_installed) return; + window.__wdtt_interceptor_installed = true; + + const origFetch = window.fetch; + window.fetch = async function() { + const args = arguments; + const url = args[0] || ''; + if (typeof url === 'string' && url.includes('captchaNotRobot.check')) { + const response = await origFetch.apply(this, args); + const clone = response.clone(); + try { + const data = await clone.json(); + if (data.response && data.response.success_token) { + window.WdttCaptcha.onSuccess(data.response.success_token); + } else if (data.error) { + window.WdttCaptcha.onError(JSON.stringify(data.error)); + } + } catch(e) {} + return response; + } + return origFetch.apply(this, args); + }; + + const origXHROpen = XMLHttpRequest.prototype.open; + const origXHRSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this._wdtt_url = url; + return origXHROpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + const xhr = this; + if (xhr._wdtt_url && xhr._wdtt_url.includes('captchaNotRobot.check')) { + xhr.addEventListener('load', function() { + try { + const data = JSON.parse(xhr.responseText); + if (data.response && data.response.success_token) { + window.WdttCaptcha.onSuccess(data.response.success_token); + } else if (data.error) { + window.WdttCaptcha.onError(JSON.stringify(data.error)); + } + } catch(e) {} + }); + } + return origXHRSend.apply(this, arguments); + }; + })(); + """.trimIndent() + + private val hideElementsJSCode = """ + (function() { + // Перехватываем клик по нативному крестику ВК, чтобы закрывать Android Activity и останавливать туннель + document.addEventListener('click', function(e) { + if (e.target.closest('.vkc__ModalCardBase-module__dismiss')) { + window.WdttCaptcha.onCancelAndStop(); + } + }); + + const style = document.createElement('style'); + style.innerHTML = ` + /* Скрываем серый фон, лого, id, ссылку и кнопку АУДИО (крестик ВК оставляем!) */ + .vkc__VisuallyHiddenModalOverlay-module__host, + .vkc__ModalOverlay-module__host, + .vkc__KaleidoscopeScreen-module__logoBlock, + .vkc__KaleidoscopeScreen-module__captchaId, + .vkc__SliderCaptcha-module__descriptionLink, + .vkc__SliderCaptcha-module__changeTypeButton { + display: none !important; + } + + /* Основной фон вокруг окна - прозрачный прозрачным, убираем тени */ + body, html, .vkc__ModalCard-module__host, .vkc__AppRoot-module__host, .vkui__root { + background: transparent !important; + box-shadow: none !important; + } + + /* Сама карточка (окно) - фон чёрный */ + .vkc__ModalCardBase-module__container { + background: #000000 !important; + box-shadow: none !important; + } + + /* Стилизуем крестик ВК: левее, меньше, красный */ + .vkc__ModalCardBase-module__dismiss { + color: #ef4444 !important; + transform: scale(0.8) translateX(-12px) !important; + } + .vkc__ModalCardBase-module__dismiss svg { + fill: #ef4444 !important; + } + + /* Текст "Обновить" и описание капчи делаем белыми */ + .vkc__RefreshButton-module__text, + .vkc__SliderCaptcha-module__description { + color: #ffffff !important; + } + + /* Поле (трек), где нужно потянуть вправо - делаем белым */ + .vkc__SwipeButton-module__track { + background-color: #ffffff !important; + } + + /* Текст "Потяните вправо" внутри трека - делаем синим */ + .vkc__SwipeButton-module__track span { + color: #0000FF !important; + } + `; + document.head.appendChild(style); + })(); + """.trimIndent() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ManlCaptchaWebViewManager.activeActivity = this + MainActivity.isForeground = true // Если появилось само окно капчи, мы тоже считаемся в фореграунде + val redirectUri = intent.getStringExtra("redirectUri") ?: return finish() + + setContent { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + var isLoading by rememberSaveable { mutableStateOf(true) } + + Box { + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.Transparent, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + setBackgroundColor(android.graphics.Color.TRANSPARENT) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + mediaPlaybackRequiresUserGesture = false + loadWithOverviewMode = true + useWideViewPort = true + blockNetworkLoads = false + cacheMode = WebSettings.LOAD_DEFAULT // Включаем кэш для моментальной загрузки! + userAgentString = "Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" + } + + addJavascriptInterface(object { + @JavascriptInterface + fun onSuccess(token: String) { + Log.d("ManlCaptchaWV", "Token received") + ManlCaptchaWebViewManager.notifyResult(Result.success(token)) + finish() + } + @JavascriptInterface + fun onError(err: String) { + Log.e("ManlCaptchaWV", "Error: $err") + ManlCaptchaWebViewManager.notifyResult(Result.failure(Exception("VK Captcha error: ${'$'}err"))) + finish() + } + @JavascriptInterface + fun onCancelAndStop() { + Log.d("ManlCaptchaWV", "User clicked VK Close. Stopping tunnel.") + TunnelManager.stop() + ManlCaptchaWebViewManager.notifyResult(Result.failure(Exception("Cancelled and stopped by user"))) + finish() + } + }, "WdttCaptcha") + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + super.onPageStarted(view, url, favicon) + view?.evaluateJavascript(interceptorJSCode, null) + } + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + view?.evaluateJavascript(interceptorJSCode, null) + view?.evaluateJavascript(hideElementsJSCode, null) + isLoading = false + } + } + webChromeClient = WebChromeClient() + loadUrl(redirectUri) + } + }) + } + + // Индикатор загрузки, пока страница белая/прозрачная + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(48.dp), + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + MainActivity.isForeground = false + if (ManlCaptchaWebViewManager.activeActivity === this) { + ManlCaptchaWebViewManager.activeActivity = null + } + // Мы НЕ отправляем ошибку здесь! + // Если юзер смахнул окно (нажал назад), капча останется висеть в памяти (через пуш). + // Ошибка или Успех отправляются только по явным действиям (крестик, решение, или таймаут 5 мин). + } +} + +class CaptchaCancelReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + TunnelManager.stop() + ManlCaptchaWebViewManager.activeActivity?.finish() + val notifMgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notifMgr.cancel(9001) // NOTIFICATION_ID + } +} diff --git a/app/src/main/java/com/wdtt/client/SecureStringStore.kt b/app/src/main/java/com/wdtt/client/SecureStringStore.kt new file mode 100644 index 0000000..efb51ae --- /dev/null +++ b/app/src/main/java/com/wdtt/client/SecureStringStore.kt @@ -0,0 +1,72 @@ +package com.wdtt.client + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class SecureStringStore(context: Context) { + private val appContext = context.applicationContext + + companion object { + private const val KEY_ALIAS = "wdtt.settings.secrets" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_TAG_BITS = 128 + private const val VERSION_PREFIX = "v1:" + } + + private val keyStore: KeyStore by lazy { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + } + + fun encrypt(value: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + return VERSION_PREFIX + + Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + + ":" + + Base64.encodeToString(encrypted, Base64.NO_WRAP) + } + + fun decrypt(value: String?): String? { + if (value.isNullOrBlank() || !value.startsWith(VERSION_PREFIX)) return null + val payload = value.removePrefix(VERSION_PREFIX) + val parts = payload.split(":", limit = 2) + if (parts.size != 2) return null + + return runCatching { + val iv = Base64.decode(parts[0], Base64.NO_WRAP) + val encrypted = Base64.decode(parts[1], Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + cipher.doFinal(encrypted).toString(Charsets.UTF_8) + }.getOrNull() + } + + private fun getOrCreateKey(): SecretKey { + synchronized(appContext) { + val existing = keyStore.getKey(KEY_ALIAS, null) as? SecretKey + if (existing != null) return existing + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val spec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .build() + generator.init(spec) + return generator.generateKey() + } + } +} diff --git a/app/src/main/java/com/wdtt/client/SettingsStore.kt b/app/src/main/java/com/wdtt/client/SettingsStore.kt new file mode 100644 index 0000000..2bf2d50 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/SettingsStore.kt @@ -0,0 +1,393 @@ +package com.wdtt.client + +import android.content.Context +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SettingsStore(context: Context) { + private val appContext = context.applicationContext + companion object { + private val Context.dataStore by preferencesDataStore("settings") + private val PEER = stringPreferencesKey("peer") + private val VK_HASHES = stringPreferencesKey("vk_hashes") + private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash") + private val WORKERS_PER_HASH = intPreferencesKey("workers_per_hash") + private val PROTOCOL = stringPreferencesKey("protocol") + private val LISTEN_PORT = intPreferencesKey("listen_port") + private val MANUAL_PORTS_ENABLED = booleanPreferencesKey("manual_ports_enabled") + private val SERVER_DTLS_PORT = intPreferencesKey("server_dtls_port") + private val SERVER_WG_PORT = intPreferencesKey("server_wg_port") + private val SNI = stringPreferencesKey("sni") + private val NO_DTLS = booleanPreferencesKey("no_dtls") + private val NO_DNS = booleanPreferencesKey("no_dns") + + private val USER_AGENT = stringPreferencesKey("user_agent") + + private val DEPLOY_IP = stringPreferencesKey("deploy_ip") + private val DEPLOY_LOGIN = stringPreferencesKey("deploy_login") + private val DEPLOY_PASSWORD = stringPreferencesKey("deploy_password") + private val DEPLOY_PASSWORD_ENCRYPTED = stringPreferencesKey("deploy_password_encrypted") + private val DEPLOY_SSH_PORT = stringPreferencesKey("deploy_ssh_port") + private val EXCLUDED_APPS = stringPreferencesKey("excluded_apps") + + private val DETAILED_LOGS = booleanPreferencesKey("detailed_logs") + + // ═══ Пароли и Управление ═══ + private val CONNECTION_PASSWORD = stringPreferencesKey("connection_password") + private val CONNECTION_PASSWORD_ENCRYPTED = stringPreferencesKey("connection_password_encrypted") + private val DEPLOY_MAIN_PASSWORD = stringPreferencesKey("deploy_main_password") + private val DEPLOY_MAIN_PASSWORD_ENCRYPTED = stringPreferencesKey("deploy_main_password_encrypted") + private val DEPLOY_ADMIN_ID = stringPreferencesKey("deploy_admin_id") + private val DEPLOY_ADMIN_ID_ENCRYPTED = stringPreferencesKey("deploy_admin_id_encrypted") + private val DEPLOY_BOT_TOKEN = stringPreferencesKey("deploy_bot_token") + private val DEPLOY_BOT_TOKEN_ENCRYPTED = stringPreferencesKey("deploy_bot_token_encrypted") + + // ═══ Proxy Mode ═══ + private val PROXY_MODE = stringPreferencesKey("proxy_mode") // "tun" or "socks5" + private val PROXY_HOST = stringPreferencesKey("proxy_host") + private val PROXY_PORT = intPreferencesKey("proxy_port") + + // ═══ Captcha Solve Mode ═══ + private val CAPTCHA_MODE = stringPreferencesKey("captcha_mode") // "auto", "wv", or "rjs" + private val CAPTCHA_SOLVE_METHOD = stringPreferencesKey("captcha_solve_method") // "manual" or "auto" + private val CAPTCHA_WBV_SOLVE_METHOD = stringPreferencesKey("captcha_wbv_solve_method") // "manual" or "auto" + + // ═══ VPN Exclusions Mode ═══ + private val IS_WHITELIST = booleanPreferencesKey("is_whitelist") + + // ═══ Theme Mode ═══ + private val THEME_MODE = stringPreferencesKey("theme_mode") // "system", "light", "dark" + private val IS_DYNAMIC_COLOR = booleanPreferencesKey("is_dynamic_color") + private val THEME_PALETTE = stringPreferencesKey("theme_palette") + + private val UPDATE_LAST_CHECK_AT = longPreferencesKey("update_last_check_at") + private val UPDATE_LATEST_VERSION = stringPreferencesKey("update_latest_version") + private val UPDATE_LAST_ERROR = stringPreferencesKey("update_last_error") + private val UPDATE_CHECK_INTERVAL_HOURS = intPreferencesKey("update_check_interval_hours") + private val UPDATE_POSTPONE_UNTIL = longPreferencesKey("update_postpone_until") + private val UPDATE_POSTPONE_VERSION = stringPreferencesKey("update_postpone_version") + private val UPDATE_DIALOG_LAST_SHOWN_VERSION = stringPreferencesKey("update_dialog_last_shown_version") + private val UPDATE_DIALOG_LAST_SHOWN_AT = longPreferencesKey("update_dialog_last_shown_at") + private val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version") + private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action") + private val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at") + } + + private val dataStore = appContext.dataStore + private val secureStore = SecureStringStore(appContext) + + init { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + migrateSecretsToKeystore() + } + } + + val peer: Flow = dataStore.data.map { it[PEER] ?: "" } + val vkHashes: Flow = dataStore.data.map { it[VK_HASHES] ?: "" } + val secondaryVkHash: Flow = dataStore.data.map { it[SECONDARY_VK_HASH] ?: "" } + val workersPerHash: Flow = dataStore.data.map { it[WORKERS_PER_HASH] ?: 16 } + val protocol: Flow = dataStore.data.map { it[PROTOCOL] ?: "udp" } + val listenPort: Flow = dataStore.data.map { it[LISTEN_PORT] ?: 9000 } + val manualPortsEnabled: Flow = dataStore.data.map { it[MANUAL_PORTS_ENABLED] ?: false } + val serverDtlsPort: Flow = dataStore.data.map { it[SERVER_DTLS_PORT] ?: 56000 } + val serverWgPort: Flow = dataStore.data.map { it[SERVER_WG_PORT] ?: 56001 } + val sni: Flow = dataStore.data.map { it[SNI] ?: "" } + val noDns: Flow = dataStore.data.map { it[NO_DNS] ?: false } + val userAgent: Flow = dataStore.data.map { it[USER_AGENT] ?: "" } + + val deployIp: Flow = dataStore.data.map { it[DEPLOY_IP] ?: "" } + val deployLogin: Flow = dataStore.data.map { it[DEPLOY_LOGIN] ?: "" } + val deployPassword: Flow = dataStore.data.map { + readSecret(it, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD) + } + val deploySshPort: Flow = dataStore.data.map { it[DEPLOY_SSH_PORT] ?: "" } + val excludedApps: Flow = dataStore.data.map { it[EXCLUDED_APPS] ?: "" } + + val detailedLogs: Flow = dataStore.data.map { it[DETAILED_LOGS] ?: false } + + // ═══ Пароли и Управление ═══ + val connectionPassword: Flow = dataStore.data.map { + readSecret(it, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD) + } + val deployMainPassword: Flow = dataStore.data.map { + readSecret(it, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD) + } + val deployAdminId: Flow = dataStore.data.map { + readSecret(it, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID) + } + val deployBotToken: Flow = dataStore.data.map { + readSecret(it, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN) + } + + // ═══ Proxy Mode ═══ + val proxyMode: Flow = dataStore.data.map { it[PROXY_MODE] ?: "tun" } + val proxyHost: Flow = dataStore.data.map { it[PROXY_HOST] ?: "127.0.0.1" } + val proxyPort: Flow = dataStore.data.map { it[PROXY_PORT] ?: 1080 } + + // ═══ Captcha Solve Mode ═══ + val captchaMode: Flow = dataStore.data.map { it[CAPTCHA_MODE] ?: "auto" } + val captchaSolveMethod: Flow = dataStore.data.map { it[CAPTCHA_SOLVE_METHOD] ?: "auto" } + val captchaWbvSolveMethod: Flow = dataStore.data.map { it[CAPTCHA_WBV_SOLVE_METHOD] ?: "auto" } + + // ═══ VPN Exclusions Mode ═══ + val isWhitelist: Flow = dataStore.data.map { it[IS_WHITELIST] ?: false } + + // ═══ Theme Mode ═══ + val themeMode: Flow = dataStore.data.map { it[THEME_MODE] ?: "system" } + val isDynamicColor: Flow = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: false } + val themePalette: Flow = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" } + + val updateLastCheckAt: Flow = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L } + val updateLatestVersion: Flow = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" } + val updateLastError: Flow = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" } + val updateCheckIntervalHours: Flow = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS } + val updatePostponeUntil: Flow = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L } + val updatePostponeVersion: Flow = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" } + val updateDialogLastShownVersion: Flow = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" } + val updateDialogLastShownAt: Flow = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_AT] ?: 0L } + val updateDialogLastActionVersion: Flow = dataStore.data.map { it[UPDATE_DIALOG_LAST_ACTION_VERSION] ?: "" } + val updateDialogLastAction: Flow = dataStore.data.map { it[UPDATE_DIALOG_LAST_ACTION] ?: "" } + val updateDialogLastActionAt: Flow = dataStore.data.map { it[UPDATE_DIALOG_LAST_ACTION_AT] ?: 0L } + + suspend fun saveThemeMode(mode: String) { + dataStore.edit { prefs -> + prefs[THEME_MODE] = mode + } + } + + suspend fun saveDynamicColor(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[IS_DYNAMIC_COLOR] = enabled + } + } + + suspend fun saveThemePalette(palette: String) { + dataStore.edit { prefs -> + prefs[THEME_PALETTE] = palette + } + } + + suspend fun saveUpdateState(lastCheckAt: Long, latestVersion: String, error: String) { + dataStore.edit { prefs -> + prefs[UPDATE_LAST_CHECK_AT] = lastCheckAt + prefs[UPDATE_LATEST_VERSION] = latestVersion + prefs[UPDATE_LAST_ERROR] = error + } + } + + suspend fun saveUpdateCheckIntervalHours(hours: Int) { + dataStore.edit { prefs -> + prefs[UPDATE_CHECK_INTERVAL_HOURS] = hours + } + } + + suspend fun saveUpdatePostpone(version: String, until: Long) { + dataStore.edit { prefs -> + prefs[UPDATE_POSTPONE_VERSION] = version + prefs[UPDATE_POSTPONE_UNTIL] = until + } + } + + suspend fun saveUpdateDialogShown(version: String, shownAt: Long) { + dataStore.edit { prefs -> + prefs[UPDATE_DIALOG_LAST_SHOWN_VERSION] = version + prefs[UPDATE_DIALOG_LAST_SHOWN_AT] = shownAt + } + } + + suspend fun saveUpdateDialogAction(version: String, action: String, actedAt: Long) { + dataStore.edit { prefs -> + prefs[UPDATE_DIALOG_LAST_ACTION_VERSION] = version + prefs[UPDATE_DIALOG_LAST_ACTION] = action + prefs[UPDATE_DIALOG_LAST_ACTION_AT] = actedAt + } + } + + suspend fun save( + peer: String, + vkHashes: String, + secondaryVkHash: String, + workersPerHash: Int, + protocol: String, + listenPort: Int, + sni: String = "", + noDns: Boolean = false + ) { + dataStore.edit { prefs -> + prefs[PEER] = peer + prefs[VK_HASHES] = vkHashes + prefs[SECONDARY_VK_HASH] = secondaryVkHash + prefs[WORKERS_PER_HASH] = workersPerHash + prefs[PROTOCOL] = protocol + prefs[LISTEN_PORT] = listenPort + prefs[SNI] = sni + prefs[NO_DNS] = noDns + } + } + + suspend fun saveManualPortsEnabled(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[MANUAL_PORTS_ENABLED] = enabled + } + } + + suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) { + dataStore.edit { prefs -> + prefs[SERVER_DTLS_PORT] = serverDtlsPort + prefs[SERVER_WG_PORT] = serverWgPort + prefs[LISTEN_PORT] = listenPort + } + } + + suspend fun saveUserAgent(ua: String) { + dataStore.edit { prefs -> + prefs[USER_AGENT] = ua + } + } + + suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) { + dataStore.edit { prefs -> + prefs[DEPLOY_IP] = ip + prefs[DEPLOY_LOGIN] = login + prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass) + prefs[DEPLOY_SSH_PORT] = sshPort + } + } + + suspend fun saveExcludedApps(packages: String) { + dataStore.edit { prefs -> + prefs[EXCLUDED_APPS] = packages + } + } + + suspend fun saveDetailedLogs(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[DETAILED_LOGS] = enabled + } + } + + // ═══ Сохранение пароля подключения ═══ + suspend fun saveConnectionPassword(password: String) { + dataStore.edit { prefs -> + prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password) + } + } + + // ═══ Сохранение секретов деплоя ═══ + suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) { + dataStore.edit { prefs -> + prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass) + prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId) + prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken) + prefs[DEPLOY_SSH_PORT] = sshPort + } + } + + // ═══ Сохранение proxy mode ═══ + suspend fun saveProxyMode(mode: String, host: String, port: Int) { + dataStore.edit { prefs -> + prefs[PROXY_MODE] = mode + prefs[PROXY_HOST] = host + prefs[PROXY_PORT] = port + } + } + + // ═══ Сохранение режима обхода капчи ═══ + suspend fun saveCaptchaMode(mode: String) { + dataStore.edit { prefs -> + prefs[CAPTCHA_MODE] = mode + } + } + + suspend fun saveCaptchaSolveMethod(method: String) { + dataStore.edit { prefs -> + prefs[CAPTCHA_SOLVE_METHOD] = method + } + } + + suspend fun saveWbvCaptchaSolveMethod(method: String) { + dataStore.edit { prefs -> + prefs[CAPTCHA_WBV_SOLVE_METHOD] = method + if (prefs[CAPTCHA_MODE] == "wv") { + prefs[CAPTCHA_SOLVE_METHOD] = method + } + } + } + + // ═══ Сохранение режима списка (ЧС/БС) ═══ + suspend fun saveIsWhitelist(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[IS_WHITELIST] = enabled + } + } + + // Атомарное сохранение обоих параметров для исключения гонки при перезагрузке + suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) { + dataStore.edit { prefs -> + prefs[EXCLUDED_APPS] = packages + prefs[IS_WHITELIST] = isWhitelist + } + } + + private suspend fun migrateSecretsToKeystore() { + dataStore.edit { prefs -> + prefs.migrateSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD) + prefs.migrateSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD) + prefs.migrateSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD) + prefs.migrateSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID) + prefs.migrateSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN) + } + } + + private fun readSecret( + prefs: Preferences, + encryptedKey: Preferences.Key, + legacyKey: Preferences.Key + ): String { + return secureStore.decrypt(prefs[encryptedKey]) ?: prefs[legacyKey] ?: "" + } + + private fun MutablePreferences.putSecret( + encryptedKey: Preferences.Key, + legacyKey: Preferences.Key, + value: String + ) { + if (value.isBlank()) { + remove(encryptedKey) + remove(legacyKey) + } else { + this[encryptedKey] = secureStore.encrypt(value) + remove(legacyKey) + } + } + + private fun MutablePreferences.migrateSecret( + encryptedKey: Preferences.Key, + legacyKey: Preferences.Key + ) { + val legacyValue = this[legacyKey] + val encryptedValue = this[encryptedKey] + if (!encryptedValue.isNullOrBlank()) { + remove(legacyKey) + return + } + if (!legacyValue.isNullOrBlank()) { + runCatching { + this[encryptedKey] = secureStore.encrypt(legacyValue) + remove(legacyKey) + } + } + } +} diff --git a/app/src/main/java/com/wdtt/client/Theme.kt b/app/src/main/java/com/wdtt/client/Theme.kt new file mode 100644 index 0000000..08ffa4a --- /dev/null +++ b/app/src/main/java/com/wdtt/client/Theme.kt @@ -0,0 +1,281 @@ +package com.wdtt.client + +import android.os.Build +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat + +// ═══ Inter Font Family ═══ +val InterFontFamily = FontFamily( + Font(R.font.inter_regular, FontWeight.Normal), + Font(R.font.inter_medium, FontWeight.Medium), + Font(R.font.inter_semibold, FontWeight.SemiBold), + Font(R.font.inter_bold, FontWeight.Bold), +) + +// ═══ Типография на Inter ═══ +val WDTTTypography = Typography( + displayLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp), + displayMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, lineHeight = 52.sp), + displaySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 44.sp), + headlineLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp), + headlineMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp), + headlineSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp), + titleLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp), + titleMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp), + titleSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp), + bodyLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp), + bodyMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp), + bodySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp), + labelLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp), + labelMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp), + labelSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp), +) + +// ═══ Светлая палитра — «Раф на кокосовом молоке» ═══ +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF6D4C41), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFD7CCC8), + onPrimaryContainer = Color(0xFF3E2723), + secondary = Color(0xFF8D6E63), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFEFEBE9), + onSecondaryContainer = Color(0xFF4E342E), + tertiary = Color(0xFF795548), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFBCAAA4), + onTertiaryContainer = Color(0xFF3E2723), + background = Color(0xFFF2F0EC), + onBackground = Color(0xFF1C1B1A), + surface = Color(0xFFFAF8F4), + onSurface = Color(0xFF1C1B1A), + surfaceVariant = Color(0xFFEFEBE9), + onSurfaceVariant = Color(0xFF5D4037), + outline = Color(0xFFBCAAA4), + outlineVariant = Color(0xFFD7CCC8), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + inverseSurface = Color(0xFF322F2D), + inverseOnSurface = Color(0xFFF5F0EB), + inversePrimary = Color(0xFFD7CCC8), + surfaceTint = Color(0xFF6D4C41), +) + +// ═══ Тёмная палитра — «Эспрессо» ═══ +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFFD7CCC8), + onPrimary = Color(0xFF3E2723), + primaryContainer = Color(0xFF5D4037), + onPrimaryContainer = Color(0xFFEFEBE9), + secondary = Color(0xFFBCAAA4), + onSecondary = Color(0xFF3E2723), + secondaryContainer = Color(0xFF4E342E), + onSecondaryContainer = Color(0xFFEFEBE9), + tertiary = Color(0xFFA1887F), + onTertiary = Color(0xFF3E2723), + tertiaryContainer = Color(0xFF5D4037), + onTertiaryContainer = Color(0xFFEFEBE9), + background = Color(0xFF1A1614), + onBackground = Color(0xFFEDE0D4), + surface = Color(0xFF211D1B), + onSurface = Color(0xFFEDE0D4), + surfaceVariant = Color(0xFF2C2624), + onSurfaceVariant = Color(0xFFD7CCC8), + outline = Color(0xFF8D6E63), + outlineVariant = Color(0xFF4E342E), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + inverseSurface = Color(0xFFEDE0D4), + inverseOnSurface = Color(0xFF322F2D), + inversePrimary = Color(0xFF6D4C41), + surfaceTint = Color(0xFFD7CCC8), +) + +private val IndigoLightColorScheme = lightColorScheme( + primary = Color(0xFF5B588D), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFE2DFFF), + onPrimaryContainer = Color(0xFF1A1744), + secondary = Color(0xFF5B588D), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE2DFFF), + onSecondaryContainer = Color(0xFF1A1744), + background = Color(0xFFFBF8FF), + onBackground = Color(0xFF1B1B1F), + surface = Color(0xFFF6F3FA), + onSurface = Color(0xFF1B1B1F), + surfaceVariant = Color(0xFFE4E1EC), + onSurfaceVariant = Color(0xFF47464F), + outline = Color(0xFF787680), + outlineVariant = Color(0xFFC8C5D0), +) + +private val IndigoDarkColorScheme = darkColorScheme( + primary = Color(0xFFC4C0FF), + onPrimary = Color(0xFF2D2A5B), + primaryContainer = Color(0xFF434073), + onPrimaryContainer = Color(0xFFE2DFFF), + secondary = Color(0xFFC4C0FF), + onSecondary = Color(0xFF2D2A5B), + secondaryContainer = Color(0xFF434073), + onSecondaryContainer = Color(0xFFE2DFFF), + background = Color(0xFF131316), + onBackground = Color(0xFFE4E1E6), + surface = Color(0xFF1B1B1F), + onSurface = Color(0xFFC8C5D0), + surfaceVariant = Color(0xFF47464F), + onSurfaceVariant = Color(0xFFC8C5D0), + outline = Color(0xFF918F9A), + outlineVariant = Color(0xFF47464F), +) + +private val ForestLightColorScheme = lightColorScheme( + primary = Color(0xFF5F5D68), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFE5E0F0), + onPrimaryContainer = Color(0xFF1C1A23), + secondary = Color(0xFF5F5D68), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE5E0F0), + onSecondaryContainer = Color(0xFF1C1A23), + background = Color(0xFFFCF8FF), + onBackground = Color(0xFF1D1B20), + surface = Color(0xFFF7F2FA), + onSurface = Color(0xFF1D1B20), + surfaceVariant = Color(0xFFE6E0E9), + onSurfaceVariant = Color(0xFF48454E), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), +) + +private val ForestDarkColorScheme = darkColorScheme( + primary = Color(0xFFC8C4D3), + onPrimary = Color(0xFF312F38), + primaryContainer = Color(0xFF474550), + onPrimaryContainer = Color(0xFFE5E0F0), + secondary = Color(0xFFC8C4D3), + onSecondary = Color(0xFF312F38), + secondaryContainer = Color(0xFF474550), + onSecondaryContainer = Color(0xFFE5E0F0), + background = Color(0xFF141318), + onBackground = Color(0xFFE6E1E5), + surface = Color(0xFF1D1B20), + onSurface = Color(0xFFCAC4D0), + surfaceVariant = Color(0xFF48454E), + onSurfaceVariant = Color(0xFFCAC4D0), + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF48454E), +) + +private fun getAppColorScheme(palette: String, isDark: Boolean): androidx.compose.material3.ColorScheme { + return when (palette) { + "espresso" -> if (isDark) DarkColorScheme else LightColorScheme + "forest" -> if (isDark) ForestDarkColorScheme else ForestLightColorScheme + else -> if (isDark) IndigoDarkColorScheme else IndigoLightColorScheme + } +} + +// ═══ Расширенные цвета для кастомных элементов ═══ +object WDTTColors { + // Статус: подключено + val connected = Color(0xFF4CAF50) + val connectedContainer = Color(0xFF4CAF50).copy(alpha = 0.12f) + val onConnected = Color(0xFF1B5E20) + + val connectedDark = Color(0xFF81C784) + val connectedContainerDark = Color(0xFF81C784).copy(alpha = 0.15f) + val onConnectedDark = Color(0xFFC8E6C9) + + // Статус: предупреждение + val warning = Color(0xFFFFA726) + val warningDark = Color(0xFFFFCC80) + + // Терминал (логи) + val terminalBg = Color(0xFF1A1A2E) + val terminalBgDark = Color(0xFF0D0D1A) + val terminalText = Color(0xFFE0E0E0) + val terminalGreen = Color(0xFF4CAF50) + val terminalBlue = Color(0xFF42A5F5) + val terminalRed = Color(0xFFEF5350) + val terminalYellow = Color(0xFFFFC107) + val terminalCounter = Color(0xFF1E88E5) + + // GitHub + val github = Color(0xFF24292E) + val githubDark = Color(0xFF333C47) + + // Donate + val donate = Color(0xFF8B3FFD) +} + +@Composable +fun WDTTTheme( + themeMode: String = "system", + dynamicColor: Boolean = false, + themePalette: String = "indigo", + content: @Composable () -> Unit +) { + val darkTheme = when (themeMode) { + "dark" -> true + "light" -> false + else -> isSystemInDarkTheme() + } + + val colorScheme = when { + dynamicColor && !darkTheme && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + dynamicLightColorScheme(context) + } + else -> getAppColorScheme(themePalette, darkTheme) + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val navigationBarColor = if (darkTheme) { + Color.Transparent + } else { + lerp(colorScheme.background, colorScheme.surface, 0.55f) + } + window.statusBarColor = Color.Transparent.toArgb() + window.navigationBarColor = navigationBarColor.toArgb() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false + } + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = WDTTTypography, + content = content + ) +} diff --git a/app/src/main/java/com/wdtt/client/TunnelManager.kt b/app/src/main/java/com/wdtt/client/TunnelManager.kt new file mode 100644 index 0000000..71693af --- /dev/null +++ b/app/src/main/java/com/wdtt/client/TunnelManager.kt @@ -0,0 +1,836 @@ +package com.wdtt.client + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.withContext +import java.io.File + +import androidx.compose.runtime.Stable + +@Stable +data class LogEntry( + val key: String, + val message: String, + val count: Int = 1, + val priority: Int = 99, // 0 - Creds, 1 - DTLS, 2 - Ready, 3 - Stats, 99 - Errors/Other + val isError: Boolean = false +) + +object TunnelManager { + // 100% защита от утечек: единый управляемый глобальный Scope + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private var process: Process? = null + private var readerJob: Job? = null + private var watchdogJob: Job? = null + private var wgHelper: WireGuardHelper? = null + + // Error counters for circuit breaker + private var floodCount = 0 + private var mismatchCount = 0 + private var refusedCount = 0 + private var currentHashErrorCount = 0 + private var wrapAuthTimeoutCount = 0 + private var processStartedAtMs = 0L + private var lastActiveAtMs = 0L + private var activeHashIndex = 0 // 0: primary, 1: secondary + private var currentParams: TunnelParams? = null + private var lastContext: Context? = null + private var forceRegenerateUA = false // принудительная перегенерация UA при ошибках + private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs" + private var currentCaptchaSolveMethod = "auto" // "manual" или "auto" + + val running = MutableStateFlow(false) + val logs = MutableStateFlow>(emptyList()) + val unreadErrorCount = MutableStateFlow(0) + val config = MutableStateFlow(null) + val stats = MutableStateFlow("Ожидание данных...") + val activeWorkers = MutableStateFlow(0) + + val cooldownSeconds = MutableStateFlow(0) + private var cooldownJob: Job? = null + + fun clearUnreadErrors() { + unreadErrorCount.value = 0 + } + + // Добавляем лог с Деплоя + fun addDeployErrorLog(message: String) { + val hash = message.hashCode().toString() + updateLog("deploy_err_$hash", "[ДЕПЛОЙ] $message", 99, true) + } + + fun addDeploySuccessLog(message: String) { + val hash = message.hashCode().toString() + System.currentTimeMillis() + updateLog("deploy_ok_$hash", message, 2, false) + } + + private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) { + if (isError) { + val list = logs.value + if (list.none { it.key == key }) { + unreadErrorCount.value++ + } + } + logs.update { currentList -> + val current = currentList.toMutableList() + val index = current.indexOfFirst { it.key == key } + + if (index != -1) { + // Обновляем текст и счётчик НА МЕСТЕ + val entry = current[index] + current[index] = entry.copy(count = entry.count + 1, message = message, priority = priority, isError = isError) + } else { + // Новая запись + current.add(LogEntry(key, message, 1, priority, isError)) + } + + // Сортировка: по приоритету (наименьший сверху), затем ошибки + // Приоритеты: Основной=1, Капча=5, Готов=10, Статы=100, Ошибки=200 + val sorted = current.sortedWith(compareBy({ it.priority }, { if (it.isError) 1 else 0 }, { it.key })) + + // Лимит 100 записей + if (sorted.size > 100) sorted.takeLast(100) else sorted + } + } + + fun start(context: Context, params: TunnelParams, isSwitching: Boolean = false) { + if (running.value && !isSwitching) return + + val appContext = context.applicationContext // Защита от Memory Leak + + if (!isSwitching) { + clearLogs() + config.value = null + stats.value = "Ожидание данных..." + floodCount = 0 + mismatchCount = 0 + refusedCount = 0 + currentHashErrorCount = 0 + wrapAuthTimeoutCount = 0 + processStartedAtMs = 0L + lastActiveAtMs = 0L + activeHashIndex = 0 + currentParams = params + lastContext = appContext + forceRegenerateUA = false + currentCaptchaMode = params.captchaMode + currentCaptchaSolveMethod = params.captchaSolveMethod + } + + wgHelper = WireGuardHelper(appContext) + + scope.launch { + try { + val targetHash = if (activeHashIndex == 0) params.vkHashes else params.secondaryVkHash + + // Robust hash parsing: split by comma, newline, or whitespace + val hashList = targetHash + .split(Regex("[,\\s\\n]+")) + .map { it.trim() } + .filter { it.isNotEmpty() } + .take(3) + + if (hashList.isEmpty()) { + updateLog("hash_error", "Ошибка: Хеш не указан", 99, true) + running.value = false + return@launch + } + if (params.connectionPassword.isBlank()) { + updateLog("password_error", "Ошибка: пароль подключения не указан", 99, true) + running.value = false + return@launch + } + + val hashCount = hashList.size.coerceIn(1, 3) + val totalWorkers = params.workersPerHash.coerceIn(1, 128) // Максимум ограничивается UI (80), но тут ставим хард-лимит побольше на случай запаса + + val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной" + updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1) + + + // CRITICAL FIX: Use nativeLibraryDir with extractNativeLibs="true" + val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so" + val binaryFile = File(binaryPath) + + if (!binaryFile.exists()) { + updateLog("binary_error", "Ошибка: Бинарный файл не найден", 99, true) + return@launch + } + + val cmd = mutableListOf( + binaryPath, + "-peer", params.peer, + "-vk", hashList.joinToString(","), + "-n", totalWorkers.toString(), + "-listen", "127.0.0.1:${params.port}" + ) + + val androidId = android.provider.Settings.Secure.getString(context.contentResolver, android.provider.Settings.Secure.ANDROID_ID) ?: "unknown" + cmd.add("-device-id") + cmd.add(androidId) + + cmd.add("-password") + cmd.add(params.connectionPassword) + + // Captcha mode: wv или rjs + cmd.add("-captcha-mode") + cmd.add(params.captchaMode) + + val pb = ProcessBuilder(cmd) + pb.directory(context.filesDir) // Устанавливаем рабочую директорию + pb.redirectErrorStream(true) + + // Set LD_LIBRARY_PATH + val env = pb.environment() + env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir + + process = pb.start() + processStartedAtMs = System.currentTimeMillis() + wrapAuthTimeoutCount = 0 + lastActiveAtMs = 0L + running.value = true + startLogReader() + startWatchdog(appContext, params) + + } catch (e: Exception) { + updateLog("critical_start_error", "Критическая ошибка запуска: ${e.message}", 99, true) + e.printStackTrace() + running.value = false + } + } + } + + private fun startLogReader() { + readerJob = scope.launch { + val reader = process?.inputStream?.bufferedReader() ?: return@launch + var collectingConfig = false + val configBuilder = StringBuilder() + + try { + var lastResetTime = System.currentTimeMillis() + + reader.forEachLine { line -> + // Периодический сброс счетчиков ошибок (раз в 60 сек) + val now = System.currentTimeMillis() + if (now - lastResetTime > 60000) { + refusedCount = 0 + floodCount = 0 + mismatchCount = 0 + currentHashErrorCount = 0 + lastResetTime = now + } + + // Чистим лог от даты из Go (например, "2023/10/24 12:34:56.123456 [ВОРКЕР...") + val msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "") + val lineTrim = msgPrefixReplaced.trim() + + val isError = lineTrim.contains("Ошибка", true) || lineTrim.contains("error", true) || lineTrim.contains("FAIL", true) || lineTrim.contains("timeout", true) || lineTrim.contains("refused", true) || lineTrim.contains("FATAL_AUTH", true) + + // 0. FATAL AUTH — мгновенная остановка + if (lineTrim.contains("FATAL_AUTH")) { + val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) || + lineTrim.contains("WRAP_AUTH_TIMEOUT", true) + if (isWrapHandshakeTimeout) { + if (activeWorkers.value > 0) { + wrapAuthTimeoutCount = 0 + updateLog( + "wrap_timeout_recovered", + "[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем", + 50, + true + ) + } else { + wrapAuthTimeoutCount++ + updateLog( + "wrap_timeout_wait", + "[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)", + 50, + true + ) + } + return@forEachLine + } + + val reason = when { + lineTrim.contains("неверный пароль") -> "Неверный пароль подключения" + lineTrim.contains("истёк") -> "Срок действия пароля истёк" + lineTrim.contains("другому устройству") -> "Пароль привязан к другому устройству" + else -> "Ошибка авторизации" + } + handleCriticalError("\uD83D\uDD12 $reason. Воркеры остановлены.") + return@forEachLine + } + + // 0a. WRAP auth timeout — не фатально для отдельного воркера. + // Критичным считаем только ситуацию, когда за стартовое окно не поднялся ни один поток. + if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) { + if (activeWorkers.value > 0) { + wrapAuthTimeoutCount = 0 + updateLog( + "wrap_timeout_recovered", + "[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем", + 50, + true + ) + } else { + wrapAuthTimeoutCount++ + updateLog( + "wrap_timeout_wait", + "[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)", + 50, + true + ) + } + return@forEachLine + } + + // 0b. CAPTCHA_SOLVE — запрос от Go для WBV-режима. + if (lineTrim.startsWith("CAPTCHA_SOLVE|")) { + val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|") + val parts = payload.split("|", limit = 3) + when (parts.size) { + 3 -> { + val requestMode = parts[0] + val redirectUri = parts[1] + val sessionToken = parts[2] + scope.launch { + handleCaptchaSolve(requestMode, redirectUri, sessionToken) + } + } + 2 -> { + val redirectUri = parts[0] + val sessionToken = parts[1] + scope.launch { + handleCaptchaSolve("selected", redirectUri, sessionToken) + } + } + else -> { + writeCaptchaResult("error:invalid CAPTCHA_SOLVE format") + } + } + return@forEachLine + } + + // 1. ПРЕДОХРАНИТЕЛЬ (Circuit Breaker) + if (isError) { + when { + lineTrim.contains("Flood control", true) -> { + floodCount++ + if (floodCount >= 5) { + handleCriticalError("Flood Control (ВК ограничил ваш IP). Попробуйте позже.") + return@forEachLine + } + } + lineTrim.contains("ip mismatch", true) -> { + mismatchCount++ + if (mismatchCount >= 5) { + handleCriticalError("IP Mismatch (IP утерян). Попробуйте переподключиться.") + return@forEachLine + } + } + lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> { + // Огромный лимит, потому что каждый воркер кидает эту ошибку при смене сети + refusedCount++ + if (refusedCount >= 400) { + handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.") + return@forEachLine + } + } + lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> { + currentHashErrorCount++ + // Нужно больше попыток, так как 1 воркер может спамить + if (currentHashErrorCount >= 10) { + handleHashError() + return@forEachLine + } + } + } + } + + // 1. Статистика (Обновляемая строка) + if (lineTrim.contains("[СТАТИСТИКА]")) { + val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim() + stats.value = msg + + val match = Regex("Активных:\\s*(\\d+)").find(msg) + if (match != null) { + val active = match.groupValues[1].toIntOrNull() ?: 0 + activeWorkers.value = active + if (active > 0) { + lastActiveAtMs = now + wrapAuthTimeoutCount = 0 + } + } + + updateLog("stats", "[СТАТИСТИКА] $msg", 3, false) + return@forEachLine + } + + // 2. Этапы подключения и Ошибки + when { + + // ═══ Авто-оркестратор капчи ═══ + lineTrim.contains("[КАПЧА] AUTO:") -> { + var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim() + text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() + + val isErr = text.contains("ошибка", true) || + text.contains("timeout", true) || + text.contains("не решил", true) + val stableKey = when { + text.contains("старт") -> "captcha_auto_1" + text.contains("Go v2") && text.contains("2 попыт") -> "captcha_auto_2" + text.contains("WBV Auto попытка") -> "captcha_auto_3" + text.contains("финальная") -> "captcha_auto_4" + text.contains("ручной WebView") -> "captcha_auto_5" + text.contains("решил") || text.contains("решила") -> "captcha_auto_done" + else -> "captcha_auto_${text.take(18).hashCode()}" + } + updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr) + } + + // ═══ RJS капча логи: [КАПЧА RJS] со стабильными ключами-шагами ═══ + lineTrim.contains("[КАПЧА] RJS:") -> { + // Удаляем тайминги и лишние скобки: (123мс), (diff=2), (общее время...) + var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim() + text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() + + val stableKey = when { + text.contains("Загрузка") || text.contains("fetch") -> "captcha_rjs_1" + text.contains("PoW") -> "captcha_rjs_2" + text.contains("осматривает") || text.contains("человек") -> "captcha_rjs_3" + text.contains("captchaNotRobot") || text.contains("Отправка") -> "captcha_rjs_4" + text.contains("endSession") -> "captcha_rjs_5" + text.contains("решена") -> "captcha_rjs_6" + else -> "captcha_rjs_${text.take(15).hashCode()}" + } + updateLog(stableKey, "[КАПЧА RJS] $text", 5, false) + } + + // ═══ WV капча логи от Go: [КАПЧА WBV] со стабильными ключами ═══ + lineTrim.contains("[КАПЧА] WBV:") -> { + var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim() + text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() + + val isErr = text.contains("Ошибка") + val stableKey = when { + text.contains("Запрос") -> "captcha_wv_step_2" // Step 2 (после создания WV) + text.contains("Токен") -> "captcha_wv_step_5" // Step 5 (перед уничтожением) + isErr -> "captcha_wv_err" + else -> "captcha_wv_go_other" + } + updateLog(stableKey, "[КАПЧА WBV] $text", 5, isErr) + } + + lineTrim.contains("Старт") || lineTrim.contains("Ожидайте") -> + updateLog("creds_start", "[ВК] Получение учетных данных...", 2, false) + lineTrim.contains("Креды получены") -> + updateLog("creds_lifetime", lineTrim, 2, false) + lineTrim.contains("Креды OK") || lineTrim.contains("Первые креды") -> + updateLog("creds_ok", "[ВК] Учетные данные проверены ✓", 2, false) + lineTrim.contains("Решаю VK Smart Captcha") -> + updateLog("captcha_start", "[КАПЧА] Решение капчи...", 5, false) + lineTrim.contains("Smart Captcha решена") -> + updateLog("captcha_done", "[КАПЧА] Капча решена ✓", 5, false) + lineTrim.contains("капча не решена") || lineTrim.contains("ошибка решения капчи") -> + updateLog("captcha_failed", "[КАПЧА] Ошибка решения капчи", 5, true) + lineTrim.contains("[WRAP]") -> { + val text = lineTrim.substringAfter("[WRAP]").trim() + updateLog("wrap_status", "[WRAP] $text", 1, false) + } + lineTrim.contains("[TURN]") -> { + val text = lineTrim.substringAfter("[TURN]").trim() + val turnError = text.contains("Ошибка", true) || + text.contains("не удалось", true) || + text.contains("неполный ответ", true) + updateLog("turn_${text.take(32).hashCode()}", "[TURN] $text", 2, turnError) + } + lineTrim.contains("Relay:") -> + updateLog("dtls_start", "[DTLS] Рукопожатие (Handshake)...", 1, false) + lineTrim.contains("DTLS ОК") -> + updateLog("dtls_ok", "[DTLS] Соединение установлено ✓", 1, false) + lineTrim.contains("Активна ✓") -> + updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false) + + // Ошибки (в конец) + isError -> { + // Формируем уникальный ключ ошибки на основе её типа (группируем по типу ошибки) + val errorKey = when { + lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns" + lineTrim.contains("connection refused") -> "err_conn_refused" + lineTrim.contains("timeout") -> "err_timeout" + lineTrim.contains("кредов") -> "err_creds" + lineTrim.contains("DTLS") -> "err_dtls" + else -> "general_error_" + lineTrim.take(15).hashCode() + } + val errorMessage = if (errorKey == "err_vk_dns") { + "[СЕТЬ] DNS до VK недоступен: login.vk.ru" + } else { + lineTrim + } + updateLog(errorKey, errorMessage, 99, true) + } + } + + // 3. Обработка конфига (Скрываем от пользователя) + if (line.contains("╔") && line.contains("WireGuard")) { + collectingConfig = true + configBuilder.clear() + return@forEachLine + } else if (collectingConfig) { + if (line.contains("╚")) { + collectingConfig = false + val configStr = configBuilder.toString().trim() + config.value = configStr + + scope.launch(Dispatchers.Main) { + try { + wgHelper?.startTunnel(configStr) + } catch (e: Exception) { + updateLog("vpn_start_error", "Ошибка запуска VPN: ${e.readableMessage()}", 99, true) + } + } + } else if (line.contains("║")) { + val content = line.replace("║", "").trim() + if (content.isNotEmpty()) { + configBuilder.appendLine(content) + } + } + return@forEachLine + } + } + } catch (e: Exception) { + updateLog("sys_error", "Процесс остановлен: ${e.message}", -1, true) + } finally { + running.value = false + process = null + } + } + } + + private fun handleCriticalError(message: String) { + updateLog("circuit_breaker", "[СТОП] $message", -1, true) + stop() + } + + private fun handleHashError() { + val params = currentParams ?: return + val context = lastContext ?: return + + currentHashErrorCount = 0 + forceRegenerateUA = true // Перегенерируем UA при следующих ошибках + + if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) { + updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true) + activeHashIndex = 1 + stopOnlyProcess() + start(context, params, isSwitching = true) + } else { + val msg = if (activeHashIndex == 1) "Запасной хеш тоже мертв. Отключение." else "Хеш умер, запасного нет. Отключение." + handleCriticalError(msg) + } + } + + // ==================== WATCHDOG ==================== + // Проверяет, жив ли Go-процесс. Если умер — перезапускает. + // Если процесс жив, но 0 воркеров уже 30 сек — тоже перезапуск (зомби). + private fun startWatchdog(context: Context, params: TunnelParams) { + watchdogJob?.cancel() + watchdogJob = scope.launch { + var zeroWorkersSince = 0L + delay(10_000) // Даём 10 сек на старт + while (isActive && running.value) { + val proc = process + if (proc == null || !proc.isAlive) { + // Go-процесс мёртв! + updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true) + activeWorkers.value = 0 + forceRegenerateUA = true + killProcess() + delay(2000) + if (running.value) { + start(context, params, isSwitching = true) + } + return@launch // startWatchdog будет перезапущен из start() + } + + // Детекция зомби: процесс жив, но 0 воркеров + val workers = activeWorkers.value + if (workers <= 0) { + if (zeroWorkersSince == 0L) { + zeroWorkersSince = System.currentTimeMillis() + } else if ( + wrapAuthTimeoutCount >= 3 && + processStartedAtMs > 0L && + System.currentTimeMillis() - processStartedAtMs > 30_000 && + lastActiveAtMs == 0L && + !ManlCaptchaWebViewManager.isCaptchaPending + ) { + handleCriticalError("\uD83D\uDD12 Неверный пароль подключения или несовместимый WRAP. Воркеры остановлены.") + return@launch + } else if (System.currentTimeMillis() - zeroWorkersSince > 90_000 && !ManlCaptchaWebViewManager.isCaptchaPending) { + updateLog("watchdog", "⚠ Зомби-процесс (0 воркеров 90с). Перезапуск...", 50, true) + forceRegenerateUA = true + killProcess() + delay(2000) + if (running.value) { + start(context, params, isSwitching = true) + } + return@launch + } + } else { + zeroWorkersSince = 0L + } + + delay(5_000) + } + } + } + + fun restartTransport() { + val params = currentParams ?: return + val context = lastContext ?: return + updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false) + killProcess() // Только убиваем процесс, running не трогаем! + scope.launch { + delay(1500) + start(context, params, isSwitching = true) + } + } + + fun pause() { + if (!running.value) return + killProcess() // Не ставим running=false, чтоб сервис не умер + activeWorkers.value = 0 + } + + fun resume() { + if (currentParams != null && lastContext != null) { + scope.launch { + start(lastContext!!, currentParams!!, isSwitching = true) + } + } + } + + // Убивает процесс без изменения running + private fun killProcess() { + watchdogJob?.cancel() + readerJob?.cancel() + val proc = process + process = null + if (proc != null) { + try { proc.destroy() } catch (_: Exception) {} + // Даём 500мс на graceful shutdown + try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {} + if (proc.isAlive) { + try { proc.destroyForcibly() } catch (_: Exception) {} + try { proc.waitFor(1000, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {} + } + } + } + + private fun stopOnlyProcess() { + killProcess() + running.value = false + } + + private fun log(message: String) { + updateLog("internal_${message.hashCode()}", message, 50, false) + } + + fun stop() { + scope.launch(Dispatchers.Main) { + wgHelper?.stopTunnel() + } + killProcess() + running.value = false + activeWorkers.value = 0 + currentParams = null + ManlCaptchaWebViewManager.cancelCaptcha() + } + + // Suspend-версия: гарантирует что процесс мёртв и порт свободен + suspend fun stopAndWait() { + // Сначала останавливаем WireGuard и ждём завершения + withContext(Dispatchers.Main) { + wgHelper?.stopTunnel() + } + withContext(Dispatchers.IO) { + killProcess() + running.value = false + activeWorkers.value = 0 + currentParams = null + ManlCaptchaWebViewManager.cancelCaptcha() + // Ждём освобождения порта 9000 (до 3 секунд) + repeat(30) { + try { + java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() } + return@withContext // Порт свободен! + } catch (_: Exception) { + delay(100) + } + } + } + } + + fun reloadWireGuard() { + if (running.value) { + scope.launch { + wgHelper?.reloadTunnel() + } + } + } + + // ==================== CAPTCHA SOLVER (WebView Mode) ==================== + + /** + * Вызывается при получении CAPTCHA_SOLVE от Go-процесса. + * auto: одна короткая скрытая попытка для Go-оркестратора. + * manual: сразу видимый WebView. + * selected: старое поведение из UI, когда пользователь сам выбрал режим. + * Результат ВСЕГДА отправляется обратно в Go через writeCaptchaResult. + */ + private suspend fun handleCaptchaSolve(requestMode: String, redirectUri: String, sessionToken: String) { + val ctx = lastContext ?: run { + writeCaptchaResult("error:context is null") + return + } + val mode = requestMode.lowercase() + + try { + val token = when (mode) { + "auto" -> solveSingleAutoWebViewCaptcha(redirectUri, sessionToken) + "manual" -> { + updateLog("captcha_wv_step_1", "[КАПЧА WBV] Создание ручного WebView...", 5, false) + ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken) + } + else -> { + if (currentCaptchaSolveMethod == "auto") { + solveAutoWebViewCaptcha(ctx, redirectUri, sessionToken) + } else { + updateLog("captcha_wv_step_1", "[КАПЧА WBV] Создание ручного WebView...", 5, false) + ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken) + } + } + } + updateLog("captcha_wv_step_4", "[КАПЧА WBV] Капча решена ✓", 5, false) + writeCaptchaResult(token) + } catch (e: IllegalStateException) { + val errorMsg = e.message ?: "WV state error" + updateLog("captcha_wv_err", "[КАПЧА WBV] $errorMsg", 5, true) + writeCaptchaResult("error:$errorMsg") + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + updateLog("captcha_wv_err", "[КАПЧА WBV] Таймаут WebView", 5, true) + writeCaptchaResult("error:timeout") + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + updateLog("captcha_wv_err", "[КАПЧА WBV] Отменено", 5, true) + writeCaptchaResult("error:cancelled") + } catch (e: Exception) { + val errorMsg = e.message ?: "${e::class.simpleName}" + if (errorMsg != "tunnel stopped") { + updateLog("captcha_wv_err", "[КАПЧА WBV] Ошибка — $errorMsg", 5, true) + } + writeCaptchaResult("error:$errorMsg") + } + + // WebView уничтожен в finally блоке соответствующего менеджера. + updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false) + } + + private suspend fun solveSingleAutoWebViewCaptcha( + redirectUri: String, + sessionToken: String + ): String { + updateLog("captcha_wv_step_1", "[КАПЧА WBV] Авто WebView попытка 10с...", 5, false) + return CaptchaWebViewManager.solveCaptchaAsync(redirectUri, sessionToken) { step -> + updateLog("captcha_wv_auto_step", "[КАПЧА WBV] $step", 5, false) + } + } + + private suspend fun solveAutoWebViewCaptcha( + ctx: Context, + redirectUri: String, + sessionToken: String + ): String { + for (attempt in 1..2) { + updateLog("captcha_wv_step_1", "[КАПЧА WBV] Авто WebView попытка $attempt/2...", 5, false) + try { + return CaptchaWebViewManager.solveCaptchaAsync(redirectUri, sessionToken) { step -> + updateLog("captcha_wv_auto_step", "[КАПЧА WBV] $step", 5, false) + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + updateLog("captcha_wv_timeout_$attempt", "[КАПЧА WBV] Авто таймаут 10с ($attempt/2)", 5, attempt == 2) + if (attempt == 2) { + updateLog("captcha_wv_fallback", "[КАПЧА WBV] 2 таймаута авто, открыт ручной WebView", 5, false) + return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken) + } + } catch (e: IllegalStateException) { + if (e.message == CaptchaWebViewManager.ERROR_SLIDER_DETECTED) { + updateLog("captcha_wv_fallback", "[КАПЧА WBV] Обнаружен слайдер, открыт ручной WebView", 5, false) + return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken) + } + throw e + } + } + return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken) + } + + /** + * Записывает результат решения капчи в stdin Go-процесса. + */ + private fun writeCaptchaResult(result: String) { + val proc = process + if (proc == null || !proc.isAlive) return + try { + val line = "CAPTCHA_RESULT|$result\n" + proc.outputStream.write(line.toByteArray(Charsets.UTF_8)) + proc.outputStream.flush() + } catch (e: Exception) { + updateLog("captcha_write_err", "[КАПЧА] Ошибка записи: ${e.message}", 200, true) + } + } + + fun clearLogs() { + logs.value = emptyList() + activeWorkers.value = 0 + } + + fun startCooldown(seconds: Int) { + cooldownJob?.cancel() + cooldownSeconds.value = seconds + cooldownJob = scope.launch(Dispatchers.Main) { + while (cooldownSeconds.value > 0) { + delay(1000) + cooldownSeconds.update { it - 1 } + } + } + } + + private fun Throwable.readableMessage(): String { + val text = message ?: localizedMessage + return if (text.isNullOrBlank()) this::class.java.simpleName else "${this::class.java.simpleName}: $text" + } +} + +data class TunnelParams( + val peer: String, + val vkHashes: String, + val secondaryVkHash: String = "", + val workersPerHash: Int, + val port: Int, + val sni: String = "", + val connectionPassword: String = "", + val protocol: String = "udp", + val captchaMode: String = "auto", // "auto", "wv" или "rjs" + val captchaSolveMethod: String = "auto" // "manual" или "auto" +) diff --git a/app/src/main/java/com/wdtt/client/TunnelService.kt b/app/src/main/java/com/wdtt/client/TunnelService.kt new file mode 100644 index 0000000..fa3507b --- /dev/null +++ b/app/src/main/java/com/wdtt/client/TunnelService.kt @@ -0,0 +1,378 @@ +package com.wdtt.client + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiManager +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +private const val TUNNEL_NOTIFICATION_CHANNEL_ID = "wdtt_tunnel_v4" +private const val TUNNEL_NOTIFICATION_ID = 1 + +class TunnelService : Service() { + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: WifiManager.WifiLock? = null + private var updateJob: Job? = null + private var lastNotificationText: String? = null + + // Network Monitoring + private var connectivityManager: ConnectivityManager? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + private var lastNetworkChangeTime = 0L + private val activeNetworks = mutableSetOf() + private var isTunnelPaused = false + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + // Сразу берем лок при создании + acquireWakeLock() + setupNetworkCallback() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + restoreTunnel() + return START_STICKY + } + + when (intent.action) { + "START" -> { + val notification = createNotification("Запуск...") + startPersistentForeground(notification) + + val params = TunnelParams( + peer = intent.getStringExtra("peer") ?: "", + vkHashes = intent.getStringExtra("vk_hashes") ?: "", + secondaryVkHash = intent.getStringExtra("secondary_vk_hash") ?: "", + workersPerHash = intent.getIntExtra("workers_per_hash", 16), + port = intent.getIntExtra("port", 9000), + sni = intent.getStringExtra("sni") ?: "", + connectionPassword = intent.getStringExtra("connection_password") ?: "", + protocol = intent.getStringExtra("protocol") ?: "udp", + captchaMode = sanitizeCaptchaMode(intent.getStringExtra("captcha_mode")), + captchaSolveMethod = intent.getStringExtra("captcha_solve_method") ?: "auto" + ) + startTunnel(params) + } + "STOP" -> stopTunnel() + "DEPLOY_START" -> { + val notification = createNotification("Установка на сервер...", "DEPLOY_CANCEL", "Отменить") + startPersistentForeground(notification) + acquireWakeLock() + } + "DEPLOY_CANCEL" -> { + com.wdtt.client.DeployManager.writeError("[!] ❌ Установка отменена пользователем") + com.wdtt.client.DeployManager.stopDeploy("error: Отменена пользователем") + stopForeground(STOP_FOREGROUND_REMOVE) + } + "DEPLOY_STOP" -> { + if (!TunnelManager.running.value) { + stopTunnel() + } else { + updateNotification("Туннель активен") + } + } + } + return START_STICKY + } + + private fun restoreTunnel() { + val notification = createNotification("Восстановление соединения...") + startPersistentForeground(notification) + + val appContext = applicationContext + TunnelManager.scope.launch { + try { + val store = SettingsStore(appContext) + val basePeer = store.peer.first() + val manualPortsEnabled = store.manualPortsEnabled.first() + val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000 + val peerWithPort = if (basePeer.isBlank() || basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort" + val params = TunnelParams( + peer = peerWithPort, + vkHashes = store.vkHashes.first(), + secondaryVkHash = store.secondaryVkHash.first(), + workersPerHash = store.workersPerHash.first(), + port = store.listenPort.first(), + sni = store.sni.first(), + connectionPassword = store.connectionPassword.first(), + captchaMode = sanitizeCaptchaMode(store.captchaMode.first()), + captchaSolveMethod = store.captchaSolveMethod.first() + ) + if (params.peer.isNotEmpty() && params.vkHashes.isNotEmpty()) { + launch(Dispatchers.Main) { + startTunnel(params) + } + } else { + launch(Dispatchers.Main) { + stopTunnel() + } + } + } catch (e: Exception) { + launch(Dispatchers.Main) { + stopTunnel() + } + } + } + } + + private fun startTunnel(params: TunnelParams) { + updateNotification("Подключение...") + acquireWakeLock() + acquireWifiLock() + + // Подготавливаем CaptchaWebViewManager (не создаёт WebView — просто сохраняет контекст) + // Вызываем всегда — дёшево, а WebView создаётся на лету при каждом запросе капчи + CaptchaWebViewManager.onTunnelStart(applicationContext) + + TunnelManager.start(this, params) + startStatsUpdater() + } + + private fun stopTunnel() { + updateJob?.cancel() + + // Уничтожаем текущий WebView (если капча решается) и чистим контекст + CaptchaWebViewManager.onTunnelStop() + + TunnelManager.stop() + releaseWakeLock() + releaseWifiLock() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun setupNetworkCallback() { + connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + activeNetworks.clear() + + networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + val wasEmpty = activeNetworks.isEmpty() + activeNetworks.add(network) + if (wasEmpty) { + if (isTunnelPaused) { + isTunnelPaused = false + Log.d("TunnelService", "Сеть появилась, возобновляем туннель") + TunnelManager.resume() + updateNotification("Подключение...") + } else { + handleNetworkChange() + } + } else { + handleNetworkChange() + } + } + + override fun onLost(network: Network) { + super.onLost(network) + activeNetworks.remove(network) + if (activeNetworks.isEmpty() && TunnelManager.running.value && !isTunnelPaused) { + isTunnelPaused = true + Log.d("TunnelService", "Сеть потеряна, приостанавливаем туннель") + TunnelManager.pause() + updateNotification("Ожидание сети (Фоновый сон)") + } + } + } + + // ВАЖНО: Слушаем только реальные (не VPN) сети с доступом в интернет. + // Иначе интерфейс VPN (tun0) считается активной сетью, и при "Режиме полёта" activeNetworks не падает до 0. + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .build() + + connectivityManager?.registerNetworkCallback(request, networkCallback!!) + } + + private fun handleNetworkChange() { + val now = System.currentTimeMillis() + if (now - lastNetworkChangeTime < 5000) return + lastNetworkChangeTime = now + + if (TunnelManager.running.value && !isTunnelPaused) { + Log.d("TunnelService", "Сеть изменилась, мягкий перезапуск Go-клиента") + TunnelManager.restartTransport() + } + } + + private fun sanitizeCaptchaMode(mode: String?): String { + return when (mode?.lowercase()) { + "auto" -> "auto" + "rjs" -> "rjs" + "wv" -> "wv" + else -> "auto" + } + } + + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + val pm = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "wdtt:tunnel_cpu" + ).apply { + setReferenceCounted(false) + acquire() + } + } + + @Suppress("DEPRECATION") + private fun acquireWifiLock() { + if (wifiLock?.isHeld == true) return + val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + + // Используем WIFI_MODE_FULL_LOW_LATENCY для Android 10+, + // это предотвращает отключение радиомодуля при выключенном экране + val mode = if (Build.VERSION.SDK_INT >= 29) { + WifiManager.WIFI_MODE_FULL_LOW_LATENCY + } else { + WifiManager.WIFI_MODE_FULL_HIGH_PERF + } + + wifiLock = wm.createWifiLock(mode, "wdtt:wifi_perf").apply { + setReferenceCounted(false) + acquire() + } + } + + private fun releaseWakeLock() { + if (wakeLock?.isHeld == true) { + wakeLock?.release() + } + wakeLock = null + } + + private fun releaseWifiLock() { + if (wifiLock?.isHeld == true) { + wifiLock?.release() + } + wifiLock = null + } + + private fun startStatsUpdater() { + updateJob?.cancel() + updateJob = TunnelManager.scope.launch(Dispatchers.Main) { + delay(1000) + while (isActive) { + if (!TunnelManager.running.value && !isTunnelPaused) { + // Туннель полностью остановлен (не на паузе) — убиваем сервис + stopSelf() + break + } + if (!isTunnelPaused) { + updateNotification(buildTunnelNotificationText()) + } + delay(2000) + } + } + } + + private fun buildTunnelNotificationText(): String { + val statsText = TunnelManager.stats.value.trim() + return when { + statsText.isEmpty() -> "Туннель активен" + statsText == "Ожидание данных..." -> "Туннель активен" + else -> statsText + } + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + TUNNEL_NOTIFICATION_CHANNEL_ID, + "WDTT Туннель", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Уведомление о работе туннеля" + setShowBadge(false) + // ВАЖНО: Разрешаем показывать на экране блокировки + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setSound(null, null) + enableVibration(false) + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun createNotification(text: String, actionName: String = "STOP", actionTitle: String = "Отключить"): Notification { + val openIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val stopIntent = PendingIntent.getService( + this, if (actionName == "STOP") 1 else 2, + Intent(this, TunnelService::class.java).apply { action = actionName }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + return NotificationCompat.Builder(this, TUNNEL_NOTIFICATION_CHANNEL_ID) + .setContentTitle("WDTT") + .setContentText(text) + .setSmallIcon(R.drawable.ic_stat_connected) + .setOngoing(true) + .setLocalOnly(true) + .setContentIntent(openIntent) + .addAction(R.drawable.ic_stop, actionTitle, stopIntent) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFAULT) + // ВАЖНО: Делаем уведомление публичным (видимым на локскрине) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + // Категория SERVICE помогает системе понять важность + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setOnlyAlertOnce(true) // Не издавать звук и не будить экран при обновлении статистики! + .setSilent(true) // Делаем тихим само уведомление + .setShowWhen(false) + .setUsesChronometer(false) + .setWhen(0L) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun startPersistentForeground(notification: Notification) { + if (Build.VERSION.SDK_INT >= 34) { + startForeground(TUNNEL_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(TUNNEL_NOTIFICATION_ID, notification) + } + } + + private fun updateNotification(text: String) { + if (lastNotificationText == text) return + lastNotificationText = text + val notification = createNotification(text) + getSystemService(NotificationManager::class.java).notify(TUNNEL_NOTIFICATION_ID, notification) + } + + override fun onDestroy() { + super.onDestroy() + networkCallback?.let { + connectivityManager?.unregisterNetworkCallback(it) + } + stopTunnel() + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/com/wdtt/client/UserAgentGenerator.kt b/app/src/main/java/com/wdtt/client/UserAgentGenerator.kt new file mode 100644 index 0000000..41f2b7a --- /dev/null +++ b/app/src/main/java/com/wdtt/client/UserAgentGenerator.kt @@ -0,0 +1,77 @@ +package com.wdtt.client + +import android.os.Build +import kotlin.random.Random + +/** + * Генерирует реалистичный User-Agent на основе информации об устройстве. + * Включает случайные вариации версий браузеров и мелкие опечатки для уникальности. + */ +object UserAgentGenerator { + + private val chromeVersions = listOf(120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131) + private val firefoxVersions = listOf(120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130) + private val edgeVersions = listOf(120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130) + private val yaBrowserVersions = listOf("24.1", "24.2", "24.3", "24.4", "24.5", "24.6", "24.7", "24.8", "24.9", "24.10", "24.11", "24.12") + private val operaVersions = listOf(106, 107, 108, 109, 110, 111, 112, 113, 114, 115) + + /** + * Генерирует User-Agent для текущего устройства. + * @param seed опциональный seed для детерминированной генерации (на основе device ID) + */ + fun generate(seed: Long? = null): String { + val rng = seed?.let { Random(it) } ?: Random.Default + + val androidVersion = Build.VERSION.RELEASE + val deviceModel = Build.MODEL ?: "Unknown" + + val androidPlatform = "Linux; Android $androidVersion; $deviceModel" + + val chromeVersion = chromeVersions[rng.nextInt(chromeVersions.size)] + val patchVersion = rng.nextInt(0, 99) + val fullChromeVersion = "$chromeVersion.0.$patchVersion.0" + + val browserType = rng.nextInt(100) + + return when { + // 60% — обычный Chrome на Android + browserType < 60 -> { + "Mozilla/5.0 ($androidPlatform) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$fullChromeVersion Mobile Safari/537.36" + } + // 15% — Chrome на десктопе (имитация) + browserType < 75 -> { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$fullChromeVersion Safari/537.36" + } + // 8% — Yandex Browser + browserType < 83 -> { + val yaVer = yaBrowserVersions[rng.nextInt(yaBrowserVersions.size)] + val yaPatch = rng.nextInt(0, 9) + "Mozilla/5.0 ($androidPlatform) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$chromeVersion.0.$patchVersion.0 YaBrowser/$yaVer.$yaPatch Yowser/2.5 Mobile Safari/537.36" + } + // 7% — Firefox + browserType < 90 -> { + val ffVersion = firefoxVersions[rng.nextInt(firefoxVersions.size)] + "Mozilla/5.0 ($androidPlatform; rv:$ffVersion.0) Gecko/20100101 Firefox/$ffVersion.0" + } + // 5% — Edge + browserType < 95 -> { + val edgeVersion = edgeVersions[rng.nextInt(edgeVersions.size)] + "Mozilla/5.0 ($androidPlatform) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$fullChromeVersion Mobile Safari/537.36 EdgA/$edgeVersion.0.${rng.nextInt(0, 99)}.${rng.nextInt(0, 99)}" + } + // 5% — Opera + else -> { + val operaVersion = operaVersions[rng.nextInt(operaVersions.size)] + "Mozilla/5.0 ($androidPlatform) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$fullChromeVersion Mobile Safari/537.36 OPR/$operaVersion.0.${rng.nextInt(0, 99)}" + } + } + } + + /** + * Генерирует детерминированный UA на основе device ID. + * Один и тот же device ID всегда даёт одинаковый UA. + */ + fun generateForDevice(deviceId: String): String { + val seed = deviceId.hashCode().toLong() and 0xFFFFFFFFL + return generate(seed) + } +} diff --git a/app/src/main/java/com/wdtt/client/WdttApplication.kt b/app/src/main/java/com/wdtt/client/WdttApplication.kt new file mode 100644 index 0000000..29585ee --- /dev/null +++ b/app/src/main/java/com/wdtt/client/WdttApplication.kt @@ -0,0 +1,24 @@ +package com.wdtt.client + +import android.app.Application +import android.content.Context +import com.wireguard.android.backend.GoBackend + +class WdttApplication : Application() { + @Volatile + private var backendInstance: GoBackend? = null + + val backend: GoBackend + get() = getBackend(this) + + override fun onCreate() { + super.onCreate() + DeployManager.init(this) + } + + fun getBackend(context: Context): GoBackend { + return backendInstance ?: synchronized(this) { + backendInstance ?: GoBackend(context.applicationContext).also { backendInstance = it } + } + } +} diff --git a/app/src/main/java/com/wdtt/client/WireGuardHelper.kt b/app/src/main/java/com/wdtt/client/WireGuardHelper.kt new file mode 100644 index 0000000..74147dd --- /dev/null +++ b/app/src/main/java/com/wdtt/client/WireGuardHelper.kt @@ -0,0 +1,209 @@ +package com.wdtt.client + +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.util.Log +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.Tunnel +import com.wireguard.config.Config +import com.wireguard.config.Interface +import com.wireguard.config.Peer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream + +class WireGuardHelper(context: Context) { + private val appContext = context.applicationContext + private val backend = (appContext as WdttApplication).getBackend(context) + + private companion object { + val wgMutex = Mutex() + var sharedTunnel: WgTunnel? = null + } + + class WgTunnel : Tunnel { + override fun getName() = "wdtt" + override fun onStateChange(newState: Tunnel.State) {} + } + + suspend fun startTunnel(configString: String) = wgMutex.withLock { + startTunnelLocked(configString) + } + + private suspend fun startTunnelLocked(configString: String) = withContext(Dispatchers.IO) { + try { + if (VpnService.prepare(appContext) != null) { + throw IllegalStateException("VPN-разрешение не выдано") + } + + ensureGoBackendServiceStarted() + + sharedTunnel?.let { existingTunnel -> + try { + backend.setState(existingTunnel, Tunnel.State.DOWN, null) + } catch (e: Exception) { + Log.w("WG", "Failed to stop previous tunnel before restart: ${e.readableMessage()}") + } + sharedTunnel = null + delay(150) + } + + val parsedConfig = Config.parse(ByteArrayInputStream(configString.toByteArray(Charsets.UTF_8))) + + val builder = Interface.Builder() + .parseAddresses(parsedConfig.`interface`.addresses.joinToString(", ") { it.toString() }) + + if (parsedConfig.`interface`.dnsServers.isNotEmpty()) { + builder.parseDnsServers(parsedConfig.`interface`.dnsServers.joinToString(", ") { it.hostAddress ?: "" }) + } + if (parsedConfig.`interface`.listenPort.isPresent) { + builder.parseListenPort(parsedConfig.`interface`.listenPort.get().toString()) + } + if (parsedConfig.`interface`.mtu.isPresent) { + val serverMtu = parsedConfig.`interface`.mtu.get() + // Используем серверное значение, но не менее 1280 для мобильных сетей + builder.parseMtu(serverMtu.coerceAtLeast(1280).toString()) + } else { + builder.parseMtu("1280") + } + builder.parsePrivateKey(parsedConfig.`interface`.keyPair.privateKey.toBase64()) + + // 1. Пакеты, которые всегда исключаются (наше приложение, ВК) + // 2. Получаю настройки пользователя + val settingsStore = SettingsStore(appContext) + val savedExcluded = settingsStore.excludedApps.first() + + val userSelected = savedExcluded.split(",").filter { it.isNotEmpty() }.toSet() + + // В обоих режимах (ЧС и БС) мы технически используем Blacklist (Checked = Excluded), + // так как пользователю удобнее логика "снимите галочку, чтобы приложение пошло в туннель". + // Разница только в описании и начальном состоянии списка (пустой/полный). + val excluded = mutableSetOf(appContext.packageName, "com.vkontakte.android", "com.vk.calls") + excluded.addAll(userSelected) + val installedExcluded = excluded.filter { it.isInstalledPackage() }.toSet() + if (installedExcluded.isNotEmpty()) { + builder.excludeApplications(installedExcluded) + } + + val newInterface = builder.build() + + val peerBuilder = Peer.Builder() + val firstPeer = parsedConfig.peers.firstOrNull() + ?: throw IllegalStateException("WireGuard config has no peer") + firstPeer.let { peer -> + peerBuilder.parsePublicKey(peer.publicKey.toBase64()) + if (peer.preSharedKey.isPresent) peerBuilder.parsePreSharedKey(peer.preSharedKey.get().toBase64()) + if (peer.endpoint.isPresent) peerBuilder.parseEndpoint(peer.endpoint.get().toString()) + if (peer.persistentKeepalive.isPresent) peerBuilder.parsePersistentKeepalive(peer.persistentKeepalive.get().toString()) + } + // Override AllowedIPs + peerBuilder.parseAllowedIPs("0.0.0.0/0") + + val finalConfig = Config.Builder() + .setInterface(newInterface) + .addPeer(peerBuilder.build()) + .build() + + val nextTunnel = WgTunnel() + setTunnelUpWithRetry(nextTunnel, finalConfig) + sharedTunnel = nextTunnel + Log.d("WG", "WireGuard tunnel started successfully") + } catch (e: Exception) { + val detailed = "WireGuard start failed: ${e.readableMessage()}; ${configString.describeWireGuardConfig()}" + Log.e("WG", detailed) + e.printStackTrace() + throw IllegalStateException(detailed, e) + } + } + + suspend fun reloadTunnel() = wgMutex.withLock { + withContext(Dispatchers.IO) { + val currentTunnel = sharedTunnel ?: return@withContext + try { + val configFlow = TunnelManager.config.first() ?: return@withContext + backend.setState(currentTunnel, Tunnel.State.DOWN, null) + sharedTunnel = null + delay(150) + startTunnelLocked(configFlow) + Log.d("WG", "WireGuard tunnel reloaded for new exceptions") + } catch (e: Exception) { + Log.e("WG", "Failed to reload WireGuard: ${e.readableMessage()}") + } + } + } + + suspend fun stopTunnel() = wgMutex.withLock { + withContext(Dispatchers.IO) { + try { + sharedTunnel?.let { + backend.setState(it, Tunnel.State.DOWN, null) + sharedTunnel = null + Log.d("WG", "WireGuard tunnel stopped") + } + } catch (e: Exception) { + Log.e("WG", "Failed to stop WireGuard: ${e.readableMessage()}") + } + } + } + + private suspend fun ensureGoBackendServiceStarted() { + withContext(Dispatchers.Main) { + runCatching { + val intent = Intent(appContext, GoBackend.VpnService::class.java) + appContext.startService(intent) + }.onFailure { + Log.w("WG", "GoBackend service warmup failed: ${it.readableMessage()}") + } + } + delay(300) + } + + private suspend fun setTunnelUpWithRetry(nextTunnel: WgTunnel, finalConfig: Config) { + var lastError: Exception? = null + repeat(3) { attempt -> + try { + backend.setState(nextTunnel, Tunnel.State.UP, finalConfig) + return + } catch (e: Exception) { + lastError = e + Log.w("WG", "WireGuard UP attempt ${attempt + 1}/3 failed: ${e.readableMessage()}") + runCatching { backend.setState(nextTunnel, Tunnel.State.DOWN, null) } + ensureGoBackendServiceStarted() + delay(250L * (attempt + 1)) + } + } + throw lastError ?: IllegalStateException("WireGuard UP failed") + } + + private fun Throwable.readableMessage(): String { + val text = message ?: localizedMessage + return if (text.isNullOrBlank()) this::class.java.simpleName else "${this::class.java.simpleName}: $text" + } + + private fun String.isInstalledPackage(): Boolean { + return runCatching { + appContext.packageManager.getPackageInfo(this, 0) + true + }.getOrDefault(false) + } + + private fun String.describeWireGuardConfig(): String { + val lines = lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList() + val hasInterface = lines.any { it.equals("[Interface]", ignoreCase = true) } + val hasPeer = lines.any { it.equals("[Peer]", ignoreCase = true) } + val hasPrivateKey = lines.any { it.startsWith("PrivateKey", ignoreCase = true) } + val hasPublicKey = lines.any { it.startsWith("PublicKey", ignoreCase = true) } + val hasAddress = lines.any { it.startsWith("Address", ignoreCase = true) } + val endpoint = lines.firstOrNull { it.startsWith("Endpoint", ignoreCase = true) } + ?.substringAfter("=", "") + ?.trim() + ?.take(80) + ?: "none" + return "config lines=${lines.size}, interface=$hasInterface, peer=$hasPeer, privateKey=$hasPrivateKey, publicKey=$hasPublicKey, address=$hasAddress, endpoint=$endpoint" + } +} diff --git a/app/src/main/java/com/wdtt/client/ui/AppSectionCard.kt b/app/src/main/java/com/wdtt/client/ui/AppSectionCard.kt new file mode 100644 index 0000000..711f95f --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ui/AppSectionCard.kt @@ -0,0 +1,66 @@ +package com.wdtt.client.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp + +@Composable +private fun appSectionCardColor(): Color { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + return if (isDark) { + lerp(colors.surface, colors.surfaceVariant, 0.10f) + } else { + lerp(colors.surface, colors.surfaceVariant, 0.28f) + } +} + +@Composable +private fun appSectionCardBorderColor(): Color { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + return if (isDark) { + colors.outlineVariant.copy(alpha = 0.26f) + } else { + colors.outlineVariant.copy(alpha = 0.24f) + } +} + +@Composable +fun AppSectionCard( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp), + content: @Composable ColumnScope.() -> Unit +) { + Surface( + shape = RoundedCornerShape(28.dp), + color = appSectionCardColor(), + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(1.dp, appSectionCardBorderColor()), + shadowElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 2.dp else 10.dp, + tonalElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 0.dp else 2.dp, + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding), + verticalArrangement = verticalArrangement, + content = content + ) + } +} diff --git a/app/src/main/java/com/wdtt/client/ui/AppUpdateDialog.kt b/app/src/main/java/com/wdtt/client/ui/AppUpdateDialog.kt new file mode 100644 index 0000000..c369cd6 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ui/AppUpdateDialog.kt @@ -0,0 +1,128 @@ +package com.wdtt.client.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.wdtt.client.AppReleaseInfo +import com.wdtt.client.RemoteVersionSource + +@Composable +fun AppUpdateDialog( + release: AppReleaseInfo, + onPostpone: () -> Unit, + onUpdate: () -> Unit +) { + val isTagOnly = release.source == RemoteVersionSource.Tag + val title = if (isTagOnly) "Найден новый tag" else "Доступно обновление" + val description = if (isTagOnly) { + "На GitHub обнаружен более новый tag ${release.versionTag}. Похоже, опубликованный release ещё не догнал его." + } else { + "Вышла новая версия приложения ${release.versionTag}. Можно открыть страницу релиза и обновиться вручную." + } + + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 8.dp, + modifier = Modifier.fillMaxWidth(0.92f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Update, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(10.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = release.versionTag, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 20.sp + ) + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onPostpone, + modifier = Modifier + .weight(1f) + .height(50.dp), + shape = RoundedCornerShape(22.dp) + ) { + Text("Позже", fontWeight = FontWeight.SemiBold) + } + + Button( + onClick = onUpdate, + modifier = Modifier + .weight(1f) + .height(50.dp), + shape = RoundedCornerShape(22.dp) + ) { + Text("Обновить", fontWeight = FontWeight.Bold) + } + } + } + } + } +} diff --git a/app/src/main/java/com/wdtt/client/ui/DeployTab.kt b/app/src/main/java/com/wdtt/client/ui/DeployTab.kt new file mode 100644 index 0000000..e808397 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ui/DeployTab.kt @@ -0,0 +1,912 @@ +package com.wdtt.client.ui + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import com.wdtt.client.TunnelService +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.jcraft.jsch.ChannelExec +import com.jcraft.jsch.ChannelSftp +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import com.wdtt.client.DeployManager +import com.wdtt.client.SettingsStore +import com.wdtt.client.TunnelManager +import com.wdtt.client.WDTTColors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.util.Properties + +private const val CMD_TIMEOUT = 900000L // 15 minutes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeployTab() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val settingsStore = remember { SettingsStore(context) } + + LaunchedEffect(Unit) { DeployManager.init(context) } + + val savedIp by settingsStore.deployIp.collectAsStateWithLifecycle(initialValue = "") + val savedLogin by settingsStore.deployLogin.collectAsStateWithLifecycle(initialValue = "") + val savedPassword by settingsStore.deployPassword.collectAsStateWithLifecycle(initialValue = "") + + var ip by remember { mutableStateOf("") } + var login by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + val savedMainPass by settingsStore.deployMainPassword.collectAsStateWithLifecycle(initialValue = "") + val savedAdminId by settingsStore.deployAdminId.collectAsStateWithLifecycle(initialValue = "") + val savedBotToken by settingsStore.deployBotToken.collectAsStateWithLifecycle(initialValue = "") + val savedSshPort by settingsStore.deploySshPort.collectAsStateWithLifecycle(initialValue = "22") + val savedManualPorts by settingsStore.manualPortsEnabled.collectAsStateWithLifecycle(initialValue = false) + val savedServerDtlsPort by settingsStore.serverDtlsPort.collectAsStateWithLifecycle(initialValue = 56000) + val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001) + + var showSecretsDialog by remember { mutableStateOf(false) } + var showUninstallDialog by remember { mutableStateOf(false) } + + var showSuccessBanner by rememberSaveable { mutableStateOf(false) } + var successCountdown by rememberSaveable { mutableIntStateOf(5) } + + LaunchedEffect(showSuccessBanner) { + if (showSuccessBanner) { + while (successCountdown > 0) { + kotlinx.coroutines.delay(1000) + successCountdown-- + } + showSuccessBanner = false + } + } + + val isDeploying by DeployManager.isDeploying.collectAsStateWithLifecycle() + val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle() + val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle() + + LaunchedEffect(savedIp) { if (savedIp.isNotEmpty()) ip = savedIp } + LaunchedEffect(savedLogin) { if (savedLogin.isNotEmpty()) login = savedLogin } + LaunchedEffect(savedPassword) { if (savedPassword.isNotEmpty()) password = savedPassword } + val animatedProgress by animateFloatAsState( + targetValue = deployProgress, + animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing), + label = "progress" + ) + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "Настройки сервера", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + + // ═══ Поля ввода в Card ═══ + AppSectionCard( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = ip, + onValueChange = { + ip = it.filter { c -> !c.isWhitespace() } + scope.launch { settingsStore.saveDeploy(ip, login, password, savedSshPort) } + }, + label = { Text("IP сервера или домен (без порта)") }, + placeholder = { Text("1.2.3.4 (без порта)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + enabled = !isDeploying, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = login, + onValueChange = { + login = it.filter { c -> !c.isWhitespace() } + scope.launch { settingsStore.saveDeploy(ip, login, password, savedSshPort) } + }, + label = { Text("Логин") }, + placeholder = { Text("root") }, + singleLine = true, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(16.dp), + enabled = !isDeploying, + ) + OutlinedTextField( + value = password, + onValueChange = { + password = it.filter { c -> !c.isWhitespace() } + scope.launch { settingsStore.saveDeploy(ip, login, password, savedSshPort) } + }, + label = { Text("Пароль SSH") }, + placeholder = { Text("password") }, + singleLine = true, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(16.dp), + enabled = !isDeploying, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Ручное управление портами", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = savedManualPorts, + enabled = !isDeploying, + onCheckedChange = { enabled -> + scope.launch { settingsStore.saveManualPortsEnabled(enabled) } + } + ) + } + } + + if (showSecretsDialog) { + DeploySecretsDialog( + settingsStore = settingsStore, + initialMainPass = savedMainPass, + initialAdminId = savedAdminId, + initialBotToken = savedBotToken, + initialSshPort = savedSshPort, + manualPortsEnabled = savedManualPorts, + initialServerDtlsPort = savedServerDtlsPort.toString(), + initialServerWgPort = savedServerWgPort.toString(), + onSaved = { _, _ -> }, + onDismiss = { showSecretsDialog = false } + ) + } + + // ═══ Прогресс ═══ + if (isDeploying) { + AppSectionCard( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = currentStep, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${(animatedProgress * 100).toInt()}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + + // ═══ Кнопки ═══ + val deploySecretsMissing = savedMainPass.isBlank() + OutlinedButton( + onClick = { showSecretsDialog = true }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (deploySecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface, + contentColor = if (deploySecretsMissing) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface + ), + border = BorderStroke( + 1.dp, + if (deploySecretsMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) { + Icon(Icons.Default.Key, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text( + if (savedManualPorts) "Секреты (BOT, Пароли, Порты)" else "Секреты (BOT, Пароли)", + fontWeight = FontWeight.SemiBold + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + if (ip.isBlank() || password.isBlank() || savedMainPass.isBlank()) return@Button + val effectiveLogin = if (login.isBlank()) "root" else login + val effectiveDtlsPort = if (savedManualPorts) savedServerDtlsPort.coerceIn(1, 65535) else 56000 + val effectiveWgPort = if (savedManualPorts) savedServerWgPort.coerceIn(1, 65535) else 56001 + val appContext = context.applicationContext + DeployManager.scope.launch { + try { + DeployManager.startDeploy() + val intent = Intent(appContext, TunnelService::class.java).apply { action = "DEPLOY_START" } + if (Build.VERSION.SDK_INT >= 26) appContext.startForegroundService(intent) + else appContext.startService(intent) + + val success = performDeploy( + context = appContext, + host = ip, user = effectiveLogin, pass = password, port = savedSshPort.toIntOrNull() ?: 22, + mainPass = savedMainPass, adminId = savedAdminId, botToken = savedBotToken, + dtlsPort = effectiveDtlsPort, wgPort = effectiveWgPort, + onProgress = { p, s -> DeployManager.updateProgress(p, s) } + ) + if (success) { + successCountdown = 5 + showSuccessBanner = true + } + } finally { + try { appContext.startService(Intent(appContext, TunnelService::class.java).apply { action = "DEPLOY_STOP" }) } catch (_: Exception) {} + } + } + }, + modifier = Modifier.weight(1f).height(50.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary), + enabled = !isDeploying && ip.isNotBlank() && password.isNotBlank() && savedMainPass.isNotBlank() + ) { + if (isDeploying) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp) + } else { + Icon(Icons.Default.CloudUpload, null, Modifier.size(18.dp)) + } + Spacer(Modifier.width(8.dp)) + Text(if (isDeploying) "Установка..." else "Установить", fontWeight = FontWeight.Bold) + } + + Button( + onClick = { + if (ip.isBlank() || password.isBlank()) return@Button + showUninstallDialog = true + }, + modifier = Modifier.weight(1f).height(50.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ), + enabled = !isDeploying && ip.isNotBlank() && password.isNotBlank() + ) { + Icon(Icons.Default.Delete, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Удалить", fontWeight = FontWeight.Bold) + } + } + + if (showUninstallDialog) { + UninstallConfirmDialog( + onDismiss = { showUninstallDialog = false }, + onConfirm = { + showUninstallDialog = false + val effectiveLogin = if (login.isBlank()) "root" else login + val effectiveDtlsPort = if (savedManualPorts) savedServerDtlsPort.coerceIn(1, 65535) else 56000 + val effectiveWgPort = if (savedManualPorts) savedServerWgPort.coerceIn(1, 65535) else 56001 + DeployManager.scope.launch { + try { + DeployManager.startDeploy() + performUninstall( + host = ip, user = effectiveLogin, pass = password, port = savedSshPort.toIntOrNull() ?: 22, + dtlsPort = effectiveDtlsPort, wgPort = effectiveWgPort, + onProgress = { p, s -> DeployManager.updateProgress(p, s) } + ) + } catch (_: Exception) {} + } + } + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // ═══ Success Banner ═══ + if (showSuccessBanner) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = WDTTColors.connected.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(1.dp, WDTTColors.connected.copy(alpha = 0.4f)) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.CheckCircle, contentDescription = null, tint = WDTTColors.connected) + Spacer(Modifier.width(8.dp)) + Text( + text = "Деплой успешно завершен ($successCountdown)", + color = WDTTColors.connected, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} + +// ==================== SSH ==================== + +private class SSHClient(private val session: Session, private val pass: String) { + + fun exec(command: String, timeout: Long = CMD_TIMEOUT): String { + if (!session.isConnected) { + DeployManager.writeError("SSH exec: сессия разорвана перед командой: ${command.take(80)}") + return "error: session is down" + } + + var channel: ChannelExec? = null + val result = StringBuilder() + + return try { + channel = session.openChannel("exec") as ChannelExec + val cmd = if (command.contains("sudo") && !command.contains("sudo -S")) { + command.replace("sudo ", "sudo -S ") + } else command + + channel.setCommand(cmd) + val outStream = channel.outputStream + val input = channel.inputStream + val err = channel.errStream + channel.connect(15000) + + if (cmd.contains("sudo -S")) { + outStream.write("$pass\n".toByteArray()) + outStream.flush() + } + + val reader = input.bufferedReader() + val errReader = err.bufferedReader() + val startTime = System.currentTimeMillis() + val progressRegex = Regex("^WDTT_PROGRESS\\|(\\d+\\.?\\d*)\\|(.+)$") + + while (!channel.isClosed || reader.ready() || errReader.ready()) { + if (System.currentTimeMillis() - startTime > timeout) { + DeployManager.writeError("SSH timeout (${timeout/1000}s): ${command.take(80)}") + try { channel.disconnect() } catch (_: Exception) {} + return "error: timeout" + } + + if (reader.ready()) { + val line = reader.readLine() + if (line != null) { + val match = progressRegex.find(line.trim()) + if (match != null) { + val p = match.groupValues[1].toFloatOrNull() ?: 0f + DeployManager.updateProgress(p, match.groupValues[2]) + } else if (!line.contains("WDTT_PROGRESS")) { + val clean = line.replace(Regex("\u001B\\[[;\\d]*m"), "") + result.appendLine(clean) + if (clean.contains("[✗]") || clean.contains("FAIL") || + (clean.contains("error", true) && !clean.contains("2>/dev/null"))) { + DeployManager.writeError("REMOTE: $clean") + TunnelManager.addDeployErrorLog("REMOTE: $clean") + } + } + } + } + if (errReader.ready()) { + val line = errReader.readLine() + if (line != null && !line.contains("password for")) { + val clean = line.replace(Regex("\u001B\\[[;\\d]*m"), "") + result.appendLine(clean) + if (clean.isNotBlank() && !clean.startsWith("Warning:")) { + DeployManager.writeError("STDERR: $clean") + TunnelManager.addDeployErrorLog("STDERR: $clean") + } + } + } + if (!reader.ready() && !errReader.ready()) Thread.sleep(100) + } + + result.toString() + } catch (e: Exception) { + DeployManager.writeError("SSH exec error: ${e.message} | cmd: ${command.take(80)}") + TunnelManager.addDeployErrorLog("SSH exec error: ${e.message}") + "error: ${e.message}" + } finally { + try { channel?.disconnect() } catch (_: Exception) {} + } + } + + fun upload(localFile: File, remotePath: String) { + if (!session.isConnected) { + DeployManager.writeError("SSH upload: сессия разорвана") + throw Exception("Session is down") + } + var sftp: ChannelSftp? = null + try { + sftp = session.openChannel("sftp") as ChannelSftp + sftp.connect(15000) + sftp.put(localFile.absolutePath, remotePath) + } catch (e: Exception) { + DeployManager.writeError("SFTP upload error: ${e.message} | file: ${localFile.name}") + throw e + } finally { + try { sftp?.disconnect() } catch (_: Exception) {} + } + } +} + +private fun createSSHSession(host: String, user: String, pass: String, port: Int = 22): Session { + val jsch = JSch() + val session = jsch.getSession(user, host, port) + session.setPassword(pass) + session.setConfig(Properties().apply { + put("StrictHostKeyChecking", "no") + put("ServerAliveInterval", "10") + put("ServerAliveCountMax", "6") + put("ConnectTimeout", "15000") + put("PreferredAuthentications", "password,keyboard-interactive") + }) + session.connect(20000) + return session +} + +private fun shellQuote(value: String): String { + return "'" + value.replace("'", "'\"'\"'") + "'" +} + +private fun rootCommand(command: String): String { + val quoted = shellQuote(command) + return "if command -v sudo >/dev/null 2>&1; then sudo bash -c $quoted; " + + "elif [ \"\$(id -u)\" = \"0\" ]; then bash -c $quoted; " + + "else echo 'error: root privileges required and sudo not found'; exit 1; fi" +} + +private fun File.containsBinaryToken(token: String): Boolean { + val data = readBytes() + val needle = token.toByteArray() + if (needle.isEmpty() || data.size < needle.size) return false + for (i in 0..data.size - needle.size) { + var matched = true + for (j in needle.indices) { + if (data[i + j] != needle[j]) { + matched = false + break + } + } + if (matched) return true + } + return false +} + +private fun isUnsafeLegacyServerAsset(serverFile: File): Boolean { + return serverFile.containsBinaryToken("/etc/wireguard") || + (serverFile.containsBinaryToken("wg0") && !serverFile.containsBinaryToken("wdtt0")) +} + +// ==================== Deploy ==================== + +private suspend fun performDeploy( + context: Context, + host: String, user: String, pass: String, port: Int, + mainPass: String, adminId: String, botToken: String, + dtlsPort: Int, wgPort: Int, + onProgress: (Float, String) -> Unit +): Boolean = withContext(Dispatchers.IO) { + var session: Session? = null + try { + onProgress(0.02f, "Подключение...") + session = createSSHSession(host, user, pass, port) + DeployManager.activeSession = session + val ssh = SSHClient(session, pass) + + onProgress(0.05f, "Подготовка файлов...") + val passArg = if (mainPass.isNotBlank()) "-password \"$mainPass\" " else "" + val adminArg = if (adminId.isNotBlank()) "-admin \"$adminId\" " else "" + val botArg = if (botToken.isNotBlank()) "-bot-token \"$botToken\" " else "" + val args = "$passArg$adminArg$botArg".trim() + + val scriptFile = File(context.cacheDir, "deploy.sh") + val serverFile = File(context.cacheDir, "server") + try { + context.assets.open("deploy.sh").use { inp -> FileOutputStream(scriptFile).use { out -> inp.copyTo(out) } } + context.assets.open("server").use { inp -> FileOutputStream(serverFile).use { out -> inp.copyTo(out) } } + } catch (e: Exception) { + DeployManager.writeError("Assets extraction failed: ${e.message}") + DeployManager.stopDeploy("Ошибка: файлы не найдены в assets") + return@withContext false + } + if (isUnsafeLegacyServerAsset(serverFile)) { + scriptFile.delete() + serverFile.delete() + DeployManager.writeError("Unsafe legacy server asset: найдено wg0 или /etc/wireguard. Нужна пересборка server под wdtt0 и /etc/wdtt.") + DeployManager.stopDeploy("Нужна пересборка server asset") + return@withContext false + } + + onProgress(0.06f, "Загрузка на сервер...") + ssh.upload(scriptFile, "/tmp/deploy.sh") + ssh.upload(serverFile, "/tmp/wdtt-server") + scriptFile.delete() + serverFile.delete() + + onProgress(0.08f, "Установка...") + val output = ssh.exec( + rootCommand("env WDTT_ARGS=${shellQuote(args)} WDTT_DTLS_PORT=$dtlsPort WDTT_WG_PORT=$wgPort WDTT_SSH_PORT=$port bash /tmp/deploy.sh"), + timeout = CMD_TIMEOUT + ) + + if (output.contains("✅") || output.contains("Деплой успешно") || output.contains("active")) { + DeployManager.stopDeploy("success") + TunnelManager.addDeploySuccessLog("Деплой успешно завершен. Сервис активен.") + return@withContext true + } else if (output.contains("error:")) { + DeployManager.writeError("Deploy script output contains error") + DeployManager.stopDeploy("Ошибка выполнения скрипта (см. errors.log)") + return@withContext false + } else { + DeployManager.stopDeploy("success") + TunnelManager.addDeploySuccessLog("Деплой завершён. (Проверьте подключение)") + return@withContext true + } + + } catch (e: Exception) { + DeployManager.writeError("Deploy critical: ${e.message}\n${e.stackTraceToString().take(500)}") + DeployManager.stopDeploy("Ошибка: ${e.message?.take(100)}") + return@withContext false + } finally { + try { session?.disconnect() } catch (_: Exception) {} + DeployManager.activeSession = null + } +} + + +// ==================== Uninstall ==================== + +private suspend fun performUninstall( + host: String, user: String, pass: String, port: Int, + dtlsPort: Int, wgPort: Int, + onProgress: (Float, String) -> Unit +) = withContext(Dispatchers.IO) { + var session: Session? = null + try { + onProgress(0.05f, "Подключение...") + session = createSSHSession(host, user, pass, port) + DeployManager.activeSession = session + val ssh = SSHClient(session, pass) + + onProgress(0.15f, "Остановка сервиса...") + ssh.exec( + rootCommand( + "systemctl unmask wdtt 2>/dev/null || true; " + + "systemctl stop wdtt 2>/dev/null || true; " + + "systemctl disable wdtt 2>/dev/null || true; " + + "rm -f /etc/systemd/system/wdtt.service; " + + "systemctl daemon-reload 2>/dev/null || true" + ), + timeout = 15000L + ) + + onProgress(0.30f, "Удаление через deploy.sh...") + ssh.exec(rootCommand("[ -f /tmp/deploy.sh ] && env WDTT_DTLS_PORT=$dtlsPort WDTT_WG_PORT=$wgPort WDTT_SSH_PORT=$port bash /tmp/deploy.sh uninstall 2>/dev/null || true"), timeout = 30000L) + + onProgress(0.45f, "Удаление бинарника...") + ssh.exec(rootCommand("pkill -x wdtt-server 2>/dev/null || true; rm -f /usr/local/bin/wdtt-server"), timeout = 10000L) + + onProgress(0.60f, "Очистка firewall...") + ssh.exec( + rootCommand( + "if command -v iptables >/dev/null 2>&1; then " + + "for i in 1 2 3 4 5; do " + + "for iface in $(ls /sys/class/net 2>/dev/null || true); do " + + "iptables -t nat -D POSTROUTING -s 10.66.66.0/24 -o \"${'$'}iface\" -m comment --comment WDTT_MANAGED -j MASQUERADE 2>/dev/null || true; " + + "done; " + + "iptables -D INPUT -p udp --dport $dtlsPort -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " + + "iptables -D INPUT -p udp --dport $wgPort -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " + + "iptables -D INPUT -p udp --dport 56000 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " + + "iptables -D INPUT -p udp --dport 56001 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " + + "iptables -D INPUT -p tcp --dport $port -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " + + "iptables -D INPUT -p tcp --dport 22 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " + + "iptables -D FORWARD -i wdtt0 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " + + "iptables -D FORWARD -o wdtt0 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " + + "done; fi; " + + "if command -v nft >/dev/null 2>&1; then " + + "nft delete table ip wdtt 2>/dev/null || true; " + + "nft delete table inet wdtt 2>/dev/null || true; " + + "nft delete table inet wdtt_mangle 2>/dev/null || true; " + + "fi" + ), + timeout = 15000L + ) + + onProgress(0.75f, "Удаление WDTT-интерфейса...") + ssh.exec( + rootCommand( + "ip link show wdtt0 >/dev/null 2>&1 && ip link del wdtt0 2>/dev/null || true; " + + "[ -d /etc/wdtt ] && find /etc/wdtt -mindepth 1 -maxdepth 1 ! -name passwords.json -exec rm -rf {} + 2>/dev/null || true; " + + "[ -f /etc/wdtt/passwords.json ] && chmod 600 /etc/wdtt/passwords.json 2>/dev/null || true" + ), + timeout = 10000L + ) + + onProgress(0.90f, "Очистка sysctl...") + ssh.exec(rootCommand("rm -f /etc/sysctl.d/99-wdtt.conf; sysctl --system >/dev/null 2>&1 || true"), timeout = 15000L) + + onProgress(1.0f, "Готово!") + DeployManager.stopDeploy("success") + + } catch (e: Exception) { + DeployManager.writeError("Uninstall error: ${e.message}") + DeployManager.stopDeploy("Ошибка: ${e.message?.take(100)}") + } finally { + try { session?.disconnect() } catch (_: Exception) {} + DeployManager.activeSession = null + } +} + +// ==================== Dialogs ==================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeploySecretsDialog( + settingsStore: SettingsStore, + initialMainPass: String, + initialAdminId: String, + initialBotToken: String, + initialSshPort: String, + manualPortsEnabled: Boolean, + initialServerDtlsPort: String, + initialServerWgPort: String, + onSaved: (String, String) -> Unit, + onDismiss: () -> Unit +) { + val scope = rememberCoroutineScope() + var passInput by rememberSaveable { mutableStateOf(initialMainPass) } + var adminIdInput by rememberSaveable { mutableStateOf(initialAdminId) } + var botTokenInput by rememberSaveable { mutableStateOf(initialBotToken) } + var sshPortInput by rememberSaveable { mutableStateOf(if (initialSshPort.isBlank()) "22" else initialSshPort) } + var dtlsPortInput by rememberSaveable { mutableStateOf(initialServerDtlsPort.ifBlank { "56000" }) } + var wgPortInput by rememberSaveable { mutableStateOf(initialServerWgPort.ifBlank { "56001" }) } + + fun normalizePort(value: String, fallback: String): String { + return value.toIntOrNull()?.takeIf { it in 1..65535 }?.toString() ?: fallback + } + + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 8.dp + ) { + Column(modifier = Modifier.padding(24.dp).fillMaxWidth().verticalScroll(rememberScrollState())) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Секреты Деплоя", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Закрыть") + } + } + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = passInput, + onValueChange = { passInput = it }, + label = { Text("Задайте пароль туннеля (любой)") }, + placeholder = { Text("Придумайте надежный пароль") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Text("Телеграм бот для управления", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = adminIdInput, + onValueChange = { adminIdInput = it }, + label = { Text("ID Админа (Опционально)") }, + placeholder = { Text("ID из @getmyid_bot") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number + ) + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = botTokenInput, + onValueChange = { botTokenInput = it }, + label = { Text("Токен Бота (Опционально)") }, + placeholder = { Text("Токен от BotFather") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Text("SSH Порт", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = sshPortInput, + onValueChange = { sshPortInput = it.filter(Char::isDigit).take(5) }, + label = { Text("Порт для деплоя SSH") }, + placeholder = { Text("22") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number + ) + ) + + if (manualPortsEnabled) { + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Text("Порты сервера", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = dtlsPortInput, + onValueChange = { dtlsPortInput = it.filter(Char::isDigit).take(5) }, + label = { Text("Порт DTLS сервера") }, + placeholder = { Text("56000") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number + ) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = wgPortInput, + onValueChange = { wgPortInput = it.filter(Char::isDigit).take(5) }, + label = { Text("Порт WireGuard сервера") }, + placeholder = { Text("56001") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number + ) + ) + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = { + val finalPort = if (sshPortInput.isBlank()) "22" else sshPortInput + val finalDtls = normalizePort(dtlsPortInput, "56000") + val finalWg = normalizePort(wgPortInput, "56001") + scope.launch { + settingsStore.saveDeploySecrets(passInput, adminIdInput, botTokenInput, finalPort) + settingsStore.savePorts(finalDtls.toInt(), finalWg.toInt(), settingsStore.listenPort.first()) + onSaved(finalDtls, finalWg) + onDismiss() + } + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + enabled = passInput.isNotBlank(), + colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary) + ) { Text("Сохранить", fontWeight = FontWeight.SemiBold) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UninstallConfirmDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { + var confirmText by remember { mutableStateOf("") } + val isConfirmed = confirmText.trim().lowercase() == "да" + + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 8.dp + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + "Удаление WDTT с сервера", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Text( + "Будут удалены: бинарник, systemd-сервис, бот, конфигурация WDTT и только помеченные правила firewall/NAT для WDTT.\n\nЭто действие необратимо.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedTextField( + value = confirmText, + onValueChange = { confirmText = it }, + label = { Text("Введите «да» для подтверждения") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.error, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = onDismiss, modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) + ) { Text("Отмена") } + Button( + onClick = onConfirm, modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), enabled = isConfirmed, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Icon(Icons.Default.Delete, null, Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Удалить", fontWeight = FontWeight.Bold) + } + } + } + } + } +} diff --git a/app/src/main/java/com/wdtt/client/ui/ExceptionsTab.kt b/app/src/main/java/com/wdtt/client/ui/ExceptionsTab.kt new file mode 100644 index 0000000..ac58957 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ui/ExceptionsTab.kt @@ -0,0 +1,297 @@ +package com.wdtt.client.ui + +import android.content.pm.PackageManager +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wdtt.client.SettingsStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +import androidx.compose.runtime.Stable + +@Stable +data class AppItem( + val name: String, + val packageName: String, + val icon: ImageBitmap? +) + +object AppCache { + var cachedList: List? = null +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExceptionsTab() { + val context = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() + val settingsStore = remember { SettingsStore(context) } + + val savedExcluded by settingsStore.excludedApps.collectAsStateWithLifecycle(initialValue = "") + val selectedPackages = remember(savedExcluded) { + savedExcluded.split(",").filter { it.isNotEmpty() }.toSet() + } + + var appsList by remember { mutableStateOf>(AppCache.cachedList ?: emptyList()) } + var isLoading by remember { mutableStateOf(AppCache.cachedList == null) } + var searchQuery by remember { mutableStateOf("") } + + val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false) + + // Load Apps + LaunchedEffect(Unit) { + if (AppCache.cachedList != null) return@LaunchedEffect + isLoading = true + withContext(Dispatchers.IO) { + val list = mutableListOf() + val pm = context.packageManager + val installedApps = pm.getInstalledApplications(PackageManager.GET_META_DATA) + + installedApps.forEach { app -> + if (app.packageName != context.packageName && + !app.packageName.contains("vkontakte") && + !app.packageName.contains("vk.calls")) { + list.add(AppItem( + name = app.loadLabel(pm).toString(), + packageName = app.packageName, + icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap() + )) + } + } + appsList = list.sortedBy { it.name.lowercase() } + AppCache.cachedList = appsList + } + isLoading = false + } + + val filteredApps by remember { + derivedStateOf { + if (searchQuery.isBlank()) appsList + else appsList.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + } + } + } + + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + // Header + Text( + "Исключения приложений", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) + ) + + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Поиск приложений...", fontSize = 14.sp) }, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(52.dp), + shape = RoundedCornerShape(16.dp), + leadingIcon = { Icon(Icons.Default.Search, null, modifier = Modifier.size(20.dp)) }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(14.dp)) + + // Mode Toggle + AppSectionCard( + modifier = Modifier.padding(bottom = 12.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text( + "Режим исключений", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + if (isWhitelist) "БС: Неотмеченные приложения добавляются в туннель" + else "ЧС: Выбранные приложения исключаются из туннеля", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + lineHeight = 14.sp + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ModeChip("ЧС", !isWhitelist) { + if (isWhitelist) { + scope.launch { + val all = appsList.map { it.packageName }.toSet() + val inverted = all - selectedPackages + settingsStore.saveExceptionsMode(inverted.joinToString(","), false) + delay(300) + com.wdtt.client.TunnelManager.reloadWireGuard() + } + } + } + ModeChip("БС", isWhitelist) { + if (!isWhitelist) { + scope.launch { + val all = appsList.map { it.packageName }.toSet() + val inverted = all - selectedPackages + settingsStore.saveExceptionsMode(inverted.joinToString(","), true) + delay(300) + com.wdtt.client.TunnelManager.reloadWireGuard() + } + } + } + } + } + } + + // List + if (isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + val listState = rememberLazyListState() + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(filteredApps, key = { it.packageName }) { app -> + val isSelected = selectedPackages.contains(app.packageName) + + AppRow( + app = app, + isSelected = isSelected, + onClick = { + val newList = if (isSelected) { + selectedPackages - app.packageName + } else { + selectedPackages + app.packageName + } + scope.launch { + settingsStore.saveExcludedApps(newList.joinToString(",")) + com.wdtt.client.TunnelManager.reloadWireGuard() + } + } + ) + } + } + } + } +} + +@Composable +private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) { + FilterChip( + selected = selected, + onClick = onClick, + label = { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + label, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + }, + modifier = Modifier.width(64.dp), + shape = RoundedCornerShape(12.dp), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + labelColor = MaterialTheme.colorScheme.onSurface + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + selectedBorderColor = MaterialTheme.colorScheme.primary + ) + ) +} + +@Composable +fun AppRow(app: AppItem, isSelected: Boolean, onClick: () -> Unit) { + Surface( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = if (isSelected) 4.dp else 0.dp + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (app.icon != null) { + Image( + bitmap = app.icon, + contentDescription = null, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) + ) + } else { + Box(modifier = Modifier.size(40.dp).background(Color.Gray, RoundedCornerShape(8.dp))) + } + + Spacer(Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = app.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + Text( + text = app.packageName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + + Checkbox( + checked = isSelected, + onCheckedChange = null, + colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colorScheme.primary) + ) + } + } +} diff --git a/app/src/main/java/com/wdtt/client/ui/FloatingToolbar.kt b/app/src/main/java/com/wdtt/client/ui/FloatingToolbar.kt new file mode 100644 index 0000000..764a77f --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ui/FloatingToolbar.kt @@ -0,0 +1,305 @@ +package com.wdtt.client.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.wdtt.client.R +import android.os.Build +import androidx.compose.ui.graphics.Color +import kotlin.math.roundToInt + +@Composable +fun FloatingToolbar( + currentTheme: String, + onThemeChange: (String) -> Unit, + isDynamicColor: Boolean, + onDynamicColorChange: (Boolean) -> Unit, + currentPalette: String, + onPaletteChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val screenHeightPx = remember(configuration.screenHeightDp, density) { + with(density) { configuration.screenHeightDp.dp.toPx() } + } + val screenWidthPx = remember(configuration.screenWidthDp, density) { + with(density) { configuration.screenWidthDp.dp.toPx() } + } + + var offsetY by rememberSaveable { mutableFloatStateOf(-1f) } + var isRightSide by rememberSaveable { mutableStateOf(true) } + var isExpanded by rememberSaveable { mutableStateOf(false) } + var tabHeightPx by remember { mutableFloatStateOf(0f) } + var panelHeightPx by remember { mutableFloatStateOf(0f) } + + val tabWidthDp = 42.dp + val tabHeightDp = 52.dp + val panelWidthDp = 220.dp + + val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } } + val fallbackTabHeightPx = remember(density) { with(density) { tabHeightDp.toPx() } } + val edgePaddingPx = remember(density) { with(density) { 8.dp.toPx() } } + val safeTopPx = WindowInsets.safeDrawing.getTop(density).toFloat() + val safeBottomPx = WindowInsets.safeDrawing.getBottom(density).toFloat() + val effectiveTabHeightPx = maxOf(tabHeightPx, fallbackTabHeightPx) + val floatingHeightPx = if (isExpanded && panelHeightPx > 0f) { + maxOf(effectiveTabHeightPx, panelHeightPx) + } else { + effectiveTabHeightPx + } + val minOffsetY = safeTopPx + edgePaddingPx + val maxOffsetY = (screenHeightPx - safeBottomPx - floatingHeightPx - edgePaddingPx) + .coerceAtLeast(minOffsetY) + val defaultOffsetY = (screenHeightPx * 0.24f).coerceIn(minOffsetY, maxOffsetY) + + val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f + + val animatedTabXPx by animateFloatAsState( + targetValue = targetXPx, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "tab_shift" + ) + + LaunchedEffect(minOffsetY, maxOffsetY) { + offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY) + } + + Box(modifier = modifier.fillMaxSize()) { + Surface( + onClick = { isExpanded = !isExpanded }, + modifier = Modifier + .offset { IntOffset(animatedTabXPx.roundToInt(), offsetY.roundToInt()) } + .onGloballyPositioned { coordinates -> + tabHeightPx = coordinates.size.height.toFloat() + } + .pointerInput(minOffsetY, maxOffsetY) { + detectDragGestures( + onDrag = { change, dragAmount -> + change.consume() + offsetY = (offsetY + dragAmount.y).coerceIn(minOffsetY, maxOffsetY) + } + ) + }, + shape = if (isRightSide) + RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp) + else + RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), + shadowElevation = 6.dp, + tonalElevation = 4.dp, + ) { + Box( + modifier = Modifier.size(tabWidthDp, tabHeightDp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_palette), + contentDescription = "Тема", + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.offset { + val panelWidthPx = with(density) { panelWidthDp.toPx() } + val gap = with(density) { 8.dp.toPx() } + val panelX = if (isRightSide) { + (targetXPx - panelWidthPx - gap).roundToInt() + } else { + (tabWidthPx + gap).roundToInt() + } + IntOffset(panelX, offsetY.roundToInt()) + } + ) { + Surface( + modifier = Modifier.onGloballyPositioned { coordinates -> + panelHeightPx = coordinates.size.height.toFloat() + }, + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp, + tonalElevation = 4.dp, + ) { + Column( + modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + "Тема", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) + ) + + ThemeOption( + icon = R.drawable.ic_auto, + label = "Системная", + selected = currentTheme == "system", + onClick = { onThemeChange("system"); isExpanded = false } + ) + ThemeOption( + icon = R.drawable.ic_light_mode, + label = "Светлая", + selected = currentTheme == "light", + onClick = { onThemeChange("light"); isExpanded = false } + ) + ThemeOption( + icon = R.drawable.ic_dark_mode, + label = "Тёмная", + selected = currentTheme == "dark", + onClick = { onThemeChange("dark"); isExpanded = false } + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + + val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val showDynamicColorOn = isDynamicColor && supportsDynamicColor + val showPalettes = !showDynamicColorOn + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Динамические", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = if (supportsDynamicColor) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + } + ) + Switch( + checked = showDynamicColorOn, + onCheckedChange = { onDynamicColorChange(it) }, + enabled = supportsDynamicColor, + modifier = Modifier.scale(0.8f) + ) + } + + AnimatedVisibility(visible = showPalettes) { + Column { + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + Text( + "Палитра", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp, start = 4.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + PaletteCircle("indigo", 0xFF5B588D, currentPalette, onPaletteChange) + PaletteCircle("forest", 0xFF5F5D68, currentPalette, onPaletteChange) + PaletteCircle("espresso", 0xFF6D4C41, currentPalette, onPaletteChange) + } + Spacer(modifier = Modifier.height(6.dp)) + } + } + } + } + } + } +} + +@Composable +private fun ThemeOption( + icon: Int, + label: String, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(24.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + fontSize = 13.sp + ) + } + } +} + +@Composable +fun PaletteCircle( + paletteId: String, + colorHex: Long, + selectedId: String, + onClick: (String) -> Unit +) { + val isSelected = paletteId == selectedId + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(Color(colorHex)) + .clickable { onClick(paletteId) } + .then( + if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.primary, CircleShape) + else Modifier + ) + ) +} diff --git a/app/src/main/java/com/wdtt/client/ui/InfoTab.kt b/app/src/main/java/com/wdtt/client/ui/InfoTab.kt new file mode 100644 index 0000000..f187b5a --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ui/InfoTab.kt @@ -0,0 +1,902 @@ +package com.wdtt.client.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wdtt.client.BuildConfig +import com.wdtt.client.R +import com.wdtt.client.SettingsStore +import com.wdtt.client.UPDATE_DIALOG_ACTION_POSTPONED +import com.wdtt.client.UPDATE_DIALOG_ACTION_UPDATE +import com.wdtt.client.WDTTColors +import com.wdtt.client.fetchLatestReleaseInfo +import com.wdtt.client.isNewerVersion +import kotlinx.coroutines.launch +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +private const val ReleasesUrl = "https://github.com/amurcanov/proxy-turn-vk-android/releases" +private const val IssuesUrl = "https://github.com/amurcanov/proxy-turn-vk-android/issues/new" +private const val DeveloperProfileUrl = "https://github.com/amurcanov" +private const val RepositoryUrl = "https://github.com/amurcanov/proxy-turn-vk-android" +private const val DonateUrl = "" +private val DonateActionButtonColor = Color(0xFF00AEA5) + +private val browserPackages = listOf( + "com.android.chrome", + "com.google.android.googlequicksearchbox", + "org.mozilla.firefox", + "com.yandex.browser", + "ru.yandex.searchplugin", + "com.yandex.browser.lite", + "com.opera.browser", + "com.opera.mini.native", + "com.microsoft.emmx", + "com.brave.browser", + "com.duckduckgo.mobile.android", + "com.sec.android.app.sbrowser", + "com.vivaldi.browser", + "com.kiwibrowser.browser", +) + +private val Android16BlobShape: Shape = GenericShape { size, _ -> + val centerX = size.width / 2f + val centerY = size.height / 2f + val outerRadius = min(size.width, size.height) / 2f + val innerRadius = outerRadius * 0.92f + val points = 14 + + for (i in 0 until points * 2) { + val angle = (-PI / 2.0) + (i * PI / points) + val radius = if (i % 2 == 0) outerRadius else innerRadius + val x = centerX + (radius * cos(angle)).toFloat() + val y = centerY + (radius * sin(angle)).toFloat() + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() +} + +private fun openUrlInBrowser(context: Context, url: String) { + try { + val pm = context.packageManager + val uri = Uri.parse(url) + for (pkg in browserPackages) { + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + setPackage(pkg) + } + if (intent.resolveActivity(pm) != null) { + context.startActivity(intent) + return + } + } + val intent = Intent(Intent.ACTION_VIEW, uri).apply { addCategory(Intent.CATEGORY_BROWSABLE) } + if (intent.resolveActivity(pm) != null) context.startActivity(intent) + } catch (_: Exception) { + } +} + +@Composable +fun InfoTab() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val settingsStore = remember { SettingsStore(context) } + val currentVersion = remember { "v${BuildConfig.VERSION_NAME.removePrefix("v")}" } + var isCheckingUpdates by remember { mutableStateOf(false) } + var pendingManualRelease by remember { mutableStateOf(null) } + var showHelpDialog by remember { mutableStateOf(false) } + var showDonateDialog by remember { mutableStateOf(false) } + var actionsExpanded by rememberSaveable { mutableStateOf(true) } + var projectExpanded by rememberSaveable { mutableStateOf(true) } + val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "") + val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "") + val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) { + when { + isCheckingUpdates -> "Проверяем GitHub releases..." + updateLatestVersion.isNotBlank() && isNewerVersion(currentVersion, updateLatestVersion) -> + "На GitHub доступна версия $updateLatestVersion" + updateLatestVersion.isNotBlank() -> "Последняя версия: $updateLatestVersion" + updateLastError.isNotBlank() -> "Последняя проверка завершилась ошибкой" + else -> "Проверить GitHub вручную" + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 28.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Информация", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + } + + InfoHeroCard(currentVersion = currentVersion, onSupportClick = { showDonateDialog = true }) + + ExpandableSectionCard( + title = "Действия", + itemCount = "4 пункта", + expanded = actionsExpanded, + onToggle = { actionsExpanded = !actionsExpanded }, + icon = { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + InfoActionTile( + title = "Поднять вопрос", + subtitle = "Открыть GitHub issue", + modifier = Modifier.weight(1f), + onClick = { openUrlInBrowser(context, IssuesUrl) }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + + InfoActionTile( + title = "Собрать отчёт", + subtitle = "Android, ABI, версия, устройство", + modifier = Modifier.weight(1f), + onClick = { + val clipboard = context.getSystemService(ClipboardManager::class.java) + clipboard?.setPrimaryClip(ClipData.newPlainText("WDTT Report", buildSupportReport())) + Toast.makeText(context, "Отчёт сформирован и скопирован", Toast.LENGTH_SHORT).show() + }, + icon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + } + + WideActionTile( + title = "Справка", + subtitle = "Коротко про VPN, исключения, капчу и запуск", + onClick = { showHelpDialog = true }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.HelpOutline, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + + WideActionTile( + title = "Проверить обновления", + subtitle = updateStatus, + onClick = { + if (isCheckingUpdates) return@WideActionTile + isCheckingUpdates = true + scope.launch { + val checkedAt = System.currentTimeMillis() + val release = fetchLatestReleaseInfo(currentVersion) + val latest = release?.versionTag + settingsStore.saveUpdateState( + lastCheckAt = checkedAt, + latestVersion = latest ?: "", + error = if (release == null) "Не удалось проверить" else "" + ) + isCheckingUpdates = false + + if (release == null) { + val message = if (updateLatestVersion.isNotBlank()) { + "Не удалось проверить. Последняя известная версия: $updateLatestVersion" + } else { + "Не удалось проверить обновления" + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + return@launch + } + + if (isNewerVersion(currentVersion, release.versionTag)) { + settingsStore.saveUpdateDialogShown(release.versionTag, checkedAt) + pendingManualRelease = release + } else { + Toast.makeText( + context, + "У вас уже последняя версия: ${release.versionTag}", + Toast.LENGTH_SHORT + ).show() + } + } + }, + icon = { + Icon( + imageVector = Icons.Default.Update, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + } + + pendingManualRelease?.let { release -> + AppUpdateDialog( + release = release, + onPostpone = { + pendingManualRelease = null + Toast.makeText(context, "Обновление отложено на 24 часа.", Toast.LENGTH_SHORT).show() + scope.launch { + val now = System.currentTimeMillis() + settingsStore.saveUpdatePostpone( + version = release.versionTag, + until = now + 24L * 60L * 60L * 1000L + ) + settingsStore.saveUpdateDialogAction( + version = release.versionTag, + action = UPDATE_DIALOG_ACTION_POSTPONED, + actedAt = now + ) + } + }, + onUpdate = { + pendingManualRelease = null + scope.launch { + settingsStore.saveUpdateDialogAction( + version = release.versionTag, + action = UPDATE_DIALOG_ACTION_UPDATE, + actedAt = System.currentTimeMillis() + ) + openUrlInBrowser(context, release.releaseUrl) + } + } + ) + } + + ExpandableSectionCard( + title = "О проекте", + itemCount = "3 ссылки", + expanded = projectExpanded, + onToggle = { projectExpanded = !projectExpanded }, + icon = { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) { + ProjectLinkRow( + title = "Автор Android-версии", + subtitle = "GitHub профиль amurcanov", + onClick = { openUrlInBrowser(context, DeveloperProfileUrl) }, + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + + ProjectLinkRow( + title = "Репозиторий WDTT", + subtitle = "Исходники и релизы приложения", + onClick = { openUrlInBrowser(context, RepositoryUrl) }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_github), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + + ProjectLinkRow( + title = "Актуальные релизы", + subtitle = "Страница загрузки APK", + onClick = { openUrlInBrowser(context, ReleasesUrl) }, + icon = { + Icon( + imageVector = Icons.Default.Update, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + } + + if (showHelpDialog) ImportantInfoDialog(onDismiss = { showHelpDialog = false }) + if (showDonateDialog) DonateDialog(onDismiss = { showDonateDialog = false }) +} + +@Composable +private fun InfoHeroCard(currentVersion: String, onSupportClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + val heroBrush = remember(colors.primaryContainer, colors.secondaryContainer, colors.surfaceVariant) { + Brush.linearGradient( + listOf( + colors.primaryContainer, + colors.secondaryContainer, + colors.surfaceVariant + ) + ) + } + val glassColor = if (isDark) colors.surface.copy(alpha = 0.46f) else Color.White.copy(alpha = 0.54f) + val glassBorder = colors.outlineVariant.copy(alpha = if (isDark) 0.50f else 0.32f) + + Surface( + shape = RoundedCornerShape(32.dp), + color = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + shadowElevation = 10.dp, + tonalElevation = 0.dp + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(32.dp)) + .background(heroBrush) + .padding(22.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 30.dp, y = (-34).dp) + .size(138.dp) + .clip(Android16BlobShape) + .background(colors.primary.copy(alpha = 0.10f)) + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 26.dp, y = 30.dp) + .size(112.dp) + .clip(Android16BlobShape) + .background(colors.secondary.copy(alpha = 0.12f)) + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HeroMetaPill( + text = "WDTT", + containerColor = glassColor, + borderColor = glassBorder, + modifier = Modifier.weight(1f) + ) + HeroMetaPill( + text = currentVersion, + containerColor = colors.primary.copy(alpha = if (isDark) 0.18f else 0.10f), + borderColor = colors.primary.copy(alpha = if (isDark) 0.22f else 0.14f), + modifier = Modifier.weight(1f) + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "WDTT VPN Tunnel", + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Black, + fontSize = 30.sp, + lineHeight = 34.sp + ), + color = colors.onSurface + ) + Text( + text = "Android-клиент для TURN/VK туннеля с WireGuard, капчей и управлением сервером.", + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + lineHeight = 21.sp + ) + } + + Button( + onClick = onSupportClick, + shape = RoundedCornerShape(22.dp), + colors = ButtonDefaults.buttonColors( + containerColor = DonateActionButtonColor, + contentColor = Color.White + ), + modifier = Modifier + .fillMaxWidth() + .height(54.dp) + ) { + Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Поддержать проект", fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + } + } + } +} + +@Composable +private fun HeroMetaPill( + text: String, + containerColor: Color, + borderColor: Color, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = containerColor, + border = BorderStroke(1.dp, borderColor), + modifier = modifier + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun ExpandableSectionCard( + title: String, + itemCount: String, + expanded: Boolean, + onToggle: () -> Unit, + icon: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + val arrowRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "section_arrow_rotation" + ) + + AppSectionCard( + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onToggle) + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.primaryContainer) { + Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { icon() } + } + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + MetaChip(text = itemCount) + + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .rotate(arrowRotation) + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.30f)) + content() + } + } + } +} + +@Composable +private fun MetaChip(text: String) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun InfoActionTile( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + icon: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.70f), + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 116.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer) { + Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { icon() } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + } + } + } +} + +@Composable +private fun WideActionTile( + title: String, + subtitle: String, + onClick: () -> Unit, + icon: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.70f), + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 15.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer) { + Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { icon() } + } + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun ProjectLinkRow( + title: String, + subtitle: String, + onClick: () -> Unit, + icon: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.64f), + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer) { + Box( + modifier = Modifier.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp), + contentAlignment = Alignment.Center + ) { + icon() + } + } + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun DonateDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 10.dp, + shadowElevation = 14.dp, + modifier = Modifier.fillMaxWidth(0.92f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Поддержка проекта", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + FilledTonalIconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Закрыть") + } + } + + Text( + text = "Если приложение реально помогает, можно поддержать Android-версию проекта.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = 20.sp + ) + + Button( + onClick = { openUrlInBrowser(context, DonateUrl) }, + modifier = Modifier + .fillMaxWidth() + .height(62.dp), + shape = RoundedCornerShape(22.dp), + colors = ButtonDefaults.buttonColors( + containerColor = WDTTColors.donate, + contentColor = Color.White + ) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_yoomoney), + contentDescription = "ЮMoney", + tint = Color.Unspecified, + modifier = Modifier + .width(126.dp) + .height(28.dp) + ) + } + } + } + } +} + +private fun buildSupportReport(): String { + val androidVersion = Build.VERSION.RELEASE ?: "?" + val sdkInt = Build.VERSION.SDK_INT + val primaryAbi = Build.SUPPORTED_ABIS.firstOrNull().orEmpty().ifBlank { "unknown" } + val supportedAbis = Build.SUPPORTED_ABIS.joinToString().ifBlank { "unknown" } + val manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "unknown" } + val brand = Build.BRAND.orEmpty().ifBlank { "unknown" } + val model = Build.MODEL.orEmpty().ifBlank { "unknown" } + val device = Build.DEVICE.orEmpty().ifBlank { "unknown" } + val product = Build.PRODUCT.orEmpty().ifBlank { "unknown" } + val hardware = Build.HARDWARE.orEmpty().ifBlank { "unknown" } + val board = Build.BOARD.orEmpty().ifBlank { "unknown" } + val romDisplay = Build.DISPLAY.orEmpty().ifBlank { "unknown" } + val buildId = Build.ID.orEmpty().ifBlank { "unknown" } + val buildFingerprint = Build.FINGERPRINT.orEmpty().ifBlank { "unknown" } + val buildType = Build.TYPE.orEmpty().ifBlank { "unknown" } + val socManufacturer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Build.SOC_MANUFACTURER.orEmpty().ifBlank { "unknown" } + } else { + "n/a" + } + val socModel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Build.SOC_MODEL.orEmpty().ifBlank { "unknown" } + } else { + "n/a" + } + + return buildString { + appendLine("Версия приложения: ${BuildConfig.VERSION_NAME}") + appendLine("Андроид: $androidVersion (SDK $sdkInt)") + appendLine("Устройство: $manufacturer / $brand / $model") + appendLine("Код устройства: $device") + appendLine("Продукт: $product") + appendLine("ABI: $primaryAbi") + appendLine("Все ABI: $supportedAbis") + appendLine("SoC: $socManufacturer / $socModel") + appendLine("Hardware: $hardware") + appendLine("Board: $board") + appendLine("ROM: $romDisplay") + appendLine("Build ID: $buildId") + appendLine("Build type: $buildType") + appendLine("Fingerprint: $buildFingerprint") + }.trim() +} diff --git a/app/src/main/java/com/wdtt/client/ui/LogsTab.kt b/app/src/main/java/com/wdtt/client/ui/LogsTab.kt new file mode 100644 index 0000000..941d74d --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ui/LogsTab.kt @@ -0,0 +1,147 @@ +package com.wdtt.client.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.core.* +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wdtt.client.LogEntry +import com.wdtt.client.TunnelManager +import com.wdtt.client.WDTTColors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsTab() { + val context = LocalContext.current + val currentLogs by TunnelManager.logs.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + // Toolbar + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Лог событий", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + Row { + IconButton(onClick = { TunnelManager.clearLogs() }) { + Icon(Icons.Default.Delete, contentDescription = "Clear", tint = MaterialTheme.colorScheme.primary) + } + IconButton(onClick = { + val text = currentLogs.joinToString("\n") { "${it.message} (x${it.count})" } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("WDTT Logs", text) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show() + }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy", tint = MaterialTheme.colorScheme.primary) + } + } + } + + // Logs container — адаптивный к теме + val isDark = isSystemInDarkTheme() + val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg + + Card( + modifier = Modifier.fillMaxSize(), + colors = CardDefaults.cardColors(containerColor = terminalBg), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(12.dp), + contentPadding = PaddingValues(bottom = 12.dp) + ) { + items(currentLogs, key = { it.key }) { entry -> + LogLine(entry) + } + } + } + } +} + +@Composable +fun LogLine(entry: LogEntry) { + val color = when { + entry.isError -> WDTTColors.terminalRed + entry.priority <= 2 -> WDTTColors.terminalGreen + entry.priority == 3 -> WDTTColors.terminalBlue + else -> WDTTColors.terminalText + } + + var trigger by remember { mutableIntStateOf(0) } + LaunchedEffect(entry.count) { trigger++ } + + val animatedScale by animateFloatAsState( + targetValue = if (trigger > 0) 1.15f else 1.0f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "scale", + finishedListener = { trigger = 0 } + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + color = WDTTColors.terminalCounter.copy(alpha = 0.2f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .defaultMinSize(minWidth = 24.dp, minHeight = 24.dp) + .graphicsLayer(scaleX = animatedScale, scaleY = animatedScale) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Text( + text = "${entry.count}", + color = WDTTColors.terminalBlue, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = entry.message, + color = color, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + fontWeight = if (entry.isError) FontWeight.Bold else FontWeight.Normal, + lineHeight = 18.sp, + modifier = Modifier.weight(1f) + ) + } +} diff --git a/app/src/main/java/com/wdtt/client/ui/SettingsTab.kt b/app/src/main/java/com/wdtt/client/ui/SettingsTab.kt new file mode 100644 index 0000000..59ee179 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/ui/SettingsTab.kt @@ -0,0 +1,1099 @@ +package com.wdtt.client.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wdtt.client.SettingsStore +import com.wdtt.client.TunnelManager +import com.wdtt.client.TunnelService +import com.wdtt.client.WDTTColors +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import kotlin.math.roundToInt + +private const val WORKERS_PER_GROUP = 9 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsTab() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val settingsStore = remember { SettingsStore(context) } + + val currentDensity = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density(currentDensity.density, fontScale = 1f) + ) { + SettingsTabContent(context, scope, settingsStore) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutines.CoroutineScope, settingsStore: SettingsStore) { + val savedConnectionPassword by settingsStore.connectionPassword.collectAsStateWithLifecycle(initialValue = "") + val savedManualPortsEnabled by settingsStore.manualPortsEnabled.collectAsStateWithLifecycle(initialValue = false) + val savedServerDtlsPort by settingsStore.serverDtlsPort.collectAsStateWithLifecycle(initialValue = 56000) + val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001) + val savedListenPort by settingsStore.listenPort.collectAsStateWithLifecycle(initialValue = 9000) + + val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle() + + val cooldownSeconds by TunnelManager.cooldownSeconds.collectAsStateWithLifecycle() + var wasRunning by remember { mutableStateOf(false) } + + LaunchedEffect(tunnelRunning) { + if (wasRunning && !tunnelRunning) { + TunnelManager.startCooldown(5) + } + wasRunning = tunnelRunning + } + + var peerInput by rememberSaveable { mutableStateOf("") } + var vkHash1 by rememberSaveable { mutableStateOf("") } + var vkHash2 by rememberSaveable { mutableStateOf("") } + var vkHash3 by rememberSaveable { mutableStateOf("") } + var vkHash4 by rememberSaveable { mutableStateOf("") } + var workersInput by rememberSaveable { mutableFloatStateOf(18f) } + var showHashesDialog by rememberSaveable { mutableStateOf(false) } + var autoCaptchaEnabled by rememberSaveable { mutableStateOf(true) } + var useWVCaptcha by rememberSaveable { mutableStateOf(false) } + var isManualMode by rememberSaveable { mutableStateOf(true) } + var wbvManualMode by rememberSaveable { mutableStateOf(true) } + var manualPortsEnabled by rememberSaveable { mutableStateOf(false) } + var serverDtlsPortInput by rememberSaveable { mutableStateOf("56000") } + var serverWgPortInput by rememberSaveable { mutableStateOf("56001") } + + val allHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { listOf(vkHash1, vkHash2, vkHash3, vkHash4) } + val uniqueHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { allHashes.filter { it.isNotBlank() && it.length >= 16 }.distinct() } + val filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.size } + val combinedHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.joinToString(",") } + val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() } + var portInput by rememberSaveable { mutableStateOf("9000") } + var sniInput by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(dynamicMaxWorkers) { + if (workersInput > dynamicMaxWorkers) { + workersInput = dynamicMaxWorkers + } + } + + val currentWorkers = workersInput.coerceIn(WORKERS_PER_GROUP.toFloat(), dynamicMaxWorkers) + + val hashErrors = remember(vkHash1, vkHash2, vkHash3, vkHash4) { + buildList { + allHashes.forEachIndexed { i, h -> + if (h.isNotBlank() && h.length < 16) add("Хеш ${i + 1} — короткий") + } + val filled = allHashes.filter { it.isNotBlank() && it.length >= 16 } + if (filled.size != filled.distinct().size) add("Есть дубликаты хешей") + } + } + val hasInputHashErrors = remember(vkHash1, vkHash2, vkHash3, vkHash4) { hashErrors.isNotEmpty() } + + var showSecretsDialog by rememberSaveable { mutableStateOf(false) } + var initialized by remember { mutableStateOf(false) } + + fun parseHashes(raw: String) { + val parts = raw.split(Regex("[,\\s\\n]+")).map { stripVkUrlStatic(it) }.filter { it.isNotEmpty() } + vkHash1 = parts.getOrElse(0) { "" } + vkHash2 = parts.getOrElse(1) { "" } + vkHash3 = parts.getOrElse(2) { "" } + vkHash4 = parts.getOrElse(3) { "" } + } + + fun normalizeHashes(vararg hashes: String): String { + return hashes + .map { stripVkUrlStatic(it) } + .filter { it.isNotBlank() && it.length >= 16 } + .distinct() + .joinToString(",") + } + + LaunchedEffect(Unit) { + val peer = settingsStore.peer.first() + val hashes = settingsStore.vkHashes.first() + val workers = settingsStore.workersPerHash.first() + val port = settingsStore.listenPort.first() + val manualPorts = settingsStore.manualPortsEnabled.first() + val serverDtlsPort = settingsStore.serverDtlsPort.first() + val serverWgPort = settingsStore.serverWgPort.first() + val sni = settingsStore.sni.first() + val captchaMode = settingsStore.captchaMode.first() + val captchaMethod = settingsStore.captchaSolveMethod.first() + val wbvCaptchaMethod = settingsStore.captchaWbvSolveMethod.first() + + peerInput = peer + parseHashes(hashes) + workersInput = roundToGroup(workers.toFloat(), (listOf(vkHash1, vkHash2, vkHash3, vkHash4).count { it.isNotBlank() }.coerceAtLeast(1) * 27).toFloat()) + portInput = port.toString() + manualPortsEnabled = manualPorts + serverDtlsPortInput = serverDtlsPort.toString() + serverWgPortInput = serverWgPort.toString() + sniInput = sni + autoCaptchaEnabled = captchaMode == "auto" + useWVCaptcha = captchaMode != "rjs" + wbvManualMode = wbvCaptchaMethod != "auto" + isManualMode = if (captchaMode == "wv") wbvManualMode else captchaMethod != "auto" + + initialized = true + } + + LaunchedEffect(savedManualPortsEnabled) { + manualPortsEnabled = savedManualPortsEnabled + } + + LaunchedEffect(savedServerDtlsPort) { + serverDtlsPortInput = savedServerDtlsPort.toString() + } + + LaunchedEffect(savedServerWgPort) { + serverWgPortInput = savedServerWgPort.toString() + } + + LaunchedEffect(savedListenPort) { + portInput = savedListenPort.toString() + } + + if (!initialized) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + return + } + + var saveJob by remember { mutableStateOf(null) } + + fun saveTunnelSettingsNow(hashes: String = combinedHashes, onSaved: (() -> Unit)? = null) { + saveJob?.cancel() + scope.launch { + val savedLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000 + settingsStore.save( + peerInput, hashes, "", + workersInput.toInt(), "udp", savedLocalPort, sniInput, false + ) + onSaved?.invoke() + } + } + + fun scheduleSave() { + saveJob?.cancel() + saveJob = scope.launch { + delay(300) + val savedLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000 + settingsStore.save( + peerInput, combinedHashes, "", + workersInput.toInt(), "udp", savedLocalPort, sniInput, false + ) + } + } + + val scrollState = rememberScrollState() + + val isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":") + val isHashesValid = combinedHashes.isNotBlank() + val isValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors + val effectiveServerDtlsPort = if (manualPortsEnabled) serverDtlsPortInput.toIntOrNull()?.coerceIn(1, 65535) ?: 56000 else 56000 + val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000 + var pendingStartAfterVpnPermission by remember { mutableStateOf(false) } + + fun startTunnelService() { + val effectiveCaptchaMode = if (autoCaptchaEnabled) "auto" else if (useWVCaptcha) "wv" else "rjs" + val effectiveCaptchaSolveMethod = if (!autoCaptchaEnabled && effectiveCaptchaMode == "wv" && isManualMode) "manual" else "auto" + saveJob?.cancel() + scope.launch { + settingsStore.save( + peerInput, combinedHashes, "", + workersInput.toInt(), "udp", effectiveLocalPort, sniInput, false + ) + settingsStore.saveCaptchaMode(effectiveCaptchaMode) + settingsStore.saveCaptchaSolveMethod(effectiveCaptchaSolveMethod) + } + val intent = Intent(context, TunnelService::class.java).apply { + action = "START" + putExtra("peer", "$peerInput:$effectiveServerDtlsPort") + putExtra("vk_hashes", combinedHashes) + putExtra("secondary_vk_hash", "") + putExtra("workers_per_hash", workersInput.toInt()) + putExtra("port", effectiveLocalPort) + putExtra("sni", sniInput) + putExtra("connection_password", savedConnectionPassword) + putExtra("captcha_mode", effectiveCaptchaMode) + putExtra("captcha_solve_method", effectiveCaptchaSolveMethod) + } + if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) + else context.startService(intent) + } + + val vpnPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (pendingStartAfterVpnPermission) { + pendingStartAfterVpnPermission = false + if (VpnService.prepare(context) == null) { + startTunnelService() + } else { + Toast.makeText(context, "VPN-разрешение не выдано", Toast.LENGTH_SHORT).show() + } + } + } + + fun requestVpnAndStart() { + val vpnIntent = VpnService.prepare(context) + if (vpnIntent != null) { + pendingStartAfterVpnPermission = true + vpnPermissionLauncher.launch(vpnIntent) + } else { + startTunnelService() + } + } + + // ═══ Dialogs ═══ + if (showSecretsDialog) { + SecretsDialog( + settingsStore = settingsStore, + initialPassword = savedConnectionPassword, + manualPortsEnabled = manualPortsEnabled, + initialServerDtlsPort = serverDtlsPortInput, + initialServerWgPort = serverWgPortInput, + initialLocalPort = portInput, + onSaved = { dtls, wg, local -> + serverDtlsPortInput = dtls + serverWgPortInput = wg + portInput = local + }, + onDismiss = { showSecretsDialog = false } + ) + } + + if (showHashesDialog) { + HashesDialog( + hash1 = vkHash1, + hash2 = vkHash2, + hash3 = vkHash3, + hash4 = vkHash4, + onSave = { h1, h2, h3, h4 -> + val cleaned1 = stripVkUrlStatic(h1) + val cleaned2 = stripVkUrlStatic(h2) + val cleaned3 = stripVkUrlStatic(h3) + val cleaned4 = stripVkUrlStatic(h4) + vkHash1 = cleaned1 + vkHash2 = cleaned2 + vkHash3 = cleaned3 + vkHash4 = cleaned4 + saveTunnelSettingsNow(normalizeHashes(cleaned1, cleaned2, cleaned3, cleaned4)) { + showHashesDialog = false + } + }, + onDismiss = { showHashesDialog = false } + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // ═══ Заголовок раздела ═══ + Text( + "Настройки туннеля", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + + // ═══ Настройки туннеля ═══ + AppSectionCard( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = peerInput, + onValueChange = { + peerInput = it.filter { c -> c != ' ' } + scheduleSave() + }, + label = { Text("IP сервера или домен (без порта)") }, + placeholder = { Text("1.2.3.4 (или test.com)") }, + singleLine = true, + isError = !isPeerValid && peerInput.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + ) + + OutlinedButton( + onClick = { showHashesDialog = true }, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + contentColor = MaterialTheme.colorScheme.onSurface + ), + border = BorderStroke( + 1.dp, + if (hasInputHashErrors) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) { + Icon(Icons.Default.Tag, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Настройка VK Хешей ($filledHashCount/4)", fontWeight = FontWeight.SemiBold) + } + + val errorTexts = hashErrors.filter { !it.contains("короткий") } + if (errorTexts.isNotEmpty()) { + Text( + text = errorTexts.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + + // ═══ Мощность + Капча ═══ + AppSectionCard( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + // — Мощность — + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Мощность", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "${currentWorkers.toInt()}", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(Modifier.height(4.dp)) + + val maxWorkers = dynamicMaxWorkers + val minWorkers = WORKERS_PER_GROUP.toFloat() + val currentWorkersVal = roundToGroup(currentWorkers.coerceIn(minWorkers, maxWorkers), maxWorkers) + + CompactSteppedSlider( + value = currentWorkersVal, + onValueChange = { raw -> + workersInput = roundToGroup(raw, maxWorkers) + scheduleSave() + }, + valueRange = minWorkers..maxWorkers, + stepSize = WORKERS_PER_GROUP.toFloat(), + enabled = !tunnelRunning, + modifier = Modifier.fillMaxWidth() + ) + + // — Разделитель — + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + + // — Авто капча — + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + if (autoCaptchaEnabled) "Авто капча" else "Ручная капча", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Switch( + checked = autoCaptchaEnabled, + onCheckedChange = { enabled -> + autoCaptchaEnabled = enabled + scope.launch { + if (enabled) { + settingsStore.saveCaptchaMode("auto") + settingsStore.saveCaptchaSolveMethod("auto") + } else { + val mode = if (useWVCaptcha) "wv" else "rjs" + settingsStore.saveCaptchaMode(mode) + settingsStore.saveCaptchaSolveMethod(if (mode == "wv" && isManualMode) "manual" else "auto") + } + } + } + ) + } + + AnimatedVisibility( + visible = !autoCaptchaEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + // — Разделитель — + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + + // — Метод обхода капчи — + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + "Метод обхода капчи", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ProtocolChip("WBV", useWVCaptcha, enabled = true) { + useWVCaptcha = true + isManualMode = wbvManualMode + scope.launch { + settingsStore.saveCaptchaMode("wv") + settingsStore.saveCaptchaSolveMethod(if (wbvManualMode) "manual" else "auto") + } + } + ProtocolChip("RJS", !useWVCaptcha, enabled = true, isError = false) { + useWVCaptcha = false + isManualMode = false + scope.launch { + settingsStore.saveCaptchaMode("rjs") + settingsStore.saveCaptchaSolveMethod("auto") + } + } + } + } + + // — Разделитель — + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + + // — Режим обхода — + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + "Режим обхода", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (useWVCaptcha) { + ProtocolChip( + "РУЧ", + isManualMode, + enabled = true, + isError = false + ) { + isManualMode = true + wbvManualMode = true + scope.launch { settingsStore.saveWbvCaptchaSolveMethod("manual") } + } + ProtocolChip( + "АВТ", + !isManualMode, + enabled = true, + isError = false + ) { + isManualMode = false + wbvManualMode = false + scope.launch { settingsStore.saveWbvCaptchaSolveMethod("auto") } + } + } else { + ProtocolChip( + "АВТ", + selected = true, + enabled = true, + isError = false + ) {} + } + } + } + } + } + } + + // ═══ Кнопки: Секреты + Подключить ═══ + val tunnelSecretsMissing = savedConnectionPassword.isBlank() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { showSecretsDialog = true }, + modifier = Modifier.height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface, + contentColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface + ), + border = BorderStroke( + 1.dp, + if (tunnelSecretsMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) { + Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Секреты", fontWeight = FontWeight.SemiBold) + } + + val buttonColor by animateColorAsState( + targetValue = if (tunnelRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + animationSpec = tween(400), + label = "btn_color" + ) + + Button( + onClick = { + if (tunnelRunning) { + context.startService( + Intent(context, TunnelService::class.java).apply { action = "STOP" } + ) + } else { + requestVpnAndStart() + } + }, + enabled = (isValid && cooldownSeconds == 0) || tunnelRunning, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + imageVector = if (tunnelRunning) Icons.Default.Stop else Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = when { + tunnelRunning -> "Остановить" + cooldownSeconds > 0 -> "Подождите ($cooldownSeconds)" + else -> "Подключить" + }, + fontWeight = FontWeight.Bold + ) + } + } + + } +} + +// ═══ Reusable mode chip ═══ +@Composable +private fun ProtocolChip(label: String, selected: Boolean, enabled: Boolean = true, isError: Boolean = false, onClick: () -> Unit) { + FilterChip( + selected = selected, + onClick = onClick, + enabled = enabled, + label = { + Text( + label, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + color = if (isError) MaterialTheme.colorScheme.error else (if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface) + ) + }, + shape = RoundedCornerShape(16.dp), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + labelColor = MaterialTheme.colorScheme.onSurface, + disabledLabelColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + selectedBorderColor = MaterialTheme.colorScheme.primary + ) + ) +} + +@Composable +private fun CompactSteppedSlider( + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + stepSize: Float, + enabled: Boolean, + modifier: Modifier = Modifier +) { + val activeColor = MaterialTheme.colorScheme.primary.copy(alpha = if (enabled) 1f else 0.38f) + val inactiveColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 1f else 0.55f) + val thumbStrokeColor = MaterialTheme.colorScheme.surface + val density = LocalDensity.current + val thumbRadiusPx = with(density) { 9.dp.toPx() } + val trackWidthPx = with(density) { 5.dp.toPx() } + + fun snap(raw: Float): Float { + val min = valueRange.start + val max = valueRange.endInclusive + val snapped = (((raw - min) / stepSize).roundToInt() * stepSize) + min + return snapped.coerceIn(min, max) + } + + fun positionToValue(x: Float, width: Float): Float { + val left = thumbRadiusPx + val right = (width - thumbRadiusPx).coerceAtLeast(left + 1f) + val fraction = ((x.coerceIn(left, right) - left) / (right - left)).coerceIn(0f, 1f) + return snap(valueRange.start + fraction * (valueRange.endInclusive - valueRange.start)) + } + + Canvas( + modifier = modifier + .height(34.dp) + .pointerInput(enabled, valueRange, stepSize) { + if (!enabled) return@pointerInput + detectTapGestures { offset -> + onValueChange(positionToValue(offset.x, size.width.toFloat())) + } + } + .pointerInput(enabled, valueRange, stepSize) { + if (!enabled) return@pointerInput + detectDragGestures { change, _ -> + onValueChange(positionToValue(change.position.x, size.width.toFloat())) + } + } + ) { + val centerY = size.height / 2f + val left = thumbRadiusPx + val right = size.width - thumbRadiusPx + val range = (valueRange.endInclusive - valueRange.start).coerceAtLeast(1f) + val fraction = ((value - valueRange.start) / range).coerceIn(0f, 1f) + val thumbX = left + (right - left) * fraction + + drawLine( + color = inactiveColor, + start = Offset(left, centerY), + end = Offset(right, centerY), + strokeWidth = trackWidthPx, + cap = StrokeCap.Round + ) + drawLine( + color = activeColor, + start = Offset(left, centerY), + end = Offset(thumbX, centerY), + strokeWidth = trackWidthPx, + cap = StrokeCap.Round + ) + + val tickCount = (((valueRange.endInclusive - valueRange.start) / stepSize).roundToInt()).coerceAtLeast(1) + repeat(tickCount + 1) { index -> + val tickFraction = index / tickCount.toFloat() + val tickX = left + (right - left) * tickFraction + drawCircle( + color = if (tickX <= thumbX) activeColor else inactiveColor, + radius = 2.dp.toPx(), + center = Offset(tickX, centerY) + ) + } + + drawCircle( + color = activeColor, + radius = thumbRadiusPx, + center = Offset(thumbX, centerY) + ) + drawCircle( + color = thumbStrokeColor, + radius = thumbRadiusPx, + center = Offset(thumbX, centerY), + style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()) + ) + } +} + +// ═══ Important Info Dialog ═══ +@Composable +fun ImportantInfoDialog(onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.fillMaxWidth(0.95f).padding(8.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(24.dp).verticalScroll(rememberScrollState())) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Важная информация", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, null) + } + } + + Spacer(Modifier.height(16.dp)) + + InfoSection("Капча ВК", + "По умолчанию в приложении установлен ручной режим (WBV + РУЧ), но его можно заменить на RJS-АВТ. Это продвинутый автоматический метод решения капчи без всплывающих окон и участия человека, основанный на реверс-инжиниринге JS-кода капчи. Он имитирует действия пользователя в фоновом режиме, обеспечивая бесперебойную работу.\n\nВАЖНО: Если в вашем случае RJS не проходит капчу или выдает ошибки (проблемы со связью или изменения на стороне ВК) — переключитесь обратно в ручной режим." + ) + InfoSection("Как решать капчу", + "Она не сложная: нужно просто потянуть слайдер вправо так, чтобы все элементы (обычно это 3 слова) идеально сошлись в пазле." + ) + InfoSection("Сетевое окружение", + "Отключите другие VPN/Прокси и «Приватный DNS» перед использованием." + ) + InfoSection("Связь потоков и капч", + "Рекомендую выбирать 12-36 потока для меньшего количества капч. Если вам всё равно на частоту ввода капчи в фоне — ставьте 48 и более ради скорости." + ) + + Spacer(Modifier.height(20.dp)) + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary) + ) { + Text("Понятно") + } + } + } + } +} + +@Composable +private fun InfoSection(title: String, body: String) { + Spacer(Modifier.height(12.dp)) + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(4.dp)) + Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.height(4.dp)) +} + +// Округление до ближайшего кратного WORKERS_PER_GROUP +private fun roundToGroup(value: Float, maxW: Float = 96f): Float { + val rounded = (Math.round(value / WORKERS_PER_GROUP) * WORKERS_PER_GROUP).toFloat() + return rounded.coerceIn(WORKERS_PER_GROUP.toFloat(), maxW) +} + +/** Извлекает хеш из VK ссылки */ +private fun stripVkUrlStatic(input: String): String { + var s = input.trim() + val lower = s.lowercase() + val prefixes = listOf( + "https://vk.com/call/join/", + "http://vk.com/call/join/", + "https://m.vk.com/call/join/", + "http://m.vk.com/call/join/", + "m.vk.com/call/join/", + "vk.com/call/join/" + ) + for (prefix in prefixes) { + if (lower.startsWith(prefix)) { + s = s.substring(prefix.length) + break + } + } + val qIdx = s.indexOf('?') + if (qIdx != -1) s = s.substring(0, qIdx) + val hIdx = s.indexOf('#') + if (hIdx != -1) s = s.substring(0, hIdx) + return s.trimEnd('/') +} + +// ═══ Модальное окно хешей ═══ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HashesDialog( + hash1: String, + hash2: String, + hash3: String, + hash4: String, + onSave: (String, String, String, String) -> Unit, + onDismiss: () -> Unit +) { + var h1 by remember { mutableStateOf(hash1) } + var h2 by remember { mutableStateOf(hash2) } + var h3 by remember { mutableStateOf(hash3) } + var h4 by remember { mutableStateOf(hash4) } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 8.dp + ) { + Column( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Tag, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp)) + Spacer(Modifier.width(8.dp)) + Text("VK Хеши", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Закрыть") + } + } + + Text( + text = "Больше хешей — выше лимит потоков и лучшее распределение нагрузки.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp) + ) + + listOf( + Triple("VK Хеш 1 *", h1) { v: String -> h1 = v }, + Triple("VK Хеш 2", h2) { v: String -> h2 = v }, + Triple("VK Хеш 3", h3) { v: String -> h3 = v }, + Triple("VK Хеш 4", h4) { v: String -> h4 = v } + ).forEachIndexed { idx, (label, value, onChange) -> + val isShort = value.isNotBlank() && value.length < 16 + OutlinedTextField( + value = value, + onValueChange = { raw -> + val cleaned = raw.filter { c -> c != ' ' && c != '\n' } + onChange(stripVkUrlStatic(cleaned)) + }, + label = { Text(label) }, + placeholder = { Text("Ссылка звонка или хеш") }, + singleLine = true, + isError = isShort, + supportingText = if (isShort) { + { Text("Хеш ${idx + 1} — короткий (мин. 16)", color = MaterialTheme.colorScheme.error) } + } else null, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) + } + + Button( + onClick = { + onSave(h1, h2, h3, h4) + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + enabled = h1.isNotBlank() && h1.length >= 16, + colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary) + ) { + Text("Сохранить", fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +// ═══ Модальное окно секретов ═══ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SecretsDialog( + settingsStore: SettingsStore, + initialPassword: String, + manualPortsEnabled: Boolean, + initialServerDtlsPort: String, + initialServerWgPort: String, + initialLocalPort: String, + onSaved: (String, String, String) -> Unit, + onDismiss: () -> Unit +) { + val scope = rememberCoroutineScope() + var passwordInput by rememberSaveable { mutableStateOf(initialPassword) } + var serverDtlsPort by rememberSaveable { mutableStateOf(initialServerDtlsPort.ifBlank { "56000" }) } + var serverWgPort by rememberSaveable { mutableStateOf(initialServerWgPort.ifBlank { "56001" }) } + var localPort by rememberSaveable { mutableStateOf(initialLocalPort.ifBlank { "9000" }) } + + fun normalizePort(value: String, fallback: String): String { + return value.toIntOrNull()?.takeIf { it in 1..65535 }?.toString() ?: fallback + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 8.dp + ) { + Column( + modifier = Modifier.padding(24.dp).fillMaxWidth().verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Секреты", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + IconButton(onClick = onDismiss) { + Icon(imageVector = Icons.Default.Close, contentDescription = "Закрыть") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + label = { Text("Заданный пароль туннеля") }, + placeholder = { Text("Придумайте надежный пароль") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) + + if (manualPortsEnabled) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + Text("Порты", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = serverDtlsPort, + onValueChange = { serverDtlsPort = it.filter(Char::isDigit).take(5) }, + label = { Text("Порт сервера DTLS") }, + placeholder = { Text("56000") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = serverWgPort, + onValueChange = { serverWgPort = it.filter(Char::isDigit).take(5) }, + label = { Text("Порт сервера WireGuard") }, + placeholder = { Text("56001") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = localPort, + onValueChange = { localPort = it.filter(Char::isDigit).take(5) }, + label = { Text("Локальный порт VPN") }, + placeholder = { Text("9000") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = { + val finalDtls = normalizePort(serverDtlsPort, "56000") + val finalWg = normalizePort(serverWgPort, "56001") + val finalLocal = normalizePort(localPort, "9000") + scope.launch { + settingsStore.saveConnectionPassword(passwordInput) + settingsStore.savePorts(finalDtls.toInt(), finalWg.toInt(), finalLocal.toInt()) + onSaved(finalDtls, finalWg, finalLocal) + onDismiss() + } + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + enabled = passwordInput.isNotEmpty(), + colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary) + ) { + Text("Сохранить", fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +// extension +private fun androidx.compose.ui.graphics.Color.luminance(): Float { + val r = red + val g = green + val b = blue + return 0.2126f * r + 0.7152f * g + 0.0722f * b +} diff --git a/app/src/main/res/drawable/ic_da.xml b/app/src/main/res/drawable/ic_da.xml new file mode 100644 index 0000000..b5f0bf6 --- /dev/null +++ b/app/src/main/res/drawable/ic_da.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_da_black.xml b/app/src/main/res/drawable/ic_da_black.xml new file mode 100644 index 0000000..e276f00 --- /dev/null +++ b/app/src/main/res/drawable/ic_da_black.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..63d5961 --- /dev/null +++ b/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stat_connected.xml b/app/src/main/res/drawable/ic_stat_connected.xml new file mode 100644 index 0000000..1024271 --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_connected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000..172430c --- /dev/null +++ b/app/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_yoomoney.xml b/app/src/main/res/drawable/ic_yoomoney.xml new file mode 100644 index 0000000..ff6dd45 --- /dev/null +++ b/app/src/main/res/drawable/ic_yoomoney.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..52d5f26 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..17fc07a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..dde1a32 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..b3d4c42 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..17fc07a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..aded8a3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..89f8168 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..17fc07a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..33a09c3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..57a9108 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..17fc07a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..cd6171e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..a35eb9a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..17fc07a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..56a6f45 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..927922e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1E1E1E + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..69e61e7 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..614a4fd --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..06b9667 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module wg-turn-client + +go 1.25.0 + +require ( + github.com/cbeuw/connutil v1.0.1 + github.com/google/uuid v1.6.0 + github.com/pion/dtls/v3 v3.1.2 + github.com/pion/logging v0.2.4 + github.com/pion/turn/v5 v5.0.2 + golang.org/x/crypto v0.51.0 + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb +) + +require ( + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect +) diff --git a/go_client/captcha_v2.go b/go_client/captcha_v2.go new file mode 100644 index 0000000..a86efdb --- /dev/null +++ b/go_client/captcha_v2.go @@ -0,0 +1,655 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + mathrand "math/rand" + "regexp" + "strconv" + "strings" + "sync" + "time" + + neturl "net/url" + + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" +) + +const ( + captchaV2APIVersion = "5.131" + captchaV2ScriptVersion = "1.1.1324" + captchaV2DeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1080,"innerWidth":1920,"innerHeight":951,"devicePixelRatio":1,"language":"en-US","languages":["en-US","en"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}` +) + +var ( + reCaptchaV2PowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) + reCaptchaV2Difficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`) + reCaptchaV2WindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`) + reCaptchaV2ScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`) + reCaptchaV2DebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`) + reCaptchaV2Version = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`) + + errCaptchaV2RateLimit = errors.New("captcha session rate limit reached") + errCaptchaV2Bot = errors.New("captcha bot challenge") + + captchaV2MaxAttempts = 2 + + captchaV2DebugCache sync.Map // scriptURL -> string + captchaV2HeaderOrder = []string{ + "host", + "content-length", + "sec-ch-ua-platform", + "accept-language", + "sec-ch-ua", + "content-type", + "sec-ch-ua-mobile", + "user-agent", + "accept", + "origin", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "referer", + "accept-encoding", + "priority", + } + captchaV2PHeaderOrder = []string{":method", ":path", ":authority", ":scheme"} +) + +type captchaV2Init struct { + Data captchaV2InitData `json:"data"` +} + +type captchaV2InitData struct { + ShowCaptchaType string `json:"show_captcha_type"` + CaptchaSettings []captchaV2InitSetting `json:"captcha_settings"` +} + +type captchaV2InitSetting struct { + Type string `json:"type"` + Settings string `json:"settings"` +} + +type captchaV2Page struct { + PowInput string + PowDifficulty int + ScriptURL string + Init *captchaV2Init +} + +type captchaV2Check struct { + Status string + SuccessToken string + ShowType string +} + +type captchaV2ShowTypeError struct { + ShowType string +} + +func (e *captchaV2ShowTypeError) Error() string { + return "captcha show type mismatch: " + e.ShowType +} + +type captchaV2Session struct { + ctx context.Context + client tlsclient.HttpClient + profile Profile + savedProfile *SavedProfile +} + +func solveVkCaptchaV2( + ctx context.Context, + captchaErr *VkCaptchaError, + client tlsclient.HttpClient, + profile Profile, + savedProfile *SavedProfile, +) (string, error) { + return solveVkCaptchaV2Attempts(ctx, captchaErr, client, profile, savedProfile, captchaV2MaxAttempts) +} + +func solveVkCaptchaV2Attempts( + ctx context.Context, + captchaErr *VkCaptchaError, + client tlsclient.HttpClient, + profile Profile, + savedProfile *SavedProfile, + maxAttempts int, +) (string, error) { + if captchaErr == nil || captchaErr.SessionToken == "" { + return "", fmt.Errorf("no session_token in redirect_uri") + } + if maxAttempts < 1 { + maxAttempts = 1 + } + log.Printf("[КАПЧА] Решаю VK Smart Captcha автоматически (v2, попыток=%d)...", maxAttempts) + + s := &captchaV2Session{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile} + + for attempt := 1; attempt <= maxAttempts; attempt++ { + token, solveErr := s.solveOnce(captchaErr) + if solveErr == nil { + return token, nil + } + log.Printf("[КАПЧА] v2 попытка %d ошибка: %v", attempt, solveErr) + if errors.Is(solveErr, errCaptchaV2RateLimit) { + return "", solveErr + } + + backoffSteps := attempt + if backoffSteps > 10 { + backoffSteps = 10 + } + timer := time.NewTimer(time.Duration(backoffSteps) * 500 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return "", ctx.Err() + case <-timer.C: + } + } + return "", fmt.Errorf("v2 captcha attempts exhausted (%d)", maxAttempts) +} + +func (s *captchaV2Session) solveOnce(captchaErr *VkCaptchaError) (string, error) { + html, err := s.fetchCaptchaHTML(captchaErr.RedirectURI) + if err != nil { + return "", err + } + + page, err := parseCaptchaV2Page(html) + if err != nil { + return "", err + } + if page.PowInput == "" { + return "", errors.New("failed to find PoW settings") + } + + sliderSettings := "" + if page.Init != nil { + for _, setting := range page.Init.Data.CaptchaSettings { + if setting.Type == "slider" { + sliderSettings = setting.Settings + } + } + } + if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderSettings == "" { + return "", errors.New("failed to find slider captcha settings") + } + + log.Printf("[КАПЧА] v2 solving pow difficulty=%d", page.PowDifficulty) + hash := solveCaptchaPoWV2(s.ctx, page.PowInput, page.PowDifficulty) + if hash == "" { + return "", errors.New("captcha pow failed") + } + log.Printf("[КАПЧА] v2 pow solved") + + base := captchaV2BaseValues(captchaErr.SessionToken) + if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil { + return "", fmt.Errorf("captcha settings failed: %w", settingsErr) + } + + browserFP, err := captchaV2BrowserFP() + if err != nil { + return "", err + } + if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" { + browserFP = s.savedProfile.BrowserFp + } + + if m := reCaptchaV2Version.FindStringSubmatch(page.ScriptURL); len(m) > 1 { + if m[1] != captchaV2ScriptVersion { + log.Printf("[КАПЧА] v2 script version drift: known=%s latest=%s", captchaV2ScriptVersion, m[1]) + } + } + + debugInfo, err := s.fetchDebugInfo(page.ScriptURL) + if err != nil { + return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaV2ScriptVersion) + } + + showType := "" + if page.Init != nil { + showType = page.Init.Data.ShowCaptchaType + } + var token string + for { + log.Printf("[КАПЧА] v2 solving show_type=%s", showType) + switch showType { + case "slider": + token, err = s.solveSliderCaptcha(captchaErr.SessionToken, browserFP, hash, sliderSettings, debugInfo) + case "checkbox", "": + token, err = s.solveCheckboxCaptcha(captchaErr.SessionToken, browserFP, hash, debugInfo) + default: + return "", fmt.Errorf("unsupported captcha type: %s", showType) + } + if err == nil { + break + } + if errors.Is(err, errCaptchaV2Bot) && !strings.EqualFold(showType, "slider") && sliderSettings != "" { + log.Printf("[КАПЧА] v2 checkbox returned BOT, trying slider") + showType = "slider" + continue + } + var stErr *captchaV2ShowTypeError + if !errors.As(err, &stErr) || stErr.ShowType == "" { + return "", err + } + showType = stErr.ShowType + } + + if _, endErr := s.captchaRequest("captchaNotRobot.endSession", base); endErr != nil { + log.Printf("[КАПЧА] v2 endSession failed: %v", endErr) + } + return token, nil +} + +func captchaV2BaseValues(sessionToken string) [][2]string { + return [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"access_token", ""}, + } +} + +func captchaV2BrowserFP() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("browser fp generate: %w", err) + } + return hex.EncodeToString(b), nil +} + +func (s *captchaV2Session) fetchCaptchaHTML(redirectURI string) (string, error) { + body, err := s.doRaw(fhttp.MethodGet, redirectURI, nil, map[string]string{ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "cross-site", + }) + if err != nil { + return "", err + } + return string(body), nil +} + +func (s *captchaV2Session) fetchDebugInfo(scriptURL string) (string, error) { + if cached, ok := captchaV2DebugCache.Load(scriptURL); ok { + if cachedDebugInfo, ok := cached.(string); ok { + return cachedDebugInfo, nil + } + captchaV2DebugCache.Delete(scriptURL) + } + body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{ + "Accept": "text/javascript,*/*", + "Referer": "https://id.vk.com/", + }) + if err != nil { + return "", err + } + m := reCaptchaV2DebugInfo.FindSubmatch(body) + if len(m) < 2 { + return "", errors.New("debug_info match not found") + } + v := string(m[1]) + captchaV2DebugCache.Store(scriptURL, v) + log.Printf("[КАПЧА] v2 debug_info fetched url=%s", scriptURL) + return v, nil +} + +func parseCaptchaV2Page(html string) (*captchaV2Page, error) { + page := &captchaV2Page{} + + match := reCaptchaV2WindowInit.FindStringSubmatch(html) + if len(match) < 2 { + return nil, errors.New("captcha init json not found") + } + var init captchaV2Init + if err := json.Unmarshal([]byte(match[1]), &init); err != nil { + return nil, fmt.Errorf("captcha init json parse: %w", err) + } + page.Init = &init + + match = reCaptchaV2ScriptSrc.FindStringSubmatch(html) + if len(match) < 2 { + return nil, errors.New("captcha script url not found") + } + page.ScriptURL = match[1] + + if m := reCaptchaV2PowInput.FindStringSubmatch(html); len(m) >= 2 { + page.PowInput = m[1] + } + if page.PowInput == "" { + return page, nil + } + + match = reCaptchaV2Difficulty.FindStringSubmatch(html) + if len(match) < 2 { + return nil, errors.New("captcha difficulty const not found") + } + difficulty, err := strconv.Atoi(match[1]) + if err != nil || difficulty <= 0 { + return nil, fmt.Errorf("invalid captcha difficulty %q", match[1]) + } + page.PowDifficulty = difficulty + return page, nil +} + +func (s *captchaV2Session) captchaRequest(method string, form [][2]string) (map[string]any, error) { + endpoint := "https://api.vk.ru/method/" + method + "?v=" + captchaV2APIVersion + body, err := s.doRaw(fhttp.MethodPost, endpoint, form, map[string]string{ + "Origin": "https://id.vk.com", + "Referer": "https://id.vk.com/", + "Priority": "u=1, i", + }) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("captcha api decode: %w", err) + } + return out, nil +} + +func (s *captchaV2Session) performCaptchaCheck( + sessionToken string, + browserFP string, + hash string, + answerJSON string, + cursor string, + debugInfo string, +) (*captchaV2Check, error) { + values := [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"accelerometer", "[]"}, + {"gyroscope", "[]"}, + {"motion", "[]"}, + {"cursor", cursor}, + {"taps", "[]"}, + {"connectionRtt", "[]"}, + {"connectionDownlink", "[]"}, + {"browser_fp", browserFP}, + {"hash", hash}, + {"answer", base64.StdEncoding.EncodeToString([]byte(answerJSON))}, + {"debug_info", debugInfo}, + {"access_token", ""}, + } + resp, err := s.captchaRequest("captchaNotRobot.check", values) + if err != nil { + return nil, fmt.Errorf("captcha check failed: %w", err) + } + check, err := parseCaptchaV2Check(resp) + if err != nil { + return nil, err + } + if check.ShowType != "" { + log.Printf("[КАПЧА] v2 check status=%s show_type=%s", check.Status, check.ShowType) + } else { + log.Printf("[КАПЧА] v2 check status=%s", check.Status) + } + return check, nil +} + +func parseCaptchaV2Check(raw map[string]any) (*captchaV2Check, error) { + resp, ok := raw["response"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid captcha check response: %v", raw) + } + out := &captchaV2Check{ + Status: captchaV2StringifyAny(resp["status"]), + SuccessToken: captchaV2StringifyAny(resp["success_token"]), + ShowType: captchaV2StringifyAny(resp["show_captcha_type"]), + } + if out.Status == "" { + return nil, fmt.Errorf("captcha check status missing: %v", raw) + } + return out, nil +} + +func (s *captchaV2Session) solveCheckboxCaptcha( + sessionToken string, + browserFP string, + hash string, + debugInfo string, +) (string, error) { + deviceJSON := captchaV2DeviceInfo + if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" { + deviceJSON = s.savedProfile.DeviceJSON + } + if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"browser_fp", browserFP}, + {"device", deviceJSON}, + {"access_token", ""}, + }); err != nil { + return "", fmt.Errorf("captcha componentDone failed: %w", err) + } + + select { + case <-s.ctx.Done(): + return "", s.ctx.Err() + case <-time.After(time.Duration(400+mathrand.Intn(250)) * time.Millisecond): + } + + check, err := s.performCaptchaCheck(sessionToken, browserFP, hash, "{}", "[]", debugInfo) + if err != nil { + return "", err + } + if check.ShowType != "" && !strings.EqualFold(check.ShowType, "checkbox") { + return "", &captchaV2ShowTypeError{ShowType: check.ShowType} + } + if strings.EqualFold(check.Status, "error_limit") { + return "", errCaptchaV2RateLimit + } + if strings.EqualFold(check.Status, "bot") { + return "", fmt.Errorf("%w: checkbox captcha rejected: status=%s", errCaptchaV2Bot, check.Status) + } + if !strings.EqualFold(check.Status, "ok") { + return "", fmt.Errorf("checkbox captcha rejected: status=%s", check.Status) + } + if check.SuccessToken == "" { + return "", errors.New("captcha success token not found") + } + return check.SuccessToken, nil +} + +func solveCaptchaPoWV2(ctx context.Context, input string, difficulty int) string { + if input == "" || difficulty <= 0 { + return "" + } + target := strings.Repeat("0", difficulty) + for nonce := 1; nonce <= 10_000_000; nonce++ { + if nonce%4096 == 0 { + select { + case <-ctx.Done(): + return "" + default: + } + } + sum := sha256.Sum256([]byte(input + strconv.Itoa(nonce))) + hashHex := hex.EncodeToString(sum[:]) + if strings.HasPrefix(hashHex, target) { + return hashHex + } + } + return "" +} + +func (s *captchaV2Session) doRaw( + method string, + endpoint string, + form [][2]string, + extraHeaders map[string]string, +) ([]byte, error) { + var body []byte + if form != nil { + body = []byte(captchaV2EncodeForm(form)) + } + req, err := fhttp.NewRequestWithContext(s.ctx, method, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + applyBrowserProfileFhttp(req, s.profile) + req.Header.Set("Accept", "*/*") + req.Header.Set("Sec-Fetch-Site", "same-site") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Origin", "https://vk.com") + req.Header.Set("Referer", "https://vk.com/") + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + req.Header[fhttp.HeaderOrderKey] = captchaV2HeaderOrder + req.Header[fhttp.PHeaderOrderKey] = captchaV2PHeaderOrder + + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("[КАПЧА] v2 close body: %s", closeErr) + } + }() + return io.ReadAll(resp.Body) +} + +func captchaV2EncodeForm(values [][2]string) string { + if len(values) == 0 { + return "" + } + var sb strings.Builder + for i, kv := range values { + if i > 0 { + sb.WriteByte('&') + } + sb.WriteString(captchaV2QueryEscape(kv[0])) + sb.WriteByte('=') + sb.WriteString(captchaV2QueryEscape(kv[1])) + } + return sb.String() +} + +func captchaV2QueryEscape(s string) string { + const upper = "0123456789ABCDEF" + hexDigits := func(b byte) [3]byte { + return [3]byte{'%', upper[b>>4], upper[b&0xF]} + } + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == ' ': + out = append(out, '+') + case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~': + out = append(out, c) + default: + h := hexDigits(c) + out = append(out, h[:]...) + } + } + return string(out) +} + +func captchaV2StringifyAny(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + return strconv.FormatBool(v) + default: + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(data) + } +} + +// applyBrowserProfileFhttp applies browser headers to fhttp requests +func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) { + req.Header.Set("User-Agent", profile.UserAgent) + req.Header.Set("sec-ch-ua", profile.SecChUa) + req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile) + req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform) + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("DNT", "1") +} + +// VkCaptchaError represents a VK captcha challenge +type VkCaptchaError struct { + ErrorCode int + ErrorMsg string + CaptchaSid string + RedirectURI string + SessionToken string + CaptchaTs string + CaptchaAttempt string +} + +func parseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { + codeFloat, _ := errData["error_code"].(float64) + redirectUri, _ := errData["redirect_uri"].(string) + errorMsg, _ := errData["error_msg"].(string) + + captchaSid, _ := errData["captcha_sid"].(string) + if captchaSid == "" { + if sidNum, ok := errData["captcha_sid"].(float64); ok { + captchaSid = fmt.Sprintf("%.0f", sidNum) + } + } + + var sessionToken string + if redirectUri != "" { + if parsed, err := neturl.Parse(redirectUri); err == nil { + sessionToken = parsed.Query().Get("session_token") + } + } + + var captchaTs string + if tsFloat, ok := errData["captcha_ts"].(float64); ok { + captchaTs = fmt.Sprintf("%.0f", tsFloat) + } else if tsStr, ok := errData["captcha_ts"].(string); ok { + captchaTs = tsStr + } + + var captchaAttempt string + if attFloat, ok := errData["captcha_attempt"].(float64); ok { + captchaAttempt = fmt.Sprintf("%.0f", attFloat) + } else if attStr, ok := errData["captcha_attempt"].(string); ok { + captchaAttempt = attStr + } + + return &VkCaptchaError{ + ErrorCode: int(codeFloat), + ErrorMsg: errorMsg, + CaptchaSid: captchaSid, + RedirectURI: redirectUri, + SessionToken: sessionToken, + CaptchaTs: captchaTs, + CaptchaAttempt: captchaAttempt, + } +} diff --git a/go_client/captcha_v2_slider.go b/go_client/captcha_v2_slider.go new file mode 100644 index 0000000..9184be6 --- /dev/null +++ b/go_client/captcha_v2_slider.go @@ -0,0 +1,637 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "image" + "image/color" + _ "image/jpeg" + "log" + "math" + mathrand "math/rand" + "runtime" + "sort" + "strconv" + "strings" + "sync" +) + +type sliderPuzzleV2 struct { + Image image.Image + Size int + Swaps []int + Attempts int +} + +type sliderGuessV2 struct { + Index int + Swaps []int + Score int64 + ScoreRGB int64 + ScoreLuma int64 + ScoreText float64 + ConsensusRank int +} + +func (s *captchaV2Session) solveSliderCaptcha( + sessionToken string, + browserFP string, + hash string, + settings string, + debugInfo string, +) (string, error) { + values := [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"access_token", ""}, + {"captcha_settings", settings}, + } + + resp, err := s.captchaRequest("captchaNotRobot.getContent", values) + if err != nil { + return "", fmt.Errorf("slider getContent failed: %w", err) + } + puzzle, err := parseSliderPuzzleV2(resp) + if err != nil { + return "", err + } + log.Printf("[КАПЧА] v2 slider puzzle decoded: grid=%d attempts=%d swaps=%d", puzzle.Size, puzzle.Attempts, len(puzzle.Swaps)) + + guesses, err := rankSliderGuessesV2(puzzle.Image, puzzle.Size, puzzle.Swaps) + if err != nil { + return "", err + } + + limit := puzzle.Attempts + if limit > len(guesses) { + limit = len(guesses) + } + if limit <= 0 { + return "", errors.New("slider has no attempts available") + } + log.Printf("[КАПЧА] v2 slider guesses ranked: total=%d limit=%d", len(guesses), limit) + + deviceJSON := captchaV2DeviceInfo + if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" { + deviceJSON = s.savedProfile.DeviceJSON + } + if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"access_token", ""}, + {"browser_fp", browserFP}, + {"device", deviceJSON}, + }); err != nil { + return "", fmt.Errorf("captcha componentDone failed: %w", err) + } + + for i := 0; i < limit; i++ { + log.Printf("[КАПЧА] v2 slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index) + answerData, err := json.Marshal(struct { + Value []int `json:"value"` + }{Value: guesses[i].Swaps}) + if err != nil { + return "", err + } + check, err := s.performCaptchaCheck( + sessionToken, + browserFP, + hash, + string(answerData), + buildSliderCursorV2(guesses[i].Index, len(guesses)), + debugInfo, + ) + if err != nil { + return "", err + } + if strings.EqualFold(check.Status, "ok") { + if check.SuccessToken == "" { + return "", errors.New("captcha success token not found") + } + log.Printf("[КАПЧА] v2 slider accepted on attempt %d", i+1) + return check.SuccessToken, nil + } + if strings.EqualFold(check.Status, "error_limit") { + return "", errCaptchaV2RateLimit + } + } + return "", errors.New("slider guesses exhausted") +} + +func parseSliderPuzzleV2(raw map[string]any) (*sliderPuzzleV2, error) { + resp, ok := raw["response"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid slider content response: %v", raw) + } + status := captchaV2StringifyAny(resp["status"]) + if !strings.EqualFold(status, "ok") { + return nil, fmt.Errorf("slider getContent status: %s", status) + } + rawImage := captchaV2StringifyAny(resp["image"]) + if rawImage == "" { + return nil, errors.New("slider image missing") + } + rawSteps, ok := resp["steps"].([]any) + if !ok { + return nil, errors.New("slider steps missing") + } + steps := make([]int, 0, len(rawSteps)) + for _, item := range rawSteps { + switch v := item.(type) { + case float64: + steps = append(steps, int(v)) + case int: + steps = append(steps, v) + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return nil, fmt.Errorf("invalid numeric value: %v", item) + } + steps = append(steps, n) + default: + return nil, fmt.Errorf("invalid numeric value: %v", item) + } + } + size, swaps, attempts, err := splitSliderStepsV2(steps) + if err != nil { + return nil, err + } + data, err := base64.StdEncoding.DecodeString(rawImage) + if err != nil { + return nil, fmt.Errorf("decode slider image: %w", err) + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode slider image: %w", err) + } + return &sliderPuzzleV2{Image: img, Size: size, Swaps: swaps, Attempts: attempts}, nil +} + +func splitSliderStepsV2(steps []int) (int, []int, int, error) { + if len(steps) < 3 { + return 0, nil, 0, errors.New("slider steps payload too short") + } + size := steps[0] + if size <= 0 { + return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size) + } + tail := append([]int(nil), steps[1:]...) + attempts := 4 + if len(tail)%2 != 0 { + attempts = tail[len(tail)-1] + tail = tail[:len(tail)-1] + log.Printf("[КАПЧА] v2 slider payload had odd-length tail; fallback attempts=%d", attempts) + } + if attempts <= 0 { + attempts = 4 + } + if len(tail) == 0 || len(tail)%2 != 0 { + return 0, nil, 0, errors.New("invalid slider swap payload") + } + return size, tail, attempts, nil +} + +func rankSliderGuessesV2(img image.Image, gridSize int, swaps []int) ([]sliderGuessV2, error) { + candidateCount := len(swaps) / 2 + if candidateCount == 0 { + return nil, errors.New("slider has no candidates") + } + + guesses := make([]sliderGuessV2, candidateCount) + for idx := 1; idx <= candidateCount; idx++ { + active := activeSwapsForIndexV2(swaps, idx) + mapping, err := applySliderSwapsV2(gridSize, active) + if err != nil { + return nil, err + } + guesses[idx-1] = sliderGuessV2{Index: idx, Swaps: active} + guesses[idx-1].ScoreLuma = seamScoreLumaV2(img, gridSize, mapping) + } + + lumaOrder := append([]sliderGuessV2(nil), guesses...) + sort.SliceStable(lumaOrder, func(i, j int) bool { + if lumaOrder[i].ScoreLuma == lumaOrder[j].ScoreLuma { + return lumaOrder[i].Index < lumaOrder[j].Index + } + return lumaOrder[i].ScoreLuma < lumaOrder[j].ScoreLuma + }) + lumaRank := make(map[int]int, candidateCount) + for rank, g := range lumaOrder { + lumaRank[g.Index] = rank + } + + stage2Count := candidateCount + if stage2Count > 12 { + stage2Count = 12 + } + stage2Set := make(map[int]struct{}, stage2Count) + for i := 0; i < stage2Count; i++ { + stage2Set[lumaOrder[i].Index] = struct{}{} + } + + type stage2Result struct { + index int + rgb int64 + text float64 + err error + } + jobs := make([]int, 0, stage2Count) + for idx := range stage2Set { + jobs = append(jobs, idx) + } + jobCh := make(chan int, len(jobs)) + resCh := make(chan stage2Result, len(jobs)) + + workers := runtime.NumCPU() + if workers < 1 { + workers = 1 + } + if workers > len(jobs) { + workers = len(jobs) + } + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for index := range jobCh { + mapping, err := applySliderSwapsV2(gridSize, guesses[index-1].Swaps) + if err != nil { + resCh <- stage2Result{index: index, err: err} + continue + } + rgb, text := seamScoreRGBTextV2(img, gridSize, mapping) + resCh <- stage2Result{index: index, rgb: rgb, text: text} + } + }() + } + for _, idx := range jobs { + jobCh <- idx + } + close(jobCh) + wg.Wait() + close(resCh) + for r := range resCh { + if r.err != nil { + return nil, r.err + } + g := &guesses[r.index-1] + g.ScoreRGB = r.rgb + g.ScoreText = r.text + } + + stage2 := make([]sliderGuessV2, 0, stage2Count) + for _, g := range guesses { + if _, ok := stage2Set[g.Index]; ok { + stage2 = append(stage2, g) + } + } + + rgbOrder := append([]sliderGuessV2(nil), stage2...) + sort.SliceStable(rgbOrder, func(i, j int) bool { + if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB { + return rgbOrder[i].Index < rgbOrder[j].Index + } + return rgbOrder[i].ScoreRGB < rgbOrder[j].ScoreRGB + }) + rgbRank := make(map[int]int, len(rgbOrder)) + for rank, g := range rgbOrder { + rgbRank[g.Index] = rank + } + + textOrder := append([]sliderGuessV2(nil), stage2...) + sort.SliceStable(textOrder, func(i, j int) bool { + if textOrder[i].ScoreText == textOrder[j].ScoreText { + return textOrder[i].Index < textOrder[j].Index + } + return textOrder[i].ScoreText < textOrder[j].ScoreText + }) + textRank := make(map[int]int, len(textOrder)) + for rank, g := range textOrder { + textRank[g.Index] = rank + } + + for i := range guesses { + g := &guesses[i] + g.ConsensusRank = lumaRank[g.Index] + if _, ok := stage2Set[g.Index]; ok { + g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index] + } else { + g.ConsensusRank += candidateCount + } + g.Score = int64(g.ConsensusRank) + } + + sort.SliceStable(guesses, func(i, j int) bool { + if guesses[i].ConsensusRank == guesses[j].ConsensusRank { + if guesses[i].ScoreLuma == guesses[j].ScoreLuma { + return guesses[i].Index < guesses[j].Index + } + return guesses[i].ScoreLuma < guesses[j].ScoreLuma + } + return guesses[i].ConsensusRank < guesses[j].ConsensusRank + }) + return guesses, nil +} + +func activeSwapsForIndexV2(swaps []int, index int) []int { + if index <= 0 { + return []int{} + } + end := index * 2 + if end > len(swaps) { + end = len(swaps) + } + return append([]int(nil), swaps[:end]...) +} + +func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) { + tileCount := gridSize * gridSize + if tileCount <= 0 { + return nil, fmt.Errorf("invalid slider tile count: %d", tileCount) + } + if len(swaps)%2 != 0 { + return nil, fmt.Errorf("invalid slider swaps length: %d", len(swaps)) + } + mapping := make([]int, tileCount) + for i := range mapping { + mapping[i] = i + } + for i := 0; i < len(swaps); i += 2 { + left := swaps[i] + right := swaps[i+1] + if left < 0 || right < 0 || left >= tileCount || right >= tileCount { + return nil, fmt.Errorf("slider step out of range: %d,%d", left, right) + } + mapping[left], mapping[right] = mapping[right], mapping[left] + } + return mapping, nil +} + +func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle { + w := bounds.Dx() / gridSize + h := bounds.Dy() / gridSize + col := index % gridSize + row := index / gridSize + return image.Rect( + bounds.Min.X+col*w, + bounds.Min.Y+row*h, + bounds.Min.X+(col+1)*w, + bounds.Min.Y+(row+1)*h, + ) +} + +func pixelDiff(a, b color.Color) int64 { + ar, ag, ab, _ := a.RGBA() + br, bg, bb, _ := b.RGBA() + dr := int64(ar>>8) - int64(br>>8) + dg := int64(ag>>8) - int64(bg>>8) + db := int64(ab>>8) - int64(bb>>8) + if dr < 0 { + dr = -dr + } + if dg < 0 { + dg = -dg + } + if db < 0 { + db = -db + } + return dr + dg + db +} + +func seamScoreLumaV2(img image.Image, gridSize int, mapping []int) int64 { + bounds := img.Bounds() + var score int64 + for row := 0; row < gridSize; row++ { + for col := 0; col < gridSize-1; col++ { + leftIdx := row*gridSize + col + rightIdx := leftIdx + 1 + leftDst := sliderTileRect(bounds, gridSize, leftIdx) + rightDst := sliderTileRect(bounds, gridSize, rightIdx) + leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx]) + rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx]) + h := leftDst.Dy() + if rightDst.Dy() < h { + h = rightDst.Dy() + } + for y := 0; y < h; y++ { + yy := leftDst.Min.Y + y + a := sampleLumaMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy) + b := sampleLumaMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy) + score += int64(absIntV2(int(a) - int(b))) + } + } + } + for row := 0; row < gridSize-1; row++ { + for col := 0; col < gridSize; col++ { + topIdx := row*gridSize + col + bottomIdx := (row+1)*gridSize + col + topDst := sliderTileRect(bounds, gridSize, topIdx) + bottomDst := sliderTileRect(bounds, gridSize, bottomIdx) + topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx]) + bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx]) + w := topDst.Dx() + if bottomDst.Dx() < w { + w = bottomDst.Dx() + } + for x := 0; x < w; x++ { + xx := topDst.Min.X + x + a := sampleLumaMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1) + b := sampleLumaMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y) + score += int64(absIntV2(int(a) - int(b))) + } + } + } + return score +} + +func seamScoreRGBTextV2(img image.Image, gridSize int, mapping []int) (int64, float64) { + bounds := img.Bounds() + height := float64(bounds.Dy()) + textCenters := []float64{ + float64(bounds.Min.Y) + 0.2*height, + float64(bounds.Min.Y) + 0.5*height, + float64(bounds.Min.Y) + 0.8*height, + } + sigma := height * 0.14 + if sigma < 1.0 { + sigma = 1.0 + } + weight := func(y int) float64 { + yf := float64(y) + best := absFloatV2(yf - textCenters[0]) + for i := 1; i < len(textCenters); i++ { + d := absFloatV2(yf - textCenters[i]) + if d < best { + best = d + } + } + return 1 + 3*math.Exp(-(best*best)/(2*sigma*sigma)) + } + + var rgbScore int64 + var textScore float64 + for row := 0; row < gridSize; row++ { + for col := 0; col < gridSize-1; col++ { + leftIdx := row*gridSize + col + rightIdx := leftIdx + 1 + leftDst := sliderTileRect(bounds, gridSize, leftIdx) + rightDst := sliderTileRect(bounds, gridSize, rightIdx) + leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx]) + rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx]) + h := leftDst.Dy() + if rightDst.Dy() < h { + h = rightDst.Dy() + } + for y := 0; y < h; y++ { + yy := leftDst.Min.Y + y + l := sampleColorMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy) + r := sampleColorMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy) + rgbScore += pixelDiff(l, r) + _, _, lb, _ := l.RGBA() + _, _, rb, _ := r.RGBA() + textScore += weight(yy) * float64(absIntV2(int(lb>>8)-int(rb>>8))) + } + } + } + for row := 0; row < gridSize-1; row++ { + for col := 0; col < gridSize; col++ { + topIdx := row*gridSize + col + bottomIdx := (row+1)*gridSize + col + topDst := sliderTileRect(bounds, gridSize, topIdx) + bottomDst := sliderTileRect(bounds, gridSize, bottomIdx) + topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx]) + bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx]) + w := topDst.Dx() + if bottomDst.Dx() < w { + w = bottomDst.Dx() + } + for x := 0; x < w; x++ { + xx := topDst.Min.X + x + t := sampleColorMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1) + b := sampleColorMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y) + rgbScore += pixelDiff(t, b) + _, _, tb, _ := t.RGBA() + _, _, bb, _ := b.RGBA() + textScore += 0.65 * float64(absIntV2(int(tb>>8)-int(bb>>8))) + } + } + } + return rgbScore, textScore +} + +func sampleColorMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) color.Color { + dx := dstRect.Dx() + if dx < 1 { + dx = 1 + } + dy := dstRect.Dy() + if dy < 1 { + dy = 1 + } + sx := srcRect.Min.X + (dstX-dstRect.Min.X)*srcRect.Dx()/dx + sy := srcRect.Min.Y + (dstY-dstRect.Min.Y)*srcRect.Dy()/dy + return img.At(sx, sy) +} + +func sampleLumaMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 { + c := sampleColorMappedV2(img, dstRect, srcRect, dstX, dstY) + r, g, b, _ := c.RGBA() + y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000 + return uint8(y) +} + +func absFloatV2(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func absIntV2(v int) int { + if v < 0 { + return -v + } + return v +} + +func buildSliderCursorV2(candidateIndex int, candidateCount int) string { + if candidateCount <= 0 { + return "[]" + } + if candidateIndex < 1 { + candidateIndex = 1 + } + if candidateIndex > candidateCount { + candidateIndex = candidateCount + } + + type cursorPoint struct { + X int `json:"x"` + Y int `json:"y"` + } + + startX := 570 + mathrand.Intn(40) + startY := 875 + mathrand.Intn(30) + + denom := candidateCount - 1 + if denom < 1 { + denom = 1 + } + baseTargetX := 734 + (937-734)*(candidateIndex-1)/denom + targetX := baseTargetX + mathrand.Intn(10) - 5 + targetY := 655 + mathrand.Intn(14) + + points := make([]cursorPoint, 0, 28) + + for i := 0; i < 1+mathrand.Intn(3); i++ { + points = append(points, cursorPoint{ + X: startX + mathrand.Intn(5) - 2, + Y: startY + mathrand.Intn(5) - 2, + }) + } + + transitSteps := 2 + mathrand.Intn(3) + arcOffX := mathrand.Intn(60) - 30 + arcOffY := -(mathrand.Intn(30) + 10) + for i := 1; i <= transitSteps; i++ { + t := float64(i) / float64(transitSteps+1) + cx := float64(startX+targetX)/2 + float64(arcOffX) + cy := float64(startY+targetY)/2 + float64(arcOffY) + bx := (1-t)*(1-t)*float64(startX) + 2*t*(1-t)*cx + t*t*float64(targetX) + by := (1-t)*(1-t)*float64(startY) + 2*t*(1-t)*cy + t*t*float64(targetY) + jitter := int((1-t)*8) + 2 + points = append(points, cursorPoint{ + X: int(math.Round(bx)) + mathrand.Intn(jitter*2+1) - jitter, + Y: int(math.Round(by)) + mathrand.Intn(jitter*2+1) - jitter, + }) + } + + approachSteps := 4 + mathrand.Intn(4) + prev := points[len(points)-1] + for i := 1; i <= approachSteps; i++ { + t := float64(i) / float64(approachSteps) + ax := prev.X + int(math.Round(t*float64(targetX-prev.X))) + mathrand.Intn(5) - 2 + ay := prev.Y + int(math.Round(t*float64(targetY-prev.Y))) + mathrand.Intn(5) - 2 + points = append(points, cursorPoint{X: ax, Y: ay}) + } + + settleCount := 3 + mathrand.Intn(5) + for i := 0; i < settleCount; i++ { + points = append(points, cursorPoint{ + X: targetX + mathrand.Intn(7) - 3, + Y: targetY + mathrand.Intn(7) - 3, + }) + } + + data, err := json.Marshal(points) + if err != nil { + return "[]" + } + return string(data) +} diff --git a/go_client/creds.go b/go_client/creds.go new file mode 100644 index 0000000..754d994 --- /dev/null +++ b/go_client/creds.go @@ -0,0 +1,675 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net" + neturl "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" + "github.com/google/uuid" +) + +// ─── VK Credential Sets (2 stable app_id with rotating fallback) ─── + +type VKCredentials struct { + ClientID string + ClientSecret string +} + +var vkCredentialsList = []VKCredentials{ + {ClientID: "6287487", ClientSecret: "MuAxFaKDYDOICzGnEOhp"}, + {ClientID: "8202606", ClientSecret: "lMRsTiMCyPnp5vfoldmn"}, +} + +const vkCredentialAttemptLimit = 4 + +// ─── Credential Caching ─── + +type TurnCredentials struct { + Username string + Password string + ServerAddrs []string + ExpiresAt time.Time + Link string +} + +type StreamCredentialsCache struct { + creds TurnCredentials + mutex sync.RWMutex + errorCount atomic.Int32 + lastErrorTime atomic.Int64 +} + +const ( + credentialLifetime = 10 * time.Minute + cacheSafetyMargin = 60 * time.Second + maxCacheErrors = 3 + errorWindow = 10 * time.Second +) + +var streamsPerCache = 10 + +func getCacheID(streamID int) int { + return streamID / streamsPerCache +} + +var credentialsStore = struct { + mu sync.RWMutex + caches map[int]*StreamCredentialsCache +}{ + caches: make(map[int]*StreamCredentialsCache), +} + +func getStreamCache(streamID int) *StreamCredentialsCache { + cacheID := getCacheID(streamID) + + credentialsStore.mu.RLock() + cache, exists := credentialsStore.caches[cacheID] + credentialsStore.mu.RUnlock() + + if exists { + return cache + } + + credentialsStore.mu.Lock() + defer credentialsStore.mu.Unlock() + + if cache, exists = credentialsStore.caches[cacheID]; exists { + return cache + } + + cache = &StreamCredentialsCache{} + credentialsStore.caches[cacheID] = cache + return cache +} + +func (c *StreamCredentialsCache) invalidate(streamID int) { + c.mutex.Lock() + c.creds = TurnCredentials{} + c.mutex.Unlock() + + c.errorCount.Store(0) + c.lastErrorTime.Store(0) + + log.Printf("[STREAM %d] [VK Auth] Credentials cache invalidated", streamID) +} + +func cloneStringSlice(in []string) []string { + out := make([]string, len(in)) + copy(out, in) + return out +} + +func isAuthError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "401") || + strings.Contains(errStr, "Unauthorized") || + strings.Contains(errStr, "authentication") || + strings.Contains(errStr, "invalid credential") || + strings.Contains(errStr, "stale nonce") +} + +func handleAuthError(streamID int) bool { + cache := getStreamCache(streamID) + cacheID := getCacheID(streamID) + + now := time.Now().Unix() + + if now-cache.lastErrorTime.Load() > int64(errorWindow.Seconds()) { + cache.errorCount.Store(0) + } + + count := cache.errorCount.Add(1) + cache.lastErrorTime.Store(now) + + log.Printf("[STREAM %d] Auth error (cache=%d, count=%d/%d)", streamID, cacheID, count, maxCacheErrors) + + if count >= maxCacheErrors { + log.Printf("[VK Auth] Multiple auth errors detected (%d), invalidating cache %d", count, cacheID) + cache.invalidate(streamID) + return true + } + return false +} + +// ─── Captcha lockout ─── + +var globalCaptchaLockout atomic.Int64 + +const ( + captchaAutoWebViewTimeout = 10 * time.Second + captchaManualWebViewTimeout = 60 * time.Second + captchaSelectedWebViewTimeout = 120 * time.Second +) + +// ─── Random delay ─── + +func vkDelayRandom(minMs, maxMs int) { + ms := minMs + rand.Intn(maxMs-minMs+1) + time.Sleep(time.Duration(ms) * time.Millisecond) +} + +// ─── Cached credential fetcher ─── + +func getVkCredsCached(ctx context.Context, link string, streamID int) (string, string, []string, error) { + cache := getStreamCache(streamID) + cacheID := getCacheID(streamID) + + cache.mutex.RLock() + if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 { + expires := time.Until(cache.creds.ExpiresAt) + u, p := cache.creds.Username, cache.creds.Password + addr := cache.creds.ServerAddrs[streamID%len(cache.creds.ServerAddrs)] + addrs := cloneStringSlice(cache.creds.ServerAddrs) + cache.mutex.RUnlock() + log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v, selected=%s, urls=%d)", streamID, cacheID, expires.Truncate(time.Second), addr, len(addrs)) + return u, p, addrs, nil + } + cache.mutex.RUnlock() + + cache.mutex.Lock() + defer cache.mutex.Unlock() + + // Double-check inside lock + if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) && len(cache.creds.ServerAddrs) > 0 { + return cache.creds.Username, cache.creds.Password, cloneStringSlice(cache.creds.ServerAddrs), nil + } + + user, pass, addrs, err := fetchVkCredsSerialized(ctx, link, streamID) + if err != nil { + return "", "", nil, err + } + + cache.creds = TurnCredentials{ + Username: user, + Password: pass, + ServerAddrs: addrs, + ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), + Link: link, + } + return user, pass, cloneStringSlice(addrs), nil +} + +// ─── Serialized (throttled) fetcher ─── + +var ( + vkRequestMu sync.Mutex + globalLastVkFetchTime time.Time +) + +func fetchVkCredsSerialized(ctx context.Context, link string, streamID int) (string, string, []string, error) { + vkRequestMu.Lock() + defer vkRequestMu.Unlock() + + // Throttle: 3-6 seconds between requests + minInterval := 3*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond + elapsed := time.Since(globalLastVkFetchTime) + + if !globalLastVkFetchTime.IsZero() && elapsed < minInterval { + wait := minInterval - elapsed + log.Printf("[STREAM %d] [VK Auth] Throttling: waiting %v to prevent rate limit...", streamID, wait.Truncate(time.Millisecond)) + select { + case <-ctx.Done(): + return "", "", nil, ctx.Err() + case <-time.After(wait): + } + } + + defer func() { + globalLastVkFetchTime = time.Now() + }() + + return fetchVkCreds(ctx, link, streamID) +} + +// ─── Main credential fetcher (rotates through stable credential sets) ─── + +func fetchVkCreds(ctx context.Context, link string, streamID int) (string, string, []string, error) { + if time.Now().Unix() < globalCaptchaLockout.Load() { + return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active") + } + + var lastErr error + jar := tlsclient.NewCookieJar() + + for attempt := 0; attempt < vkCredentialAttemptLimit; attempt++ { + creds := vkCredentialsList[attempt%len(vkCredentialsList)] + log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s (attempt %d/%d)", streamID, creds.ClientID, attempt+1, vkCredentialAttemptLimit) + + user, pass, addrs, err := getTokenChain(ctx, link, streamID, creds, jar) + + if err == nil { + log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID) + return user, pass, addrs, nil + } + + lastErr = err + log.Printf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err) + + if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") || strings.Contains(err.Error(), "FATAL_CAPTCHA") { + return "", "", nil, err + } + + if strings.Contains(err.Error(), "error_code:29") || strings.Contains(err.Error(), "error_code: 29") || strings.Contains(err.Error(), "Rate limit") { + log.Printf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials...", streamID) + } + + if attempt%len(vkCredentialsList) == len(vkCredentialsList)-1 && attempt+1 < vkCredentialAttemptLimit { + wait := time.Duration(900+rand.Intn(900)) * time.Millisecond + log.Printf("[STREAM %d] [VK Auth] Both VK credentials failed, retrying stable pair after %v...", streamID, wait) + select { + case <-ctx.Done(): + return "", "", nil, ctx.Err() + case <-time.After(wait): + } + } + } + + return "", "", nil, fmt.Errorf("all VK credentials failed: %w", lastErr) +} + +// ─── Token chain: anon_token → getCallPreview → getAnonymousToken → OK session → joinConversation → TURN creds ─── + +func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, jar tlsclient.CookieJar) (string, string, []string, error) { + profile := getRandomProfile() + + client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), + tlsclient.WithTimeoutSeconds(20), + tlsclient.WithClientProfile(profiles.Chrome_146), + tlsclient.WithCookieJar(jar), + ) + if err != nil { + return "", "", nil, fmt.Errorf("failed to initialize tls_client: %w", err) + } + + name := generateName() + escapedName := neturl.QueryEscape(name) + + log.Printf("[STREAM %d] [VK Auth] Identity - Name: %s | UA: %s", streamID, name, profile.UserAgent) + + doRequest := func(data string, url string) (resp map[string]interface{}, err error) { + parsedURL, err := neturl.Parse(url) + if err != nil { + return nil, fmt.Errorf("parse request URL: %w", err) + } + domain := parsedURL.Hostname() + + req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data))) + if err != nil { + return nil, err + } + + req.Host = domain + applyBrowserProfileFhttp(req, profile) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "https://vk.ru") + req.Header.Set("Referer", "https://vk.ru/") + req.Header.Set("Sec-Fetch-Site", "same-site") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Priority", "u=1, i") + + httpResp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { + if closeErr := httpResp.Body.Close(); closeErr != nil { + log.Printf("close response body: %s", closeErr) + } + }() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &resp) + if err != nil { + return nil, err + } + return resp, nil + } + + // Step 1: get_anonym_token + data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", creds.ClientID, creds.ClientSecret, creds.ClientID) + resp, err := doRequest(data, "https://login.vk.ru/?act=get_anonym_token") + if err != nil { + return "", "", nil, err + } + dataMap, ok := resp["data"].(map[string]interface{}) + if !ok { + return "", "", nil, fmt.Errorf("unexpected anon token response: %v", resp) + } + token1, ok := dataMap["access_token"].(string) + if !ok { + return "", "", nil, fmt.Errorf("missing access_token in response: %v", resp) + } + + vkDelayRandom(100, 150) + + // Step 2: getCallPreview (mimics real VK client behavior) + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1) + _, err = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID) + if err != nil { + log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, err) + } + + vkDelayRandom(200, 400) + + // Step 3: getAnonymousToken (with captcha handling) + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1) + urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID) + + var token2 string + var savedProfile *SavedProfile + savedProfile, _ = LoadProfileFromDisk() + + for attempt := 0; ; attempt++ { + resp, err = doRequest(data, urlAddr) + if err != nil { + return "", "", nil, err + } + + if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr { + captchaErr := parseVkCaptchaError(errObj) + if captchaErr != nil && captchaErr.RedirectURI != "" && captchaErr.SessionToken != "" { + if attempt >= 3 { + log.Printf("[STREAM %d] [Captcha] Max attempts reached", streamID) + globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix()) + return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED") + } + + successToken, solveErr := solveCaptchaBySelectedMode(ctx, streamID, attempt+1, captchaErr, client, profile, savedProfile) + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] Solve failed: %v", streamID, solveErr) + globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix()) + return "", "", nil, fmt.Errorf("CAPTCHA_WAIT_REQUIRED") + } + + captchaAttempt := captchaErr.CaptchaAttempt + if captchaAttempt == "0" || captchaAttempt == "" { + captchaAttempt = "1" + } + + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s", + link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaAttempt, token1) + continue + } + return "", "", nil, fmt.Errorf("VK API error: %v", errObj) + } + + respMap, okLoop := resp["response"].(map[string]interface{}) + if !okLoop { + return "", "", nil, fmt.Errorf("unexpected getAnonymousToken response: %v", resp) + } + token2, okLoop = respMap["token"].(string) + if !okLoop { + return "", "", nil, fmt.Errorf("missing token in response: %v", resp) + } + break + } + + vkDelayRandom(100, 150) + + // Step 4: OK.ru anonymLogin + sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New()) + data = fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", neturl.QueryEscape(sessionData)) + resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do") + if err != nil { + return "", "", nil, err + } + token3, ok := resp["session_key"].(string) + if !ok { + return "", "", nil, fmt.Errorf("missing session_key in response: %v", resp) + } + + vkDelayRandom(100, 150) + + // Step 5: joinConversationByLink → TURN creds + data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3) + resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do") + if err != nil { + return "", "", nil, err + } + + tsRaw, ok := resp["turn_server"].(map[string]interface{}) + if !ok { + return "", "", nil, fmt.Errorf("missing turn_server in response: %v", resp) + } + user, ok := tsRaw["username"].(string) + if !ok { + return "", "", nil, fmt.Errorf("missing username in turn_server") + } + pass, ok := tsRaw["credential"].(string) + if !ok { + return "", "", nil, fmt.Errorf("missing credential in turn_server") + } + urlsRaw, ok := tsRaw["urls"].([]interface{}) + if !ok || len(urlsRaw) == 0 { + return "", "", nil, fmt.Errorf("missing or empty urls in turn_server") + } + + log.Printf("[STREAM %d] [VK Auth] TURN urls (%d total):", streamID, len(urlsRaw)) + for i, u := range urlsRaw { + log.Printf("[STREAM %d] [VK Auth] [%d] %v", streamID, i, u) + } + + var addresses []string + for _, u := range urlsRaw { + urlStr, ok := u.(string) + if !ok { + continue + } + clean := strings.Split(urlStr, "?")[0] + address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") + addresses = append(addresses, address) + } + + if len(addresses) == 0 { + return "", "", nil, fmt.Errorf("no valid TURN addresses found") + } + + return user, pass, addresses, nil +} + +func solveCaptchaBySelectedMode( + ctx context.Context, + streamID int, + attempt int, + captchaErr *VkCaptchaError, + client tlsclient.HttpClient, + profile Profile, + savedProfile *SavedProfile, +) (string, error) { + switch getCaptchaMode() { + case "wv": + log.Printf("[STREAM %d] [КАПЧА] WBV: режим из настроек Android (attempt %d)", streamID, attempt) + return requestWebViewCaptcha(streamID, captchaErr, "selected", captchaSelectedWebViewTimeout) + case "rjs": + log.Printf("[STREAM %d] [КАПЧА] RJS: Go v2 выбран в настройках (attempt %d)", streamID, attempt) + token, solveErr := solveVkCaptchaV2Attempts(ctx, captchaErr, client, profile, savedProfile, 2) + if solveErr == nil { + return token, nil + } + if ctx.Err() != nil { + return "", solveErr + } + log.Printf("[STREAM %d] [КАПЧА] RJS: ошибка, fallback на WBV Auto: %v", streamID, solveErr) + return requestWebViewCaptcha(streamID, captchaErr, "auto", captchaAutoWebViewTimeout) + } + + log.Printf("[STREAM %d] [КАПЧА] AUTO: старт цепочки (captcha attempt %d)", streamID, attempt) + + token, solveErr := solveVkCaptchaV2Attempts(ctx, captchaErr, client, profile, savedProfile, 2) + if solveErr == nil { + log.Printf("[STREAM %d] [КАПЧА] AUTO: Go v2 решил капчу", streamID) + return token, nil + } + if ctx.Err() != nil { + return "", solveErr + } + lastErr := solveErr + log.Printf("[STREAM %d] [КАПЧА] AUTO: Go v2 не решил за 2 попытки: %v", streamID, solveErr) + + for wbvAttempt := 1; wbvAttempt <= 2; wbvAttempt++ { + log.Printf("[STREAM %d] [КАПЧА] AUTO: WBV Auto попытка %d/2 (timeout %s)", streamID, wbvAttempt, captchaAutoWebViewTimeout) + token, solveErr = requestWebViewCaptcha(streamID, captchaErr, "auto", captchaAutoWebViewTimeout) + if solveErr == nil { + log.Printf("[STREAM %d] [КАПЧА] AUTO: WBV Auto решил капчу", streamID) + return token, nil + } + if ctx.Err() != nil { + return "", solveErr + } + lastErr = solveErr + if isWebViewCaptchaTimeout(solveErr) { + log.Printf("[STREAM %d] [КАПЧА] AUTO: WBV Auto timeout %d/2", streamID, wbvAttempt) + } else { + log.Printf("[STREAM %d] [КАПЧА] AUTO: WBV Auto ошибка %d/2: %v", streamID, wbvAttempt, solveErr) + } + + timer := time.NewTimer(time.Duration(250+rand.Intn(250)) * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return "", ctx.Err() + case <-timer.C: + } + } + + log.Printf("[STREAM %d] [КАПЧА] AUTO: финальная Go v2 попытка после WBV", streamID) + token, solveErr = solveVkCaptchaV2Attempts(ctx, captchaErr, client, profile, savedProfile, 1) + if solveErr == nil { + log.Printf("[STREAM %d] [КАПЧА] AUTO: финальная Go v2 решила капчу", streamID) + return token, nil + } + if ctx.Err() != nil { + return "", solveErr + } + lastErr = solveErr + log.Printf("[STREAM %d] [КАПЧА] AUTO: финальная Go v2 ошибка: %v", streamID, solveErr) + + log.Printf("[STREAM %d] [КАПЧА] AUTO: автоцепочка не прошла, открыт ручной WebView", streamID) + token, solveErr = requestWebViewCaptcha(streamID, captchaErr, "manual", captchaManualWebViewTimeout) + if solveErr == nil { + log.Printf("[STREAM %d] [КАПЧА] AUTO: ручной WebView решил капчу", streamID) + return token, nil + } + if lastErr != nil { + return "", fmt.Errorf("automatic captcha chain failed: %w; manual fallback failed: %v", lastErr, solveErr) + } + return "", solveErr +} + +func requestWebViewCaptcha(streamID int, captchaErr *VkCaptchaError, mode string, timeout time.Duration) (string, error) { + if CaptchaResultChan == nil || captchaErr == nil || captchaErr.RedirectURI == "" || captchaErr.SessionToken == "" { + return "", fmt.Errorf("webview captcha data is incomplete") + } + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode != "manual" && mode != "selected" { + mode = "auto" + } + if timeout <= 0 { + timeout = captchaAutoWebViewTimeout + } + + drainCaptchaResult() + fmt.Printf("CAPTCHA_SOLVE|%s|%s|%s\n", mode, captchaErr.RedirectURI, captchaErr.SessionToken) + + waitCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + select { + case result := <-CaptchaResultChan: + result = strings.TrimSpace(result) + if result == "" { + return "", fmt.Errorf("webview captcha returned empty result") + } + lowerResult := strings.ToLower(result) + if lowerResult == "error:timeout" { + return "", fmt.Errorf("webview captcha timed out") + } + if strings.HasPrefix(lowerResult, "error:") { + return "", fmt.Errorf("webview captcha failed: %s", result) + } + log.Printf("[STREAM %d] [КАПЧА] WBV: %s solve succeeded", streamID, mode) + return result, nil + case <-waitCtx.Done(): + return "", fmt.Errorf("webview captcha timed out") + } +} + +func isWebViewCaptchaTimeout(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "timed out") +} + +// ─── GetCreds returns TURN credentials for a given stream ─── + +func GetCreds(ctx context.Context, link string, streamID int) (string, string, []string, error) { + return getVkCredsCached(ctx, link, streamID) +} + +// ─── DNS dialer setup ─── + +func setupGlobalResolver() { + dialer := &net.Dialer{ + Timeout: 3 * time.Second, + KeepAlive: 30 * time.Second, + } + yandexDNSServers := []string{"77.88.8.8:53", "77.88.8.1:53"} + + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + var lastErr error + for _, dns := range yandexDNSServers { + conn, err := dialer.DialContext(ctx, "udp", dns) + if err == nil { + return conn, nil + } + lastErr = err + conn, err = dialer.DialContext(ctx, "tcp", dns) + if err == nil { + return conn, nil + } + lastErr = err + } + + address = strings.TrimSpace(address) + if address != "" && !isYandexDNSAddress(address) { + conn, err := dialer.DialContext(ctx, network, address) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr + }, + } +} + +func isYandexDNSAddress(address string) bool { + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + host = strings.Trim(host, "[]") + return host == "77.88.8.8" || host == "77.88.8.1" +} diff --git a/go_client/dispatcher.go b/go_client/dispatcher.go new file mode 100644 index 0000000..ed14e31 --- /dev/null +++ b/go_client/dispatcher.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "log" + "net" + "sync" + "sync/atomic" + "time" +) + +const ( + returnChBuf = 384 + + // chunkSize — количество последовательных пакетов, отправляемых в один worker + // перед переключением на следующий. + // + // Зачем: при round-robin (chunk=1) каждый пакет летит через разный TURN relay + // с разным latency, что приводит к reorder на сервере. TCP внутри WireGuard + // интерпретирует reorder как потери → cwnd collapse → скорость single-flow + // падает до ~8 KB/s. + // + // С chunk=8: пакеты в пределах одного TCP congestion window (~10 пакетов при + // initial cwnd) уходят через один TURN relay → прилетают по порядку. + // Reorder возможен только между chunk-границами, что покрывается WG replay + // window (2048 пакетов). + // + // Агрегатная пропускная способность не меняется — все workers загружены + // равномерно по-прежнему (каждый получает 1/N от общего трафика за время). + chunkSize = 8 +) + +type WorkerSlot struct { + ID int + SendCh chan []byte +} + +type Dispatcher struct { + localConn net.PacketConn + clientAddr atomic.Pointer[net.Addr] + mu sync.Mutex + workers []*WorkerSlot + rrIndex int + rrCount int // сколько пакетов отправлено в текущий worker (0..chunkSize-1) + ReturnCh chan []byte + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + stats *Stats +} + +func NewDispatcher(ctx context.Context, localConn net.PacketConn, stats *Stats) *Dispatcher { + dctx, dcancel := context.WithCancel(ctx) + d := &Dispatcher{ + localConn: localConn, + ReturnCh: make(chan []byte, returnChBuf), + ctx: dctx, + cancel: dcancel, + stats: stats, + } + + d.wg.Add(2) + go d.readLoop() + go d.writeLoop() + return d +} + +func (d *Dispatcher) Shutdown() { + d.cancel() + d.wg.Wait() +} + +func (d *Dispatcher) Register(w *WorkerSlot) { + d.mu.Lock() + d.workers = append(d.workers, w) + count := len(d.workers) + d.mu.Unlock() + log.Printf("[ДИСП] Воркер #%d зарегистрирован (всего: %d)", w.ID, count) +} + +func (d *Dispatcher) Unregister(slot *WorkerSlot) { + d.mu.Lock() + for i, w := range d.workers { + if w == slot { + d.workers = append(d.workers[:i], d.workers[i+1:]...) + break + } + } + remaining := len(d.workers) + // Подстраховка: если текущий rrIndex вылез за границу после удаления + if d.rrIndex >= remaining && remaining > 0 { + d.rrIndex = d.rrIndex % remaining + } + d.rrCount = 0 + d.mu.Unlock() + log.Printf("[ДИСП] Воркер #%d отключён (осталось: %d)", slot.ID, remaining) +} + +// readLoop читает WireGuard-пакеты и распределяет по workers chunk'ами. +// +// Логика: отправляем chunkSize подряд пакетов в один worker, потом переходим +// к следующему. Если текущий worker перегружен (канал полный) — немедленно +// ищем свободный worker и начинаем новый chunk на нём. Это гарантирует: +// - В рамках chunk пакеты идут через один TURN relay → in-order delivery +// - Между chunks — разные relay → максимальная агрегатная скорость +// - Нет блокировки, нет буферизации, нет дополнительного latency +func (d *Dispatcher) readLoop() { + defer d.wg.Done() + + buf := make([]byte, readBufSize) + for { + if err := d.ctx.Err(); err != nil { + return + } + + n, addr, err := d.localConn.ReadFrom(buf) + if err != nil { + if d.ctx.Err() != nil { + return + } + time.Sleep(10 * time.Millisecond) + continue + } + + d.clientAddr.Store(&addr) + atomic.AddInt64(&d.stats.TotalBytesUp, int64(n)) + + pkt := make([]byte, n) + copy(pkt, buf[:n]) + + d.mu.Lock() + nw := len(d.workers) + if nw == 0 { + d.mu.Unlock() + continue + } + + sent := false + idx := d.rrIndex % nw + + // Пробуем текущий worker (chunk affinity) + w := d.workers[idx] + select { + case w.SendCh <- pkt: + sent = true + d.rrCount++ + if d.rrCount >= chunkSize { + d.rrIndex = (idx + 1) % nw + d.rrCount = 0 + } + default: + // Текущий worker перегружен — ищем свободный, начинаем новый chunk + for i := 1; i < nw; i++ { + altIdx := (idx + i) % nw + select { + case d.workers[altIdx].SendCh <- pkt: + sent = true + d.rrIndex = altIdx + d.rrCount = 1 // первый пакет нового chunk'а уже отправлен + default: + } + if sent { + break + } + } + } + + if !sent { + // Все workers перегружены — сдвигаем указатель, пакет дропается + d.rrIndex = (idx + 1) % nw + d.rrCount = 0 + } + d.mu.Unlock() + } +} + +func (d *Dispatcher) writeLoop() { + defer d.wg.Done() + + for { + select { + case <-d.ctx.Done(): + return + case pkt := <-d.ReturnCh: + addrPtr := d.clientAddr.Load() + if addrPtr == nil { + continue + } + addr := *addrPtr + if _, err := d.localConn.WriteTo(pkt, addr); err != nil { + if d.ctx.Err() != nil { + return + } + } + atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt))) + } + } +} diff --git a/go_client/go.mod b/go_client/go.mod new file mode 100644 index 0000000..1c079a6 --- /dev/null +++ b/go_client/go.mod @@ -0,0 +1,33 @@ +module wg-turn-client + +go 1.26 + +require ( + github.com/bogdanfinn/fhttp v0.6.8 + github.com/bogdanfinn/tls-client v1.14.0 + github.com/cbeuw/connutil v1.0.1 + github.com/google/uuid v1.6.0 + github.com/pion/dtls/v3 v3.1.2 + github.com/pion/logging v0.2.4 + github.com/pion/turn/v5 v5.0.2 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bdandy/go-socks4 v1.2.3 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect + github.com/bogdanfinn/utls v1.7.7-barnius // indirect + github.com/bogdanfinn/websocket v1.5.5-barnius // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/go_client/group.go b/go_client/group.go new file mode 100644 index 0000000..64be21d --- /dev/null +++ b/go_client/group.go @@ -0,0 +1,300 @@ +package main + +import ( + "context" + "fmt" + "log" + "math/rand" + "net" + "strings" + "sync" + "sync/atomic" + "time" +) + +var groupAuthMutex sync.Mutex + +const ( + workersPerGroup = 9 + defaultCycleSecs = 36000 +) + +// WorkerGroup: +// Запускает 9 потоков с одними кредами. Ротации нет — работает до смерти воркеров. +func WorkerGroup( + ctx context.Context, + groupID int, + hashIndex int, + tp *TurnParams, + peer *net.UDPAddr, + d *Dispatcher, + localPort string, + getConfig bool, + configCh chan<- string, + workerIDs []int, + cycleDuration time.Duration, + pauseFlag *int32, + deviceID, password string, + stats *Stats, + waitReady <-chan struct{}, + signalReady chan<- struct{}, +) { + // Каскадный запуск: ждем свою очередь + if waitReady != nil { + log.Printf("[ГРУППА #%d] Ожидание сигнала от предыдущей группы...", groupID) + select { + case <-waitReady: + case <-ctx.Done(): + return + } + } + + var configSent int32 + if !getConfig { + configSent = 1 + } + + // Doze-mode пауза + for atomic.LoadInt32(pauseFlag) != 0 { + if ctx.Err() != nil { + return + } + time.Sleep(1 * time.Second) + } + + hash := tp.Hashes[hashIndex%len(tp.Hashes)] + shortHash := hash + if len(shortHash) > 8 { + shortHash = shortHash[:8] + } + log.Printf("[ГРУППА #%d] Запрос кредов (хеш: %s...)", groupID, shortHash) + + credStreamID := groupID * 100 + user, pass, turnURLs, err := GetCreds(ctx, hash, credStreamID) + var creds *Credentials + if err == nil { + creds = &Credentials{User: user, Pass: pass, TurnURLs: turnURLs, CacheStreamID: credStreamID} + } else { + log.Printf("[ГРУППА #%d] Ошибка кредов: %v", groupID, err) + return + } + + log.Printf("[ГРУППА #%d] Креды OK, TURN: %v, %d воркеров", groupID, creds.TurnURLs, len(workerIDs)) + + var configRequestInFlight int32 + var wg sync.WaitGroup + var credsMu sync.RWMutex + var refreshMu sync.Mutex + var lastCredRefresh atomic.Int64 + + refreshCreds := func(reason string) bool { + refreshMu.Lock() + defer refreshMu.Unlock() + + now := time.Now().Unix() + last := lastCredRefresh.Load() + if last > 0 && now-last < 15 { + log.Printf("[TURN] Креды уже обновлялись %d сек назад, ждём следующий retry (%s)", now-last, reason) + return true + } + + getStreamCache(credStreamID).invalidate(credStreamID) + u, p, urls, refreshErr := GetCreds(ctx, hash, credStreamID) + if refreshErr != nil { + log.Printf("[TURN] Не удалось обновить креды после %s: %v", reason, refreshErr) + return false + } + + credsMu.Lock() + creds = &Credentials{User: u, Pass: p, TurnURLs: urls, CacheStreamID: credStreamID} + credsMu.Unlock() + lastCredRefresh.Store(time.Now().Unix()) + log.Printf("[TURN] Креды обновлены после %s, TURN urls=%d", reason, len(urls)) + return true + } + + // Сигнализируем следующей группе, что мы успешно запустились (креды получены + 2 сек форы) + if signalReady != nil { + go func() { + time.Sleep(2000 * time.Millisecond) + close(signalReady) + log.Printf("[ГРУППА #%d] Успешный старт! Передача эстафеты следующей группе...", groupID) + }() + } + + for i, wid := range workerIDs { + wg.Add(1) + + // Stagger: 500мс между воркерами + workerDelay := time.Duration(i) * 500 * time.Millisecond + + go func(wid int, delay time.Duration) { + defer wg.Done() + + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return + } + } + + shouldGetConfig := getConfig + attempt := 0 + + for { + if ctx.Err() != nil { + return + } + + getConf := false + if shouldGetConfig && atomic.LoadInt32(&configSent) == 0 { + getConf = atomic.CompareAndSwapInt32(&configRequestInFlight, 0, 1) + } + var cc chan<- string + if getConf { + cc = configCh + } + + credsMu.RLock() + credsSnapshot := *creds + credsSnapshot.TurnURLs = cloneStringSlice(creds.TurnURLs) + credsMu.RUnlock() + + configDelivered, sessErr := RunSession(ctx, tp, peer, d, localPort, + getConf, cc, wid, &credsSnapshot, deviceID, password, stats) + + if getConf { + if configDelivered { + atomic.StoreInt32(&configSent, 1) + } else { + atomic.StoreInt32(&configRequestInFlight, 0) + } + } + + if sessErr != nil { + if ctx.Err() != nil { + return + } + errStr := sessErr.Error() + errStrLower := strings.ToLower(errStr) + + turnAllocAttrMissing := strings.Contains(errStrLower, "turn allocate") && + strings.Contains(errStrLower, "attribute not found") + turnCredRefreshNeeded := turnAllocAttrMissing || + strings.Contains(errStrLower, "turn allocate auth") || + strings.Contains(errStrLower, "invalid credential") || + strings.Contains(errStrLower, "stale nonce") || + strings.Contains(errStrLower, "allocation mismatch") || + strings.Contains(errStrLower, "error 508") || + strings.Contains(errStrLower, "turn квота") || + strings.Contains(errStrLower, "quota") + + if strings.Contains(errStrLower, "rate limit") || + strings.Contains(errStrLower, "flood control") || + strings.Contains(errStrLower, "ip mismatch") || + strings.Contains(errStrLower, "error 29") { + errStr += " (ошибка со стороны ВК)" + } + + if strings.Contains(errStr, "хеш мёртв") || + strings.Contains(errStr, "FATAL_AUTH") { + log.Printf("[ВОРКЕР #%d] Фатальная ошибка: %s", wid, errStr) + return + } + + attempt++ + if turnAllocAttrMissing { + log.Printf("[ВОРКЕР #%d] [TURN] Allocate вернул неполный ответ, обновляем TURN-креды и повторяем (попытка %d): %s", wid, attempt, errStr) + refreshCreds("TURN Allocate attribute-not-found") + } else if turnCredRefreshNeeded { + log.Printf("[ВОРКЕР #%d] [TURN] Ошибка allocation/кредов, обновляем TURN-креды и повторяем (попытка %d): %s", wid, attempt, errStr) + refreshCreds("TURN allocation error") + } else { + log.Printf("[ВОРКЕР #%d] Ошибка (попытка %d): %s", wid, attempt, errStr) + } + + // Если ошибка STUN (credentials invalid), воркер не сможет переподключиться. Завершаем. + isStunDeath := strings.Contains(errStrLower, "error 29") || + strings.Contains(errStrLower, "cannot create socket") + + if isStunDeath { + log.Printf("[ВОРКЕР #%d] Невосстановимая TURN/STUN ошибка, завершение: %s", wid, errStr) + return + } + } + + if ctx.Err() != nil { + return + } + + retryDelay := time.Duration(5+rand.Intn(11)) * time.Second + select { + case <-time.After(retryDelay): + case <-ctx.Done(): + return + } + } + }(wid, workerDelay) + } + + wg.Wait() + log.Printf("[ГРУППА #%d] Все воркеры группы завершились.", groupID) +} + +// ParseHashes — парсит строку хешей +func ParseHashes(raw string) []string { + var result []string + seen := make(map[string]struct{}) + for _, h := range strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' ' + }) { + h = normalizeVKJoinHash(h) + if h != "" { + if _, exists := seen[h]; exists { + continue + } + seen[h] = struct{}{} + result = append(result, h) + } + } + return result +} + +func normalizeVKJoinHash(input string) string { + s := strings.Trim(strings.TrimSpace(input), "<>\"'") + if s == "" { + return "" + } + + lower := strings.ToLower(s) + if idx := strings.Index(lower, "/call/join/"); idx >= 0 { + s = s[idx+len("/call/join/"):] + } else if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + return "" + } + + if idx := strings.IndexAny(s, "?#/"); idx != -1 { + s = s[:idx] + } + return strings.Trim(strings.TrimSpace(s), "/") +} + +// TurnParams — конфигурация TURN +type TurnParams struct { + Host string + Port string + Hashes []string + WrapKey []byte // Password-derived WRAP key (32 bytes), nil = disabled +} + +// Credentials — учетные данные TURN +type Credentials struct { + User string + Pass string + TurnURLs []string + CacheStreamID int +} + +// Unused import suppressor +var _ = fmt.Sprintf diff --git a/go_client/main.go b/go_client/main.go new file mode 100644 index 0000000..10fe629 --- /dev/null +++ b/go_client/main.go @@ -0,0 +1,305 @@ +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "log" + "net" + "os" + "os/signal" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +// CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView) +var CaptchaResultChan = make(chan string, 1) + +var captchaModeValue atomic.Value + +func init() { + captchaModeValue.Store("auto") +} + +func normalizeCaptchaMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "auto", "rjs", "wv": + return strings.ToLower(strings.TrimSpace(mode)) + default: + return "auto" + } +} + +func setCaptchaMode(mode string) string { + normalized := normalizeCaptchaMode(mode) + captchaModeValue.Store(normalized) + return normalized +} + +func getCaptchaMode() string { + mode, _ := captchaModeValue.Load().(string) + if mode == "" { + return "auto" + } + return mode +} + +// drainCaptchaResult удаляет устаревший результат капчи из канала +func drainCaptchaResult() { + select { + case <-CaptchaResultChan: + default: + } +} + +func main() { + log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds) + + setupGlobalResolver() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Сигналы + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT) + go func() { + select { + case s := <-sig: + log.Printf("[КЛИЕНТ] Сигнал %v, завершаю...", s) + cancel() + case <-ctx.Done(): + return + } + select { + case s := <-sig: + log.Printf("[КЛИЕНТ] Повторный %v, принудительный выход", s) + os.Exit(1) + case <-ctx.Done(): + } + }() + + var pauseFlag int32 + + // STDIN для PAUSE/RESUME/STOP и CAPTCHA_RESULT + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.Contains(line, "error:tunnel stopped") { + log.Printf("[STDIN] %s", line) + } + switch { + case line == "PAUSE": + atomic.StoreInt32(&pauseFlag, 1) + case line == "RESUME": + atomic.StoreInt32(&pauseFlag, 0) + case line == "STOP": + cancel() + return + case strings.HasPrefix(line, "CAPTCHA_RESULT|"): + result := strings.TrimPrefix(line, "CAPTCHA_RESULT|") + drainCaptchaResult() + CaptchaResultChan <- result + log.Printf("[КАПЧА] Результат от Kotlin записан в канал") + } + } + }() + + host := flag.String("turn", "", "переопределить IP TURN") + port := flag.String("port", "", "переопределить порт TURN") + listen := flag.String("listen", "127.0.0.1:9000", "локальный адрес") + vkHash := flag.String("vk", "", "хеши VK-звонков (через запятую)") + peerAddr := flag.String("peer", "", "адрес:порт VPS сервера") + numW := flag.Int("n", 24, "количество воркеров (кратно 12)") + + deviceID := flag.String("device-id", "unknown", "уникальный ID устройства") + connPassword := flag.String("password", "", "пароль подключения") + captchaMode := flag.String("captcha-mode", "auto", "режим обхода капчи (auto/wv/rjs)") + + flag.Parse() + activeCaptchaMode := setCaptchaMode(*captchaMode) + + if *peerAddr == "" || *vkHash == "" { + log.Fatal("[КЛИЕНТ] Нужны -peer и -vk") + } + + peer, err := net.ResolveUDPAddr("udp", *peerAddr) + if err != nil { + log.Fatalf("[КЛИЕНТ] Ошибка разбора пира: %v", err) + } + + hashes := ParseHashes(*vkHash) + if len(hashes) == 0 { + log.Fatal("[КЛИЕНТ] Нет хешей VK") + } + + if *connPassword == "" { + log.Fatal("[КЛИЕНТ] Нужен -password: WRAP ключ теперь выводится из пароля подключения") + } + + // WRAP key + wrapKey, err := deriveWrapKey(*connPassword) + if err != nil { + log.Fatalf("[КЛИЕНТ] WRAP key derive: %v", err) + } + + // Лимит воркеров + maxWorkers := 108 + if *numW > maxWorkers { + *numW = maxWorkers + } + if *numW < workersPerGroup { + *numW = workersPerGroup + } + *numW = (*numW / workersPerGroup) * workersPerGroup + + tp := &TurnParams{ + Host: *host, + Port: *port, + Hashes: hashes, + WrapKey: wrapKey, + } + + // Слушаем локально + localConn, err := net.ListenPacket("udp", *listen) + if err != nil { + log.Fatalf("[КЛИЕНТ] Ошибка слушателя %s: %v", *listen, err) + } + if uc, ok := localConn.(*net.UDPConn); ok { + _ = uc.SetReadBuffer(socketBufSize) + _ = uc.SetWriteBuffer(socketBufSize) + } + stopLocalConn := context.AfterFunc(ctx, func() { _ = localConn.Close() }) + defer stopLocalConn() + + _, localPort, _ := net.SplitHostPort(*listen) + if localPort == "" { + localPort = "9000" + } + + numGroups := *numW / workersPerGroup + + wrapStatus := "OFF" + if len(wrapKey) == wrapKeyLen { + wrapStatus = "ON (password HKDF + RTP AEAD)" + } + + captchaStatus := "AUTO: Go v2 x2 -> WBV Auto x2 -> Go v2 x1 -> Manual WBV" + switch activeCaptchaMode { + case "wv": + captchaStatus = "WBV selected in Android" + case "rjs": + captchaStatus = "RJS Go v2 with WBV Auto fallback" + } + + log.Println("[КЛИЕНТ] ═══════════════════════════════════════") + log.Printf("[КЛИЕНТ] VK Creds: 2 stable app_id с циклическим fallback") + log.Printf("[КЛИЕНТ] TLS: Chrome 146 fingerprint") + log.Printf("[КЛИЕНТ] Воркеров: %d (групп: %d, по %d)", *numW, numGroups, workersPerGroup) + log.Printf("[КЛИЕНТ] Хешей: %d", len(hashes)) + log.Printf("[КЛИЕНТ] Слушаю: %s | Пир: %s", *listen, *peerAddr) + log.Printf("[КЛИЕНТ] Протокол: UDP") + log.Printf("[КЛИЕНТ] WRAP: %s", wrapStatus) + log.Printf("[WRAP] Ключ выведен из пароля, режим RTP AEAD активен") + log.Printf("[КЛИЕНТ] Device ID: %s", *deviceID) + log.Printf("[КЛИЕНТ] Captcha: %s", captchaStatus) + log.Println("[КЛИЕНТ] ═══════════════════════════════════════") + + stats := NewStats() + shutdownCh := make(chan struct{}) + go func() { + <-ctx.Done() + close(shutdownCh) + }() + go stats.RunLoop(shutdownCh) + + disp := NewDispatcher(ctx, localConn, stats) + defer disp.Shutdown() + + configCh := make(chan string, 1) + configDone := make(chan struct{}) + go func() { + defer close(configDone) + select { + case rawConf, ok := <-configCh: + if !ok || rawConf == "" { + return + } + finalConf := rawConf + if !strings.Contains(finalConf, "MTU =") { + lines := strings.Split(finalConf, "\n") + var newLines []string + for _, line := range lines { + newLines = append(newLines, line) + if strings.TrimSpace(line) == "[Interface]" { + newLines = append(newLines, "MTU = 1280") + } + } + finalConf = strings.Join(newLines, "\n") + } + fmt.Println() + fmt.Println("╔══════════════ WireGuard Конфиг ══════════════╗") + for _, line := range strings.Split(finalConf, "\n") { + fmt.Printf("║ %-44s ║\n", line) + } + fmt.Println("╚══════════════════════════════════════════════╝") + if err := os.WriteFile("wg-turn.conf", []byte(finalConf+"\n"), 0600); err != nil { + log.Printf("[КОНФИГ] Ошибка сохранения: %v", err) + } else { + log.Println("[КОНФИГ] Сохранён в wg-turn.conf") + } + case <-ctx.Done(): + } + }() + + var wg sync.WaitGroup + workerIDCounter := 1 + + var prevWaitReady <-chan struct{} + + for g := 0; g < numGroups; g++ { + isFirst := (g == 0) + + var myWaitReady <-chan struct{} + var mySignalReady chan<- struct{} + + if g > 0 { + myWaitReady = prevWaitReady + } + if g < numGroups-1 { + ch := make(chan struct{}) + mySignalReady = ch + prevWaitReady = ch + } + + ids := make([]int, workersPerGroup) + for i := range ids { + ids[i] = workerIDCounter + workerIDCounter++ + } + + gID := g + 1 + cycle := time.Duration(defaultCycleSecs) * time.Second + var cc chan<- string + if isFirst { + cc = configCh + } + + wg.Add(1) + go func(groupID int, cycleDir time.Duration, isFirstGroup bool, configChan chan<- string, workerIds []int, startHashIndex int, waitR <-chan struct{}, sigR chan<- struct{}) { + defer wg.Done() + WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort, + isFirstGroup, configChan, workerIds, cycleDir, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR) + }(gID, cycle, isFirst, cc, ids, g, myWaitReady, mySignalReady) + } + + wg.Wait() + close(configCh) + <-configDone + log.Println("[КЛИЕНТ] Все воркеры завершены") +} diff --git a/go_client/namegen.go b/go_client/namegen.go new file mode 100644 index 0000000..38bcf9e --- /dev/null +++ b/go_client/namegen.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "math/rand" + "strings" +) + +var maleFirstNames = []string{ + "Александр", "Алексей", "Андрей", "Антон", "Арсений", + "Артур", "Артём", "Богдан", "Валерий", "Василий", + "Виктор", "Владислав", "Глеб", "Григорий", "Даниил", + "Денис", "Дмитрий", "Евгений", "Егор", "Иван", + "Игорь", "Илья", "Кирилл", "Леонид", "Максим", + "Марк", "Матвей", "Михаил", "Никита", "Николай", + "Олег", "Павел", "Пётр", "Роман", "Руслан", + "Сергей", "Станислав", "Тимофей", "Фёдор", +} + +var femaleFirstNames = []string{ + "Алина", "Алёна", "Анастасия", "Ангелина", "Анна", + "Вера", "Вероника", "Виктория", "Дарья", "Ева", + "Екатерина", "Елена", "Елизавета", "Ирина", "Кира", + "Кристина", "Ксения", "Любовь", "Маргарита", "Марина", + "Мария", "Милана", "Надежда", "Наталья", "Ольга", + "Полина", "Светлана", "София", "Татьяна", "Юлия", "Яна", +} + +var lastNames = []string{ + "Алексеев", "Андреев", "Антонов", "Баранов", "Белов", + "Белый", "Бельский", "Беляев", "Борисов", "Васильев", + "Великий", "Волков", "Воробьёв", "Григорьев", "Давыдов", + "Егоров", "Жуков", "Зайцев", "Захаров", "Иванов", + "Калинин", "Ковалёв", "Козлов", "Комаров", "Крамской", + "Кузнецов", "Кузьмин", "Лебедев", "Макаров", "Медведев", + "Михайлов", "Морозов", "Никитин", "Николаев", "Новиков", + "Орлов", "Островский", "Павлов", "Петров", "Покровский", + "Попов", "Раевский", "Романов", "Семёнов", "Сергеев", + "Смирнов", "Соколов", "Соловьёв", "Степанов", "Тарасов", + "Титов", "Толстой", "Трубецкой", "Филиппов", "Фролов", + "Фёдоров", "Чайковский", "Черный", "Яковлев", +} + +// convertToFemaleSurname handles Russian suffix rules +func convertToFemaleSurname(surname string) string { + if strings.HasSuffix(surname, "ий") || strings.HasSuffix(surname, "ый") || strings.HasSuffix(surname, "ой") { + return surname[:len(surname)-4] + "ая" + } + if strings.HasSuffix(surname, "ов") || strings.HasSuffix(surname, "ев") || + strings.HasSuffix(surname, "ин") || strings.HasSuffix(surname, "ын") || + strings.HasSuffix(surname, "ёв") { + return surname + "а" + } + return surname +} + +func generateName() string { + isFemale := rand.Intn(2) == 0 + var fn string + if isFemale { + fn = femaleFirstNames[rand.Intn(len(femaleFirstNames))] + } else { + fn = maleFirstNames[rand.Intn(len(maleFirstNames))] + } + // 70% chance to have a last name + if rand.Float32() < 0.3 { + return fn + } + ln := lastNames[rand.Intn(len(lastNames))] + if isFemale { + ln = convertToFemaleSurname(ln) + } + return fmt.Sprintf("%s %s", fn, ln) +} diff --git a/go_client/obfs.go b/go_client/obfs.go new file mode 100644 index 0000000..c180b49 --- /dev/null +++ b/go_client/obfs.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +// obfs.go — WebRTC SRTP-like obfuscation for DTLS traffic +// Each UDP packet is wrapped in an RTP header making it indistinguishable +// from a real WebRTC OPUS audio stream to DPI systems. +// +// Packet format: +// [RTP Header 12 bytes][ChaCha20-Poly1305 payload+tag][Padding 0-N bytes][PadLen 1 byte] +// +// The RTP header fields (SSRC + SeqNum + Timestamp) form the 12-byte AEAD nonce, +// so no separate nonce prefix is needed. + +package main + +import ( + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "sync" + + "golang.org/x/crypto/chacha20poly1305" +) + +// ─── Configuration ─── + +// ObfsConfig holds per-session obfuscation parameters. +type ObfsConfig struct { + SSRC uint32 // Synchronization Source — random per session + PayloadType uint8 // RTP payload type (111 = OPUS dynamic) + PaddingMax int // Max random padding bytes appended +} + +// NewObfsConfig creates a config with random SSRC and sane defaults. +func NewObfsConfig() *ObfsConfig { + var buf [4]byte + rand.Read(buf[:]) + return &ObfsConfig{ + SSRC: binary.BigEndian.Uint32(buf[:]), + PayloadType: 111, // dynamic PT for OPUS + PaddingMax: 24, + } +} + +// ─── Per-direction state (sequence + timestamp counters) ─── + +// ObfsState tracks monotonically increasing RTP sequence number and timestamp. +type ObfsState struct { + mu sync.Mutex + seq uint16 + ts uint32 +} + +// NewObfsState creates a state with random initial seq/ts. +func NewObfsState() *ObfsState { + var buf [6]byte + rand.Read(buf[:]) + return &ObfsState{ + seq: binary.BigEndian.Uint16(buf[0:2]), + ts: binary.BigEndian.Uint32(buf[2:6]), + } +} + +// ─── Nonce derivation ─── + +// obfsBuildNonce deterministically builds a 12-byte AEAD nonce from RTP fields. +// +// [SSRC 4B][SeqNum 2B][0x00 0x00][Timestamp 4B] +func obfsBuildNonce(ssrc uint32, seq uint16, ts uint32) []byte { + n := make([]byte, 12) + binary.BigEndian.PutUint32(n[0:4], ssrc) + binary.BigEndian.PutUint16(n[4:6], seq) + // n[6], n[7] = 0x00 — zero padding for unique nonce space + binary.BigEndian.PutUint32(n[8:12], ts) + return n +} + +// ─── Wrap (encrypt + add RTP header) ─── + +// obfsWrapPacket wraps a plaintext payload into an RTP-like packet with authenticated encryption. +// The output looks like: +// +// [V=2,P=1,X=0,CC=0 | PT | SeqNum | Timestamp | SSRC | encrypted_payload | padding | padLen] +func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]byte, error) { + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + if len(payload) == 0 { + return nil, errors.New("obfs: empty payload") + } + + state.mu.Lock() + seq := state.seq + ts := state.ts + state.seq++ + state.ts += 960 // 20ms frame @ 48kHz (OPUS standard) + state.mu.Unlock() + + // Build nonce from RTP fields + nonce := obfsBuildNonce(cfg.SSRC, seq, ts) + + // Determine padding + padRand := 0 + if cfg.PaddingMax > 0 { + var rndBuf [1]byte + rand.Read(rndBuf[:]) + padRand = int(rndBuf[0]) % cfg.PaddingMax + } + padTotal := padRand + 1 // +1 for the length byte itself + + // Allocate output: 12 (header) + payload + AEAD tag + padTotal + outLen := 12 + len(payload) + chacha20poly1305.Overhead + padTotal + out := make([]byte, outLen) + + // RTP Header (12 bytes) + out[0] = 0x80 | 0x20 // V=2, P=1 (padding present) + out[1] = cfg.PayloadType & 0x7F + binary.BigEndian.PutUint16(out[2:4], seq) + binary.BigEndian.PutUint32(out[4:8], ts) + binary.BigEndian.PutUint32(out[8:12], cfg.SSRC) + + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, fmt.Errorf("obfs: cipher init: %w", err) + } + sealed := aead.Seal(out[12:12], nonce, payload, out[:12]) + + // Random padding bytes + padStart := 12 + len(sealed) + if padRand > 0 { + rand.Read(out[padStart : padStart+padRand]) + } + + // Last byte = total padding count (RFC 3550 §5.1) + out[outLen-1] = byte(padTotal) + + return out, nil +} + +// ─── Unwrap (strip RTP header + decrypt) ─── + +// obfsUnwrapPacket strips the RTP header, removes padding, and decrypts the payload. +// Returns number of plaintext bytes written to dst. +func obfsUnwrapPacket(key, wire, dst []byte) (int, error) { + if len(key) != wrapKeyLen { + return 0, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + if len(wire) < 13 { // 12 header + at least 1 byte + return 0, errors.New("obfs: packet too short") + } + + // Validate RTP version + if (wire[0] >> 6) != 2 { + return 0, errors.New("obfs: not RTP v2") + } + + // Extract RTP fields for nonce + seq := binary.BigEndian.Uint16(wire[2:4]) + ts := binary.BigEndian.Uint32(wire[4:8]) + ssrc := binary.BigEndian.Uint32(wire[8:12]) + + // Handle padding (P bit) + payloadEnd := len(wire) + if wire[0]&0x20 != 0 { + padLen := int(wire[len(wire)-1]) + if padLen == 0 || padLen > payloadEnd-12 { + return 0, fmt.Errorf("obfs: invalid padding length %d", padLen) + } + payloadEnd -= padLen + } + + ciphertextLen := payloadEnd - 12 + if ciphertextLen <= chacha20poly1305.Overhead { + return 0, errors.New("obfs: no payload after stripping header/padding") + } + if ciphertextLen-chacha20poly1305.Overhead > len(dst) { + return 0, errors.New("obfs: dst buffer too small") + } + + // Build nonce and decrypt + nonce := obfsBuildNonce(ssrc, seq, ts) + aead, err := chacha20poly1305.New(key) + if err != nil { + return 0, fmt.Errorf("obfs: cipher init: %w", err) + } + plain, err := aead.Open(dst[:0], nonce, wire[12:payloadEnd], wire[:12]) + if err != nil { + return 0, fmt.Errorf("obfs: auth: %w", err) + } + + return len(plain), nil +} + +// ─── Detection ─── + +// obfsIsRTPPacket checks if a raw UDP packet looks like our obfuscated RTP. +// Used by the server and client to reject non-obfuscated packets. +func obfsIsRTPPacket(wire []byte) bool { + if len(wire) < 13 { + return false + } + // RTP version must be 2 + if (wire[0] >> 6) != 2 { + return false + } + // Our payload type = 111 + pt := wire[1] & 0x7F + return pt == 111 +} diff --git a/go_client/profiles.go b/go_client/profiles.go new file mode 100644 index 0000000..85da123 --- /dev/null +++ b/go_client/profiles.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "math/rand" + "os" +) + +// Profile holds consistent browser fingerprint headers for TLS+HTTP requests. +type Profile struct { + UserAgent string `json:"user_agent"` + SecChUa string `json:"sec_ch_ua"` + SecChUaMobile string `json:"sec_ch_ua_mobile"` + SecChUaPlatform string `json:"sec_ch_ua_platform"` +} + +// SavedProfile is a saved real browser profile loaded from disk. +type SavedProfile struct { + Profile + DeviceJSON string `json:"device_json"` + BrowserFp string `json:"browser_fp"` +} + +const profileFile = "vk_profile.json" + +func LoadProfileFromDisk() (*SavedProfile, error) { + data, err := os.ReadFile(profileFile) + if err != nil { + return nil, err + } + var sp SavedProfile + if err := json.Unmarshal(data, &sp); err != nil { + return nil, err + } + return &sp, nil +} + +func SaveProfileToDisk(sp SavedProfile) error { + data, err := json.MarshalIndent(sp, "", " ") + if err != nil { + return err + } + return os.WriteFile(profileFile, data, 0644) +} + +// profileList contains paired User-Agent and Client Hints strings. +var profileList = []Profile{ + // Windows Chrome + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + + // Windows Edge + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Microsoft Edge";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + + // macOS Chrome + { + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"macOS"`, + }, + { + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"macOS"`, + }, + + // Linux Chrome + { + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Linux"`, + }, + { + UserAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Linux"`, + }, +} + +// getRandomProfile returns a paired User-Agent and Client Hints profile. +func getRandomProfile() Profile { + return profileList[rand.Intn(len(profileList))] +} diff --git a/go_client/protocol.go b/go_client/protocol.go new file mode 100644 index 0000000..0504284 --- /dev/null +++ b/go_client/protocol.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + "net" + "strings" + "time" +) + +// RequestConfig запрашивает WireGuard конфиг через DTLS-соединение. +func RequestConfig(conn net.Conn, localPort, deviceID, password string) (string, error) { + payload := fmt.Sprintf("GETCONF:%s|%s|%s", localPort, deviceID, password) + if _, err := conn.Write([]byte(payload)); err != nil { + return "", fmt.Errorf("отправка GETCONF: %w", err) + } + + b := make([]byte, 4096) + if err := conn.SetReadDeadline(time.Now().Add(15 * time.Second)); err != nil { + return "", fmt.Errorf("установка дедлайна: %w", err) + } + n, err := conn.Read(b) + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + return "", fmt.Errorf("чтение ответа конфига: %w", err) + } + + resp := string(b[:n]) + if resp == "NOCONF" { + return "", nil + } + + if strings.HasPrefix(resp, "DENIED:") { + reason := strings.TrimPrefix(resp, "DENIED:") + switch reason { + case "wrong_password": + return "", fmt.Errorf("FATAL_AUTH: неверный пароль подключения") + case "expired": + return "", fmt.Errorf("FATAL_AUTH: срок действия пароля истёк") + case "device_mismatch": + return "", fmt.Errorf("FATAL_AUTH: пароль привязан к другому устройству") + default: + return "", fmt.Errorf("FATAL_AUTH: доступ запрещён (%s)", reason) + } + } + + return resp, nil +} + + diff --git a/go_client/session.go b/go_client/session.go new file mode 100644 index 0000000..48efe8d --- /dev/null +++ b/go_client/session.go @@ -0,0 +1,424 @@ +package main + +import ( + "context" + "crypto/tls" + "fmt" + "log" + "net" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/cbeuw/connutil" + "github.com/pion/dtls/v3" + "github.com/pion/dtls/v3/pkg/crypto/selfsign" + "github.com/pion/logging" + "github.com/pion/turn/v5" +) + +const ( + workerSendBuf = 128 + sessionReadTimeout = 30 * time.Minute // Increased from 60s to 30min + readBufSize = 1600 + socketBufSize = 625 * 1024 + keepaliveByte = 0xFF // DTLS-level keepalive marker + keepaliveInterval = 15 * time.Second +) + +// Handshake semaphore: limit to 3 concurrent DTLS handshakes +var handshakeSem = make(chan struct{}, 3) + +// NullLoggerFactory подавляет логи pion +type NullLoggerFactory struct{} + +func (n *NullLoggerFactory) NewLogger(_ string) logging.LeveledLogger { return &NullLogger{} } + +type NullLogger struct{} + +func (n *NullLogger) Trace(_ string) {} +func (n *NullLogger) Tracef(_ string, _ ...interface{}) {} +func (n *NullLogger) Debug(_ string) {} +func (n *NullLogger) Debugf(_ string, _ ...interface{}) {} +func (n *NullLogger) Info(_ string) {} +func (n *NullLogger) Infof(_ string, _ ...interface{}) {} +func (n *NullLogger) Warn(_ string) {} +func (n *NullLogger) Warnf(_ string, _ ...interface{}) {} +func (n *NullLogger) Error(_ string) {} +func (n *NullLogger) Errorf(_ string, _ ...interface{}) {} + +// connectedUDPConn — обёртка для connected UDP socket → PacketConn +type connectedUDPConn struct{ *net.UDPConn } + +func (c *connectedUDPConn) WriteTo(p []byte, _ net.Addr) (int, error) { return c.Write(p) } + +func RunSession( + ctx context.Context, + tp *TurnParams, + peer *net.UDPAddr, + d *Dispatcher, + localPort string, + getConfig bool, + configCh chan<- string, + sessionID int, + creds *Credentials, + deviceID, password string, + stats *Stats, +) (bool, error) { + configDelivered := false + + if len(creds.TurnURLs) == 0 { + return false, fmt.Errorf("нет TURN URL в учетных данных") + } + selectedURL := creds.TurnURLs[sessionID%len(creds.TurnURLs)] + + urlhost, urlport, err := net.SplitHostPort(selectedURL) + if err != nil { + return false, fmt.Errorf("разбор TURN URL %q: %w", selectedURL, err) + } + if tp.Host != "" { + urlhost = tp.Host + } + if tp.Port != "" { + urlport = tp.Port + } + turnAddr := net.JoinHostPort(urlhost, urlport) + + // Транспорт: всегда UDP + resolved, err := net.ResolveUDPAddr("udp", turnAddr) + if err != nil { + return false, fmt.Errorf("резолв TURN: %w", err) + } + c, err := net.DialUDP("udp", nil, resolved) + if err != nil { + return false, fmt.Errorf("подключение TURN UDP: %w", err) + } + defer c.Close() + _ = c.SetReadBuffer(socketBufSize) + _ = c.SetWriteBuffer(socketBufSize) + var turnConn net.PacketConn = &connectedUDPConn{c} + + log.Printf("[СЕССИЯ #%d] TURN UDP (%s)", sessionID, turnAddr) + + // RequestedAddressFamily + var addrFamily turn.RequestedAddressFamily + if peer.IP.To4() != nil { + addrFamily = turn.RequestedAddressFamilyIPv4 + } else { + addrFamily = turn.RequestedAddressFamilyIPv6 + } + + // TURN Client (pion/turn/v5) + tc, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Conn: turnConn, + Username: creds.User, + Password: creds.Pass, + RequestedAddressFamily: addrFamily, + LoggerFactory: &NullLoggerFactory{}, + }) + if err != nil { + return false, fmt.Errorf("TURN клиент: %w", err) + } + defer tc.Close() + + if err = tc.Listen(); err != nil { + return false, fmt.Errorf("TURN Listen: %w", err) + } + + relay, err := tc.Allocate() + if err != nil { + if isAuthError(err) { + handleAuthError(creds.CacheStreamID) + } + errStr := err.Error() + if strings.Contains(errStr, "Quota") || strings.Contains(errStr, "486") { + return false, fmt.Errorf("TURN квота: %w", err) + } + return false, fmt.Errorf("TURN Allocate: %w", err) + } + defer relay.Close() + + // Reset error count on successful allocation + getStreamCache(creds.CacheStreamID).errorCount.Store(0) + + log.Printf("[СЕССИЯ #%d] Relay: %s", sessionID, relay.LocalAddr()) + + // Pipe для DTLS ↔ TURN relay + pipeA, pipeB := connutil.AsyncPacketPipe() + + sessCtx, sessCancel := context.WithCancel(ctx) + defer sessCancel() + + // Keepalive goroutine (TURN binding request) + var sessionWg sync.WaitGroup + sessionWg.Add(1) + go func() { + defer sessionWg.Done() + t := time.NewTicker(10 * time.Second) + defer t.Stop() + for { + select { + case <-sessCtx.Done(): + return + case <-t.C: + tc.SendBindingRequest() + } + } + }() + + // Relay ↔ Pipe proxy (with RTP obfuscation) + var relayWg sync.WaitGroup + relayWg.Add(2) + + useWrap := len(tp.WrapKey) == wrapKeyLen + + // Initialize obfs config per session + var obfsCfg *ObfsConfig + var obfsWriteState *ObfsState + if useWrap { + obfsCfg = NewObfsConfig() + obfsWriteState = NewObfsState() + } + + stopRelay := context.AfterFunc(sessCtx, func() { + _ = relay.SetDeadline(time.Now()) + _ = pipeA.SetDeadline(time.Now()) + }) + defer stopRelay() + + // relay → pipeA (UNWRAP: strip RTP header + decrypt) + go func() { + defer relayWg.Done() + defer sessCancel() + // Max incoming: RTP header (12) + AEAD tag (16) + padding. + readBufLen := readBufSize + 80 + buf := make([]byte, readBufLen) + plain := make([]byte, readBufSize) + for { + n, _, readErr := relay.ReadFrom(buf) + if readErr != nil { + return + } + payload := buf[:n] + if useWrap { + if !obfsIsRTPPacket(payload) { + log.Printf("[СЕССИЯ #%d] OBFS unwrap: unexpected packet (n=%d)", sessionID, n) + continue + } + m, wrapErr := obfsUnwrapPacket(tp.WrapKey, payload, plain) + if wrapErr != nil { + log.Printf("[СЕССИЯ #%d] OBFS unwrap: %v (n=%d)", sessionID, wrapErr, n) + continue + } + payload = plain[:m] + } + if _, writeErr := pipeA.WriteTo(payload, peer); writeErr != nil { + return + } + } + }() + + // pipeA → relay (WRAP: add RTP header + encrypt) + go func() { + defer relayWg.Done() + defer sessCancel() + b := make([]byte, readBufSize) + for { + n, _, readErr := pipeA.ReadFrom(b) + if readErr != nil { + return + } + out := b[:n] + if useWrap { + if obfsCfg != nil && obfsWriteState != nil { + wrapped, wrapErr := obfsWrapPacket(tp.WrapKey, out, obfsCfg, obfsWriteState) + if wrapErr != nil { + log.Printf("[СЕССИЯ #%d] OBFS wrap: %v", sessionID, wrapErr) + return + } + out = wrapped + } + } + if _, writeErr := relay.WriteTo(out, peer); writeErr != nil { + return + } + } + }() + + // DTLS с поддержкой Connection ID (без SNI) + cert, err := selfsign.GenerateSelfSigned() + if err != nil { + return false, fmt.Errorf("генерация сертификата: %w", err) + } + + // Acquire handshake semaphore + select { + case handshakeSem <- struct{}{}: + case <-sessCtx.Done(): + return false, sessCtx.Err() + } + + dtlsCfg := &dtls.Config{ + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: true, + ExtendedMasterSecret: dtls.RequireExtendedMasterSecret, + CipherSuites: []dtls.CipherSuiteID{dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, + ConnectionIDGenerator: dtls.OnlySendCIDGenerator(), + // No ServerName (SNI) — less detectable by DPI + } + + dtlsConn, err := dtls.Client(pipeB, peer, dtlsCfg) + if err != nil { + <-handshakeSem + return false, fmt.Errorf("DTLS клиент: %w", err) + } + defer dtlsConn.Close() + + hctx, hcancel := context.WithTimeout(sessCtx, 20*time.Second) + log.Printf("[ВОРКЕР #%d] [DTLS] Рукопожатие (Handshake)...", sessionID) + err = dtlsConn.HandshakeContext(hctx) + hcancel() + <-handshakeSem // RELEASE SEMAPHORE IMMEDIATELY AFTER HANDSHAKE + + if err != nil { + if useWrap { + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "deadline") || strings.Contains(errStr, "timeout") { + return false, fmt.Errorf("WRAP_AUTH_TIMEOUT: DTLS timeout, пароль/WRAP не подтверждён") + } + } + return false, fmt.Errorf("DTLS хендшейк: %w", err) + } + log.Printf("[ВОРКЕР #%d] [DTLS] Соединение установлено ✓", sessionID) + + atomic.AddInt32(&stats.ActiveConnections, 1) + defer atomic.AddInt32(&stats.ActiveConnections, -1) + + // Запрос конфига + if getConfig && configCh != nil { + conf, confErr := RequestConfig(dtlsConn, localPort, deviceID, password) + if confErr != nil { + errStr := confErr.Error() + if strings.Contains(errStr, "FATAL_AUTH") { + return false, confErr + } + log.Printf("[ВОРКЕР #%d] Ошибка конфига: %v", sessionID, confErr) + } else if conf != "" { + select { + case configCh <- conf: + configDelivered = true + log.Printf("[ВОРКЕР #%d] Конфиг получен", sessionID) + default: + configDelivered = true + log.Printf("[ВОРКЕР #%d] Конфиг уже был доставлен другим воркером", sessionID) + } + } else { + log.Printf("[ВОРКЕР #%d] Сервер ещё не выдал WireGuard-конфиг, повторим позже", sessionID) + } + } + + log.Printf("[ВОРКЕР #%d] [READY] Туннель готов к работе ✓", sessionID) + + // Регистрация в диспетчере + slot := &WorkerSlot{ + ID: sessionID, + SendCh: make(chan []byte, workerSendBuf), + } + d.Register(slot) + defer d.Unregister(slot) + + // Proxy DTLS ↔ Dispatcher + var proxyWg sync.WaitGroup + proxyWg.Add(3) // +1 for keepalive goroutine + + stopDTLS := context.AfterFunc(sessCtx, func() { + _ = dtlsConn.SetDeadline(time.Now()) + }) + defer stopDTLS() + + // DTLS Keepalive: prevents TURN allocation timeout and DTLS idle disconnect + go func() { + defer proxyWg.Done() + t := time.NewTicker(keepaliveInterval) + defer t.Stop() + ping := []byte{keepaliveByte} + for { + select { + case <-sessCtx.Done(): + return + case <-t.C: + _ = dtlsConn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if _, err := dtlsConn.Write(ping); err != nil { + return + } + } + } + }() + + // Writer: dispatcher → DTLS + go func() { + defer proxyWg.Done() + defer sessCancel() + for { + select { + case <-sessCtx.Done(): + return + case pkt, ok := <-slot.SendCh: + if !ok { + return + } + _ = dtlsConn.SetWriteDeadline(time.Now().Add(sessionReadTimeout)) + if _, writeErr := dtlsConn.Write(pkt); writeErr != nil { + log.Printf("[ВОРКЕР #%d] Ошибка Writer: %v", sessionID, writeErr) + return + } + } + } + }() + + // Reader: DTLS → dispatcher + go func() { + defer proxyWg.Done() + defer sessCancel() + b := make([]byte, 2000) + for { + _ = dtlsConn.SetReadDeadline(time.Now().Add(sessionReadTimeout)) + n, readErr := dtlsConn.Read(b) + if readErr != nil { + if sessCtx.Err() != nil { + return + } + if ne, ok := readErr.(net.Error); ok && ne.Timeout() { + continue + } + log.Printf("[ВОРКЕР #%d] Ошибка Reader: %v", sessionID, readErr) + return + } + + // Skip keepalive pong from server + if n == 1 && b[0] == keepaliveByte { + continue + } + + pkt := make([]byte, n) + copy(pkt, b[:n]) + select { + case d.ReturnCh <- pkt: + case <-sessCtx.Done(): + return + } + } + }() + + proxyWg.Wait() + sessCancel() + relayWg.Wait() + sessionWg.Wait() + _ = pipeA.Close() + _ = pipeB.Close() + log.Printf("[СЕССИЯ #%d] Завершена", sessionID) + return configDelivered, nil +} diff --git a/go_client/stats.go b/go_client/stats.go new file mode 100644 index 0000000..30fa96e --- /dev/null +++ b/go_client/stats.go @@ -0,0 +1,38 @@ +package main + +import ( + "log" + "sync/atomic" + "time" +) + +type Stats struct { + ActiveConnections int32 + Reconnects int64 + TotalBytesUp int64 + TotalBytesDown int64 + CredsErrors int64 +} + +func NewStats() *Stats { + return &Stats{} +} + +func (s *Stats) RunLoop(shutdown <-chan struct{}) { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + select { + case <-shutdown: + return + case <-ticker.C: + active := atomic.LoadInt32(&s.ActiveConnections) + up := atomic.LoadInt64(&s.TotalBytesUp) + down := atomic.LoadInt64(&s.TotalBytesDown) + totalMB := float64(up+down) / (1024.0 * 1024.0) + + log.Printf("[СТАТИСТИКА] Активных: %d | Трафик: %.2f МБ", active, totalMB) + } + } +} diff --git a/go_client/wrap.go b/go_client/wrap.go new file mode 100644 index 0000000..3088796 --- /dev/null +++ b/go_client/wrap.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + + "golang.org/x/crypto/hkdf" +) + +const ( + wrapKeyLen = 32 +) + +func deriveWrapKey(password string) ([]byte, error) { + if password == "" { + return nil, errors.New("empty password") + } + key := make([]byte, wrapKeyLen) + reader := hkdf.New( + sha256.New, + []byte(password), + []byte("WDTT-WRAP-v1"), + []byte("rtp-obfs/chacha20poly1305"), + ) + if _, err := io.ReadFull(reader, key); err != nil { + return nil, fmt.Errorf("derive wrap key: %w", err) + } + return key, nil +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..44d3097 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2e11132 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/server.go b/server.go new file mode 100644 index 0000000..981b45c --- /dev/null +++ b/server.go @@ -0,0 +1,1718 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/pion/dtls/v3" + "github.com/pion/dtls/v3/pkg/crypto/selfsign" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" + + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/ipc" + "golang.zx2c4.com/wireguard/tun" + + dtlsnet "github.com/pion/dtls/v3/pkg/net" + pionudp "github.com/pion/transport/v4/udp" +) + +const ( + wgIfaceName = "wdtt0" + wgServerAddr = "10.66.66.1" + wgClientAddr = "10.66.66.2" + wgClientCIDR = wgClientAddr + "/32" + wgServerCIDR = wgServerAddr + "/24" + defaultInternalWGPort = 56001 + dns = "1.1.1.1" + wgMTU = 1280 + keepalive = 25 +) + +// ==================== База данных и Бот ==================== + +type ClientDevice struct { + DeviceID string `json:"device_id"` + IP string `json:"ip"` + PrivKey string `json:"priv_key"` + PubKey string `json:"pub_key"` +} + +type PasswordEntry struct { + DeviceID string `json:"device_id"` // пусто = ещё не привязан + ExpiresAt int64 `json:"expires_at"` // unix timestamp + DownBytes int64 `json:"down_bytes"` // скачано клиентом + UpBytes int64 `json:"up_bytes"` // отдано клиентом +} + +// Трафик главного пароля (владельца) +var ( + mainPassDown int64 + mainPassUp int64 +) + +// Онлайн-статус устройств +var ( + activeDevices = make(map[string]int32) // deviceID -> кол-во активных коннектов + activeDevicesMu sync.Mutex +) + +type Database struct { + MainPassword string `json:"main_password"` + AdminID string `json:"admin_id"` + BotToken string `json:"bot_token"` + Passwords map[string]*PasswordEntry `json:"passwords"` + Devices map[string]*ClientDevice `json:"devices"` +} + +var ( + db *Database + dbMutex sync.Mutex + dbFile string +) + +var serverWrapKeys = newWrapKeyStore() + +const ( + passChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789" + generatedPasswordLen = 16 + maxGeneratedPasswords = 10 +) + +func generatePassword() string { + b := make([]byte, generatedPasswordLen) + randomBytes := make([]byte, len(b)) + if _, err := rand.Read(randomBytes); err != nil { + now := time.Now().UnixNano() + for i := range b { + b[i] = passChars[int(now+int64(i))%len(passChars)] + } + return string(b) + } + for i, raw := range randomBytes { + b[i] = passChars[int(raw)%len(passChars)] + } + return string(b) +} + +type wrapKeyEntry struct { + id string + key []byte +} + +type wrapKeyStore struct { + mu sync.RWMutex + entries []wrapKeyEntry +} + +func newWrapKeyStore() *wrapKeyStore { + return &wrapKeyStore{} +} + +func deriveWrapKey(password string) ([]byte, error) { + if password == "" { + return nil, errors.New("empty password") + } + key := make([]byte, wrapKeyLen) + reader := hkdf.New( + sha256.New, + []byte(password), + []byte("WDTT-WRAP-v1"), + []byte("rtp-obfs/chacha20poly1305"), + ) + if _, err := io.ReadFull(reader, key); err != nil { + return nil, fmt.Errorf("derive wrap key: %w", err) + } + return key, nil +} + +func wrapKeyID(password string) string { + sum := sha256.Sum256([]byte("WDTT-WRAP-ID-v1\x00" + password)) + return hex.EncodeToString(sum[:8]) +} + +func zeroBytes(b []byte) { + for i := range b { + b[i] = 0 + } +} + +func (s *wrapKeyStore) SetPasswords(mainPassword string, generated []string) error { + next := make([]wrapKeyEntry, 0, len(generated)+1) + seen := make(map[string]struct{}, len(generated)+1) + + if mainPassword != "" { + key, err := deriveWrapKey(mainPassword) + if err != nil { + return err + } + next = append(next, wrapKeyEntry{id: "main", key: key}) + seen["main"] = struct{}{} + } + + for _, password := range generated { + if password == "" { + continue + } + id := "pass:" + wrapKeyID(password) + if _, exists := seen[id]; exists { + continue + } + key, err := deriveWrapKey(password) + if err != nil { + for _, entry := range next { + zeroBytes(entry.key) + } + return err + } + next = append(next, wrapKeyEntry{id: id, key: key}) + seen[id] = struct{}{} + } + + s.mu.Lock() + old := s.entries + s.entries = next + s.mu.Unlock() + for _, entry := range old { + zeroBytes(entry.key) + } + return nil +} + +func (s *wrapKeyStore) AddPassword(password string) error { + key, err := deriveWrapKey(password) + if err != nil { + return err + } + id := "pass:" + wrapKeyID(password) + + s.mu.Lock() + defer s.mu.Unlock() + for _, entry := range s.entries { + if entry.id == id { + zeroBytes(key) + return nil + } + } + s.entries = append(s.entries, wrapKeyEntry{id: id, key: key}) + return nil +} + +func (s *wrapKeyStore) RemovePassword(password string) { + id := "pass:" + wrapKeyID(password) + + s.mu.Lock() + defer s.mu.Unlock() + for i, entry := range s.entries { + if entry.id != id { + continue + } + zeroBytes(entry.key) + copy(s.entries[i:], s.entries[i+1:]) + s.entries[len(s.entries)-1] = wrapKeyEntry{} + s.entries = s.entries[:len(s.entries)-1] + return + } +} + +func (s *wrapKeyStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.entries) +} + +func (s *wrapKeyStore) Unwrap(raw, dst []byte) ([]byte, int, error) { + if !obfsIsRTPPacket(raw) { + return nil, 0, errors.New("wrap: non-obfs packet") + } + + s.mu.RLock() + defer s.mu.RUnlock() + if len(s.entries) == 0 { + return nil, 0, errors.New("wrap: no active keys") + } + for _, entry := range s.entries { + m, err := obfsUnwrapPacket(entry.key, raw, dst) + if err == nil { + return append([]byte(nil), entry.key...), m, nil + } + } + return nil, 0, errors.New("wrap: auth failed") +} + +func refreshWrapKeysFromDBLocked() error { + passwords := make([]string, 0, len(db.Passwords)) + for password, entry := range db.Passwords { + if !isPasswordExpired(entry) { + passwords = append(passwords, password) + } + } + return serverWrapKeys.SetPasswords(db.MainPassword, passwords) +} + +func initDB(dir, mainPass, adminID, botToken string) { + dbFile = filepath.Join(dir, "passwords.json") + db = &Database{ + Passwords: make(map[string]*PasswordEntry), + Devices: make(map[string]*ClientDevice), + } + data, err := os.ReadFile(dbFile) + if err == nil { + json.Unmarshal(data, db) + } + if db.Passwords == nil { + db.Passwords = make(map[string]*PasswordEntry) + } + if db.Devices == nil { + db.Devices = make(map[string]*ClientDevice) + } + db.MainPassword = mainPass + db.AdminID = adminID + db.BotToken = botToken + saveDB() + if err := refreshWrapKeysFromDBLocked(); err != nil { + log.Fatalf("[WRAP] init keys: %v", err) + } +} + +func saveDB() { + data, _ := json.MarshalIndent(db, "", " ") + os.WriteFile(dbFile, data, 0600) +} + +func isPasswordExpired(entry *PasswordEntry) bool { + if entry == nil { + return true + } + if entry.ExpiresAt == 0 { + return false // бессрочный + } + return time.Now().Unix() > entry.ExpiresAt +} + +func getNextIP() string { + used := make(map[string]bool) + for _, dev := range db.Devices { + used[dev.IP] = true + } + for i := 2; i <= 250; i++ { + ip := fmt.Sprintf("10.66.66.%d", i) + if !used[ip] { + return ip + } + } + return "" +} + +func botLoop(token string, adminIDstr string, wgDev *device.Device) { + if token == "" || adminIDstr == "" { + return + } + adminID, _ := strconv.ParseInt(adminIDstr, 10, 64) + if adminID == 0 { + return + } + + // Устанавливаем команды для синей кнопки Menu + go func() { + cmds := `{"commands":[{"command":"new","description":"Создать временный пароль"},{"command":"list","description":"Управление доступами"}]}` + resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", token), "application/json", strings.NewReader(cmds)) + if err == nil { + resp.Body.Close() + } + }() + + offset := 0 + client := &http.Client{Timeout: 65 * time.Second} + + // Состояние ожидания ввода дней + var waitingForDays bool + + for { + url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?timeout=60&offset=%d", token, offset) + resp, err := client.Get(url) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + + var res struct { + Ok bool `json:"ok"` + Result []struct { + UpdateID int `json:"update_id"` + Message *struct { + Chat struct { + ID int64 `json:"id"` + } `json:"chat"` + Text string `json:"text"` + } `json:"message"` + CallbackQuery *struct { + ID string `json:"id"` + Data string `json:"data"` + Message struct { + MessageID int `json:"message_id"` + Chat struct { + ID int64 `json:"id"` + } `json:"chat"` + } `json:"message"` + } `json:"callback_query"` + } `json:"result"` + } + + err = json.NewDecoder(resp.Body).Decode(&res) + resp.Body.Close() + if err != nil { + time.Sleep(2 * time.Second) + continue + } + + for _, u := range res.Result { + offset = u.UpdateID + 1 + + // ═══ Callback кнопки ═══ + if u.CallbackQuery != nil && u.CallbackQuery.Message.Chat.ID == adminID { + data := u.CallbackQuery.Data + answerCallback(token, u.CallbackQuery.ID) + + if strings.HasPrefix(data, "viewpass_") { + // Просмотр деталей пароля + pass := strings.TrimPrefix(data, "viewpass_") + dbMutex.Lock() + entry, exists := db.Passwords[pass] + if !exists || entry == nil { + dbMutex.Unlock() + sendTelegram(token, adminID, "❌ Пароль не найден", nil) + continue + } + txt := fmt.Sprintf("🔑 *Пароль:* `%s`\n", pass) + if entry.ExpiresAt > 0 { + expireTime := time.Unix(entry.ExpiresAt, 0) + remaining := time.Until(expireTime) + if remaining > 0 { + txt += fmt.Sprintf("⏰ Истекает: %s (через %dd)\n", expireTime.Format("02.01.2006"), int(remaining.Hours()/24)) + } else { + txt += "⏰ *ИСТЁК* ❌\n" + } + } else { + txt += "⏰ Бессрочный ♾\n" + } + txt += "\n📱 *Привязанное устройство:*\n" + var kb []map[string]interface{} + if entry.DeviceID == "" { + txt += "_Ожидает первого подключения..._\n" + } else { + dev, devExists := db.Devices[entry.DeviceID] + if devExists { + txt += fmt.Sprintf("• ID: `%s`\n• IP: `%s`\n", entry.DeviceID, dev.IP) + } else { + txt += fmt.Sprintf("• ID: `%s` (устройство удалено)\n", entry.DeviceID) + } + kb = append(kb, map[string]interface{}{ + "text": "🗑 Отвязать устройство", + "callback_data": "unbind_" + pass, + }) + } + dbMutex.Unlock() + kb = append(kb, map[string]interface{}{ + "text": "❌ Удалить пароль", + "callback_data": "delpass_" + pass, + }) + kb = append(kb, map[string]interface{}{ + "text": "◀️ Назад к списку", + "callback_data": "backlist", + }) + var keyboard [][]map[string]interface{} + for _, btn := range kb { + keyboard = append(keyboard, []map[string]interface{}{btn}) + } + sendTelegram(token, adminID, txt, map[string]interface{}{"inline_keyboard": keyboard}) + + } else if strings.HasPrefix(data, "unbind_") { + pass := strings.TrimPrefix(data, "unbind_") + dbMutex.Lock() + entry, exists := db.Passwords[pass] + if exists && entry != nil && entry.DeviceID != "" { + // Удаляем устройство из WG и из хранилища + dev, devExists := db.Devices[entry.DeviceID] + if devExists { + pubHex, _ := b64ToHex(dev.PubKey) + wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex)) + delete(db.Devices, entry.DeviceID) + } + entry.DeviceID = "" + saveDB() + } + dbMutex.Unlock() + sendTelegram(token, adminID, fmt.Sprintf("✅ Устройство отвязано от пароля `%s`", pass), nil) + + } else if strings.HasPrefix(data, "delpass_") { + pass := strings.TrimPrefix(data, "delpass_") + dbMutex.Lock() + entry, exists := db.Passwords[pass] + if exists && entry != nil && entry.DeviceID != "" { + dev, devExists := db.Devices[entry.DeviceID] + if devExists { + pubHex, _ := b64ToHex(dev.PubKey) + wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex)) + delete(db.Devices, entry.DeviceID) + } + } + delete(db.Passwords, pass) + serverWrapKeys.RemovePassword(pass) + saveDB() + dbMutex.Unlock() + sendTelegram(token, adminID, fmt.Sprintf("✅ Пароль `%s` и его устройство удалены", pass), nil) + + } else if strings.HasPrefix(data, "deldev_") { + devID := strings.TrimPrefix(data, "deldev_") + dbMutex.Lock() + dev, exists := db.Devices[devID] + if exists { + delete(db.Devices, devID) + pubHex, _ := b64ToHex(dev.PubKey) + wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex)) + // Очищаем привязку из пароля + for _, entry := range db.Passwords { + if entry != nil && entry.DeviceID == devID { + entry.DeviceID = "" + } + } + saveDB() + } + dbMutex.Unlock() + sendTelegram(token, adminID, fmt.Sprintf("✅ Устройство `%s` удалено", devID), nil) + + } else if data == "backlist" { + sendPasswordList(token, adminID, wgDev) + } + } + + // ═══ Текстовые команды ═══ + msg := u.Message + if msg == nil || msg.Chat.ID != adminID { + continue + } + + cmd := strings.TrimSpace(msg.Text) + + // Обработка ввода количества дней + if waitingForDays { + waitingForDays = false + days, parseErr := strconv.Atoi(cmd) + if parseErr != nil || days < 1 || days > 365 { + sendTelegram(token, adminID, "❌ Неверное значение. Укажите число от 1 до 365, или отправьте /new заново.", nil) + continue + } + expiresAt := time.Now().Add(time.Duration(days) * 24 * time.Hour).Unix() + dbMutex.Lock() + if cleanupExpiredPasswordsLocked(wgDev) > 0 { + saveDB() + } + if len(db.Passwords) >= maxGeneratedPasswords { + dbMutex.Unlock() + sendTelegram(token, adminID, fmt.Sprintf("❌ Лимит паролей: максимум %d активных. Удалите ненужный пароль через /list.", maxGeneratedPasswords), nil) + continue + } + newPass := "" + for i := 0; i < 10; i++ { + candidate := generatePassword() + if _, exists := db.Passwords[candidate]; !exists { + newPass = candidate + break + } + } + if newPass == "" { + dbMutex.Unlock() + sendTelegram(token, adminID, "❌ Не удалось создать уникальный пароль. Повторите /new.", nil) + continue + } + if err := serverWrapKeys.AddPassword(newPass); err != nil { + dbMutex.Unlock() + sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil) + continue + } + db.Passwords[newPass] = &PasswordEntry{ExpiresAt: expiresAt} + saveDB() + dbMutex.Unlock() + expDate := time.Unix(expiresAt, 0).Format("02.01.2006") + sendTelegram(token, adminID, fmt.Sprintf("🔑 Новый пароль:\n`%s`\n\n⏰ Действует %d дн. (до %s)\n📱 Ожидает первого подключения", newPass, days, expDate), nil) + continue + } + + if cmd == "/start" || cmd == "/help" { + sendTelegram(token, adminID, "🤖 *WDTT VPN Manager*\n\n/new — Создать пароль\n/list — Список паролей", nil) + + } else if cmd == "/new" { + dbMutex.Lock() + if cleanupExpiredPasswordsLocked(wgDev) > 0 { + saveDB() + } + if len(db.Passwords) >= maxGeneratedPasswords { + dbMutex.Unlock() + sendTelegram(token, adminID, fmt.Sprintf("❌ Лимит паролей: максимум %d активных. Удалите ненужный пароль через /list.", maxGeneratedPasswords), nil) + continue + } + dbMutex.Unlock() + waitingForDays = true + sendTelegram(token, adminID, "📅 Введите срок действия пароля в днях (1–365):\n\n_Примеры: 30 = месяц, 365 = год_", nil) + + } else if cmd == "/list" { + sendPasswordList(token, adminID, wgDev) + } + } + } +} + +func removePeerFromWG(wgDev *device.Device, dev *ClientDevice) { + if wgDev == nil || dev == nil || dev.PubKey == "" { + return + } + pubHex, err := b64ToHex(dev.PubKey) + if err != nil { + return + } + wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex)) +} + +func upsertPeerInWG(wgDev *device.Device, dev *ClientDevice) { + if wgDev == nil || dev == nil || dev.PubKey == "" || dev.IP == "" { + return + } + pubHex, err := b64ToHex(dev.PubKey) + if err != nil { + return + } + wgDev.IpcSet(fmt.Sprintf("public_key=%s\nallowed_ip=%s/32\n", pubHex, dev.IP)) +} + +func cleanupExpiredPasswordsLocked(wgDev *device.Device) int { + removed := 0 + for p, entry := range db.Passwords { + if isPasswordExpired(entry) { + if entry != nil && entry.DeviceID != "" { + removePeerFromWG(wgDev, db.Devices[entry.DeviceID]) + delete(db.Devices, entry.DeviceID) + } + delete(db.Passwords, p) + serverWrapKeys.RemovePassword(p) + removed++ + } + } + return removed +} + +func cleanupExpiredPasswords(wgDev *device.Device) int { + dbMutex.Lock() + defer dbMutex.Unlock() + removed := cleanupExpiredPasswordsLocked(wgDev) + if removed > 0 { + saveDB() + } + return removed +} + +func expiredPasswordJanitor(ctx context.Context, wgDev *device.Device) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if removed := cleanupExpiredPasswords(wgDev); removed > 0 { + log.Printf("[DB] Удалено истёкших паролей: %d", removed) + } + } + } +} + +func syncPersistedPeersToWG(wgDev *device.Device) { + dbMutex.Lock() + defer dbMutex.Unlock() + count := 0 + for _, dev := range db.Devices { + upsertPeerInWG(wgDev, dev) + count++ + } + if count > 0 { + log.Printf("[WG] Восстановлено сохранённых устройств: %d", count) + } +} + +func sendPasswordList(token string, adminID int64, wgDev *device.Device) { + dbMutex.Lock() + defer dbMutex.Unlock() + + // Очистка истёкших + if cleanupExpiredPasswordsLocked(wgDev) > 0 { + saveDB() + } + + txt := "🔐 *Пароли:*\n\n" + txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword) + + var inlineKb []map[string]interface{} + + if len(db.Passwords) == 0 { + txt += "_Нет сгенерированных паролей._\n" + } else { + txt += fmt.Sprintf("_Активно: %d/%d_\n\n", len(db.Passwords), maxGeneratedPasswords) + for p, entry := range db.Passwords { + status := "🟢" + if entry.DeviceID != "" { + status = "🔗" + } + expiry := "♾" + if entry.ExpiresAt > 0 { + remaining := time.Until(time.Unix(entry.ExpiresAt, 0)) + if remaining > 0 { + expiry = fmt.Sprintf("%dd", int(remaining.Hours()/24)+1) + } else { + expiry = "❌" + } + } + txt += fmt.Sprintf("%s `%s` (%s)\n", status, p, expiry) + inlineKb = append(inlineKb, map[string]interface{}{ + "text": "🔍 " + p, + "callback_data": "viewpass_" + p, + }) + } + } + + txt += "\n🟢 = свободен | 🔗 = привязан" + + var replyMarkup interface{} + if len(inlineKb) > 0 { + var keyboard [][]map[string]interface{} + for _, btn := range inlineKb { + keyboard = append(keyboard, []map[string]interface{}{btn}) + } + replyMarkup = map[string]interface{}{"inline_keyboard": keyboard} + } + sendTelegram(token, adminID, txt, replyMarkup) +} + +func answerCallback(token, callbackID string) { + url := fmt.Sprintf("https://api.telegram.org/bot%s/answerCallbackQuery", token) + payload := map[string]interface{}{"callback_query_id": callbackID} + body, _ := json.Marshal(payload) + http.Post(url, "application/json", bytes.NewBuffer(body)) +} + +func maskPassword(pass string) string { + if len(pass) <= 3 { + return pass + } + return pass[:3] + "****" +} + +func sendTelegram(token string, chatID int64, text string, replyMarkup interface{}) { + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token) + payload := map[string]interface{}{ + "chat_id": chatID, + "text": text, + "parse_mode": "Markdown", + } + if replyMarkup != nil { + payload["reply_markup"] = replyMarkup + } + body, _ := json.Marshal(payload) + http.Post(url, "application/json", bytes.NewBuffer(body)) +} + +// ==================== Пул буферов ==================== + +var bufPool = sync.Pool{ + New: func() interface{} { + b := make([]byte, 1600) + return &b + }, +} + +func getBuf() *[]byte { return bufPool.Get().(*[]byte) } +func putBuf(b *[]byte) { bufPool.Put(b) } + +// ==================== Оптимизация ==================== + +func enableBBR() { + log.Println("[SYS] Оптимизация TCP...") + out, _ := runCmd("bash", "-c", "sysctl net.ipv4.tcp_congestion_control") + if strings.Contains(out, "bbr") { + log.Println("[SYS] BBR уже активен ✓") + return + } + cmds := [][]string{ + {"sysctl", "-w", "net.core.default_qdisc=fq"}, + {"sysctl", "-w", "net.ipv4.tcp_congestion_control=bbr"}, + {"sysctl", "-w", "net.core.rmem_max=25165824"}, + {"sysctl", "-w", "net.core.wmem_max=25165824"}, + {"sysctl", "-w", "net.ipv4.tcp_rmem=4096 87380 25165824"}, + {"sysctl", "-w", "net.ipv4.tcp_wmem=4096 65536 25165824"}, + } + for _, cmd := range cmds { + runCmd(cmd[0], cmd[1:]...) + } + log.Println("[SYS] BBR включен ✓") +} + +// ==================== Статистика ==================== + +var ( + totalBytesFromClient int64 + totalBytesToClient int64 + activeConns int32 + totalConns int64 + natType string = "Инициализация..." + serverStartTime time.Time +) + +func statsLoop(ctx context.Context, configDir string) { + serverStartTime = time.Now() + statsFile := filepath.Join(configDir, "server.log") + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + fromC := atomic.LoadInt64(&totalBytesFromClient) + toC := atomic.LoadInt64(&totalBytesToClient) + active := atomic.LoadInt32(&activeConns) + total := atomic.LoadInt64(&totalConns) + uptime := time.Since(serverStartTime) + + log.Printf("[СТАТ] Активных: %d | Всего: %d | NAT: %s | ↑%.2f МБ | ↓%.2f МБ", + active, total, natType, + float64(fromC)/1024/1024, + float64(toC)/1024/1024, + ) + + // Пишем server.log + dbMutex.Lock() + numPasswords := len(db.Passwords) + numDevices := len(db.Devices) + dbMutex.Unlock() + + uptimeStr := formatUptime(uptime) + downGB := float64(toC) / (1024 * 1024 * 1024) + upGB := float64(fromC) / (1024 * 1024 * 1024) + + statsJSON, _ := json.Marshal(map[string]interface{}{ + "active": active, + "total": total, + "nat": natType, + "uptime": uptimeStr, + "down_gb": fmt.Sprintf("%.2f", downGB), + "up_gb": fmt.Sprintf("%.2f", upGB), + "passwords": numPasswords, + "devices": numDevices, + "timestamp": time.Now().Unix(), + }) + os.WriteFile(statsFile, statsJSON, 0644) + } + } +} + +func formatUptime(d time.Duration) string { + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + mins := int(d.Minutes()) % 60 + if days > 0 { + return fmt.Sprintf("%dд %dч %dм", days, hours, mins) + } + if hours > 0 { + return fmt.Sprintf("%dч %dм", hours, mins) + } + return fmt.Sprintf("%dм", mins) +} + +// ==================== Утилиты ==================== + +func runCmd(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func runCmdSilent(name string, args ...string) string { + out, _ := exec.Command(name, args...).CombinedOutput() + return strings.TrimSpace(string(out)) +} + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func isNetTimeout(err error) bool { + ne, ok := err.(net.Error) + return ok && ne.Timeout() +} + +func getDefaultInterface() string { + out := runCmdSilent("bash", "-c", "ip route show default | awk '/default/ {print $5}' | head -1") + if out != "" { + return strings.TrimSpace(out) + } + out = runCmdSilent("bash", "-c", "ip -o link show | awk -F': ' '{print $2}' | grep -v -E 'lo|wg|tun|wdtt' | head -1") + if out != "" { + return strings.TrimSpace(out) + } + return "eth0" +} + +// ==================== Ключи ==================== + +type wgKeys struct { + serverPrivate, serverPublic, clientPrivate, clientPublic string +} + +func b64ToHex(s string) (string, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", err + } + if len(b) != 32 { + return "", fmt.Errorf("key length %d != 32", len(b)) + } + return hex.EncodeToString(b), nil +} + +func generateKeyPair() (privB64, pubB64 string, err error) { + var priv [32]byte + if _, err := rand.Read(priv[:]); err != nil { + return "", "", err + } + priv[0] &= 248 + priv[31] = (priv[31] & 127) | 64 + pub, err := curve25519.X25519(priv[:], curve25519.Basepoint) + if err != nil { + return "", "", err + } + return base64.StdEncoding.EncodeToString(priv[:]), + base64.StdEncoding.EncodeToString(pub), nil +} + +func loadOrGenerateKeys(dir string) (*wgKeys, error) { + f := filepath.Join(dir, "wg-keys.dat") + if data, err := os.ReadFile(f); err == nil { + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) >= 4 { + keys := &wgKeys{ + serverPrivate: strings.TrimSpace(lines[0]), + serverPublic: strings.TrimSpace(lines[1]), + clientPrivate: strings.TrimSpace(lines[2]), + clientPublic: strings.TrimSpace(lines[3]), + } + for _, k := range []string{keys.serverPrivate, keys.serverPublic, + keys.clientPrivate, keys.clientPublic} { + if _, err := b64ToHex(k); err != nil { + goto generate + } + } + log.Printf("[WG] Ключи загружены из %s", f) + return keys, nil + } + } +generate: + log.Println("[WG] Генерирую новые ключи...") + sPriv, sPub, err := generateKeyPair() + if err != nil { + return nil, err + } + cPriv, cPub, err := generateKeyPair() + if err != nil { + return nil, err + } + keys := &wgKeys{sPriv, sPub, cPriv, cPub} + os.MkdirAll(dir, 0700) + os.WriteFile(f, []byte(fmt.Sprintf("%s\n%s\n%s\n%s\n", + keys.serverPrivate, keys.serverPublic, + keys.clientPrivate, keys.clientPublic)), 0600) + log.Printf("[WG] Ключи сохранены в %s", f) + return keys, nil +} + +// ==================== NAT ==================== + +func setupFullConeNAT(wgIface string) error { + log.Println("[NAT] ══════════════════════════════════════") + + os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644) + + extIface := getDefaultInterface() + log.Printf("[NAT] Внешний: %s", extIface) + + switch { + case commandExists("iptables"): + for i := 0; i < 5; i++ { + exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", wgServerCIDR, "-o", extIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "MASQUERADE").Run() + } + exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", wgServerCIDR, "-o", extIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "MASQUERADE").Run() + natType = "MASQUERADE iptables ✅" + setupForwardRules(wgIface) + case commandExists("nft"): + setupNftNAT(extIface) + natType = "MASQUERADE nft ✅" + setupForwardRules(wgIface) + default: + natType = "NAT не настроен: нет iptables/nft" + log.Printf("[NAT] WARNING: %s", natType) + } + + log.Printf("[NAT] Режим: %s", natType) + log.Println("[NAT] ══════════════════════════════════════") + return nil +} + +func setupNftNAT(extIface string) { + exec.Command("nft", "add", "table", "ip", "wdtt").Run() + exec.Command("nft", "add", "chain", "ip", "wdtt", "postrouting", "{ type nat hook postrouting priority 100; }").Run() + exec.Command("nft", "add", "rule", "ip", "wdtt", "postrouting", "ip", "saddr", wgServerCIDR, "oifname", extIface, "masquerade").Run() +} + +func setupForwardRules(wgIface string) { + if commandExists("iptables") { + for i := 0; i < 5; i++ { + exec.Command("iptables", "-D", "FORWARD", "-i", wgIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "ACCEPT").Run() + exec.Command("iptables", "-D", "FORWARD", "-o", wgIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "ACCEPT").Run() + } + exec.Command("iptables", "-A", "FORWARD", "-i", wgIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "ACCEPT").Run() + exec.Command("iptables", "-A", "FORWARD", "-o", wgIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "ACCEPT").Run() + return + } + if commandExists("nft") { + exec.Command("nft", "add", "table", "inet", "wdtt").Run() + exec.Command("nft", "add", "chain", "inet", "wdtt", "forward", "{ type filter hook forward priority 0; policy accept; }").Run() + exec.Command("nft", "add", "rule", "inet", "wdtt", "forward", "iifname", wgIface, "accept").Run() + exec.Command("nft", "add", "rule", "inet", "wdtt", "forward", "oifname", wgIface, "accept").Run() + } +} + +// ==================== WireGuard ==================== + +func startUserspaceWG(keys *wgKeys, wgPort int) (*device.Device, error) { + runCmdSilent("ip", "link", "del", wgIfaceName) + time.Sleep(100 * time.Millisecond) + + tunDev, err := tun.CreateTUN(wgIfaceName, wgMTU) + if err != nil { + return nil, fmt.Errorf("CreateTUN: %w", err) + } + + ifaceName, err := tunDev.Name() + if err != nil { + tunDev.Close() + return nil, fmt.Errorf("TUN name: %w", err) + } + + logger := device.NewLogger(device.LogLevelError, "[WG] ") + bind := conn.NewDefaultBind() + dev := device.NewDevice(tunDev, bind, logger) + + serverPrivHex, _ := b64ToHex(keys.serverPrivate) + + if err := dev.IpcSet(fmt.Sprintf( + "private_key=%s\nlisten_port=%d\n", + serverPrivHex, wgPort, + )); err != nil { + dev.Close() + return nil, fmt.Errorf("IpcSet: %w", err) + } + + for _, d := range db.Devices { + pubHex, _ := b64ToHex(d.PubKey) + if pubHex != "" { + dev.IpcSet(fmt.Sprintf("public_key=%s\nallowed_ip=%s/32\n", pubHex, d.IP)) + } + } + + if err := dev.Up(); err != nil { + dev.Close() + return nil, fmt.Errorf("device.Up: %w", err) + } + + if err := configureInterface(ifaceName); err != nil { + dev.Close() + return nil, err + } + + if err := setupFullConeNAT(ifaceName); err != nil { + dev.Close() + return nil, err + } + + go func() { + uapiFile, err := ipc.UAPIOpen(ifaceName) + if err != nil { + return + } + uapi, err := ipc.UAPIListen(ifaceName, uapiFile) + if err != nil { + return + } + defer uapi.Close() + for { + c, err := uapi.Accept() + if err != nil { + return + } + go dev.IpcHandle(c) + } + }() + + log.Printf("[WG] Запущен на порту %d", wgPort) + return dev, nil +} + +func configureInterface(ifaceName string) error { + for _, cmd := range [][]string{ + {"ip", "addr", "add", wgServerCIDR, "dev", ifaceName}, + {"ip", "link", "set", "mtu", fmt.Sprintf("%d", wgMTU), "dev", ifaceName}, + {"ip", "link", "set", ifaceName, "up"}, + } { + out, err := runCmd(cmd[0], cmd[1:]...) + if err != nil && !strings.Contains(out, "File exists") { + return fmt.Errorf("%s: %s", strings.Join(cmd, " "), out) + } + } + return nil +} + +func buildClientConfig(serverPublic, clientPrivate, clientIP, clientPort string) string { + return fmt.Sprintf(`[Interface] +PrivateKey = %s +Address = %s/32 +DNS = %s +MTU = %d + +[Peer] +PublicKey = %s +AllowedIPs = 0.0.0.0/0 +Endpoint = 127.0.0.1:%s +PersistentKeepalive = %d`, + clientPrivate, clientIP, dns, wgMTU, + serverPublic, clientPort, keepalive, + ) +} + +// ==================== Main ==================== + +func main() { + listen := flag.String("listen", "0.0.0.0:56000", "DTLS адрес") + wgPort := flag.Int("wg-port", defaultInternalWGPort, "WireGuard UDP порт") + configDir := flag.String("config-dir", "/etc/wdtt", "директория конфигурации") + mainPass := flag.String("password", "", "пароль владельца") + adminID := flag.String("admin", "", "Telegram Admin ID") + botToken := flag.String("bot-token", "", "Telegram Bot Token") + flag.Parse() + + log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds) + log.Println("══════════════════════════════════════════") + log.Println(" WDTT Server v2 (Multi-User)") + log.Println("══════════════════════════════════════════") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-sig + cancel() + time.Sleep(2 * time.Second) + os.Exit(0) + }() + + initDB(*configDir, *mainPass, *adminID, *botToken) + + keys, err := loadOrGenerateKeys(*configDir) + if err != nil { + log.Fatalf("[WG] Ключи: %v", err) + } + + enableBBR() + + wgDev, err := startUserspaceWG(keys, *wgPort) + if err != nil { + log.Fatalf("[WG] Запуск: %v", err) + } + if removed := cleanupExpiredPasswords(wgDev); removed > 0 { + log.Printf("[DB] Удалено истёкших паролей при старте: %d", removed) + } + syncPersistedPeersToWG(wgDev) + defer func() { + wgDev.Close() + runCmdSilent("ip", "link", "del", wgIfaceName) + }() + + go statsLoop(ctx, *configDir) + go expiredPasswordJanitor(ctx, wgDev) + go botLoop(*botToken, *adminID, wgDev) + + addr, _ := net.ResolveUDPAddr("udp", *listen) + cert, _ := selfsign.GenerateSelfSigned() + if serverWrapKeys.Count() == 0 { + log.Fatalf("[WRAP] нет активных паролей для WRAP") + } + + wrapListener, err := listenWrapped(addr, serverWrapKeys) + if err != nil { + log.Fatalf("[WRAP] %v", err) + } + + listener, err := dtls.NewListenerWithOptions(wrapListener, dtls.WithCertificates(cert), dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret), dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8))) + if err != nil { + log.Fatalf("[DTLS] %v", err) + } + context.AfterFunc(ctx, func() { listener.Close() }) + + wgEndpoint := fmt.Sprintf("127.0.0.1:%d", *wgPort) + + log.Printf(" DTLS: %s | WG: %s | NAT: %s", *listen, wgEndpoint, natType) + log.Printf(" WRAP: password HKDF + RTP AEAD | keys: %d", serverWrapKeys.Count()) + log.Println("[SERVER] Готов") + + var wg sync.WaitGroup + for { + dtlsConn, err := listener.Accept() + if err != nil { + select { + case <-ctx.Done(): + wg.Wait() + return + default: + } + continue + } + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer c.Close() + handleConn(ctx, c, wgEndpoint, wgDev, keys) + }(dtlsConn) + } +} + +// ==================== Обработка соединений ==================== + +func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) { + atomic.AddInt64(&totalConns, 1) + + var connDeviceID string + var connPassword string + var connIsMainPass bool + + dtlsConn, ok := clientConn.(*dtls.Conn) + if !ok { + return + } + + hctx, hcancel := context.WithTimeout(ctx, 30*time.Second) + if err := dtlsConn.HandshakeContext(hctx); err != nil { + hcancel() + return + } + hcancel() + + atomic.AddInt32(&activeConns, 1) + defer atomic.AddInt32(&activeConns, -1) + + buf := make([]byte, 1600) + clientConn.SetReadDeadline(time.Now().Add(30 * time.Second)) + n, err := clientConn.Read(buf) + if err != nil { + return + } + clientConn.SetReadDeadline(time.Time{}) + + firstPacket := buf[:n] + firstStr := string(firstPacket) + + if strings.HasPrefix(firstStr, "GETCONF:") { + parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(firstStr, "GETCONF:")), "|") + clientPort := "9000" + deviceID := "unknown" + password := "" + if len(parts) > 0 { + clientPort = parts[0] + } + if len(parts) > 1 { + deviceID = parts[1] + } + if len(parts) > 2 { + password = parts[2] + } + + dbMutex.Lock() + + // Проверяем пароль + isMainPass := password != "" && password == db.MainPassword + entry, isGenPass := db.Passwords[password] + valid := isMainPass || (isGenPass && !isPasswordExpired(entry)) + + // Для сгенерированных паролей — проверяем привязку к устройству + if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID { + // Пароль уже привязан к другому устройству + clientConn.Write([]byte("DENIED:device_mismatch")) + log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID) + dbMutex.Unlock() + } else if valid { + connDeviceID = deviceID + connPassword = password + connIsMainPass = isMainPass + + // Привязываем пароль к устройству при первом использовании + if isGenPass && entry.DeviceID == "" { + entry.DeviceID = deviceID + saveDB() + log.Printf("[WG] Пароль %s привязан к устройству %s", maskPassword(password), deviceID) + } + + dev, exists := db.Devices[deviceID] + if !exists { + dev = &ClientDevice{DeviceID: deviceID, IP: getNextIP()} + privB64, pubB64, keyErr := generateKeyPair() + if keyErr == nil && dev.IP != "" { + dev.PrivKey = privB64 + dev.PubKey = pubB64 + db.Devices[deviceID] = dev + saveDB() + log.Printf("[WG] Новое устройство %s (IP: %s)", deviceID, dev.IP) + } else { + dev = nil + } + } + if dev != nil { + upsertPeerInWG(wgDev, dev) + clientConn.Write([]byte(buildClientConfig(keys.serverPublic, dev.PrivKey, dev.IP, clientPort))) + } else { + clientConn.Write([]byte("NOCONF")) + } + dbMutex.Unlock() + } else { + if isGenPass && isPasswordExpired(entry) { + clientConn.Write([]byte("DENIED:expired")) + log.Printf("[WG] Отказ: пароль %s истёк, от %s", maskPassword(password), deviceID) + } else { + clientConn.Write([]byte("DENIED:wrong_password")) + log.Printf("[WG] Отказ (неверный пароль) от %s", deviceID) + } + dbMutex.Unlock() + } + + clientConn.SetReadDeadline(time.Now().Add(5 * time.Minute)) + n, err = clientConn.Read(buf) + if err != nil { + return + } + clientConn.SetReadDeadline(time.Time{}) + firstPacket = buf[:n] + firstStr = string(firstPacket) + } + + if firstStr == "READY" { + clientConn.Write([]byte("READY_OK")) + clientConn.SetReadDeadline(time.Now().Add(10 * time.Minute)) + n, err = clientConn.Read(buf) + if err != nil { + return + } + clientConn.SetReadDeadline(time.Time{}) + firstPacket = buf[:n] + } + + // WG прокси + wgConn, err := net.Dial("udp", wgEndpoint) + if err != nil { + return + } + defer wgConn.Close() + + if uc, ok := wgConn.(*net.UDPConn); ok { + uc.SetReadBuffer(2 * 1024 * 1024) + uc.SetWriteBuffer(2 * 1024 * 1024) + } + + if _, err := wgConn.Write(firstPacket); err != nil { + return + } + atomic.AddInt64(&totalBytesFromClient, int64(len(firstPacket))) + + // Трекинг онлайн-статуса + if connDeviceID != "" { + activeDevicesMu.Lock() + activeDevices[connDeviceID]++ + activeDevicesMu.Unlock() + defer func() { + activeDevicesMu.Lock() + activeDevices[connDeviceID]-- + if activeDevices[connDeviceID] <= 0 { + delete(activeDevices, connDeviceID) + } + activeDevicesMu.Unlock() + }() + } + + pctx, pcancel := context.WithCancel(ctx) + defer pcancel() + + context.AfterFunc(pctx, func() { + clientConn.SetDeadline(time.Now()) + wgConn.SetDeadline(time.Now()) + }) + + var proxyWg sync.WaitGroup + proxyWg.Add(2) + + // Клиент → WG + go func() { + defer proxyWg.Done() + defer pcancel() + b := getBuf() + defer putBuf(b) + for { + select { + case <-pctx.Done(): + return + default: + } + clientConn.SetReadDeadline(time.Now().Add(30 * time.Minute)) + nn, err := clientConn.Read(*b) + if err != nil { + return + } + // Skip DTLS keepalive packets (1-byte 0xFF ping from client) + if nn == 1 && (*b)[0] == 0xFF { + continue + } + atomic.AddInt64(&totalBytesFromClient, int64(nn)) + // Per-password upload tracking + if connIsMainPass { + atomic.AddInt64(&mainPassUp, int64(nn)) + } else if connPassword != "" { + dbMutex.Lock() + e, ok := db.Passwords[connPassword] + if !ok || e == nil || isPasswordExpired(e) { + dbMutex.Unlock() + return + } + e.UpBytes += int64(nn) + dbMutex.Unlock() + } + if _, err := wgConn.Write((*b)[:nn]); err != nil { + return + } + } + }() + + // WG → Клиент + go func() { + defer proxyWg.Done() + defer pcancel() + b := getBuf() + defer putBuf(b) + for { + select { + case <-pctx.Done(): + return + default: + } + wgConn.SetReadDeadline(time.Now().Add(30 * time.Minute)) + nn, err := wgConn.Read(*b) + if err != nil { + if isNetTimeout(err) { + if pctx.Err() != nil { + return + } + continue + } + return + } + atomic.AddInt64(&totalBytesToClient, int64(nn)) + // Per-password download tracking + if connIsMainPass { + atomic.AddInt64(&mainPassDown, int64(nn)) + } else if connPassword != "" { + dbMutex.Lock() + e, ok := db.Passwords[connPassword] + if !ok || e == nil || isPasswordExpired(e) { + dbMutex.Unlock() + return + } + e.DownBytes += int64(nn) + dbMutex.Unlock() + } + if _, err := clientConn.Write((*b)[:nn]); err != nil { + return + } + } + }() + + proxyWg.Wait() +} + +const ( + wrapNonceLen = 12 + wrapKeyLen = 32 +) + +// ==================== RTP Обфускация ==================== + +type ObfsConfig struct { + SSRC uint32 + PayloadType uint8 + PaddingMax int +} + +type ObfsState struct { + mu sync.Mutex + seq uint16 + ts uint32 +} + +func NewObfsConfig() *ObfsConfig { + var buf [4]byte + rand.Read(buf[:]) + return &ObfsConfig{ + SSRC: binary.BigEndian.Uint32(buf[:]), + PayloadType: 111, + PaddingMax: 24, + } +} + +func NewObfsState() *ObfsState { + var buf [6]byte + rand.Read(buf[:]) + return &ObfsState{ + seq: binary.BigEndian.Uint16(buf[0:2]), + ts: binary.BigEndian.Uint32(buf[2:6]), + } +} + +func obfsBuildNonce(ssrc uint32, seq uint16, ts uint32) []byte { + n := make([]byte, 12) + binary.BigEndian.PutUint32(n[0:4], ssrc) + binary.BigEndian.PutUint16(n[4:6], seq) + binary.BigEndian.PutUint32(n[8:12], ts) + return n +} + +func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]byte, error) { + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + if len(payload) == 0 { + return nil, errors.New("obfs: empty payload") + } + state.mu.Lock() + seq := state.seq + ts := state.ts + state.seq++ + state.ts += 960 + state.mu.Unlock() + + nonce := obfsBuildNonce(cfg.SSRC, seq, ts) + padRand := 0 + if cfg.PaddingMax > 0 { + var rndBuf [1]byte + rand.Read(rndBuf[:]) + padRand = int(rndBuf[0]) % cfg.PaddingMax + } + padTotal := padRand + 1 + outLen := 12 + len(payload) + chacha20poly1305.Overhead + padTotal + out := make([]byte, outLen) + + out[0] = 0x80 | 0x20 + out[1] = cfg.PayloadType & 0x7F + binary.BigEndian.PutUint16(out[2:4], seq) + binary.BigEndian.PutUint32(out[4:8], ts) + binary.BigEndian.PutUint32(out[8:12], cfg.SSRC) + + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, fmt.Errorf("obfs: cipher init: %w", err) + } + sealed := aead.Seal(out[12:12], nonce, payload, out[:12]) + padStart := 12 + len(sealed) + if padRand > 0 { + rand.Read(out[padStart : padStart+padRand]) + } + out[outLen-1] = byte(padTotal) + return out, nil +} + +func obfsUnwrapPacket(key, wire, dst []byte) (int, error) { + if len(key) != wrapKeyLen { + return 0, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key)) + } + if len(wire) < 13 { + return 0, errors.New("obfs: packet too short") + } + if (wire[0] >> 6) != 2 { + return 0, errors.New("obfs: not RTP v2") + } + seq := binary.BigEndian.Uint16(wire[2:4]) + ts := binary.BigEndian.Uint32(wire[4:8]) + ssrc := binary.BigEndian.Uint32(wire[8:12]) + + payloadEnd := len(wire) + if wire[0]&0x20 != 0 { + padLen := int(wire[len(wire)-1]) + if padLen == 0 || padLen > payloadEnd-12 { + return 0, fmt.Errorf("obfs: invalid padding length %d", padLen) + } + payloadEnd -= padLen + } + ciphertextLen := payloadEnd - 12 + if ciphertextLen <= chacha20poly1305.Overhead { + return 0, errors.New("obfs: no payload") + } + if ciphertextLen-chacha20poly1305.Overhead > len(dst) { + return 0, errors.New("obfs: dst buffer too small") + } + nonce := obfsBuildNonce(ssrc, seq, ts) + aead, err := chacha20poly1305.New(key) + if err != nil { + return 0, fmt.Errorf("obfs: cipher init: %w", err) + } + plain, err := aead.Open(dst[:0], nonce, wire[12:payloadEnd], wire[:12]) + if err != nil { + return 0, fmt.Errorf("obfs: auth: %w", err) + } + return len(plain), nil +} + +func obfsIsRTPPacket(wire []byte) bool { + if len(wire) < 13 { + return false + } + if (wire[0] >> 6) != 2 { + return false + } + pt := wire[1] & 0x7F + return pt == 111 +} + +func listenWrapped(addr *net.UDPAddr, keys *wrapKeyStore) (dtlsnet.PacketListener, error) { + if keys == nil || keys.Count() == 0 { + return nil, errors.New("wrap: no active keys") + } + inner, err := pionudp.Listen("udp", addr) + if err != nil { + return nil, fmt.Errorf("wrap: udp listen: %w", err) + } + return &wrapPacketListener{ + inner: dtlsnet.PacketListenerFromListener(inner), + keys: keys, + }, nil +} + +type wrapPacketListener struct { + inner dtlsnet.PacketListener + keys *wrapKeyStore +} + +func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) { + pc, addr, err := l.inner.Accept() + if err != nil { + return pc, addr, err + } + return &wrapPacketConn{inner: pc, keys: l.keys}, addr, nil +} + +func (l *wrapPacketListener) Close() error { return l.inner.Close() } +func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() } + +type wrapPacketConn struct { + inner net.PacketConn + keys *wrapKeyStore + key []byte + selected int32 + authLog int32 + obfsCfg *ObfsConfig + obfsWrite *ObfsState +} + +func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { + // Extra space for RTP header (12) + AEAD tag (16) + padding. + buf := make([]byte, len(p)+80) + n, addr, err := c.inner.ReadFrom(buf) + if err != nil { + return 0, addr, err + } + raw := buf[:n] + + if atomic.LoadInt32(&c.selected) == 0 { + key, m, uErr := c.keys.Unwrap(raw, p) + if uErr != nil { + if atomic.CompareAndSwapInt32(&c.authLog, 0, 1) { + log.Printf("[WRAP] Отказ: RTP AEAD auth failed from %s (keys=%d)", addr.String(), c.keys.Count()) + } + return 0, addr, uErr + } + c.key = key + c.obfsCfg = NewObfsConfig() + c.obfsWrite = NewObfsState() + atomic.StoreInt32(&c.selected, 1) + if atomic.CompareAndSwapInt32(&c.authLog, 0, 1) { + log.Printf("[WRAP] OK: ключ выбран для %s (keys=%d)", addr.String(), c.keys.Count()) + } + return m, addr, nil + } + + m, uErr := obfsUnwrapPacket(c.key, raw, p) + if uErr != nil { + return 0, addr, fmt.Errorf("obfs unwrap: %w", uErr) + } + return m, addr, nil +} + +func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + if atomic.LoadInt32(&c.selected) == 0 || len(c.key) != wrapKeyLen { + return 0, errors.New("wrap: key not selected") + } + if c.obfsCfg == nil || c.obfsWrite == nil { + c.obfsCfg = NewObfsConfig() + c.obfsWrite = NewObfsState() + } + wrapped, wErr := obfsWrapPacket(c.key, p, c.obfsCfg, c.obfsWrite) + if wErr != nil { + return 0, fmt.Errorf("obfs wrap: %w", wErr) + } + if _, err := c.inner.WriteTo(wrapped, addr); err != nil { + return 0, err + } + return len(p), nil +} + +func (c *wrapPacketConn) Close() error { return c.inner.Close() } +func (c *wrapPacketConn) LocalAddr() net.Addr { return c.inner.LocalAddr() } +func (c *wrapPacketConn) SetDeadline(t time.Time) error { return c.inner.SetDeadline(t) } +func (c *wrapPacketConn) SetReadDeadline(t time.Time) error { return c.inner.SetReadDeadline(t) } +func (c *wrapPacketConn) SetWriteDeadline(t time.Time) error { return c.inner.SetWriteDeadline(t) } diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..a45e29e --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "WDTT" +include(":app")